├── .dockerignore ├── .flake8 ├── .github └── workflows │ ├── publish-docs.yml │ ├── publish-to-pypi.yml │ ├── test-aws-ip-ranges-data.yml │ └── tests.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── awsipranges ├── __init__.py ├── config.py ├── data_loading.py ├── exceptions.py ├── models │ ├── __init__.py │ ├── awsipprefix.py │ └── awsipprefixes.py └── utils.py ├── docs ├── api.md ├── assets │ └── styles │ │ └── extra.css ├── index.md └── quickstart.md ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── requirements-dev.txt └── tests ├── __init__.py ├── data ├── __init__.py └── test_syntax_and_semantics.py ├── integration ├── __init__.py └── test_package_apis.py ├── test_utils.py ├── unit ├── __init__.py ├── models │ ├── __init__.py │ └── test_awsipprefix.py ├── test_data_loading.py └── test_exceptions.py └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | ** 3 | 4 | # Include specific files and directories 5 | !/requirements-dev.txt 6 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 88 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | # We need to configure the mypy.ini because flake8-mypy's default 7 | # options don't properly override it, so if we don't specify it we get 8 | # half of the config from mypy.ini and half from flake8-mypy. 9 | mypy_config = mypy.ini 10 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: publish-docs 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | 8 | env: 9 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 10 | 11 | jobs: 12 | publish-docs: 13 | runs-on: ubuntu-latest 14 | if: github.event.repository.fork == false 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.x 20 | - name: Install Poetry 21 | run: curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python - 22 | - name: Add Poetry to system PATH 23 | run: echo "$HOME/.poetry/bin" >> $GITHUB_PATH 24 | - name: Install dependencies 25 | run: poetry install 26 | - name: Publish docs to GitHub Pages 27 | run: poetry run make publish-docs 28 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: publish-to-pypi 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | publish-to-pypi: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.x 16 | - name: Install Poetry 17 | run: curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python - 18 | - name: Add Poetry to system PATH 19 | run: echo "$HOME/.poetry/bin" >> $GITHUB_PATH 20 | - name: Install dependencies 21 | run: poetry install 22 | - name: Test 23 | run: poetry run make test 24 | - name: Build 25 | run: poetry build 26 | - name: Publish 27 | run: | 28 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 29 | poetry publish 30 | -------------------------------------------------------------------------------- /.github/workflows/test-aws-ip-ranges-data.yml: -------------------------------------------------------------------------------- 1 | name: test-aws-ip-ranges-data 2 | 3 | on: 4 | schedule: 5 | - cron: '59 23 * * *' 6 | 7 | jobs: 8 | test-aws-ip-ranges-data: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.x 15 | - name: Install dependencies 16 | run: | 17 | pip install --upgrade pip 18 | pip install -r requirements-dev.txt 19 | - name: Test AWS IP ranges data 20 | run: make data-tests 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | static-analysis: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-python@v2 11 | with: 12 | python-version: 3.x 13 | - name: Install dependencies 14 | run: | 15 | pip install --upgrade pip 16 | pip install -r requirements-dev.txt 17 | - name: Lint code 18 | run: make lint 19 | - name: Check style 20 | run: make check-style 21 | 22 | pytest-awsipranges: 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | os: [ubuntu-latest, macos-latest, windows-latest] 27 | python-version: [3.7, 3.8, 3.9] 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - uses: actions/checkout@v2 31 | - uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install dependencies 35 | run: | 36 | pip install --upgrade pip 37 | pip install -r requirements-dev.txt 38 | - name: Test awsipranges library 39 | run: make library-tests 40 | - name: "Upload coverage to Codecov" 41 | uses: codecov/codecov-action@v2 42 | with: 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | fail_ci_if_error: false 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | 142 | ### JupyterNotebooks template 143 | # gitignore template for Jupyter Notebooks 144 | # website: http://jupyter.org/ 145 | 146 | */.ipynb_checkpoints/* 147 | 148 | # IPython 149 | 150 | # Remove previous ipynb_checkpoints 151 | # git rm -r .ipynb_checkpoints/ 152 | 153 | ### Cloud9 template 154 | # Cloud9 IDE - http://c9.io 155 | .c9revisions 156 | .c9 157 | 158 | ### JetBrains template 159 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 160 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 161 | 162 | # User-specific stuff 163 | .idea/**/workspace.xml 164 | .idea/**/tasks.xml 165 | .idea/**/usage.statistics.xml 166 | .idea/**/dictionaries 167 | .idea/**/shelf 168 | 169 | # Generated files 170 | .idea/**/contentModel.xml 171 | 172 | # Sensitive or high-churn files 173 | .idea/**/dataSources/ 174 | .idea/**/dataSources.ids 175 | .idea/**/dataSources.local.xml 176 | .idea/**/sqlDataSources.xml 177 | .idea/**/dynamic.xml 178 | .idea/**/uiDesigner.xml 179 | .idea/**/dbnavigator.xml 180 | 181 | # Gradle 182 | .idea/**/gradle.xml 183 | .idea/**/libraries 184 | 185 | # Gradle and Maven with auto-import 186 | # When using Gradle or Maven with auto-import, you should exclude module files, 187 | # since they will be recreated, and may cause churn. Uncomment if using 188 | # auto-import. 189 | # .idea/artifacts 190 | # .idea/compiler.xml 191 | # .idea/jarRepositories.xml 192 | # .idea/modules.xml 193 | # .idea/*.iml 194 | # .idea/modules 195 | # *.iml 196 | # *.ipr 197 | 198 | # CMake 199 | cmake-build-*/ 200 | 201 | # Mongo Explorer plugin 202 | .idea/**/mongoSettings.xml 203 | 204 | # File-based project format 205 | *.iws 206 | 207 | # IntelliJ 208 | out/ 209 | 210 | # mpeltonen/sbt-idea plugin 211 | .idea_modules/ 212 | 213 | # JIRA plugin 214 | atlassian-ide-plugin.xml 215 | 216 | # Cursive Clojure plugin 217 | .idea/replstate.xml 218 | 219 | # Crashlytics plugin (for Android Studio and IntelliJ) 220 | com_crashlytics_export_strings.xml 221 | crashlytics.properties 222 | crashlytics-build.properties 223 | fabric.properties 224 | 225 | # Editor-based Rest Client 226 | .idea/httpRequests 227 | 228 | # Android studio 3.1+ serialized cache file 229 | .idea/caches/build_file_checksums.ser 230 | 231 | ### macOS template 232 | # General 233 | .DS_Store 234 | .AppleDouble 235 | .LSOverride 236 | 237 | # Icon must end with two \r 238 | Icon 239 | 240 | # Thumbnails 241 | ._* 242 | 243 | # Files that might appear in the root of a volume 244 | .DocumentRevisions-V100 245 | .fseventsd 246 | .Spotlight-V100 247 | .TemporaryItems 248 | .Trashes 249 | .VolumeIcon.icns 250 | .com.apple.timemachine.donotpresent 251 | 252 | # Directories potentially created on remote AFP share 253 | .AppleDB 254 | .AppleDesktop 255 | Network Trash Folder 256 | Temporary Items 257 | .apdisk 258 | 259 | ### VirtualEnv template 260 | # Virtualenv 261 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 262 | [Bb]in 263 | [Ii]nclude 264 | [Ll]ib 265 | [Ll]ib64 266 | [Ll]ocal 267 | [Ss]cripts 268 | pyvenv.cfg 269 | pip-selfcheck.json 270 | 271 | ### JetBrains template 272 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 273 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 274 | 275 | # User-specific stuff 276 | 277 | # Generated files 278 | 279 | # Sensitive or high-churn files 280 | 281 | # Gradle 282 | 283 | # Gradle and Maven with auto-import 284 | # When using Gradle or Maven with auto-import, you should exclude module files, 285 | # since they will be recreated, and may cause churn. Uncomment if using 286 | # auto-import. 287 | # .idea/artifacts 288 | # .idea/compiler.xml 289 | # .idea/jarRepositories.xml 290 | # .idea/modules.xml 291 | # .idea/*.iml 292 | # .idea/modules 293 | # *.iml 294 | # *.ipr 295 | 296 | # CMake 297 | 298 | # Mongo Explorer plugin 299 | 300 | # File-based project format 301 | 302 | # IntelliJ 303 | 304 | # mpeltonen/sbt-idea plugin 305 | 306 | # JIRA plugin 307 | 308 | # Cursive Clojure plugin 309 | 310 | # Crashlytics plugin (for Android Studio and IntelliJ) 311 | 312 | # Editor-based Rest Client 313 | 314 | # Android studio 3.1+ serialized cache file 315 | 316 | ### JupyterNotebooks template 317 | # gitignore template for Jupyter Notebooks 318 | # website: http://jupyter.org/ 319 | 320 | 321 | # IPython 322 | 323 | # Remove previous ipynb_checkpoints 324 | # git rm -r .ipynb_checkpoints/ 325 | 326 | ### VirtualEnv template 327 | # Virtualenv 328 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 329 | 330 | ### Python template 331 | # Byte-compiled / optimized / DLL files 332 | 333 | # C extensions 334 | 335 | # Distribution / packaging 336 | 337 | # PyInstaller 338 | # Usually these files are written by a python script from a template 339 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 340 | 341 | # Installer logs 342 | 343 | # Unit test / coverage reports 344 | 345 | # Translations 346 | 347 | # Django stuff: 348 | 349 | # Flask stuff: 350 | 351 | # Scrapy stuff: 352 | 353 | # Sphinx documentation 354 | 355 | # PyBuilder 356 | 357 | # Jupyter Notebook 358 | 359 | # IPython 360 | 361 | # pyenv 362 | # For a library or package, you might want to ignore these files since the code is 363 | # intended to run in multiple environments; otherwise, check them in: 364 | # .python-version 365 | 366 | # pipenv 367 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 368 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 369 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 370 | # install all needed dependencies. 371 | #Pipfile.lock 372 | 373 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 374 | 375 | # Celery stuff 376 | 377 | # SageMath parsed files 378 | 379 | # Environments 380 | 381 | # Spyder project settings 382 | 383 | # Rope project settings 384 | 385 | # mkdocs documentation 386 | 387 | # mypy 388 | 389 | # Pyre type checker 390 | 391 | # pytype static type analyzer 392 | 393 | # Cython debug symbols 394 | 395 | ### SAM template 396 | # gitignore template for AWS Serverless Application Model project 397 | # website: https://docs.aws.amazon.com/serverless-application-model 398 | 399 | # Ignore build folder 400 | .aws-sam/ 401 | 402 | ### JupyterNotebooks template 403 | # gitignore template for Jupyter Notebooks 404 | # website: http://jupyter.org/ 405 | 406 | 407 | # IPython 408 | 409 | # Remove previous ipynb_checkpoints 410 | # git rm -r .ipynb_checkpoints/ 411 | 412 | ### SAM template 413 | # gitignore template for AWS Serverless Application Model project 414 | # website: https://docs.aws.amazon.com/serverless-application-model 415 | 416 | # Ignore build folder 417 | 418 | ### VirtualEnv template 419 | # Virtualenv 420 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 421 | 422 | ### Python template 423 | # Byte-compiled / optimized / DLL files 424 | 425 | # C extensions 426 | 427 | # Distribution / packaging 428 | 429 | # PyInstaller 430 | # Usually these files are written by a python script from a template 431 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 432 | 433 | # Installer logs 434 | 435 | # Unit test / coverage reports 436 | 437 | # Translations 438 | 439 | # Django stuff: 440 | 441 | # Flask stuff: 442 | 443 | # Scrapy stuff: 444 | 445 | # Sphinx documentation 446 | 447 | # PyBuilder 448 | 449 | # Jupyter Notebook 450 | 451 | # IPython 452 | 453 | # pyenv 454 | # For a library or package, you might want to ignore these files since the code is 455 | # intended to run in multiple environments; otherwise, check them in: 456 | # .python-version 457 | 458 | # pipenv 459 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 460 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 461 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 462 | # install all needed dependencies. 463 | #Pipfile.lock 464 | 465 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 466 | 467 | # Celery stuff 468 | 469 | # SageMath parsed files 470 | 471 | # Environments 472 | 473 | # Spyder project settings 474 | 475 | # Rope project settings 476 | 477 | # mkdocs documentation 478 | 479 | # mypy 480 | 481 | # Pyre type checker 482 | 483 | # pytype static type analyzer 484 | 485 | # Cython debug symbols 486 | 487 | ### JetBrains template 488 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 489 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 490 | 491 | # User-specific stuff 492 | 493 | # Generated files 494 | 495 | # Sensitive or high-churn files 496 | 497 | # Gradle 498 | 499 | # Gradle and Maven with auto-import 500 | # When using Gradle or Maven with auto-import, you should exclude module files, 501 | # since they will be recreated, and may cause churn. Uncomment if using 502 | # auto-import. 503 | # .idea/artifacts 504 | # .idea/compiler.xml 505 | # .idea/jarRepositories.xml 506 | # .idea/modules.xml 507 | # .idea/*.iml 508 | # .idea/modules 509 | # *.iml 510 | # *.ipr 511 | 512 | # CMake 513 | 514 | # Mongo Explorer plugin 515 | 516 | # File-based project format 517 | 518 | # IntelliJ 519 | 520 | # mpeltonen/sbt-idea plugin 521 | 522 | # JIRA plugin 523 | 524 | # Cursive Clojure plugin 525 | 526 | # Crashlytics plugin (for Android Studio and IntelliJ) 527 | 528 | # Editor-based Rest Client 529 | 530 | # Android studio 3.1+ serialized cache file 531 | 532 | ### JupyterNotebooks template 533 | # gitignore template for Jupyter Notebooks 534 | # website: http://jupyter.org/ 535 | 536 | 537 | # IPython 538 | 539 | # Remove previous ipynb_checkpoints 540 | # git rm -r .ipynb_checkpoints/ 541 | 542 | ### Cloud9 template 543 | # Cloud9 IDE - http://c9.io 544 | 545 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for running the docs development server locally 2 | FROM python:3.8 3 | 4 | WORKDIR /awsipranges 5 | 6 | ENV PYTHONPATH=/awsipranges 7 | 8 | # Install Python dependencies 9 | COPY requirements-dev.txt ./ 10 | RUN pip install -r requirements-dev.txt 11 | 12 | EXPOSE 8000 13 | 14 | CMD ["mkdocs", "serve", "--dev-addr=0.0.0.0:8000"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY : serve update test lint check-style tests fast-tests data-tests docker 2 | 3 | serve : docker 4 | docker run --rm -it -p 8000:8000 -v ${PWD}:/awsipranges awsipranges:local 5 | 6 | install : pyproject.toml 7 | poetry install 8 | 9 | update : poetry-update requirements-dev.txt 10 | 11 | poetry-update : pyproject.toml 12 | poetry update 13 | 14 | poetry.lock : pyproject.toml 15 | poetry lock 16 | 17 | requirements-dev.txt : poetry.lock 18 | poetry export --dev --without-hashes -o requirements-dev.txt 19 | 20 | test : lint check-style tests 21 | 22 | lint : 23 | flake8 24 | 25 | check-style : 26 | black --check . 27 | 28 | tests : 29 | pytest --cov=awsipranges 30 | 31 | fast-tests : 32 | pytest --cov=awsipranges -m "not data and not extra_data_loading and not slow and not test_utils" 33 | 34 | library-tests : 35 | pytest --cov=awsipranges --cov-report=xml -m "not data" 36 | 37 | data-tests : 38 | pytest -m "data" 39 | 40 | docker : Dockerfile requirements-dev.txt 41 | docker build -t awsipranges:local ./ 42 | 43 | publish-docs : 44 | mkdocs gh-deploy --force 45 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | awsipranges 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # awsipranges 2 | 3 | *Work with the AWS IP address ranges in native Python.* 4 | 5 | [![License](https://img.shields.io/github/license/aws-samples/awsipranges)](https://github.com/aws-samples/awsipranges/blob/main/LICENSE) 6 | [![PyPI](https://img.shields.io/pypi/v/awsipranges.svg)](https://pypi.org/project/awsipranges/) 7 | [![Code Coverage](https://img.shields.io/codecov/c/github/aws-samples/awsipranges)](https://app.codecov.io/github/aws-samples/awsipranges/) 8 | [![Build](https://img.shields.io/github/workflow/status/aws-samples/awsipranges/tests)](https://github.com/aws-samples/awsipranges/actions/workflows/tests.yml) 9 | [![Docs](https://img.shields.io/github/workflow/status/aws-samples/awsipranges/publish-docs?label=docs)](https://aws-samples.github.io/awsipranges/) 10 | 11 | --- 12 | 13 | Amazon Web Services (AWS) publishes its [current IP address ranges](https://docs.aws.amazon.com/general/latest/gr/aws-ip-ranges.html) in [JSON](https://ip-ranges.amazonaws.com/ip-ranges.json) format. Python v3 provides an [ipaddress](https://docs.python.org/3/library/ipaddress.html) module in the standard library that allows you to create, manipulate, and perform operations on IPv4 and IPv6 addresses and networks. Wouldn't it be nice if you could work with the AWS IP address ranges like native Python objects? 14 | 15 | ## Features 16 | 17 | - Work with the AWS IP prefixes as a simple `AWSIPPrefixes` collection. 18 | - Quickly check if an IP address, interface, or network is contained in the AWS IP address ranges. 19 | - Get the AWS IP prefix that contains an IP address, interface, or network. 20 | - See what services are served from an IP prefix. 21 | - Filter the AWS IP prefixes by _region_, _network border group_, _service_, and IP prefix _version_. 22 | - Use the AWS prefix data in your app or automation scripts in the format required by your infrastructure. 23 | - Easily validate the TLS certificate presented by the IP ranges server. 24 | - awsipranges has _no third-party dependencies_ and is compatible with CPython v3.7+. 25 | 26 | ```python 27 | >>> import awsipranges 28 | 29 | >>> aws_ip_ranges = awsipranges.get_ranges(cafile="amazon_root_certificates.pem") 30 | 31 | >>> '52.94.5.15' in aws_ip_ranges 32 | True 33 | 34 | >>> aws_ip_ranges['52.94.5.15'] 35 | AWSIPv4Prefix('52.94.5.0/24', region='eu-west-1', network_border_group='eu-west-1', services=('AMAZON', 'DYNAMODB')) 36 | 37 | >>> aws_ip_ranges.filter(services='CODEBUILD') 38 | {'create_date': datetime.datetime(2021, 8, 24, 1, 31, 14, tzinfo=datetime.timezone.utc), 39 | 'ipv4_prefixes': (AWSIPv4Prefix('3.26.127.24/29', region='ap-southeast-2', network_border_group='ap-southeast-2', services=('CODEBUILD',)), 40 | AWSIPv4Prefix('3.38.90.8/29', region='ap-northeast-2', network_border_group='ap-northeast-2', services=('CODEBUILD',)), 41 | AWSIPv4Prefix('3.68.251.232/29', region='eu-central-1', network_border_group='eu-central-1', services=('CODEBUILD',)), 42 | AWSIPv4Prefix('3.98.171.224/29', region='ca-central-1', network_border_group='ca-central-1', services=('CODEBUILD',)), 43 | AWSIPv4Prefix('3.101.177.48/29', region='us-west-1', network_border_group='us-west-1', services=('CODEBUILD',)), 44 | ...), 45 | 'ipv6_prefixes': (), 46 | 'sync_token': '1629768674'} 47 | 48 | >>> for prefix in aws_ip_ranges.filter(regions='eu-west-1', services='DYNAMODB'): 49 | ... print(prefix.network_address, prefix.netmask) 50 | ... 51 | 52.94.5.0 255.255.255.0 52 | 52.94.24.0 255.255.254.0 53 | 52.94.26.0 255.255.254.0 54 | 52.119.240.0 255.255.248.0 55 | ``` 56 | 57 | ## Installation 58 | 59 | Installing and upgrading `awsipranges` is easy: 60 | 61 | **Install via PIP** 62 | 63 | ```shell 64 | ❯ pip install awsipranges 65 | ``` 66 | 67 | **Upgrade to the latest version** 68 | 69 | ```shell 70 | ❯ pip install --upgrade awsipranges 71 | ``` 72 | 73 | ## Documentation 74 | 75 | Excellent documentation is now available at: https://aws-samples.github.io/awsipranges/ 76 | 77 | Check out the [Quickstart](https://aws-samples.github.io/awsipranges/quickstart.html) to dive in and begin using awsipranges. 78 | 79 | ## Contribute 80 | 81 | See [CONTRIBUTING](https://github.com/aws-samples/awsipranges/blob/main/CONTRIBUTING.md) for information on how to contribute to this project. 82 | 83 | ## Security 84 | 85 | See [CONTRIBUTING](https://github.com/aws-samples/awsipranges/blob/main/CONTRIBUTING.md#security-issue-notifications) for information on how to report a security issue with this project. 86 | 87 | ## License 88 | 89 | This project is licensed under the [Apache-2.0 License](https://github.com/aws-samples/awsipranges/blob/main/LICENSE). 90 | -------------------------------------------------------------------------------- /awsipranges/__init__.py: -------------------------------------------------------------------------------- 1 | """Work with AWS IP address ranges in native Python.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). 6 | # You may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from awsipranges.data_loading import get_ranges # noqa: F401 18 | from awsipranges.exceptions import AWSIPRangesException, HTTPError # noqa: F401 19 | from awsipranges.models.awsipprefix import ( # noqa: F401 20 | AWSIPPrefix, 21 | AWSIPv4Prefix, 22 | AWSIPv6Prefix, 23 | ) 24 | from awsipranges.models.awsipprefixes import AWSIPPrefixes # noqa: F401 25 | -------------------------------------------------------------------------------- /awsipranges/config.py: -------------------------------------------------------------------------------- 1 | """Package configurations.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | from datetime import timezone 7 | 8 | 9 | AWS_IP_ADDRESS_RANGES_URL = "https://ip-ranges.amazonaws.com/ip-ranges.json" 10 | 11 | CREATE_DATE_FORMAT = "%Y-%m-%d-%H-%M-%S" 12 | CREATE_DATE_TIMEZONE = timezone.utc 13 | -------------------------------------------------------------------------------- /awsipranges/data_loading.py: -------------------------------------------------------------------------------- 1 | """Load AWS IP address ranges.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | import hashlib 7 | import json 8 | import urllib.request 9 | from datetime import datetime 10 | from pathlib import Path 11 | from typing import Any, Dict, Optional, Tuple, Union 12 | 13 | from awsipranges.config import ( 14 | AWS_IP_ADDRESS_RANGES_URL, 15 | CREATE_DATE_FORMAT, 16 | CREATE_DATE_TIMEZONE, 17 | ) 18 | from awsipranges.exceptions import raise_for_status 19 | from awsipranges.models.awsipprefix import aws_ip_prefix 20 | from awsipranges.models.awsipprefixes import AWSIPPrefixes 21 | from awsipranges.utils import check_type 22 | 23 | 24 | def get_json_data( 25 | cafile: Union[str, Path, None] = None, capath: Union[str, Path, None] = None 26 | ) -> Tuple[Dict[str, Any], Optional[str]]: 27 | """Retrieve and parse the AWS IP address ranges JSON file.""" 28 | check_type("cafile", cafile, (str, Path), optional=True) 29 | cafile = Path(cafile) if isinstance(cafile, str) else cafile 30 | if cafile and not cafile.is_file(): 31 | raise ValueError( 32 | "Invalid path; cafile must be a path to a bundled CA certificate file." 33 | ) 34 | 35 | check_type("capath", capath, (str, Path), optional=True) 36 | capath = Path(capath) if isinstance(capath, str) else capath 37 | if capath and not capath.is_dir(): 38 | raise ValueError("Invalid path; capath must be a path to a directory.") 39 | 40 | with urllib.request.urlopen( 41 | AWS_IP_ADDRESS_RANGES_URL, cafile=cafile, capath=capath 42 | ) as response: 43 | raise_for_status(response) 44 | 45 | response_bytes = response.read() 46 | response_data = json.loads(response_bytes) 47 | 48 | if "md5" in hashlib.algorithms_available: 49 | md5_hash = hashlib.md5() 50 | md5_hash.update(response_bytes) 51 | md5_hex_digest = md5_hash.hexdigest() 52 | else: 53 | md5_hex_digest = None 54 | 55 | return response_data, md5_hex_digest 56 | 57 | 58 | def get_ranges(cafile: Path = None, capath: Path = None) -> AWSIPPrefixes: 59 | """Get the AWS IP address ranges from the published JSON document. 60 | 61 | It is your responsibility to verify the TLS certificate presented by the 62 | server. By default, the Python 63 | [urllib](https://docs.python.org/3/library/urllib.html) module (used by 64 | this function) verifies the TLS certificate presented by the server against 65 | the system-provided certificate datastore. 66 | 67 | You can download the Amazon root CA certificates from the 68 | [Amazon Trust Services](https://www.amazontrust.com/repository/) 69 | repository. 70 | 71 | The optional `cafile` and `capath` parameters may be used to specify a set 72 | of trusted CA certificates for the HTTPS request. `cafile` should point to a 73 | single file containing a bundle of CA certificates, whereas `capath` 74 | should point to a directory of certificate files with OpenSSL hash filenames. 75 | To verify the TLS certificate against Amazon root certificates, download the 76 | CA certificates (in PEM format) from Amazon Trust Services and provide the 77 | path to the certificate(s) using the `cafile` or `capath` parameters. 78 | 79 | See the OpenSSL [SSL_CTX_load_verify_locations](https://www.openssl.org/docs/man1.1.0/man3/SSL_CTX_set_default_verify_dir.html) 80 | documentation for details on the expected CAfile and CApath file formats. 81 | 82 | **Parameters:** 83 | 84 | - **cafile** (_optional_ Path) - path to a file of stacked (concatenated) CA 85 | certificates in PEM format 86 | 87 | - **capath** (_optional_ Path) - path to a directory containing one or more 88 | certificates in PEM format using the OpenSSL subject-name-hash filenames 89 | 90 | **Returns:** 91 | 92 | The AWS IP address ranges in a `AWSIPPrefixes` collection. 93 | """ 94 | json_data, json_md5 = get_json_data(cafile=cafile, capath=capath) 95 | 96 | assert "syncToken" in json_data 97 | assert "createDate" in json_data 98 | assert "prefixes" in json_data 99 | assert "ipv6_prefixes" in json_data 100 | 101 | return AWSIPPrefixes( 102 | sync_token=json_data["syncToken"], 103 | create_date=datetime.strptime( 104 | json_data["createDate"], CREATE_DATE_FORMAT 105 | ).replace(tzinfo=CREATE_DATE_TIMEZONE), 106 | ipv4_prefixes=(aws_ip_prefix(record) for record in json_data["prefixes"]), 107 | ipv6_prefixes=(aws_ip_prefix(record) for record in json_data["ipv6_prefixes"]), 108 | md5=json_md5, 109 | ) 110 | -------------------------------------------------------------------------------- /awsipranges/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions.""" 2 | 3 | from typing import Optional, Tuple 4 | 5 | 6 | class AWSIPRangesException(Exception): 7 | """Base class for all awsipranges exceptions.""" 8 | 9 | 10 | class HTTPError(AWSIPRangesException): 11 | """An HTTP/HTTPS error.""" 12 | 13 | args: Tuple[object, ...] 14 | status: Optional[int] 15 | reason: Optional[str] 16 | 17 | def __init__(self, *args, status: Optional[int], reason: Optional[str]): 18 | super(HTTPError, self).__init__(*args) 19 | self.args = args 20 | self.status = status 21 | self.reason = reason 22 | 23 | def __repr__(self): 24 | return ( 25 | f"{self.__class__.__name__}(" 26 | f"{', '.join([repr(arg) for arg in self.args])}, " 27 | f"status={self.status!r}, " 28 | f"reason={self.reason!r}" 29 | f")" 30 | ) 31 | 32 | def __str__(self): 33 | msg = [] 34 | if self.status: 35 | msg.append(str(self.status)) 36 | 37 | if self.reason: 38 | msg.append(self.reason) 39 | 40 | if self.args: 41 | if msg: 42 | msg.append("-") 43 | msg += [str(arg) for arg in self.args] 44 | 45 | return " ".join(msg) 46 | 47 | 48 | def raise_for_status(response): 49 | """Raise an HTTPError on 4xx and 5xx status codes.""" 50 | # Get the status code 51 | if hasattr(response, "status"): 52 | status = int(response.status) 53 | elif hasattr(response, "code"): 54 | status = int(response.code) 55 | elif hasattr(response, "getstatus"): 56 | status = int(response.getstatus()) 57 | else: 58 | raise ValueError( 59 | f"Response object {response!r} does not contain a status code." 60 | ) 61 | 62 | # Get the URL 63 | if hasattr(response, "url"): 64 | url = response.url 65 | elif hasattr(response, "geturl"): 66 | url = response.geturl() 67 | else: 68 | raise ValueError(f"Response object {response!r} does not contain a url.") 69 | 70 | # Get the reason, if available 71 | reason = response.reason if hasattr(response, "reason") else None 72 | 73 | if 400 <= status < 500: 74 | raise HTTPError(f"Client error for URL: {url}", status=status, reason=reason) 75 | 76 | if 500 <= status < 600: 77 | raise HTTPError(f"Server error for URL: {url}", status=status, reason=reason) 78 | -------------------------------------------------------------------------------- /awsipranges/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/awsipranges/24455318d1fa4b6d206e49800451d096a84e100d/awsipranges/models/__init__.py -------------------------------------------------------------------------------- /awsipranges/models/awsipprefix.py: -------------------------------------------------------------------------------- 1 | """Model an AWS IP Prefix as a native Python object.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | import itertools 7 | from abc import ABC, abstractmethod 8 | from functools import total_ordering 9 | from ipaddress import ( 10 | ip_network, 11 | IPv4Address, 12 | IPv4Interface, 13 | IPv4Network, 14 | IPv6Address, 15 | IPv6Interface, 16 | IPv6Network, 17 | ) 18 | from typing import Any, Dict, Iterable, Tuple, Union 19 | 20 | from awsipranges.utils import check_type 21 | 22 | 23 | @total_ordering 24 | class AWSIPPrefix(ABC): 25 | """AWS IP Prefix.""" 26 | 27 | __slots__ = ["_prefix", "_region", "_network_border_group", "_services"] 28 | 29 | _prefix: Union[IPv4Network, IPv6Network] 30 | _region: str 31 | _network_border_group: str 32 | _services: Tuple[str, ...] 33 | 34 | def __init__( 35 | self, 36 | prefix: Union[str, IPv4Network, IPv6Network], 37 | region: str, 38 | network_border_group: str, 39 | services: Union[str, Iterable[str]], 40 | ) -> None: 41 | super().__init__() 42 | 43 | check_type("prefix", prefix, (str, IPv4Network, IPv6Network)) 44 | check_type("region", region, str) 45 | check_type("network_border_group", network_border_group, str) 46 | check_type("services", services, (str, tuple)) 47 | 48 | self._prefix = ip_network(prefix) if isinstance(prefix, str) else prefix 49 | self._region = region 50 | self._network_border_group = network_border_group 51 | self._services = ( 52 | (services,) if isinstance(services, str) else tuple(sorted(services)) 53 | ) 54 | 55 | @property 56 | @abstractmethod 57 | def prefix(self) -> Union[IPv4Network, IPv6Network]: 58 | """The public IP network prefix.""" 59 | return self._prefix 60 | 61 | @property 62 | def region(self) -> str: 63 | """The AWS Region or `GLOBAL` for edge locations. 64 | 65 | The `CLOUDFRONT` and `ROUTE53` ranges are GLOBAL. 66 | """ 67 | return self._region 68 | 69 | @property 70 | def network_border_group(self) -> str: 71 | """The name of the network border group. 72 | 73 | A network border group is a unique set of Availability Zones or Local 74 | Zones from where AWS advertises IP addresses. 75 | """ 76 | return self._network_border_group 77 | 78 | @property 79 | def services(self) -> Tuple[str, ...]: 80 | """Services that use IP addresses in this IP prefix. 81 | 82 | The addresses listed for `API_GATEWAY` are egress only. 83 | 84 | The service `"AMAZON"` is not a service but rather an identifier used 85 | to get all IP address ranges - meaning that every prefix is contained in 86 | the subset of prefixes tagged with the `"AMAZON"` service. Some IP 87 | address ranges are only tagged with the `"AMAZON"` service. 88 | """ 89 | return self._services 90 | 91 | def __repr__(self) -> str: 92 | """An executable string representation of this object.""" 93 | return ( 94 | f"{self.__class__.__name__}(" 95 | f"{str(self._prefix)!r}, " 96 | f"region={self._region!r}, " 97 | f"network_border_group={self._network_border_group!r}, " 98 | f"services={self._services!r}" 99 | f")" 100 | ) 101 | 102 | def __str__(self) -> str: 103 | """The IP prefix in CIDR notation.""" 104 | return self._prefix.with_prefixlen 105 | 106 | @property 107 | def __tuple(self) -> tuple: 108 | """A tuple representation of the AWS IP prefix.""" 109 | return self._prefix, self._region, self._network_border_group, self._services 110 | 111 | def __eq__(self, other) -> bool: 112 | """Compare for equality. 113 | 114 | This method allows comparisons between AWSIPPrefix objects, None, 115 | IPv4Network and IPv6Network objects, and strings that can be converted 116 | to IPv4Network and IPv6Network objects. 117 | 118 | **Raises:** 119 | 120 | A `ValueError` exception if the `other` object is a string and 121 | cannot be converted to an IPv4Network or IPv6Network object. 122 | 123 | A `TypeError` if the `other` object is of an unsupported type. 124 | """ 125 | if other is None: 126 | return False 127 | 128 | if isinstance(other, str): 129 | other = ip_network(other) 130 | 131 | if isinstance(other, (IPv4Network, IPv6Network)): 132 | return self.prefix == other 133 | 134 | if isinstance(other, AWSIPPrefix): 135 | return self.__tuple == other.__tuple 136 | 137 | raise TypeError( 138 | f"Cannot compare an AWSIPPrefix object with an object of type" 139 | f" {other!r}." 140 | ) 141 | 142 | def __hash__(self) -> int: 143 | return hash(self.__tuple) 144 | 145 | def __lt__(self, other) -> bool: 146 | """Comparison operator to facilitate sorting. 147 | 148 | **Sort order:** 149 | 150 | - IPv4 prefixes before IPv6 prefixes 151 | - IP network addresses in ascending order 152 | - IP prefix length in ascending order 153 | - Region in ascending order 154 | - Network border group in ascending order 155 | - Services in ascending order 156 | """ 157 | # Allow comparison between AWSIPPrefixes and Python native IPv4 and IPv6 158 | # network objects. 159 | if isinstance(other, (IPv4Network, IPv6Network)): 160 | if self.prefix != other: 161 | return self.prefix < other 162 | else: 163 | return False 164 | 165 | # Compare two AWSIPPrefix objects 166 | if self.prefix.version != other.prefix.version: 167 | return self.prefix.version < other.prefix.version 168 | 169 | if self.prefix.network_address != other.prefix.network_address: 170 | return self.prefix.network_address < other.prefix.network_address 171 | 172 | if self.prefix.prefixlen != other.prefix.prefixlen: 173 | return self.prefix.prefixlen < other.prefix.prefixlen 174 | 175 | if self.region != other.region: 176 | return self.region < other.region 177 | 178 | if self.network_border_group != other.network_border_group: 179 | return self.network_border_group < other.network_border_group 180 | 181 | if self.services != other.services: 182 | return self.services < other.services 183 | 184 | return False 185 | 186 | def __contains__( 187 | self, 188 | item: Union[ 189 | str, 190 | IPv4Address, 191 | IPv4Network, 192 | IPv4Interface, 193 | IPv6Address, 194 | IPv6Network, 195 | IPv6Interface, 196 | ], 197 | ) -> bool: 198 | check_type( 199 | "item", 200 | item, 201 | ( 202 | str, 203 | IPv4Address, 204 | IPv4Network, 205 | IPv4Interface, 206 | IPv6Address, 207 | IPv6Network, 208 | IPv6Interface, 209 | ), 210 | ) 211 | 212 | item_network = ip_network(item, strict=False) 213 | 214 | if item_network.version != self.version: 215 | return False 216 | 217 | return item_network.subnet_of(self._prefix) 218 | 219 | @property 220 | def version(self) -> int: 221 | """The IP version (4, 6).""" 222 | return self._prefix.version 223 | 224 | @property 225 | @abstractmethod 226 | def network_address(self) -> Union[IPv4Address, IPv6Address]: 227 | """The network address for the network.""" 228 | return self._prefix.network_address 229 | 230 | @property 231 | def prefixlen(self) -> int: 232 | """Length of the network prefix, in bits.""" 233 | return self._prefix.prefixlen 234 | 235 | @property 236 | def with_prefixlen(self) -> str: 237 | """A string representation of the IP prefix, in network/prefix notation.""" 238 | return self._prefix.with_prefixlen 239 | 240 | @property 241 | @abstractmethod 242 | def netmask(self) -> Union[IPv4Address, IPv6Address]: 243 | """The net mask, as an IP Address object.""" 244 | return self._prefix.netmask 245 | 246 | @property 247 | def with_netmask(self) -> str: 248 | """A string representation of the network, with the mask in net mask notation.""" 249 | return self._prefix.with_netmask 250 | 251 | @property 252 | @abstractmethod 253 | def hostmask(self) -> Union[IPv4Address, IPv6Address]: 254 | """The host mask (aka. wildcard mask), as an IP Address object.""" 255 | return self._prefix.hostmask 256 | 257 | @property 258 | def with_hostmask(self) -> str: 259 | """A string representation of the network, with the mask in host mask notation.""" 260 | return self._prefix.with_hostmask 261 | 262 | @property 263 | def num_addresses(self) -> int: 264 | """The total number of addresses in the network.""" 265 | return self._prefix.num_addresses 266 | 267 | def __getattr__(self, name: str) -> Any: 268 | """Proxy all other attributes to self._prefix.""" 269 | return getattr(self._prefix, name) 270 | 271 | 272 | class AWSIPv4Prefix(AWSIPPrefix): 273 | """AWS IPv4 Prefix.""" 274 | 275 | __slots__ = ["_prefix", "_region", "_network_border_group", "_services"] 276 | 277 | _prefix: IPv4Network 278 | 279 | def __init__( 280 | self, 281 | prefix: Union[str, IPv4Network], 282 | region: str, 283 | network_border_group: str, 284 | services: Union[str, Iterable[str]], 285 | ) -> None: 286 | super().__init__( 287 | prefix=prefix, 288 | region=region, 289 | network_border_group=network_border_group, 290 | services=services, 291 | ) 292 | check_type("prefix", self._prefix, IPv4Network) 293 | 294 | @property 295 | def prefix(self) -> IPv4Network: 296 | """The public IPv4 network prefix.""" 297 | return self._prefix 298 | 299 | @property 300 | def ip_prefix(self) -> IPv4Network: 301 | """The public IPv4 network prefix. 302 | 303 | This is a convenience attribute to maintain API compatibility with the 304 | JSON attribute names. 305 | """ 306 | return self._prefix 307 | 308 | @property 309 | def network_address(self) -> IPv4Address: 310 | """The network address for the network.""" 311 | return self._prefix.network_address 312 | 313 | @property 314 | def netmask(self) -> IPv4Address: 315 | """The net mask, as an IPv4Address object.""" 316 | return self._prefix.netmask 317 | 318 | @property 319 | def hostmask(self) -> IPv4Address: 320 | """The host mask (aka. wildcard mask), as an IPv4Address object.""" 321 | return self._prefix.hostmask 322 | 323 | 324 | class AWSIPv6Prefix(AWSIPPrefix): 325 | """AWS IPv6 Prefix.""" 326 | 327 | __slots__ = ["_prefix", "_region", "_network_border_group", "_services"] 328 | 329 | _prefix: IPv6Network 330 | 331 | def __init__( 332 | self, 333 | prefix: Union[str, IPv6Network], 334 | region: str, 335 | network_border_group: str, 336 | services: Union[str, Iterable[str]], 337 | ) -> None: 338 | super().__init__( 339 | prefix=prefix, 340 | region=region, 341 | network_border_group=network_border_group, 342 | services=services, 343 | ) 344 | check_type("prefix", self._prefix, IPv6Network) 345 | 346 | @property 347 | def prefix(self) -> IPv6Network: 348 | """The public IPv6 network prefix.""" 349 | return self._prefix 350 | 351 | @property 352 | def ipv6_prefix(self) -> IPv6Network: 353 | """The public IPv6 network prefix. 354 | 355 | This is a convenience attribute to maintain API compatibility with the 356 | JSON attribute names. 357 | """ 358 | return self._prefix 359 | 360 | @property 361 | def network_address(self) -> IPv6Address: 362 | """The network address for the network.""" 363 | return self._prefix.network_address 364 | 365 | @property 366 | def netmask(self) -> IPv6Address: 367 | """The net mask, as an IPv6Address object.""" 368 | return self._prefix.netmask 369 | 370 | @property 371 | def hostmask(self) -> IPv6Address: 372 | """The host mask (aka. wildcard mask), as an IPv6Address object.""" 373 | return self._prefix.hostmask 374 | 375 | 376 | def aws_ip_prefix(json_data: Dict[str, str]) -> Union[AWSIPv4Prefix, AWSIPv6Prefix]: 377 | """Factory function to create AWS IP Prefix objects from JSON data.""" 378 | check_type("data", json_data, dict) 379 | assert "ip_prefix" in json_data or "ipv6_prefix" in json_data 380 | assert "region" in json_data 381 | assert "network_border_group" in json_data 382 | assert "service" in json_data 383 | 384 | if "ip_prefix" in json_data: 385 | return AWSIPv4Prefix( 386 | prefix=json_data["ip_prefix"], 387 | region=json_data["region"], 388 | network_border_group=json_data["network_border_group"], 389 | services=json_data["service"], 390 | ) 391 | 392 | if "ipv6_prefix" in json_data: 393 | return AWSIPv6Prefix( 394 | prefix=json_data["ipv6_prefix"], 395 | region=json_data["region"], 396 | network_border_group=json_data["network_border_group"], 397 | services=json_data["service"], 398 | ) 399 | 400 | 401 | def combine_prefixes( 402 | prefixes: Iterable[Union[AWSIPv4Prefix, AWSIPv6Prefix]] 403 | ) -> Union[AWSIPv4Prefix, AWSIPv6Prefix]: 404 | """Combine multiple AWS IP prefix records into a single AWS IP prefix. 405 | 406 | The prefix records should have identical prefixes, regions, and network 407 | border groups. Only the services should be combined. 408 | """ 409 | prefixes = list(prefixes) 410 | assert len(prefixes) > 1 411 | 412 | first = prefixes[0] 413 | 414 | check_type("prefixes", first, (AWSIPv4Prefix, AWSIPv6Prefix)) 415 | for prefix in prefixes[1:]: 416 | check_type("prefixes", prefix, type(first)) 417 | 418 | # Ensure prefixes are the same (only services are different) before combining 419 | if not all( 420 | [ 421 | (prefix.prefix, prefix.region, prefix.network_border_group) 422 | == (first.prefix, first.region, first.network_border_group) 423 | for prefix in prefixes[1:] 424 | ] 425 | ): 426 | raise ValueError( 427 | "Cannot combine prefixes with different prefix, region, or " 428 | "network_border_group values." 429 | ) 430 | 431 | # Combine the services 432 | services = set( 433 | itertools.chain.from_iterable((prefix.services for prefix in prefixes)) 434 | ) 435 | services = tuple(sorted(services)) 436 | 437 | return type(first)( 438 | prefix=first.prefix, 439 | region=first.region, 440 | network_border_group=first.network_border_group, 441 | services=services, 442 | ) 443 | -------------------------------------------------------------------------------- /awsipranges/models/awsipprefixes.py: -------------------------------------------------------------------------------- 1 | """Model a set of AWS IP address prefixes as a python collection.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | import itertools 7 | import pprint 8 | from bisect import bisect_left 9 | from collections import defaultdict 10 | from datetime import datetime 11 | from ipaddress import ( 12 | ip_network, 13 | IPv4Address, 14 | IPv4Interface, 15 | IPv4Network, 16 | IPv6Address, 17 | IPv6Interface, 18 | IPv6Network, 19 | ) 20 | from typing import FrozenSet, Iterable, Optional, Tuple, Union 21 | 22 | from awsipranges.models.awsipprefix import ( 23 | AWSIPv4Prefix, 24 | AWSIPv6Prefix, 25 | combine_prefixes, 26 | ) 27 | from awsipranges.utils import check_type, normalize_to_set, supernets, validate_values 28 | 29 | 30 | # Main class 31 | class AWSIPPrefixes(object): 32 | """A collection of AWS IP address prefixes.""" 33 | 34 | _sync_token: Optional[str] 35 | _create_date: Optional[datetime] 36 | _ipv4_prefixes: Tuple[AWSIPv4Prefix, ...] 37 | _ipv6_prefixes: Tuple[AWSIPv6Prefix, ...] 38 | _md5: Optional[str] 39 | 40 | _regions: Optional[FrozenSet[str]] = None 41 | _network_border_groups: Optional[FrozenSet[str]] = None 42 | _services: Optional[FrozenSet[str]] = None 43 | 44 | def __init__( 45 | self, 46 | sync_token: Optional[str] = None, 47 | create_date: Optional[datetime] = None, 48 | ipv4_prefixes: Iterable[AWSIPv4Prefix] = None, 49 | ipv6_prefixes: Iterable[AWSIPv6Prefix] = None, 50 | md5: Optional[str] = None, 51 | ) -> None: 52 | super().__init__() 53 | 54 | check_type("sync_token", sync_token, str, optional=True) 55 | check_type("create_date", create_date, datetime, optional=True) 56 | check_type("ipv4_prefixes", ipv4_prefixes, Iterable) 57 | check_type("ipv6_prefixes", ipv6_prefixes, Iterable) 58 | check_type("md5", md5, str, optional=True) 59 | 60 | self._sync_token = sync_token 61 | self._create_date = create_date 62 | self._ipv4_prefixes = self._process_prefixes(ipv4_prefixes) 63 | self._ipv6_prefixes = self._process_prefixes(ipv6_prefixes) 64 | self._md5 = md5 65 | 66 | @staticmethod 67 | def _process_prefixes( 68 | prefixes: Iterable[Union[AWSIPv4Prefix, AWSIPv6Prefix]], 69 | ) -> Tuple[Union[AWSIPv4Prefix, AWSIPv6Prefix], ...]: 70 | """Create a deduplicated sorted tuple of AWS IP prefixes.""" 71 | collect_duplicates = defaultdict(list) 72 | for prefix in prefixes: 73 | collect_duplicates[prefix.prefix].append(prefix) 74 | 75 | deduplicated_prefixes = list() 76 | for prefixes in collect_duplicates.values(): 77 | if len(prefixes) == 1: 78 | prefix = prefixes[0] 79 | else: 80 | prefix = combine_prefixes(prefixes) 81 | deduplicated_prefixes.append(prefix) 82 | 83 | deduplicated_prefixes.sort() 84 | 85 | return tuple(deduplicated_prefixes) 86 | 87 | def _get_prefix( 88 | self, prefix: Union[str, IPv4Network, IPv6Network] 89 | ) -> Union[None, AWSIPv4Prefix, AWSIPv6Prefix]: 90 | """Retrieve a specific prefix from the AWS IP address ranges.""" 91 | check_type("prefix", prefix, (str, IPv4Network, IPv6Network)) 92 | if isinstance(prefix, str): 93 | prefix = ip_network(prefix) 94 | 95 | if isinstance(prefix, IPv4Network): 96 | prefixes_collection = self.ipv4_prefixes 97 | elif isinstance(prefix, IPv6Network): 98 | prefixes_collection = self.ipv6_prefixes 99 | else: 100 | raise TypeError("`prefix` must be an IPv4Network or IPv6Network object.") 101 | 102 | # Retrieve the prefix from the collection 103 | index = bisect_left(prefixes_collection, prefix) 104 | if ( 105 | index != len(prefixes_collection) 106 | and prefixes_collection[index].prefix == prefix 107 | ): 108 | return prefixes_collection[index] 109 | else: 110 | # Not found 111 | return None 112 | 113 | @property 114 | def sync_token(self) -> str: 115 | """The publication time, in Unix epoch time format.""" 116 | return self._sync_token 117 | 118 | @property 119 | def syncToken(self) -> str: # noqa 120 | """The publication time, in Unix epoch time format. 121 | 122 | This is a convenience attribute to maintain API compatibility with the 123 | JSON attribute names. 124 | """ 125 | return self._sync_token 126 | 127 | @property 128 | def create_date(self) -> datetime: 129 | """The publication date and time, in UTC.""" 130 | return self._create_date 131 | 132 | @property 133 | def createDate(self) -> datetime: # noqa 134 | """The publication date and time, in UTC. 135 | 136 | This is a convenience attribute to maintain API compatibility with the 137 | JSON attribute names. 138 | """ 139 | return self._create_date 140 | 141 | @property 142 | def ipv4_prefixes(self) -> Tuple[AWSIPv4Prefix, ...]: 143 | """The IPv4 prefixes in the collection.""" 144 | return self._ipv4_prefixes 145 | 146 | @property 147 | def prefixes(self) -> Tuple[AWSIPv4Prefix, ...]: 148 | """The IPv4 prefixes in the collection. 149 | 150 | This is a convenience attribute to maintain API compatibility with the 151 | JSON attribute names. 152 | """ 153 | return self._ipv4_prefixes 154 | 155 | @property 156 | def ipv6_prefixes(self) -> Tuple[AWSIPv6Prefix, ...]: 157 | """The IPv6 prefixes in the collection.""" 158 | return self._ipv6_prefixes 159 | 160 | @property 161 | def md5(self) -> Optional[str]: 162 | """The MD5 cryptographic hash value of the ip-ranges.json file. 163 | 164 | You can use this value to verify the integrity of the downloaded file. 165 | """ 166 | return self._md5 167 | 168 | def __repr__(self) -> str: 169 | return pprint.pformat( 170 | { 171 | "sync_token": self.sync_token, 172 | "create_date": self.create_date, 173 | "ipv4_prefixes": self.ipv4_prefixes, 174 | "ipv6_prefixes": self.ipv6_prefixes, 175 | "md5": self.md5, 176 | } 177 | ) 178 | 179 | def __contains__( 180 | self, 181 | item: Union[ 182 | str, 183 | IPv4Address, 184 | IPv6Address, 185 | IPv4Interface, 186 | IPv6Interface, 187 | IPv4Network, 188 | IPv6Network, 189 | AWSIPv4Prefix, 190 | AWSIPv6Prefix, 191 | ], 192 | ) -> bool: 193 | """Is the IP address, interface, or network in the AWS IP Address ranges?""" 194 | try: 195 | self[item] 196 | except KeyError: 197 | return False 198 | else: 199 | return True 200 | 201 | def __getitem__( 202 | self, 203 | item: Union[ 204 | str, 205 | IPv4Address, 206 | IPv6Address, 207 | IPv4Network, 208 | IPv6Network, 209 | IPv4Interface, 210 | IPv6Interface, 211 | AWSIPv4Prefix, 212 | AWSIPv6Prefix, 213 | ], 214 | ): 215 | """Get the AWS IP address prefix that contains the IPv4 or IPv6 item. 216 | 217 | Returns the longest-match prefix that contains the provided item. 218 | 219 | **Parameters:** 220 | 221 | - **item** (str, IPv4Address, IPv6Address, IPv4Network, IPv6Network, 222 | IPv4Interface, IPv6Interface, AWSIPv4Prefix, AWSIPv6Prefix) - the item 223 | to retrieve from the collection 224 | 225 | **Returns:** 226 | 227 | The `AWSIPv4Prefix` or `AWSIPv6Prefix` that contains the provided `item`. 228 | 229 | **Raises:** 230 | 231 | A `KeyError` exception if the provided `item` is not contained in the 232 | collection. 233 | """ 234 | if isinstance(item, (AWSIPv4Prefix, AWSIPv6Prefix)): 235 | network = item.prefix 236 | else: 237 | network = ip_network(item, strict=False) 238 | 239 | for supernet in supernets(network): 240 | supernet_prefix = self._get_prefix(supernet) 241 | if supernet_prefix: 242 | return supernet_prefix 243 | 244 | raise KeyError( 245 | f"{item!r} is not contained in this AWSIPAddressRanges collection." 246 | ) 247 | 248 | def get( 249 | self, 250 | key: Union[ 251 | str, 252 | IPv4Address, 253 | IPv6Address, 254 | IPv4Network, 255 | IPv6Network, 256 | IPv4Interface, 257 | IPv6Interface, 258 | AWSIPv4Prefix, 259 | AWSIPv6Prefix, 260 | ], 261 | default=None, 262 | ) -> Union[AWSIPv4Prefix, AWSIPv6Prefix]: 263 | """Get the AWS IP address prefix that contains the IPv4 or IPv6 key. 264 | 265 | Returns the longest-match prefix that contains the provided key or the 266 | value of the `default=` parameter if the key is not found in the 267 | collection. 268 | 269 | **Parameters:** 270 | 271 | - **key** (str, IPv4Address, IPv6Address, IPv4Network, IPv6Network, 272 | IPv4Interface, IPv6Interface, AWSIPv4Prefix, AWSIPv6Prefix) - the IP 273 | address or network to retrieve from the collection 274 | - **default** - the value to return if the key is not found in the 275 | collection 276 | 277 | **Returns:** 278 | 279 | The `AWSIPv4Prefix` or `AWSIPv6Prefix` that contains the provided key. 280 | """ 281 | try: 282 | return self[key] 283 | except KeyError: 284 | return default 285 | 286 | def get_prefix_and_supernets( 287 | self, 288 | key: Union[ 289 | str, 290 | IPv4Address, 291 | IPv6Address, 292 | IPv4Network, 293 | IPv6Network, 294 | IPv4Interface, 295 | IPv6Interface, 296 | AWSIPv4Prefix, 297 | AWSIPv6Prefix, 298 | ], 299 | default=None, 300 | ) -> Optional[Tuple[Union[AWSIPv4Prefix, AWSIPv6Prefix], ...]]: 301 | """Get the prefix and supernets that contain the IPv4 or IPv6 key. 302 | 303 | Returns a tuple that contains the longest-match prefix and supernets 304 | that contains the provided key or the value of the `default=` parameter 305 | if the key is not found in the collection. 306 | 307 | The tuple is sorted by prefix length in ascending order (shorter prefixes 308 | come before longer prefixes). 309 | 310 | **Parameters:** 311 | 312 | - **key** (str, IPv4Address, IPv6Address, IPv4Network, IPv6Network, 313 | IPv4Interface, IPv6Interface, AWSIPv4Prefix, AWSIPv6Prefix) - the IP 314 | address or network to retrieve from the collection 315 | - **default** - the value to return if the key is not found in the 316 | collection 317 | 318 | **Returns:** 319 | 320 | A tuple of the `AWSIPv4Prefix`es or `AWSIPv6Prefix`es that contains the 321 | provided key. 322 | """ 323 | if isinstance(key, (AWSIPv4Prefix, AWSIPv6Prefix)): 324 | network = key.prefix 325 | else: 326 | network = ip_network(key, strict=False) 327 | 328 | prefixes = list() 329 | for supernet in supernets(network): 330 | prefix = self._get_prefix(supernet) 331 | if prefix: 332 | prefixes.append(prefix) 333 | 334 | if prefixes: 335 | prefixes.sort() 336 | return tuple(prefixes) 337 | else: 338 | return default 339 | 340 | def __iter__(self): 341 | return itertools.chain(self.ipv4_prefixes, self.ipv6_prefixes) 342 | 343 | def __len__(self): 344 | return len(self.ipv4_prefixes) + len(self.ipv6_prefixes) 345 | 346 | @property 347 | def regions(self) -> FrozenSet[str]: 348 | """The set of regions in the collection.""" 349 | if self._regions is None: 350 | self._regions = frozenset((prefix.region for prefix in self)) 351 | 352 | return self._regions 353 | 354 | @property 355 | def network_border_groups(self) -> FrozenSet[str]: 356 | """The set of network border groups in the collection.""" 357 | if self._network_border_groups is None: 358 | self._network_border_groups = frozenset( 359 | (prefix.network_border_group for prefix in self) 360 | ) 361 | 362 | return self._network_border_groups 363 | 364 | @property 365 | def services(self) -> FrozenSet[str]: 366 | """The set of services in the collection. 367 | 368 | The service `"AMAZON"` is not a service but rather an identifier used 369 | to get all IP address ranges - meaning that every prefix is contained in 370 | the subset of prefixes tagged with the `"AMAZON"` service. Some IP 371 | address ranges are only tagged with the `"AMAZON"` service. 372 | """ 373 | if self._services is None: 374 | self._services = frozenset( 375 | (service for prefix in self for service in prefix.services) 376 | ) 377 | 378 | return self._services 379 | 380 | def filter( 381 | self, 382 | regions: Union[None, str, Iterable[str]] = None, 383 | network_border_groups: Union[None, str, Iterable[str]] = None, 384 | services: Union[None, str, Iterable[str]] = None, 385 | versions: Union[None, int, Iterable[int]] = None, 386 | ): 387 | """Filter the AWS IP address ranges. 388 | 389 | The service `"AMAZON"` is not a service but rather an identifier used 390 | to get all IP address ranges - meaning that every prefix is contained in 391 | the subset of prefixes tagged with the `"AMAZON"` service. Some IP 392 | address ranges are only tagged with the `"AMAZON"` service. 393 | 394 | **Parameters:** 395 | 396 | - **regions** (_optional_ str or iterable sequence of strings) - the 397 | AWS Regions to include in the subset 398 | - **network_border_groups** (_optional_ str or iterable sequence of 399 | strings) - the AWS network border groups to include in the subset 400 | - **services** (_optional_ str or iterable sequence of strings) - the 401 | AWS services to include in the subset 402 | - **versions** (_optional_ int) - the IP address version (4, 6) to 403 | include in the subset 404 | 405 | **Returns:** 406 | 407 | A new `AWSIPPrefixes` object that contains the subset of IP prefixes that 408 | match your filter criteria. 409 | """ 410 | # Normalize, validate, and process the input variables 411 | 412 | # regions 413 | regions = normalize_to_set(regions) or self.regions 414 | validate_values("region", regions, valid_values=self.regions) 415 | 416 | # network_border_groups 417 | network_border_groups = ( 418 | normalize_to_set(network_border_groups) or self.network_border_groups 419 | ) 420 | validate_values( 421 | "network_border_group", 422 | network_border_groups, 423 | valid_values=self.network_border_groups, 424 | ) 425 | 426 | # services 427 | services = normalize_to_set(services) or self.services 428 | validate_values("services", services, valid_values=self.services) 429 | 430 | # prefix_type -> prefix_version 431 | versions = normalize_to_set(versions) or {4, 6} 432 | validate_values("versions", versions, valid_values=frozenset((4, 6))) 433 | 434 | # Generate the filtered prefix list 435 | return self.__class__( 436 | sync_token=self.sync_token, 437 | create_date=self.create_date, 438 | ipv4_prefixes=tuple() 439 | if 4 not in versions 440 | else ( 441 | prefix 442 | for prefix in self.ipv4_prefixes 443 | if prefix.region in regions 444 | if prefix.network_border_group in network_border_groups 445 | if set(prefix.services).intersection(services) 446 | ), 447 | ipv6_prefixes=tuple() 448 | if 6 not in versions 449 | else ( 450 | prefix 451 | for prefix in self.ipv6_prefixes 452 | if prefix.region in regions 453 | if prefix.network_border_group in network_border_groups 454 | if set(prefix.services).intersection(services) 455 | ), 456 | ) 457 | -------------------------------------------------------------------------------- /awsipranges/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | from ipaddress import ( 7 | IPv4Network, 8 | IPv6Network, 9 | ) 10 | from typing import Any, FrozenSet, Generator, Iterable, Set, Tuple, Type, Union 11 | 12 | 13 | def check_type( 14 | variable_name: str, 15 | obj: Any, 16 | acceptable_types: Union[Type, Tuple[Type, ...]], 17 | optional: bool = False, 18 | ): 19 | """Object is an instance of one of the acceptable types or None. 20 | Args: 21 | variable_name: The name of the variable being inspected. 22 | obj: The object to inspect. 23 | acceptable_types: A type or tuple of acceptable types. 24 | optional(bool): Whether or not the object may be None. 25 | Raises: 26 | TypeError: If the object is not an instance of one of the acceptable 27 | types, or if the object is None and optional=False. 28 | """ 29 | assert isinstance(variable_name, str) 30 | if not isinstance(acceptable_types, tuple): 31 | acceptable_types = (acceptable_types,) 32 | assert isinstance(optional, bool) 33 | 34 | if isinstance(obj, acceptable_types): 35 | # Object is an instance of an acceptable type. 36 | return 37 | elif optional and obj is None: 38 | # Object is None, and that is okay! 39 | return 40 | else: 41 | # Object is something else. 42 | raise TypeError( 43 | f"{variable_name} should be a " 44 | f"{', '.join([t.__name__ for t in acceptable_types])}" 45 | f"{', or None' if optional else ''}. Received {obj!r} which is a " 46 | f"{type(obj).__name__}." 47 | ) 48 | 49 | 50 | def normalize_to_set( 51 | value: Union[None, str, int, Iterable[Union[str, int]]] 52 | ) -> Set[Union[str, int]]: 53 | """Normalize an optional or iterable variable to a set of unique values.""" 54 | if value is None: 55 | return set() 56 | 57 | if isinstance(value, (str, int)): 58 | return {value} 59 | 60 | if isinstance(value, Iterable): 61 | return set(value) 62 | 63 | raise TypeError("The value must be a string, integer, iterable type, or None.") 64 | 65 | 66 | def validate_values( 67 | variable_name: str, 68 | values: Set[Union[str, int]], 69 | valid_values: FrozenSet[Union[str, int]], 70 | ): 71 | """Validate the values in a set against a set of valid values.""" 72 | if not values.issubset(valid_values): 73 | raise ValueError( 74 | f"One or more of the provided {variable_name} {values!r} do not " 75 | f"exist in this set of AWS IP address ranges. " 76 | f"Valid {variable_name}: {valid_values}" 77 | ) 78 | 79 | 80 | def supernets( 81 | subnet: Union[IPv4Network, IPv6Network] 82 | ) -> Generator[Union[IPv4Network, IPv6Network], None, None]: 83 | """Incrementally yield the supernets of the provided subnet.""" 84 | for prefix_length in range(subnet.prefixlen, 0, -1): 85 | yield subnet.supernet(new_prefix=prefix_length) 86 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # Developer Interfaces 2 | 3 | All public interfaces and classes are exposed under the main `awsipranges` 4 | package. 5 | 6 | ## get_ranges() 7 | 8 | ::: awsipranges.get_ranges 9 | :docstring: 10 | 11 | ## AWSIPPrefixes 12 | 13 | ::: awsipranges.AWSIPPrefixes 14 | :docstring: 15 | :members: 16 | 17 | ## AWSIPPrefix 18 | 19 | Base class for the `AWSIPv4Prefix` and `AWSIPv6Prefix` classes. `AWSIPPrefix` objects are _immutable_ and _hashable_ and therefore may be added to Python sets and be used as keys in dictionaries. 20 | 21 | ::: awsipranges.AWSIPPrefix 22 | :docstring: 23 | :members: 24 | 25 | ### AWSIPv4Prefix 26 | 27 | Supports all the properties and methods of the [`AWSIPPrefix`](./api.md#awsipprefix) base class and the Python native [`IPv4Network`](https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Network) class. 28 | 29 | ::: awsipranges.AWSIPv4Prefix 30 | :docstring: 31 | :members: ip_prefix 32 | 33 | ### AWSIPv6Prefix 34 | 35 | Supports all the properties and methods of the [`AWSIPPrefix`](./api.md#awsipprefix) base class and the Python native [`IPv6Network`](https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv6Network) class. 36 | 37 | ::: awsipranges.AWSIPv6Prefix 38 | :docstring: 39 | :members: ipv6_prefix 40 | -------------------------------------------------------------------------------- /docs/assets/styles/extra.css: -------------------------------------------------------------------------------- 1 | div.autodoc-docstring { 2 | padding-left: 20px; 3 | margin-bottom: 30px; 4 | border-left: 5px solid rgba(230, 230, 230); 5 | } 6 | 7 | div.autodoc-members { 8 | padding-left: 20px; 9 | margin-bottom: 15px; 10 | } 11 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- 2 | README.md 3 | --8<-- 4 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | `awsipranges` helps you answer simple questions fast and makes it easy for you to leverage the AWS IP range data in your Python automation scripts and infrastructure configurations. 4 | 5 | To get started, make sure you have: 6 | 7 | - `awsipranges` [installed](./index.md#installation) 8 | - `awsipranges` [upgraded to the latest version](./index.md#installation) 9 | 10 | Then dive-in with this Quickstart to begin working with the AWS IP address ranges as native Python objects! 11 | 12 | ## Verify server TLS certificates 13 | 14 | *How do I know that I am working with the authentic and latest AWS IP address ranges?* 15 | 16 | By default, if you do not provide trusted certificates, the Python [urllib](https://docs.python.org/3/library/urllib.html) module (used by the `awsipranges.get_ranges()` function) verifies the TLS certificate presented by the server against the system-provided certificate datastore. 17 | 18 | It is your responsibility to verify the TLS certificate presented by the server. `awsipranges` downloads the latest AWS IP address ranges from the [published JSON file](https://docs.aws.amazon.com/general/latest/gr/aws-ip-ranges.html) and makes it easy for you to verify the authenticity of the TLS certificate presented by the server. 19 | 20 | Amazon publishes their root Certificate Authority (CA) certificates in the [Amazon Trust Services Repository](https://www.amazontrust.com/repository/). 21 | 22 | To verify the TLS certificate presented by the Amazon IP ranges server (ip-ranges.amazonaws.com): 23 | 24 | 1. Download the Amazon Root CA certificates (in PEM format) from the [Amazon Trust Services Repository](https://www.amazontrust.com/repository/). 25 | 26 | 2. Prepare the certificates for use by either: 27 | 28 | - Stacking (concatenating) the files into a single certificate bundle (single file) 29 | 30 | - Storing them in a directory using OpenSSL hash filenames 31 | 32 | You can do this with the [`c_rehash` script](https://www.openssl.org/docs/man1.1.0/man1/rehash.html) included in many OpenSSL distributions: 33 | 34 | ```shell 35 | ❯ c_rehash amazon_root_certificates/ 36 | ``` 37 | 38 | > ***Tip***: See [`tests/unit/test_data_loading.py`](https://github.com/aws-samples/awsipranges/blob/main/tests/unit/test_data_loading.py) in the `awsipranges` repository for sample Python functions that download the Amazon Root CA certificates and prepare the certificates as both a stacked certificate bundle file and as a directory with OpenSSL hash filenames. 39 | 40 | 3. Pass the path to the prepared certificates to the `awsipranges.get_ranges()` function using the `cafile` or `capath` parameters: 41 | 42 | - `cafile=` path to the stacked certificate bundle file 43 | 44 | - `capath=` path to the directory containing the certificates with OpenSSL hashed filenames 45 | 46 | ```python 47 | >>> import awsipranges 48 | 49 | # Using a stacked certificate bundle 50 | >>> aws_ip_ranges = awsipranges.get_ranges(cafile="amazon_root_certificates.pem") 51 | 52 | # Using directory containing the certificates with OpenSSL hashed filenames 53 | >>> aws_ip_ranges = awsipranges.get_ranges(capath="amazon_root_certificates/") 54 | ``` 55 | 56 | ## Download AWS IP address ranges 57 | 58 | One line of code (okay, two if you count the import statement) is all it takes to download and parse the AWS IP address ranges into a pythonic data structure: 59 | 60 | ```python 61 | >>> import awsipranges 62 | >>> aws_ip_ranges = awsipranges.get_ranges() 63 | ``` 64 | 65 | The [`awsipranges.get_ranges()`](./api.md#get_ranges) function returns an [`AWSIPPrefixes`](./api.md#awsipprefixes) object, which is a structured collection of AWS IP prefixes. 66 | 67 | You can access the `create_date` and `sync_token` attributes of the `AWSIPPrefixes` collection to check the version of the downloaded JSON file and verify the integrity of the file with the `md5` attribute: 68 | 69 | ```python 70 | >>> aws_ip_ranges.create_date 71 | datetime.datetime(2021, 10, 1, 16, 33, 13, tzinfo=datetime.timezone.utc) 72 | 73 | >>> aws_ip_ranges.sync_token 74 | '1633105993' 75 | 76 | >>> aws_ip_ranges.md5 77 | '59e4cd7f4757a9f380c626d772a5eef2' 78 | ``` 79 | 80 | You can access the IPv4 and IPv6 address prefixes with the `ipv4_prefixes` and `ipv6_prefixes` attributes: 81 | 82 | ```python 83 | >>> aws_ip_ranges.ipv4_prefixes 84 | (AWSIPv4Prefix('3.0.0.0/15', region='ap-southeast-1', network_border_group='ap-southeast-1', services=('AMAZON', 'EC2')), 85 | AWSIPv4Prefix('3.0.5.32/29', region='ap-southeast-1', network_border_group='ap-southeast-1', services=('EC2_INSTANCE_CONNECT',)), 86 | AWSIPv4Prefix('3.0.5.224/27', region='ap-southeast-1', network_border_group='ap-southeast-1', services=('ROUTE53_RESOLVER',)), 87 | AWSIPv4Prefix('3.2.0.0/24', region='us-east-1', network_border_group='us-east-1-iah-1', services=('AMAZON', 'EC2')), 88 | AWSIPv4Prefix('3.2.2.0/24', region='us-east-1', network_border_group='us-east-1-mia-1', services=('AMAZON', 'EC2')), 89 | ...) 90 | 91 | >>> aws_ip_ranges.ipv6_prefixes 92 | (AWSIPv6Prefix('2400:6500:0:9::1/128', region='ap-southeast-3', network_border_group='ap-southeast-3', services=('AMAZON',)), 93 | AWSIPv6Prefix('2400:6500:0:9::2/128', region='ap-southeast-3', network_border_group='ap-southeast-3', services=('AMAZON',)), 94 | AWSIPv6Prefix('2400:6500:0:9::3/128', region='ap-southeast-3', network_border_group='ap-southeast-3', services=('AMAZON',)), 95 | AWSIPv6Prefix('2400:6500:0:9::4/128', region='ap-southeast-3', network_border_group='ap-southeast-3', services=('AMAZON',)), 96 | AWSIPv6Prefix('2400:6500:0:7000::/56', region='ap-southeast-1', network_border_group='ap-southeast-1', services=('AMAZON',)), 97 | ...) 98 | ``` 99 | 100 | ...and if that's all this library did, it would be pretty boring. 101 | 102 | ## Check an IP address or network 103 | 104 | *How can I check to see if an IP address or network is contained in the AWS IP address ranges?* 105 | 106 | [`AWSIPPrefixes`](./api.md#awsipprefixes) works like a standard python collection. You can check to see if an IP address, interface, or network is contained in the collection by using the Python `in` operator: 107 | 108 | ```python 109 | >>> '52.94.5.15' in aws_ip_ranges 110 | True 111 | 112 | >>> '43.195.173.0/24' in aws_ip_ranges 113 | True 114 | 115 | >>> IPv4Network('13.50.0.0/16') in aws_ip_ranges 116 | True 117 | 118 | >>> '1.1.1.1' in aws_ip_ranges 119 | False 120 | ``` 121 | 122 | ## Find an AWS IP prefix 123 | 124 | *How can I find the AWS IP prefix that contains an IP address or network?* 125 | 126 | You can get the longest-match prefix that contains an IP address or network by indexing into the `AWSIPPrefixes` collection or using the `get()` method just like you do with a Python dictionary: 127 | 128 | ```python 129 | >>> aws_ip_ranges['52.94.5.15'] 130 | AWSIPv4Prefix('52.94.5.0/24', region='eu-west-1', network_border_group='eu-west-1', services=('AMAZON', 'DYNAMODB')) 131 | 132 | >>> aws_ip_ranges.get('52.94.5.15') 133 | AWSIPv4Prefix('52.94.5.0/24', region='eu-west-1', network_border_group='eu-west-1', services=('AMAZON', 'DYNAMODB')) 134 | 135 | >>> aws_ip_ranges.get('1.1.1.1', default='Nope') 136 | 'Nope' 137 | ``` 138 | 139 | The AWS IP address ranges contain supernet and subnet prefixes, so an IP address or network may be contained in more than one AWS IP prefix. Use the `get_prefix_and_supernets()` method to retrieve all IP prefixes that contain an IP address or network: 140 | 141 | ```python 142 | >>> aws_ip_ranges.get_prefix_and_supernets('3.218.180.73') 143 | (AWSIPv4Prefix('3.208.0.0/12', region='us-east-1', network_border_group='us-east-1', services=('AMAZON', 'EC2')), 144 | AWSIPv4Prefix('3.218.180.0/22', region='us-east-1', network_border_group='us-east-1', services=('DYNAMODB',)), 145 | AWSIPv4Prefix('3.218.180.0/25', region='us-east-1', network_border_group='us-east-1', services=('DYNAMODB',))) 146 | ``` 147 | 148 | ## Filter AWS IP prefixes 149 | 150 | *How can I filter the AWS IP prefixes by Region, network border group, or service?* 151 | 152 | The `filter()` method allows you to select a subset of AWS IP prefixes from the collection. You can filter on `regions`, `network_border_groups`, IP `versions` (4, 6), and `services`. The `filter()` method returns a new `AWSIPPrefixes` object that contains the subset of IP prefixes that match your filter criteria. 153 | 154 | You may pass a single value (`regions='eu-central-2'`) or a sequence of values (`regions=['eu-central-1', 'eu-central-2']`) to the filter parameters. The `filter()` method returns the prefixes that match all the provided parameters; selecting prefixes where the prefix's attributes intersect the provided set of values. 155 | 156 | For example, `filter(regions=['eu-central-1', 'eu-central-2'], services='EC2', versions=4)` will select all IP version `4` prefixes that have `EC2` in the prefix's list of services and are in the `eu-central-1` or `eu-central-2` Regions. 157 | 158 | ```python 159 | >>> aws_ip_ranges.filter(regions='eu-central-2', services='EC2', versions=4) 160 | {'create_date': datetime.datetime(2021, 9, 16, 17, 43, 14, tzinfo=datetime.timezone.utc), 161 | 'ipv4_prefixes': (AWSIPv4Prefix('3.5.52.0/22', region='eu-central-2', network_border_group='eu-central-2', services=('AMAZON', 'EC2', 'S3')), 162 | AWSIPv4Prefix('16.62.0.0/15', region='eu-central-2', network_border_group='eu-central-2', services=('AMAZON', 'EC2')), 163 | AWSIPv4Prefix('52.94.250.0/28', region='eu-central-2', network_border_group='eu-central-2', services=('AMAZON', 'EC2')), 164 | AWSIPv4Prefix('99.151.80.0/21', region='eu-central-2', network_border_group='eu-central-2', services=('AMAZON', 'EC2'))), 165 | 'ipv6_prefixes': (), 166 | 'sync_token': '1631814194'} 167 | ``` 168 | 169 | > ***Tip***: You can view the set of all regions, network border groups, and services contained in an `AWSIPPrefixes` collection with the `regions`, `network_border_groups`, and `services` attributes. 170 | 171 | ```python 172 | >>> aws_ip_ranges.services 173 | frozenset({'AMAZON', 174 | 'AMAZON_APPFLOW', 175 | 'AMAZON_CONNECT', 176 | 'API_GATEWAY', 177 | 'CHIME_MEETINGS', 178 | 'CHIME_VOICECONNECTOR', 179 | 'CLOUD9', 180 | 'CLOUDFRONT', 181 | 'CODEBUILD', 182 | 'DYNAMODB', 183 | 'EBS', 184 | 'EC2', 185 | 'EC2_INSTANCE_CONNECT', 186 | 'GLOBALACCELERATOR', 187 | 'KINESIS_VIDEO_STREAMS', 188 | 'ROUTE53', 189 | 'ROUTE53_HEALTHCHECKS', 190 | 'ROUTE53_HEALTHCHECKS_PUBLISHING', 191 | 'ROUTE53_RESOLVER', 192 | 'S3', 193 | 'WORKSPACES_GATEWAYS'}) 194 | ``` 195 | 196 | ## Work with AWS IP prefix objects 197 | 198 | *My router/firewall wants IP networks in a net-mask or host-mask format. Do the AWS IP prefix objects provide a way for me to get the prefix in the format I need?* 199 | 200 | [`AWSIPv4Prefix`](./api.md#awsipv4prefix) and [`AWSIPv6Prefix`](./api.md#awsipv6prefix) objects are proxies around [`IPv4Network`](https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Network) and [`IPv6Network`](https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv6Network) objects from the Python standard library (see the [`ipaddress`](https://docs.python.org/3/library/ipaddress.html) module). They support all the attributes and methods available on the `IPv4Network` and `IPv6Network` objects. They also inherit additional attributes (like `region`, `network_border_group`, and `services`) and additional functionality from the [`AWSIPPrefix`](./api.md#awsipprefix) base class. 201 | 202 | Combining the functionality provided by the standard library objects with the rich collection capabilities provided by the `awsipranges` library allows you to complete complex tasks easily: 203 | 204 | Like adding routes to the `DYNAMODB` prefixes in the `eu-west-1` Region to a router: 205 | 206 | ```python 207 | >>> for prefix in aws_ip_ranges.filter(regions='eu-west-1', services='DYNAMODB'): 208 | ... print(f"ip route {prefix.network_address} {prefix.netmask} 1.1.1.1") 209 | ... 210 | ip route 52.94.5.0 255.255.255.0 1.1.1.1 211 | ip route 52.94.24.0 255.255.254.0 1.1.1.1 212 | ip route 52.94.26.0 255.255.254.0 1.1.1.1 213 | ip route 52.119.240.0 255.255.248.0 1.1.1.1 214 | ``` 215 | 216 | Or, configuring an access control list to allow traffic to the `S3` prefixes in `eu-north-1`: 217 | 218 | ```python 219 | >>> for prefix in aws_ip_ranges.filter(regions='eu-north-1', services='S3', versions=4): 220 | ... print(f"permit tcp any {prefix.network_address} {prefix.hostmask} eq 443") 221 | ... 222 | permit tcp any 3.5.216.0 0.0.3.255 eq 443 223 | permit tcp any 13.51.71.176 0.0.0.15 eq 443 224 | permit tcp any 13.51.71.192 0.0.0.15 eq 443 225 | permit tcp any 52.95.169.0 0.0.0.255 eq 443 226 | permit tcp any 52.95.170.0 0.0.1.255 eq 443 227 | ``` 228 | 229 | These are only a couple possibilities. Python and `awsipranges` allow you to use the AWS IP ranges in your automation scripts to accomplish powerful tasks simply. 230 | 231 | ## What about IPv6? 232 | 233 | All the functionality and examples shown in this Quickstart also work with IPv6 prefixes! 😎 234 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: awsipranges 2 | site_description: Work with AWS IP address ranges in native Python 3 | site_url: https://aws-samples.github.io/awsipranges/ 4 | site_author: Chris Lunsford 5 | repo_url: https://github.com/aws-samples/awsipranges/ 6 | edit_uri: blob/main/docs/ 7 | repo_name: GitHub 8 | copyright: Copyright © 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 9 | 10 | 11 | theme: 12 | name: readthedocs 13 | language: en 14 | palette: 15 | accent: amber 16 | font: false # security 17 | features: 18 | - navigation.tabs 19 | - navigation.tabs.sticky 20 | - navigation.instant 21 | 22 | plugins: 23 | - search 24 | 25 | extra_css: 26 | - assets/styles/extra.css 27 | 28 | markdown_extensions: 29 | - toc: 30 | permalink: true 31 | toc_depth: 4 32 | - admonition 33 | - attr_list 34 | - def_list 35 | - md_in_html 36 | - mkautodoc 37 | - pymdownx.highlight 38 | - pymdownx.inlinehilite 39 | - pymdownx.superfences 40 | - pymdownx.magiclink 41 | - pymdownx.mark 42 | - pymdownx.details 43 | - pymdownx.tabbed 44 | - pymdownx.tilde 45 | - pymdownx.emoji 46 | - pymdownx.snippets 47 | - pymdownx.tasklist: 48 | custom_checkbox: true 49 | 50 | 51 | use_directory_urls: false 52 | 53 | nav: 54 | - 'Home': index.md 55 | - 'Quickstart': quickstart.md 56 | - 'API Reference': api.md 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "awsipranges" 3 | version = "0.3.1" 4 | description = "Work with the AWS IP address ranges in native Python." 5 | license = "Apache-2.0" 6 | authors = ["Chris Lunsford "] 7 | readme = "README.md" 8 | homepage = "https://aws-samples.github.io/awsipranges/" 9 | repository = "https://github.com/aws-samples/awsipranges" 10 | documentation = "https://aws-samples.github.io/awsipranges/" 11 | keywords = ["AWS", "IP", "ranges", "addresses"] 12 | classifiers = [ 13 | "Intended Audience :: Developers", 14 | "Intended Audience :: Information Technology", 15 | "Intended Audience :: System Administrators", 16 | "Intended Audience :: Telecommunications Industry", 17 | "Natural Language :: English", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python :: 3", 20 | "Topic :: Internet", 21 | "Topic :: System :: Networking", 22 | "Topic :: System :: Systems Administration", 23 | "Topic :: Software Development :: Libraries :: Python Modules" 24 | ] 25 | 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.7" 29 | 30 | [tool.poetry.dev-dependencies] 31 | black = "^21.6b0" 32 | flake8 = "^3.9.2" 33 | ipython = "^7.25.0" 34 | pytest = "^6.2.4" 35 | pyOpenSSL = "^20.0.1" 36 | mkdocs = "^1.2.2" 37 | pymdown-extensions = "^8.2" 38 | mkautodoc = "^0.1.0" 39 | coverage = "^5.5" 40 | pytest-cov = "^2.12.1" 41 | 42 | [build-system] 43 | requires = ["poetry-core>=1.0.0"] 44 | build-backend = "poetry.core.masonry.api" 45 | 46 | [pytest] 47 | log_cli = true 48 | log_cli_level = 20 49 | 50 | [tool.pytest.ini_options] 51 | markers = [ 52 | "data: marks JSON data syntax and semantics tests (deselect with '-m \"not data\"')", 53 | "extra_data_loading: marks extra tests that load the JSON data from the web (deselect with '-m \"not extra_data_loading\"')", 54 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 55 | "test_utils: marks tests as slow (deselect with '-m \"not test_utils\"')", 56 | ] 57 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.2; sys_platform == "darwin" and python_version >= "3.7" 2 | atomicwrites==1.4.0; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6") or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.4.0" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6") 3 | attrs==21.2.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 4 | backcall==0.2.0; python_version >= "3.7" 5 | black==21.9b0; python_full_version >= "3.6.2" 6 | cffi==1.15.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 7 | click==8.0.3; python_version >= "3.6" and python_full_version >= "3.6.2" 8 | colorama==0.4.4; sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.6.2" and platform_system == "Windows" and (python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6") or sys_platform == "win32" and python_version >= "3.6" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6") and python_full_version >= "3.5.0") 9 | coverage==5.5; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0" and python_version < "4") 10 | cryptography==35.0.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 11 | decorator==5.1.0; python_version >= "3.7" 12 | flake8==3.9.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") 13 | ghp-import==2.0.2; python_version >= "3.6" 14 | importlib-metadata==4.8.1; python_full_version >= "3.6.2" and python_version < "3.8" and python_version >= "3.6" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6") 15 | iniconfig==1.1.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 16 | ipython==7.28.0; python_version >= "3.7" 17 | jedi==0.18.0; python_version >= "3.7" 18 | jinja2==3.0.2; python_version >= "3.6" 19 | markdown==3.3.4; python_version >= "3.6" 20 | markupsafe==2.0.1; python_version >= "3.6" 21 | matplotlib-inline==0.1.3; python_version >= "3.7" 22 | mccabe==0.6.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" 23 | mergedeep==1.3.4; python_version >= "3.6" 24 | mkautodoc==0.1.0; python_version >= "3.6" 25 | mkdocs==1.2.3; python_version >= "3.6" 26 | mypy-extensions==0.4.3; python_full_version >= "3.6.2" 27 | packaging==21.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 28 | parso==0.8.2; python_version >= "3.7" 29 | pathspec==0.9.0; python_full_version >= "3.6.2" 30 | pexpect==4.8.0; sys_platform != "win32" and python_version >= "3.7" 31 | pickleshare==0.7.5; python_version >= "3.7" 32 | platformdirs==2.4.0; python_version >= "3.6" and python_full_version >= "3.6.2" 33 | pluggy==1.0.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 34 | prompt-toolkit==3.0.20; python_full_version >= "3.6.2" and python_version >= "3.7" 35 | ptyprocess==0.7.0; sys_platform != "win32" and python_version >= "3.7" 36 | py==1.10.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 37 | pycodestyle==2.7.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" 38 | pycparser==2.20; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 39 | pyflakes==2.3.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" 40 | pygments==2.10.0; python_version >= "3.7" 41 | pymdown-extensions==8.2; python_version >= "3.6" 42 | pyopenssl==20.0.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") 43 | pyparsing==2.4.7; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6" 44 | pytest-cov==2.12.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") 45 | pytest==6.2.5; python_version >= "3.6" 46 | python-dateutil==2.8.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6" 47 | pyyaml-env-tag==0.1; python_version >= "3.6" 48 | pyyaml==6.0; python_version >= "3.6" 49 | regex==2021.10.8; python_full_version >= "3.6.2" 50 | six==1.16.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 51 | toml==0.10.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 52 | tomli==1.2.1; python_version >= "3.6" and python_full_version >= "3.6.2" 53 | traitlets==5.1.0; python_version >= "3.7" 54 | typed-ast==1.4.3; python_version < "3.8" and python_full_version >= "3.6.2" 55 | typing-extensions==3.10.0.2 56 | watchdog==2.1.6; python_version >= "3.6" 57 | wcwidth==0.2.5; python_full_version >= "3.6.2" and python_version >= "3.7" 58 | zipp==3.6.0; python_version >= "3.6" and python_full_version < "3.0.0" and python_version < "3.8" or python_full_version >= "3.5.0" and python_version < "3.8" and python_version >= "3.6" 59 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/awsipranges/24455318d1fa4b6d206e49800451d096a84e100d/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/awsipranges/24455318d1fa4b6d206e49800451d096a84e100d/tests/data/__init__.py -------------------------------------------------------------------------------- /tests/data/test_syntax_and_semantics.py: -------------------------------------------------------------------------------- 1 | """Test the syntax of the AWS IP address ranges JSON data. 2 | 3 | https://docs.aws.amazon.com/general/latest/gr/aws-ip-ranges.html 4 | 5 | Testing the documented data structures and assumptions validates the data 6 | dependencies and lets us know when something changes. 7 | """ 8 | 9 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 10 | # SPDX-License-Identifier: Apache-2.0 11 | 12 | import json 13 | import urllib.request 14 | from collections import defaultdict 15 | from datetime import datetime 16 | from ipaddress import ip_network, IPv4Network, IPv6Network 17 | from typing import Any, Dict, List 18 | 19 | import pytest 20 | 21 | from awsipranges.config import ( 22 | AWS_IP_ADDRESS_RANGES_URL, 23 | CREATE_DATE_FORMAT, 24 | CREATE_DATE_TIMEZONE, 25 | ) 26 | 27 | 28 | pytestmark = pytest.mark.data 29 | 30 | 31 | # Fixtures 32 | @pytest.fixture(scope="module") 33 | def json_data() -> Dict[str, Any]: 34 | """Retrieve and parse JSON data from a URL.""" 35 | with urllib.request.urlopen(AWS_IP_ADDRESS_RANGES_URL) as response: 36 | response_data = json.load(response) 37 | return response_data 38 | 39 | 40 | @pytest.fixture(scope="module") 41 | def create_date(json_data) -> datetime: 42 | """The JSON file publication date and time as a Python datetime object. 43 | 44 | createDate is the JSON document's publication date and time, in UTC 45 | YY-MM-DD-hh-mm-ss format. 46 | """ 47 | assert "createDate" in json_data 48 | create_date_string = json_data["createDate"] 49 | assert isinstance(create_date_string, str) 50 | 51 | create_date_datetime = datetime.strptime(create_date_string, CREATE_DATE_FORMAT) 52 | create_date_datetime = create_date_datetime.replace(tzinfo=CREATE_DATE_TIMEZONE) 53 | 54 | return create_date_datetime 55 | 56 | 57 | @pytest.fixture(scope="module") 58 | def deduplicated_prefixes(json_data) -> Dict[IPv4Network, List[Dict[str, str]]]: 59 | """Dictionary of `prefixes` indexed by IPv4Network.""" 60 | deduplicated_prefixes = defaultdict(list) 61 | 62 | for prefix in json_data["prefixes"]: 63 | prefix_string = prefix["ip_prefix"] 64 | prefix_network = ip_network(prefix_string) 65 | deduplicated_prefixes[prefix_network].append(prefix) 66 | 67 | return deduplicated_prefixes 68 | 69 | 70 | @pytest.fixture(scope="module") 71 | def deduplicated_ipv6_prefixes(json_data) -> Dict[IPv6Network, List[Dict[str, str]]]: 72 | """Dictionary of `ipv6_prefixes` indexed by IPv6Network.""" 73 | deduplicated_ipv6_prefixes = defaultdict(list) 74 | 75 | for ipv6_prefix in json_data["ipv6_prefixes"]: 76 | prefix_string = ipv6_prefix["ipv6_prefix"] 77 | prefix_network = ip_network(prefix_string) 78 | deduplicated_ipv6_prefixes[prefix_network].append(ipv6_prefix) 79 | 80 | return deduplicated_ipv6_prefixes 81 | 82 | 83 | # Tests 84 | def test_log_sync_token(json_data): 85 | """Capture the syncToken for the JSON data file.""" 86 | print("syncToken:", json_data["syncToken"]) 87 | 88 | 89 | def test_log_json_data_summary( 90 | json_data, deduplicated_prefixes, deduplicated_ipv6_prefixes 91 | ): 92 | """Capture a summary of the data to the test log.""" 93 | json_data_summary = { 94 | "sync_token": json_data["syncToken"], 95 | "create_date": json_data["createDate"], 96 | "prefixes": { 97 | "count": len(json_data["prefixes"]), 98 | "unique": len(deduplicated_prefixes), 99 | "duplicate": len(json_data["prefixes"]) - len(deduplicated_prefixes), 100 | }, 101 | "ipv6_prefixes": { 102 | "count": len(json_data["ipv6_prefixes"]), 103 | "unique": len(deduplicated_ipv6_prefixes), 104 | "duplicate": len(json_data["ipv6_prefixes"]) 105 | - len(deduplicated_ipv6_prefixes), 106 | }, 107 | } 108 | print("json_data_summary = ", json.dumps(json_data_summary, indent=2)) 109 | 110 | 111 | def test_parsing_create_date(create_date): 112 | """Ensure the createDate attribute is parsable into a Python datetime.""" 113 | assert isinstance(create_date, datetime) 114 | 115 | 116 | def test_sync_token_is_publication_time_in_unix_epoch_time_format( 117 | json_data, create_date 118 | ): 119 | """Ensure the syncToken is the publication time, in Unix epoch time format.""" 120 | assert "syncToken" in json_data 121 | sync_token = int(json_data["syncToken"]) 122 | assert isinstance(sync_token, int) 123 | 124 | publication_time = datetime.fromtimestamp(sync_token, tz=CREATE_DATE_TIMEZONE) 125 | 126 | assert publication_time == create_date 127 | 128 | 129 | def test_prefixes_syntax(json_data): 130 | """Validate the structure of the IPv4 `prefixes` objects.""" 131 | assert "prefixes" in json_data 132 | prefixes = json_data["prefixes"] 133 | assert isinstance(prefixes, list) 134 | 135 | for prefix in prefixes: 136 | assert "ip_prefix" in prefix 137 | assert isinstance(prefix["ip_prefix"], str) 138 | assert isinstance(ip_network(prefix["ip_prefix"]), IPv4Network) 139 | 140 | assert "region" in prefix 141 | assert isinstance(prefix["region"], str) 142 | 143 | assert "network_border_group" in prefix 144 | assert isinstance(prefix["network_border_group"], str) 145 | 146 | assert "service" in prefix 147 | assert isinstance(prefix["service"], str) 148 | 149 | 150 | def test_ipv6_prefixes_syntax(json_data): 151 | """Validate the structure of the IPv6 `ipv6_prefixes` objects.""" 152 | assert "ipv6_prefixes" in json_data 153 | ipv6_prefixes = json_data["ipv6_prefixes"] 154 | assert isinstance(ipv6_prefixes, list) 155 | 156 | for ipv6_prefix in ipv6_prefixes: 157 | assert "ipv6_prefix" in ipv6_prefix 158 | assert isinstance(ipv6_prefix["ipv6_prefix"], str) 159 | assert isinstance(ip_network(ipv6_prefix["ipv6_prefix"]), IPv6Network) 160 | 161 | assert "region" in ipv6_prefix 162 | assert isinstance(ipv6_prefix["region"], str) 163 | 164 | assert "network_border_group" in ipv6_prefix 165 | assert isinstance(ipv6_prefix["network_border_group"], str) 166 | 167 | assert "service" in ipv6_prefix 168 | assert isinstance(ipv6_prefix["service"], str) 169 | 170 | 171 | def test_duplicate_prefix_records_are_from_same_region(deduplicated_prefixes): 172 | """Verify duplicate records for a prefix are from the same Region. 173 | 174 | A single prefix should not be advertised from multiple Regions. 175 | """ 176 | test = True 177 | for prefix, records in deduplicated_prefixes.items(): 178 | region = records[0]["region"] 179 | if not all([record["region"] == region for record in records]): 180 | test = False 181 | print( 182 | f"Prefix {prefix!s} is advertised from multiple Regions: " 183 | f"{[record['region'] for record in records]}" 184 | ) 185 | assert test 186 | 187 | 188 | def test_duplicate_prefix_records_are_from_same_network_border_group( 189 | deduplicated_prefixes, 190 | ): 191 | """Verify duplicate records are from the same network border group. 192 | 193 | A single prefix should not be advertised from multiple network border 194 | groups. 195 | """ 196 | test = True 197 | for prefix, records in deduplicated_prefixes.items(): 198 | network_border_group = records[0]["network_border_group"] 199 | if not all( 200 | [ 201 | record["network_border_group"] == network_border_group 202 | for record in records 203 | ] 204 | ): 205 | test = False 206 | print( 207 | f"Prefix {prefix!s} is advertised from multiple network border groups: " 208 | f"{[record['network_border_group'] for record in records]}" 209 | ) 210 | assert test 211 | 212 | 213 | def test_duplicate_prefix_records_are_for_different_services( 214 | deduplicated_prefixes, 215 | ): 216 | """Verify duplicate records for a prefix are for different services. 217 | 218 | A single prefix may service multiple AWS services, which should be the cause 219 | for having duplicate records. Each duplicate records should be for a 220 | different service. 221 | """ 222 | test = True 223 | for prefix, records in deduplicated_prefixes.items(): 224 | if len(records) == 1: 225 | # A single record for this prefix 226 | continue 227 | 228 | # Unique set of services from the records 229 | services = {record["service"] for record in records} 230 | if len(services) != len(records): 231 | test = False 232 | print( 233 | f"Prefix {prefix!s} has duplicate services: " 234 | f"{[record['service'] for record in records]}" 235 | ) 236 | assert test 237 | 238 | 239 | def test_prefixes_contain_subnets(deduplicated_prefixes): 240 | """Verify IPv4 prefixes contain subnets (more specific prefixes).""" 241 | for test_prefix in deduplicated_prefixes: 242 | for prefix in deduplicated_prefixes: 243 | if test_prefix != prefix and test_prefix.subnet_of(prefix): 244 | assert True 245 | return 246 | assert False 247 | 248 | 249 | def test_duplicate_ipv6_prefix_records_are_from_same_region(deduplicated_ipv6_prefixes): 250 | """Verify duplicate records for a prefix are from the same Region. 251 | 252 | A single prefix should not be advertised from multiple Regions. 253 | """ 254 | test = True 255 | for prefix, records in deduplicated_ipv6_prefixes.items(): 256 | region = records[0]["region"] 257 | if not all([record["region"] == region for record in records]): 258 | test = False 259 | print( 260 | f"Prefix {prefix!s} is advertised from multiple Regions: " 261 | f"{[record['region'] for record in records]}" 262 | ) 263 | assert test 264 | 265 | 266 | def test_duplicate_ipv6_prefix_records_are_from_same_network_border_group( 267 | deduplicated_ipv6_prefixes, 268 | ): 269 | """Verify duplicate records are from the same network border group. 270 | 271 | A single prefix should not be advertised from multiple network border 272 | groups. 273 | """ 274 | test = True 275 | for prefix, records in deduplicated_ipv6_prefixes.items(): 276 | network_border_group = records[0]["network_border_group"] 277 | if not all( 278 | [ 279 | record["network_border_group"] == network_border_group 280 | for record in records 281 | ] 282 | ): 283 | test = False 284 | print( 285 | f"Prefix {prefix!s} is advertised from multiple network border " 286 | f"groups: " 287 | f"{[record['network_border_group'] for record in records]}" 288 | ) 289 | assert test 290 | 291 | 292 | def test_duplicate_ipv6_prefix_records_are_for_different_services( 293 | deduplicated_ipv6_prefixes, 294 | ): 295 | """Verify duplicate records for a prefix are for different services. 296 | 297 | A single prefix may service multiple AWS services, which should be the cause 298 | for having duplicate records. Each duplicate records should be for a 299 | different service. 300 | """ 301 | test = True 302 | for prefix, records in deduplicated_ipv6_prefixes.items(): 303 | if len(records) == 1: 304 | # A single record for this prefix 305 | continue 306 | 307 | # Unique set of services from the records 308 | services = {record["service"] for record in records} 309 | if len(services) != len(records): 310 | test = False 311 | print( 312 | f"Prefix {prefix!s} has duplicate services: " 313 | f"{[record['service'] for record in records]}" 314 | ) 315 | assert test 316 | 317 | 318 | def test_ipv6_prefixes_contain_subnets(deduplicated_ipv6_prefixes): 319 | """Verify IPv6 prefixes contain subnets (more specific prefixes).""" 320 | for test_ipv6_prefix in deduplicated_ipv6_prefixes: 321 | for ipv6_prefix in deduplicated_ipv6_prefixes: 322 | if test_ipv6_prefix != ipv6_prefix and test_ipv6_prefix.subnet_of( 323 | ipv6_prefix 324 | ): 325 | assert True 326 | return 327 | assert False 328 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/awsipranges/24455318d1fa4b6d206e49800451d096a84e100d/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_package_apis.py: -------------------------------------------------------------------------------- 1 | """End-to-end package API tests.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | import random 7 | from datetime import datetime, timezone 8 | 9 | import pytest 10 | 11 | import awsipranges 12 | from awsipranges import AWSIPPrefixes, AWSIPv4Prefix, AWSIPv6Prefix 13 | from tests.utils import ( 14 | random_ipv4_address, 15 | random_ipv4_host_in_network, 16 | random_ipv4_subnet_in_network, 17 | random_ipv6_address, 18 | random_ipv6_host_in_network, 19 | random_ipv6_subnet_in_network, 20 | ) 21 | 22 | 23 | # Fixtures 24 | @pytest.fixture(scope="session") 25 | def aws_ip_ranges() -> AWSIPPrefixes: 26 | """Get the AWS IP address ranges.""" 27 | return awsipranges.get_ranges() 28 | 29 | 30 | # Happy path tests 31 | def test_get_ranges(aws_ip_ranges: AWSIPPrefixes): 32 | assert isinstance(aws_ip_ranges, AWSIPPrefixes) 33 | 34 | 35 | def test_create_date_is_utc_datetime(aws_ip_ranges: AWSIPPrefixes): 36 | assert isinstance(aws_ip_ranges.create_date, datetime) 37 | assert aws_ip_ranges.create_date.tzinfo == timezone.utc 38 | 39 | 40 | def test_sync_token_is_opaque_string(aws_ip_ranges: AWSIPPrefixes): 41 | assert isinstance(aws_ip_ranges.sync_token, str) 42 | assert len(aws_ip_ranges.sync_token) > 0 43 | 44 | 45 | def test_md5_is_hexadecimal_string(aws_ip_ranges: AWSIPPrefixes): 46 | assert isinstance(aws_ip_ranges.md5, str) 47 | assert len(aws_ip_ranges.md5) > 0 48 | assert int(aws_ip_ranges.md5, 16) > 0 49 | 50 | 51 | def test_ipv4_prefixes_are_aws_ip4_prefixes(aws_ip_ranges: AWSIPPrefixes): 52 | for prefix in aws_ip_ranges.ipv4_prefixes: 53 | assert isinstance(prefix, AWSIPv4Prefix) 54 | 55 | 56 | def test_ipv6_prefixes_are_aws_ipv6_prefixes(aws_ip_ranges: AWSIPPrefixes): 57 | for prefix in aws_ip_ranges.ipv6_prefixes: 58 | assert isinstance(prefix, AWSIPv6Prefix) 59 | 60 | 61 | def test_can_iterate_over_all_aws_ip_prefixes(aws_ip_ranges: AWSIPPrefixes): 62 | for prefix in aws_ip_ranges: 63 | assert isinstance(prefix, (AWSIPv4Prefix, AWSIPv6Prefix)) 64 | assert len(list(aws_ip_ranges)) == len(aws_ip_ranges.ipv4_prefixes) + len( 65 | aws_ip_ranges.ipv6_prefixes 66 | ) 67 | 68 | 69 | def test_can_check_if_ipv4_address_is_contained_in_aws_ip_prefixes( 70 | aws_ip_ranges: AWSIPPrefixes, 71 | ): 72 | prefix = random.choice(aws_ip_ranges.ipv4_prefixes) 73 | address = random_ipv4_host_in_network(prefix.prefix) 74 | assert address in aws_ip_ranges 75 | assert str(address) in aws_ip_ranges 76 | 77 | 78 | def test_can_check_if_ipv6_address_is_contained_in_aws_ip_prefixes( 79 | aws_ip_ranges: AWSIPPrefixes, 80 | ): 81 | prefix = random.choice(aws_ip_ranges.ipv6_prefixes) 82 | address = random_ipv6_host_in_network(prefix.prefix) 83 | assert address in aws_ip_ranges 84 | assert str(address) in aws_ip_ranges 85 | 86 | 87 | def test_can_index_aws_ip_prefix_by_ipv4_address(aws_ip_ranges: AWSIPPrefixes): 88 | prefix = random.choice(aws_ip_ranges.ipv4_prefixes) 89 | address = random_ipv4_host_in_network(prefix.prefix) 90 | 91 | # Possible prefixes 92 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(address)) 93 | assert prefix in possible_prefixes 94 | 95 | assert aws_ip_ranges[address] in possible_prefixes 96 | assert aws_ip_ranges[str(address)] in possible_prefixes 97 | 98 | 99 | def test_can_index_aws_ip_prefix_by_ipv6_address(aws_ip_ranges: AWSIPPrefixes): 100 | prefix = random.choice(aws_ip_ranges.ipv6_prefixes) 101 | address = random_ipv6_host_in_network(prefix.prefix) 102 | 103 | # Possible prefixes 104 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(address)) 105 | assert prefix in possible_prefixes 106 | 107 | assert aws_ip_ranges[address] in possible_prefixes 108 | assert aws_ip_ranges[str(address)] in possible_prefixes 109 | 110 | 111 | @pytest.mark.slow 112 | def test_can_index_all_aws_ip_prefix_by_ipv4_address(aws_ip_ranges: AWSIPPrefixes): 113 | for prefix in aws_ip_ranges.ipv4_prefixes: 114 | address = random_ipv4_host_in_network(prefix.prefix) 115 | 116 | # Possible prefixes 117 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(address)) 118 | assert prefix in possible_prefixes 119 | 120 | assert aws_ip_ranges[address] in possible_prefixes 121 | assert aws_ip_ranges[str(address)] in possible_prefixes 122 | 123 | 124 | @pytest.mark.slow 125 | def test_can_index_all_aws_ip_prefix_by_ipv6_address(aws_ip_ranges: AWSIPPrefixes): 126 | for prefix in aws_ip_ranges.ipv6_prefixes: 127 | address = random_ipv6_host_in_network(prefix.prefix) 128 | 129 | # Possible prefixes 130 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(address)) 131 | assert prefix in possible_prefixes 132 | 133 | assert aws_ip_ranges[address] in possible_prefixes 134 | assert aws_ip_ranges[str(address)] in possible_prefixes 135 | 136 | 137 | def test_can_index_aws_ip_prefix_by_ipv4_network(aws_ip_ranges: AWSIPPrefixes): 138 | prefix = random.choice(aws_ip_ranges.ipv4_prefixes) 139 | network = prefix.prefix 140 | 141 | # Possible prefixes 142 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(network)) 143 | assert prefix in possible_prefixes 144 | 145 | assert aws_ip_ranges[network] in possible_prefixes 146 | assert aws_ip_ranges[str(network)] in possible_prefixes 147 | 148 | 149 | def test_can_index_aws_ip_prefix_by_ipv6_network(aws_ip_ranges: AWSIPPrefixes): 150 | prefix = random.choice(aws_ip_ranges.ipv6_prefixes) 151 | network = prefix.prefix 152 | 153 | # Possible prefixes 154 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(network)) 155 | assert prefix in possible_prefixes 156 | 157 | assert aws_ip_ranges[network] in possible_prefixes 158 | assert aws_ip_ranges[str(network)] in possible_prefixes 159 | 160 | 161 | def test_can_index_aws_ip_prefix_by_ipv4_subnet(aws_ip_ranges: AWSIPPrefixes): 162 | prefix = random.choice(aws_ip_ranges.ipv4_prefixes) 163 | subnet = random_ipv4_subnet_in_network(prefix.prefix) 164 | 165 | # Possible prefixes 166 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(subnet)) 167 | assert prefix in possible_prefixes 168 | 169 | assert aws_ip_ranges[subnet] in possible_prefixes 170 | assert aws_ip_ranges[str(subnet)] in possible_prefixes 171 | 172 | 173 | def test_can_index_aws_ip_prefix_by_ipv6_subnet(aws_ip_ranges: AWSIPPrefixes): 174 | prefix = random.choice(aws_ip_ranges.ipv6_prefixes) 175 | subnet = random_ipv6_subnet_in_network(prefix.prefix) 176 | 177 | # Possible prefixes 178 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(subnet)) 179 | assert prefix in possible_prefixes 180 | 181 | assert aws_ip_ranges[subnet] in possible_prefixes 182 | assert aws_ip_ranges[str(subnet)] in possible_prefixes 183 | 184 | 185 | def test_can_get_aws_ip_prefix_by_ipv4_address(aws_ip_ranges: AWSIPPrefixes): 186 | prefix = random.choice(aws_ip_ranges.ipv4_prefixes) 187 | address = random_ipv4_host_in_network(prefix.prefix) 188 | 189 | # Possible prefixes 190 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(address)) 191 | assert prefix in possible_prefixes 192 | 193 | assert aws_ip_ranges.get(address) in possible_prefixes 194 | assert aws_ip_ranges.get(str(address)) in possible_prefixes 195 | 196 | 197 | def test_can_get_aws_ip_prefix_by_ipv6_address(aws_ip_ranges: AWSIPPrefixes): 198 | prefix = random.choice(aws_ip_ranges.ipv6_prefixes) 199 | address = random_ipv6_host_in_network(prefix.prefix) 200 | 201 | # Possible prefixes 202 | possible_prefixes = set(aws_ip_ranges.get_prefix_and_supernets(address)) 203 | assert prefix in possible_prefixes 204 | 205 | assert aws_ip_ranges.get(address) in possible_prefixes 206 | assert aws_ip_ranges.get(str(address)) in possible_prefixes 207 | 208 | 209 | def test_get_unknown_ipv4_address_returns_default_value(aws_ip_ranges: AWSIPPrefixes): 210 | while True: 211 | address = random_ipv4_address() 212 | if address not in aws_ip_ranges: 213 | break 214 | 215 | assert aws_ip_ranges.get(address, default="default") == "default" 216 | assert aws_ip_ranges.get(str(address), default="default") == "default" 217 | 218 | 219 | def test_get_unknown_ipv6_address_returns_default_value(aws_ip_ranges: AWSIPPrefixes): 220 | while True: 221 | address = random_ipv6_address() 222 | if address not in aws_ip_ranges: 223 | break 224 | 225 | assert aws_ip_ranges.get(address, default="default") == "default" 226 | assert aws_ip_ranges.get(str(address), default="default") == "default" 227 | 228 | 229 | def test_can_filter_by_region(aws_ip_ranges: AWSIPPrefixes): 230 | region = random.choice(list(aws_ip_ranges.regions)) 231 | filtered_ranges = aws_ip_ranges.filter(regions=region) 232 | for prefix in filtered_ranges: 233 | assert prefix.region == region 234 | 235 | 236 | def test_can_filter_by_multiple_regions(aws_ip_ranges: AWSIPPrefixes): 237 | regions = [ 238 | random.choice(list(aws_ip_ranges.regions)), 239 | random.choice(list(aws_ip_ranges.regions)), 240 | ] 241 | filtered_ranges = aws_ip_ranges.filter(regions=regions) 242 | for prefix in filtered_ranges: 243 | assert prefix.region in regions 244 | 245 | 246 | def test_can_filter_by_network_border_group(aws_ip_ranges: AWSIPPrefixes): 247 | network_border_group = random.choice(list(aws_ip_ranges.network_border_groups)) 248 | filtered_ranges = aws_ip_ranges.filter(network_border_groups=network_border_group) 249 | for prefix in filtered_ranges: 250 | assert prefix.network_border_group == network_border_group 251 | 252 | 253 | def test_can_filter_by_multiple_network_border_groups(aws_ip_ranges: AWSIPPrefixes): 254 | network_border_groups = [ 255 | random.choice(list(aws_ip_ranges.network_border_groups)), 256 | random.choice(list(aws_ip_ranges.network_border_groups)), 257 | ] 258 | filtered_ranges = aws_ip_ranges.filter(network_border_groups=network_border_groups) 259 | for prefix in filtered_ranges: 260 | assert prefix.network_border_group in network_border_groups 261 | 262 | 263 | def test_can_filter_by_service(aws_ip_ranges: AWSIPPrefixes): 264 | service = random.choice(list(aws_ip_ranges.services)) 265 | filtered_ranges = aws_ip_ranges.filter(services=service) 266 | for prefix in filtered_ranges: 267 | assert service in prefix.services 268 | 269 | 270 | def test_can_filter_by_multiple_services(aws_ip_ranges: AWSIPPrefixes): 271 | services = [ 272 | random.choice(list(aws_ip_ranges.services)), 273 | random.choice(list(aws_ip_ranges.services)), 274 | ] 275 | filtered_ranges = aws_ip_ranges.filter(services=services) 276 | for prefix in filtered_ranges: 277 | assert set(services).intersection(set(prefix.services)) 278 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Test the tests utility functions.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | from ipaddress import ( 7 | IPv4Address, 8 | IPv4Interface, 9 | IPv4Network, 10 | IPv6Address, 11 | IPv6Interface, 12 | IPv6Network, 13 | ) 14 | 15 | import pytest 16 | 17 | import tests.utils 18 | 19 | 20 | ITERATIONS_OF_RANDOM_TESTS = 100 21 | 22 | 23 | pytestmark = pytest.mark.test_utils 24 | 25 | 26 | # Happy path tests 27 | def test_random_string(): 28 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 29 | random_string = tests.utils.random_string(1, 50) 30 | assert isinstance(random_string, str) 31 | assert 1 <= len(random_string) <= 50 32 | 33 | 34 | def test_random_ipv4_address(): 35 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 36 | random_address = tests.utils.random_ipv4_address() 37 | assert isinstance(random_address, IPv4Address) 38 | 39 | 40 | def test_random_ipv4_interface(): 41 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 42 | random_interface = tests.utils.random_ipv4_interface() 43 | assert isinstance(random_interface, IPv4Interface) 44 | assert isinstance(random_interface.ip, IPv4Address) 45 | assert isinstance(random_interface.network, IPv4Network) 46 | 47 | 48 | def test_random_ipv4_network(): 49 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 50 | random_network = tests.utils.random_ipv4_network() 51 | assert isinstance(random_network, IPv4Network) 52 | 53 | 54 | def test_random_ipv4_host_in_network(): 55 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 56 | random_network = tests.utils.random_ipv4_network( 57 | max_prefix_length=tests.utils.IPV4_MAX_PREFIX_LENGTH - 2, 58 | ) 59 | random_host = tests.utils.random_ipv4_host_in_network(random_network) 60 | assert isinstance(random_host, IPv4Address) 61 | assert random_host in random_network 62 | 63 | 64 | def test_random_ipv4_subnet_in_network(): 65 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 66 | random_network = tests.utils.random_ipv4_network( 67 | max_prefix_length=tests.utils.IPV4_MAX_PREFIX_LENGTH - 2, 68 | ) 69 | random_subnet = tests.utils.random_ipv4_subnet_in_network(random_network) 70 | assert isinstance(random_subnet, IPv4Network) 71 | assert random_subnet.subnet_of(random_network) 72 | 73 | 74 | def test_random_ipv6_address(): 75 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 76 | random_address = tests.utils.random_ipv6_address() 77 | assert isinstance(random_address, IPv6Address) 78 | 79 | 80 | def test_random_ipv6_interface(): 81 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 82 | random_interface = tests.utils.random_ipv6_interface() 83 | assert isinstance(random_interface, IPv6Interface) 84 | assert isinstance(random_interface.ip, IPv6Address) 85 | assert isinstance(random_interface.network, IPv6Network) 86 | 87 | 88 | def test_random_ipv6_network(): 89 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 90 | random_network = tests.utils.random_ipv6_network() 91 | assert isinstance(random_network, IPv6Network) 92 | 93 | 94 | def test_random_ipv6_host_in_network(): 95 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 96 | random_network = tests.utils.random_ipv6_network( 97 | max_prefix_length=tests.utils.IPV6_MAX_PREFIX_LENGTH - 2, 98 | ) 99 | random_host = tests.utils.random_ipv6_host_in_network(random_network) 100 | assert isinstance(random_host, IPv6Address) 101 | assert random_host in random_network 102 | 103 | 104 | def test_random_ipv6_subnet_in_network(): 105 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 106 | random_network = tests.utils.random_ipv6_network( 107 | max_prefix_length=tests.utils.IPV6_MAX_PREFIX_LENGTH - 2, 108 | ) 109 | random_subnet = tests.utils.random_ipv6_subnet_in_network(random_network) 110 | assert isinstance(random_subnet, IPv6Network) 111 | assert random_subnet.subnet_of(random_network) 112 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/awsipranges/24455318d1fa4b6d206e49800451d096a84e100d/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/awsipranges/24455318d1fa4b6d206e49800451d096a84e100d/tests/unit/models/__init__.py -------------------------------------------------------------------------------- /tests/unit/models/test_awsipprefix.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the awsipprefix module.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | import ipaddress 7 | import random 8 | 9 | from awsipranges.models.awsipprefix import aws_ip_prefix, AWSIPv4Prefix, AWSIPv6Prefix 10 | from tests.utils import ( 11 | random_ipv4_host_in_network, 12 | random_ipv4_network, 13 | random_ipv6_host_in_network, 14 | random_ipv6_network, 15 | random_string, 16 | ) 17 | 18 | 19 | ITERATIONS_OF_RANDOM_TESTS = 100 20 | 21 | 22 | # Helper functions 23 | def random_aws_ipv4_prefix() -> AWSIPv4Prefix: 24 | return AWSIPv4Prefix( 25 | prefix=random_ipv4_network(), 26 | region=random_string(), 27 | network_border_group=random_string(), 28 | services=(tuple(random_string() for _ in range(random.randint(1, 5)))), 29 | ) 30 | 31 | 32 | def random_aws_ipv6_prefix() -> AWSIPv6Prefix: 33 | return AWSIPv6Prefix( 34 | prefix=random_ipv6_network(), 35 | region=random_string(), 36 | network_border_group=random_string(), 37 | services=(tuple(random_string() for _ in range(random.randint(1, 5)))), 38 | ) 39 | 40 | 41 | # Happy path tests 42 | def test_creating_basic_aws_ipv4_prefix(): 43 | prefix = AWSIPv4Prefix( 44 | prefix="3.5.140.0/22", 45 | region="ap-northeast-2", 46 | network_border_group="ap-northeast-2", 47 | services=("AMAZON", "EC2", "S3"), 48 | ) 49 | assert isinstance(prefix, AWSIPv4Prefix) 50 | print(repr(prefix)) 51 | 52 | 53 | def test_creating_basic_aws_ipv4_prefix_with_factory_function(): 54 | json_data = { 55 | "ip_prefix": "3.5.140.0/22", 56 | "region": "ap-northeast-2", 57 | "network_border_group": "ap-northeast-2", 58 | "service": "EC2", 59 | } 60 | prefix = aws_ip_prefix(json_data) 61 | assert isinstance(prefix, AWSIPv4Prefix) 62 | print(repr(prefix)) 63 | 64 | 65 | def test_creating_random_aws_ipv4_prefixes(): 66 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 67 | prefix = random_aws_ipv4_prefix() 68 | assert isinstance(prefix, AWSIPv4Prefix) 69 | 70 | 71 | def test_aws_ip4_prefix_attribute_access(): 72 | prefix = random_aws_ipv4_prefix() 73 | assert isinstance(prefix, AWSIPv4Prefix) 74 | assert isinstance(prefix.prefix, ipaddress.IPv4Network) 75 | assert prefix.prefix == prefix.ip_prefix 76 | assert isinstance(prefix.region, str) 77 | assert isinstance(prefix.network_border_group, str) 78 | assert isinstance(prefix.services, tuple) 79 | assert isinstance(prefix.version, int) 80 | assert prefix.version == 4 81 | assert isinstance(prefix.prefixlen, int) 82 | assert isinstance(prefix.network_address, ipaddress.IPv4Address) 83 | assert isinstance(prefix.netmask, ipaddress.IPv4Address) 84 | assert isinstance(prefix.hostmask, ipaddress.IPv4Address) 85 | assert isinstance(prefix.with_prefixlen, str) 86 | assert isinstance(prefix.with_netmask, str) 87 | assert isinstance(prefix.with_hostmask, str) 88 | 89 | 90 | def test_aws_ipv4_prefix_repr(): 91 | print(repr(random_aws_ipv4_prefix())) 92 | 93 | 94 | def test_aws_ipv4_prefix_str(): 95 | print(random_aws_ipv4_prefix()) 96 | 97 | 98 | def test_aws_ipv4_prefix_eq(): 99 | prefix = random_aws_ipv4_prefix() 100 | other_prefix = AWSIPv4Prefix( 101 | prefix=prefix.prefix, 102 | region=prefix.region, 103 | network_border_group=prefix.network_border_group, 104 | services=prefix.services, 105 | ) 106 | assert prefix == other_prefix 107 | 108 | 109 | def test_aws_ipv4_prefix_subnet_sort_order(): 110 | supernet = AWSIPv4Prefix( 111 | "10.0.0.0/8", 112 | region=random_string(), 113 | network_border_group=random_string(), 114 | services=random_string(), 115 | ) 116 | subnet1 = AWSIPv4Prefix( 117 | "10.0.0.0/16", 118 | region=random_string(), 119 | network_border_group=random_string(), 120 | services=random_string(), 121 | ) 122 | subnet2 = AWSIPv4Prefix( 123 | "10.1.0.0/16", 124 | region=random_string(), 125 | network_border_group=random_string(), 126 | services=random_string(), 127 | ) 128 | 129 | networks = [subnet2, subnet1, supernet] 130 | networks.sort() 131 | 132 | assert networks == [supernet, subnet1, subnet2] 133 | 134 | 135 | def test_aws_ipv4_prefix_contains(): 136 | prefix = random_aws_ipv4_prefix() 137 | address = random_ipv4_host_in_network(prefix.prefix) 138 | network = ipaddress.IPv4Network((address, 32)) 139 | interface = ipaddress.IPv4Interface((address, network.prefixlen)) 140 | string = str(address) 141 | 142 | assert string in prefix 143 | assert address in prefix 144 | assert network in prefix 145 | assert interface in prefix 146 | 147 | 148 | def test_creating_basic_aws_ipv6_prefix(): 149 | prefix = AWSIPv6Prefix( 150 | prefix="2a05:d070:e000::/40", 151 | region="me-south-1", 152 | network_border_group="me-south-1", 153 | services=("AMAZON", "EC2", "S3"), 154 | ) 155 | assert isinstance(prefix, AWSIPv6Prefix) 156 | print(repr(prefix)) 157 | 158 | 159 | def test_creating_basic_aws_ipv6_prefix_with_factory_function(): 160 | json_data = { 161 | "ipv6_prefix": "2a05:d070:e000::/40", 162 | "region": "me-south-1", 163 | "service": "EC2", 164 | "network_border_group": "me-south-1", 165 | } 166 | prefix = aws_ip_prefix(json_data) 167 | assert isinstance(prefix, AWSIPv6Prefix) 168 | print(repr(prefix)) 169 | 170 | 171 | def test_creating_random_aws_ipv6_prefixes(): 172 | for _ in range(ITERATIONS_OF_RANDOM_TESTS): 173 | prefix = random_aws_ipv6_prefix() 174 | assert isinstance(prefix, AWSIPv6Prefix) 175 | 176 | 177 | def test_aws_ip6_prefix_attribute_access(): 178 | prefix = random_aws_ipv6_prefix() 179 | assert isinstance(prefix, AWSIPv6Prefix) 180 | assert isinstance(prefix.prefix, ipaddress.IPv6Network) 181 | assert prefix.prefix == prefix.ipv6_prefix 182 | assert isinstance(prefix.region, str) 183 | assert isinstance(prefix.network_border_group, str) 184 | assert isinstance(prefix.services, tuple) 185 | assert isinstance(prefix.version, int) 186 | assert prefix.version == 6 187 | assert isinstance(prefix.prefixlen, int) 188 | assert isinstance(prefix.network_address, ipaddress.IPv6Address) 189 | assert isinstance(prefix.netmask, ipaddress.IPv6Address) 190 | assert isinstance(prefix.hostmask, ipaddress.IPv6Address) 191 | assert isinstance(prefix.with_prefixlen, str) 192 | assert isinstance(prefix.with_netmask, str) 193 | assert isinstance(prefix.with_hostmask, str) 194 | 195 | 196 | def test_aws_ipv6_prefix_repr(): 197 | print(repr(random_aws_ipv6_prefix())) 198 | 199 | 200 | def test_aws_ipv6_prefix_str(): 201 | print(random_aws_ipv6_prefix()) 202 | 203 | 204 | def test_aws_ipv6_prefix_eq(): 205 | prefix = random_aws_ipv6_prefix() 206 | other_prefix = AWSIPv6Prefix( 207 | prefix=prefix.prefix, 208 | region=prefix.region, 209 | network_border_group=prefix.network_border_group, 210 | services=prefix.services, 211 | ) 212 | assert prefix == other_prefix 213 | 214 | 215 | def test_aws_ipv6_prefix_subnet_sort_order(): 216 | supernet = AWSIPv6Prefix( 217 | "2001:face::/32", 218 | region=random_string(), 219 | network_border_group=random_string(), 220 | services=random_string(), 221 | ) 222 | subnet1 = AWSIPv6Prefix( 223 | "2001:face::/48", 224 | region=random_string(), 225 | network_border_group=random_string(), 226 | services=random_string(), 227 | ) 228 | subnet2 = AWSIPv6Prefix( 229 | "2001:face:1::/48", 230 | region=random_string(), 231 | network_border_group=random_string(), 232 | services=random_string(), 233 | ) 234 | 235 | networks = [subnet2, subnet1, supernet] 236 | networks.sort() 237 | 238 | assert networks == [supernet, subnet1, subnet2] 239 | 240 | 241 | def test_aws_ipv6_prefix_contains(): 242 | prefix = random_aws_ipv6_prefix() 243 | address = random_ipv6_host_in_network(prefix.prefix) 244 | network = ipaddress.IPv6Network((address, 128)) 245 | interface = ipaddress.IPv6Interface((address, network.prefixlen)) 246 | string = str(address) 247 | 248 | assert string in prefix 249 | assert address in prefix 250 | assert network in prefix 251 | assert interface in prefix 252 | -------------------------------------------------------------------------------- /tests/unit/test_data_loading.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the data_loading module.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | from pathlib import Path 7 | from tempfile import TemporaryDirectory 8 | from typing import Dict, Iterable 9 | from urllib.parse import urljoin 10 | from urllib.request import urlopen 11 | 12 | import pytest 13 | from OpenSSL import crypto 14 | 15 | from awsipranges.data_loading import get_json_data 16 | 17 | 18 | AMAZON_TRUST_SERVICES_REPOSITORY_URL = "https://www.amazontrust.com/repository/" 19 | 20 | AMAZON_ROOT_CA_FILENAMES = [ 21 | "AmazonRootCA1.pem", 22 | "AmazonRootCA2.pem", 23 | "AmazonRootCA3.pem", 24 | "AmazonRootCA4.pem", 25 | ] 26 | 27 | 28 | pytestmark = pytest.mark.extra_data_loading 29 | 30 | 31 | # Helper functions 32 | def calculate_subject_name_hash(pem: str) -> str: 33 | """Calculate the OpenSSL subject_name_hash for a certificate in PEM format.""" 34 | assert isinstance(pem, str) 35 | certificate = crypto.load_certificate(crypto.FILETYPE_PEM, pem.encode()) 36 | return format(certificate.subject_name_hash(), "02x") 37 | 38 | 39 | def save_to_stacked_certificate_file( 40 | certificates: Iterable[str], file_path: Path 41 | ) -> Path: 42 | """Save certificates (in PEM format) to a directory of hashed certificates.""" 43 | assert isinstance(certificates, Iterable) 44 | assert isinstance(file_path, Path) 45 | 46 | stacked_certificates = "\n".join( 47 | (certificate.strip() for certificate in certificates) 48 | ) 49 | 50 | with open(file_path, "w") as file: 51 | file.write(stacked_certificates) 52 | 53 | return file_path 54 | 55 | 56 | def save_to_directory_of_hashed_certificates(pem: str, directory: Path) -> Path: 57 | """Save a certificate (in PEM format) to a directory of hashed certificates.""" 58 | assert isinstance(pem, str) 59 | assert isinstance(directory, Path) 60 | assert directory.is_dir() 61 | 62 | subject_name_hash = calculate_subject_name_hash(pem) 63 | certificate_number = 0 64 | 65 | while True: 66 | file_path = directory / f"{subject_name_hash}.{certificate_number}" 67 | if file_path.exists(): 68 | certificate_number += 1 69 | continue 70 | else: 71 | with open(file_path, "w") as file: 72 | file.write(pem) 73 | break 74 | 75 | return file_path 76 | 77 | 78 | # Fixtures 79 | @pytest.fixture(scope="module") 80 | def certificates_directory() -> Path: 81 | with TemporaryDirectory(suffix="certificates") as certificates_directory: 82 | yield Path(certificates_directory) 83 | 84 | 85 | @pytest.fixture(scope="module") 86 | def amazon_root_certificates() -> Dict[str, str]: 87 | """Download the Amazon root certificates from Amazon Trust Services.""" 88 | amazon_root_certificates = {} 89 | for ca_filename in AMAZON_ROOT_CA_FILENAMES: 90 | with urlopen( 91 | urljoin(AMAZON_TRUST_SERVICES_REPOSITORY_URL, ca_filename) 92 | ) as response: 93 | assert response.status == 200 94 | certificate_contents = response.read().decode() 95 | amazon_root_certificates[ca_filename] = certificate_contents 96 | 97 | return amazon_root_certificates 98 | 99 | 100 | @pytest.fixture(scope="module") 101 | def cafile( 102 | amazon_root_certificates: Dict[str, str], certificates_directory: Path 103 | ) -> Path: 104 | cafile = certificates_directory / "stacked_certificates.pem" 105 | save_to_stacked_certificate_file(amazon_root_certificates.values(), cafile) 106 | 107 | return cafile 108 | 109 | 110 | @pytest.fixture(scope="module") 111 | def capath( 112 | amazon_root_certificates: Dict[str, str], certificates_directory: Path 113 | ) -> Path: 114 | capath = certificates_directory 115 | assert capath.is_dir() 116 | for certificate in amazon_root_certificates.values(): 117 | save_to_directory_of_hashed_certificates(certificate, capath) 118 | 119 | return capath 120 | 121 | 122 | # Happy path tests 123 | def test_get_json_data(): 124 | json_data, json_md5 = get_json_data() 125 | assert isinstance(json_data, dict) 126 | assert isinstance(json_md5, str) 127 | assert json_md5 128 | 129 | 130 | def test_get_json_data_with_cafile(cafile: Path): 131 | json_data, json_md5 = get_json_data(cafile=cafile) 132 | assert isinstance(json_data, dict) 133 | assert isinstance(json_md5, str) 134 | assert json_md5 135 | 136 | 137 | def test_get_json_data_with_capath(capath: Path): 138 | json_data, json_md5 = get_json_data(capath=capath) 139 | assert isinstance(json_data, dict) 140 | assert isinstance(json_md5, str) 141 | assert json_md5 142 | -------------------------------------------------------------------------------- /tests/unit/test_exceptions.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the awsipranges custom exceptions.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | import pytest 6 | 7 | from awsipranges.config import AWS_IP_ADDRESS_RANGES_URL 8 | from awsipranges.exceptions import AWSIPRangesException, HTTPError, raise_for_status 9 | 10 | 11 | # Helper classes 12 | @dataclass 13 | class Response: 14 | url: str = AWS_IP_ADDRESS_RANGES_URL 15 | status: int = 200 16 | reason: str = "OK" 17 | 18 | 19 | @dataclass 20 | class LegacyResponse: 21 | _url: str = AWS_IP_ADDRESS_RANGES_URL 22 | _status: int = 200 23 | 24 | def geturl(self) -> str: 25 | return self._url 26 | 27 | def getstatus(self) -> int: 28 | return self._status 29 | 30 | 31 | @dataclass 32 | class LegacyResponseWithCode: 33 | _url: str = AWS_IP_ADDRESS_RANGES_URL 34 | code: int = 200 35 | 36 | def geturl(self) -> str: 37 | return self._url 38 | 39 | 40 | @dataclass 41 | class BadResponseNoStatus: 42 | url: str = AWS_IP_ADDRESS_RANGES_URL 43 | 44 | 45 | @dataclass 46 | class BadResponseNoURL: 47 | status: int = 200 48 | 49 | 50 | # Happy path tests 51 | def test_raising_aws_ip_ranges_exception(): 52 | with pytest.raises(AWSIPRangesException): 53 | raise AWSIPRangesException("Custom error message.") 54 | 55 | 56 | def test_raising_http_error(): 57 | with pytest.raises(HTTPError): 58 | raise HTTPError( 59 | "Request failed because of something you did", 60 | status=400, 61 | reason="Bad Request", 62 | ) 63 | 64 | 65 | def test_convert_aws_ip_ranges_exception_to_str(): 66 | exception = AWSIPRangesException("Custom error message.") 67 | exception_str = str(exception) 68 | print(exception_str) 69 | 70 | 71 | def test_convert_aws_ip_ranges_exception_to_repr(): 72 | exception = AWSIPRangesException("Custom error message.") 73 | exception_repr = repr(exception) 74 | print(exception_repr) 75 | 76 | 77 | def test_convert_http_error_to_str(): 78 | exception = HTTPError( 79 | "Request failed because of something you did", 80 | status=400, 81 | reason="Bad Request", 82 | ) 83 | exception_str = str(exception) 84 | print(exception_str) 85 | 86 | 87 | def test_convert_http_error_to_repr(): 88 | exception = HTTPError( 89 | "Request failed because of something you did", 90 | status=400, 91 | reason="Bad Request", 92 | ) 93 | exception_repr = repr(exception) 94 | print(exception_repr) 95 | 96 | 97 | def test_raise_for_status_ok(): 98 | response = Response() 99 | raise_for_status(response) 100 | 101 | 102 | def test_legacy_raise_for_status_ok(): 103 | response = LegacyResponse() 104 | raise_for_status(response) 105 | 106 | 107 | def test_legacy_response_with_code_ok(): 108 | response = LegacyResponseWithCode() 109 | raise_for_status(response) 110 | 111 | 112 | # Unhappy path tests 113 | def test_raise_for_status_client_error(): 114 | with pytest.raises(HTTPError): 115 | response = Response(status=400, reason="Bad Request") 116 | raise_for_status(response) 117 | 118 | 119 | def test_raise_for_status_server_error(): 120 | with pytest.raises(HTTPError): 121 | response = Response(status=500, reason="Internal Server Error") 122 | raise_for_status(response) 123 | 124 | 125 | def test_legacy_raise_for_status_client_error(): 126 | with pytest.raises(HTTPError): 127 | response = LegacyResponse(_status=400) 128 | raise_for_status(response) 129 | 130 | 131 | def test_legacy_response_with_code_client_error(): 132 | with pytest.raises(HTTPError): 133 | response = LegacyResponseWithCode(code=400) 134 | raise_for_status(response) 135 | 136 | 137 | def test_bad_response_no_status(): 138 | with pytest.raises(ValueError): 139 | response = BadResponseNoStatus() 140 | raise_for_status(response) 141 | 142 | 143 | def test_bad_response_no_url(): 144 | with pytest.raises(ValueError): 145 | response = BadResponseNoURL() 146 | raise_for_status(response) 147 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """Tests utility functions and classes.""" 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | import random 7 | import string 8 | from ipaddress import ( 9 | IPv4Address, 10 | IPv4Interface, 11 | IPv4Network, 12 | IPv6Address, 13 | IPv6Interface, 14 | IPv6Network, 15 | ) 16 | 17 | 18 | IPV4_START = "1.0.0.0" 19 | IPV4_END = "223.255.255.0" 20 | IPV4_MIN_PREFIX_LENGTH = 8 21 | IPV4_MAX_PREFIX_LENGTH = 32 22 | 23 | IPV6_START = "2000::" 24 | IPV6_END = "2c00:ffff:ffff:ffff:ffff:ffff:ffff:ffff" 25 | IPV6_MIN_PREFIX_LENGTH = 8 26 | IPV6_MAX_PREFIX_LENGTH = 128 27 | 28 | RANDOM_STRING_CHARACTERS = string.ascii_letters + "0123456789" + "-" 29 | 30 | 31 | def random_string(min_length: int = 12, max_length: int = 12) -> str: 32 | assert min_length <= max_length 33 | if min_length == max_length: 34 | length = max_length 35 | else: 36 | length = random.randint(min_length, max_length) 37 | 38 | random_characters = [random.choice(RANDOM_STRING_CHARACTERS) for _ in range(length)] 39 | 40 | return "".join(random_characters) 41 | 42 | 43 | def random_ipv4_address( 44 | start: str = IPV4_START, 45 | end: str = IPV4_END, 46 | ) -> IPv4Address: 47 | start_int = int(IPv4Address(start)) 48 | end_int = int(IPv4Address(end)) 49 | ip_int = random.randint(start_int, end_int) 50 | return IPv4Address(ip_int) 51 | 52 | 53 | def random_ipv4_interface( 54 | start: str = IPV4_START, 55 | end: str = IPV4_END, 56 | min_prefix_length: int = IPV4_MIN_PREFIX_LENGTH, 57 | max_prefix_length: int = IPV4_MAX_PREFIX_LENGTH, 58 | ) -> IPv4Interface: 59 | ip = random_ipv4_address(start, end) 60 | prefix_len = random.randint(min_prefix_length, max_prefix_length) 61 | return IPv4Interface((ip, prefix_len)) 62 | 63 | 64 | def random_ipv4_network( 65 | start: str = IPV4_START, 66 | end: str = IPV4_END, 67 | min_prefix_length: int = IPV4_MIN_PREFIX_LENGTH, 68 | max_prefix_length: int = IPV4_MAX_PREFIX_LENGTH, 69 | ) -> IPv4Network: 70 | interface = random_ipv4_interface( 71 | start=start, 72 | end=end, 73 | min_prefix_length=min_prefix_length, 74 | max_prefix_length=max_prefix_length, 75 | ) 76 | return interface.network 77 | 78 | 79 | def random_ipv4_host_in_network(network: IPv4Network) -> IPv4Address: 80 | if network.prefixlen == IPV4_MAX_PREFIX_LENGTH: 81 | # Host (/32) network 82 | return network.network_address 83 | elif network.prefixlen == IPV4_MAX_PREFIX_LENGTH - 1: 84 | # Point-to-point (/31) network 85 | start_int = int(network.network_address) 86 | end_int = int(network.network_address) + 1 87 | else: 88 | start_int = int(network.network_address) + 1 89 | end_int = int(network.network_address) + network.num_addresses - 2 90 | 91 | ip_int = random.randint(start_int, end_int) 92 | return IPv4Address(ip_int) 93 | 94 | 95 | def random_ipv4_subnet_in_network(network: IPv4Network) -> IPv4Network: 96 | if network.prefixlen == IPV4_MAX_PREFIX_LENGTH: 97 | # Host (/32) network 98 | return network 99 | elif network.prefixlen == IPV4_MAX_PREFIX_LENGTH - 1: 100 | # Point-to-point (/31) network 101 | subnet_int = random.choice([0, 1]) 102 | prefix_len = IPV4_MAX_PREFIX_LENGTH 103 | else: 104 | # Regular network 105 | min_prefix_len = network.prefixlen + 1 106 | max_prefix_len = IPV4_MAX_PREFIX_LENGTH - 1 107 | prefix_len = random.randint(min_prefix_len, max_prefix_len) 108 | num_subnet_bits = prefix_len - network.prefixlen 109 | subnet_int = random.randint(0, (2 ** num_subnet_bits) - 1) 110 | 111 | ip_int = int(network.network_address) + ( 112 | subnet_int << (IPV4_MAX_PREFIX_LENGTH - prefix_len) 113 | ) 114 | return IPv4Network((ip_int, prefix_len)) 115 | 116 | 117 | def random_ipv6_address( 118 | start: str = IPV6_START, 119 | end: str = IPV6_END, 120 | ) -> IPv6Address: 121 | start_int = int(IPv6Address(start)) 122 | end_int = int(IPv6Address(end)) 123 | ip_int = random.randint(start_int, end_int) 124 | return IPv6Address(ip_int) 125 | 126 | 127 | def random_ipv6_interface( 128 | start: str = IPV6_START, 129 | end: str = IPV6_END, 130 | min_prefix_length: int = IPV6_MIN_PREFIX_LENGTH, 131 | max_prefix_length: int = IPV6_MAX_PREFIX_LENGTH, 132 | ) -> IPv6Interface: 133 | ip = random_ipv6_address(start, end) 134 | prefix_len = random.randint(min_prefix_length, max_prefix_length) 135 | return IPv6Interface((ip, prefix_len)) 136 | 137 | 138 | def random_ipv6_network( 139 | start: str = IPV6_START, 140 | end: str = IPV6_END, 141 | min_prefix_length: int = IPV6_MIN_PREFIX_LENGTH, 142 | max_prefix_length: int = IPV6_MAX_PREFIX_LENGTH, 143 | ) -> IPv6Network: 144 | interface = random_ipv6_interface( 145 | start=start, 146 | end=end, 147 | min_prefix_length=min_prefix_length, 148 | max_prefix_length=max_prefix_length, 149 | ) 150 | return interface.network 151 | 152 | 153 | def random_ipv6_host_in_network(network: IPv6Network) -> IPv6Address: 154 | if network.prefixlen == IPV6_MAX_PREFIX_LENGTH: 155 | # Host (/128) network 156 | return network.network_address 157 | elif network.prefixlen == IPV6_MAX_PREFIX_LENGTH - 1: 158 | # Point-to-point (/127) network 159 | start_int = int(network.network_address) 160 | end_int = int(network.network_address) + 1 161 | else: 162 | start_int = int(network.network_address) + 1 163 | end_int = int(network.network_address) + network.num_addresses - 2 164 | 165 | ip_int = random.randint(start_int, end_int) 166 | return IPv6Address(ip_int) 167 | 168 | 169 | def random_ipv6_subnet_in_network(network: IPv6Network) -> IPv6Network: 170 | if network.prefixlen == IPV6_MAX_PREFIX_LENGTH: 171 | # Host (/128) network 172 | return network 173 | elif network.prefixlen == IPV6_MAX_PREFIX_LENGTH - 1: 174 | # Point-to-point (/127) network 175 | subnet_int = random.choice([0, 1]) 176 | prefix_len = IPV6_MAX_PREFIX_LENGTH 177 | else: 178 | # Regular network 179 | min_prefix_len = network.prefixlen + 1 180 | max_prefix_len = IPV6_MAX_PREFIX_LENGTH - 1 181 | prefix_len = random.randint(min_prefix_len, max_prefix_len) 182 | num_subnet_bits = prefix_len - network.prefixlen 183 | subnet_int = random.randint(0, (2 ** num_subnet_bits) - 1) 184 | 185 | ip_int = int(network.network_address) + ( 186 | subnet_int << (IPV6_MAX_PREFIX_LENGTH - prefix_len) 187 | ) 188 | return IPv6Network((ip_int, prefix_len)) 189 | --------------------------------------------------------------------------------