├── .github ├── dependabot.yml └── workflows │ ├── code_quality.yaml │ ├── guarddog.yml │ ├── pypi-release.yml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE-3rdparty.csv ├── Makefile ├── NOTICE ├── README.md ├── SECURITY.md ├── examples ├── logger.py └── verifier.py ├── images ├── datadog_log.png ├── demo.gif └── logo.png ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── scfw ├── __init__.py ├── __main__.py ├── cli.py ├── configure │ ├── __init__.py │ ├── constants.py │ ├── dd_agent.py │ ├── env.py │ └── interactive.py ├── ecosystem.py ├── firewall.py ├── logger.py ├── loggers │ ├── __init__.py │ ├── dd_agent_logger.py │ ├── dd_api_logger.py │ └── dd_logger.py ├── main.py ├── package.py ├── package_manager.py ├── package_managers │ ├── __init__.py │ ├── npm.py │ ├── pip.py │ └── poetry.py ├── parser.py ├── report.py ├── verifier.py └── verifiers │ ├── __init__.py │ ├── dd_verifier.py │ └── osv_verifier │ ├── __init__.py │ └── osv_advisory.py └── tests ├── package_managers ├── __init__.py ├── test_npm.py ├── test_npm_class.py ├── test_pip.py ├── test_pip_class.py ├── test_poetry.py ├── test_poetry_class.py ├── top_npm_packages.txt ├── top_pypi_packages.txt └── utils.py ├── test_cli.py └── verifiers ├── test_dd_verifier.py └── test_osv_verifier.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/code_quality.yaml: -------------------------------------------------------------------------------- 1 | name: code-quality 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | 16 | typecheck: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.10" 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r requirements.txt 28 | pip install -r requirements-dev.txt 29 | - name: Type check 30 | run: make typecheck 31 | 32 | lint: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Python 3.10 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: "3.10" 40 | - name: Install dependencies 41 | run: | 42 | python -m pip install --upgrade pip 43 | pip install -r requirements.txt 44 | pip install -r requirements-dev.txt 45 | - name: Lint 46 | run: make lint 47 | -------------------------------------------------------------------------------- /.github/workflows/guarddog.yml: -------------------------------------------------------------------------------- 1 | name: GuardDog 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - v* 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | guarddog: 14 | permissions: 15 | contents: read # for actions/checkout to fetch code 16 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 17 | name: Scan dependencies 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.10" 29 | 30 | - name: Install GuardDog 31 | run: pip install guarddog 32 | 33 | - run: guarddog pypi verify requirements.txt --output-format sarif --exclude-rules repository_integrity_mismatch > guarddog.sarif 34 | 35 | - name: Upload SARIF file for GitHub code scanning 36 | uses: github/codeql-action/upload-sarif@v3 37 | with: 38 | category: guarddog-builtin 39 | sarif_file: guarddog.sarif 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/pypi-release.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build-and-publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.10" 24 | 25 | - name: Install build dependencies 26 | run: python -m pip install --upgrade build 27 | 28 | - name: Build 29 | run: python -m build 30 | 31 | - name: Publish distribution to PyPI 32 | uses: pypa/gh-action-pypi-publish@release/v1 33 | with: 34 | password: ${{ secrets.PYPI_PUBLISH_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | 16 | cli: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.10" 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r requirements.txt 28 | pip install -r requirements-dev.txt 29 | - name: Install supply-chain firewall 30 | run: pip install . 31 | - name: Test firewall CLI 32 | run: make test-cli 33 | 34 | python-executable: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Set up Python 3.10 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: "3.10" 42 | - name: Install dependencies 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install -r requirements.txt 46 | pip install -r requirements-dev.txt 47 | - name: Install supply-chain firewall 48 | run: pip install . 49 | - name: Test Python executable in the global environment 50 | run: make test-python-executable 51 | - name: Test Python executable in a virtual environment 52 | run: | 53 | python -m venv venv 54 | source venv/bin/activate 55 | make test-python-executable 56 | 57 | pip-integration: 58 | runs-on: ubuntu-latest 59 | strategy: 60 | fail-fast: false 61 | matrix: 62 | pip-version: ["22.2", "22.3", "23.0", "23.1", "23.2", "23.3", "24.0", "24.1", "24.2", "24.3", "25.0", "25.1"] 63 | steps: 64 | - uses: actions/checkout@v4 65 | - name: Set up Python 3.10 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: "3.10" 69 | - name: Install dependencies 70 | run: | 71 | python -m pip install pip==${{ matrix.pip-version }} 72 | pip install -r requirements.txt 73 | pip install -r requirements-dev.txt 74 | - name: Install supply-chain firewall 75 | run: pip install . 76 | - name: Test firewall pip integration 77 | run: | 78 | make test-pip 79 | make test-pip-class 80 | 81 | poetry-integration: 82 | runs-on: ubuntu-latest 83 | strategy: 84 | fail-fast: false 85 | matrix: 86 | poetry-version: ["1.7.0", "1.8.0", "2.0.0", "2.1.0"] 87 | steps: 88 | - uses: actions/checkout@v4 89 | - name: Set up Python 3.10 90 | uses: actions/setup-python@v5 91 | with: 92 | python-version: "3.10" 93 | - name: Install Poetry 94 | run: | 95 | pip install poetry==${{ matrix.poetry-version }} 96 | if [ "${{ matrix.poetry-version }}" = "1.7.0" ] || [ "${{ matrix.poetry-version }}" = "1.8.0" ]; then 97 | # Known issue with virtualenv 20.31.x breaking these Poetry versions 98 | pip install virtualenv==20.30.0 99 | fi 100 | - name: Install dependencies 101 | run: | 102 | pip install -r requirements.txt 103 | pip install -r requirements-dev.txt 104 | - name: Install supply-chain firewall 105 | run: pip install . 106 | - name: Test firewall Poetry integration 107 | run: | 108 | make test-poetry 109 | make test-poetry-class 110 | 111 | npm-integration: 112 | name: npm-integration (${{ matrix.npm-version }}) 113 | runs-on: ubuntu-latest 114 | strategy: 115 | matrix: 116 | include: 117 | - node-version: "15.0.0" 118 | npm-version: "7.0" 119 | - node-version: "15.0.0" 120 | npm-version: "7.1" 121 | - node-version: "15.0.0" 122 | npm-version: "7.2" 123 | - node-version: "15.0.0" 124 | npm-version: "7.3" 125 | - node-version: "15.0.0" 126 | npm-version: "7.4" 127 | - node-version: "15.0.0" 128 | npm-version: "7.5" 129 | - node-version: "15.0.0" 130 | npm-version: "7.6" 131 | - node-version: "15.0.0" 132 | npm-version: "7.7" 133 | - node-version: "15.0.0" 134 | npm-version: "7.8" 135 | - node-version: "15.0.0" 136 | npm-version: "7.9" 137 | - node-version: "15.0.0" 138 | npm-version: "7.10" 139 | - node-version: "15.0.0" 140 | npm-version: "7.11" 141 | - node-version: "15.0.0" 142 | npm-version: "7.12" 143 | - node-version: "15.0.0" 144 | npm-version: "7.13" 145 | - node-version: "15.0.0" 146 | npm-version: "7.14" 147 | - node-version: "15.0.0" 148 | npm-version: "7.15" 149 | - node-version: "15.0.0" 150 | npm-version: "7.16" 151 | - node-version: "15.0.0" 152 | npm-version: "7.17" 153 | - node-version: "15.0.0" 154 | npm-version: "7.18" 155 | - node-version: "15.0.0" 156 | npm-version: "7.19" 157 | - node-version: "15.0.0" 158 | npm-version: "7.20" 159 | - node-version: "15.0.0" 160 | npm-version: "7.21" 161 | - node-version: "15.0.0" 162 | npm-version: "7.22" 163 | - node-version: "15.0.0" 164 | npm-version: "7.23" 165 | - node-version: "15.0.0" 166 | npm-version: "7.24" 167 | - node-version: "16.0.0" 168 | npm-version: "8.0" 169 | - node-version: "16.0.0" 170 | npm-version: "8.1" 171 | - node-version: "16.0.0" 172 | npm-version: "8.2" 173 | - node-version: "16.0.0" 174 | npm-version: "8.3" 175 | - node-version: "16.0.0" 176 | npm-version: "8.4" 177 | - node-version: "16.0.0" 178 | npm-version: "8.5" 179 | - node-version: "16.0.0" 180 | npm-version: "8.6" 181 | - node-version: "16.0.0" 182 | npm-version: "8.7" 183 | - node-version: "16.0.0" 184 | npm-version: "8.8" 185 | - node-version: "16.0.0" 186 | npm-version: "8.9" 187 | - node-version: "16.0.0" 188 | npm-version: "8.10" 189 | - node-version: "16.0.0" 190 | npm-version: "8.11" 191 | - node-version: "16.0.0" 192 | npm-version: "8.12" 193 | - node-version: "16.0.0" 194 | npm-version: "8.13" 195 | - node-version: "16.0.0" 196 | npm-version: "8.14" 197 | - node-version: "16.0.0" 198 | npm-version: "8.15" 199 | - node-version: "16.0.0" 200 | npm-version: "8.16" 201 | - node-version: "16.0.0" 202 | npm-version: "8.17" 203 | - node-version: "16.0.0" 204 | npm-version: "8.18" 205 | - node-version: "16.0.0" 206 | npm-version: "8.19" 207 | - node-version: "19.0.0" 208 | npm-version: "9.0" 209 | - node-version: "19.0.0" 210 | npm-version: "9.1" 211 | - node-version: "19.0.0" 212 | npm-version: "9.2" 213 | - node-version: "19.0.0" 214 | npm-version: "9.3" 215 | - node-version: "19.0.0" 216 | npm-version: "9.4" 217 | - node-version: "19.0.0" 218 | npm-version: "9.5" 219 | - node-version: "19.0.0" 220 | npm-version: "9.6" 221 | - node-version: "19.0.0" 222 | npm-version: "9.7" 223 | - node-version: "19.0.0" 224 | npm-version: "9.8" 225 | - node-version: "19.0.0" 226 | npm-version: "9.9" 227 | - node-version: "22.0.0" 228 | npm-version: "10.0" 229 | - node-version: "22.0.0" 230 | npm-version: "10.1" 231 | - node-version: "22.0.0" 232 | npm-version: "10.2" 233 | - node-version: "22.0.0" 234 | npm-version: "10.3" 235 | - node-version: "22.0.0" 236 | npm-version: "10.4" 237 | - node-version: "22.0.0" 238 | npm-version: "10.5" 239 | - node-version: "22.0.0" 240 | npm-version: "10.6" 241 | - node-version: "22.0.0" 242 | npm-version: "10.7" 243 | - node-version: "22.0.0" 244 | npm-version: "10.8" 245 | - node-version: "22.0.0" 246 | npm-version: "10.9" 247 | - node-version: "22.9.0" 248 | npm-version: "11.0" 249 | - node-version: "22.9.0" 250 | npm-version: "11.1" 251 | - node-version: "22.9.0" 252 | npm-version: "11.2" 253 | - node-version: "22.9.0" 254 | npm-version: "11.3" 255 | steps: 256 | - uses: actions/checkout@v4 257 | - name: Set up Python 3.10 258 | uses: actions/setup-python@v5 259 | with: 260 | python-version: "3.10" 261 | - name: Install dependencies 262 | run: | 263 | python -m pip install --upgrade pip 264 | pip install -r requirements.txt 265 | pip install -r requirements-dev.txt 266 | - name: Install supply-chain firewall 267 | run: pip install . 268 | - name: Install Node.js v${{ matrix.node-version }} 269 | uses: actions/setup-node@v4 270 | with: 271 | node-version: ${{ matrix.node-version }} 272 | - name: Test firewall npm v${{ matrix.npm-version }} integration 273 | run: | 274 | if [ "${{ matrix.node-version }}" = "15.0.0" ]; then 275 | # Known issue on npm v7.x: need to first install this manually 276 | npm install -g agentkeepalive 277 | fi 278 | npm install -g npm@${{ matrix.npm-version }} 279 | make test-npm 280 | make test-npm-class 281 | 282 | verifiers: 283 | runs-on: ubuntu-latest 284 | steps: 285 | - uses: actions/checkout@v4 286 | - name: Set up Python 3.10 287 | uses: actions/setup-python@v5 288 | with: 289 | python-version: "3.10" 290 | - name: Install dependencies 291 | run: | 292 | python -m pip install --upgrade pip 293 | pip install -r requirements.txt 294 | pip install -r requirements-dev.txt 295 | - name: Install supply-chain firewall 296 | run: pip install . 297 | - name: Test firewall installation target verifiers 298 | run: make test-verifiers 299 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: typecheck 6 | name: Typecheck the firewall code 7 | language: system 8 | entry: make typecheck 9 | - id: lint 10 | name: Lint the firewall code 11 | language: system 12 | entry: make lint 13 | - id: test-cli 14 | name: Test the firewall CLI 15 | language: system 16 | entry: make test-cli 17 | - id: test-pip 18 | name: Test firewall pip integration 19 | language: system 20 | entry: make test-pip 21 | - id: test-npm 22 | name: Test firewall npm integration 23 | language: system 24 | entry: make test-npm 25 | - id: test-verifiers 26 | name: Test firewall installation target verifiers 27 | language: system 28 | entry: make test-verifiers 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Supply-Chain Firewall 2 | 3 | ## :hammer_and_wrench: Setting up for development 4 | 5 | To set up for development and testing, create a fresh `virtualenv`, 6 | activate it and run the following sequence of commands: 7 | 8 | ```bash 9 | git clone https://github.com/DataDog/supply-chain-firewall.git 10 | cd supply-chain-firewall 11 | make install-dev 12 | ``` 13 | 14 | This will install `scfw` as well as its development dependencies into 15 | your development environment. 16 | 17 | ### Documentation 18 | 19 | API documentation may be built via `pdoc` by running `make docs` in 20 | your development environment. This will automatically open the 21 | documentation in your system's default browser. 22 | 23 | ### Testing 24 | 25 | Execute the test suite by running `make test` in your development 26 | environment. To additionally view code coverage, run `make coverage`. 27 | 28 | ### Code quality 29 | 30 | The test suite contains code quality checks in the form of 31 | type-checking and linting. Run `make typecheck` or `make lint`, 32 | respectively, in your development environment. 33 | 34 | You can run `make checks` to execute all tests and code quality 35 | checks. Up to matrix testing across `pip` and `npm` versions, this is 36 | the same set of checks that run in the repository's CI for pull 37 | requests. The repository also contains a pre-commit hook to run these 38 | checks on each commit, if so desired. 39 | 40 | ## :bug: Creating issues 41 | 42 | Before opening a new issue, first check to see whether the same or a 43 | similar issue already exists. If not, please feel free to open a new 44 | issue while selecting an appropriate label (bug, enhancement, etc.) to 45 | assist with issue prioritization. 46 | 47 | ## :white_check_mark: Opening pull requests 48 | 49 | To work on an issue, create a new branch following the naming scheme 50 | `/`. When you have finished making 51 | your changes, create a pull request with a succinct description of 1) 52 | the issue your pull request addresses, 2) the changes you have made 53 | and 3) any special information a reviewer should be aware of. 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-3rdparty.csv: -------------------------------------------------------------------------------- 1 | Component,Origin,License,Copyright 2 | cvss,PyPI,LGPL-3.0,Stanislav Kontar 3 | datadog-api-client,PyPI,Apache-2.0,Datadog Inc. 4 | inquirer,PyPI,MIT,Miguel Angel Garcia 5 | packaging,PyPI,Apache-2.0,Donald Stufft 6 | python-dotenv,PyPI,BSD-3-Clause,Saurabh Kumar 7 | requests,PyPI,Apache-2.0,Kenneth Reitz 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | pip install . 3 | 4 | install-dev: 5 | pip install -r requirements-dev.txt 6 | pip install . 7 | 8 | checks: typecheck lint test 9 | 10 | coverage: test coverage-report 11 | 12 | test: test-cli test-python-executable test-pip test-pip-class test-poetry test-poetry-class test-npm test-npm-class test-verifiers 13 | 14 | typecheck: 15 | mypy --install-types --non-interactive scfw 16 | 17 | lint: 18 | flake8 scfw --count --show-source --statistics --max-line-length=120 19 | 20 | test-cli: 21 | COVERAGE_FILE=.coverage.cli coverage run -m pytest tests/test_cli.py 22 | 23 | test-python-executable: 24 | COVERAGE_FILE=.coverage.python.executable coverage run -m pytest tests/package_managers/test_pip_class.py -k test_executable 25 | 26 | test-pip: 27 | COVERAGE_FILE=.coverage.pip coverage run -m pytest tests/package_managers/test_pip.py 28 | 29 | test-pip-class: 30 | COVERAGE_FILE=.coverage.pip.class coverage run -m pytest tests/package_managers/test_pip_class.py -k 'not test_executable' 31 | 32 | test-poetry: 33 | COVERAGE_FILE=.coverage.poetry coverage run -m pytest tests/package_managers/test_poetry.py 34 | 35 | test-poetry-class: 36 | COVERAGE_FILE=.coverage.poetry.class coverage run -m pytest tests/package_managers/test_poetry_class.py 37 | 38 | test-npm: 39 | COVERAGE_FILE=.coverage.npm coverage run -m pytest tests/package_managers/test_npm.py 40 | 41 | test-npm-class: 42 | COVERAGE_FILE=.coverage.npm.class coverage run -m pytest tests/package_managers/test_npm_class.py 43 | 44 | test-verifiers: 45 | COVERAGE_FILE=.coverage.verifiers coverage run -m pytest tests/verifiers 46 | 47 | coverage-report: 48 | coverage combine .coverage.cli \ 49 | .coverage.python.executable .coverage.pip .coverage.pip.class \ 50 | .coverage.poetry .coverage.poetry.class \ 51 | .coverage.npm .coverage.npm.class \ 52 | .coverage.verifiers 53 | coverage report 54 | 55 | docs: 56 | pdoc --docformat google ./scfw > /dev/null & 57 | 58 | clean: 59 | rm -rf .mypy_cache .pytest_cache .coverage* build scfw.egg-info 60 | find . -name '__pycache__' -print0 | xargs -0 rm -rf 61 | 62 | .PHONY: checks clean coverage install install-dev test 63 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Datadog supply-chain-firewall 2 | Copyright 2024-Present Datadog, Inc. 3 | 4 | This product includes software developed at Datadog (https://www.datadoghq.com/). 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supply-Chain Firewall 2 | 3 | ![Test](https://github.com/DataDog/supply-chain-firewall/actions/workflows/test.yaml/badge.svg) 4 | ![Code quality](https://github.com/DataDog/supply-chain-firewall/actions/workflows/code_quality.yaml/badge.svg) 5 | 6 |

7 | Supply-Chain Firewall 8 |

9 | 10 | Supply-Chain Firewall is a command-line tool for preventing the installation of malicious PyPI and npm packages. It is intended primarily for use by engineers to protect their development workstations from compromise in a supply-chain attack. 11 | 12 | ![scfw demo usage](https://github.com/DataDog/supply-chain-firewall/blob/main/images/demo.gif?raw=true) 13 | 14 | Given a command for a supported package manager, Supply-Chain Firewall collects all package targets that would be installed by the command and checks them against reputable sources of data on open source malware and vulnerabilities. The command is automatically blocked from running when any data source finds that any target is malicious. In cases where a data source reports other findings for a target, they are presented to the user along with a prompt confirming intent to proceed with the installation. 15 | 16 | Default data sources include: 17 | 18 | - Datadog Security Research's public [malicious packages dataset](https://github.com/DataDog/malicious-software-packages-dataset) 19 | - [OSV.dev](https://osv.dev) advisories 20 | 21 | Users may also implement verifiers for alternative data sources. A template for implementating custom verifiers may be found in `examples/verifier.py`. Details may also be found in the API documentation. 22 | 23 | The principal goal of Supply-Chain Firewall is to block 100% of installations of known-malicious packages within the purview of its data sources. 24 | 25 | ## Getting started 26 | 27 | ### Installation 28 | 29 | The recommended way to install Supply-Chain Firewall is via [`pipx`](https://pipx.pypa.io/): 30 | 31 | ```bash 32 | $ pipx install scfw 33 | ``` 34 | 35 | This will install the `scfw` command-line program into an isolated Python environment on your system and make it available in any other Python environment, including ephemeral ones created with `venv` or `virtualenv`. `pipx` may be installed via Homebrew on macOS or via the system package manager on major Linux distributions. Be sure to run `pipx ensurepath` after installation to properly configure your `PATH`. 36 | 37 | Supply-Chain Firewall can also be installed via `pip install scfw` directly into the active Python environment. 38 | 39 | To check whether the installation succeeded, run the following command and verify that you see output similar to the following. 40 | 41 | ```bash 42 | $ scfw --version 43 | 2.0.0 44 | ``` 45 | 46 | ### Post-installation steps 47 | 48 | To get the most out of Supply-Chain Firewall, it is recommended to run the `scfw configure` command after installation. This script will walk you through configuring your environment so that all commands for supported package managers are passively run through `scfw` as well as enabling Datadog logging, described in more detail below. 49 | 50 | ```bash 51 | $ scfw configure 52 | ... 53 | ``` 54 | 55 | ### Compatibility and limitations 56 | 57 | | Package manager | Compatible versions | Inspected subcommands | 58 | | :---------------: | :-------------------: | :--------------------------------: | 59 | | npm | >= 7.0 | `install` (including aliases) | 60 | | pip | >= 22.2 | `install` | 61 | | poetry | >= 1.7 | `add`, `install`, `sync`, `update` | 62 | 63 | In keeping with its goal of blocking 100% of known-malicious package installations, `scfw` will refuse to run with an incompatible version of a supported package manager. Please upgrade to or verify that you are running a compatible version before using this tool. 64 | 65 | Supply-Chain Firewall may only know how to inspect some of the "installish" subcommands for its supported package managers. These are shown in the above table. Any other subcommands are always allowed to run. 66 | 67 | Currently, Supply-Chain Firewall is only fully supported on macOS systems, though it should run as intended on common Linux distributions. It is currently not supported on Windows. 68 | 69 | ### Uninstalling Supply-Chain Firewall 70 | 71 | Supply-Chain Firewall may be uninstalled via `pip(x) uninstall scfw`. Before doing so, be sure to run the command `scfw configure --remove` to remove any Supply-Chain Firewall-managed configuration you may have previously added to your environment. 72 | 73 | ```bash 74 | $ scfw configure --remove 75 | ... 76 | ``` 77 | 78 | ## Usage 79 | 80 | To use Supply-Chain Firewall, simply prepend `scfw run` to the command you want to run. 81 | 82 | ``` 83 | $ scfw run npm install react 84 | $ scfw run pip install -r requirements.txt 85 | $ scfw run poetry add git+https://github.com/DataDog/guarddog 86 | ``` 87 | 88 | For `pip install` commands, packages will be installed in the same environment (virtual or global) in which the command was run. 89 | 90 | ## Datadog Log Management integration 91 | 92 | Supply-Chain Firewall can optionally send logs of blocked and successful installations to Datadog. 93 | 94 | ![scfw datadog log](https://github.com/DataDog/supply-chain-firewall/blob/main/images/datadog_log.png?raw=true) 95 | 96 | Users can configure their environments so that Supply-Chain Firewall forwards logs either via the Datadog HTTP API (requires an API key) or to a local Datadog Agent process. Configuration consists of setting necessary environment variables and, for Agent log forwarding, configuring the Datadog Agent to accept logs from Supply-Chain Firewall. 97 | 98 | To opt in, use the `scfw configure` command to interactively or non-interactively configure your environment for Datadog logging. 99 | 100 | Supply-Chain Firewall can integrate with user-supplied loggers. A template for implementating a custom logger may be found in `examples/logger.py`. Refer to the API documentation for details. 101 | 102 | ## Development 103 | 104 | We welcome community contributions to Supply-Chain Firewall. Refer to the [CONTRIBUTING](https://github.com/DataDog/supply-chain-firewall/blob/main/CONTRIBUTING.md) guide for instructions on building the API documentation and setting up for development. 105 | 106 | ## Maintainers 107 | 108 | - [Ian Kretz](https://github.com/ikretz) 109 | - [Sebastian Obregoso](https://www.linkedin.com/in/sebastianobregoso/) 110 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | ## Supported versions 4 | 5 | Latest released major version. 6 | 7 | ## Reporting a vulnerability 8 | 9 | To report a vulnerability, please use GitHub's built-in "Report a 10 | vulnerability" feature: 11 | https://github.com/DataDog/supply-chain-firewall/security/advisories/new. 12 | 13 | Your report will be private and only accessible to maintainers. You 14 | can expect to hear back within 1 week, on a best-effort basis. 15 | -------------------------------------------------------------------------------- /examples/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Users of the supply chain firewall are able to use their own custom 3 | loggers according to their own logging needs. This module contains a 4 | template for writing such a custom logger. 5 | 6 | The firewall discovers loggers at runtime via the following simple protocol. 7 | The module implementing the custom logger must contain a function with the 8 | following name and signature: 9 | 10 | ``` 11 | def load_logger() -> FirewallLogger 12 | ``` 13 | 14 | This `load_logger` function should return an instance of the custom logger 15 | for the firewall's use. The module may then be placed in the `scfw/loggers` 16 | directory for runtime import, no further modification required. Make sure 17 | to reinstall the package after doing so. 18 | """ 19 | 20 | from scfw.ecosystem import ECOSYSTEM 21 | from scfw.logger import FirewallAction, FirewallLogger 22 | from scfw.target import InstallTarget 23 | 24 | 25 | class CustomFirewallLogger(FirewallLogger): 26 | def log( 27 | self, 28 | action: FirewallAction, 29 | ecosystem: ECOSYSTEM, 30 | command: list[str], 31 | targets: list[InstallTarget] 32 | ): 33 | return 34 | 35 | 36 | def load_logger() -> FirewallLogger: 37 | return CustomFirewallLogger() 38 | -------------------------------------------------------------------------------- /examples/verifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | Users of the supply chain firewall may provide custom installation target 3 | verifiers representing alternative sources of truth for the firewall to use. 4 | This module contains a template for writing such a custom verifier. 5 | 6 | The firewall discovers verifiers at runtime via the following simple protocol. 7 | The module implementing the custom verifier must contain a function with the 8 | following name and signature: 9 | 10 | ``` 11 | def load_verifier() -> InstallTargetVerifier 12 | ``` 13 | 14 | This `load_verifier` function should return an instance of the custom verifier 15 | for the firewall's use. The module may then be placed in the scfw/verifiers directory 16 | for runtime import, no further modification required.. Make sure to reinstall the 17 | package after doing so. 18 | """ 19 | 20 | from scfw.target import InstallTarget 21 | from scfw.verifier import FindingSeverity, InstallTargetVerifier 22 | 23 | 24 | class CustomInstallTargetVerifier(InstallTargetVerifier): 25 | def name(self) -> str: 26 | return "CustomInstallTargetVerifier" 27 | 28 | def verify(self, target: InstallTarget) -> list[tuple[FindingSeverity, str]]: 29 | return [] 30 | 31 | 32 | def load_verifier() -> InstallTargetVerifier: 33 | return CustomInstallTargetVerifier() 34 | -------------------------------------------------------------------------------- /images/datadog_log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataDog/supply-chain-firewall/6f913e0de04c80884e776e774fc3d255ea4342dd/images/datadog_log.png -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataDog/supply-chain-firewall/6f913e0de04c80884e776e774fc3d255ea4342dd/images/demo.gif -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataDog/supply-chain-firewall/6f913e0de04c80884e776e774fc3d255ea4342dd/images/logo.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "scfw" 3 | dynamic = ["version"] 4 | dependencies = [ 5 | "cvss", 6 | "datadog-api-client", 7 | "inquirer", 8 | "packaging", 9 | "python-dotenv", 10 | "requests", 11 | ] 12 | requires-python = ">=3.10" 13 | authors = [ 14 | {name = "Ian Kretz", email = "ian.kretz@datadoghq.com"}, 15 | ] 16 | maintainers = [ 17 | {name = "Ian Kretz", email = "ian.kretz@datadoghq.com"}, 18 | ] 19 | description = "A tool for preventing the installation of malicious open-source packages" 20 | readme = "README.md" 21 | 22 | [project.scripts] 23 | scfw = "scfw.main:main" 24 | 25 | [build-system] 26 | requires = ["setuptools"] 27 | build-backend = "setuptools.build_meta" 28 | 29 | [tool.setuptools] 30 | packages = [ 31 | "scfw", 32 | "scfw.configure", 33 | "scfw.loggers", 34 | "scfw.package_managers", 35 | "scfw.verifiers", 36 | "scfw.verifiers.osv_verifier" 37 | ] 38 | 39 | [tool.setuptools.dynamic] 40 | version = {attr = "scfw.__version__"} 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blessed==1.21.0 ; python_version >= "3.10" and python_version < "4" \ 2 | --hash=sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec \ 3 | --hash=sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588 4 | certifi==2025.4.26 ; python_version >= "3.10" and python_version < "4" \ 5 | --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ 6 | --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 7 | charset-normalizer==3.4.2 ; python_version >= "3.10" and python_version < "4" \ 8 | --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ 9 | --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ 10 | --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ 11 | --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ 12 | --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ 13 | --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ 14 | --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ 15 | --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ 16 | --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ 17 | --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ 18 | --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ 19 | --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ 20 | --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ 21 | --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ 22 | --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ 23 | --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ 24 | --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ 25 | --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ 26 | --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ 27 | --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ 28 | --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ 29 | --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ 30 | --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ 31 | --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ 32 | --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ 33 | --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ 34 | --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ 35 | --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ 36 | --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ 37 | --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ 38 | --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ 39 | --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ 40 | --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ 41 | --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ 42 | --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ 43 | --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ 44 | --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ 45 | --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ 46 | --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ 47 | --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ 48 | --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ 49 | --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ 50 | --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ 51 | --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ 52 | --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ 53 | --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ 54 | --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ 55 | --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ 56 | --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ 57 | --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ 58 | --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ 59 | --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ 60 | --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ 61 | --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ 62 | --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ 63 | --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ 64 | --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ 65 | --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ 66 | --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ 67 | --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ 68 | --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ 69 | --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ 70 | --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ 71 | --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ 72 | --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ 73 | --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ 74 | --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ 75 | --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ 76 | --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ 77 | --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ 78 | --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ 79 | --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ 80 | --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ 81 | --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ 82 | --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ 83 | --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ 84 | --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ 85 | --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ 86 | --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ 87 | --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ 88 | --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ 89 | --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ 90 | --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ 91 | --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ 92 | --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ 93 | --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ 94 | --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ 95 | --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ 96 | --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ 97 | --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ 98 | --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ 99 | --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f 100 | cvss==3.4 ; python_version >= "3.10" and python_version < "4" \ 101 | --hash=sha256:632353244ba3c58b53355466677edc968b9d7143c317b66271f9fd7939951ee8 \ 102 | --hash=sha256:d9950613758e60820f7fac37ca5f35158712f8f2ea4f6629858a60c4984fe4ef 103 | datadog-api-client==2.34.0 ; python_version >= "3.10" and python_version < "4" \ 104 | --hash=sha256:29534095d6270bfe3a0a80882f060e65f25b28250fae3437b97bfcd9e9ff027d \ 105 | --hash=sha256:ec1061ec272dd426e183947d9103a9a98f6823b9882157abec1ba4fcdfea9f05 106 | editor==1.6.6 ; python_version >= "3.10" and python_version < "4" \ 107 | --hash=sha256:bb6989e872638cd119db9a4fce284cd8e13c553886a1c044c6b8d8a160c871f8 \ 108 | --hash=sha256:e818e6913f26c2a81eadef503a2741d7cca7f235d20e217274a009ecd5a74abf 109 | idna==3.10 ; python_version >= "3.10" and python_version < "4" \ 110 | --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ 111 | --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 112 | inquirer==3.4.0 ; python_version >= "3.10" and python_version < "4" \ 113 | --hash=sha256:8edc99c076386ee2d2204e5e3653c2488244e82cb197b2d498b3c1b5ffb25d0b \ 114 | --hash=sha256:bb0ec93c833e4ce7b51b98b1644b0a4d2bb39755c39787f6a504e4fee7a11b60 115 | packaging==25.0 ; python_version >= "3.10" and python_version < "4" \ 116 | --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ 117 | --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f 118 | python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4" \ 119 | --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ 120 | --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 121 | python-dotenv==1.1.0 ; python_version >= "3.10" and python_version < "4" \ 122 | --hash=sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5 \ 123 | --hash=sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d 124 | readchar==4.2.1 ; python_version >= "3.10" and python_version < "4" \ 125 | --hash=sha256:91ce3faf07688de14d800592951e5575e9c7a3213738ed01d394dcc949b79adb \ 126 | --hash=sha256:a769305cd3994bb5fa2764aa4073452dc105a4ec39068ffe6efd3c20c60acc77 127 | requests==2.32.3 ; python_version >= "3.10" and python_version < "4" \ 128 | --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ 129 | --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 130 | runs==1.2.2 ; python_version >= "3.10" and python_version < "4" \ 131 | --hash=sha256:0980dcbc25aba1505f307ac4f0e9e92cbd0be2a15a1e983ee86c24c87b839dfd \ 132 | --hash=sha256:9dc1815e2895cfb3a48317b173b9f1eac9ba5549b36a847b5cc60c3bf82ecef1 133 | six==1.17.0 ; python_version >= "3.10" and python_version < "4" \ 134 | --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ 135 | --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 136 | typing-extensions==4.13.2 ; python_version >= "3.10" and python_version < "4" \ 137 | --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \ 138 | --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef 139 | urllib3==2.4.0 ; python_version >= "3.10" and python_version < "4" \ 140 | --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \ 141 | --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813 142 | wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4" \ 143 | --hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \ 144 | --hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5 145 | xmod==1.8.1 ; python_version >= "3.10" and python_version < "4" \ 146 | --hash=sha256:38c76486b9d672c546d57d8035df0beb7f4a9b088bc3fb2de5431ae821444377 \ 147 | --hash=sha256:a24e9458a4853489042522bdca9e50ee2eac5ab75c809a91150a8a7f40670d48 148 | -------------------------------------------------------------------------------- /scfw/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A supply-chain "firewall" for preventing the installation of vulnerable or malicious `pip` and `npm` packages. 3 | """ 4 | 5 | __version__ = "2.0.0" 6 | -------------------------------------------------------------------------------- /scfw/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entry point for module invocation via `python -m`. 3 | """ 4 | 5 | import sys 6 | 7 | import scfw.main as main 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(main.main()) 12 | -------------------------------------------------------------------------------- /scfw/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the supply-chain firewall's command-line interface and performs argument parsing. 3 | """ 4 | 5 | from argparse import ArgumentError, Namespace 6 | from enum import Enum 7 | import logging 8 | import sys 9 | from typing import Callable, Optional 10 | 11 | import scfw 12 | from scfw.logger import FirewallAction 13 | from scfw.package_managers import SUPPORTED_PACKAGE_MANAGERS 14 | from scfw.parser import ArgumentParser 15 | 16 | _LOG_LEVELS = list( 17 | map( 18 | logging.getLevelName, 19 | [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR] 20 | ) 21 | ) 22 | _DEFAULT_LOG_LEVEL = logging.getLevelName(logging.WARNING) 23 | 24 | 25 | def _add_configure_cli(parser: ArgumentParser): 26 | """ 27 | Defines the command-line interface for the firewall's `configure` subcommand. 28 | 29 | Args: 30 | parser: The `ArgumentParser` to which the `configure` command line will be added. 31 | """ 32 | parser.add_argument( 33 | "-r", 34 | "--remove", 35 | action="store_true", 36 | help="Remove all Supply-Chain Firewall-managed configuration" 37 | ) 38 | 39 | parser.add_argument( 40 | "--alias-npm", 41 | action="store_true", 42 | help="Add shell aliases to always run npm commands through Supply-Chain Firewall" 43 | ) 44 | 45 | parser.add_argument( 46 | "--alias-pip", 47 | action="store_true", 48 | help="Add shell aliases to always run pip commands through Supply-Chain Firewall" 49 | ) 50 | 51 | parser.add_argument( 52 | "--alias-poetry", 53 | action="store_true", 54 | help="Add shell aliases to always run Poetry commands through Supply-Chain Firewall" 55 | ) 56 | 57 | parser.add_argument( 58 | "--dd-agent-port", 59 | type=str, 60 | default=None, 61 | metavar="PORT", 62 | help="Configure log forwarding to the local Datadog Agent on the given port" 63 | ) 64 | 65 | parser.add_argument( 66 | "--dd-api-key", 67 | type=str, 68 | default=None, 69 | metavar="KEY", 70 | help="API key to use when forwarding logs via the Datadog API" 71 | ) 72 | 73 | parser.add_argument( 74 | "--dd-log-level", 75 | type=str, 76 | default=None, 77 | choices=[str(action) for action in FirewallAction], 78 | metavar="LEVEL", 79 | help="Desired logging level for Datadog log forwarding (options: %(choices)s)" 80 | ) 81 | 82 | 83 | def _add_run_cli(parser: ArgumentParser): 84 | """ 85 | Defines the command-line interface for the firewall's `run` subcommand. 86 | 87 | Args: 88 | parser: The `ArgumentParser` to which the `run` command line will be added. 89 | """ 90 | parser.add_argument( 91 | "--dry-run", 92 | action="store_true", 93 | help="Verify any installation targets but do not run the package manager command" 94 | ) 95 | 96 | parser.add_argument( 97 | "--executable", 98 | type=str, 99 | default=None, 100 | metavar="PATH", 101 | help="Python or npm executable to use for running commands (default: environmentally determined)" 102 | ) 103 | 104 | 105 | class Subcommand(Enum): 106 | """ 107 | The set of subcommands that comprise the supply-chain firewall's command line. 108 | """ 109 | Configure = "configure" 110 | Run = "run" 111 | 112 | def __str__(self) -> str: 113 | """ 114 | Format a `Subcommand` for printing. 115 | 116 | Returns: 117 | A `str` representing the given `Subcommand` suitable for printing. 118 | """ 119 | return self.value 120 | 121 | def _parser_spec(self) -> dict: 122 | """ 123 | Return the `ArgumentParser` configuration for the given subcommand's parser. 124 | 125 | Returns: 126 | A `dict` of `kwargs` to pass to the `argparse.SubParsersAction.add_parser()` 127 | method for configuring the subparser corresponding to the subcommand. 128 | """ 129 | match self: 130 | case Subcommand.Configure: 131 | return { 132 | "exit_on_error": False, 133 | "description": "Configure the environment for using Supply-Chain Firewall." 134 | } 135 | case Subcommand.Run: 136 | return { 137 | "usage": "%(prog)s [options] COMMAND", 138 | "exit_on_error": False, 139 | "description": "Run a package manager command through Supply-Chain Firewall." 140 | } 141 | 142 | def _cli_spec(self) -> Callable[[ArgumentParser], None]: 143 | """ 144 | Return a function for adding the given subcommand's command-line options 145 | to a given `ArgumentParser`. 146 | 147 | Returns: 148 | A `Callable[[ArgumentParser], None]` that adds the command-line options 149 | for the subcommand to the `ArgumentParser` it is given, in the intended 150 | case via a sequence of calls to `ArgumentParser.add_argument()`. 151 | """ 152 | match self: 153 | case Subcommand.Configure: 154 | return _add_configure_cli 155 | case Subcommand.Run: 156 | return _add_run_cli 157 | 158 | 159 | def _cli() -> ArgumentParser: 160 | """ 161 | Defines the command-line interface for the supply-chain firewall. 162 | 163 | Returns: 164 | A parser for the supply-chain firewall's command line. 165 | 166 | This parser only handles the firewall's own optional arguments and subcommands. 167 | It does not parse the package manager commands being run through the firewall. 168 | """ 169 | parser = ArgumentParser( 170 | prog="scfw", 171 | exit_on_error=False, 172 | description="A tool for preventing the installation of malicious PyPI and npm packages." 173 | ) 174 | 175 | parser.add_argument( 176 | "-v", 177 | "--version", 178 | action="version", 179 | version=scfw.__version__ 180 | ) 181 | 182 | parser.add_argument( 183 | "--log-level", 184 | type=str, 185 | choices=_LOG_LEVELS, 186 | default=_DEFAULT_LOG_LEVEL, 187 | metavar="LEVEL", 188 | help="Desired logging level (default: %(default)s, options: %(choices)s)" 189 | ) 190 | 191 | subparsers = parser.add_subparsers(dest="subcommand", required=True) 192 | 193 | for subcommand in Subcommand: 194 | subparser = subparsers.add_parser(str(subcommand), **subcommand._parser_spec()) 195 | subcommand._cli_spec()(subparser) 196 | 197 | return parser 198 | 199 | 200 | def _parse_command_line(argv: list[str]) -> tuple[Optional[Namespace], str]: 201 | """ 202 | Parse the supply-chain firewall's command line from a given argument vector. 203 | 204 | Args: 205 | argv: The argument vector to be parsed. 206 | 207 | Returns: 208 | A `tuple` of a `Namespace` object containing the results of parsing the given 209 | argument vector and a `str` help message for the caller's use in early exits. 210 | In the case of a parsing failure, `None` is returned instead of a `Namespace`. 211 | 212 | On success, and only for the `run` subcommand, the returned `Namespace` contains 213 | the package manager command present in the given argument vector as a `list[str]` 214 | under the `command` attribute. 215 | """ 216 | hinge = len(argv) 217 | for name in SUPPORTED_PACKAGE_MANAGERS: 218 | try: 219 | hinge = min(hinge, argv.index(name)) 220 | except ValueError: 221 | pass 222 | 223 | parser = _cli() 224 | help_msg = parser.format_help() 225 | 226 | try: 227 | args = parser.parse_args(argv[1:hinge]) 228 | 229 | # Config removal option is mutually exclusive with the others 230 | if ( 231 | Subcommand(args.subcommand) == Subcommand.Configure 232 | and args.remove 233 | and any({ 234 | args.alias_npm, 235 | args.alias_pip, 236 | args.alias_poetry, 237 | args.dd_agent_port, 238 | args.dd_api_key, 239 | args.dd_log_level, 240 | }) 241 | ): 242 | raise ArgumentError(None, "Cannot combine configuration and removal options") 243 | 244 | # Only allow a package manager `command` argument when the user selected 245 | # the `run` subcommand 246 | match Subcommand(args.subcommand), argv[hinge:]: 247 | case Subcommand.Run, []: 248 | raise ArgumentError(None, "Missing required package manager command") 249 | case Subcommand.Run, _: 250 | args.command = argv[hinge:] 251 | case _, []: 252 | pass 253 | case _: 254 | raise ArgumentError(None, "Received unexpected package manager command") 255 | 256 | return args, help_msg 257 | 258 | except ArgumentError: 259 | return None, help_msg 260 | 261 | 262 | def parse_command_line() -> tuple[Optional[Namespace], str]: 263 | """ 264 | Parse the supply-chain firewall's command line. 265 | 266 | Returns: 267 | A `tuple` of a `Namespace` object containing the results of parsing the 268 | firewall's command line and a `str` help message for the caller's use in 269 | early exits. In the case of a parsing failure, `None` is returned instead 270 | of a `Namespace`. 271 | 272 | On success, the returned `Namespace` contains the package manager command 273 | provided to the firewall as a (possibly empty) `list[str]` under the `command` 274 | attribute. 275 | """ 276 | return _parse_command_line(sys.argv) 277 | -------------------------------------------------------------------------------- /scfw/configure/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements Supply-Chain Firewall's `configure` subcommand. 3 | """ 4 | 5 | from argparse import Namespace 6 | import logging 7 | 8 | from scfw.configure.constants import * # noqa 9 | import scfw.configure.dd_agent as dd_agent 10 | import scfw.configure.env as env 11 | import scfw.configure.interactive as interactive 12 | from scfw.configure.interactive import GREETING 13 | 14 | _log = logging.getLogger(__name__) 15 | 16 | 17 | def run_configure(args: Namespace) -> int: 18 | """ 19 | Configure the environment for use with the supply-chain firewall. 20 | 21 | Args: 22 | args: A `Namespace` containing the parsed `configure` subcommand command line. 23 | 24 | Returns: 25 | An integer status code, 0 or 1. 26 | """ 27 | try: 28 | if args.remove: 29 | # These options result in the firewall's configuration block being removed 30 | env.update_config_files({ 31 | "alias_npm": False, 32 | "alias_pip": False, 33 | "alias_poetry": False, 34 | "dd_agent_port": None, 35 | "dd_api_key": None, 36 | "dd_log_level": None 37 | }) 38 | dd_agent.remove_agent_logging() 39 | print( 40 | "All Supply-Chain Firewall-managed configuration has been removed from your environment." 41 | "\n\nPost-removal tasks:" 42 | "\n* Update your current shell environment by sourcing from your .bashrc/.zshrc file." 43 | "\n* If you had previously configured Datadog Agent log forwarding, restart the Agent." 44 | ) 45 | return 0 46 | 47 | # The CLI parser guarantees that all of these arguments are present 48 | is_interactive = not any({ 49 | args.alias_npm, 50 | args.alias_pip, 51 | args.alias_poetry, 52 | args.dd_agent_port, 53 | args.dd_api_key, 54 | args.dd_log_level 55 | }) 56 | 57 | if is_interactive: 58 | print(GREETING) 59 | answers = interactive.get_answers() 60 | else: 61 | answers = vars(args) 62 | 63 | if not answers: 64 | return 0 65 | 66 | if (port := answers.get("dd_agent_port")): 67 | dd_agent.configure_agent_logging(port) 68 | 69 | env.update_config_files(answers) 70 | 71 | if is_interactive: 72 | print(interactive.get_farewell(answers)) 73 | 74 | return 0 75 | 76 | except Exception as e: 77 | _log.error(e) 78 | return 1 79 | -------------------------------------------------------------------------------- /scfw/configure/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides various configuration-related constants. 3 | """ 4 | 5 | DD_SOURCE = "scfw" 6 | """ 7 | Source value for Datadog logging. 8 | """ 9 | 10 | DD_SERVICE = "scfw" 11 | """ 12 | Service value for Datadog logging. 13 | """ 14 | 15 | DD_ENV = "dev" 16 | """ 17 | Default environment value for Datadog logging. 18 | """ 19 | 20 | DD_API_KEY_VAR = "DD_API_KEY" 21 | """ 22 | The environment variable under which the firewall looks for a Datadog API key. 23 | """ 24 | 25 | DD_LOG_LEVEL_VAR = "SCFW_DD_LOG_LEVEL" 26 | """ 27 | The environment variable under which the firewall looks for a Datadog log level setting. 28 | """ 29 | 30 | DD_AGENT_PORT_VAR = "SCFW_DD_AGENT_LOG_PORT" 31 | """ 32 | The environment variable under which the firewall looks for a port number on which to 33 | forward firewall logs to the local Datadog Agent. 34 | """ 35 | -------------------------------------------------------------------------------- /scfw/configure/dd_agent.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides utilities for configuring the local Datadog Agent to receive logs from Supply-Chain Firewall. 3 | """ 4 | 5 | import json 6 | from pathlib import Path 7 | import shutil 8 | import subprocess 9 | 10 | from scfw.configure.constants import DD_SERVICE, DD_SOURCE 11 | 12 | 13 | def configure_agent_logging(port: str): 14 | """ 15 | Configure a local Datadog Agent for accepting logs from the firewall. 16 | 17 | Args: 18 | port: The local port number where the firewall logs will be sent to the Agent. 19 | 20 | Raises: 21 | ValueError: An invalid port number was provided. 22 | RuntimeError: An error occurred while querying the Agent's status. 23 | """ 24 | if not (0 < int(port) < 65536): 25 | raise ValueError("Invalid port number provided for Datadog Agent logging") 26 | 27 | config_file = ( 28 | "logs:\n" 29 | " - type: tcp\n" 30 | f" port: {port}\n" 31 | f' service: "{DD_SERVICE}"\n' 32 | f' source: "{DD_SOURCE}"\n' 33 | ) 34 | 35 | scfw_config_dir = _dd_agent_scfw_config_dir() 36 | scfw_config_file = scfw_config_dir / "conf.yaml" 37 | 38 | if not scfw_config_dir.is_dir(): 39 | scfw_config_dir.mkdir() 40 | with open(scfw_config_file, 'w') as f: 41 | f.write(config_file) 42 | 43 | 44 | def remove_agent_logging(): 45 | """ 46 | Remove Datadog Agent configuration for Supply-Chain Firewall, if it exists. 47 | 48 | Raises: 49 | RuntimeError: An error occurred while attempting to remove the configuration directory. 50 | """ 51 | scfw_config_dir = _dd_agent_scfw_config_dir() 52 | 53 | if not scfw_config_dir.is_dir(): 54 | return 55 | 56 | try: 57 | shutil.rmtree(scfw_config_dir) 58 | except Exception: 59 | raise RuntimeError( 60 | "Failed to delete Datadog Agent configuration directory for Supply-Chain Firewall" 61 | ) 62 | 63 | 64 | def _dd_agent_scfw_config_dir() -> Path: 65 | """ 66 | Get the filesystem path to the firewall's configuration directory for 67 | Datadog Agent log forwarding. 68 | 69 | Returns: 70 | A `Path` indicating the absolute filesystem path to this directory. 71 | 72 | Raises: 73 | RuntimeError: 74 | Unable to query Datadog Agent status to read the location of its 75 | global configuration directory. 76 | """ 77 | try: 78 | agent_status = subprocess.run( 79 | ["datadog-agent", "status", "--json"], check=True, text=True, capture_output=True 80 | ) 81 | agent_config_dir = json.loads(agent_status.stdout).get("config", {}).get("confd_path", "") 82 | 83 | except subprocess.CalledProcessError: 84 | raise RuntimeError( 85 | "Unable to query Datadog Agent status: please ensure the Agent is running. " 86 | "Linux users may need sudo to run this command." 87 | ) 88 | 89 | return Path(agent_config_dir) / "scfw.d" 90 | -------------------------------------------------------------------------------- /scfw/configure/env.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides utilities for configuring the environment (via `.rc` files) for using Supply-Chain Firewall. 3 | """ 4 | 5 | from pathlib import Path 6 | import re 7 | import os 8 | import tempfile 9 | 10 | from scfw.configure.constants import DD_AGENT_PORT_VAR, DD_API_KEY_VAR, DD_LOG_LEVEL_VAR 11 | 12 | _CONFIG_FILES = [".bashrc", ".zshrc"] 13 | 14 | _BLOCK_START = "# BEGIN SCFW MANAGED BLOCK" 15 | _BLOCK_END = "# END SCFW MANAGED BLOCK" 16 | 17 | 18 | def update_config_files(answers: dict): 19 | """ 20 | Update the firewall's configuration in all supported .rc files. 21 | 22 | Args: 23 | answers: A `dict` configuration options to format and write to each file. 24 | """ 25 | for file in [Path.home() / file for file in _CONFIG_FILES]: 26 | if file.exists(): 27 | _update_config_file(file, answers) 28 | 29 | 30 | def _update_config_file(config_file: Path, answers: dict): 31 | """ 32 | Update the firewall's section in the given configuration file. 33 | 34 | Args: 35 | config_file: A `Path` to the configuration file to update. 36 | answers: The `dict` of configuration options to write. 37 | """ 38 | def enclose(config: str) -> str: 39 | return f"{_BLOCK_START}{config}\n{_BLOCK_END}" 40 | 41 | with open(config_file) as f: 42 | contents = f.read() 43 | 44 | config = _format_answers(answers) 45 | 46 | pattern = f"{_BLOCK_START}(.*?){_BLOCK_END}" 47 | if not config: 48 | pattern = f"\n{pattern}\n" 49 | 50 | updated = re.sub(pattern, enclose(config) if config else '', contents, flags=re.DOTALL) 51 | if updated == contents and config not in contents: 52 | updated = f"{contents}\n{enclose(config)}\n" 53 | 54 | temp_fd, temp_file = tempfile.mkstemp(text=True) 55 | temp_handle = os.fdopen(temp_fd, 'w') 56 | temp_handle.write(updated) 57 | temp_handle.close() 58 | 59 | os.rename(temp_file, config_file) 60 | 61 | 62 | def _format_answers(answers: dict) -> str: 63 | """ 64 | Format configuration options into .rc file `str` content. 65 | 66 | Args: 67 | answers: A `dict` containing the user's selected configuration options. 68 | 69 | Returns: 70 | A `str` containing the desired configuration content for writing into a .rc file. 71 | """ 72 | config = '' 73 | 74 | if answers.get("alias_npm"): 75 | config += '\nalias npm="scfw run npm"' 76 | if answers.get("alias_pip"): 77 | config += '\nalias pip="scfw run pip"' 78 | if answers.get("alias_poetry"): 79 | config += '\nalias poetry="scfw run poetry"' 80 | if (dd_agent_port := answers.get("dd_agent_port")): 81 | config += f'\nexport {DD_AGENT_PORT_VAR}="{dd_agent_port}"' 82 | if (dd_api_key := answers.get("dd_api_key")): 83 | config += f'\nexport {DD_API_KEY_VAR}="{dd_api_key}"' 84 | if (dd_log_level := answers.get("dd_log_level")): 85 | config += f'\nexport {DD_LOG_LEVEL_VAR}="{dd_log_level}"' 86 | 87 | return config 88 | -------------------------------------------------------------------------------- /scfw/configure/interactive.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides utilities for interactively accepting configuration options from the user. 3 | """ 4 | 5 | import os 6 | 7 | import inquirer # type: ignore 8 | 9 | from scfw.configure.constants import DD_API_KEY_VAR 10 | from scfw.logger import FirewallAction 11 | 12 | GREETING = ( 13 | "Thank you for using scfw, the Supply-Chain Firewall by Datadog!\n\n" 14 | "scfw is a tool for preventing the installation of malicious PyPI and npm packages.\n\n" 15 | "This script will walk you through setting up your environment to get the most out\n" 16 | "of scfw. You can rerun this script at any time.\n" 17 | ) 18 | 19 | _DD_AGENT_DEFAULT_LOG_PORT = "10365" 20 | 21 | 22 | def get_answers() -> dict: 23 | """ 24 | Get the user's selection of configuration options in interactive mode. 25 | 26 | Returns: 27 | A `dict` containing the user's selected configuration options. 28 | """ 29 | has_dd_api_key = os.getenv(DD_API_KEY_VAR) is not None 30 | 31 | questions = [ 32 | inquirer.Confirm( 33 | name="alias_npm", 34 | message="Would you like to set a shell alias to run all npm commands through the firewall?", 35 | default=True 36 | ), 37 | inquirer.Confirm( 38 | name="alias_pip", 39 | message="Would you like to set a shell alias to run all pip commands through the firewall?", 40 | default=True 41 | ), 42 | inquirer.Confirm( 43 | name="alias_poetry", 44 | message="Would you like to set a shell alias to run all Poetry commands through the firewall?", 45 | default=True 46 | ), 47 | inquirer.Confirm( 48 | name="dd_agent_logging", 49 | message="If you have the Datadog Agent installed locally, would you like to forward firewall logs to it?", 50 | default=False 51 | ), 52 | inquirer.Text( 53 | name="dd_agent_port", 54 | message=f"Enter the local port where the Agent will receive logs (default: {_DD_AGENT_DEFAULT_LOG_PORT})", 55 | ignore=lambda answers: not answers["dd_agent_logging"] 56 | ), 57 | inquirer.Confirm( 58 | name="dd_api_logging", 59 | message="Would you like to enable sending firewall logs to Datadog using an API key?", 60 | default=False, 61 | ignore=lambda answers: has_dd_api_key or answers["dd_agent_logging"] 62 | ), 63 | inquirer.Text( 64 | name="dd_api_key", 65 | message="Enter a Datadog API key", 66 | validate=lambda _, current: current != '', 67 | ignore=lambda answers: has_dd_api_key or not answers["dd_api_logging"] 68 | ), 69 | inquirer.List( 70 | name="dd_log_level", 71 | message="Select the desired log level for Datadog logging", 72 | choices=[(_describe_log_level(action), str(action)) for action in FirewallAction], 73 | ignore=lambda answers: not (answers["dd_agent_logging"] or has_dd_api_key or answers["dd_api_logging"]) 74 | ) 75 | ] 76 | 77 | answers = inquirer.prompt(questions) 78 | 79 | # Patch for inquirer's broken `default` option 80 | if answers.get("dd_agent_logging") and not answers.get("dd_agent_port"): 81 | answers["dd_agent_port"] = _DD_AGENT_DEFAULT_LOG_PORT 82 | 83 | return answers 84 | 85 | 86 | def get_farewell(answers: dict) -> str: 87 | """ 88 | Generate a farewell message in interactive mode based on the configuration 89 | options selected by the user. 90 | 91 | Args: 92 | answers: The dictionary of user-selected configuration options. 93 | 94 | Returns: 95 | A `str` farewell message to print in interactive mode. 96 | """ 97 | farewell = ( 98 | "The environment was successfully configured for Supply-Chain Firewall." 99 | "\n\nPost-configuration tasks:" 100 | "\n* Update your current shell environment by sourcing from your .bashrc/.zshrc file." 101 | ) 102 | 103 | if answers.get("dd_agent_logging"): 104 | farewell += "\n* Restart the Datadog Agent in order for it to accept firewall logs." 105 | 106 | farewell += "\n\nGood luck!" 107 | 108 | return farewell 109 | 110 | 111 | def _describe_log_level(action: FirewallAction) -> str: 112 | """ 113 | Return a description of the given `action` considered as a log level. 114 | 115 | Args: 116 | action: A `FirewallAction` considered as a log level. 117 | 118 | Returns: 119 | A `str` description of which firewall actions are logged at the given level. 120 | """ 121 | match action: 122 | case FirewallAction.ALLOW: 123 | return "Log allowed and blocked commands" 124 | case FirewallAction.BLOCK: 125 | return "Log only blocked commands" 126 | -------------------------------------------------------------------------------- /scfw/ecosystem.py: -------------------------------------------------------------------------------- 1 | """ 2 | A representation of package ecosystems supported by the supply-chain firewall. 3 | """ 4 | 5 | from enum import Enum 6 | 7 | 8 | class ECOSYSTEM(Enum): 9 | """ 10 | Package ecosystems supported by the supply-chain firewall. 11 | """ 12 | Npm = "npm" 13 | PyPI = "PyPI" 14 | 15 | def __str__(self) -> str: 16 | """ 17 | Format an `ECOSYSTEM` for printing. 18 | 19 | Returns: 20 | A `str` representing the given `ECOSYSTEM` suitable for printing. 21 | """ 22 | return self.value 23 | -------------------------------------------------------------------------------- /scfw/firewall.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the supply-chain firewall's core `run` subcommand. 3 | """ 4 | 5 | from argparse import Namespace 6 | import inquirer # type: ignore 7 | import logging 8 | 9 | from scfw.logger import FirewallAction 10 | from scfw.loggers import FirewallLoggers 11 | from scfw.package_manager import UnsupportedVersionError 12 | import scfw.package_managers as package_managers 13 | from scfw.verifier import FindingSeverity 14 | from scfw.verifiers import FirewallVerifiers 15 | 16 | _log = logging.getLogger(__name__) 17 | 18 | 19 | def run_firewall(args: Namespace) -> int: 20 | """ 21 | Run a package manager command through the supply-chain firewall. 22 | 23 | Args: 24 | args: 25 | A `Namespace` parsed from a `run` subcommand command line containing a 26 | command to run through the firewall. 27 | 28 | Returns: 29 | An integer status code, 0 or 1. 30 | """ 31 | try: 32 | warned = False 33 | 34 | loggers = FirewallLoggers() 35 | _log.info(f"Command: '{' '.join(args.command)}'") 36 | 37 | package_manager = package_managers.get_package_manager(args.command, executable=args.executable) 38 | 39 | targets = package_manager.resolve_install_targets(args.command) 40 | _log.info(f"Command would install: [{', '.join(map(str, targets))}]") 41 | 42 | if targets: 43 | verifiers = FirewallVerifiers() 44 | _log.info( 45 | f"Using package verifiers: [{', '.join(verifiers.names())}]" 46 | ) 47 | 48 | reports = verifiers.verify_packages(targets) 49 | 50 | if (critical_report := reports.get(FindingSeverity.CRITICAL)): 51 | loggers.log( 52 | package_manager.ecosystem(), 53 | package_manager.executable(), 54 | args.command, 55 | list(critical_report.packages()), 56 | action=FirewallAction.BLOCK, 57 | warned=False 58 | ) 59 | print(critical_report) 60 | print("\nThe installation request was blocked. No changes have been made.") 61 | return 0 62 | 63 | if (warning_report := reports.get(FindingSeverity.WARNING)): 64 | print(warning_report) 65 | warned = True 66 | 67 | if not (inquirer.confirm("Proceed with installation?", default=False)): 68 | loggers.log( 69 | package_manager.ecosystem(), 70 | package_manager.executable(), 71 | args.command, 72 | list(warning_report.packages()), 73 | action=FirewallAction.BLOCK, 74 | warned=warned 75 | ) 76 | print("The installation request was aborted. No changes have been made.") 77 | return 0 78 | 79 | if args.dry_run: 80 | _log.info("Firewall dry-run mode enabled: command will not be run") 81 | print("Dry-run: exiting without running command.") 82 | else: 83 | loggers.log( 84 | package_manager.ecosystem(), 85 | package_manager.executable(), 86 | args.command, 87 | targets, 88 | action=FirewallAction.ALLOW, 89 | warned=warned 90 | ) 91 | package_manager.run_command(args.command) 92 | return 0 93 | 94 | except UnsupportedVersionError as e: 95 | _log.error(f"Incompatible package manager version: {e}") 96 | return 0 97 | 98 | except Exception as e: 99 | _log.error(e) 100 | return 1 101 | -------------------------------------------------------------------------------- /scfw/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides an interface for client loggers to receive information about a 3 | completed run of the supply-chain firewall. 4 | """ 5 | 6 | from abc import (ABCMeta, abstractmethod) 7 | from enum import Enum 8 | from typing_extensions import Self 9 | 10 | from scfw.ecosystem import ECOSYSTEM 11 | from scfw.package import Package 12 | 13 | 14 | class FirewallAction(Enum): 15 | """ 16 | The various actions the firewall may take in response to inspecting a 17 | package manager command. 18 | """ 19 | ALLOW = 0 20 | BLOCK = 1 21 | 22 | def __lt__(self, other) -> bool: 23 | """ 24 | Compare two `FirewallAction` instances on the basis of their underlying numeric values. 25 | 26 | Args: 27 | self: The `FirewallAction` to be compared on the left-hand side 28 | other: The `FirewallAction` to be compared on the right-hand side 29 | 30 | Returns: 31 | A `bool` indicating whether `<` holds between the two given `FirewallAction`. 32 | 33 | Raises: 34 | TypeError: The other argument given was not a `FirewallAction`. 35 | """ 36 | if self.__class__ is not other.__class__: 37 | raise TypeError( 38 | f"'<' not supported between instances of '{self.__class__}' and '{other.__class__}'" 39 | ) 40 | 41 | return self.value < other.value 42 | 43 | def __str__(self) -> str: 44 | """ 45 | Format a `FirewallAction` for printing. 46 | 47 | Returns: 48 | A `str` representing the given `FirewallAction` suitable for printing. 49 | """ 50 | return self.name 51 | 52 | @classmethod 53 | def from_string(cls, s: str) -> Self: 54 | """ 55 | Convert a string into a `FirewallAction`. 56 | 57 | Args: 58 | s: The `str` to be converted. 59 | 60 | Returns: 61 | The `FirewallAction` referred to by the given string. 62 | 63 | Raises: 64 | ValueError: The given string does not refer to a valid `FirewallAction`. 65 | """ 66 | mappings = {f"{action}".lower(): action for action in cls} 67 | if (action := mappings.get(s.lower())): 68 | return action 69 | raise ValueError(f"Invalid firewall action '{s}'") 70 | 71 | 72 | class FirewallLogger(metaclass=ABCMeta): 73 | """ 74 | An interface for passing information about a completed firewall run to 75 | client loggers. 76 | """ 77 | @abstractmethod 78 | def log( 79 | self, 80 | ecosystem: ECOSYSTEM, 81 | executable: str, 82 | command: list[str], 83 | targets: list[Package], 84 | action: FirewallAction, 85 | warned: bool 86 | ): 87 | """ 88 | Pass data from a completed run of the firewall to a logger. 89 | 90 | Args: 91 | ecosystem: The ecosystem of the inspected package manager command. 92 | executable: The executable used to execute the inspected package manager command. 93 | command: The package manager command line provided to the firewall. 94 | targets: 95 | The installation targets relevant to firewall's action. 96 | 97 | In the case of a blocking action, `targets` contains the installation 98 | targets that caused the firewall to block. In the case of an aborting 99 | action, `targets` contains the targets that prompted the firewall to 100 | warn the user and seek confirmation to proceed. 101 | action: The action taken by the firewall. 102 | warned: 103 | Indicates whether the user was warned about findings for any installation 104 | targets and prompted for approval to proceed with `command`. 105 | """ 106 | pass 107 | -------------------------------------------------------------------------------- /scfw/loggers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exposes the currently discoverable set of client loggers implementing the 3 | firewall's logging protocol. 4 | 5 | Two loggers ship with the supply chain firewall by default: `DDAgentLogger` 6 | and `DDAPILogger`, which send logs to Datadog via a local Datadog Agent 7 | or the HTTP API, respectively. Firewall users may additionally provide custom 8 | loggers according to their own logging needs. 9 | 10 | The firewall discovers loggers at runtime via the following simple protocol. 11 | The module implementing the custom logger must contain a function with the 12 | following name and signature: 13 | 14 | ``` 15 | def load_logger() -> FirewallLogger 16 | ``` 17 | 18 | This `load_logger` function should return an instance of the custom logger 19 | for the firewall's use. The module may then be placed in the same directory 20 | as this source file for runtime import. Make sure to reinstall the package 21 | after doing so. 22 | """ 23 | 24 | import importlib 25 | import logging 26 | import os 27 | import pkgutil 28 | 29 | from scfw.ecosystem import ECOSYSTEM 30 | from scfw.logger import FirewallAction, FirewallLogger 31 | from scfw.package import Package 32 | 33 | _log = logging.getLogger(__name__) 34 | 35 | 36 | class FirewallLoggers(FirewallLogger): 37 | """ 38 | A `FirewallLogger` that logs to all currently discoverable `FirewallLoggers`. 39 | """ 40 | def __init__(self): 41 | """ 42 | Initialize a new `FirewallLoggers` instance from currently discoverable loggers. 43 | """ 44 | self._loggers = [] 45 | 46 | for _, module, _ in pkgutil.iter_modules([os.path.dirname(__file__)]): 47 | try: 48 | logger = importlib.import_module(f".{module}", package=__name__).load_logger() 49 | self._loggers.append(logger) 50 | except ModuleNotFoundError: 51 | _log.warning(f"Failed to load module {module} while collecting loggers") 52 | except AttributeError: 53 | _log.info(f"Module {module} does not export a logger") 54 | 55 | def log( 56 | self, 57 | ecosystem: ECOSYSTEM, 58 | executable: str, 59 | command: list[str], 60 | targets: list[Package], 61 | action: FirewallAction, 62 | warned: bool 63 | ): 64 | """ 65 | Log a completed run of the supply-chain firewall to all discovered loggers. 66 | """ 67 | for logger in self._loggers: 68 | logger.log(ecosystem, executable, command, targets, action, warned) 69 | -------------------------------------------------------------------------------- /scfw/loggers/dd_agent_logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configures a logger for sending firewall logs to a local Datadog Agent. 3 | """ 4 | 5 | import logging 6 | import os 7 | import socket 8 | 9 | from scfw.configure import DD_AGENT_PORT_VAR 10 | from scfw.logger import FirewallLogger 11 | from scfw.loggers.dd_logger import DDLogFormatter, DDLogger 12 | 13 | _log = logging.getLogger(__name__) 14 | 15 | _DD_LOG_NAME = "dd_agent_log" 16 | 17 | 18 | class _DDLogHandler(logging.Handler): 19 | def emit(self, record): 20 | """ 21 | Format and send a log to the Datadog Agent. 22 | 23 | Args: 24 | record: The log record to be forwarded. 25 | """ 26 | try: 27 | message = self.format(record).encode() 28 | 29 | agent_port = int(os.getenv(DD_AGENT_PORT_VAR)) 30 | 31 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 32 | s.connect(("localhost", agent_port)) 33 | if s.send(message) != len(message): 34 | raise ValueError("Log send failed") 35 | s.close() 36 | 37 | except Exception as e: 38 | _log.warning(f"Failed to forward log to Datadog Agent: {e}") 39 | 40 | 41 | # Configure a single logging handle for all `DDAgentLogger` instances to share 42 | _handler = _DDLogHandler() if os.getenv(DD_AGENT_PORT_VAR) else logging.NullHandler() 43 | _handler.setFormatter(DDLogFormatter()) 44 | 45 | _ddlog = logging.getLogger(_DD_LOG_NAME) 46 | _ddlog.setLevel(logging.INFO) 47 | _ddlog.addHandler(_handler) 48 | 49 | 50 | class DDAgentLogger(DDLogger): 51 | """ 52 | An implementation of `FirewallLogger` for sending logs to a local Datadog Agent. 53 | """ 54 | def __init__(self): 55 | """ 56 | Initialize a new `DDAgentLogger`. 57 | """ 58 | super().__init__(_ddlog) 59 | 60 | 61 | def load_logger() -> FirewallLogger: 62 | """ 63 | Export `DDAgentLogger` for discovery by the firewall. 64 | 65 | Returns: 66 | A `DDAgentLogger` for use in a run of the firewall. 67 | """ 68 | return DDAgentLogger() 69 | -------------------------------------------------------------------------------- /scfw/loggers/dd_api_logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configures a logger for sending firewall logs to Datadog's API over HTTP. 3 | """ 4 | 5 | import logging 6 | import os 7 | 8 | from datadog_api_client import ApiClient, Configuration 9 | from datadog_api_client.v2.api.logs_api import LogsApi 10 | from datadog_api_client.v2.model.content_encoding import ContentEncoding 11 | from datadog_api_client.v2.model.http_log import HTTPLog 12 | from datadog_api_client.v2.model.http_log_item import HTTPLogItem 13 | 14 | import scfw 15 | from scfw.configure import DD_API_KEY_VAR, DD_ENV, DD_SERVICE, DD_SOURCE 16 | from scfw.logger import FirewallLogger 17 | from scfw.loggers.dd_logger import DDLogFormatter, DDLogger 18 | 19 | _DD_LOG_NAME = "dd_api_log" 20 | 21 | 22 | class _DDLogHandler(logging.Handler): 23 | """ 24 | A log handler for adding tags and forwarding firewall logs of blocked and 25 | permitted package installation requests to the Datadog API. 26 | 27 | In addition to USM tags, install targets are tagged with the `target` tag and included. 28 | """ 29 | def emit(self, record): 30 | """ 31 | Format and send a log to Datadog. 32 | 33 | Args: 34 | record: The log record to be forwarded. 35 | """ 36 | usm_tags = { 37 | f"env:{os.getenv('DD_ENV', DD_ENV)}", 38 | f"version:{scfw.__version__}" 39 | } 40 | 41 | targets = record.__dict__.get("targets", {}) 42 | target_tags = set(map(lambda e: f"target:{e}", targets)) 43 | 44 | body = HTTPLog( 45 | [ 46 | HTTPLogItem( 47 | ddsource=DD_SOURCE, 48 | ddtags=",".join(usm_tags | target_tags), 49 | message=self.format(record), 50 | service=DD_SERVICE, 51 | ), 52 | ] 53 | ) 54 | 55 | configuration = Configuration() 56 | with ApiClient(configuration) as api_client: 57 | api_instance = LogsApi(api_client) 58 | api_instance.submit_log(content_encoding=ContentEncoding.DEFLATE, body=body) 59 | 60 | 61 | # Configure a single logging handle for all `DDAPILogger` instances to share 62 | _handler = _DDLogHandler() if os.getenv(DD_API_KEY_VAR) else logging.NullHandler() 63 | _handler.setFormatter(DDLogFormatter()) 64 | 65 | _ddlog = logging.getLogger(_DD_LOG_NAME) 66 | _ddlog.setLevel(logging.INFO) 67 | _ddlog.addHandler(_handler) 68 | 69 | 70 | class DDAPILogger(DDLogger): 71 | """ 72 | An implementation of `FirewallLogger` for sending logs to the Datadog API. 73 | """ 74 | def __init__(self): 75 | """ 76 | Initialize a new `DDAPILogger`. 77 | """ 78 | super().__init__(_ddlog) 79 | 80 | 81 | def load_logger() -> FirewallLogger: 82 | """ 83 | Export `DDAPILogger` for discovery by the firewall. 84 | 85 | Returns: 86 | A `DDAPILogger` for use in a run of the firewall. 87 | """ 88 | return DDAPILogger() 89 | -------------------------------------------------------------------------------- /scfw/loggers/dd_logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a `FirewallLogger` class for sending logs to Datadog. 3 | """ 4 | 5 | import getpass 6 | import json 7 | import logging 8 | import os 9 | import socket 10 | 11 | import dotenv 12 | 13 | import scfw 14 | from scfw.configure import DD_ENV, DD_LOG_LEVEL_VAR, DD_SERVICE, DD_SOURCE 15 | from scfw.ecosystem import ECOSYSTEM 16 | from scfw.logger import FirewallAction, FirewallLogger 17 | from scfw.package import Package 18 | 19 | _log = logging.getLogger(__name__) 20 | 21 | _DD_LOG_LEVEL_DEFAULT = FirewallAction.BLOCK 22 | 23 | 24 | dotenv.load_dotenv() 25 | 26 | 27 | class DDLogFormatter(logging.Formatter): 28 | """ 29 | A custom JSON formatter for firewall logs. 30 | """ 31 | def format(self, record) -> str: 32 | """ 33 | Format a log record as a JSON string. 34 | 35 | Args: 36 | record: The log record to be formatted. 37 | """ 38 | log_record = { 39 | "source": DD_SOURCE, 40 | "service": DD_SERVICE, 41 | "version": scfw.__version__, 42 | "env": os.getenv("DD_ENV", DD_ENV), 43 | "hostname": socket.gethostname(), 44 | } 45 | 46 | try: 47 | log_record["username"] = getpass.getuser() 48 | except Exception as e: 49 | _log.warning(f"Failed to query username: {e}") 50 | 51 | # The `created` and `msg` attributes are provided by `logging.LogRecord` 52 | for key in {"action", "created", "ecosystem", "executable", "msg", "targets", "warned"}: 53 | log_record[key] = record.__dict__[key] 54 | 55 | return json.dumps(log_record) + '\n' 56 | 57 | 58 | class DDLogger(FirewallLogger): 59 | """ 60 | An implementation of `FirewallLogger` for sending logs to Datadog. 61 | """ 62 | def __init__(self, logger: logging.Logger): 63 | """ 64 | Initialize a new `DDLogger`. 65 | 66 | Args: 67 | logger: A configured log handle to which logs will be written. 68 | """ 69 | self._logger = logger 70 | self._level = _DD_LOG_LEVEL_DEFAULT 71 | 72 | try: 73 | if (dd_log_level := os.getenv(DD_LOG_LEVEL_VAR)) is not None: 74 | self._level = FirewallAction.from_string(dd_log_level) 75 | except ValueError: 76 | _log.warning(f"Undefined or invalid Datadog log level: using default level {_DD_LOG_LEVEL_DEFAULT}") 77 | 78 | def log( 79 | self, 80 | ecosystem: ECOSYSTEM, 81 | executable: str, 82 | command: list[str], 83 | targets: list[Package], 84 | action: FirewallAction, 85 | warned: bool 86 | ): 87 | """ 88 | Receive and log data about a completed firewall run. 89 | 90 | Args: 91 | ecosystem: The ecosystem of the inspected package manager command. 92 | executable: The executable used to execute the inspected package manager command. 93 | command: The package manager command line provided to the firewall. 94 | targets: The installation targets relevant to firewall's action. 95 | action: The action taken by the firewall. 96 | warned: Indicates whether the user was warned about findings and prompted for approval. 97 | """ 98 | if not self._level or action < self._level: 99 | return 100 | 101 | self._logger.info( 102 | f"Command '{' '.join(command)}' was {str(action).lower()}ed", 103 | extra={ 104 | "ecosystem": str(ecosystem), 105 | "executable": executable, 106 | "targets": list(map(str, targets)), 107 | "action": str(action), 108 | "warned": warned, 109 | } 110 | ) 111 | -------------------------------------------------------------------------------- /scfw/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides the supply-chain firewall's main routine. 3 | """ 4 | 5 | import logging 6 | import time 7 | 8 | import scfw.cli as cli 9 | from scfw.cli import Subcommand 10 | import scfw.configure as configure 11 | import scfw.firewall as firewall 12 | 13 | _log = logging.getLogger(__name__) 14 | 15 | 16 | def main() -> int: 17 | """ 18 | The supply-chain firewall's main routine. 19 | 20 | Returns: 21 | An integer status code, 0 or 1. 22 | """ 23 | args, help = cli.parse_command_line() 24 | 25 | if not args: 26 | print(help, end='') 27 | return 0 28 | 29 | _configure_logging(args.log_level) 30 | 31 | _log.info(f"Starting Supply-Chain Firewall on {time.asctime(time.localtime())}") 32 | _log.debug(f"Command line: {vars(args)}") 33 | 34 | match Subcommand(args.subcommand): 35 | case Subcommand.Configure: 36 | return configure.run_configure(args) 37 | case Subcommand.Run: 38 | return firewall.run_firewall(args) 39 | 40 | return 0 41 | 42 | 43 | def _configure_logging(level: int): 44 | """ 45 | Configure the root logger. 46 | 47 | Args: 48 | level: The log level selected by the user. 49 | """ 50 | handler = logging.StreamHandler() 51 | handler.addFilter(logging.Filter(name="scfw")) 52 | handler.setFormatter(logging.Formatter("[SCFW] %(levelname)s: %(message)s")) 53 | 54 | log = logging.getLogger() 55 | log.addHandler(handler) 56 | log.setLevel(level) 57 | -------------------------------------------------------------------------------- /scfw/package.py: -------------------------------------------------------------------------------- 1 | """ 2 | A representation of software packages in supported ecosystems. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | 7 | from scfw.ecosystem import ECOSYSTEM 8 | 9 | 10 | @dataclass(eq=True, frozen=True) 11 | class Package: 12 | """ 13 | Specifies a software package in a supported ecosystem. 14 | 15 | Attributes: 16 | ecosystem: The package's ecosystem. 17 | name: The package's name. 18 | version: The package's version string. 19 | """ 20 | ecosystem: ECOSYSTEM 21 | name: str 22 | version: str 23 | 24 | def __str__(self) -> str: 25 | """ 26 | Represent a `Package` as a string using ecosystem-specific formatting. 27 | 28 | Returns: 29 | A `str` with ecosystem-specific formatting describing the `Package` name and version. 30 | 31 | `npm` packages: `"{name}@{version}"`. 32 | `PyPI` packages: `"{name}-{version}"` 33 | """ 34 | match self.ecosystem: 35 | case ECOSYSTEM.Npm: 36 | return f"{self.name}@{self.version}" 37 | case ECOSYSTEM.PyPI: 38 | return f"{self.name}-{self.version}" 39 | -------------------------------------------------------------------------------- /scfw/package_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a base class for representing supported package managers. 3 | """ 4 | 5 | from abc import (ABCMeta, abstractmethod) 6 | from typing import Optional 7 | 8 | from scfw.ecosystem import ECOSYSTEM 9 | from scfw.package import Package 10 | 11 | 12 | class PackageManager(metaclass=ABCMeta): 13 | """ 14 | Abstract base class for representing supported package managers. 15 | """ 16 | @abstractmethod 17 | def __init__(self, executable: Optional[str] = None): 18 | """ 19 | Initialize a new `PackageManager`. 20 | 21 | Args: 22 | executable: 23 | An optional local filesystem path to the underlying package manager executable 24 | that should be used for running commands. If none is provided, the executable 25 | is determined by the current environment. 26 | 27 | Raises: 28 | UnsupportedVersionError: 29 | Implementors should raise this error when the underlying executable has an 30 | unsupported version. 31 | """ 32 | pass 33 | 34 | @classmethod 35 | @abstractmethod 36 | def name(cls) -> str: 37 | """ 38 | Return the name of the package manager, the standard fixed token by which 39 | it is invoked on the command line. 40 | """ 41 | pass 42 | 43 | @classmethod 44 | @abstractmethod 45 | def ecosystem(cls) -> ECOSYSTEM: 46 | """ 47 | Return the fixed package ecosystem the package manager is for. 48 | """ 49 | pass 50 | 51 | @abstractmethod 52 | def executable(self) -> str: 53 | """ 54 | Return the local filesystem path to the package manager executable. 55 | """ 56 | pass 57 | 58 | @abstractmethod 59 | def run_command(self, command: list[str]): 60 | """ 61 | Run the given package manager command. 62 | 63 | Args: 64 | command: The package manager command to be run. 65 | """ 66 | pass 67 | 68 | @abstractmethod 69 | def resolve_install_targets(self, command: list[str]) -> list[Package]: 70 | """ 71 | Resolve the package targets that would be installed if the given package 72 | manager command were run (without running it). 73 | 74 | Args: 75 | command: The package manager command whose installation targets are to be resolved. 76 | 77 | Returns: 78 | A `list[Package]` representing the package targets that would be installed 79 | if `command` were run. 80 | """ 81 | pass 82 | 83 | 84 | class UnsupportedVersionError(Exception): 85 | """ 86 | An exception that occurs when an attempt is made to initialize a `PackageManager` 87 | with an unsupported version of the underlying executable. Supply-Chain Firewall 88 | handles this exception gracefully, emitting an error log that states what occurred 89 | along with any error message attached to the `UnsupportedVersionError` exception. 90 | """ 91 | pass 92 | -------------------------------------------------------------------------------- /scfw/package_managers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides an interface for obtaining `PackageManager` instances needed for runs 3 | of Supply-Chain Firewall. 4 | """ 5 | 6 | from typing import Optional 7 | 8 | from scfw.package_manager import PackageManager 9 | from scfw.package_managers.npm import Npm 10 | from scfw.package_managers.pip import Pip 11 | from scfw.package_managers.poetry import Poetry 12 | 13 | SUPPORTED_PACKAGE_MANAGERS = { 14 | Npm.name(), 15 | Pip.name(), 16 | Poetry.name(), 17 | } 18 | """ 19 | Contains the command line names of supported package managers. 20 | """ 21 | 22 | 23 | def get_package_manager(command: list[str], executable: Optional[str] = None) -> PackageManager: 24 | """ 25 | Return a `PackageManager` corresponding to the given command line. 26 | 27 | Args: 28 | command: The command line of the desired command as provided to Supply-Chain Firewall. 29 | executable: An optional executable to use to initialize the returned `PackageManager`. 30 | 31 | Returns: 32 | A `PackageManager` corresponding to `command` and initialized from `executable`. 33 | 34 | Raises: 35 | ValueError: An empty or unsupported package manager command line was provided. 36 | """ 37 | if not command: 38 | raise ValueError("Missing package manager command") 39 | 40 | if command[0] == Npm.name(): 41 | return Npm(executable) 42 | if command[0] == Pip.name(): 43 | return Pip(executable) 44 | if command[0] == Poetry.name(): 45 | return Poetry(executable) 46 | 47 | raise ValueError(f"Unsupported package manager '{command[0]}'") 48 | -------------------------------------------------------------------------------- /scfw/package_managers/npm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a `PackageManager` representation of `npm`. 3 | """ 4 | 5 | import logging 6 | import os 7 | import shutil 8 | import subprocess 9 | from typing import Optional 10 | 11 | from packaging.version import InvalidVersion, Version, parse as version_parse 12 | 13 | from scfw.ecosystem import ECOSYSTEM 14 | from scfw.package import Package 15 | from scfw.package_manager import PackageManager, UnsupportedVersionError 16 | 17 | _log = logging.getLogger(__name__) 18 | 19 | MIN_NPM_VERSION = version_parse("7.0.0") 20 | 21 | 22 | class Npm(PackageManager): 23 | """ 24 | A `PackageManager` representation of `npm`. 25 | """ 26 | def __init__(self, executable: Optional[str] = None): 27 | """ 28 | Initialize a new `Npm` instance. 29 | 30 | Args: 31 | executable: 32 | An optional path in the local filesystem to the `npm` executable to use. 33 | If not provided, this value is determined by the current environment. 34 | 35 | Raises: 36 | RuntimeError: A valid executable could not be resolved. 37 | UnsupportedVersionError: The underlying `npm` executable is of an unsupported version. 38 | """ 39 | def get_npm_version(executable: str) -> Optional[Version]: 40 | try: 41 | # All supported versions adhere to this format 42 | npm_version = subprocess.run([executable, "--version"], check=True, text=True, capture_output=True) 43 | return version_parse(npm_version.stdout.strip()) 44 | except InvalidVersion: 45 | return None 46 | 47 | executable = executable if executable else shutil.which(self.name()) 48 | if not executable: 49 | raise RuntimeError("Failed to resolve local npm executable") 50 | if not os.path.isfile(executable): 51 | raise RuntimeError(f"Path '{executable}' does not correspond to a regular file") 52 | 53 | npm_version = get_npm_version(executable) 54 | if not npm_version or npm_version < MIN_NPM_VERSION: 55 | raise UnsupportedVersionError(f"npm before v{MIN_NPM_VERSION} is not supported") 56 | 57 | self._executable = executable 58 | 59 | @classmethod 60 | def name(cls) -> str: 61 | """ 62 | Return the token for invoking `npm` on the command line. 63 | """ 64 | return "npm" 65 | 66 | @classmethod 67 | def ecosystem(cls) -> ECOSYSTEM: 68 | """ 69 | Return the ecosystem of packages managed by `npm`. 70 | """ 71 | return ECOSYSTEM.Npm 72 | 73 | def executable(self) -> str: 74 | """ 75 | Return the local filesystem path to the underlying `npm` executable. 76 | """ 77 | return self._executable 78 | 79 | def run_command(self, command: list[str]): 80 | """ 81 | Run an `npm` command. 82 | 83 | Args: 84 | command: A `list[str]` containing an `npm` command to execute. 85 | 86 | Raises: 87 | ValueError: The given `command` is empty or not a valid `npm` command. 88 | """ 89 | subprocess.run(self._normalize_command(command)) 90 | 91 | def resolve_install_targets(self, command: list[str]) -> list[Package]: 92 | """ 93 | Resolve the installation targets of the given `npm` command. 94 | 95 | Args: 96 | command: 97 | A `list[str]` representing an `npm` command whose installation targets 98 | are to be resolved. 99 | 100 | Returns: 101 | A `list[Package]` representing the package targets that would be installed 102 | if `command` were run. 103 | 104 | Raises: 105 | ValueError: Failed to parse an installation target. 106 | """ 107 | def is_install_command(command: list[str]) -> bool: 108 | # https://docs.npmjs.com/cli/v10/commands/npm-install 109 | install_aliases = { 110 | "install", "add", "i", "in", "ins", "inst", "insta", "instal", "isnt", "isnta", "isntal", "isntall" 111 | } 112 | return any(alias in command for alias in install_aliases) 113 | 114 | def is_place_dep_line(line: str) -> bool: 115 | # The "placeDep" log lines describe a new dependency added to the 116 | # dependency tree being constructed by an installish command 117 | return "placeDep" in line 118 | 119 | def line_to_dependency(line: str) -> str: 120 | # Each added dependency is always the fifth token in its log line 121 | return line.split()[4] 122 | 123 | def str_to_package(s: str) -> Package: 124 | name, sep, version = s.rpartition('@') 125 | if version == s or (sep and not name): 126 | raise ValueError("Failed to parse npm installation target") 127 | return Package(ECOSYSTEM.Npm, name, version) 128 | 129 | command = self._normalize_command(command) 130 | 131 | # For now, allow all non-`install` commands 132 | if not is_install_command(command): 133 | return [] 134 | 135 | # The presence of these options prevents the install command from running 136 | if any(opt in command for opt in {"-h", "--help", "--dry-run"}): 137 | return [] 138 | 139 | try: 140 | # Compute the set of dependencies added by the install command 141 | dry_run_command = command + ["--dry-run", "--loglevel", "silly"] 142 | dry_run = subprocess.run(dry_run_command, check=True, text=True, capture_output=True) 143 | dependencies = map(line_to_dependency, filter(is_place_dep_line, dry_run.stderr.strip().split('\n'))) 144 | except subprocess.CalledProcessError: 145 | # An erroring command does not install anything 146 | _log.info("Encountered an error while resolving npm installation targets") 147 | return [] 148 | 149 | try: 150 | # List targets already installed in the npm environment 151 | list_command = [self.executable(), "list", "--all"] 152 | installed = subprocess.run(list_command, check=True, text=True, capture_output=True).stdout 153 | except subprocess.CalledProcessError: 154 | # If this operation fails, rather than blocking, assume nothing is installed 155 | # This has the effect of treating all dependencies like installation targets 156 | _log.warning( 157 | "Failed to list installed npm packages: treating all dependencies as installation targets" 158 | ) 159 | installed = "" 160 | 161 | # The installation targets are the dependencies that are not already installed 162 | targets = filter(lambda dep: dep not in installed, dependencies) 163 | 164 | return list(map(str_to_package, targets)) 165 | 166 | def _normalize_command(self, command: list[str]) -> list[str]: 167 | """ 168 | Normalize an `npm` command. 169 | 170 | Args: 171 | command: A `list[str]` containing an `npm` command line. 172 | 173 | Returns: 174 | The equivalent but normalized form of `command` with the initial `npm` 175 | token replaced with the local filesystem path to `self.executable()`. 176 | 177 | Raises: 178 | ValueError: The given `command` is empty or not a valid `npm` command. 179 | """ 180 | if not command: 181 | raise ValueError("Received empty npm command line") 182 | if command[0] != self.name(): 183 | raise ValueError("Received invalid npm command line") 184 | 185 | return [self._executable] + command[1:] 186 | -------------------------------------------------------------------------------- /scfw/package_managers/pip.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a `PackageManager` representation of `pip`. 3 | """ 4 | 5 | import json 6 | import logging 7 | import os 8 | import shutil 9 | import subprocess 10 | from typing import Optional 11 | 12 | from packaging.version import InvalidVersion, Version, parse as version_parse 13 | 14 | from scfw.ecosystem import ECOSYSTEM 15 | from scfw.package import Package 16 | from scfw.package_manager import PackageManager, UnsupportedVersionError 17 | 18 | _log = logging.getLogger(__name__) 19 | 20 | MIN_PIP_VERSION = version_parse("22.2") 21 | 22 | 23 | class Pip(PackageManager): 24 | """ 25 | A `PackageManager` representation of `pip`. 26 | """ 27 | def __init__(self, executable: Optional[str] = None): 28 | """ 29 | Initialize a new `Pip` instance. 30 | 31 | Args: 32 | executable: 33 | An optional path in the local filesystem to the Python executable 34 | to use for running `pip` as a module. If not provided, this value 35 | is determined by the current environment. 36 | 37 | Raises: 38 | RuntimeError: A valid executable could not be resolved. 39 | UnsupportedVersionError: The underlying `pip` executable is of an unsupported version. 40 | """ 41 | def get_python_executable() -> Optional[str]: 42 | # Explicitly checking whether we are in a venv circumvents issues 43 | # caused by pyenv shims stomping the PATH with its own directories 44 | venv = os.environ.get("VIRTUAL_ENV") 45 | return os.path.join(venv, "bin/python") if venv else shutil.which("python") 46 | 47 | def get_pip_version(executable: str) -> Optional[Version]: 48 | try: 49 | pip_version = subprocess.run( 50 | [executable, "-m", "pip", "--version"], 51 | check=True, 52 | text=True, 53 | capture_output=True 54 | ) 55 | # All supported versions adhere to this format 56 | version_str = pip_version.stdout.split()[1] 57 | return version_parse(version_str) 58 | except IndexError: 59 | return None 60 | except InvalidVersion: 61 | return None 62 | 63 | executable = executable if executable else get_python_executable() 64 | if not executable: 65 | raise RuntimeError("Failed to resolve local Python executable") 66 | if not os.path.isfile(executable): 67 | raise RuntimeError(f"Path '{executable}' does not correspond to a regular file") 68 | 69 | pip_version = get_pip_version(executable) 70 | if not pip_version or pip_version < MIN_PIP_VERSION: 71 | raise UnsupportedVersionError(f"pip before v{MIN_PIP_VERSION} is not supported") 72 | 73 | self._executable = executable 74 | 75 | @classmethod 76 | def name(cls) -> str: 77 | """ 78 | Return the token for invoking `pip` on the command line. 79 | """ 80 | return "pip" 81 | 82 | @classmethod 83 | def ecosystem(cls) -> ECOSYSTEM: 84 | """ 85 | Return the ecosystem of packages managed by `pip`. 86 | """ 87 | return ECOSYSTEM.PyPI 88 | 89 | def executable(self) -> str: 90 | """ 91 | Return the local filesystem path to the underlying `pip` executable. 92 | """ 93 | return self._executable 94 | 95 | def run_command(self, command: list[str]): 96 | """ 97 | Run a `pip` command. 98 | 99 | Args: 100 | command: A `list[str]` containing a `pip` command to execute. 101 | 102 | Raises: 103 | ValueError: The given `command` is empty or not a valid `pip` command. 104 | """ 105 | subprocess.run(self._normalize_command(command)) 106 | 107 | def resolve_install_targets(self, command: list[str]) -> list[Package]: 108 | """ 109 | Resolve the installation targets of the given `pip` command. 110 | 111 | Args: 112 | command: 113 | A `list[str]` representing a `pip` command whose installation targets 114 | are to be resolved. 115 | 116 | Returns: 117 | A `list[Package]` representing the package targets that would be installed 118 | if `command` were run. 119 | 120 | Raises: 121 | ValueError: The dry-run output did not have the required format. 122 | """ 123 | def report_to_install_target(install_report: dict) -> Package: 124 | if not (metadata := install_report.get("metadata")): 125 | raise ValueError("Missing metadata for pip installation target") 126 | if not (name := metadata.get("name")): 127 | raise ValueError("Missing name for pip installation target") 128 | if not (version := metadata.get("version")): 129 | raise ValueError("Missing version for pip installation target") 130 | return Package(ECOSYSTEM.PyPI, name, version) 131 | 132 | command = self._normalize_command(command) 133 | 134 | # pip only installs or upgrades packages via the `pip install` subcommand 135 | # If `install` is not present, the command is automatically safe to run 136 | # If `install` is present with any of the below options, a usage or error 137 | # message is printed or a dry-run install occurs: nothing will be installed 138 | if "install" not in command or any(opt in command for opt in {"-h", "--help", "--dry-run"}): 139 | return [] 140 | 141 | # Otherwise, this is probably a live `pip install` command 142 | # To be certain, we would need to write a full parser for pip 143 | try: 144 | dry_run_command = command + ["--dry-run", "--quiet", "--report", "-"] 145 | dry_run = subprocess.run(dry_run_command, check=True, text=True, capture_output=True) 146 | install_reports = json.loads(dry_run.stdout).get("install", []) 147 | return list(map(report_to_install_target, install_reports)) 148 | except subprocess.CalledProcessError: 149 | # An error must have resulted from the given pip command 150 | # As nothing will be installed in this case, allow the command 151 | _log.info("Encountered an error while resolving pip installation targets") 152 | return [] 153 | 154 | def _normalize_command(self, command: list[str]) -> list[str]: 155 | """ 156 | Normalize a `pip` command. 157 | 158 | Args: 159 | command: 160 | A `list[str]` containing a "pure" `pip` command line (i.e., one 161 | that starts with `"pip"`) 162 | 163 | Returns: 164 | The equivalent but normalized form of `command` permitting Python 165 | module invocation of `pip`. 166 | 167 | Raises: 168 | ValueError: The given `command` is empty or not a valid `pip` command. 169 | """ 170 | if not command: 171 | raise ValueError("Received empty pip command line") 172 | if command[0] != self.name(): 173 | raise ValueError("Received invalid pip command line") 174 | 175 | return [self._executable, "-m"] + command 176 | -------------------------------------------------------------------------------- /scfw/package_managers/poetry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a `PackageManager` representation of `poetry`. 3 | """ 4 | 5 | import logging 6 | import os 7 | import re 8 | import shutil 9 | import subprocess 10 | from typing import Optional 11 | 12 | from packaging.version import InvalidVersion, Version, parse as version_parse 13 | 14 | from scfw.ecosystem import ECOSYSTEM 15 | from scfw.package import Package 16 | from scfw.package_manager import PackageManager, UnsupportedVersionError 17 | 18 | _log = logging.getLogger(__name__) 19 | 20 | MIN_POETRY_VERSION = version_parse("1.7.0") 21 | 22 | INSPECTED_SUBCOMMANDS = {"add", "install", "sync", "update"} 23 | 24 | 25 | class Poetry(PackageManager): 26 | """ 27 | A `PackageManager` representation of `poetry`. 28 | """ 29 | def __init__(self, executable: Optional[str] = None): 30 | """ 31 | Initialize a new `Poetry` instance. 32 | 33 | Args: 34 | executable: 35 | An optional path in the local filesystem to the `poetry` executable to use. 36 | If not provided, this value is determined by the current environment. 37 | 38 | Raises: 39 | RuntimeError: A valid executable could not be resolved. 40 | UnsupportedVersionError: The underlying `poetry` executable is of an unsupported version. 41 | """ 42 | def get_poetry_version(executable: str) -> Optional[Version]: 43 | try: 44 | # All supported versions adhere to this format 45 | poetry_version = subprocess.run([executable, "--version"], check=True, text=True, capture_output=True) 46 | match = re.search(r"Poetry \(version (.*)\)", poetry_version.stdout.strip()) 47 | return version_parse(match.group(1)) if match else None 48 | except InvalidVersion: 49 | return None 50 | 51 | executable = executable if executable else shutil.which(self.name()) 52 | if not executable: 53 | raise RuntimeError("Failed to resolve local poetry executable") 54 | if not os.path.isfile(executable): 55 | raise RuntimeError(f"Path '{executable}' does not correspond to a regular file") 56 | 57 | poetry_version = get_poetry_version(executable) 58 | if not poetry_version or poetry_version < MIN_POETRY_VERSION: 59 | raise UnsupportedVersionError(f"Poetry before v{MIN_POETRY_VERSION} is not supported") 60 | 61 | self._executable = executable 62 | 63 | @classmethod 64 | def name(cls) -> str: 65 | """ 66 | Return the token for invoking `poetry` on the command line. 67 | """ 68 | return "poetry" 69 | 70 | @classmethod 71 | def ecosystem(cls) -> ECOSYSTEM: 72 | """ 73 | Return the ecosystem of packages managed by `poetry`. 74 | """ 75 | return ECOSYSTEM.PyPI 76 | 77 | def executable(self) -> str: 78 | """ 79 | Return the local filesystem path to the underlying `poetry` executable. 80 | """ 81 | return self._executable 82 | 83 | def run_command(self, command: list[str]): 84 | """ 85 | Run a `poetry` command. 86 | 87 | Args: 88 | command: A `list[str]` containing a `poetry` command to execute. 89 | 90 | Raises: 91 | ValueError: The given `command` is empty or not a valid `poetry` command. 92 | """ 93 | subprocess.run(self._normalize_command(command)) 94 | 95 | def resolve_install_targets(self, command: list[str]) -> list[Package]: 96 | """ 97 | Resolve the installation targets of the given `poetry` command. 98 | 99 | Args: 100 | command: 101 | A `list[str]` representing a `poetry` command whose installation targets 102 | are to be resolved. 103 | 104 | Returns: 105 | A `list[Package]` representing the package targets that would be installed 106 | if `command` were run. 107 | 108 | Raises: 109 | ValueError: The given `command` is empty or not a valid `poetry` command. 110 | """ 111 | def get_target_version(version_spec: str) -> str: 112 | _, arrow, new_version = version_spec.partition(" -> ") 113 | version, _, _ = version_spec.partition(' ') 114 | return get_target_version(new_version) if arrow else version 115 | 116 | def line_to_package(line: str) -> Optional[Package]: 117 | # All supported versions adhere to this format 118 | pattern = r"(Installing|Updating|Downgrading) (?:the current project: )?(.*) \((.*)\)" 119 | if "Skipped" not in line and (match := re.search(pattern, line.strip())): 120 | return Package(self.ecosystem(), match.group(2), get_target_version(match.group(3))) 121 | return None 122 | 123 | command = self._normalize_command(command) 124 | 125 | if not any(subcommand in command for subcommand in INSPECTED_SUBCOMMANDS): 126 | return [] 127 | 128 | # The presence of these options prevents the command from running 129 | if any(opt in command for opt in {"-V", "--version", "-h", "--help", "--dry-run"}): 130 | return [] 131 | 132 | try: 133 | # Compute installation targets: new dependencies and updates/downgrades of existing ones 134 | dry_run = subprocess.run(command + ["--dry-run"], check=True, text=True, capture_output=True) 135 | return list(filter(None, map(line_to_package, dry_run.stdout.split('\n')))) 136 | except subprocess.CalledProcessError: 137 | # An erroring command does not install anything 138 | _log.info("Encountered an error while resolving poetry installation targets") 139 | return [] 140 | 141 | def _normalize_command(self, command: list[str]) -> list[str]: 142 | """ 143 | Normalize a `poetry` command. 144 | 145 | Args: 146 | command: A `list[str]` containing a `poetry` command line. 147 | 148 | Returns: 149 | The equivalent but normalized form of `command` with the initial `poetry` 150 | token replaced with the local filesystem path to `self.executable()`. 151 | 152 | Raises: 153 | ValueError: The given `command` is empty or not a valid `poetry` command. 154 | """ 155 | if not command: 156 | raise ValueError("Received empty poetry command line") 157 | if command[0] != self.name(): 158 | raise ValueError("Received invalid poetry command line") 159 | 160 | return [self._executable] + command[1:] 161 | -------------------------------------------------------------------------------- /scfw/parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | A drop-in replacement for `argparse.ArgumentParser`. 3 | """ 4 | 5 | import argparse 6 | 7 | 8 | class ArgumentParser(argparse.ArgumentParser): 9 | """ 10 | A drop-in replacement for `argparse.ArgumentParser` with a patched 11 | implementation of the latter's `exit_on_error` behavior. 12 | 13 | See https://github.com/python/cpython/issues/103498 for more info. 14 | """ 15 | def error(self, message): 16 | """ 17 | Handle a parsing error. 18 | 19 | Args: 20 | message: The error message. 21 | """ 22 | raise argparse.ArgumentError(None, message) 23 | -------------------------------------------------------------------------------- /scfw/report.py: -------------------------------------------------------------------------------- 1 | """ 2 | A class for structuring and displaying the results of package verification. 3 | """ 4 | 5 | from collections.abc import Iterable 6 | from typing import Optional 7 | 8 | from scfw.package import Package 9 | 10 | 11 | class VerificationReport: 12 | """ 13 | A structured report containing findings resulting from package verification. 14 | """ 15 | def __init__(self) -> None: 16 | """ 17 | Initialize a new, empty `VerificationReport`. 18 | """ 19 | self._report: dict[Package, list[str]] = {} 20 | 21 | def __len__(self) -> int: 22 | """ 23 | Return the number of entries in the report. 24 | """ 25 | return len(self._report) 26 | 27 | def __str__(self) -> str: 28 | """ 29 | Return a human-readable version of a verification report. 30 | 31 | Returns: 32 | A `str` containing the formatted verification report. 33 | """ 34 | def show_line(linenum: int, line: str) -> str: 35 | return (f" - {line}" if linenum == 0 else f" {line}") 36 | 37 | def show_finding(finding: str) -> str: 38 | return '\n'.join( 39 | show_line(linenum, line) for linenum, line in enumerate(finding.split('\n')) 40 | ) 41 | 42 | def show_findings(package: Package, findings: list[str]) -> str: 43 | return f"Package {package}:\n" + '\n'.join(map(show_finding, findings)) 44 | 45 | return '\n'.join( 46 | show_findings(package, findings) for package, findings in self._report.items() 47 | ) 48 | 49 | def get(self, package: Package) -> Optional[list[str]]: 50 | """ 51 | Get the findings for the given package. 52 | 53 | Args: 54 | package: The `Package` to look up in the report. 55 | 56 | Returns: 57 | The reported findings for `package` or `None` if it is not present. 58 | """ 59 | return self._report.get(package) 60 | 61 | def insert(self, package: Package, finding: str) -> None: 62 | """ 63 | Insert the given package and finding into the report. 64 | 65 | Args: 66 | package: The `Package` to insert into the report. 67 | findings: The finding being reported for `package`. 68 | """ 69 | if package in self._report: 70 | self._report[package].append(finding) 71 | else: 72 | self._report[package] = [finding] 73 | 74 | def packages(self) -> Iterable[Package]: 75 | """ 76 | Return an iterator over `Package` mentioned in the report. 77 | """ 78 | return (package for package in self._report) 79 | -------------------------------------------------------------------------------- /scfw/verifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a base class for package verifiers. 3 | """ 4 | 5 | from abc import (ABCMeta, abstractmethod) 6 | from enum import Enum 7 | 8 | from scfw.package import Package 9 | 10 | 11 | class FindingSeverity(Enum): 12 | """ 13 | A hierarchy of severity levels for package verifier findings. 14 | 15 | Package verifiers attach severity levels to their findings in order to direct 16 | Supply-Chain Firewall to take the correct action with respect to blocking or 17 | warning on a package manager command. 18 | 19 | A `CRITICAL` finding causes Supply-Chain Firewall to block. A `WARNING` finding 20 | prompts it to seek confirmation from the user before running the command. 21 | """ 22 | CRITICAL = "CRITICAL" 23 | WARNING = "WARNING" 24 | 25 | 26 | class PackageVerifier(metaclass=ABCMeta): 27 | """ 28 | Abstract base class for package verifiers. 29 | 30 | Each package verifier should implement a service for verifying packages in all 31 | supported ecosystems against a single reputable source of data on vulnerable and 32 | malicious open source packages. 33 | """ 34 | @classmethod 35 | @abstractmethod 36 | def name(cls) -> str: 37 | """ 38 | Return the verifier's name. 39 | 40 | Returns: 41 | A constant, short, descriptive name `str` identifying the verifier. 42 | """ 43 | pass 44 | 45 | @abstractmethod 46 | def verify(self, package: Package) -> list[tuple[FindingSeverity, str]]: 47 | """ 48 | Verify the given package. 49 | 50 | Args: 51 | package: The `Package` to verify. 52 | 53 | Returns: 54 | A `list[tuple[FindingSeverity, str]]` of all findings for the given package 55 | reported by the backing data source, each tagged with a severity level. 56 | 57 | Each `str` in this list should be a concise summary of a single finding and 58 | would ideally provide a link or handle to more information about that finding 59 | for the benefit of the user. 60 | """ 61 | pass 62 | -------------------------------------------------------------------------------- /scfw/verifiers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exports the currently discoverable set of package verifiers for use in 3 | Supply-Chain Firewall. 4 | 5 | Two package verifiers ship with Supply-Chain Firewall by default: one for 6 | Datadog Security Research's malicious packages dataset and one for OSV.dev's 7 | advisory database. Users of Supply-Chain Firewall may additionally provide 8 | custom verifiers representing alternative sources of truth. 9 | 10 | Supply-Chain Firewall discovers verifiers at runtime via the following protocol. 11 | The module implementing the custom verifier must contain a function with the 12 | following name and signature: 13 | 14 | ``` 15 | def load_verifier() -> PackageVerifier 16 | ``` 17 | 18 | This `load_verifier` function should return an instance of the custom verifier. 19 | The module may then be placed in the same directory as this source file for 20 | runtime import. Make sure to reinstall Supply-Chain Firewall after doing so. 21 | """ 22 | 23 | import concurrent.futures as cf 24 | import importlib 25 | import itertools 26 | import logging 27 | import os 28 | import pkgutil 29 | 30 | from scfw.package import Package 31 | from scfw.report import VerificationReport 32 | from scfw.verifier import FindingSeverity 33 | 34 | _log = logging.getLogger(__name__) 35 | 36 | 37 | class FirewallVerifiers: 38 | """ 39 | Provides a simple interface to verifying packages against the set of currently 40 | discoverable verifiers. 41 | """ 42 | def __init__(self): 43 | """ 44 | Initialize a `FirewallVerifiers` from currently discoverable package verifiers. 45 | """ 46 | self._verifiers = [] 47 | 48 | for _, module, _ in pkgutil.iter_modules([os.path.dirname(__file__)]): 49 | try: 50 | verifier = importlib.import_module(f".{module}", package=__name__).load_verifier() 51 | self._verifiers.append(verifier) 52 | except ModuleNotFoundError: 53 | _log.warning(f"Failed to load module {module} while collecting package verifiers") 54 | except AttributeError: 55 | _log.warning(f"Module {module} does not export a package verifier") 56 | 57 | def names(self) -> list[str]: 58 | """ 59 | Return the names of discovered package verifiers. 60 | """ 61 | return [verifier.name() for verifier in self._verifiers] 62 | 63 | def verify_packages(self, packages: list[Package]) -> dict[FindingSeverity, VerificationReport]: 64 | """ 65 | Verify a set of packages against all discovered verifiers. 66 | 67 | Args: 68 | packages: The set of `Package` to verify. 69 | 70 | Returns: 71 | A set of severity-ranked verification reports resulting from verifying 72 | `packages` against all discovered verifiers. 73 | """ 74 | reports: dict[FindingSeverity, VerificationReport] = {} 75 | 76 | with cf.ThreadPoolExecutor() as executor: 77 | task_results = { 78 | executor.submit(lambda v, t: v.verify(t), verifier, package): (verifier.name(), package) 79 | for verifier, package in itertools.product(self._verifiers, packages) 80 | } 81 | for future in cf.as_completed(task_results): 82 | verifier, package = task_results[future] 83 | if (findings := future.result()): 84 | _log.info(f"Verifier {verifier} had findings for package {package}") 85 | for severity, finding in findings: 86 | if severity not in reports: 87 | reports[severity] = VerificationReport() 88 | reports[severity].insert(package, finding) 89 | else: 90 | _log.info(f"Verifier {verifier} had no findings for package {package}") 91 | 92 | _log.info("Verification of packages complete") 93 | return reports 94 | -------------------------------------------------------------------------------- /scfw/verifiers/dd_verifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines a package verifier for Datadog Security Research's malicious packages dataset. 3 | """ 4 | 5 | import requests 6 | 7 | from scfw.ecosystem import ECOSYSTEM 8 | from scfw.package import Package 9 | from scfw.verifier import FindingSeverity, PackageVerifier 10 | 11 | _DD_DATASET_SAMPLES_URL = "https://raw.githubusercontent.com/DataDog/malicious-software-packages-dataset/main/samples" 12 | 13 | 14 | class DatadogMaliciousPackagesVerifier(PackageVerifier): 15 | """ 16 | A `PackageVerifier` for Datadog Security Research's malicious packages dataset. 17 | """ 18 | def __init__(self): 19 | """ 20 | Initialize a new `DatadogMaliciousPackagesVerifier`. 21 | 22 | Raises: 23 | requests.HTTPError: An error occurred while fetching a manifest file. 24 | """ 25 | def download_manifest(ecosystem: str) -> dict[str, list[str]]: 26 | manifest_url = f"{_DD_DATASET_SAMPLES_URL}/{ecosystem}/manifest.json" 27 | request = requests.get(manifest_url, timeout=5) 28 | request.raise_for_status() 29 | return request.json() 30 | 31 | self._pypi_manifest = download_manifest("pypi") 32 | self._npm_manifest = download_manifest("npm") 33 | 34 | @classmethod 35 | def name(cls) -> str: 36 | """ 37 | Return the `DatadogMaliciousPackagesVerifier` name string. 38 | 39 | Returns: 40 | The class' constant name string: `"DatadogMaliciousPackagesVerifier"`. 41 | """ 42 | return "DatadogMaliciousPackagesVerifier" 43 | 44 | def verify(self, package: Package) -> list[tuple[FindingSeverity, str]]: 45 | """ 46 | Determine whether the given package is malicious by consulting the dataset's manifests. 47 | 48 | Args: 49 | package: The `Package` to verify. 50 | 51 | Returns: 52 | A list containing any findings for the given package, obtained by checking for its 53 | presence in the dataset's manifests. Only a single `CRITICAL` finding to this effect 54 | is present in this case. 55 | """ 56 | match package.ecosystem: 57 | case ECOSYSTEM.Npm: 58 | manifest = self._npm_manifest 59 | case ECOSYSTEM.PyPI: 60 | manifest = self._pypi_manifest 61 | 62 | # We take the more conservative approach of ignoring version strings when 63 | # deciding whether the given package is malicious 64 | if package.name in manifest: 65 | return [ 66 | ( 67 | FindingSeverity.CRITICAL, 68 | f"Datadog Security Research has determined that package {package.name} is malicious" 69 | ) 70 | ] 71 | else: 72 | return [] 73 | 74 | 75 | def load_verifier() -> PackageVerifier: 76 | """ 77 | Export `DatadogMaliciousPackagesVerifier` for discovery by Supply-Chain Firewall. 78 | 79 | Returns: 80 | A `DatadogMaliciousPackagesVerifier` for use in a run of Supply-Chain Firewall. 81 | """ 82 | return DatadogMaliciousPackagesVerifier() 83 | -------------------------------------------------------------------------------- /scfw/verifiers/osv_verifier/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines a package verifier for the OSV.dev advisory database. 3 | """ 4 | 5 | import functools 6 | import logging 7 | 8 | import requests 9 | 10 | from scfw.package import Package 11 | from scfw.verifier import FindingSeverity, PackageVerifier 12 | from scfw.verifiers.osv_verifier.osv_advisory import OsvAdvisory 13 | 14 | _log = logging.getLogger(__name__) 15 | 16 | _OSV_DEV_QUERY_URL = "https://api.osv.dev/v1/query" 17 | _OSV_DEV_VULN_URL_PREFIX = "https://osv.dev/vulnerability" 18 | _OSV_DEV_LIST_URL_PREFIX = "https://osv.dev/list" 19 | 20 | 21 | class OsvVerifier(PackageVerifier): 22 | """ 23 | A `PackageVerifier` for the OSV.dev advisory database. 24 | """ 25 | @classmethod 26 | def name(cls) -> str: 27 | """ 28 | Return the `OsvVerifier` name string. 29 | 30 | Returns: 31 | The class' constant name string: `"OsvVerifier"`. 32 | """ 33 | return "OsvVerifier" 34 | 35 | def verify(self, package: Package) -> list[tuple[FindingSeverity, str]]: 36 | """ 37 | Query a given package against the OSV.dev database. 38 | 39 | Args: 40 | package: The `Package` to query. 41 | 42 | Returns: 43 | A list containing any findings for the given package, obtained by querying 44 | the OSV.dev API. 45 | 46 | OSV.dev advisories with `MAL` IDs are treated as `CRITICAL` findings and all 47 | others are treated as `WARNING`. *It is very important to note that most but 48 | **not all** OSV.dev malicious package advisories have `MAL` IDs.* 49 | 50 | Raises: 51 | requests.HTTPError: 52 | An error occurred while querying a package against the OSV.dev API. 53 | """ 54 | def finding(osv: OsvAdvisory) -> str: 55 | kind = "malicious package " if osv.id.startswith("MAL") else "" 56 | severity_tag = f"[{osv.severity}] " if osv.severity else "" 57 | return ( 58 | f"An OSV.dev {kind}advisory exists for package {package}:\n" 59 | f" * {severity_tag}{_OSV_DEV_VULN_URL_PREFIX}/{osv.id}" 60 | ) 61 | 62 | def error_message(e: str) -> str: 63 | url = f"{_OSV_DEV_LIST_URL_PREFIX}?q={package.name}&ecosystem={str(package.ecosystem)}" 64 | return ( 65 | f"Failed to verify package against OSV.dev: {e if e else 'An unspecified error occurred'}.\n" 66 | f"Before proceeding, please check for OSV.dev advisories related to this package.\n" 67 | f"DO NOT PROCEED if it has an advisory with a MAL ID: it is very likely malicious.\n" 68 | f" * {url}" 69 | ) 70 | 71 | vulns = [] 72 | 73 | query = { 74 | "version": package.version, 75 | "package": { 76 | "name": package.name, 77 | "ecosystem": str(package.ecosystem) 78 | } 79 | } 80 | 81 | try: 82 | while True: 83 | # The OSV.dev API is sometimes quite slow, hence the generous timeout 84 | request = requests.post(_OSV_DEV_QUERY_URL, json=query, timeout=10) 85 | request.raise_for_status() 86 | response = request.json() 87 | 88 | if (response_vulns := response.get("vulns")): 89 | vulns.extend(response_vulns) 90 | 91 | query["page_token"] = response.get("next_page_token") 92 | 93 | if not query["page_token"]: 94 | break 95 | 96 | if not vulns: 97 | return [] 98 | 99 | osvs = set(map(OsvAdvisory.from_json, filter(lambda vuln: vuln.get("id"), vulns))) 100 | mal_osvs = set(filter(lambda osv: osv.id.startswith("MAL"), osvs)) 101 | non_mal_osvs = osvs - mal_osvs 102 | 103 | osv_sort_key = functools.cmp_to_key(OsvAdvisory.compare_severities) 104 | sorted_mal_osvs = sorted(mal_osvs, reverse=True, key=osv_sort_key) 105 | sorted_non_mal_osvs = sorted(non_mal_osvs, reverse=True, key=osv_sort_key) 106 | 107 | return ( 108 | [(FindingSeverity.CRITICAL, finding(osv)) for osv in sorted_mal_osvs] 109 | + [(FindingSeverity.WARNING, finding(osv)) for osv in sorted_non_mal_osvs] 110 | ) 111 | 112 | except requests.exceptions.RequestException as e: 113 | _log.warning(f"Failed to query OSV.dev API: returning WARNING finding for package {package}") 114 | return [(FindingSeverity.WARNING, error_message(str(e)))] 115 | 116 | except Exception as e: 117 | _log.warning(f"Verification failed: returning WARNING finding for package {package}") 118 | return [(FindingSeverity.WARNING, error_message(str(e)))] 119 | 120 | 121 | def load_verifier() -> PackageVerifier: 122 | """ 123 | Export `OsvVerifier` for discovery by Supply-Chain Firewall. 124 | 125 | Returns: 126 | An `OsvVerifier` for use in a run of Supply-Chain Firewall. 127 | """ 128 | return OsvVerifier() 129 | -------------------------------------------------------------------------------- /scfw/verifiers/osv_verifier/osv_advisory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a representation of OSV advisories for use in `OsvVerifier`. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | from enum import Enum 7 | from typing import Optional 8 | from typing_extensions import Self 9 | 10 | from cvss import CVSS2, CVSS3, CVSS4 # type: ignore 11 | 12 | 13 | class Severity(Enum): 14 | """ 15 | Represents the possible severities that an OSV advisory can have. 16 | """ 17 | Non = 0 18 | Low = 1 19 | Medium = 2 20 | High = 3 21 | Critical = 4 22 | 23 | def __lt__(self, other: Self) -> bool: 24 | """ 25 | Compare two `Severity` instances. 26 | 27 | Args: 28 | self: The `Severity` to be compared on the left-hand side 29 | other: The `Severity` to be compared on the right-hand side 30 | 31 | Returns: 32 | A `bool` indicating whether `<` holds between the two given `Severity`. 33 | 34 | Raises: 35 | TypeError: The other argument given was not a `Severity`. 36 | """ 37 | if self.__class__ is not other.__class__: 38 | raise TypeError( 39 | f"'<' not supported between instances of '{self.__class__}' and '{other.__class__}'" 40 | ) 41 | 42 | return self.value < other.value 43 | 44 | def __str__(self) -> str: 45 | """ 46 | Format a `Severity` for printing. 47 | 48 | Returns: 49 | A `str` representing the given `Severity` suitable for printing. 50 | """ 51 | return "None" if self.name == "Non" else self.name 52 | 53 | @classmethod 54 | def from_string(cls, s: str) -> Self: 55 | """ 56 | Convert a string into a `Severity`. 57 | 58 | Args: 59 | s: The `str` to be converted. 60 | 61 | Returns: 62 | The `Severity` referred to by the given string. 63 | 64 | Raises: 65 | ValueError: The given string does not refer to a valid `Severity`. 66 | """ 67 | mappings = {f"{severity}".lower(): severity for severity in cls} 68 | if (severity := mappings.get(s.lower())): 69 | return severity 70 | raise ValueError(f"Invalid severity '{s}'") 71 | 72 | 73 | class OsvSeverityType(str, Enum): 74 | """ 75 | The various severity score types defined in the OSV standard. 76 | """ 77 | CVSS_V2 = "CVSS_V2" 78 | CVSS_V3 = "CVSS_V3" 79 | CVSS_V4 = "CVSS_V4" 80 | Ubuntu = "Ubuntu" 81 | 82 | 83 | @dataclass(eq=True, frozen=True) 84 | class OsvSeverityScore: 85 | """ 86 | A typed severity score used in assigning severities to OSV advisories. 87 | """ 88 | type: OsvSeverityType 89 | score: str 90 | 91 | @classmethod 92 | def from_json(cls, osv_json: dict) -> Self: 93 | """ 94 | Convert a JSON-formatted OSV advisory into an `OsvSeverityScore`. 95 | 96 | Args: 97 | osv_json: The JSON-formatted OSV severity score to be converted. 98 | 99 | Returns: 100 | An `OsvSeverityScore` derived from the content of the given JSON. 101 | 102 | Raises: 103 | ValueError: The severity score was malformed or missing required information. 104 | """ 105 | type = osv_json.get("type") 106 | score = osv_json.get("score") 107 | if type and score: 108 | return cls(type=OsvSeverityType(type), score=score) 109 | raise ValueError("Encountered malformed OSV severity score") 110 | 111 | def severity(self) -> Severity: 112 | """ 113 | Return the `Severity` of the given `OsvSeverityScore`. 114 | 115 | Returns: 116 | The computed `Severity` of the given `OsvSeverityScore`. 117 | """ 118 | match self.type: 119 | case OsvSeverityType.CVSS_V2: 120 | severity_str = CVSS2(self.score).severities()[0] 121 | case OsvSeverityType.CVSS_V3: 122 | severity_str = CVSS3(self.score).severities()[0] 123 | case OsvSeverityType.CVSS_V4: 124 | severity_str = CVSS4(self.score).severity 125 | case OsvSeverityType.Ubuntu: 126 | severity_str = "None" if self.score == "Negligible" else self.score 127 | 128 | return Severity.from_string(severity_str) 129 | 130 | 131 | @dataclass(eq=True, frozen=True) 132 | class OsvAdvisory: 133 | """ 134 | A representation of an OSV advisory containing only the fields relevant to 135 | package verification. 136 | """ 137 | id: str 138 | severity: Optional[Severity] 139 | 140 | @classmethod 141 | def compare_severities(cls, lhs: Self, rhs: Self) -> int: 142 | """ 143 | Compare two `OsvAdvisory` instances on the basis of their severities such that 144 | advisories with no severities are sorted lower than those with severities. 145 | 146 | Args: 147 | self: The `OsvAdvisory` to be compared on the left-hand side 148 | other: The `OsvAdvisory` to be compared on the right-hand side 149 | 150 | Returns: 151 | An `int` indicating whether the first `OsvAdvisory` is less than, equal to 152 | or greater than the second one. 153 | 154 | Raises: 155 | TypeError: One of the given arguments is not an `OsvAdvisory`. 156 | """ 157 | if not (isinstance(lhs, cls) and isinstance(rhs, cls)): 158 | raise TypeError("Received incompatible argument types while comparing OSV severities") 159 | 160 | # A match statement would be more natural here but mypy is not up to it 161 | if lhs.severity == rhs.severity: 162 | result = 0 163 | elif lhs.severity is None: 164 | result = -1 165 | elif rhs.severity is None: 166 | result = 1 167 | elif lhs.severity < rhs.severity: 168 | result = -1 169 | elif rhs.severity < lhs.severity: 170 | result = 1 171 | 172 | return result 173 | 174 | @classmethod 175 | def from_json(cls, osv_json: dict) -> Self: 176 | """ 177 | Convert a JSON-formatted OSV advisory into an `OsvAdvisory`. 178 | 179 | Args: 180 | osv_json: The JSON-formatted OSV advisory to be converted. 181 | 182 | Returns: 183 | An `OsvAdvisory` derived from the content of the given JSON. 184 | 185 | Raises: 186 | ValueError: The advisory was malformed or missing required information. 187 | """ 188 | if (id := osv_json.get("id")): 189 | scores = list(map(OsvSeverityScore.from_json, osv_json.get("severity", []))) 190 | return cls( 191 | id=id, 192 | severity=max(map(lambda score: score.severity(), scores)) if scores else None 193 | ) 194 | raise ValueError("Encountered OSV advisory with missing ID field") 195 | -------------------------------------------------------------------------------- /tests/package_managers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataDog/supply-chain-firewall/6f913e0de04c80884e776e774fc3d255ea4342dd/tests/package_managers/__init__.py -------------------------------------------------------------------------------- /tests/package_managers/test_npm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of npm's command line behavior. 3 | """ 4 | 5 | import packaging.version as version 6 | import pytest 7 | import subprocess 8 | 9 | from scfw.ecosystem import ECOSYSTEM 10 | 11 | from .utils import read_top_packages, select_test_install_target 12 | 13 | 14 | def npm_list() -> str: 15 | """ 16 | Get the current state of packages installed via npm. 17 | """ 18 | return subprocess.run(["npm", "list", "--all"], check=True, text=True, capture_output=True).stdout.lower() 19 | 20 | 21 | INIT_NPM_STATE = npm_list() 22 | """ 23 | Caches the npm installation state before running any tests. 24 | """ 25 | 26 | TEST_TARGET = select_test_install_target(read_top_packages(ECOSYSTEM.Npm), INIT_NPM_STATE) 27 | """ 28 | A fresh (not currently installed) package target to use for testing. 29 | """ 30 | 31 | 32 | def test_npm_version_output(): 33 | """ 34 | Test that `npm --version` has the required format. 35 | """ 36 | version_str = subprocess.run(["npm", "--version"], check=True, text=True, capture_output=True) 37 | version.parse(version_str.stdout.strip()) 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "command_line", 42 | [ 43 | ["npm", "-h", "install", TEST_TARGET], 44 | ["npm", "--help", "install", TEST_TARGET], 45 | ["npm", "--dry-run", "install", TEST_TARGET], 46 | ["npm", "install", "--dry-run", TEST_TARGET], 47 | ["npm", "install", TEST_TARGET, "--dry-run"], 48 | ["npm", "--dry-run", "install", "--dry-run", TEST_TARGET, "--dry-run"], 49 | ["npm", "--non-existent-option", "install", TEST_TARGET, "--dry-run"] 50 | ] 51 | ) 52 | def test_npm_no_change(command_line: list[str]): 53 | """ 54 | Backend function for testing that an `npm` command does not encounter any 55 | errors and does not modify the local `npm` installation state. 56 | """ 57 | subprocess.run(command_line, check=True) 58 | assert npm_list() == INIT_NPM_STATE 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "command_line", 63 | [ 64 | ["npm", "--non-existent-option"], 65 | ["npm", "install", "--dry-run", "!!!a_nonexistent_p@ckage_name"] 66 | ] 67 | ) 68 | def test_npm_no_change_error(command_line: list[str]): 69 | """ 70 | Backend function for testing that an `npm` command raises an error and 71 | does not modify the local `npm` installation state. 72 | """ 73 | with pytest.raises(subprocess.CalledProcessError): 74 | subprocess.run(command_line, check=True) 75 | assert npm_list() == INIT_NPM_STATE 76 | 77 | 78 | def test_npm_loglevel_override(): 79 | """ 80 | Test that all but the last instance of `--loglevel` are ignored by `npm`. 81 | """ 82 | command_line = [ 83 | "npm", "--loglevel", "silent", "install", "--dry-run", TEST_TARGET, "--loglevel", "silly" 84 | ] 85 | p = subprocess.run(command_line, check=True, text=True, capture_output=True) 86 | assert p.stderr 87 | assert "silly" in p.stderr 88 | -------------------------------------------------------------------------------- /tests/package_managers/test_npm_class.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of `Npm`, the `PackageManager` subclass. 3 | """ 4 | 5 | import pytest 6 | 7 | from scfw.ecosystem import ECOSYSTEM 8 | from scfw.package import Package 9 | from scfw.package_managers.npm import Npm 10 | 11 | from .test_npm import INIT_NPM_STATE, TEST_TARGET, npm_list 12 | 13 | PACKAGE_MANAGER = Npm() 14 | """ 15 | Fixed `PackageManager` to use across all tests. 16 | """ 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "command_line,has_targets", 21 | [ 22 | (["npm", "install", TEST_TARGET], True), 23 | (["npm", "-h", "install", TEST_TARGET], False), 24 | (["npm", "--help", "install", TEST_TARGET], False), 25 | (["npm", "install", "-h", TEST_TARGET], False), 26 | (["npm", "install", "--help", TEST_TARGET], False), 27 | (["npm", "--dry-run", "install", TEST_TARGET], False), 28 | (["npm", "install", "--dry-run", TEST_TARGET], False), 29 | (["npm", "--non-existent-option"], False) 30 | ] 31 | ) 32 | def test_npm_command_would_install(command_line: list[str], has_targets: bool): 33 | """ 34 | Backend function for testing that an `Npm.resolve_install_targets` call 35 | either does or does not have install targets and does not modify the local 36 | npm installation state. 37 | """ 38 | targets = PACKAGE_MANAGER.resolve_install_targets(command_line) 39 | if has_targets: 40 | assert targets 41 | else: 42 | assert not targets 43 | assert npm_list() == INIT_NPM_STATE 44 | 45 | 46 | def test_npm_command_would_install_exact(): 47 | """ 48 | Test that `Npm.resolve_install_targets` gives the right answer relative to 49 | an exact top-level installation target and its dependencies. 50 | """ 51 | true_targets = list( 52 | map( 53 | lambda p: Package(ECOSYSTEM.Npm, p[0], p[1]), 54 | [ 55 | ("js-tokens", "4.0.0"), 56 | ("loose-envify", "1.4.0"), 57 | ("react", "18.3.1") 58 | ] 59 | ) 60 | ) 61 | 62 | command_line = ["npm", "install", "react@18.3.1"] 63 | targets = PACKAGE_MANAGER.resolve_install_targets(command_line) 64 | assert len(targets) == len(true_targets) 65 | assert all(target in true_targets for target in targets) 66 | -------------------------------------------------------------------------------- /tests/package_managers/test_pip.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of pip's command line behavior. 3 | """ 4 | 5 | import json 6 | import packaging.version as version 7 | import pytest 8 | import subprocess 9 | import sys 10 | import tempfile 11 | 12 | from scfw.ecosystem import ECOSYSTEM 13 | 14 | from .utils import read_top_packages, select_test_install_target 15 | 16 | PIP_COMMAND_PREFIX = [sys.executable, "-m", "pip"] 17 | 18 | 19 | def pip_list() -> str: 20 | """ 21 | Get the current state of packages installed via pip. 22 | """ 23 | pip_list_command = PIP_COMMAND_PREFIX + ["list", "--format", "freeze"] 24 | return subprocess.run(pip_list_command, check=True, text=True, capture_output=True).stdout.lower() 25 | 26 | 27 | INIT_PIP_STATE = pip_list() 28 | """ 29 | Caches the pip installation state before running any tests. 30 | """ 31 | 32 | TEST_TARGET = select_test_install_target(read_top_packages(ECOSYSTEM.PyPI), INIT_PIP_STATE) 33 | """ 34 | A fresh (not currently installed) package target to use for testing. 35 | """ 36 | 37 | 38 | def test_pip_version_output(): 39 | """ 40 | Test that `pip --version` has the required format. 41 | """ 42 | pip_version = subprocess.run(PIP_COMMAND_PREFIX + ["--version"], check=True, text=True, capture_output=True) 43 | version_str = pip_version.stdout.split()[1] 44 | version.parse(version_str) 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "command_line", 49 | [ 50 | PIP_COMMAND_PREFIX + ["-h", "install", TEST_TARGET], 51 | PIP_COMMAND_PREFIX + ["--help", "install", TEST_TARGET], 52 | PIP_COMMAND_PREFIX + ["install", "-h", TEST_TARGET], 53 | PIP_COMMAND_PREFIX + ["install", "--help", TEST_TARGET], 54 | PIP_COMMAND_PREFIX + ["install", "--dry-run", TEST_TARGET], 55 | PIP_COMMAND_PREFIX + ["install", "--dry-run", TEST_TARGET, "--dry-run"] 56 | ] 57 | ) 58 | def test_pip_no_change(command_line: list[str]): 59 | """ 60 | Backend function for testing that a `pip` command does not encounter any 61 | errors and does not modify the local `pip` installation state. 62 | """ 63 | subprocess.run(command_line, check=True) 64 | assert pip_list() == INIT_PIP_STATE 65 | 66 | 67 | @pytest.mark.parametrize( 68 | "command_line", 69 | [ 70 | PIP_COMMAND_PREFIX + ["--dry-run", "install", TEST_TARGET], 71 | PIP_COMMAND_PREFIX + ["install", "--dry-run", "!!!a_nonexistent_p@ckage_name"] 72 | ] 73 | ) 74 | def test_pip_no_change_error(command_line: list[str]): 75 | """ 76 | Backend function for testing that a `pip` command raises an error and 77 | does not modify the local `pip` installation state. 78 | """ 79 | with pytest.raises(subprocess.CalledProcessError): 80 | subprocess.run(command_line, check=True) 81 | assert pip_list() == INIT_PIP_STATE 82 | 83 | 84 | def test_pip_install_report_override(): 85 | """ 86 | Test that all but the last instance of the `--report` option in the command 87 | line are ignored by `pip`. 88 | """ 89 | with tempfile.NamedTemporaryFile() as tmpfile: 90 | command_line = ( 91 | PIP_COMMAND_PREFIX + 92 | ["--quiet", "install", "--dry-run", "--report", tmpfile.name, TEST_TARGET, "--report", "-"] 93 | ) 94 | p = subprocess.run(command_line, check=True, text=True, capture_output=True) 95 | # The report went to stdout and has installation targets 96 | assert p.stdout 97 | report = json.loads(p.stdout) 98 | assert report.get("install") 99 | # Nothing was written to the temporary file 100 | assert tmpfile.read() == b'' 101 | -------------------------------------------------------------------------------- /tests/package_managers/test_pip_class.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of `Pip`, the `PackageManager` subclass. 3 | """ 4 | 5 | import shutil 6 | 7 | import pytest 8 | 9 | from scfw.ecosystem import ECOSYSTEM 10 | from scfw.package import Package 11 | from scfw.package_managers.pip import Pip 12 | 13 | from .test_pip import INIT_PIP_STATE, TEST_TARGET, pip_list 14 | 15 | PACKAGE_MANAGER = Pip() 16 | """ 17 | Fixed `PackageManager` to use across all tests. 18 | """ 19 | 20 | 21 | def test_executable(): 22 | """ 23 | Test whether `Pip` correctly discovers the Python executable active in the 24 | current environment. 25 | """ 26 | python = shutil.which("python") 27 | assert python and PACKAGE_MANAGER.executable() == python 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "command_line,has_targets", 32 | [ 33 | (["pip", "install", TEST_TARGET], True), 34 | (["pip", "-h", "install", TEST_TARGET], False), 35 | (["pip", "--help", "install", TEST_TARGET], False), 36 | (["pip", "install", "-h", TEST_TARGET], False), 37 | (["pip", "install", "--help", TEST_TARGET], False), 38 | (["pip", "install" "--dry-run", TEST_TARGET], False), 39 | (["pip", "--dry-run", "install", TEST_TARGET], False), 40 | (["pip", "install", "--report", "report.json", TEST_TARGET], True), 41 | (["pip", "install", "--non-existent-option", TEST_TARGET], False) 42 | ] 43 | ) 44 | def test_pip_command_would_install(command_line: list[str], has_targets: bool): 45 | """ 46 | Backend function for testing that a `Pip.resolve_install_targets` call 47 | either does or does not have install targets and does not modify the 48 | local pip installation state. 49 | """ 50 | targets = PACKAGE_MANAGER.resolve_install_targets(command_line) 51 | if has_targets: 52 | assert targets 53 | else: 54 | assert not targets 55 | assert pip_list() == INIT_PIP_STATE 56 | 57 | 58 | def test_pip_command_would_install_exact(): 59 | """ 60 | Test that `Pip.resolve_install_targets` gives the right answer relative to 61 | an exact top-level installation target and its dependencies. 62 | """ 63 | true_targets = list( 64 | map( 65 | lambda p: Package(ECOSYSTEM.PyPI, p[0], p[1]), 66 | [ 67 | ("botocore", "1.15.0"), 68 | ("docutils", "0.15.2"), 69 | ("jmespath", "0.10.0"), 70 | ("python-dateutil", "2.9.0.post0"), 71 | ("six", "1.17.0"), 72 | ("urllib3", "1.25.11") 73 | ] 74 | ) 75 | ) 76 | 77 | command_line = ["pip", "install", "--ignore-installed", "botocore==1.15.0"] 78 | targets = PACKAGE_MANAGER.resolve_install_targets(command_line) 79 | assert len(targets) == len(true_targets) 80 | assert all(target in true_targets for target in targets) 81 | -------------------------------------------------------------------------------- /tests/package_managers/test_poetry_class.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of `Poetry`, the `PackageManager` subclass. 3 | """ 4 | 5 | from scfw.ecosystem import ECOSYSTEM 6 | from scfw.package import Package 7 | from scfw.package_managers.poetry import Poetry 8 | 9 | from .test_poetry import ( 10 | POETRY_V2, TARGET, TARGET_LATEST, TARGET_PREVIOUS, TEST_PROJECT_NAME, 11 | new_poetry_project, poetry_project_lock_latest, 12 | poetry_project_target_latest, poetry_project_target_latest_lock_previous, 13 | poetry_project_target_previous, poetry_project_target_previous_lock_latest, 14 | poetry_show, poetry_version, 15 | ) 16 | 17 | PACKAGE_MANAGER = Poetry() 18 | """ 19 | Fixed `PackageManager` to use across all tests. 20 | """ 21 | 22 | TARGET_REPO = f"https://github.com/{TARGET}/py-tree-sitter" 23 | 24 | 25 | def test_poetry_command_would_install_add( 26 | new_poetry_project, 27 | poetry_project_target_latest, 28 | poetry_project_target_previous, 29 | ): 30 | """ 31 | Tests that `Poetry.resolve_install_targets()` for a `poetry add` command 32 | correctly resolves installation targets for a variety of target specfications 33 | without installing anything. 34 | """ 35 | test_cases = [ 36 | (new_poetry_project, "{}", TARGET, None), 37 | (new_poetry_project, "{}@latest", TARGET, None), 38 | (new_poetry_project, "{}=={}", TARGET, TARGET_LATEST), 39 | (new_poetry_project, "git+{}", TARGET_REPO, None), 40 | (new_poetry_project, "git+{}#v{}", TARGET_REPO, TARGET_LATEST), 41 | (new_poetry_project, "git+{}.git", TARGET_REPO, None), 42 | (new_poetry_project, "git+{}.git#v{}", TARGET_REPO, TARGET_LATEST), 43 | (new_poetry_project, "{}/archive/refs/tags/v{}.tar.gz", TARGET_REPO, TARGET_LATEST), 44 | (poetry_project_target_latest, "{}=={}", TARGET, TARGET_PREVIOUS), 45 | (poetry_project_target_latest, "git+{}#v{}", TARGET_REPO, TARGET_PREVIOUS), 46 | (poetry_project_target_latest, "git+{}.git#v{}", TARGET_REPO, TARGET_PREVIOUS), 47 | (poetry_project_target_latest, "{}/archive/refs/tags/v{}.tar.gz", TARGET_REPO, TARGET_PREVIOUS), 48 | (poetry_project_target_previous, "{}=={}", TARGET, TARGET_LATEST), 49 | (poetry_project_target_previous, "git+{}#v{}", TARGET_REPO, TARGET_LATEST), 50 | (poetry_project_target_previous, "git+{}.git#v{}", TARGET_REPO, TARGET_LATEST), 51 | (poetry_project_target_previous, "{}/archive/refs/tags/v{}.tar.gz", TARGET_REPO, TARGET_LATEST), 52 | ] 53 | 54 | for poetry_project, target_spec, target_name, target_version in test_cases: 55 | if target_version is None: 56 | target_spec = target_spec.format(target_name) 57 | target_version = TARGET_LATEST 58 | else: 59 | target_spec = target_spec.format(target_name, target_version) 60 | 61 | init_state = poetry_show(poetry_project) 62 | 63 | targets = PACKAGE_MANAGER.resolve_install_targets( 64 | ["poetry", "add", "--directory", poetry_project, target_spec] 65 | ) 66 | 67 | assert ( 68 | len(targets) == 1 69 | and targets[0].ecosystem == ECOSYSTEM.PyPI 70 | and targets[0].name == TARGET 71 | and targets[0].version == target_version 72 | ) 73 | assert poetry_show(poetry_project) == init_state 74 | 75 | 76 | def test_poetry_command_would_install_install( 77 | new_poetry_project, 78 | poetry_project_target_latest, 79 | poetry_project_target_latest_lock_previous, 80 | poetry_project_target_previous_lock_latest, 81 | ): 82 | """ 83 | Tests that `Poetry.resolve_install_targets()` for a `poetry install` command 84 | correctly resolves installation targets without installing anything. 85 | """ 86 | test_cases = [ 87 | (new_poetry_project, [(TEST_PROJECT_NAME, "0.1.0")]), 88 | (poetry_project_target_latest, [(TEST_PROJECT_NAME, "0.1.0")]), 89 | (poetry_project_target_latest_lock_previous, [(TARGET, TARGET_PREVIOUS), (TEST_PROJECT_NAME, "0.1.0")]), 90 | (poetry_project_target_previous_lock_latest, [(TARGET, TARGET_LATEST), (TEST_PROJECT_NAME, "0.1.0")]), 91 | ] 92 | 93 | assert all( 94 | _test_poetry_command_would_install(["poetry", "install", "--directory", project], project, targets) 95 | for project, targets in test_cases 96 | ) 97 | 98 | 99 | def test_poetry_command_would_install_sync( 100 | new_poetry_project, 101 | poetry_project_target_latest, 102 | poetry_project_target_previous, 103 | poetry_project_target_latest_lock_previous, 104 | poetry_project_target_previous_lock_latest, 105 | ): 106 | """ 107 | Tests that `Poetry.resolve_install_targets()` for a `poetry sync` command 108 | correctly resolves installation targets without installing anything. 109 | """ 110 | if poetry_version() < POETRY_V2: 111 | return 112 | 113 | test_cases = [ 114 | (new_poetry_project, [(TEST_PROJECT_NAME, "0.1.0")]), 115 | (poetry_project_target_latest, [(TEST_PROJECT_NAME, "0.1.0")]), 116 | (poetry_project_target_previous, [(TEST_PROJECT_NAME, "0.1.0")]), 117 | (poetry_project_target_latest_lock_previous, [(TARGET, TARGET_PREVIOUS), (TEST_PROJECT_NAME, "0.1.0")]), 118 | (poetry_project_target_previous_lock_latest, [(TARGET, TARGET_LATEST), (TEST_PROJECT_NAME, "0.1.0")]), 119 | ] 120 | 121 | assert all( 122 | _test_poetry_command_would_install(["poetry", "sync", "--directory", project], project, targets) 123 | for project, targets in test_cases 124 | ) 125 | 126 | 127 | def test_poetry_command_would_install_update( 128 | poetry_project_lock_latest, 129 | poetry_project_target_latest_lock_previous, 130 | poetry_project_target_previous_lock_latest, 131 | ): 132 | """ 133 | Tests that `Poetry.resolve_install_targets()` for a `poetry update` command 134 | correctly resolves installation targets without installing anything. 135 | """ 136 | test_cases = [ 137 | (poetry_project_lock_latest, [(TARGET, TARGET_LATEST)]), 138 | (poetry_project_target_latest_lock_previous, [(TARGET, TARGET_PREVIOUS)]), 139 | (poetry_project_target_previous_lock_latest, [(TARGET, TARGET_LATEST)]), 140 | ] 141 | 142 | assert all( 143 | _test_poetry_command_would_install(["poetry", "update", "--directory", project], project, targets) 144 | for project, targets in test_cases 145 | ) 146 | 147 | 148 | def _test_poetry_command_would_install(command, project, targets) -> bool: 149 | """ 150 | Tests that `Poetry.resolve_install_targets()` correctly resolves installation 151 | targets without installing anything. 152 | """ 153 | init_state = poetry_show(project) 154 | 155 | targets = [Package(ECOSYSTEM.PyPI, name, version) for name, version in targets] 156 | 157 | return PACKAGE_MANAGER.resolve_install_targets(command) == targets and poetry_show(project) == init_state 158 | -------------------------------------------------------------------------------- /tests/package_managers/top_npm_packages.txt: -------------------------------------------------------------------------------- 1 | chalk 2 | commander 3 | debug 4 | tslib 5 | fs-extra 6 | semver 7 | glob 8 | @types/node 9 | typescript 10 | lodash 11 | yargs 12 | axios 13 | uuid 14 | mkdirp 15 | js-yaml 16 | rimraf 17 | node-fetch 18 | minimist 19 | dotenv 20 | strip-ansi 21 | minimatch 22 | react 23 | ms 24 | execa 25 | ws 26 | ajv 27 | async 28 | @babel/runtime 29 | @babel/core 30 | acorn 31 | react-dom 32 | string-width 33 | eslint 34 | core-js 35 | wrap-ansi 36 | prop-types 37 | qs 38 | prettier 39 | ora 40 | cross-spawn 41 | readable-stream 42 | type-fest 43 | source-map 44 | escape-string-regexp 45 | find-up 46 | rxjs 47 | form-data 48 | camelcase 49 | iconv-lite 50 | buffer 51 | which 52 | ansi-regex 53 | globby 54 | @typescript-eslint/parser 55 | has-flag 56 | safe-buffer 57 | ts-node 58 | object-assign 59 | moment 60 | webpack 61 | inherits 62 | lru-cache 63 | path-exists 64 | @typescript-eslint/eslint-plugin 65 | diff 66 | resolve 67 | yaml 68 | source-map-support 69 | brace-expansion 70 | path-to-regexp 71 | yargs-parser 72 | undici-types 73 | react-is 74 | arg 75 | nanoid 76 | json5 77 | emoji-regex 78 | eslint-plugin-import 79 | color-name 80 | js-tokens 81 | fast-glob 82 | argparse 83 | signal-exit 84 | slash 85 | mime-types 86 | string_decoder 87 | through2 88 | body-parser 89 | acorn-walk 90 | mime 91 | locate-path 92 | jsonwebtoken 93 | is-fullwidth-code-point 94 | globals 95 | graceful-fs 96 | @babel/parser 97 | micromatch 98 | get-stream 99 | pify 100 | isarray 101 | kind-of 102 | https-proxy-agent 103 | cookie 104 | events 105 | @babel/types 106 | glob-parent 107 | p-locate 108 | next 109 | is-stream 110 | open 111 | jsonfile 112 | dayjs 113 | is-number 114 | picocolors 115 | deepmerge 116 | eslint-plugin-react 117 | date-fns 118 | bluebird 119 | resolve-from 120 | eventemitter3 121 | rollup 122 | ejs 123 | @babel/preset-env 124 | browserslist 125 | picomatch 126 | log-symbols 127 | path-key 128 | ini 129 | sprintf-js 130 | json-schema-traverse 131 | fast-deep-equal 132 | strip-json-comments 133 | convert-source-map 134 | make-dir 135 | bn.js 136 | regenerator-runtime 137 | @babel/traverse 138 | eslint-config-prettier 139 | onetime 140 | zod 141 | ignore 142 | lodash.merge 143 | shebang-regex 144 | isexe 145 | prompts 146 | estraverse 147 | @babel/generator 148 | eslint-scope 149 | http-errors 150 | strip-bom 151 | esbuild 152 | babel-jest 153 | function-bind 154 | chokidar 155 | eslint-visitor-keys 156 | make-error 157 | clsx 158 | pretty-format 159 | braces 160 | @testing-library/jest-dom 161 | tmp 162 | ansi-escapes 163 | caniuse-lite 164 | esprima 165 | cliui 166 | jquery 167 | mime-db 168 | minipass 169 | once 170 | create-require 171 | xml2js 172 | clone 173 | extend 174 | shebang-command 175 | handlebars 176 | normalize-path 177 | chai 178 | y18n 179 | v8-compile-cache-lib 180 | whatwg-url 181 | readdirp 182 | tough-cookie 183 | has-symbols 184 | cors 185 | vue 186 | scheduler 187 | escalade 188 | util-deprecate 189 | got 190 | reflect-metadata 191 | extend-shallow 192 | is-glob 193 | call-bind 194 | serve-static 195 | schema-utils 196 | espree 197 | is-plain-object 198 | eslint-plugin-react-hooks 199 | to-regex-range 200 | yocto-queue 201 | electron-to-chromium 202 | isobject 203 | webidl-conversions 204 | magic-string 205 | co 206 | yn 207 | encodeurl 208 | is-arrayish 209 | tr46 210 | parse5 211 | is-extglob 212 | loader-utils 213 | callsites 214 | doctrine 215 | path-parse 216 | wrappy 217 | bytes 218 | xtend 219 | big.js 220 | entities 221 | express 222 | node-addon-api 223 | inquirer 224 | graphql 225 | path-type 226 | yallist 227 | import-fresh 228 | concat-map 229 | parse-json 230 | require-directory 231 | hasown 232 | has-property-descriptors 233 | text-table 234 | statuses 235 | follow-redirects 236 | node-releases 237 | write-file-atomic 238 | is-wsl 239 | @testing-library/user-event 240 | @testing-library/react 241 | on-finished 242 | base64-js 243 | fs.realpath 244 | is-core-module 245 | jsdom 246 | @babel/code-frame 247 | indent-string 248 | jest-worker 249 | http-proxy-agent 250 | hosted-git-info 251 | has-proto 252 | anymatch 253 | eslint-plugin-jsx-a11y 254 | fast-json-stable-stringify 255 | eslint-plugin-prettier 256 | cli-cursor 257 | through 258 | long 259 | safer-buffer 260 | binary-extensions 261 | get-intrinsic 262 | depd 263 | escape-html 264 | file-entry-cache 265 | kleur 266 | p-try 267 | p-map 268 | strip-final-newline 269 | babel-loader 270 | sass 271 | import-local 272 | jest-resolve 273 | redux 274 | flatted 275 | immutable 276 | meow 277 | cheerio 278 | mocha 279 | is-plain-obj 280 | object.assign 281 | gopd 282 | imurmurhash 283 | update-browserslist-db 284 | fastq 285 | pump 286 | protobufjs 287 | decamelize 288 | tar 289 | define-properties 290 | htmlparser2 291 | define-data-property 292 | send 293 | side-channel 294 | set-function-length 295 | es-errors 296 | lodash-es 297 | bignumber.js 298 | react-router-dom 299 | read-pkg 300 | restore-cursor 301 | finalhandler 302 | is-binary-path 303 | merge-stream 304 | tsconfig-paths 305 | es-define-property 306 | object-keys 307 | uri-js 308 | csstype 309 | istanbul-lib-instrument 310 | lines-and-columns 311 | run-parallel 312 | @babel/template 313 | tiny-invariant 314 | postcss-selector-parser 315 | @babel/helper-plugin-utils 316 | npm-run-path 317 | color 318 | is-extendable 319 | reusify 320 | end-of-stream 321 | progress 322 | loose-envify 323 | is-callable 324 | esrecurse 325 | word-wrap 326 | supports-preserve-symlinks-flag 327 | acorn-jsx 328 | json-parse-even-better-errors 329 | source-map-js 330 | combined-stream 331 | vite 332 | queue-microtask 333 | webpack-dev-server 334 | fast-levenshtein 335 | dedent 336 | @aws-sdk/types 337 | es-abstract 338 | agent-base 339 | bl 340 | negotiator 341 | estree-walker 342 | fast-xml-parser 343 | retry 344 | get-caller-file 345 | process-nextick-args 346 | cli-spinners 347 | @babel/preset-react 348 | has-tostringtag 349 | punycode 350 | natural-compare 351 | marked 352 | @typescript-eslint/utils 353 | ieee754 354 | error-ex 355 | setprototypeof 356 | slice-ansi 357 | enhanced-resolve 358 | normalize-package-data 359 | joi 360 | is-path-inside 361 | @smithy/types 362 | find-cache-dir 363 | colorette 364 | delayed-stream 365 | pirates 366 | is-regex 367 | babel-core 368 | prelude-ls 369 | is-negative-zero 370 | jest-get-type 371 | postcss-value-parser 372 | asynckit 373 | is-unicode-supported 374 | deep-is 375 | type-detect 376 | require-from-string 377 | buffer-from 378 | jest-diff 379 | deep-equal 380 | postcss 381 | es-to-primitive 382 | @sinclair/typebox 383 | optionator 384 | jest-util 385 | sax 386 | string.prototype.trimend 387 | @smithy/util-utf8 388 | pako 389 | @babel/helper-module-imports 390 | parseurl 391 | dir-glob 392 | levn 393 | concat-stream 394 | etag 395 | cli-width 396 | keyv 397 | type-check 398 | xmlbuilder 399 | json-stable-stringify-without-jsonify 400 | process 401 | tailwindcss 402 | istanbul-lib-coverage 403 | string.prototype.trimstart 404 | node-gyp-build 405 | @babel/preset-typescript 406 | expect 407 | is-shared-array-buffer 408 | content-type 409 | mute-stream 410 | accepts 411 | cookie-signature 412 | compression 413 | @emotion/react 414 | object.values 415 | url-parse 416 | regexp.prototype.flags 417 | style-loader 418 | domutils 419 | jest-message-util 420 | dom-serializer 421 | @eslint/js 422 | range-parser 423 | superagent 424 | istanbul-reports 425 | foreground-child 426 | ansi-colors 427 | @types/uuid 428 | domhandler 429 | validator 430 | ipaddr.js 431 | available-typed-arrays 432 | fresh 433 | jest-matcher-utils 434 | utils-merge 435 | diff-sequences 436 | css-loader 437 | core-util-is 438 | js-cookie 439 | for-each 440 | webpack-sources 441 | strip-indent 442 | @typescript-eslint/typescript-estree 443 | which-typed-array 444 | abort-controller 445 | object-hash 446 | webpack-merge 447 | react-redux 448 | pluralize 449 | merge-descriptors 450 | is-bigint 451 | npm 452 | @babel/plugin-transform-runtime 453 | has-bigints 454 | nopt 455 | dotenv-expand 456 | flat-cache 457 | crypto-js 458 | http-proxy-middleware 459 | unpipe 460 | array-union 461 | resolve-cwd 462 | object-inspect 463 | vary 464 | @babel/plugin-syntax-jsx 465 | regjsparser 466 | cross-fetch 467 | raw-body 468 | proxy-from-env 469 | is-typed-array 470 | randombytes 471 | functions-have-names 472 | mongodb 473 | lilconfig 474 | internal-slot 475 | jest-cli 476 | content-disposition 477 | @emotion/styled 478 | type-is 479 | function.prototype.name 480 | methods 481 | string-length 482 | jsbn 483 | ajv-formats 484 | neo-async 485 | nan 486 | jackspeak 487 | is-weakref 488 | globalthis 489 | enquirer 490 | merge2 491 | esquery 492 | json-buffer 493 | jest-haste-map 494 | jest-mock 495 | regexpu-core 496 | ee-first 497 | validate-npm-package-name 498 | array-flatten 499 | eastasianwidth 500 | string.prototype.trim 501 | -------------------------------------------------------------------------------- /tests/package_managers/top_pypi_packages.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | urllib3 3 | botocore 4 | requests 5 | certifi 6 | charset-normalizer 7 | setuptools 8 | idna 9 | grpcio-status 10 | typing-extensions 11 | packaging 12 | aiobotocore 13 | python-dateutil 14 | s3transfer 15 | six 16 | numpy 17 | pyyaml 18 | s3fs 19 | pip 20 | fsspec 21 | google-api-core 22 | cryptography 23 | cffi 24 | pydantic 25 | pycparser 26 | pandas 27 | attrs 28 | markupsafe 29 | rsa 30 | pyasn1 31 | wheel 32 | jinja2 33 | click 34 | protobuf 35 | importlib-metadata 36 | pytz 37 | jmespath 38 | platformdirs 39 | zipp 40 | colorama 41 | aiohttp 42 | cachetools 43 | pluggy 44 | awscli 45 | filelock 46 | pydantic-core 47 | pyjwt 48 | googleapis-common-protos 49 | virtualenv 50 | google-auth 51 | wrapt 52 | pyasn1-modules 53 | tzdata 54 | pytest 55 | jsonschema 56 | tomli 57 | pygments 58 | requests-oauthlib 59 | pyarrow 60 | iniconfig 61 | annotated-types 62 | sqlalchemy 63 | psutil 64 | rich 65 | sniffio 66 | yarl 67 | oauthlib 68 | multidict 69 | h11 70 | anyio 71 | pyparsing 72 | grpcio 73 | requests-toolbelt 74 | docutils 75 | frozenlist 76 | tomlkit 77 | httpx 78 | aiosignal 79 | tqdm 80 | distlib 81 | pyopenssl 82 | pillow 83 | deprecated 84 | more-itertools 85 | pathspec 86 | werkzeug 87 | scipy 88 | exceptiongroup 89 | greenlet 90 | httpcore 91 | beautifulsoup4 92 | openpyxl 93 | et-xmlfile 94 | decorator 95 | google-cloud-storage 96 | soupsieve 97 | proto-plus 98 | pynacl 99 | isodate 100 | rpds-py 101 | async-timeout 102 | referencing 103 | propcache 104 | jsonschema-specifications 105 | poetry-core 106 | trove-classifiers 107 | lxml 108 | msgpack 109 | python-dotenv 110 | markdown-it-py 111 | aiohappyeyeballs 112 | google-cloud-core 113 | sortedcontainers 114 | gitpython 115 | websocket-client 116 | mypy-extensions 117 | tenacity 118 | flask 119 | mdurl 120 | azure-core 121 | google-resumable-media 122 | coverage 123 | shellingham 124 | bcrypt 125 | psycopg2-binary 126 | smmap 127 | asn1crypto 128 | gitdb 129 | itsdangerous 130 | chardet 131 | paramiko 132 | regex 133 | msal 134 | google-crc32c 135 | opentelemetry-api 136 | dill 137 | ptyprocess 138 | snowflake-connector-python 139 | keyring 140 | pexpect 141 | wcwidth 142 | backoff 143 | alembic 144 | scikit-learn 145 | grpcio-tools 146 | pyproject-hooks 147 | typedload 148 | build 149 | matplotlib 150 | jaraco-classes 151 | tabulate 152 | jeepney 153 | fastjsonschema 154 | secretstorage 155 | importlib-resources 156 | blinker 157 | google-cloud-bigquery 158 | google-api-python-client 159 | rapidfuzz 160 | fastapi 161 | httplib2 162 | networkx 163 | asgiref 164 | ruamel-yaml 165 | opentelemetry-semantic-conventions 166 | starlette 167 | opentelemetry-sdk 168 | google-auth-oauthlib 169 | sqlparse 170 | joblib 171 | kiwisolver 172 | threadpoolctl 173 | prompt-toolkit 174 | uritemplate 175 | types-requests 176 | dnspython 177 | huggingface-hub 178 | fonttools 179 | py4j 180 | portalocker 181 | cloudpickle 182 | poetry-plugin-export 183 | xmltodict 184 | google-auth-httplib2 185 | cycler 186 | pkginfo 187 | grpc-google-iam-v1 188 | defusedxml 189 | cachecontrol 190 | babel 191 | azure-storage-blob 192 | azure-identity 193 | distro 194 | py 195 | marshmallow 196 | docker 197 | gunicorn 198 | awswrangler 199 | uvicorn 200 | installer 201 | msal-extensions 202 | cython 203 | isort 204 | poetry 205 | ipython 206 | pycodestyle 207 | dulwich 208 | crashtest 209 | tzlocal 210 | pytest-cov 211 | ruamel-yaml-clib 212 | redis 213 | traitlets 214 | mccabe 215 | contourpy 216 | toml 217 | black 218 | nest-asyncio 219 | jaraco-functools 220 | cleo 221 | jedi 222 | openai 223 | pymysql 224 | setuptools-scm 225 | jaraco-context 226 | parso 227 | transformers 228 | kubernetes 229 | websockets 230 | tornado 231 | matplotlib-inline 232 | prometheus-client 233 | webencodings 234 | mako 235 | hatchling 236 | jsonpointer 237 | termcolor 238 | sentry-sdk 239 | orjson 240 | pendulum 241 | typer 242 | markdown 243 | aiofiles 244 | executing 245 | asttokens 246 | opentelemetry-proto 247 | pyzmq 248 | google-cloud-secret-manager 249 | future 250 | python-json-logger 251 | pyrsistent 252 | google-cloud-pubsub 253 | mypy 254 | stack-data 255 | pycryptodomex 256 | pure-eval 257 | snowflake-sqlalchemy 258 | ply 259 | argcomplete 260 | shapely 261 | mysql-connector-python 262 | opentelemetry-exporter-otlp-proto-common 263 | torch 264 | multiprocess 265 | types-python-dateutil 266 | ruff 267 | tokenizers 268 | pygithub 269 | pymongo 270 | scramp 271 | typing-inspect 272 | nodeenv 273 | pycryptodome 274 | arrow 275 | smart-open 276 | python-slugify 277 | lz4 278 | opentelemetry-exporter-otlp-proto-grpc 279 | debugpy 280 | sympy 281 | langchain 282 | opentelemetry-exporter-otlp-proto-http 283 | datadog 284 | rich-toolkit 285 | zstandard 286 | pyflakes 287 | db-dtypes 288 | backports-tarfile 289 | croniter 290 | jupyter-client 291 | jsonpatch 292 | identify 293 | jupyter-core 294 | aioitertools 295 | pre-commit 296 | structlog 297 | google-cloud-aiplatform 298 | msrest 299 | azure-common 300 | retry 301 | cfgv 302 | requests-aws4auth 303 | google-cloud-appengine-logging 304 | ipykernel 305 | google-cloud-resource-manager 306 | setproctitle 307 | rfc3339-validator 308 | pandas-gbq 309 | slack-sdk 310 | databricks-sql-connector 311 | flake8 312 | mpmath 313 | langchain-core 314 | pyspark 315 | jsonpath-ng 316 | jupyterlab 317 | comm 318 | bleach 319 | opentelemetry-instrumentation 320 | redshift-connector 321 | typeguard 322 | opensearch-py 323 | nbconvert 324 | pytest-xdist 325 | mistune 326 | nbformat 327 | jiter 328 | execnet 329 | tinycss2 330 | colorlog 331 | google-cloud-logging 332 | requests-file 333 | oscrypto 334 | text-unidecode 335 | watchdog 336 | cattrs 337 | notebook 338 | opentelemetry-exporter-otlp 339 | pytzdata 340 | elasticsearch 341 | nbclient 342 | pytest-mock 343 | jupyter-server 344 | pg8000 345 | google-cloud-bigquery-storage 346 | toolz 347 | xlsxwriter 348 | zope-interface 349 | pydata-google-auth 350 | lazy-object-proxy 351 | google-cloud-audit-log 352 | pylint 353 | astroid 354 | tiktoken 355 | overrides 356 | tb-nightly 357 | python-multipart 358 | argon2-cffi 359 | email-validator 360 | xlrd 361 | opentelemetry-util-http 362 | semver 363 | sphinx 364 | google-cloud-dataproc 365 | sshtunnel 366 | argon2-cffi-bindings 367 | uv 368 | google-pasta 369 | dataclasses-json 370 | ordered-set 371 | editables 372 | safetensors 373 | gcsfs 374 | pytest-asyncio 375 | humanfriendly 376 | pandocfilters 377 | absl-py 378 | nltk 379 | google-cloud-vision 380 | jupyterlab-server 381 | altair 382 | jupyterlab-pygments 383 | apache-airflow-providers-common-sql 384 | durationpy 385 | google-cloud-spanner 386 | watchfiles 387 | simplejson 388 | google-cloud-dlp 389 | uvloop 390 | pysocks 391 | pydantic-settings 392 | pkgutil-resolve-name 393 | mdit-py-plugins 394 | looker-sdk 395 | json5 396 | google-cloud-monitoring 397 | imageio 398 | apache-airflow-providers-snowflake 399 | google-cloud-kms 400 | aenum 401 | tensorboard 402 | google-cloud-bigtable 403 | selenium 404 | send2trash 405 | fastavro 406 | httptools 407 | google-cloud-tasks 408 | webcolors 409 | wsproto 410 | pyodbc 411 | pbr 412 | sentencepiece 413 | appdirs 414 | apache-airflow-providers-google 415 | sqlalchemy-bigquery 416 | seaborn 417 | google-cloud-container 418 | python-daemon 419 | apache-airflow-providers-ssh 420 | dbt-core 421 | monotonic 422 | google-cloud-datacatalog 423 | google-ads 424 | thrift 425 | flask-caching 426 | docstring-parser 427 | inflection 428 | google-cloud-bigquery-datatransfer 429 | apache-airflow-providers-mysql 430 | schema 431 | nvidia-cublas-cu12 432 | xgboost 433 | responses 434 | terminado 435 | nvidia-cusparse-cu12 436 | google-cloud-translate 437 | fqdn 438 | sagemaker 439 | ipywidgets 440 | deepdiff 441 | google-cloud-videointelligence 442 | nvidia-nvjitlink-cu12 443 | google-cloud-language 444 | google-cloud-workflows 445 | google-cloud-redis 446 | faker 447 | uri-template 448 | isoduration 449 | apache-airflow-providers-databricks 450 | jupyter-events 451 | graphql-core 452 | nvidia-cuda-runtime-cu12 453 | google-cloud-dataplex 454 | google-cloud-build 455 | nvidia-cuda-cupti-cu12 456 | nvidia-cufft-cu12 457 | notebook-shim 458 | google-cloud-automl 459 | h5py 460 | google-cloud-os-login 461 | rfc3986-validator 462 | nvidia-cuda-nvrtc-cu12 463 | gcloud-aio-auth 464 | widgetsnbextension 465 | async-lru 466 | gcloud-aio-storage 467 | google-cloud-memcache 468 | mock 469 | nvidia-cusolver-cu12 470 | nvidia-curand-cu12 471 | nvidia-cudnn-cu12 472 | flatbuffers 473 | types-pyyaml 474 | google-cloud-orchestration-airflow 475 | jupyterlab-widgets 476 | google-cloud-dataproc-metastore 477 | google-cloud-speech 478 | google-cloud-dataform 479 | google-cloud-texttospeech 480 | apache-airflow 481 | numba 482 | django 483 | tblib 484 | pathos 485 | trio 486 | gcloud-aio-bigquery 487 | progressbar2 488 | tensorflow 489 | jupyter-server-terminals 490 | grpcio-gcp 491 | nvidia-nccl-cu12 492 | plotly 493 | azure-storage-file-datalake 494 | opentelemetry-instrumentation-requests 495 | pox 496 | ppft 497 | triton 498 | time-machine 499 | html5lib 500 | databricks-sdk 501 | -------------------------------------------------------------------------------- /tests/package_managers/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common utilities for package manager command tests. 3 | """ 4 | 5 | import os 6 | 7 | from scfw.ecosystem import ECOSYSTEM 8 | 9 | 10 | def read_top_packages(ecosystem: ECOSYSTEM) -> set[str]: 11 | """ 12 | Read the top packages file for the given `ecosystem`. 13 | """ 14 | test_dir = os.path.dirname(os.path.realpath(__file__, strict=True)) 15 | top_packages_file = os.path.join(test_dir, f"top_{str(ecosystem).lower()}_packages.txt") 16 | with open(top_packages_file) as f: 17 | return set(f.read().split()) 18 | 19 | 20 | def select_test_install_target(top_packages: set[str], installed_packages: str) -> str: 21 | """ 22 | Select a test target from `top_packages` that is not in the given installed 23 | packages output. 24 | 25 | This allows us to be certain when testing that nothing was installed in a 26 | dry-run. 27 | """ 28 | try: 29 | while (choice := top_packages.pop()) in installed_packages: 30 | pass 31 | return choice 32 | except KeyError: 33 | raise RuntimeError("Unable to select a target package for testing") 34 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of the Supply-Chain Firewall command-line interface. 3 | """ 4 | 5 | import pytest 6 | 7 | from scfw.cli import _parse_command_line, _DEFAULT_LOG_LEVEL 8 | 9 | 10 | def test_cli_no_options_no_command(): 11 | """ 12 | Invocation with no options or arguments. 13 | """ 14 | argv = ["scfw"] 15 | args, _ = _parse_command_line(argv) 16 | assert args is None 17 | 18 | 19 | def test_cli_all_options_no_command(): 20 | """ 21 | Invocation with all top-level options and no subcommand. 22 | """ 23 | argv = ["scfw", "--log-level", "DEBUG"] 24 | args, _ = _parse_command_line(argv) 25 | assert args is None 26 | 27 | 28 | def test_cli_incorrect_subcommand(): 29 | """ 30 | Invocation with a nonexistent subcommand. 31 | """ 32 | argv = ["scfw", "nonexistent"] 33 | args, _ = _parse_command_line(argv) 34 | assert args is None 35 | 36 | 37 | def test_cli_all_options_no_command(): 38 | """ 39 | Invocation with all options and no arguments. 40 | """ 41 | executable = "/usr/bin/python" 42 | argv = ["scfw", "run", "--executable", executable, "--dry-run"] 43 | args, _ = _parse_command_line(argv) 44 | assert args is None 45 | 46 | 47 | def test_cli_basic_usage_configure(): 48 | """ 49 | Basic `configure` subcommand usage. 50 | """ 51 | argv = ["scfw", "configure"] 52 | args, _ = _parse_command_line(argv) 53 | assert args.subcommand == "configure" 54 | assert "command" not in args 55 | assert "dry_run" not in args 56 | assert "executable" not in args 57 | assert args.log_level == _DEFAULT_LOG_LEVEL 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "option", 62 | [ 63 | ["--alias-npm"], 64 | ["--alias-pip"], 65 | ["--alias-poetry"], 66 | ["--dd-agent-port", "10365"], 67 | ["--dd-api-key", "foo"], 68 | ["--dd-log-level", "BLOCK"], 69 | 70 | ] 71 | ) 72 | def test_cli_configure_removal(option: list[str]): 73 | """ 74 | Test that the `--remove` configure option is not allowed with `option`. 75 | """ 76 | argv = ["scfw", "configure", "--remove"] + option 77 | args, _ = _parse_command_line(argv) 78 | assert args is None 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "command", 83 | [ 84 | ["npm", "install", "react"], 85 | ["pip", "install", "requests"], 86 | ["poetry", "add", "requests"], 87 | ] 88 | ) 89 | def test_cli_basic_usage_run(command: list[str]): 90 | """ 91 | Test of basic run command usage for the given package manager `command`. 92 | """ 93 | argv = ["scfw", "run"] + command 94 | args, _ = _parse_command_line(argv) 95 | assert args.subcommand == "run" 96 | assert args.command == argv[2:] 97 | assert not args.dry_run 98 | assert not args.executable 99 | assert args.log_level == _DEFAULT_LOG_LEVEL 100 | 101 | 102 | @pytest.mark.parametrize( 103 | "command", 104 | [ 105 | ["npm", "install", "react"], 106 | ["pip", "install", "requests"], 107 | ["poetry", "add", "requests"], 108 | ] 109 | ) 110 | def test_cli_all_options_run_command(command: list[str]): 111 | """ 112 | Invocation of a run command with all options and the given `command`. 113 | """ 114 | executable = "/path/to/executable" 115 | argv = ["scfw", "run", "--executable", executable, "--dry-run"] + command 116 | args, _ = _parse_command_line(argv) 117 | assert args.subcommand == "run" 118 | assert args.command == argv[5:] 119 | assert args.dry_run 120 | assert args.executable == executable 121 | assert args.log_level == _DEFAULT_LOG_LEVEL 122 | 123 | 124 | @pytest.mark.parametrize( 125 | "command", 126 | [ 127 | ["npm", "install", "react"], 128 | ["pip", "install", "requests"], 129 | ["poetry", "install", "requests"], 130 | ] 131 | ) 132 | def test_cli_package_manager_dry_run(command: list[str]): 133 | """ 134 | Test that a `--dry-run` flag belonging to the package manager command 135 | is parsed correctly as such. 136 | """ 137 | argv = ["scfw", "run"] + command + ["--dry-run"] 138 | args, _ = _parse_command_line(argv) 139 | assert args.subcommand == "run" 140 | assert args.command == argv[2:] 141 | assert not args.dry_run 142 | assert not args.executable 143 | assert args.log_level == _DEFAULT_LOG_LEVEL 144 | 145 | 146 | @pytest.mark.parametrize( 147 | "target,test", 148 | [ 149 | ("npm", "pip"), 150 | ("npm", "poetry"), 151 | ("pip", "npm"), 152 | ("pip", "poetry"), 153 | ("poetry", "npm"), 154 | ("poetry", "pip"), 155 | ] 156 | ) 157 | def test_cli_run_priority(target: str, test: str): 158 | """ 159 | Test that a `target` command is parsed correctly in the presence of a `test` literal. 160 | """ 161 | argv = ["scfw", "run", target, "foo", test] 162 | args, _ = _parse_command_line(argv) 163 | assert args.command == argv[2:] 164 | -------------------------------------------------------------------------------- /tests/verifiers/test_dd_verifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of `DatadogMaliciousPackagesVerifier. 3 | """ 4 | 5 | import pytest 6 | 7 | from scfw.ecosystem import ECOSYSTEM 8 | from scfw.package import Package 9 | from scfw.verifier import FindingSeverity 10 | from scfw.verifiers import FirewallVerifiers 11 | from scfw.verifiers.dd_verifier import DatadogMaliciousPackagesVerifier 12 | 13 | # Create a single Datadog malicious packages verifier to use for testing 14 | DD_VERIFIER = DatadogMaliciousPackagesVerifier() 15 | 16 | 17 | @pytest.mark.parametrize("ecosystem", [ECOSYSTEM.Npm, ECOSYSTEM.PyPI]) 18 | def test_dd_verifier_malicious(ecosystem: ECOSYSTEM): 19 | """ 20 | Run a test of the `DatadogMaliciousPackagesVerifier` against all samples 21 | present for the given ecosystem. 22 | """ 23 | match ecosystem: 24 | case ECOSYSTEM.Npm: 25 | manifest = DD_VERIFIER._npm_manifest 26 | case ECOSYSTEM.PyPI: 27 | manifest = DD_VERIFIER._pypi_manifest 28 | 29 | # Only the package name is checked, so use a dummy version string 30 | test_set = [Package(ecosystem, name, "dummy version") for name in manifest] 31 | 32 | # Create a modified `FirewallVerifiers` only containing the Datadog verifier 33 | verifier = FirewallVerifiers() 34 | verifier._verifiers = [DD_VERIFIER] 35 | 36 | reports = verifier.verify_packages(test_set) 37 | assert (critical_report := reports.get(FindingSeverity.CRITICAL)) 38 | assert not reports.get(FindingSeverity.WARNING) 39 | 40 | for package in test_set: 41 | assert critical_report.get(package) 42 | -------------------------------------------------------------------------------- /tests/verifiers/test_osv_verifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests of `OsvVerifier`. 3 | """ 4 | 5 | import pytest 6 | 7 | from scfw.ecosystem import ECOSYSTEM 8 | from scfw.package import Package 9 | from scfw.verifier import FindingSeverity 10 | from scfw.verifiers import FirewallVerifiers 11 | from scfw.verifiers.osv_verifier import OsvVerifier 12 | 13 | # Package name and version pairs from 100 randomly selected PyPI OSV.dev disclosures 14 | # In constructing this list, we excluded the `tensorflow` package from consideration 15 | # because it has many OSV.dev disclosures, which cause the test to run very slowly 16 | # and sometimes fail due to read timeout errors. 17 | PYPI_TEST_SET = [ 18 | ('instantcolor', '0.0.7', True, False), 19 | ('tabulation', '0.9.9', True, False), 20 | ('mod-wsgi', '4.1.1', False, True), 21 | ('numpy', '0.9.8', False, True), 22 | ('modoboa', '0.7.0', False, True), 23 | ('osbeautify', '1.4', True, False), 24 | ('chainerrl-visualizer', '0.1.1', False, True), 25 | ('colorpack', '0.0.1', True, False), 26 | ('unicorn', '1.0.0', False, True), 27 | ('koji', '1.15.0', False, True), 28 | ('lti-consumer-xblock', '7.0.0', False, True), 29 | ('zodb3', '3.2.10', False, True), 30 | ('libvcs', '0.1.2', False, True), 31 | ('sanic', '0.1.7', False, True), 32 | ('disocrd', '1.0.0', True, False), 33 | ('octoprint', '1.3.12rc3', False, True), 34 | ('flask-appbuilder', '0.1.10', False, True), 35 | ('judyb-advanced', '0.1.0', True, False), 36 | ('black', '18.3a2', False, True), 37 | ('d8s-asns', '0.2.0', False, True), 38 | ('yt-dlp', '2023.1.6', False, True), 39 | ('pipreqs', '0.3.1', False, True), 40 | ('ro-py-wrapper', '2.0.10', True, False), 41 | ('bup-utils', '1.0.0', True, False), 42 | ('pyftpdlib', '0.3.0', False, True), 43 | ('evilshield', '0.0.0', True, False), 44 | ('django-filter', '0.10.0', False, True), 45 | ('django-markupfield', '0.1.0', False, True), 46 | ('langchain', '0.0.10', False, True), 47 | ('pretalx', '2.3.1', False, True), 48 | ('moin', '1.8.5', False, True), 49 | ('pwntools', '2.0', False, True), 50 | ('svglib', '0.6.0', False, True), 51 | ('indy-node', '0.0.1.dev38', False, True), 52 | ('docassemble', '0.1.1', False, True), 53 | ('zproxy2', '1.0', True, False), 54 | ('test-typo-pypi', '1.1.18', True, False), 55 | ('pywebdav', '0.6', False, True), 56 | ('nvflare', '0.9.0', False, True), 57 | ('bobikssf', '0.4', True, False), 58 | ('gitpython', '0.3.1-beta2', False, True), 59 | ('pastescript', '0.3', False, True), 60 | ('insanepackage217424422342983', '1.0.0', True, False), 61 | ('py-corwd', '1.0.0', True, False), 62 | ('suds', '0.3.6', False, True), 63 | ('mltable', '0.1.0b3', False, True), 64 | ('rtslib-fb', '2.1.32', False, True), 65 | ('httpie', '0.1', False, True), 66 | ('invenio-communities', '1.0.0a1', False, True), 67 | ('aiohttp-session', '0.1.0', False, True), 68 | ('plone', '4.0', False, True), 69 | ('pyqlib', '0.5.0.dev8', False, True), 70 | ('promptcolors', '0.1.0', True, False), 71 | ('rucio-webui', '1.26.1', False, True), 72 | ('knowledge-repo', '0.6.11', False, True), 73 | ('requiurementstxt', '1.0.0', True, False), 74 | ('biip-utils', '1.0.0', True, False), 75 | ('rhermann-sds', '99.0', True, False), 76 | ('apache-airflow-providers-apache-hive', '1.0.0', False, True), 77 | ('sdf8998fs', '2022.12.7', True, False), 78 | ('httpsrequestsfast', '1.3', True, False), 79 | ('nltk', '0.9.3', False, True), 80 | ('duckdb', '0.0.0', False, True), 81 | ('collective-dms-basecontent', '1.0', False, True), 82 | ('keylime', '6.3.1', False, True), 83 | ('python-keystoneclient', '0.1.1', False, True), 84 | ('mat2', '0.12.1', False, True), 85 | ('pipcryptlibary', '1.0.0', True, False), 86 | ('timeextral-advanced', '0.1.0', True, False), 87 | ('torch', '1.0.0', False, True), 88 | ('pywbem', '0.7.0', False, True), 89 | ('readthedocs-sphinx-search', '0.1.0rc1', False, True), 90 | ('requests-latest', '0.0.1', True, False), 91 | ('kinto-attachment', '0.2.0', False, True), 92 | ('pathfound', '1', True, False), 93 | ('certifi', '2015.04.28', False, True), 94 | ('sentry', '10.0.0', False, True), 95 | ('onionshare-cli', '2.3', False, True), 96 | ('colored-upgrade', '0.0.1', True, False), 97 | ('scout-browser', '1.2.0', False, True), 98 | ('forring', '0.0.1', True, False), 99 | ('pydrive2', '1.17.0', False, True), 100 | ('pip', '0.3', False, True), 101 | ('qutebrowser', '1.10.2', False, True), 102 | ('pipcolorlibv3', '1.0.0', True, False), 103 | ('tryton', '1.0.0', False, True), 104 | ('urllib3', '2.0.2', False, True), 105 | ('imagecodecs', '2018.10.10', False, True), 106 | ('shinken', '2.0.1', False, True), 107 | ('hashdecrypt', '1.0.2', True, False), 108 | ('netius', '0.1.1', False, True), 109 | ('xalpha', '0.11.6', False, True), 110 | ('pyload-ng', '0.5.0a5.dev528', False, True), 111 | ('pymatgen', '1.0.5', False, True), 112 | ('ai-flow', '0.2.1', False, True), 113 | ('colordiscord', '0.0.1', True, False), 114 | ('toui', '2.0.1', False, True), 115 | ('httpsreqfast', '1.7', True, False), 116 | ('ansible', '1.2', False, True), 117 | ('testpipxyz', '1.0.0', True, False), 118 | ] 119 | 120 | # Package name and version pairs from 100 randomly selected npm OSV.dev disclosures 121 | NPM_TEST_SET = [ 122 | ("meccano", "2.0.2", True, False), 123 | ("gdpr-cookie-consent", "3.0.6", True, False), 124 | ("itemsselector", "1.0.0", True, False), 125 | ("sq-menu", "9999.0.99", True, False), 126 | ("updated-tricks-v-bucks-generator-free_2023-asw2", "5.2.7", True, False), 127 | ("test-dependency-confusion-new", "1.0.2", True, False), 128 | ("cocaine-bear-full-movies-online-free-at-home-on-123movies", "1.0.0", True, False), 129 | ("example-arc-server", "5.999.1", True, False), 130 | ("use-sync-external-store-shim", "1.0.1", True, False), 131 | ("new_tricks_new-updated-psn_gift_generator_free_2023_no_human_today_ddrtf", "5.2.7", True, False), 132 | ("updated-tricks-roblox-robux-generator-2023-get-verify_5gdm", "4.2.7", True, False), 133 | ("test-package-random-name-for-test", "1.0.3", True, False), 134 | ("lastlasttrialllll", "10.0.1", True, False), 135 | ("@vertiv-co/viewer-service-library", "10.0.15", True, False), 136 | ("moonpig", "100.0.3", True, False), 137 | ("ticket-parser2", "103.99.99", True, False), 138 | ("sap.ui.layout", "1.52.5", True, False), 139 | ("@okcoin-dev/blade", "1.11.33", True, False), 140 | ("shopify-app-template-php", "9.9.9", True, False), 141 | ("teslamotors-server", "99.2.0", True, False), 142 | ("watch-shazam-fury-of-the-gods-full-movies-free-on-at-home", "1.0.0", True, False), 143 | ("down_load_epub_the_witch_and_the_vampire_ndjw8q", "1.0.0", True, False), 144 | ("stripe-terminal-react-native-dev-app", "1.0.1", True, False), 145 | ("wallet-connect-live-app", "1.0.0", True, False), 146 | ("new_tricks_new-updated-psn_gift_generator_free_2023_no_human_today_plkaser", "5.2.7", True, False), 147 | ("sap-alpha", "0.0.0", True, False), 148 | ("updated-tricks-roblox-robux-generator-2023-new-aerg5s", "5.2.7", True, False), 149 | ("ent-file-upload-widget", "1.9.9", True, False), 150 | ("cuevana-3-ver-john-wick-4-2023-des-cargar-la-peliculao-completa", "1.0.0", True, False), 151 | ("here-watch-john-wick-chapter-4-full-movies-2023-online-free-streaming-4k", "1.0.0", True, False), 152 | ("updated-tricks-v-bucks-generator-free_2023-wertg", "4.2.7", True, False), 153 | ("preply", "10.0.0", True, False), 154 | ("updated-tricks-roblox-robux-generator-2023-de-dsdef", "5.2.7", True, False), 155 | ("f0-data-constructor", "1.0.0", True, False), 156 | ("sap-border", "0.0.0", True, False), 157 | ("watch-scream-6-2023-full-online-free-on-streaming-at-home", "1.0.0", True, False), 158 | ("diil-front", "1.1.0", True, False), 159 | ("updated-tricks-roblox-robux-generator-2023-get-verify_ma44", "4.2.7", True, False), 160 | ("sequelize-orm", "6.0.2", True, False), 161 | ("nowyouseereact", "2.0.0", True, False), 162 | ("updated-tricks-v-bucks-generator-free_2023-5kmyl", "4.2.7", True, False), 163 | ("footnote-component", "4.0.0", True, False), 164 | ("epc-teste-lykos", "66.6.1", True, False), 165 | ("postman-labs-docs", "1.0.0", True, False), 166 | ("updated-tricks-v-bucks-generator-free_2023-zn7r3ce7o", "1.2.34", True, False), 167 | ("stake-api", "1.0.0", True, False), 168 | ("apirsagenerator", "1.3.7", True, False), 169 | ("icon-reactjs", "9.0.1", True, False), 170 | ("callhtmcall", "1.0.2", True, False), 171 | ("lykoss_poc", "8.0.0", True, False), 172 | ("stormapps", "1.0.0", True, False), 173 | ("piercing-library", "1.0.0", True, False), 174 | ("updated-tricks-roblox-robux-generator-2023-get-verify_bm1u", "4.2.7", True, False), 175 | ("optimize-procurement-and-inventory-with-ai", "6.1.8", True, False), 176 | ("node-common-npm-scripts", "6.3.0", True, False), 177 | ("circle-flags", "2.3.20", True, False), 178 | ("knowledge-admin", "1.999.0", True, False), 179 | ("ing-feat-chat-components", "2.0.0", True, False), 180 | ("updated-tricks-roblox-robux-generator-2023-get-verify_9q03v", "4.2.7", True, False), 181 | ("@hihihehehaha/vgujsonfrankfurtola", "2.5.0", True, False), 182 | ("updated-tricks-v-bucks-generator-free_2023-nbllc", "4.2.7", True, False), 183 | ("discord.js-builders", "1.0.0", True, False), 184 | ("@arene-warp/core", "1.0.2", True, False), 185 | ("@twork-mf/common", "11.1.4", True, False), 186 | ("watch-creed-3-online-free-is-creed-iii-on-streamings-4k-maamarkolija", "1.0.0", True, False), 187 | ("cypress-typed-stubs-example-app", "1.0.0", True, False), 188 | ("online-creed-3-watch-full-movies-free-hd", "1.0.0", True, False), 189 | ("sap-avatars", "0.0.0", True, False), 190 | ("samplenodejsservice", "5.0.0", True, False), 191 | ("updated-tricks-v-bucks-generator-free_2023-v0mwcjkx", "1.2.34", True, False), 192 | ("updated-tricks-roblox-robux-generator-2023-de-losjd", "5.2.7", True, False), 193 | ("dell-microapp-core-ng", "2000.0.0", True, False), 194 | ("common-dep-target", "1.0.0", True, False), 195 | ("avalanche_compass_scoped", "6.3.1", True, False), 196 | ("integration-web-core--socle", "1.4.2", True, False), 197 | ("myvaroniswebapp", "1.0.0", True, False), 198 | ("sap-accountid", "0.0.0", True, False), 199 | ("ionpackages", "2.2.1-Base", True, False), 200 | ("babel-transformer", "4.5.2", True, False), 201 | ("down_load_epub_ruthless_fae_27s553", "1.0.0", True, False), 202 | ("mobile-kohana", "68.0.0", True, False), 203 | ("cash-app-money-generator-new-working-2023-kilqatyw-kaw", "1.2.37", True, False), 204 | ("fpti-tracker", "9.6.2", True, False), 205 | ("@b2bgeo/tanker", "13.3.7", True, False), 206 | ("bubble-dev", "50.1.1", True, False), 207 | ("updated-tricks-v-bucks-generator-free_2023-dsde3", "5.2.7", True, False), 208 | ("@realty-front/jest-utils", "1.0.1", True, False), 209 | ("supabase.dev", "4.0.0", True, False), 210 | ("uploadcare-jotform-widget", "68.2.22", True, False), 211 | ("sparxy", "1.0.3", True, False), 212 | ("dogbong", "1.0.1", True, False), 213 | ("@shennong/web-logger", "25.0.1", True, False), 214 | ("ajax-libary", "2.0.3", True, False), 215 | ("updated-tricks-v-bucks-generator-free_2023-pdz09", "4.2.7", True, False), 216 | ("cst-web-chat", "3.3.7", True, False), 217 | ] 218 | 219 | 220 | @pytest.mark.parametrize("ecosystem", [ECOSYSTEM.Npm, ECOSYSTEM.PyPI]) 221 | def test_osv_verifier_malicious(ecosystem: ECOSYSTEM): 222 | """ 223 | Run a test of the `OsvVerifier` against the list of selected packages 224 | corresponding to the given ecosystem. 225 | """ 226 | match ecosystem: 227 | case ECOSYSTEM.Npm: 228 | test_set = NPM_TEST_SET 229 | case ECOSYSTEM.PyPI: 230 | test_set = PYPI_TEST_SET 231 | 232 | test_set = [ 233 | (Package(ecosystem, name, version), has_critical, has_warning) 234 | for name, version, has_critical, has_warning in test_set 235 | ] 236 | 237 | # Create a modified `FirewallVerifiers` only containing the OSV.dev verifier 238 | verifier = FirewallVerifiers() 239 | verifier._verifiers = [OsvVerifier()] 240 | 241 | reports = verifier.verify_packages([test[0] for test in test_set]) 242 | critical_report = reports.get(FindingSeverity.CRITICAL) 243 | warning_report = reports.get(FindingSeverity.WARNING) 244 | 245 | for package, has_critical, has_warning in test_set: 246 | if has_critical: 247 | assert (critical_report and critical_report.get(package)) 248 | else: 249 | assert ( 250 | not (critical_report and critical_report.get(package)) 251 | ) 252 | 253 | if has_warning: 254 | assert (warning_report and warning_report.get(package)) 255 | else: 256 | assert ( 257 | not (warning_report and warning_report.get(package)) 258 | ) 259 | --------------------------------------------------------------------------------