├── .coveragerc ├── .git_archival.json ├── .gitattributes ├── .github └── workflows │ ├── build_executable.yml │ ├── docker-image.yml │ ├── pre_release.yml │ ├── release.yml │ ├── ruff.yml │ ├── tests.yml │ └── tests_full.yml ├── .gitignore ├── .pre-commit-hooks.yaml ├── CODEOWNERS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENCE ├── README.md ├── cycode ├── __init__.py ├── __main__.py ├── cli │ ├── __init__.py │ ├── app.py │ ├── apps │ │ ├── __init__.py │ │ ├── ai_remediation │ │ │ ├── __init__.py │ │ │ ├── ai_remediation_command.py │ │ │ ├── apply_fix.py │ │ │ └── print_remediation.py │ │ ├── auth │ │ │ ├── __init__.py │ │ │ ├── auth_command.py │ │ │ ├── auth_common.py │ │ │ ├── auth_manager.py │ │ │ └── models.py │ │ ├── configure │ │ │ ├── __init__.py │ │ │ ├── configure_command.py │ │ │ ├── consts.py │ │ │ ├── messages.py │ │ │ └── prompts.py │ │ ├── ignore │ │ │ ├── __init__.py │ │ │ └── ignore_command.py │ │ ├── report │ │ │ ├── __init__.py │ │ │ ├── report_command.py │ │ │ └── sbom │ │ │ │ ├── __init__.py │ │ │ │ ├── common.py │ │ │ │ ├── path │ │ │ │ ├── __init__.py │ │ │ │ └── path_command.py │ │ │ │ ├── repository_url │ │ │ │ ├── __init__.py │ │ │ │ └── repository_url_command.py │ │ │ │ ├── sbom_command.py │ │ │ │ └── sbom_report_file.py │ │ ├── scan │ │ │ ├── __init__.py │ │ │ ├── code_scanner.py │ │ │ ├── commit_history │ │ │ │ ├── __init__.py │ │ │ │ └── commit_history_command.py │ │ │ ├── path │ │ │ │ ├── __init__.py │ │ │ │ └── path_command.py │ │ │ ├── pre_commit │ │ │ │ ├── __init__.py │ │ │ │ └── pre_commit_command.py │ │ │ ├── pre_receive │ │ │ │ ├── __init__.py │ │ │ │ └── pre_receive_command.py │ │ │ ├── repository │ │ │ │ ├── __init__.py │ │ │ │ └── repository_command.py │ │ │ ├── scan_ci │ │ │ │ ├── __init__.py │ │ │ │ ├── ci_integrations.py │ │ │ │ └── scan_ci_command.py │ │ │ └── scan_command.py │ │ └── status │ │ │ ├── __init__.py │ │ │ ├── get_cli_status.py │ │ │ ├── models.py │ │ │ ├── status_command.py │ │ │ └── version_command.py │ ├── cli_types.py │ ├── config.py │ ├── console.py │ ├── consts.py │ ├── exceptions │ │ ├── __init__.py │ │ ├── custom_exceptions.py │ │ ├── handle_ai_remediation_errors.py │ │ ├── handle_auth_errors.py │ │ ├── handle_errors.py │ │ ├── handle_report_sbom_errors.py │ │ └── handle_scan_errors.py │ ├── files_collector │ │ ├── __init__.py │ │ ├── excluder.py │ │ ├── iac │ │ │ ├── __init__.py │ │ │ └── tf_content_generator.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── in_memory_zip.py │ │ ├── path_documents.py │ │ ├── repository_documents.py │ │ ├── sca │ │ │ ├── __init__.py │ │ │ ├── base_restore_dependencies.py │ │ │ ├── go │ │ │ │ ├── __init__.py │ │ │ │ └── restore_go_dependencies.py │ │ │ ├── maven │ │ │ │ ├── __init__.py │ │ │ │ ├── restore_gradle_dependencies.py │ │ │ │ └── restore_maven_dependencies.py │ │ │ ├── npm │ │ │ │ ├── __init__.py │ │ │ │ └── restore_npm_dependencies.py │ │ │ ├── nuget │ │ │ │ ├── __init__.py │ │ │ │ └── restore_nuget_dependencies.py │ │ │ ├── ruby │ │ │ │ ├── __init__.py │ │ │ │ └── restore_ruby_dependencies.py │ │ │ ├── sbt │ │ │ │ ├── __init__.py │ │ │ │ └── restore_sbt_dependencies.py │ │ │ └── sca_code_scanner.py │ │ ├── walk_ignore.py │ │ └── zip_documents.py │ ├── logger.py │ ├── main.py │ ├── models.py │ ├── printers │ │ ├── __init__.py │ │ ├── console_printer.py │ │ ├── json_printer.py │ │ ├── printer_base.py │ │ ├── rich_printer.py │ │ ├── tables │ │ │ ├── __init__.py │ │ │ ├── sca_table_printer.py │ │ │ ├── table.py │ │ │ ├── table_models.py │ │ │ ├── table_printer.py │ │ │ └── table_printer_base.py │ │ ├── text_printer.py │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── code_snippet_syntax.py │ │ │ ├── detection_data.py │ │ │ ├── detection_ordering │ │ │ ├── __init__.py │ │ │ ├── common_ordering.py │ │ │ └── sca_ordering.py │ │ │ └── rich_helpers.py │ ├── user_settings │ │ ├── __init__.py │ │ ├── base_file_manager.py │ │ ├── config_file_manager.py │ │ ├── configuration_manager.py │ │ ├── credentials_manager.py │ │ └── jwt_creator.py │ └── utils │ │ ├── __init__.py │ │ ├── enum_utils.py │ │ ├── get_api_client.py │ │ ├── git_proxy.py │ │ ├── ignore_utils.py │ │ ├── jwt_utils.py │ │ ├── path_utils.py │ │ ├── progress_bar.py │ │ ├── scan_batch.py │ │ ├── scan_utils.py │ │ ├── sentry.py │ │ ├── shell_executor.py │ │ ├── string_utils.py │ │ ├── task_timer.py │ │ ├── version_checker.py │ │ └── yaml_utils.py ├── config.py ├── cyclient │ ├── __init__.py │ ├── auth_client.py │ ├── client_creator.py │ ├── config.py │ ├── config_dev.py │ ├── cycode_client.py │ ├── cycode_client_base.py │ ├── cycode_dev_based_client.py │ ├── cycode_token_based_client.py │ ├── headers.py │ ├── logger.py │ ├── models.py │ ├── report_client.py │ ├── scan_client.py │ └── scan_config_base.py └── logger.py ├── entitlements.plist ├── images ├── allow_cli.png ├── authorize_cli.png ├── cycode_login.png ├── image1.png ├── image2.png ├── image3.png ├── image4.png ├── new_settings_screenshot.png ├── on-demand-scans-main-page.png ├── sca_report_url.png ├── scan_details.png └── successfully_auth.png ├── poetry.lock ├── process_executable_file.py ├── pyinstaller.spec ├── pyproject.toml └── tests ├── __init__.py ├── cli ├── __init__.py ├── commands │ ├── __init__.py │ ├── configure │ │ ├── __init__.py │ │ └── test_configure_command.py │ ├── scan │ │ ├── __init__.py │ │ └── test_code_scanner.py │ ├── test_check_latest_version_on_close.py │ ├── test_main_command.py │ └── version │ │ ├── __init__.py │ │ └── test_version_checker.py ├── exceptions │ ├── __init__.py │ └── test_handle_scan_errors.py ├── files_collector │ ├── __init__.py │ ├── iac │ │ ├── __init__.py │ │ └── test_tf_content_generator.py │ └── test_walk_ignore.py └── models │ ├── __init__.py │ └── test_severity.py ├── conftest.py ├── cyclient ├── __init__.py ├── mocked_responses │ ├── __init__.py │ ├── data │ │ ├── detection_rules.json │ │ └── detections.json │ └── scan_client.py ├── scan_config │ ├── __init__.py │ ├── test_default_scan_config.py │ └── test_dev_scan_config.py ├── test_auth_client.py ├── test_client.py ├── test_client_base.py ├── test_config.py ├── test_dev_based_client.py ├── test_scan_client.py └── test_token_based_client.py ├── test_code_scanner.py ├── test_files ├── .test_env ├── hello.txt ├── hello │ └── random.txt ├── package.json ├── tf_content_generator_files │ ├── tfplan-create-example │ │ ├── tf_content.txt │ │ └── tfplan.json │ ├── tfplan-destroy-example │ │ ├── tf_content.txt │ │ └── tfplan.json │ ├── tfplan-false-var │ │ ├── tf_content.txt │ │ └── tfplan.json │ ├── tfplan-no-op-example │ │ ├── tf_content.txt │ │ └── tfplan.json │ ├── tfplan-null-example │ │ ├── tf_content.txt │ │ └── tfplan.json │ └── tfplan-update-example │ │ ├── tf_content.txt │ │ └── tfplan.json └── zip_content │ ├── __init__.py │ ├── sast.py │ └── secrets.py ├── test_models.py ├── test_performance_get_all_files.py ├── test_zip_file.py ├── user_settings ├── __init__.py └── test_configuration_manager.py └── utils ├── __init__.py ├── test_git_proxy.py ├── test_ignore_utils.py ├── test_path_utils.py └── test_string_utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # ignore all test cases in tests/ 4 | tests/* 5 | -------------------------------------------------------------------------------- /.git_archival.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash-full": "9a9084314532f5b9781e9ee30fa982153873e56a", 3 | "hash-short": "9a908431", 4 | "timestamp": "2025-05-28T09:48:37+02:00", 5 | "refs": "HEAD -> main", 6 | "describe": "v3.1.0-1-g9a908431" 7 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.json export-subst 2 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image. On tag creation push to Docker Hub. On dispatch event build the latest tag and push to Docker Hub 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | tags: [ 'v*.*.*' ] 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Get latest release tag 20 | id: latest_tag 21 | run: | 22 | LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) 23 | echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_OUTPUT 24 | 25 | - name: Check out latest release tag 26 | if: ${{ github.event_name == 'workflow_dispatch' }} 27 | run: | 28 | git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: '3.9' 34 | 35 | - name: Load cached Poetry setup 36 | id: cached_poetry 37 | uses: actions/cache@v4 38 | with: 39 | path: ~/.local 40 | key: poetry-ubuntu-0 # increment to reset cache 41 | 42 | - name: Setup Poetry 43 | if: steps.cached_poetry.outputs.cache-hit != 'true' 44 | uses: snok/install-poetry@v1 45 | with: 46 | version: 1.8.3 47 | 48 | - name: Add Poetry to PATH 49 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 50 | 51 | - name: Install Poetry Plugin 52 | run: poetry self add "poetry-dynamic-versioning[plugin]" 53 | 54 | - name: Get CLI Version 55 | id: cli_version 56 | run: | 57 | echo "::debug::Package version: $(poetry version --short)" 58 | echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT 59 | 60 | - name: Set up QEMU 61 | uses: docker/setup-qemu-action@v3 62 | 63 | - name: Set up Docker Buildx 64 | uses: docker/setup-buildx-action@v3 65 | 66 | - name: Login to Docker Hub 67 | uses: docker/login-action@v3 68 | with: 69 | username: ${{ secrets.DOCKERHUB_USER }} 70 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 71 | 72 | - name: Build and push 73 | id: docker_build 74 | if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} 75 | uses: docker/build-push-action@v6 76 | with: 77 | context: . 78 | platforms: linux/amd64,linux/arm64 79 | push: true 80 | tags: cycodehq/cycode_cli:${{ steps.latest_tag.outputs.LATEST_TAG }},cycodehq/cycode_cli:latest 81 | 82 | - name: Verify build 83 | id: docker_verify_build 84 | if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} 85 | uses: docker/build-push-action@v6 86 | with: 87 | context: . 88 | platforms: linux/amd64,linux/arm64 89 | push: false 90 | tags: cycodehq/cycode_cli:${{ steps.cli_version.outputs.CLI_VERSION }} 91 | -------------------------------------------------------------------------------- /.github/workflows/pre_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish pre-release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | pre_release: 10 | name: Pre-Release 11 | runs-on: ubuntu-latest 12 | permissions: 13 | actions: write 14 | id-token: write 15 | 16 | steps: 17 | - name: Run Cimon 18 | uses: cycodelabs/cimon-action@v0 19 | with: 20 | client-id: ${{ secrets.CIMON_CLIENT_ID }} 21 | secret: ${{ secrets.CIMON_SECRET }} 22 | prevent: true 23 | allowed-hosts: > 24 | files.pythonhosted.org 25 | install.python-poetry.org 26 | pypi.org 27 | upload.pypi.org 28 | *.sigstore.dev 29 | 30 | - name: Checkout repository 31 | uses: actions/checkout@v3 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Set up Python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: '3.9' 39 | 40 | - name: Load cached Poetry setup 41 | id: cached-poetry 42 | uses: actions/cache@v3 43 | with: 44 | path: ~/.local 45 | key: poetry-ubuntu-0 # increment to reset cache 46 | 47 | - name: Setup Poetry 48 | if: steps.cached-poetry.outputs.cache-hit != 'true' 49 | uses: snok/install-poetry@v1 50 | with: 51 | version: 1.8.3 52 | 53 | - name: Add Poetry to PATH 54 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 55 | 56 | - name: Install Poetry Plugin 57 | run: poetry self add "poetry-dynamic-versioning[plugin]" 58 | 59 | - name: Check Pre-Release Version 60 | id: check-version 61 | run: | 62 | echo "::debug::Package version: $(poetry version --short)" 63 | [[ "$(poetry version --short)" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || echo prerelease=true >> $GITHUB_OUTPUT 64 | 65 | - name: Exit if not Pre-Release Version 66 | if: steps.check-version.outputs.prerelease != 'true' 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | run: | 70 | gh run cancel ${{ github.run_id }} 71 | gh run watch ${{ github.run_id }} 72 | 73 | - name: Build package 74 | run: poetry build 75 | 76 | - name: Publish a Python distribution to PyPI 77 | uses: pypa/gh-action-pypi-publish@release/v1 78 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish release 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | permissions: 12 | actions: write 13 | id-token: write 14 | 15 | steps: 16 | - name: Run Cimon 17 | uses: cycodelabs/cimon-action@v0 18 | with: 19 | client-id: ${{ secrets.CIMON_CLIENT_ID }} 20 | secret: ${{ secrets.CIMON_SECRET }} 21 | prevent: true 22 | allowed-hosts: > 23 | files.pythonhosted.org 24 | install.python-poetry.org 25 | pypi.org 26 | upload.pypi.org 27 | *.sigstore.dev 28 | 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | with: 32 | fetch-depth: 0 33 | 34 | - name: Set up Python 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: '3.9' 38 | 39 | - name: Load cached Poetry setup 40 | id: cached-poetry 41 | uses: actions/cache@v3 42 | with: 43 | path: ~/.local 44 | key: poetry-ubuntu-0 # increment to reset cache 45 | 46 | - name: Setup Poetry 47 | if: steps.cached-poetry.outputs.cache-hit != 'true' 48 | uses: snok/install-poetry@v1 49 | with: 50 | version: 1.8.3 51 | 52 | - name: Add Poetry to PATH 53 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 54 | 55 | - name: Install Poetry Plugin 56 | run: poetry self add "poetry-dynamic-versioning[plugin]" 57 | 58 | - name: Check Pre-Release Version 59 | id: check-version 60 | run: | 61 | echo "::debug::Package version: $(poetry version --short)" 62 | [[ "$(poetry version --short)" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || echo prerelease=true >> $GITHUB_OUTPUT 63 | 64 | - name: Exit if Pre-Release Version 65 | if: steps.check-version.outputs.prerelease == 'true' 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | run: | 69 | gh run cancel ${{ github.run_id }} 70 | gh run watch ${{ github.run_id }} 71 | 72 | - name: Build package 73 | run: poetry build 74 | 75 | - name: Publish a Python distribution to PyPI 76 | uses: pypa/gh-action-pypi-publish@release/v1 77 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff (linter and code formatter) 2 | 3 | on: [ pull_request, push ] 4 | 5 | jobs: 6 | ruff: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Run Cimon 10 | uses: cycodelabs/cimon-action@v0 11 | with: 12 | client-id: ${{ secrets.CIMON_CLIENT_ID }} 13 | secret: ${{ secrets.CIMON_SECRET }} 14 | prevent: true 15 | allowed-hosts: > 16 | files.pythonhosted.org 17 | install.python-poetry.org 18 | pypi.org 19 | 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: 3.9 27 | 28 | - name: Load cached Poetry setup 29 | id: cached-poetry 30 | uses: actions/cache@v3 31 | with: 32 | path: ~/.local 33 | key: poetry-ubuntu-0 # increment to reset cache 34 | 35 | - name: Setup Poetry 36 | if: steps.cached-poetry.outputs.cache-hit != 'true' 37 | uses: snok/install-poetry@v1 38 | with: 39 | version: 1.8.3 40 | 41 | - name: Add Poetry to PATH 42 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 43 | 44 | - name: Install dependencies 45 | run: poetry install 46 | 47 | - name: Run linter check 48 | run: poetry run ruff check --output-format=github . 49 | 50 | - name: Run code style check 51 | run: poetry run ruff format --check . 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests on the lower supported Python version 2 | 3 | on: [ push ] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | unit_tests: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Run Cimon 14 | uses: cycodelabs/cimon-action@v0 15 | with: 16 | client-id: ${{ secrets.CIMON_CLIENT_ID }} 17 | secret: ${{ secrets.CIMON_SECRET }} 18 | prevent: true 19 | allowed-hosts: > 20 | files.pythonhosted.org 21 | install.python-poetry.org 22 | pypi.org 23 | *.ingest.us.sentry.io 24 | 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: '3.9' 32 | 33 | - name: Load cached Poetry setup 34 | id: cached-poetry 35 | uses: actions/cache@v3 36 | with: 37 | path: ~/.local 38 | key: poetry-ubuntu-0 # increment to reset cache 39 | 40 | - name: Setup Poetry 41 | if: steps.cached-poetry.outputs.cache-hit != 'true' 42 | uses: snok/install-poetry@v1 43 | with: 44 | version: 1.8.3 45 | 46 | - name: Add Poetry to PATH 47 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 48 | 49 | - name: Install dependencies 50 | run: poetry install 51 | 52 | - name: Run Tests 53 | run: poetry run python -m pytest 54 | -------------------------------------------------------------------------------- /.github/workflows/tests_full.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests on all supported Python versions and OS 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | unit_tests: 13 | strategy: 14 | matrix: 15 | os: [ macos-latest, ubuntu-latest, windows-latest ] 16 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] 17 | 18 | runs-on: ${{matrix.os}} 19 | 20 | defaults: 21 | run: 22 | shell: bash 23 | 24 | steps: 25 | - name: Run Cimon 26 | if: matrix.os == 'ubuntu-latest' 27 | uses: cycodelabs/cimon-action@v0 28 | with: 29 | client-id: ${{ secrets.CIMON_CLIENT_ID }} 30 | secret: ${{ secrets.CIMON_SECRET }} 31 | prevent: true 32 | allowed-hosts: > 33 | files.pythonhosted.org 34 | install.python-poetry.org 35 | pypi.org 36 | *.ingest.us.sentry.io 37 | 38 | - name: Checkout repository 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | 43 | - name: Set up Python 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | 48 | - name: Load cached Poetry setup 49 | id: cached-poetry 50 | uses: actions/cache@v3 51 | with: 52 | path: ~/.local 53 | key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-2 # increment to reset cache 54 | 55 | - name: Setup Poetry 56 | if: steps.cached-poetry.outputs.cache-hit != 'true' 57 | uses: snok/install-poetry@v1 58 | with: 59 | version: 1.8.3 60 | 61 | - name: Add Poetry to PATH 62 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 63 | 64 | - name: Install dependencies 65 | run: poetry install 66 | 67 | - name: Run executable test 68 | # we care about the one Python version that will be used to build the executable 69 | # TODO(MarshalX): upgrade to Python 3.13 70 | if: matrix.python-version == '3.12' 71 | run: | 72 | poetry run pyinstaller pyinstaller.spec 73 | ./dist/cycode-cli version 74 | 75 | - name: Run pytest 76 | run: poetry run python -m pytest 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.iml 4 | .env 5 | .ruff_cache/ 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # Unit test / coverage reports 33 | htmlcov/ 34 | .tox/ 35 | .nox/ 36 | .coverage 37 | .coverage.* 38 | .cache 39 | nosetests.xml 40 | coverage.xml 41 | *.cover 42 | .hypothesis/ 43 | .pytest_cache/ 44 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: cycode 2 | name: Cycode Secrets pre-commit defender 3 | language: python 4 | language_version: python3 5 | entry: cycode 6 | args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'secret', 'pre-commit' ] 7 | - id: cycode-sca 8 | name: Cycode SCA pre-commit defender 9 | language: python 10 | language_version: python3 11 | entry: cycode 12 | args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sca', 'pre-commit' ] 13 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @MarshalX @elsapet @gotbadger @cfabianski 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.9-alpine3.21 AS base 2 | WORKDIR /usr/cycode/app 3 | RUN apk add git=2.47.2-r0 4 | 5 | FROM base AS builder 6 | ENV POETRY_VERSION=1.8.3 7 | 8 | # deps are required to build cffi 9 | RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r9 && \ 10 | pip install --no-cache-dir "poetry==$POETRY_VERSION" "poetry-dynamic-versioning[plugin]" && \ 11 | apk del .build-deps gcc libffi-dev musl-dev 12 | 13 | COPY pyproject.toml poetry.lock README.md ./ 14 | # to be able to automatically detect version from Git Tag 15 | COPY .git ./.git 16 | # src 17 | COPY cycode ./cycode 18 | RUN poetry config virtualenvs.in-project true && \ 19 | poetry --no-cache install --only=main --no-root && \ 20 | poetry build 21 | 22 | FROM base AS final 23 | COPY --from=builder /usr/cycode/app/dist ./ 24 | RUN pip install --no-cache-dir cycode*.whl 25 | 26 | # Add cycode group and user, alpine way 27 | # https://wiki.alpinelinux.org/wiki/Setting_up_a_new_user 28 | RUN addgroup -g 5000 cycode-group 29 | RUN adduser --home /home/cycode --uid 5001 -G cycode-group --shell /bin/sh --disabled-password cycode 30 | 31 | USER cycode 32 | 33 | CMD ["cycode"] 34 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Cycode Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cycode/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag 2 | -------------------------------------------------------------------------------- /cycode/__main__.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.consts import PROGRAM_NAME 2 | from cycode.cli.main import app 3 | 4 | app(prog_name=PROGRAM_NAME) 5 | -------------------------------------------------------------------------------- /cycode/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/ai_remediation/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.ai_remediation.ai_remediation_command import ai_remediation_command 4 | 5 | app = typer.Typer() 6 | 7 | _ai_remediation_epilog = ( 8 | 'Note: AI remediation suggestions are generated automatically and should be reviewed before applying.' 9 | ) 10 | 11 | app.command( 12 | name='ai-remediation', 13 | short_help='Get AI remediation (INTERNAL).', 14 | epilog=_ai_remediation_epilog, 15 | hidden=True, 16 | no_args_is_help=True, 17 | )(ai_remediation_command) 18 | 19 | # backward compatibility 20 | app.command(hidden=True, name='ai_remediation')(ai_remediation_command) 21 | -------------------------------------------------------------------------------- /cycode/cli/apps/ai_remediation/ai_remediation_command.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from uuid import UUID 3 | 4 | import typer 5 | 6 | from cycode.cli.apps.ai_remediation.apply_fix import apply_fix 7 | from cycode.cli.apps.ai_remediation.print_remediation import print_remediation 8 | from cycode.cli.exceptions.handle_ai_remediation_errors import handle_ai_remediation_exception 9 | from cycode.cli.utils.get_api_client import get_scan_cycode_client 10 | 11 | 12 | def ai_remediation_command( 13 | ctx: typer.Context, 14 | detection_id: Annotated[UUID, typer.Argument(help='Detection ID to get remediation for', show_default=False)], 15 | fix: Annotated[ 16 | bool, typer.Option('--fix', help='Apply fixes to resolve violations. Note: fix could be not available.') 17 | ] = False, 18 | ) -> None: 19 | """:robot: [bold cyan]Get AI-powered remediation for security issues.[/] 20 | 21 | This command provides AI-generated remediation guidance for detected security issues. 22 | 23 | Example usage: 24 | * `cycode ai-remediation `: View remediation guidance 25 | * `cycode ai-remediation --fix`: Apply suggested fixes 26 | """ 27 | client = get_scan_cycode_client(ctx) 28 | 29 | try: 30 | remediation_markdown = client.get_ai_remediation(detection_id) 31 | fix_diff = client.get_ai_remediation(detection_id, fix=True) 32 | is_fix_available = bool(fix_diff) # exclude empty string, None, etc. 33 | 34 | if fix: 35 | apply_fix(ctx, fix_diff, is_fix_available) 36 | else: 37 | print_remediation(ctx, remediation_markdown, is_fix_available) 38 | except Exception as err: 39 | handle_ai_remediation_exception(ctx, err) 40 | -------------------------------------------------------------------------------- /cycode/cli/apps/ai_remediation/apply_fix.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import typer 4 | from patch_ng import fromstring 5 | 6 | from cycode.cli.models import CliResult 7 | 8 | 9 | def apply_fix(ctx: typer.Context, diff: str, is_fix_available: bool) -> None: 10 | printer = ctx.obj.get('console_printer') 11 | if not is_fix_available: 12 | printer.print_result(CliResult(success=False, message='Fix is not available for this violation')) 13 | return 14 | 15 | patch = fromstring(diff.encode('UTF-8')) 16 | if patch is False: 17 | printer.print_result(CliResult(success=False, message='Failed to parse fix diff')) 18 | return 19 | 20 | is_fix_applied = patch.apply(root=os.getcwd(), strip=0) 21 | if is_fix_applied: 22 | printer.print_result(CliResult(success=True, message='Fix applied successfully')) 23 | else: 24 | printer.print_result(CliResult(success=False, message='Failed to apply fix')) 25 | -------------------------------------------------------------------------------- /cycode/cli/apps/ai_remediation/print_remediation.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich.markdown import Markdown 3 | 4 | from cycode.cli.console import console 5 | from cycode.cli.models import CliResult 6 | 7 | 8 | def print_remediation(ctx: typer.Context, remediation_markdown: str, is_fix_available: bool) -> None: 9 | printer = ctx.obj.get('console_printer') 10 | if printer.is_json_printer: 11 | data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available} 12 | printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data)) 13 | else: # text or table 14 | console.print(Markdown(remediation_markdown)) 15 | -------------------------------------------------------------------------------- /cycode/cli/apps/auth/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.auth.auth_command import auth_command 4 | 5 | _auth_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-auth-command' 6 | _auth_command_epilog = f'[bold]Documentation:[/] [link={_auth_command_docs}]{_auth_command_docs}[/link]' 7 | 8 | app = typer.Typer(no_args_is_help=False) 9 | app.command(name='auth', epilog=_auth_command_epilog, short_help='Authenticate your machine with Cycode.')(auth_command) 10 | -------------------------------------------------------------------------------- /cycode/cli/apps/auth/auth_command.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.auth.auth_manager import AuthManager 4 | from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception 5 | from cycode.cli.logger import logger 6 | from cycode.cli.models import CliResult 7 | from cycode.cli.utils.sentry import add_breadcrumb 8 | 9 | 10 | def auth_command(ctx: typer.Context) -> None: 11 | """:key: [bold cyan]Authenticate your machine with Cycode.[/] 12 | 13 | This command handles authentication with Cycode's security platform. 14 | 15 | Example usage: 16 | * `cycode auth`: Start interactive authentication 17 | * `cycode auth --help`: View authentication options 18 | """ 19 | add_breadcrumb('auth') 20 | printer = ctx.obj.get('console_printer') 21 | 22 | try: 23 | logger.debug('Starting authentication process') 24 | 25 | auth_manager = AuthManager() 26 | auth_manager.authenticate() 27 | 28 | result = CliResult(success=True, message='Successfully logged into cycode') 29 | printer.print_result(result) 30 | except Exception as err: 31 | handle_auth_exception(ctx, err) 32 | -------------------------------------------------------------------------------- /cycode/cli/apps/auth/auth_common.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | 3 | from cycode.cli.apps.auth.models import AuthInfo 4 | from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError 5 | from cycode.cli.user_settings.credentials_manager import CredentialsManager 6 | from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token 7 | from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient 8 | 9 | if TYPE_CHECKING: 10 | from typer import Context 11 | 12 | 13 | def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: 14 | printer = ctx.obj.get('console_printer') 15 | 16 | client_id, client_secret = ctx.obj.get('client_id'), ctx.obj.get('client_secret') 17 | if not client_id or not client_secret: 18 | client_id, client_secret = CredentialsManager().get_credentials() 19 | 20 | if not client_id or not client_secret: 21 | return None 22 | 23 | try: 24 | access_token = CycodeTokenBasedClient(client_id, client_secret).get_access_token() 25 | if not access_token: 26 | return None 27 | 28 | user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) 29 | return AuthInfo(user_id=user_id, tenant_id=tenant_id) 30 | except (RequestHttpError, HttpUnauthorizedError): 31 | if ctx: 32 | printer.print_exception() 33 | 34 | return None 35 | -------------------------------------------------------------------------------- /cycode/cli/apps/auth/models.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | class AuthInfo(NamedTuple): 5 | user_id: str 6 | tenant_id: str 7 | -------------------------------------------------------------------------------- /cycode/cli/apps/configure/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.configure.configure_command import configure_command 4 | 5 | _configure_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-configure-command' 6 | _configure_command_epilog = f'[bold]Documentation:[/] [link={_configure_command_docs}]{_configure_command_docs}[/link]' 7 | 8 | 9 | app = typer.Typer(no_args_is_help=True) 10 | app.command( 11 | name='configure', 12 | epilog=_configure_command_epilog, 13 | short_help='Initial command to configure your CLI client authentication.', 14 | )(configure_command) 15 | -------------------------------------------------------------------------------- /cycode/cli/apps/configure/configure_command.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER 4 | from cycode.cli.apps.configure.messages import get_credentials_update_result_message, get_urls_update_result_message 5 | from cycode.cli.apps.configure.prompts import ( 6 | get_api_url_input, 7 | get_app_url_input, 8 | get_client_id_input, 9 | get_client_secret_input, 10 | ) 11 | from cycode.cli.console import console 12 | from cycode.cli.utils.sentry import add_breadcrumb 13 | 14 | 15 | def _should_update_value( 16 | old_value: Optional[str], 17 | new_value: Optional[str], 18 | ) -> bool: 19 | if not new_value: 20 | return False 21 | 22 | return old_value != new_value 23 | 24 | 25 | def configure_command() -> None: 26 | """:gear: [bold cyan]Configure Cycode CLI settings.[/] 27 | 28 | This command allows you to configure various aspects of the Cycode CLI. 29 | 30 | Configuration options: 31 | * API URL: The base URL for Cycode's API (for on-premise or EU installations) 32 | * APP URL: The base URL for Cycode's web application (for on-premise or EU installations) 33 | * Client ID: Your Cycode client ID for authentication 34 | * Client Secret: Your Cycode client secret for authentication 35 | 36 | Example usage: 37 | * `cycode configure`: Start interactive configuration 38 | * `cycode configure --help`: View configuration options 39 | """ 40 | add_breadcrumb('configure') 41 | 42 | global_config_manager = CONFIGURATION_MANAGER.global_config_file_manager 43 | 44 | current_api_url = global_config_manager.get_api_url() 45 | current_app_url = global_config_manager.get_app_url() 46 | api_url = get_api_url_input(current_api_url) 47 | app_url = get_app_url_input(current_app_url) 48 | 49 | config_updated = False 50 | if _should_update_value(current_api_url, api_url): 51 | global_config_manager.update_api_base_url(api_url) 52 | config_updated = True 53 | if _should_update_value(current_app_url, app_url): 54 | global_config_manager.update_app_base_url(app_url) 55 | config_updated = True 56 | 57 | current_client_id, current_client_secret = CREDENTIALS_MANAGER.get_credentials_from_file() 58 | client_id = get_client_id_input(current_client_id) 59 | client_secret = get_client_secret_input(current_client_secret) 60 | 61 | credentials_updated = False 62 | if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret): 63 | credentials_updated = True 64 | CREDENTIALS_MANAGER.update_credentials(client_id, client_secret) 65 | 66 | if config_updated: 67 | console.print(get_urls_update_result_message()) 68 | if credentials_updated: 69 | console.print(get_credentials_update_result_message()) 70 | -------------------------------------------------------------------------------- /cycode/cli/apps/configure/consts.py: -------------------------------------------------------------------------------- 1 | from cycode.cli import config, consts 2 | from cycode.cli.user_settings.configuration_manager import ConfigurationManager 3 | from cycode.cli.user_settings.credentials_manager import CredentialsManager 4 | 5 | URLS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured Cycode URLs! Saved to: {filename}' 6 | URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( 7 | 'Note that the URLs (APP and API) that already exist in environment variables ' 8 | f'({consts.CYCODE_API_URL_ENV_VAR_NAME} and {consts.CYCODE_APP_URL_ENV_VAR_NAME}) ' 9 | 'take precedent over these URLs; either update or remove the environment variables.' 10 | ) 11 | CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured CLI credentials! Saved to: {filename}' 12 | CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( 13 | 'Note that the credentials that already exist in environment variables ' 14 | f'({config.CYCODE_CLIENT_ID_ENV_VAR_NAME} and {config.CYCODE_CLIENT_SECRET_ENV_VAR_NAME}) ' 15 | 'take precedent over these credentials; either update or remove the environment variables.' 16 | ) 17 | 18 | CREDENTIALS_MANAGER = CredentialsManager() 19 | CONFIGURATION_MANAGER = ConfigurationManager() 20 | -------------------------------------------------------------------------------- /cycode/cli/apps/configure/messages.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.apps.configure.consts import ( 2 | CONFIGURATION_MANAGER, 3 | CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE, 4 | CREDENTIALS_MANAGER, 5 | CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE, 6 | URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE, 7 | URLS_UPDATED_SUCCESSFULLY_MESSAGE, 8 | ) 9 | 10 | 11 | def _are_credentials_exist_in_environment_variables() -> bool: 12 | client_id, client_secret = CREDENTIALS_MANAGER.get_credentials_from_environment_variables() 13 | return any([client_id, client_secret]) 14 | 15 | 16 | def get_credentials_update_result_message() -> str: 17 | success_message = CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) 18 | if _are_credentials_exist_in_environment_variables(): 19 | return f'{success_message}. {CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' 20 | 21 | return success_message 22 | 23 | 24 | def _are_urls_exist_in_environment_variables() -> bool: 25 | api_url = CONFIGURATION_MANAGER.get_api_url_from_environment_variables() 26 | app_url = CONFIGURATION_MANAGER.get_app_url_from_environment_variables() 27 | return any([api_url, app_url]) 28 | 29 | 30 | def get_urls_update_result_message() -> str: 31 | success_message = URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( 32 | filename=CONFIGURATION_MANAGER.global_config_file_manager.get_filename() 33 | ) 34 | if _are_urls_exist_in_environment_variables(): 35 | return f'{success_message}. {URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' 36 | 37 | return success_message 38 | -------------------------------------------------------------------------------- /cycode/cli/apps/configure/prompts.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import typer 4 | 5 | from cycode.cli import consts 6 | from cycode.cli.utils.string_utils import obfuscate_text 7 | 8 | 9 | def get_client_id_input(current_client_id: Optional[str]) -> Optional[str]: 10 | prompt_text = 'Cycode Client ID' 11 | 12 | prompt_suffix = ' []: ' 13 | if current_client_id: 14 | prompt_suffix = f' [{obfuscate_text(current_client_id)}]: ' 15 | 16 | new_client_id = typer.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) 17 | return new_client_id or current_client_id 18 | 19 | 20 | def get_client_secret_input(current_client_secret: Optional[str]) -> Optional[str]: 21 | prompt_text = 'Cycode Client Secret' 22 | 23 | prompt_suffix = ' []: ' 24 | if current_client_secret: 25 | prompt_suffix = f' [{obfuscate_text(current_client_secret)}]: ' 26 | 27 | new_client_secret = typer.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) 28 | return new_client_secret or current_client_secret 29 | 30 | 31 | def get_app_url_input(current_app_url: Optional[str]) -> str: 32 | prompt_text = 'Cycode APP URL' 33 | 34 | default = consts.DEFAULT_CYCODE_APP_URL 35 | if current_app_url: 36 | default = current_app_url 37 | 38 | return typer.prompt(text=prompt_text, default=default, type=str) 39 | 40 | 41 | def get_api_url_input(current_api_url: Optional[str]) -> str: 42 | prompt_text = 'Cycode API URL' 43 | 44 | default = consts.DEFAULT_CYCODE_API_URL 45 | if current_api_url: 46 | default = current_api_url 47 | 48 | return typer.prompt(text=prompt_text, default=default, type=str) 49 | -------------------------------------------------------------------------------- /cycode/cli/apps/ignore/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.ignore.ignore_command import ignore_command 4 | 5 | app = typer.Typer(no_args_is_help=True) 6 | app.command(name='ignore', short_help='Ignores a specific value, path or rule ID.')(ignore_command) 7 | -------------------------------------------------------------------------------- /cycode/cli/apps/report/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.report import sbom 4 | from cycode.cli.apps.report.report_command import report_command 5 | 6 | app = typer.Typer(name='report', no_args_is_help=True) 7 | app.callback(short_help='Generate report. You`ll need to specify which report type to perform as SBOM.')(report_command) 8 | app.add_typer(sbom.app) 9 | -------------------------------------------------------------------------------- /cycode/cli/apps/report/report_command.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar 4 | from cycode.cli.utils.sentry import add_breadcrumb 5 | 6 | 7 | def report_command(ctx: typer.Context) -> int: 8 | """:bar_chart: [bold cyan]Generate security reports.[/] 9 | 10 | Example usage: 11 | * `cycode report sbom`: Generate SBOM report 12 | """ 13 | add_breadcrumb('report') 14 | ctx.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) 15 | return 1 16 | -------------------------------------------------------------------------------- /cycode/cli/apps/report/sbom/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.report.sbom.path.path_command import path_command 4 | from cycode.cli.apps.report.sbom.repository_url.repository_url_command import repository_url_command 5 | from cycode.cli.apps.report.sbom.sbom_command import sbom_command 6 | 7 | app = typer.Typer(name='sbom') 8 | app.callback(short_help='Generate SBOM report for remote repository by url or local directory by path.')(sbom_command) 9 | app.command(name='path', short_help='Generate SBOM report for provided path in the command.')(path_command) 10 | app.command(name='repository-url', short_help='Generate SBOM report for provided repository URI in the command.')( 11 | repository_url_command 12 | ) 13 | 14 | # backward compatibility 15 | app.command(hidden=True, name='repository_url')(repository_url_command) 16 | -------------------------------------------------------------------------------- /cycode/cli/apps/report/sbom/path/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/report/sbom/path/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/report/sbom/path/path_command.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | from typing import Annotated 4 | 5 | import typer 6 | 7 | from cycode.cli import consts 8 | from cycode.cli.apps.report.sbom.common import create_sbom_report, send_report_feedback 9 | from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception 10 | from cycode.cli.files_collector.path_documents import get_relevant_documents 11 | from cycode.cli.files_collector.sca.sca_code_scanner import perform_pre_scan_documents_actions 12 | from cycode.cli.files_collector.zip_documents import zip_documents 13 | from cycode.cli.utils.get_api_client import get_report_cycode_client 14 | from cycode.cli.utils.progress_bar import SbomReportProgressBarSection 15 | from cycode.cli.utils.sentry import add_breadcrumb 16 | 17 | 18 | def path_command( 19 | ctx: typer.Context, 20 | path: Annotated[ 21 | Path, 22 | typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), 23 | ], 24 | ) -> None: 25 | add_breadcrumb('path') 26 | 27 | client = get_report_cycode_client(ctx) 28 | report_parameters = ctx.obj['report_parameters'] 29 | output_format = report_parameters.output_format 30 | output_file = ctx.obj['output_file'] 31 | 32 | progress_bar = ctx.obj['progress_bar'] 33 | progress_bar.start() 34 | 35 | start_scan_time = time.time() 36 | report_execution_id = -1 37 | 38 | try: 39 | documents = get_relevant_documents( 40 | progress_bar, SbomReportProgressBarSection.PREPARE_LOCAL_FILES, consts.SCA_SCAN_TYPE, (str(path),) 41 | ) 42 | # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. 43 | # unhardcode usage of context in perform_pre_scan_documents_actions 44 | perform_pre_scan_documents_actions(ctx, consts.SCA_SCAN_TYPE, documents) 45 | 46 | zipped_documents = zip_documents(consts.SCA_SCAN_TYPE, documents) 47 | report_execution = client.request_sbom_report_execution(report_parameters, zip_file=zipped_documents) 48 | report_execution_id = report_execution.id 49 | 50 | create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format) 51 | 52 | send_report_feedback( 53 | client=client, 54 | start_scan_time=start_scan_time, 55 | report_type='SBOM', 56 | report_command_type='path', 57 | request_report_parameters=report_parameters.to_dict(without_entity_type=False), 58 | report_execution_id=report_execution_id, 59 | request_zip_file_size=zipped_documents.size, 60 | ) 61 | except Exception as e: 62 | progress_bar.stop() 63 | 64 | send_report_feedback( 65 | client=client, 66 | start_scan_time=start_scan_time, 67 | report_type='SBOM', 68 | report_command_type='path', 69 | request_report_parameters=report_parameters.to_dict(without_entity_type=False), 70 | report_execution_id=report_execution_id, 71 | error_message=str(e), 72 | ) 73 | 74 | handle_report_exception(ctx, e) 75 | -------------------------------------------------------------------------------- /cycode/cli/apps/report/sbom/repository_url/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/report/sbom/repository_url/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/report/sbom/repository_url/repository_url_command.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Annotated 3 | 4 | import typer 5 | 6 | from cycode.cli.apps.report.sbom.common import create_sbom_report, send_report_feedback 7 | from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception 8 | from cycode.cli.utils.get_api_client import get_report_cycode_client 9 | from cycode.cli.utils.progress_bar import SbomReportProgressBarSection 10 | from cycode.cli.utils.sentry import add_breadcrumb 11 | 12 | 13 | def repository_url_command( 14 | ctx: typer.Context, 15 | uri: Annotated[str, typer.Argument(help='Repository URL to generate SBOM report for.', show_default=False)], 16 | ) -> None: 17 | add_breadcrumb('repository_url') 18 | 19 | progress_bar = ctx.obj['progress_bar'] 20 | progress_bar.start() 21 | progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) 22 | 23 | client = get_report_cycode_client(ctx) 24 | report_parameters = ctx.obj['report_parameters'] 25 | output_file = ctx.obj['output_file'] 26 | output_format = report_parameters.output_format 27 | 28 | start_scan_time = time.time() 29 | report_execution_id = -1 30 | 31 | try: 32 | report_execution = client.request_sbom_report_execution(report_parameters, repository_url=uri) 33 | report_execution_id = report_execution.id 34 | 35 | create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format) 36 | 37 | send_report_feedback( 38 | client=client, 39 | start_scan_time=start_scan_time, 40 | report_type='SBOM', 41 | report_command_type='repository_url', 42 | request_report_parameters=report_parameters.to_dict(without_entity_type=False), 43 | report_execution_id=report_execution_id, 44 | repository_uri=uri, 45 | ) 46 | except Exception as e: 47 | progress_bar.stop() 48 | 49 | send_report_feedback( 50 | client=client, 51 | start_scan_time=start_scan_time, 52 | report_type='SBOM', 53 | report_command_type='repository_url', 54 | request_report_parameters=report_parameters.to_dict(without_entity_type=False), 55 | report_execution_id=report_execution_id, 56 | error_message=str(e), 57 | repository_uri=uri, 58 | ) 59 | 60 | handle_report_exception(ctx, e) 61 | -------------------------------------------------------------------------------- /cycode/cli/apps/report/sbom/sbom_command.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Annotated, Optional 3 | 4 | import click 5 | import typer 6 | 7 | from cycode.cli.cli_types import SbomFormatOption, SbomOutputFormatOption 8 | from cycode.cli.utils.sentry import add_breadcrumb 9 | from cycode.cyclient.report_client import ReportParameters 10 | 11 | _OUTPUT_RICH_HELP_PANEL = 'Output options' 12 | 13 | 14 | def sbom_command( 15 | ctx: typer.Context, 16 | sbom_format: Annotated[ 17 | SbomFormatOption, 18 | typer.Option( 19 | '--format', 20 | '-f', 21 | help='SBOM format.', 22 | case_sensitive=False, 23 | show_default=False, 24 | ), 25 | ], 26 | output_format: Annotated[ 27 | SbomOutputFormatOption, 28 | typer.Option( 29 | '--output-format', 30 | '-o', 31 | help='Specify the output file format.', 32 | rich_help_panel=_OUTPUT_RICH_HELP_PANEL, 33 | ), 34 | ] = SbomOutputFormatOption.JSON, 35 | output_file: Annotated[ 36 | Optional[Path], 37 | typer.Option( 38 | help='Output file.', 39 | show_default='Autogenerated filename saved to the current directory', 40 | dir_okay=False, 41 | writable=True, 42 | rich_help_panel=_OUTPUT_RICH_HELP_PANEL, 43 | ), 44 | ] = None, 45 | include_vulnerabilities: Annotated[ 46 | bool, typer.Option('--include-vulnerabilities', help='Include vulnerabilities.', show_default=False) 47 | ] = False, 48 | include_dev_dependencies: Annotated[ 49 | bool, typer.Option('--include-dev-dependencies', help='Include dev dependencies.', show_default=False) 50 | ] = False, 51 | ) -> int: 52 | """Generate SBOM report.""" 53 | add_breadcrumb('sbom') 54 | 55 | sbom_format_parts = sbom_format.split('-') 56 | if len(sbom_format_parts) != 2: 57 | raise click.ClickException('Invalid SBOM format.') 58 | 59 | sbom_format, sbom_format_version = sbom_format_parts 60 | 61 | report_parameters = ReportParameters( 62 | entity_type='SbomCli', 63 | sbom_report_type=sbom_format, 64 | sbom_version=sbom_format_version, 65 | output_format=output_format, 66 | include_vulnerabilities=include_vulnerabilities, 67 | include_dev_dependencies=include_dev_dependencies, 68 | ) 69 | ctx.obj['report_parameters'] = report_parameters 70 | ctx.obj['output_file'] = output_file 71 | 72 | return 1 73 | -------------------------------------------------------------------------------- /cycode/cli/apps/report/sbom/sbom_report_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import re 4 | from typing import Optional 5 | 6 | import typer 7 | 8 | from cycode.cli.console import console 9 | 10 | 11 | class SbomReportFile: 12 | def __init__(self, storage_path: str, output_format: str, output_file: Optional[pathlib.Path]) -> None: 13 | if output_file is None: 14 | output_file = pathlib.Path(storage_path) 15 | 16 | output_ext = f'.{output_format}' 17 | if output_file.suffix != output_ext: 18 | output_file = output_file.with_suffix(output_ext) 19 | 20 | self._file_path = output_file 21 | 22 | def is_exists(self) -> bool: 23 | return self._file_path.exists() 24 | 25 | def _prompt_overwrite(self) -> bool: 26 | return typer.confirm(f'File {self._file_path} already exists. Save with a different filename?', default=True) 27 | 28 | def _write(self, content: str) -> None: 29 | with open(self._file_path, 'w', encoding='UTF-8') as f: 30 | f.write(content) 31 | 32 | def _notify_about_saved_file(self) -> None: 33 | console.print(f'Report saved to {self._file_path}') 34 | 35 | def _find_and_set_unique_filename(self) -> None: 36 | attempt_no = 0 37 | while self.is_exists(): 38 | attempt_no += 1 39 | 40 | base, ext = os.path.splitext(self._file_path) 41 | # Remove previous suffix 42 | base = re.sub(r'-\d+$', '', base) 43 | 44 | self._file_path = pathlib.Path(f'{base}-{attempt_no}{ext}') 45 | 46 | def write(self, content: str) -> None: 47 | if self.is_exists() and self._prompt_overwrite(): 48 | self._find_and_set_unique_filename() 49 | 50 | self._write(content) 51 | self._notify_about_saved_file() 52 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.scan.commit_history.commit_history_command import commit_history_command 4 | from cycode.cli.apps.scan.path.path_command import path_command 5 | from cycode.cli.apps.scan.pre_commit.pre_commit_command import pre_commit_command 6 | from cycode.cli.apps.scan.pre_receive.pre_receive_command import pre_receive_command 7 | from cycode.cli.apps.scan.repository.repository_command import repository_command 8 | from cycode.cli.apps.scan.scan_command import scan_command, scan_command_result_callback 9 | 10 | app = typer.Typer(name='scan', no_args_is_help=True) 11 | 12 | _scan_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#scan-command' 13 | _scan_command_epilog = f'[bold]Documentation:[/] [link={_scan_command_docs}]{_scan_command_docs}[/link]' 14 | 15 | app.callback( 16 | short_help='Scan the content for Secrets, IaC, SCA, and SAST violations.', 17 | result_callback=scan_command_result_callback, 18 | epilog=_scan_command_epilog, 19 | )(scan_command) 20 | 21 | app.command(name='path', short_help='Scan the files in the paths provided in the command.')(path_command) 22 | app.command(name='repository', short_help='Scan the Git repository included files.')(repository_command) 23 | app.command(name='commit-history', short_help='Scan all the commits history in this Git repository.')( 24 | commit_history_command 25 | ) 26 | app.command( 27 | name='pre-commit', 28 | short_help='Use this command in pre-commit hook to scan any content that was not committed yet.', 29 | rich_help_panel='Automation commands', 30 | )(pre_commit_command) 31 | app.command( 32 | name='pre-receive', 33 | short_help='Use this command in pre-receive hook ' 34 | 'to scan commits on the server side before pushing them to the repository.', 35 | rich_help_panel='Automation commands', 36 | )(pre_receive_command) 37 | 38 | # backward compatibility 39 | app.command(hidden=True, name='commit_history')(commit_history_command) 40 | app.command(hidden=True, name='pre_commit')(pre_commit_command) 41 | app.command(hidden=True, name='pre_receive')(pre_receive_command) 42 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/commit_history/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/scan/commit_history/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/scan/commit_history/commit_history_command.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Annotated 3 | 4 | import typer 5 | 6 | from cycode.cli.apps.scan.code_scanner import scan_commit_range 7 | from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception 8 | from cycode.cli.logger import logger 9 | from cycode.cli.utils.sentry import add_breadcrumb 10 | 11 | 12 | def commit_history_command( 13 | ctx: typer.Context, 14 | path: Annotated[ 15 | Path, typer.Argument(exists=True, resolve_path=True, help='Path to Git repository to scan', show_default=False) 16 | ], 17 | commit_range: Annotated[ 18 | str, 19 | typer.Option( 20 | '--commit-range', 21 | '-r', 22 | help='Scan a commit range in this Git repository (example: HEAD~1)', 23 | show_default='cycode scans all commit history', 24 | ), 25 | ] = '--all', 26 | ) -> None: 27 | try: 28 | add_breadcrumb('commit_history') 29 | 30 | logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) 31 | scan_commit_range(ctx, path=str(path), commit_range=commit_range) 32 | except Exception as e: 33 | handle_scan_exception(ctx, e) 34 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/path/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/scan/path/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/scan/path/path_command.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Annotated 3 | 4 | import typer 5 | 6 | from cycode.cli.apps.scan.code_scanner import scan_disk_files 7 | from cycode.cli.logger import logger 8 | from cycode.cli.utils.sentry import add_breadcrumb 9 | 10 | 11 | def path_command( 12 | ctx: typer.Context, 13 | paths: Annotated[ 14 | list[Path], typer.Argument(exists=True, resolve_path=True, help='Paths to scan', show_default=False) 15 | ], 16 | ) -> None: 17 | add_breadcrumb('path') 18 | 19 | progress_bar = ctx.obj['progress_bar'] 20 | progress_bar.start() 21 | 22 | logger.debug('Starting path scan process, %s', {'paths': paths}) 23 | 24 | tuple_paths = tuple(str(path) for path in paths) 25 | scan_disk_files(ctx, tuple_paths) 26 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/pre_commit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/scan/pre_commit/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/scan/pre_commit/pre_commit_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Annotated, Optional 3 | 4 | import typer 5 | 6 | from cycode.cli import consts 7 | from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents, scan_sca_pre_commit 8 | from cycode.cli.files_collector.excluder import excluder 9 | from cycode.cli.files_collector.repository_documents import ( 10 | get_diff_file_content, 11 | get_diff_file_path, 12 | ) 13 | from cycode.cli.models import Document 14 | from cycode.cli.utils.git_proxy import git_proxy 15 | from cycode.cli.utils.path_utils import ( 16 | get_path_by_os, 17 | ) 18 | from cycode.cli.utils.progress_bar import ScanProgressBarSection 19 | from cycode.cli.utils.sentry import add_breadcrumb 20 | 21 | 22 | def pre_commit_command( 23 | ctx: typer.Context, 24 | _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, 25 | ) -> None: 26 | add_breadcrumb('pre_commit') 27 | 28 | scan_type = ctx.obj['scan_type'] 29 | 30 | repo_path = os.getcwd() # change locally for easy testing 31 | 32 | progress_bar = ctx.obj['progress_bar'] 33 | progress_bar.start() 34 | 35 | if scan_type == consts.SCA_SCAN_TYPE: 36 | scan_sca_pre_commit(ctx, repo_path) 37 | return 38 | 39 | diff_files = git_proxy.get_repo(repo_path).index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) 40 | 41 | progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) 42 | 43 | documents_to_scan = [] 44 | for file in diff_files: 45 | progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) 46 | documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) 47 | 48 | documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) 49 | scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True) 50 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/pre_receive/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/scan/pre_receive/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/scan/pre_receive/pre_receive_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Annotated, Optional 3 | 4 | import click 5 | import typer 6 | 7 | from cycode.cli import consts 8 | from cycode.cli.apps.scan.code_scanner import ( 9 | enable_verbose_mode, 10 | is_verbose_mode_requested_in_pre_receive_scan, 11 | parse_pre_receive_input, 12 | perform_post_pre_receive_scan_actions, 13 | scan_commit_range, 14 | should_skip_pre_receive_scan, 15 | ) 16 | from cycode.cli.config import configuration_manager 17 | from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception 18 | from cycode.cli.files_collector.repository_documents import ( 19 | calculate_pre_receive_commit_range, 20 | ) 21 | from cycode.cli.logger import logger 22 | from cycode.cli.utils.sentry import add_breadcrumb 23 | from cycode.cli.utils.task_timer import TimeoutAfter 24 | 25 | 26 | def pre_receive_command( 27 | ctx: typer.Context, 28 | _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, 29 | ) -> None: 30 | try: 31 | add_breadcrumb('pre_receive') 32 | 33 | scan_type = ctx.obj['scan_type'] 34 | if scan_type != consts.SECRET_SCAN_TYPE: 35 | raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') 36 | 37 | if should_skip_pre_receive_scan(): 38 | logger.info( 39 | 'A scan has been skipped as per your request. ' 40 | 'Please note that this may leave your system vulnerable to secrets that have not been detected.' 41 | ) 42 | return 43 | 44 | if is_verbose_mode_requested_in_pre_receive_scan(): 45 | enable_verbose_mode(ctx) 46 | logger.debug('Verbose mode enabled: all log levels will be displayed.') 47 | 48 | command_scan_type = ctx.info_name 49 | timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) 50 | with TimeoutAfter(timeout): 51 | if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: 52 | raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') 53 | 54 | branch_update_details = parse_pre_receive_input() 55 | commit_range = calculate_pre_receive_commit_range(branch_update_details) 56 | if not commit_range: 57 | logger.info( 58 | 'No new commits found for pushed branch, %s', 59 | {'branch_update_details': branch_update_details}, 60 | ) 61 | return 62 | 63 | max_commits_to_scan = configuration_manager.get_pre_receive_max_commits_to_scan_count(command_scan_type) 64 | scan_commit_range(ctx, os.getcwd(), commit_range, max_commits_count=max_commits_to_scan) 65 | perform_post_pre_receive_scan_actions(ctx) 66 | except Exception as e: 67 | handle_scan_exception(ctx, e) 68 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/scan/repository/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/scan/repository/repository_command.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Annotated, Optional 3 | 4 | import click 5 | import typer 6 | 7 | from cycode.cli import consts 8 | from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents 9 | from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception 10 | from cycode.cli.files_collector.excluder import excluder 11 | from cycode.cli.files_collector.repository_documents import get_git_repository_tree_file_entries 12 | from cycode.cli.files_collector.sca.sca_code_scanner import perform_pre_scan_documents_actions 13 | from cycode.cli.logger import logger 14 | from cycode.cli.models import Document 15 | from cycode.cli.utils.path_utils import get_path_by_os 16 | from cycode.cli.utils.progress_bar import ScanProgressBarSection 17 | from cycode.cli.utils.sentry import add_breadcrumb 18 | 19 | 20 | def repository_command( 21 | ctx: typer.Context, 22 | path: Annotated[ 23 | Path, typer.Argument(exists=True, resolve_path=True, help='Path to Git repository to scan.', show_default=False) 24 | ], 25 | branch: Annotated[ 26 | Optional[str], typer.Option('--branch', '-b', help='Branch to scan.', show_default='default branch') 27 | ] = None, 28 | ) -> None: 29 | try: 30 | add_breadcrumb('repository') 31 | 32 | logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) 33 | 34 | scan_type = ctx.obj['scan_type'] 35 | monitor = ctx.obj.get('monitor') 36 | if monitor and scan_type != consts.SCA_SCAN_TYPE: 37 | raise click.ClickException('Monitor flag is currently supported for SCA scan type only') 38 | 39 | progress_bar = ctx.obj['progress_bar'] 40 | progress_bar.start() 41 | 42 | file_entries = list(get_git_repository_tree_file_entries(str(path), branch)) 43 | progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) 44 | 45 | documents_to_scan = [] 46 | for blob in file_entries: 47 | # FIXME(MarshalX): probably file could be tree or submodule too. we expect blob only 48 | progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) 49 | 50 | absolute_path = get_path_by_os(blob.abspath) 51 | file_path = get_path_by_os(blob.path) if monitor else absolute_path 52 | documents_to_scan.append( 53 | Document( 54 | file_path, 55 | blob.data_stream.read().decode('UTF-8', errors='replace'), 56 | absolute_path=absolute_path, 57 | ) 58 | ) 59 | 60 | documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) 61 | 62 | perform_pre_scan_documents_actions(ctx, scan_type, documents_to_scan) 63 | 64 | logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) 65 | scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (str(path),))) 66 | except Exception as e: 67 | handle_scan_exception(ctx, e) 68 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/scan_ci/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/apps/scan/scan_ci/__init__.py -------------------------------------------------------------------------------- /cycode/cli/apps/scan/scan_ci/ci_integrations.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | 5 | from cycode.cli.console import console 6 | 7 | 8 | def github_action_range() -> str: 9 | before_sha = os.getenv('BEFORE_SHA') 10 | push_base_sha = os.getenv('BASE_SHA') 11 | pr_base_sha = os.getenv('PR_BASE_SHA') 12 | default_branch = os.getenv('DEFAULT_BRANCH') 13 | head_sha = os.getenv('GITHUB_SHA') 14 | ref = os.getenv('GITHUB_REF') 15 | 16 | console.print(f'{before_sha}, {push_base_sha}, {pr_base_sha}, {default_branch}, {head_sha}, {ref}') 17 | if before_sha and before_sha != NO_COMMITS: 18 | return f'{before_sha}...' 19 | 20 | return '...' 21 | 22 | # if pr_base_sha and pr_base_sha != FIRST_COMMIT: 23 | # 24 | # if push_base_sha and push_base_sha != "null": 25 | 26 | 27 | def circleci_range() -> str: 28 | before_sha = os.getenv('BEFORE_SHA') 29 | current_sha = os.getenv('CURRENT_SHA') 30 | commit_range = f'{before_sha}...{current_sha}' 31 | console.print(f'commit range: {commit_range}') 32 | 33 | if not commit_range.startswith('...'): 34 | return commit_range 35 | 36 | commit_sha = os.getenv('CIRCLE_SHA1', 'HEAD') 37 | 38 | return f'{commit_sha}~1...' 39 | 40 | 41 | def gitlab_range() -> str: 42 | before_sha = os.getenv('CI_COMMIT_BEFORE_SHA') 43 | commit_sha = os.getenv('CI_COMMIT_SHA', 'HEAD') 44 | 45 | if before_sha and before_sha != NO_COMMITS: 46 | return f'{before_sha}...' 47 | 48 | return f'{commit_sha}' 49 | 50 | 51 | def get_commit_range() -> str: 52 | if os.getenv('GITHUB_ACTIONS'): 53 | return github_action_range() 54 | if os.getenv('CIRCLECI'): 55 | return circleci_range() 56 | if os.getenv('GITLAB_CI'): 57 | return gitlab_range() 58 | 59 | raise click.ClickException('CI framework is not supported') 60 | 61 | 62 | NO_COMMITS = '0000000000000000000000000000000000000000' 63 | -------------------------------------------------------------------------------- /cycode/cli/apps/scan/scan_ci/scan_ci_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | import typer 5 | 6 | from cycode.cli.apps.scan.code_scanner import scan_commit_range 7 | from cycode.cli.apps.scan.scan_ci.ci_integrations import get_commit_range 8 | from cycode.cli.utils.sentry import add_breadcrumb 9 | 10 | # This command is not finished yet. It is not used in the codebase. 11 | 12 | 13 | @click.command( 14 | short_help='Execute scan in a CI environment which relies on the ' 15 | 'CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables' 16 | ) 17 | @click.pass_context 18 | def scan_ci_command(ctx: typer.Context) -> None: 19 | add_breadcrumb('ci') 20 | scan_commit_range(ctx, path=os.getcwd(), commit_range=get_commit_range()) 21 | -------------------------------------------------------------------------------- /cycode/cli/apps/status/__init__.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.status.status_command import status_command 4 | from cycode.cli.apps.status.version_command import version_command 5 | 6 | app = typer.Typer(no_args_is_help=True) 7 | app.command(name='status', short_help='Show the CLI status and exit.')(status_command) 8 | app.command(name='version', hidden=True, short_help='Alias to status command.')(version_command) 9 | -------------------------------------------------------------------------------- /cycode/cli/apps/status/get_cli_status.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from typing import TYPE_CHECKING 3 | 4 | from cycode import __version__ 5 | from cycode.cli.apps.auth.auth_common import get_authorization_info 6 | from cycode.cli.apps.status.models import CliStatus, CliSupportedModulesStatus 7 | from cycode.cli.consts import PROGRAM_NAME 8 | from cycode.cli.logger import logger 9 | from cycode.cli.user_settings.configuration_manager import ConfigurationManager 10 | from cycode.cli.utils.get_api_client import get_scan_cycode_client 11 | 12 | if TYPE_CHECKING: 13 | from typer import Context 14 | 15 | 16 | def get_cli_status(ctx: 'Context') -> CliStatus: 17 | configuration_manager = ConfigurationManager() 18 | 19 | auth_info = get_authorization_info(ctx) 20 | is_authenticated = auth_info is not None 21 | 22 | supported_modules_status = CliSupportedModulesStatus() 23 | if is_authenticated: 24 | try: 25 | client = get_scan_cycode_client(ctx) 26 | supported_modules_preferences = client.get_supported_modules_preferences() 27 | 28 | supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning 29 | supported_modules_status.sca_scanning = supported_modules_preferences.sca_scanning 30 | supported_modules_status.iac_scanning = supported_modules_preferences.iac_scanning 31 | supported_modules_status.sast_scanning = supported_modules_preferences.sast_scanning 32 | supported_modules_status.ai_large_language_model = supported_modules_preferences.ai_large_language_model 33 | except Exception as e: 34 | logger.debug('Failed to get supported modules preferences', exc_info=e) 35 | 36 | return CliStatus( 37 | program=PROGRAM_NAME, 38 | version=__version__, 39 | os=platform.system(), 40 | arch=platform.machine(), 41 | python_version=platform.python_version(), 42 | installation_id=configuration_manager.get_or_create_installation_id(), 43 | app_url=configuration_manager.get_cycode_app_url(), 44 | api_url=configuration_manager.get_cycode_api_url(), 45 | is_authenticated=is_authenticated, 46 | user_id=auth_info.user_id if auth_info else None, 47 | tenant_id=auth_info.tenant_id if auth_info else None, 48 | supported_modules=supported_modules_status, 49 | ) 50 | -------------------------------------------------------------------------------- /cycode/cli/apps/status/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import asdict, dataclass 3 | 4 | 5 | class CliStatusBase: 6 | def as_dict(self) -> dict[str, any]: 7 | return asdict(self) 8 | 9 | def _get_text_message_part(self, key: str, value: any, intent: int = 0) -> str: 10 | message_parts = [] 11 | 12 | intent_prefix = ' ' * intent * 2 13 | human_readable_key = key.replace('_', ' ').capitalize() 14 | 15 | if isinstance(value, dict): 16 | message_parts.append(f'{intent_prefix}{human_readable_key}:') 17 | for sub_key, sub_value in value.items(): 18 | message_parts.append(self._get_text_message_part(sub_key, sub_value, intent=intent + 1)) 19 | elif isinstance(value, (list, set, tuple)): 20 | message_parts.append(f'{intent_prefix}{human_readable_key}:') 21 | for index, sub_value in enumerate(value): 22 | message_parts.append(self._get_text_message_part(f'#{index}', sub_value, intent=intent + 1)) 23 | else: 24 | message_parts.append(f'{intent_prefix}{human_readable_key}: {value}') 25 | 26 | return '\n'.join(message_parts) 27 | 28 | def as_text(self) -> str: 29 | message_parts = [] 30 | for key, value in self.as_dict().items(): 31 | message_parts.append(self._get_text_message_part(key, value)) 32 | 33 | return '\n'.join(message_parts) 34 | 35 | def as_json(self) -> str: 36 | return json.dumps(self.as_dict()) 37 | 38 | 39 | @dataclass 40 | class CliSupportedModulesStatus(CliStatusBase): 41 | secret_scanning: bool = False 42 | sca_scanning: bool = False 43 | iac_scanning: bool = False 44 | sast_scanning: bool = False 45 | ai_large_language_model: bool = False 46 | 47 | 48 | @dataclass 49 | class CliStatus(CliStatusBase): 50 | program: str 51 | version: str 52 | os: str 53 | arch: str 54 | python_version: str 55 | installation_id: str 56 | app_url: str 57 | api_url: str 58 | is_authenticated: bool 59 | user_id: str = None 60 | tenant_id: str = None 61 | supported_modules: CliSupportedModulesStatus = None 62 | -------------------------------------------------------------------------------- /cycode/cli/apps/status/status_command.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.status.get_cli_status import get_cli_status 4 | from cycode.cli.cli_types import OutputTypeOption 5 | from cycode.cli.console import console 6 | 7 | 8 | def status_command(ctx: typer.Context) -> None: 9 | """:information_source: [bold cyan]Show Cycode CLI status and configuration.[/] 10 | 11 | This command displays the current status and configuration of the Cycode CLI, including: 12 | * Authentication status: Whether you're logged in 13 | * Version information: Current CLI version 14 | * Configuration: Current API endpoints and settings 15 | * System information: Operating system and environment details 16 | 17 | Output formats: 18 | * Text: Human-readable format (default) 19 | * JSON: Machine-readable format 20 | 21 | Example usage: 22 | * `cycode status`: Show status in text format 23 | * `cycode -o json status`: Show status in JSON format 24 | """ 25 | output = ctx.obj['output'] 26 | 27 | cli_status = get_cli_status(ctx) 28 | if output == OutputTypeOption.JSON: 29 | console.print_json(cli_status.as_json()) 30 | else: 31 | console.print(cli_status.as_text()) 32 | -------------------------------------------------------------------------------- /cycode/cli/apps/status/version_command.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.apps.status.status_command import status_command 4 | from cycode.cli.console import console 5 | 6 | 7 | def version_command(ctx: typer.Context) -> None: 8 | console.print('[b yellow]This command is deprecated. Please use the "status" command instead.[/]') 9 | console.line() 10 | status_command(ctx) 11 | -------------------------------------------------------------------------------- /cycode/cli/cli_types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from cycode.cli import consts 4 | 5 | 6 | class StrEnum(str, Enum): 7 | def __str__(self) -> str: 8 | return self.value 9 | 10 | 11 | class OutputTypeOption(StrEnum): 12 | RICH = 'rich' 13 | TEXT = 'text' 14 | JSON = 'json' 15 | TABLE = 'table' 16 | 17 | 18 | class ExportTypeOption(StrEnum): 19 | JSON = 'json' 20 | HTML = 'html' 21 | SVG = 'svg' 22 | 23 | 24 | class ScanTypeOption(StrEnum): 25 | SECRET = consts.SECRET_SCAN_TYPE 26 | SCA = consts.SCA_SCAN_TYPE 27 | IAC = consts.IAC_SCAN_TYPE 28 | SAST = consts.SAST_SCAN_TYPE 29 | 30 | def __str__(self) -> str: 31 | return self.value 32 | 33 | 34 | class ScaScanTypeOption(StrEnum): 35 | PACKAGE_VULNERABILITIES = 'package-vulnerabilities' 36 | LICENSE_COMPLIANCE = 'license-compliance' 37 | 38 | 39 | class SbomFormatOption(StrEnum): 40 | SPDX_2_2 = 'spdx-2.2' 41 | SPDX_2_3 = 'spdx-2.3' 42 | CYCLONEDX_1_4 = 'cyclonedx-1.4' 43 | 44 | 45 | class SbomOutputFormatOption(StrEnum): 46 | JSON = 'json' 47 | 48 | 49 | class SeverityOption(StrEnum): 50 | INFO = 'info' 51 | LOW = 'low' 52 | MEDIUM = 'medium' 53 | HIGH = 'high' 54 | CRITICAL = 'critical' 55 | 56 | @classmethod 57 | def _missing_(cls, value: str) -> str: 58 | value = value.lower() 59 | for member in cls: 60 | if member.lower() == value: 61 | return member 62 | 63 | return cls.INFO # fallback to INFO if no match is found 64 | 65 | @staticmethod 66 | def get_member_weight(name: str) -> int: 67 | return _SEVERITY_WEIGHTS.get(name.lower(), _SEVERITY_DEFAULT_WEIGHT) 68 | 69 | @staticmethod 70 | def get_member_color(name: str) -> str: 71 | return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR) 72 | 73 | @staticmethod 74 | def get_member_emoji(name: str) -> str: 75 | return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI) 76 | 77 | def __rich__(self) -> str: 78 | color = self.get_member_color(self.value) 79 | return f'[{color}]{self.value.upper()}[/]' 80 | 81 | 82 | _SEVERITY_DEFAULT_WEIGHT = -1 83 | _SEVERITY_WEIGHTS = { 84 | SeverityOption.INFO.value: 0, 85 | SeverityOption.LOW.value: 1, 86 | SeverityOption.MEDIUM.value: 2, 87 | SeverityOption.HIGH.value: 3, 88 | SeverityOption.CRITICAL.value: 4, 89 | } 90 | 91 | _SEVERITY_DEFAULT_COLOR = 'white' 92 | _SEVERITY_COLORS = { 93 | SeverityOption.INFO.value: 'deep_sky_blue1', 94 | SeverityOption.LOW.value: 'gold1', 95 | SeverityOption.MEDIUM.value: 'dark_orange', 96 | SeverityOption.HIGH.value: 'red1', 97 | SeverityOption.CRITICAL.value: 'red3', 98 | } 99 | 100 | _SEVERITY_DEFAULT_EMOJI = ':white_circle:' 101 | _SEVERITY_EMOJIS = { 102 | SeverityOption.INFO.value: ':blue_circle:', 103 | SeverityOption.LOW.value: ':yellow_circle:', 104 | SeverityOption.MEDIUM.value: ':orange_circle:', 105 | SeverityOption.HIGH.value: ':red_circle:', 106 | SeverityOption.CRITICAL.value: ':exclamation_mark:', # double_exclamation_mark is not red 107 | } 108 | -------------------------------------------------------------------------------- /cycode/cli/config.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.user_settings.configuration_manager import ConfigurationManager 2 | 3 | configuration_manager = ConfigurationManager() 4 | 5 | # env vars 6 | CYCODE_CLIENT_ID_ENV_VAR_NAME = 'CYCODE_CLIENT_ID' 7 | CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET' 8 | -------------------------------------------------------------------------------- /cycode/cli/console.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from rich.console import Console, RenderResult 5 | from rich.markdown import Heading, Markdown 6 | from rich.text import Text 7 | 8 | if TYPE_CHECKING: 9 | from rich.console import ConsoleOptions 10 | 11 | console_out = Console() 12 | console_err = Console(stderr=True) 13 | 14 | console = console_out # alias 15 | 16 | 17 | def is_dark_console() -> Optional[bool]: 18 | """Detect if the console is dark or light. 19 | 20 | This function checks the environment variables and terminal type to determine if the console is dark or light. 21 | 22 | Used approaches: 23 | 1. Check the `LC_DARK_BG` environment variable. 24 | 2. Check the `COLORFGBG` environment variable for background color. 25 | 26 | And it still could be wrong in some cases. 27 | 28 | TODO(MarshalX): migrate to https://github.com/dalance/termbg when someone will implement it for Python. 29 | """ 30 | dark = None 31 | 32 | dark_bg = os.environ.get('LC_DARK_BG') 33 | if dark_bg is not None: 34 | return dark_bg != '0' 35 | 36 | # If BG color in {0, 1, 2, 3, 4, 5, 6, 8} then dark, else light. 37 | try: 38 | color = os.environ.get('COLORFGBG') 39 | *_, bg = color.split(';') 40 | bg = int(bg) 41 | dark = bool(0 <= bg <= 6 or bg == 8) 42 | except Exception: # noqa: S110 43 | pass 44 | 45 | return dark 46 | 47 | 48 | _SYNTAX_HIGHLIGHT_DARK_THEME = 'monokai' 49 | _SYNTAX_HIGHLIGHT_LIGHT_THEME = 'default' 50 | 51 | # when we could not detect it, use dark theme as most terminals are dark 52 | _SYNTAX_HIGHLIGHT_THEME = _SYNTAX_HIGHLIGHT_LIGHT_THEME if is_dark_console() is False else _SYNTAX_HIGHLIGHT_DARK_THEME 53 | 54 | 55 | class CycodeHeading(Heading): 56 | """Custom Rich Heading for Markdown. 57 | 58 | Changes: 59 | - remove justify to 'center' 60 | - remove the box for h1 61 | """ 62 | 63 | def __rich_console__(self, console: 'Console', options: 'ConsoleOptions') -> RenderResult: 64 | if self.tag == 'h2': 65 | yield Text('') 66 | yield self.text 67 | 68 | 69 | Markdown.elements['heading_open'] = CycodeHeading 70 | -------------------------------------------------------------------------------- /cycode/cli/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/exceptions/__init__.py -------------------------------------------------------------------------------- /cycode/cli/exceptions/handle_ai_remediation_errors.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS, RequestHttpError 4 | from cycode.cli.exceptions.handle_errors import handle_errors 5 | from cycode.cli.models import CliError, CliErrors 6 | 7 | 8 | class AiRemediationNotFoundError(Exception): ... 9 | 10 | 11 | def handle_ai_remediation_exception(ctx: typer.Context, err: Exception) -> None: 12 | if isinstance(err, RequestHttpError) and err.status_code == 404: 13 | err = AiRemediationNotFoundError() 14 | 15 | errors: CliErrors = { 16 | **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, 17 | AiRemediationNotFoundError: CliError( 18 | code='ai_remediation_not_found', 19 | message='The AI remediation was not found. Please try different detection ID', 20 | ), 21 | } 22 | handle_errors(ctx, err, errors) 23 | -------------------------------------------------------------------------------- /cycode/cli/exceptions/handle_auth_errors.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.exceptions.custom_exceptions import ( 4 | KNOWN_USER_FRIENDLY_REQUEST_ERRORS, 5 | AuthProcessError, 6 | ) 7 | from cycode.cli.exceptions.handle_errors import handle_errors 8 | from cycode.cli.models import CliError, CliErrors 9 | 10 | 11 | def handle_auth_exception(ctx: typer.Context, err: Exception) -> None: 12 | errors: CliErrors = { 13 | **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, 14 | AuthProcessError: CliError( 15 | code='auth_error', message='Authentication failed. Please try again later using the command `cycode auth`' 16 | ), 17 | } 18 | handle_errors(ctx, err, errors) 19 | -------------------------------------------------------------------------------- /cycode/cli/exceptions/handle_errors.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | import typer 5 | 6 | from cycode.cli.models import CliError, CliErrors 7 | from cycode.cli.utils.sentry import capture_exception 8 | 9 | 10 | def handle_errors( 11 | ctx: typer.Context, err: BaseException, cli_errors: CliErrors, *, return_exception: bool = False 12 | ) -> Optional['CliError']: 13 | printer = ctx.obj.get('console_printer') 14 | printer.print_exception(err) 15 | 16 | if type(err) in cli_errors: 17 | error = cli_errors[type(err)].enrich(additional_message=str(err)) 18 | 19 | if error.soft_fail is True: 20 | ctx.obj['soft_fail'] = True 21 | 22 | if return_exception: 23 | return error 24 | 25 | printer.print_error(error) 26 | return None 27 | 28 | if isinstance(err, click.ClickException): 29 | raise err 30 | 31 | capture_exception(err) 32 | 33 | unknown_error = CliError(code='unknown_error', message=str(err)) 34 | if return_exception: 35 | return unknown_error 36 | 37 | printer.print_error(unknown_error) 38 | raise typer.Exit(1) 39 | -------------------------------------------------------------------------------- /cycode/cli/exceptions/handle_report_sbom_errors.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.exceptions import custom_exceptions 4 | from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS 5 | from cycode.cli.exceptions.handle_errors import handle_errors 6 | from cycode.cli.models import CliError, CliErrors 7 | 8 | 9 | def handle_report_exception(ctx: typer.Context, err: Exception) -> None: 10 | errors: CliErrors = { 11 | **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, 12 | custom_exceptions.ScanAsyncError: CliError( 13 | code='report_error', 14 | message='Cycode was unable to complete this report. ' 15 | 'Please try again by executing the `cycode report` command', 16 | ), 17 | custom_exceptions.ReportAsyncError: CliError( 18 | code='report_error', 19 | message='Cycode was unable to complete this report. ' 20 | 'Please try again by executing the `cycode report` command', 21 | ), 22 | } 23 | handle_errors(ctx, err, errors) 24 | -------------------------------------------------------------------------------- /cycode/cli/exceptions/handle_scan_errors.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import typer 4 | 5 | from cycode.cli.exceptions import custom_exceptions 6 | from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS 7 | from cycode.cli.exceptions.handle_errors import handle_errors 8 | from cycode.cli.models import CliError, CliErrors 9 | from cycode.cli.utils.git_proxy import git_proxy 10 | 11 | 12 | def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exception: bool = False) -> Optional[CliError]: 13 | ctx.obj['did_fail'] = True 14 | 15 | errors: CliErrors = { 16 | **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, 17 | custom_exceptions.ScanAsyncError: CliError( 18 | soft_fail=True, 19 | code='scan_error', 20 | message='Cycode was unable to complete this scan. Please try again by executing the `cycode scan` command', 21 | ), 22 | custom_exceptions.ZipTooLargeError: CliError( 23 | soft_fail=True, 24 | code='zip_too_large_error', 25 | message='The path you attempted to scan exceeds the current maximum scanning size cap (10MB). ' 26 | 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' 27 | 'and execute the scan again', 28 | ), 29 | custom_exceptions.TfplanKeyError: CliError( 30 | soft_fail=True, 31 | code='key_error', 32 | message=f'\n{err!s}\n' 33 | 'A crucial field is missing in your terraform plan file. ' 34 | 'Please make sure that your file is well formed ' 35 | 'and execute the scan again', 36 | ), 37 | git_proxy.get_invalid_git_repository_error(): CliError( 38 | soft_fail=False, 39 | code='invalid_git_error', 40 | message='The path you supplied does not correlate to a Git repository. ' 41 | 'If you still wish to scan this path, use: `cycode scan path `', 42 | ), 43 | } 44 | 45 | return handle_errors(ctx, err, errors, return_exception=return_exception) 46 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/iac/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/iac/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/iac/tf_content_generator.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | from cycode.cli import consts 5 | from cycode.cli.exceptions.custom_exceptions import TfplanKeyError 6 | from cycode.cli.models import ResourceChange 7 | from cycode.cli.utils.path_utils import change_filename_extension, load_json 8 | 9 | ACTIONS_TO_OMIT_RESOURCE = ['delete'] 10 | 11 | 12 | def generate_tfplan_document_name(path: str) -> str: 13 | document_name = change_filename_extension(path, 'tf') 14 | timestamp = int(time.time()) 15 | return f'{timestamp}-{document_name}' 16 | 17 | 18 | def is_iac(scan_type: str) -> bool: 19 | return scan_type == consts.IAC_SCAN_TYPE 20 | 21 | 22 | def is_tfplan_file(file: str, content: str) -> bool: 23 | if not file.endswith('.json'): 24 | return False 25 | tf_plan = load_json(content) 26 | if not isinstance(tf_plan, dict): 27 | return False 28 | return 'resource_changes' in tf_plan 29 | 30 | 31 | def generate_tf_content_from_tfplan(filename: str, tfplan: str) -> str: 32 | planned_resources = _extract_resources(tfplan, filename) 33 | return _generate_tf_content(planned_resources) 34 | 35 | 36 | def _generate_tf_content(resource_changes: list[ResourceChange]) -> str: 37 | tf_content = '' 38 | for resource_change in resource_changes: 39 | if not any(item in resource_change.actions for item in ACTIONS_TO_OMIT_RESOURCE): 40 | tf_content += _generate_resource_content(resource_change) 41 | return tf_content 42 | 43 | 44 | def _generate_resource_content(resource_change: ResourceChange) -> str: 45 | resource_content = f'resource "{resource_change.resource_type}" "{_get_resource_name(resource_change)}" {{\n' 46 | if resource_change.values is not None: 47 | for key, value in resource_change.values.items(): 48 | resource_content += f' {key} = {json.dumps(value)}\n' 49 | resource_content += '}\n\n' 50 | return resource_content 51 | 52 | 53 | def _get_resource_name(resource_change: ResourceChange) -> str: 54 | parts = [resource_change.module_address, resource_change.name] 55 | 56 | if resource_change.index is not None: 57 | parts.append(str(resource_change.index)) 58 | 59 | valid_parts = [part for part in parts if part] 60 | 61 | return '.'.join(valid_parts) 62 | 63 | 64 | def _extract_resources(tfplan: str, filename: str) -> list[ResourceChange]: 65 | tfplan_json = load_json(tfplan) 66 | resources: list[ResourceChange] = [] 67 | try: 68 | resource_changes = tfplan_json['resource_changes'] 69 | for resource_change in resource_changes: 70 | resources.append( 71 | ResourceChange( 72 | module_address=resource_change.get('module_address'), 73 | resource_type=resource_change['type'], 74 | name=resource_change['name'], 75 | index=resource_change.get('index'), 76 | actions=resource_change['change']['actions'], 77 | values=resource_change['change']['after'], 78 | ) 79 | ) 80 | except (KeyError, TypeError) as e: 81 | raise TfplanKeyError(filename) from e 82 | return resources 83 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/models/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/models/in_memory_zip.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from io import BytesIO 3 | from pathlib import Path 4 | from sys import getsizeof 5 | from typing import Optional 6 | from zipfile import ZIP_DEFLATED, ZipFile 7 | 8 | from cycode.cli.user_settings.configuration_manager import ConfigurationManager 9 | from cycode.cli.utils.path_utils import concat_unique_id 10 | 11 | 12 | class InMemoryZip: 13 | def __init__(self) -> None: 14 | self.configuration_manager = ConfigurationManager() 15 | 16 | self.in_memory_zip = BytesIO() 17 | self.zip = ZipFile(self.in_memory_zip, mode='a', compression=ZIP_DEFLATED, allowZip64=False) 18 | 19 | self._files_count = 0 20 | self._extension_statistics = defaultdict(int) 21 | 22 | def append(self, filename: str, unique_id: Optional[str], content: str) -> None: 23 | self._files_count += 1 24 | self._extension_statistics[Path(filename).suffix] += 1 25 | 26 | if unique_id: 27 | filename = concat_unique_id(filename, unique_id) 28 | 29 | self.zip.writestr(filename, content) 30 | 31 | def close(self) -> None: 32 | self.zip.close() 33 | 34 | def read(self) -> bytes: 35 | self.in_memory_zip.seek(0) 36 | return self.in_memory_zip.read() 37 | 38 | def write_on_disk(self, path: 'Path') -> None: 39 | with open(path, 'wb') as f: 40 | f.write(self.read()) 41 | 42 | @property 43 | def size(self) -> int: 44 | return getsizeof(self.in_memory_zip) 45 | 46 | @property 47 | def files_count(self) -> int: 48 | return self._files_count 49 | 50 | @property 51 | def extension_statistics(self) -> dict[str, int]: 52 | return dict(self._extension_statistics) 53 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/sca/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/go/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/sca/go/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/go/restore_go_dependencies.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | import typer 5 | 6 | from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies 7 | from cycode.cli.logger import logger 8 | from cycode.cli.models import Document 9 | 10 | GO_PROJECT_FILE_EXTENSIONS = ['.mod', '.sum'] 11 | GO_RESTORE_FILE_NAME = 'go.mod.graph' 12 | BUILD_GO_FILE_NAME = 'go.mod' 13 | BUILD_GO_LOCK_FILE_NAME = 'go.sum' 14 | 15 | 16 | class RestoreGoDependencies(BaseRestoreDependencies): 17 | def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: 18 | super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) 19 | 20 | def try_restore_dependencies(self, document: Document) -> Optional[Document]: 21 | manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME) 22 | lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME) 23 | 24 | if not manifest_exists or not lock_exists: 25 | logger.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') 26 | 27 | manifest_files_exists = manifest_exists & lock_exists 28 | 29 | if not manifest_files_exists: 30 | return None 31 | 32 | return super().try_restore_dependencies(document) 33 | 34 | def is_project(self, document: Document) -> bool: 35 | return any(document.path.endswith(ext) for ext in GO_PROJECT_FILE_EXTENSIONS) 36 | 37 | def get_commands(self, manifest_file_path: str) -> list[list[str]]: 38 | return [ 39 | ['go', 'list', '-m', '-json', 'all'], 40 | ['echo', '------------------------------------------------------'], 41 | ['go', 'mod', 'graph'], 42 | ] 43 | 44 | def get_lock_file_name(self) -> str: 45 | return GO_RESTORE_FILE_NAME 46 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/maven/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/sca/maven/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import Optional 4 | 5 | import typer 6 | 7 | from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies 8 | from cycode.cli.models import Document 9 | from cycode.cli.utils.path_utils import get_path_from_context 10 | from cycode.cli.utils.shell_executor import shell 11 | 12 | BUILD_GRADLE_FILE_NAME = 'build.gradle' 13 | BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' 14 | BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' 15 | BUILD_GRADLE_ALL_PROJECTS_TIMEOUT = 180 16 | BUILD_GRADLE_ALL_PROJECTS_COMMAND = ['gradle', 'projects'] 17 | ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" 18 | 19 | 20 | class RestoreGradleDependencies(BaseRestoreDependencies): 21 | def __init__( 22 | self, ctx: typer.Context, is_git_diff: bool, command_timeout: int, projects: Optional[set[str]] = None 23 | ) -> None: 24 | super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) 25 | if projects is None: 26 | projects = set() 27 | self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects 28 | 29 | def is_gradle_sub_projects(self) -> bool: 30 | return self.ctx.params.get('gradle-all-sub-projects', False) 31 | 32 | def is_project(self, document: Document) -> bool: 33 | return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) 34 | 35 | def get_commands(self, manifest_file_path: str) -> list[list[str]]: 36 | return ( 37 | self.get_commands_for_sub_projects(manifest_file_path) 38 | if self.is_gradle_sub_projects() 39 | else [['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] 40 | ) 41 | 42 | def get_lock_file_name(self) -> str: 43 | return BUILD_GRADLE_DEP_TREE_FILE_NAME 44 | 45 | def get_working_directory(self, document: Document) -> Optional[str]: 46 | return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None 47 | 48 | def get_all_projects(self) -> set[str]: 49 | output = shell( 50 | command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, 51 | timeout=BUILD_GRADLE_ALL_PROJECTS_TIMEOUT, 52 | working_directory=get_path_from_context(self.ctx), 53 | ) 54 | if not output: 55 | return set() 56 | 57 | return set(re.findall(ALL_PROJECTS_REGEX, output)) 58 | 59 | def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[str]]: 60 | project_name = os.path.basename(os.path.dirname(manifest_file_path)) 61 | project_name = f':{project_name}' 62 | return ( 63 | [['gradle', f'{project_name}:dependencies', '-q', '--console', 'plain']] 64 | if project_name in self.projects 65 | else [] 66 | ) 67 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from typing import Optional 3 | 4 | import typer 5 | 6 | from cycode.cli.files_collector.sca.base_restore_dependencies import ( 7 | BaseRestoreDependencies, 8 | build_dep_tree_path, 9 | execute_commands, 10 | ) 11 | from cycode.cli.models import Document 12 | from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths 13 | 14 | BUILD_MAVEN_FILE_NAME = 'pom.xml' 15 | MAVEN_CYCLONE_DEP_TREE_FILE_NAME = 'bom.json' 16 | MAVEN_DEP_TREE_FILE_NAME = 'bcde.mvndeps' 17 | 18 | 19 | class RestoreMavenDependencies(BaseRestoreDependencies): 20 | def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: 21 | super().__init__(ctx, is_git_diff, command_timeout) 22 | 23 | def is_project(self, document: Document) -> bool: 24 | return path.basename(document.path).split('/')[-1] == BUILD_MAVEN_FILE_NAME 25 | 26 | def get_commands(self, manifest_file_path: str) -> list[list[str]]: 27 | return [['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.7.4:makeAggregateBom', '-f', manifest_file_path]] 28 | 29 | def get_lock_file_name(self) -> str: 30 | return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) 31 | 32 | def try_restore_dependencies(self, document: Document) -> Optional[Document]: 33 | manifest_file_path = self.get_manifest_file_path(document) 34 | if document.content is None: 35 | return self.restore_from_secondary_command(document, manifest_file_path) 36 | 37 | restore_dependencies_document = super().try_restore_dependencies(document) 38 | if restore_dependencies_document is None: 39 | return None 40 | 41 | restore_dependencies_document.content = get_file_content( 42 | join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name()) 43 | ) 44 | 45 | return restore_dependencies_document 46 | 47 | def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: 48 | restore_content = execute_commands( 49 | commands=create_secondary_restore_commands(manifest_file_path), 50 | timeout=self.command_timeout, 51 | working_directory=self.get_working_directory(document), 52 | ) 53 | if restore_content is None: 54 | return None 55 | 56 | restore_file_path = build_dep_tree_path(document.absolute_path, MAVEN_DEP_TREE_FILE_NAME) 57 | return Document( 58 | path=build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), 59 | content=get_file_content(restore_file_path), 60 | is_git_diff_format=self.is_git_diff, 61 | absolute_path=restore_file_path, 62 | ) 63 | 64 | 65 | def create_secondary_restore_commands(manifest_file_path: str) -> list[list[str]]: 66 | return [ 67 | [ 68 | 'mvn', 69 | 'dependency:tree', 70 | '-B', 71 | '-DoutputType=text', 72 | '-f', 73 | manifest_file_path, 74 | f'-DoutputFile={MAVEN_DEP_TREE_FILE_NAME}', 75 | ] 76 | ] 77 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/npm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/sca/npm/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import typer 4 | 5 | from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies 6 | from cycode.cli.models import Document 7 | 8 | NPM_PROJECT_FILE_EXTENSIONS = ['.json'] 9 | NPM_LOCK_FILE_NAME = 'package-lock.json' 10 | NPM_MANIFEST_FILE_NAME = 'package.json' 11 | 12 | 13 | class RestoreNpmDependencies(BaseRestoreDependencies): 14 | def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: 15 | super().__init__(ctx, is_git_diff, command_timeout) 16 | 17 | def is_project(self, document: Document) -> bool: 18 | return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) 19 | 20 | def get_commands(self, manifest_file_path: str) -> list[list[str]]: 21 | return [ 22 | [ 23 | 'npm', 24 | 'install', 25 | '--prefix', 26 | self.prepare_manifest_file_path_for_command(manifest_file_path), 27 | '--package-lock-only', 28 | '--ignore-scripts', 29 | '--no-audit', 30 | ] 31 | ] 32 | 33 | def get_lock_file_name(self) -> str: 34 | return NPM_LOCK_FILE_NAME 35 | 36 | @staticmethod 37 | def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: 38 | return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') 39 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/nuget/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/sca/nuget/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies 4 | from cycode.cli.models import Document 5 | 6 | NUGET_PROJECT_FILE_EXTENSIONS = ['.csproj', '.vbproj'] 7 | NUGET_LOCK_FILE_NAME = 'packages.lock.json' 8 | 9 | 10 | class RestoreNugetDependencies(BaseRestoreDependencies): 11 | def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: 12 | super().__init__(ctx, is_git_diff, command_timeout) 13 | 14 | def is_project(self, document: Document) -> bool: 15 | return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) 16 | 17 | def get_commands(self, manifest_file_path: str) -> list[list[str]]: 18 | return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet']] 19 | 20 | def get_lock_file_name(self) -> str: 21 | return NUGET_LOCK_FILE_NAME 22 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/ruby/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/sca/ruby/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies 2 | from cycode.cli.models import Document 3 | 4 | RUBY_PROJECT_FILE_EXTENSIONS = ['Gemfile'] 5 | RUBY_LOCK_FILE_NAME = 'Gemfile.lock' 6 | 7 | 8 | class RestoreRubyDependencies(BaseRestoreDependencies): 9 | def is_project(self, document: Document) -> bool: 10 | return any(document.path.endswith(ext) for ext in RUBY_PROJECT_FILE_EXTENSIONS) 11 | 12 | def get_commands(self, manifest_file_path: str) -> list[list[str]]: 13 | return [['bundle', '--quiet']] 14 | 15 | def get_lock_file_name(self) -> str: 16 | return RUBY_LOCK_FILE_NAME 17 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/sbt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/files_collector/sca/sbt/__init__.py -------------------------------------------------------------------------------- /cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies 2 | from cycode.cli.models import Document 3 | 4 | SBT_PROJECT_FILE_EXTENSIONS = ['sbt'] 5 | SBT_LOCK_FILE_NAME = 'build.sbt.lock' 6 | 7 | 8 | class RestoreSbtDependencies(BaseRestoreDependencies): 9 | def is_project(self, document: Document) -> bool: 10 | return any(document.path.endswith(ext) for ext in SBT_PROJECT_FILE_EXTENSIONS) 11 | 12 | def get_commands(self, manifest_file_path: str) -> list[list[str]]: 13 | return [['sbt', 'dependencyLockWrite', '--verbose']] 14 | 15 | def get_lock_file_name(self) -> str: 16 | return SBT_LOCK_FILE_NAME 17 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/walk_ignore.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Generator, Iterable 3 | 4 | from cycode.cli.logger import logger 5 | from cycode.cli.utils.ignore_utils import IgnoreFilterManager 6 | 7 | _SUPPORTED_IGNORE_PATTERN_FILES = { # oneday we will bring .cycodeignore or something like that 8 | '.gitignore', 9 | } 10 | _DEFAULT_GLOBAL_IGNORE_PATTERNS = [ 11 | '.git', 12 | '.cycode', 13 | ] 14 | 15 | 16 | def _walk_to_top(path: str) -> Iterable[str]: 17 | while os.path.dirname(path) != path: 18 | yield path 19 | path = os.path.dirname(path) 20 | 21 | if path: 22 | yield path # Include the top-level directory 23 | 24 | 25 | def _collect_top_level_ignore_files(path: str) -> list[str]: 26 | ignore_files = [] 27 | top_paths = reversed(list(_walk_to_top(path))) # we must reverse it to make top levels more prioritized 28 | for dir_path in top_paths: 29 | for ignore_file in _SUPPORTED_IGNORE_PATTERN_FILES: 30 | ignore_file_path = os.path.join(dir_path, ignore_file) 31 | if os.path.exists(ignore_file_path): 32 | logger.debug('Apply top level ignore file: %s', ignore_file_path) 33 | ignore_files.append(ignore_file_path) 34 | return ignore_files 35 | 36 | 37 | def walk_ignore(path: str) -> Generator[tuple[str, list[str], list[str]], None, None]: 38 | ignore_filter_manager = IgnoreFilterManager.build( 39 | path=path, 40 | global_ignore_file_paths=_collect_top_level_ignore_files(path), 41 | global_patterns=_DEFAULT_GLOBAL_IGNORE_PATTERNS, 42 | ) 43 | yield from ignore_filter_manager.walk() 44 | -------------------------------------------------------------------------------- /cycode/cli/files_collector/zip_documents.py: -------------------------------------------------------------------------------- 1 | import timeit 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from cycode.cli import consts 6 | from cycode.cli.exceptions import custom_exceptions 7 | from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip 8 | from cycode.cli.models import Document 9 | from cycode.logger import get_logger 10 | 11 | logger = get_logger('ZIP') 12 | 13 | 14 | def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: 15 | max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES) 16 | if zip_file_size > max_size_limit: 17 | raise custom_exceptions.ZipTooLargeError(max_size_limit) 18 | 19 | 20 | def zip_documents(scan_type: str, documents: list[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: 21 | if zip_file is None: 22 | zip_file = InMemoryZip() 23 | 24 | start_zip_creation_time = timeit.default_timer() 25 | 26 | for index, document in enumerate(documents): 27 | _validate_zip_file_size(scan_type, zip_file.size) 28 | 29 | logger.debug( 30 | 'Adding file, %s', 31 | {'index': index, 'filename': document.path, 'unique_id': document.unique_id}, 32 | ) 33 | zip_file.append(document.path, document.unique_id, document.content) 34 | 35 | zip_file.close() 36 | 37 | end_zip_creation_time = timeit.default_timer() 38 | zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) 39 | logger.debug( 40 | 'Finished to create file, %s', 41 | {'zip_creation_time': zip_creation_time, 'zip_size': zip_file.size, 'documents_count': len(documents)}, 42 | ) 43 | 44 | if zip_file.configuration_manager.get_debug_flag(): 45 | zip_file_path = Path.joinpath(Path.cwd(), f'{scan_type}_scan_{end_zip_creation_time}.zip') 46 | logger.debug('Writing file to disk, %s', {'zip_file_path': zip_file_path}) 47 | zip_file.write_on_disk(zip_file_path) 48 | 49 | return zip_file 50 | -------------------------------------------------------------------------------- /cycode/cli/logger.py: -------------------------------------------------------------------------------- 1 | from cycode.logger import get_logger 2 | 3 | logger = get_logger('CLI') 4 | -------------------------------------------------------------------------------- /cycode/cli/main.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import freeze_support 2 | 3 | from cycode.cli.app import app 4 | 5 | # DO NOT REMOVE OR MOVE THIS LINE 6 | # this is required to support multiprocessing in executables files packaged with PyInstaller 7 | # see https://pyinstaller.org/en/latest/common-issues-and-pitfalls.html#multi-processing 8 | freeze_support() 9 | 10 | app() 11 | -------------------------------------------------------------------------------- /cycode/cli/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import NamedTuple, Optional 3 | 4 | from cycode.cyclient.models import Detection 5 | 6 | 7 | class Document: 8 | def __init__( 9 | self, 10 | path: str, 11 | content: str, 12 | is_git_diff_format: bool = False, 13 | unique_id: Optional[str] = None, 14 | absolute_path: Optional[str] = None, 15 | ) -> None: 16 | self.path = path 17 | self.content = content 18 | self.is_git_diff_format = is_git_diff_format 19 | self.unique_id = unique_id 20 | self.absolute_path = absolute_path 21 | 22 | def __repr__(self) -> str: 23 | return f'path:{self.path}, content:{self.content}' 24 | 25 | 26 | class DocumentDetections: 27 | def __init__(self, document: Document, detections: list[Detection]) -> None: 28 | self.document = document 29 | self.detections = detections 30 | 31 | def __repr__(self) -> str: 32 | return f'document:{self.document}, detections:{self.detections}' 33 | 34 | 35 | class CliError(NamedTuple): 36 | code: str 37 | message: str 38 | soft_fail: bool = False 39 | 40 | def enrich(self, additional_message: str) -> 'CliError': 41 | message = f'{self.message} ({additional_message})' 42 | return CliError(self.code, message, self.soft_fail) 43 | 44 | 45 | CliErrors = dict[type[BaseException], CliError] 46 | 47 | 48 | class CliResult(NamedTuple): 49 | success: bool 50 | message: str 51 | data: Optional[dict[str, any]] = None 52 | 53 | 54 | class LocalScanResult(NamedTuple): 55 | scan_id: str 56 | report_url: Optional[str] 57 | document_detections: list[DocumentDetections] 58 | issue_detected: bool 59 | detections_count: int 60 | relevant_detections_count: int 61 | 62 | 63 | @dataclass 64 | class ResourceChange: 65 | module_address: Optional[str] 66 | resource_type: str 67 | name: str 68 | index: Optional[int] 69 | actions: list[str] 70 | values: dict[str, str] 71 | 72 | def __repr__(self) -> str: 73 | return f'resource_type: {self.resource_type}, name: {self.name}' 74 | -------------------------------------------------------------------------------- /cycode/cli/printers/__init__.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.printers.console_printer import ConsolePrinter 2 | 3 | __all__ = ['ConsolePrinter'] 4 | -------------------------------------------------------------------------------- /cycode/cli/printers/json_printer.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from cycode.cli.models import CliError, CliResult 5 | from cycode.cli.printers.printer_base import PrinterBase 6 | from cycode.cyclient.models import DetectionSchema 7 | 8 | if TYPE_CHECKING: 9 | from cycode.cli.models import LocalScanResult 10 | 11 | 12 | class JsonPrinter(PrinterBase): 13 | def print_result(self, result: CliResult) -> None: 14 | result = {'result': result.success, 'message': result.message, 'data': result.data} 15 | 16 | self.console.print_json(self.get_data_json(result)) 17 | 18 | def print_error(self, error: CliError) -> None: 19 | result = {'error': error.code, 'message': error.message} 20 | 21 | self.console.print_json(self.get_data_json(result)) 22 | 23 | def print_scan_results( 24 | self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None 25 | ) -> None: 26 | scan_ids = [] 27 | report_urls = [] 28 | detections = [] 29 | aggregation_report_url = self.ctx.obj.get('aggregation_report_url') 30 | if aggregation_report_url: 31 | report_urls.append(aggregation_report_url) 32 | 33 | for local_scan_result in local_scan_results: 34 | scan_ids.append(local_scan_result.scan_id) 35 | 36 | if not aggregation_report_url and local_scan_result.report_url: 37 | report_urls.append(local_scan_result.report_url) 38 | for document_detections in local_scan_result.document_detections: 39 | detections.extend(document_detections.detections) 40 | 41 | detections_dict = DetectionSchema(many=True).dump(detections) 42 | 43 | inlined_errors = [] 44 | if errors: 45 | # FIXME(MarshalX): we don't care about scan IDs in JSON output due to clumsy JSON root structure 46 | inlined_errors = [err._asdict() for err in errors.values()] 47 | 48 | self.console.print_json(self._get_json_scan_result(scan_ids, detections_dict, report_urls, inlined_errors)) 49 | 50 | def _get_json_scan_result( 51 | self, scan_ids: list[str], detections: dict, report_urls: list[str], errors: list[dict] 52 | ) -> str: 53 | result = { 54 | 'scan_ids': scan_ids, 55 | 'detections': detections, 56 | 'report_urls': report_urls, 57 | 'errors': errors, 58 | } 59 | 60 | return self.get_data_json(result) 61 | 62 | @staticmethod 63 | def get_data_json(data: dict) -> str: 64 | # ensure_ascii is disabled for symbols like "`". Eg: `cycode scan` 65 | return json.dumps(data, ensure_ascii=False) 66 | -------------------------------------------------------------------------------- /cycode/cli/printers/tables/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/printers/tables/__init__.py -------------------------------------------------------------------------------- /cycode/cli/printers/tables/table.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from rich.markup import escape 5 | from rich.table import Table as RichTable 6 | 7 | if TYPE_CHECKING: 8 | from cycode.cli.printers.tables.table_models import ColumnInfo 9 | 10 | 11 | class Table: 12 | """Helper class to manage columns and their values in the right order and only if the column should be presented.""" 13 | 14 | def __init__(self, column_infos: Optional[list['ColumnInfo']] = None) -> None: 15 | self._group_separator_indexes: set[int] = set() 16 | 17 | self._columns: dict[ColumnInfo, list[str]] = {} 18 | if column_infos: 19 | self._columns = {columns: [] for columns in column_infos} 20 | 21 | def add_column(self, column: 'ColumnInfo') -> None: 22 | self._columns[column] = [] 23 | 24 | def _add_cell_no_error(self, column: 'ColumnInfo', value: str) -> None: 25 | # we push values only for existing columns what were added before 26 | if column in self._columns: 27 | self._columns[column].append(value) 28 | 29 | def add_cell(self, column: 'ColumnInfo', value: str, color: Optional[str] = None) -> None: 30 | if color: 31 | value = f'[{color}]{value}[/]' 32 | 33 | self._add_cell_no_error(column, value) 34 | 35 | def add_file_path_cell(self, column: 'ColumnInfo', path: str) -> None: 36 | encoded_path = urllib.parse.quote(path) 37 | escaped_path = escape(encoded_path) 38 | self._add_cell_no_error(column, f'[link file://{escaped_path}]{path}') 39 | 40 | def set_group_separator_indexes(self, group_separator_indexes: set[int]) -> None: 41 | self._group_separator_indexes = group_separator_indexes 42 | 43 | def _get_ordered_columns(self) -> list['ColumnInfo']: 44 | # we are sorting columns by index to make sure that columns will be printed in the right order 45 | return sorted(self._columns, key=lambda column_info: column_info.index) 46 | 47 | def get_columns_info(self) -> list['ColumnInfo']: 48 | return self._get_ordered_columns() 49 | 50 | def get_rows(self) -> list[str]: 51 | column_values = [self._columns[column_info] for column_info in self._get_ordered_columns()] 52 | return list(zip(*column_values)) 53 | 54 | def get_table(self) -> 'RichTable': 55 | table = RichTable(expand=True, highlight=True) 56 | 57 | for column in self.get_columns_info(): 58 | extra_args = column.column_opts if column.column_opts else {} 59 | table.add_column(header=column.name, overflow='fold', **extra_args) 60 | 61 | for index, raw in enumerate(self.get_rows()): 62 | table.add_row(*raw, end_section=index in self._group_separator_indexes) 63 | 64 | return table 65 | -------------------------------------------------------------------------------- /cycode/cli/printers/tables/table_models.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Optional 2 | 3 | 4 | class ColumnInfoBuilder: 5 | def __init__(self) -> None: 6 | self._index = 0 7 | 8 | def build(self, name: str, **column_opts) -> 'ColumnInfo': 9 | column_info = ColumnInfo(name, self._index, column_opts) 10 | self._index += 1 11 | return column_info 12 | 13 | 14 | class ColumnInfo(NamedTuple): 15 | name: str 16 | index: int # Represents the order of the columns, starting from the left 17 | column_opts: Optional[dict] = None 18 | 19 | def __hash__(self) -> int: 20 | return hash((self.name, self.index)) 21 | 22 | def __eq__(self, other: object) -> bool: 23 | if not isinstance(other, ColumnInfo): 24 | return NotImplemented 25 | return (self.name, self.index) == (other.name, other.index) 26 | -------------------------------------------------------------------------------- /cycode/cli/printers/tables/table_printer_base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from cycode.cli.models import CliError, CliResult 5 | from cycode.cli.printers.printer_base import PrinterBase 6 | from cycode.cli.printers.text_printer import TextPrinter 7 | 8 | if TYPE_CHECKING: 9 | from cycode.cli.models import LocalScanResult 10 | from cycode.cli.printers.tables.table import Table 11 | 12 | 13 | class TablePrinterBase(PrinterBase, abc.ABC): 14 | def __init__(self, *args, **kwargs) -> None: 15 | super().__init__(*args, **kwargs) 16 | self.text_printer = TextPrinter(self.ctx, self.console, self.console_err) 17 | 18 | def print_result(self, result: CliResult) -> None: 19 | self.text_printer.print_result(result) 20 | 21 | def print_error(self, error: CliError) -> None: 22 | self.text_printer.print_error(error) 23 | 24 | def print_scan_results( 25 | self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None 26 | ) -> None: 27 | if not errors and all(result.issue_detected == 0 for result in local_scan_results): 28 | self.console.print(self.NO_DETECTIONS_MESSAGE) 29 | return 30 | 31 | self._print_results(local_scan_results) 32 | 33 | self.print_scan_results_summary(local_scan_results) 34 | self.text_printer.print_report_urls_and_errors(local_scan_results, errors) 35 | 36 | @abc.abstractmethod 37 | def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: 38 | raise NotImplementedError 39 | 40 | def _print_table(self, table: 'Table') -> None: 41 | if table.get_rows(): 42 | self.console.print(table.get_table()) 43 | -------------------------------------------------------------------------------- /cycode/cli/printers/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from cycode.cli import consts 2 | 3 | 4 | def is_git_diff_based_scan(scan_type: str, command_scan_type: str) -> bool: 5 | return ( 6 | command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES 7 | and scan_type in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES 8 | ) 9 | -------------------------------------------------------------------------------- /cycode/cli/printers/utils/detection_ordering/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/printers/utils/detection_ordering/__init__.py -------------------------------------------------------------------------------- /cycode/cli/printers/utils/detection_ordering/common_ordering.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from cycode.cli.cli_types import SeverityOption 4 | 5 | if TYPE_CHECKING: 6 | from cycode.cli.models import Document, LocalScanResult 7 | from cycode.cyclient.models import Detection 8 | 9 | 10 | GroupedDetections = tuple[list[tuple['Detection', 'Document']], set[int]] 11 | 12 | 13 | def __severity_sort_key(detection_with_document: tuple['Detection', 'Document']) -> int: 14 | detection, _ = detection_with_document 15 | severity = detection.severity if detection.severity else '' 16 | return SeverityOption.get_member_weight(severity) 17 | 18 | 19 | def _sort_detections_by_severity( 20 | detections_with_documents: list[tuple['Detection', 'Document']], 21 | ) -> list[tuple['Detection', 'Document']]: 22 | return sorted(detections_with_documents, key=__severity_sort_key, reverse=True) 23 | 24 | 25 | def __file_path_sort_key(detection_with_document: tuple['Detection', 'Document']) -> str: 26 | _, document = detection_with_document 27 | return document.path 28 | 29 | 30 | def _sort_detections_by_file_path( 31 | detections_with_documents: list[tuple['Detection', 'Document']], 32 | ) -> list[tuple['Detection', 'Document']]: 33 | return sorted(detections_with_documents, key=__file_path_sort_key) 34 | 35 | 36 | def sort_and_group_detections( 37 | detections_with_documents: list[tuple['Detection', 'Document']], 38 | ) -> GroupedDetections: 39 | """Sort detections by severity. We do not have grouping here (don't find the best one yet).""" 40 | group_separator_indexes = set() 41 | 42 | # we sort detections by file path to make persist output order 43 | sorted_by_path_detections = _sort_detections_by_file_path(detections_with_documents) 44 | sorted_by_severity = _sort_detections_by_severity(sorted_by_path_detections) 45 | 46 | return sorted_by_severity, group_separator_indexes 47 | 48 | 49 | def sort_and_group_detections_from_scan_result(local_scan_results: list['LocalScanResult']) -> GroupedDetections: 50 | detections_with_documents = [] 51 | for local_scan_result in local_scan_results: 52 | for document_detections in local_scan_result.document_detections: 53 | detections_with_documents.extend( 54 | [(detection, document_detections.document) for detection in document_detections.detections] 55 | ) 56 | 57 | return sort_and_group_detections(detections_with_documents) 58 | -------------------------------------------------------------------------------- /cycode/cli/printers/utils/detection_ordering/sca_ordering.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import TYPE_CHECKING 3 | 4 | from cycode.cli.cli_types import SeverityOption 5 | 6 | if TYPE_CHECKING: 7 | from cycode.cyclient.models import Detection 8 | 9 | 10 | def __group_by(detections: list['Detection'], details_field_name: str) -> dict[str, list['Detection']]: 11 | grouped = defaultdict(list) 12 | for detection in detections: 13 | grouped[detection.detection_details.get(details_field_name)].append(detection) 14 | return grouped 15 | 16 | 17 | def __severity_sort_key(detection: 'Detection') -> int: 18 | severity = detection.severity if detection.severity else 'unknown' 19 | return SeverityOption.get_member_weight(severity) 20 | 21 | 22 | def _sort_detections_by_severity(detections: list['Detection']) -> list['Detection']: 23 | return sorted(detections, key=__severity_sort_key, reverse=True) 24 | 25 | 26 | def __package_sort_key(detection: 'Detection') -> int: 27 | return detection.detection_details.get('package_name') 28 | 29 | 30 | def _sort_detections_by_package(detections: list['Detection']) -> list['Detection']: 31 | return sorted(detections, key=__package_sort_key) 32 | 33 | 34 | def sort_and_group_detections(detections: list['Detection']) -> tuple[list['Detection'], set[int]]: 35 | """Sort detections by severity and group by repository, code project and package name. 36 | 37 | Note: 38 | Code Project is path to the manifest file. 39 | 40 | Grouping by code projects also groups by ecosystem. 41 | Because manifest files are unique per ecosystem. 42 | 43 | """ 44 | resulting_detections = [] 45 | group_separator_indexes = set() 46 | 47 | # we sort detections by package name to make persist output order 48 | sorted_detections = _sort_detections_by_package(detections) 49 | 50 | grouped_by_repository = __group_by(sorted_detections, 'repository_name') 51 | for repository_group in grouped_by_repository.values(): 52 | grouped_by_code_project = __group_by(repository_group, 'file_name') 53 | for code_project_group in grouped_by_code_project.values(): 54 | grouped_by_package = __group_by(code_project_group, 'package_name') 55 | for package_group in grouped_by_package.values(): 56 | group_separator_indexes.add(len(resulting_detections) - 1) # indexing starts from 0 57 | resulting_detections.extend(_sort_detections_by_severity(package_group)) 58 | 59 | return resulting_detections, group_separator_indexes 60 | -------------------------------------------------------------------------------- /cycode/cli/printers/utils/rich_helpers.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from rich.columns import Columns 4 | from rich.markdown import Markdown 5 | from rich.panel import Panel 6 | 7 | from cycode.cli.console import console 8 | 9 | if TYPE_CHECKING: 10 | from rich.console import RenderableType 11 | 12 | 13 | def get_panel(renderable: 'RenderableType', title: str) -> Panel: 14 | return Panel( 15 | renderable, 16 | title=title, 17 | title_align='left', 18 | border_style='dim', 19 | ) 20 | 21 | 22 | def get_markdown_panel(markdown_text: str, title: str) -> Panel: 23 | return get_panel( 24 | Markdown(markdown_text.strip()), 25 | title=title, 26 | ) 27 | 28 | 29 | def get_columns_in_1_to_3_ratio(left: 'Panel', right: 'Panel', panel_border_offset: int = 5) -> Columns: 30 | terminal_width = console.width 31 | one_third_width = terminal_width // 3 32 | two_thirds_width = terminal_width - one_third_width - panel_border_offset 33 | 34 | left.width = one_third_width 35 | right.width = two_thirds_width 36 | 37 | return Columns([left, right]) 38 | -------------------------------------------------------------------------------- /cycode/cli/user_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/user_settings/__init__.py -------------------------------------------------------------------------------- /cycode/cli/user_settings/base_file_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC, abstractmethod 3 | from collections.abc import Hashable 4 | from typing import Any 5 | 6 | from cycode.cli.utils.yaml_utils import read_yaml_file, update_yaml_file 7 | 8 | 9 | class BaseFileManager(ABC): 10 | @abstractmethod 11 | def get_filename(self) -> str: ... 12 | 13 | def read_file(self) -> dict[Hashable, Any]: 14 | return read_yaml_file(self.get_filename()) 15 | 16 | def write_content_to_file(self, content: dict[Hashable, Any]) -> None: 17 | filename = self.get_filename() 18 | os.makedirs(os.path.dirname(filename), exist_ok=True) 19 | update_yaml_file(filename, content) 20 | -------------------------------------------------------------------------------- /cycode/cli/user_settings/credentials_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME 6 | from cycode.cli.user_settings.base_file_manager import BaseFileManager 7 | from cycode.cli.user_settings.jwt_creator import JwtCreator 8 | from cycode.cli.utils.sentry import setup_scope_from_access_token 9 | 10 | 11 | class CredentialsManager(BaseFileManager): 12 | HOME_PATH: str = Path.home() 13 | CYCODE_HIDDEN_DIRECTORY: str = '.cycode' 14 | FILE_NAME: str = 'credentials.yaml' 15 | 16 | CLIENT_ID_FIELD_NAME: str = 'cycode_client_id' 17 | CLIENT_SECRET_FIELD_NAME: str = 'cycode_client_secret' 18 | ACCESS_TOKEN_FIELD_NAME: str = 'cycode_access_token' 19 | ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: str = 'cycode_access_token_expires_in' 20 | ACCESS_TOKEN_CREATOR_FIELD_NAME: str = 'cycode_access_token_creator' 21 | 22 | def get_credentials(self) -> tuple[str, str]: 23 | client_id, client_secret = self.get_credentials_from_environment_variables() 24 | if client_id is not None and client_secret is not None: 25 | return client_id, client_secret 26 | 27 | return self.get_credentials_from_file() 28 | 29 | @staticmethod 30 | def get_credentials_from_environment_variables() -> tuple[str, str]: 31 | client_id = os.getenv(CYCODE_CLIENT_ID_ENV_VAR_NAME) 32 | client_secret = os.getenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME) 33 | return client_id, client_secret 34 | 35 | def get_credentials_from_file(self) -> tuple[Optional[str], Optional[str]]: 36 | file_content = self.read_file() 37 | client_id = file_content.get(self.CLIENT_ID_FIELD_NAME) 38 | client_secret = file_content.get(self.CLIENT_SECRET_FIELD_NAME) 39 | return client_id, client_secret 40 | 41 | def update_credentials(self, client_id: str, client_secret: str) -> None: 42 | file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} 43 | self.write_content_to_file(file_content_to_update) 44 | 45 | def get_access_token(self) -> tuple[Optional[str], Optional[float], Optional[JwtCreator]]: 46 | file_content = self.read_file() 47 | 48 | access_token = file_content.get(self.ACCESS_TOKEN_FIELD_NAME) 49 | expires_in = file_content.get(self.ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME) 50 | 51 | creator = None 52 | hashed_creator = file_content.get(self.ACCESS_TOKEN_CREATOR_FIELD_NAME) 53 | if hashed_creator: 54 | creator = JwtCreator(hashed_creator) 55 | 56 | setup_scope_from_access_token(access_token) 57 | 58 | return access_token, expires_in, creator 59 | 60 | def update_access_token( 61 | self, access_token: Optional[str], expires_in: Optional[float], creator: Optional[JwtCreator] 62 | ) -> None: 63 | file_content_to_update = { 64 | self.ACCESS_TOKEN_FIELD_NAME: access_token, 65 | self.ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: expires_in, 66 | self.ACCESS_TOKEN_CREATOR_FIELD_NAME: str(creator) if creator else None, 67 | } 68 | self.write_content_to_file(file_content_to_update) 69 | 70 | setup_scope_from_access_token(access_token) 71 | 72 | def get_filename(self) -> str: 73 | return os.path.join(self.HOME_PATH, self.CYCODE_HIDDEN_DIRECTORY, self.FILE_NAME) 74 | -------------------------------------------------------------------------------- /cycode/cli/user_settings/jwt_creator.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.utils.string_utils import hash_string_to_sha256 2 | 3 | _SEPARATOR = '::' 4 | 5 | 6 | def _get_hashed_creator(client_id: str, client_secret: str) -> str: 7 | return hash_string_to_sha256(_SEPARATOR.join([client_id, client_secret])) 8 | 9 | 10 | class JwtCreator: 11 | def __init__(self, hashed_creator: str) -> None: 12 | self._hashed_creator = hashed_creator 13 | 14 | def __str__(self) -> str: 15 | return self._hashed_creator 16 | 17 | @classmethod 18 | def create(cls, client_id: str, client_secret: str) -> 'JwtCreator': 19 | return cls(_get_hashed_creator(client_id, client_secret)) 20 | 21 | def __eq__(self, other: 'JwtCreator') -> bool: 22 | if not isinstance(other, JwtCreator): 23 | return NotImplemented 24 | return str(self) == str(other) 25 | -------------------------------------------------------------------------------- /cycode/cli/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cli/utils/__init__.py -------------------------------------------------------------------------------- /cycode/cli/utils/enum_utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class AutoCountEnum(Enum): 5 | @staticmethod 6 | def _generate_next_value_(name: str, start: int, count: int, last_values: list[int]) -> int: 7 | return count 8 | -------------------------------------------------------------------------------- /cycode/cli/utils/get_api_client.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional, Union 2 | 3 | import click 4 | 5 | from cycode.cli.user_settings.credentials_manager import CredentialsManager 6 | from cycode.cyclient.client_creator import create_report_client, create_scan_client 7 | 8 | if TYPE_CHECKING: 9 | import typer 10 | 11 | from cycode.cyclient.report_client import ReportClient 12 | from cycode.cyclient.scan_client import ScanClient 13 | 14 | 15 | def _get_cycode_client( 16 | create_client_func: callable, client_id: Optional[str], client_secret: Optional[str], hide_response_log: bool 17 | ) -> Union['ScanClient', 'ReportClient']: 18 | if not client_id or not client_secret: 19 | client_id, client_secret = _get_configured_credentials() 20 | if not client_id: 21 | raise click.ClickException('Cycode client id needed.') 22 | if not client_secret: 23 | raise click.ClickException('Cycode client secret is needed.') 24 | 25 | return create_client_func(client_id, client_secret, hide_response_log) 26 | 27 | 28 | def get_scan_cycode_client(ctx: 'typer.Context') -> 'ScanClient': 29 | client_id = ctx.obj.get('client_id') 30 | client_secret = ctx.obj.get('client_secret') 31 | hide_response_log = not ctx.obj.get('show_secret', False) 32 | return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log) 33 | 34 | 35 | def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ReportClient': 36 | client_id = ctx.obj.get('client_id') 37 | client_secret = ctx.obj.get('client_secret') 38 | return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) 39 | 40 | 41 | def _get_configured_credentials() -> tuple[str, str]: 42 | credentials_manager = CredentialsManager() 43 | return credentials_manager.get_credentials() 44 | -------------------------------------------------------------------------------- /cycode/cli/utils/git_proxy.py: -------------------------------------------------------------------------------- 1 | import types 2 | from abc import ABC, abstractmethod 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | _GIT_ERROR_MESSAGE = """ 6 | Cycode CLI needs the Git executable to be installed on the system. 7 | Git executable must be available in the PATH. 8 | Git 1.7.x or newer is required. 9 | You can help Cycode CLI to locate the Git executable 10 | by setting the GIT_PYTHON_GIT_EXECUTABLE= environment variable. 11 | """.strip().replace('\n', ' ') 12 | 13 | try: 14 | import git 15 | except ImportError: 16 | git = None 17 | 18 | if TYPE_CHECKING: 19 | from git import PathLike, Repo 20 | 21 | 22 | class GitProxyError(Exception): 23 | pass 24 | 25 | 26 | class _AbstractGitProxy(ABC): 27 | @abstractmethod 28 | def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': ... 29 | 30 | @abstractmethod 31 | def get_null_tree(self) -> object: ... 32 | 33 | @abstractmethod 34 | def get_invalid_git_repository_error(self) -> type[BaseException]: ... 35 | 36 | @abstractmethod 37 | def get_git_command_error(self) -> type[BaseException]: ... 38 | 39 | 40 | class _DummyGitProxy(_AbstractGitProxy): 41 | def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': 42 | raise RuntimeError(_GIT_ERROR_MESSAGE) 43 | 44 | def get_null_tree(self) -> object: 45 | raise RuntimeError(_GIT_ERROR_MESSAGE) 46 | 47 | def get_invalid_git_repository_error(self) -> type[BaseException]: 48 | return GitProxyError 49 | 50 | def get_git_command_error(self) -> type[BaseException]: 51 | return GitProxyError 52 | 53 | 54 | class _GitProxy(_AbstractGitProxy): 55 | def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': 56 | return git.Repo(path, *args, **kwargs) 57 | 58 | def get_null_tree(self) -> object: 59 | return git.NULL_TREE 60 | 61 | def get_invalid_git_repository_error(self) -> type[BaseException]: 62 | return git.InvalidGitRepositoryError 63 | 64 | def get_git_command_error(self) -> type[BaseException]: 65 | return git.GitCommandError 66 | 67 | 68 | def get_git_proxy(git_module: Optional[types.ModuleType]) -> _AbstractGitProxy: 69 | return _GitProxy() if git_module else _DummyGitProxy() 70 | 71 | 72 | class GitProxyManager(_AbstractGitProxy): 73 | """We are using this manager for easy unit testing and mocking of the git module.""" 74 | 75 | def __init__(self) -> None: 76 | self._git_proxy = get_git_proxy(git) 77 | 78 | def _set_dummy_git_proxy(self) -> None: 79 | self._git_proxy = _DummyGitProxy() 80 | 81 | def _set_git_proxy(self) -> None: 82 | self._git_proxy = _GitProxy() 83 | 84 | def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': 85 | return self._git_proxy.get_repo(path, *args, **kwargs) 86 | 87 | def get_null_tree(self) -> object: 88 | return self._git_proxy.get_null_tree() 89 | 90 | def get_invalid_git_repository_error(self) -> type[BaseException]: 91 | return self._git_proxy.get_invalid_git_repository_error() 92 | 93 | def get_git_command_error(self) -> type[BaseException]: 94 | return self._git_proxy.get_git_command_error() 95 | 96 | 97 | git_proxy = GitProxyManager() 98 | -------------------------------------------------------------------------------- /cycode/cli/utils/jwt_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import jwt 4 | 5 | _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id') 6 | 7 | 8 | def get_user_and_tenant_ids_from_access_token(access_token: str) -> tuple[Optional[str], Optional[str]]: 9 | payload = jwt.decode(access_token, options={'verify_signature': False}) 10 | 11 | user_id = None 12 | for field in _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES: 13 | user_id = payload.get(field) 14 | if user_id: 15 | break 16 | 17 | tenant_id = payload.get('tenantId') 18 | 19 | return user_id, tenant_id 20 | -------------------------------------------------------------------------------- /cycode/cli/utils/path_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from functools import cache 4 | from typing import TYPE_CHECKING, AnyStr, Optional, Union 5 | 6 | import typer 7 | from binaryornot.helpers import is_binary_string 8 | 9 | from cycode.cli.logger import logger 10 | 11 | if TYPE_CHECKING: 12 | from os import PathLike 13 | 14 | 15 | @cache 16 | def is_sub_path(path: str, sub_path: str) -> bool: 17 | try: 18 | common_path = os.path.commonpath([get_absolute_path(path), get_absolute_path(sub_path)]) 19 | return path == common_path 20 | except ValueError: 21 | # if paths are on the different drives 22 | return False 23 | 24 | 25 | def get_absolute_path(path: str) -> str: 26 | if path.startswith('~'): 27 | return os.path.expanduser(path) 28 | return os.path.abspath(path) 29 | 30 | 31 | def _get_starting_chunk(filename: str, length: int = 1024) -> Optional[bytes]: 32 | # We are using our own implementation of get_starting_chunk 33 | # because the original one from binaryornot uses print()... 34 | 35 | try: 36 | with open(filename, 'rb') as f: 37 | return f.read(length) 38 | except OSError as e: 39 | logger.debug('Failed to read the starting chunk from file: %s', filename, exc_info=e) 40 | 41 | return None 42 | 43 | 44 | def is_binary_file(filename: str) -> bool: 45 | # Check if the file extension is in a list of known binary types 46 | binary_extensions = ('.pyc',) 47 | if filename.endswith(binary_extensions): 48 | return True 49 | 50 | # Check if the starting chunk is a binary string 51 | chunk = _get_starting_chunk(filename) 52 | return is_binary_string(chunk) 53 | 54 | 55 | def get_file_size(filename: str) -> int: 56 | return os.path.getsize(filename) 57 | 58 | 59 | def get_path_by_os(filename: str) -> str: 60 | return filename.replace('/', os.sep) 61 | 62 | 63 | def is_path_exists(path: str) -> bool: 64 | return os.path.exists(path) 65 | 66 | 67 | def get_file_dir(path: str) -> str: 68 | return os.path.dirname(path) 69 | 70 | 71 | def get_immediate_subdirectories(path: str) -> list[str]: 72 | return [f.name for f in os.scandir(path) if f.is_dir()] 73 | 74 | 75 | def join_paths(path: str, filename: str) -> str: 76 | return os.path.join(path, filename) 77 | 78 | 79 | def get_file_content(file_path: Union[str, 'PathLike']) -> Optional[AnyStr]: 80 | try: 81 | with open(file_path, encoding='UTF-8') as f: 82 | return f.read() 83 | except (FileNotFoundError, UnicodeDecodeError): 84 | return None 85 | except PermissionError: 86 | logger.warn('Permission denied to read the file: %s', file_path) 87 | 88 | 89 | def load_json(txt: str) -> Optional[dict]: 90 | try: 91 | return json.loads(txt) 92 | except json.JSONDecodeError: 93 | return None 94 | 95 | 96 | def change_filename_extension(filename: str, extension: str) -> str: 97 | base_name, _ = os.path.splitext(filename) 98 | return f'{base_name}.{extension}' 99 | 100 | 101 | def concat_unique_id(filename: str, unique_id: str) -> str: 102 | if filename.startswith(os.sep): 103 | # remove leading slash to join the path correctly 104 | filename = filename[len(os.sep) :] 105 | 106 | return os.path.join(unique_id, filename) 107 | 108 | 109 | def get_path_from_context(ctx: typer.Context) -> Optional[str]: 110 | path = ctx.params.get('path') 111 | if path is None and 'paths' in ctx.params: 112 | path = ctx.params['paths'][0] 113 | return path 114 | -------------------------------------------------------------------------------- /cycode/cli/utils/scan_utils.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | 4 | def set_issue_detected(ctx: typer.Context, issue_detected: bool) -> None: 5 | ctx.obj['issue_detected'] = issue_detected 6 | 7 | 8 | def is_scan_failed(ctx: typer.Context) -> bool: 9 | did_fail = ctx.obj.get('did_fail') 10 | issue_detected = ctx.obj.get('issue_detected') 11 | return did_fail or issue_detected 12 | -------------------------------------------------------------------------------- /cycode/cli/utils/shell_executor.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from typing import Optional, Union 3 | 4 | import click 5 | import typer 6 | 7 | from cycode.logger import get_logger 8 | 9 | _SUBPROCESS_DEFAULT_TIMEOUT_SEC = 60 10 | 11 | 12 | logger = get_logger('SHELL') 13 | 14 | 15 | def shell( 16 | command: Union[str, list[str]], 17 | timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, 18 | working_directory: Optional[str] = None, 19 | silent_exc_info: bool = False, 20 | ) -> Optional[str]: 21 | logger.debug('Executing shell command: %s', command) 22 | 23 | try: 24 | result = subprocess.run( # noqa: S603 25 | command, cwd=working_directory, timeout=timeout, check=True, capture_output=True 26 | ) 27 | logger.debug('Shell command executed successfully') 28 | 29 | return result.stdout.decode('UTF-8').strip() 30 | except subprocess.CalledProcessError as e: 31 | if not silent_exc_info: 32 | logger.debug('Error occurred while running shell command', exc_info=e) 33 | except subprocess.TimeoutExpired as e: 34 | logger.debug('Command timed out', exc_info=e) 35 | raise typer.Abort(f'Command "{command}" timed out') from e 36 | except Exception as e: 37 | if not silent_exc_info: 38 | logger.debug('Unhandled exception occurred while running shell command', exc_info=e) 39 | 40 | raise click.ClickException(f'Unhandled exception: {e}') from e 41 | 42 | return None 43 | -------------------------------------------------------------------------------- /cycode/cli/utils/string_utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import math 3 | import random 4 | import re 5 | import string 6 | from sys import getsizeof 7 | 8 | from binaryornot.check import is_binary_string 9 | 10 | from cycode.cli.consts import SCA_SHORTCUT_DEPENDENCY_PATHS 11 | 12 | 13 | def obfuscate_text(text: str) -> str: 14 | match_len = len(text) 15 | start_reveled_len = math.ceil(match_len / 8) 16 | end_reveled_len = match_len - (math.ceil(match_len / 8)) 17 | 18 | obfuscated = obfuscate_regex.sub('*', text) 19 | 20 | return f'{text[:start_reveled_len]}{obfuscated[start_reveled_len:end_reveled_len]}{text[end_reveled_len:]}' 21 | 22 | 23 | obfuscate_regex = re.compile(r'[^+\-\s]') 24 | 25 | 26 | def is_binary_content(content: str) -> bool: 27 | """Get the first 1024 chars and check if it's binary or not.""" 28 | chunk = content[:1024] 29 | chunk_bytes = convert_string_to_bytes(chunk) 30 | return is_binary_string(chunk_bytes) 31 | 32 | 33 | def get_content_size(content: str) -> int: 34 | return getsizeof(content) 35 | 36 | 37 | def convert_string_to_bytes(content: str) -> bytes: 38 | return bytes(content, 'UTF-8') 39 | 40 | 41 | def hash_string_to_sha256(content: str) -> str: 42 | return hashlib.sha256(content.encode()).hexdigest() 43 | 44 | 45 | def generate_random_string(string_len: int) -> str: 46 | # letters, digits, and symbols 47 | characters = string.ascii_letters + string.digits + string.punctuation 48 | return ''.join(random.choice(characters) for _ in range(string_len)) # noqa: S311 49 | 50 | 51 | def get_position_in_line(text: str, position: int) -> int: 52 | return position - text.rfind('\n', 0, position) - 1 53 | 54 | 55 | def shortcut_dependency_paths(dependency_paths_list: str) -> str: 56 | separate_dependency_paths_list = dependency_paths_list.split(',') 57 | result = '' 58 | for dependency_paths in separate_dependency_paths_list: 59 | dependency_paths = dependency_paths.strip().rstrip() 60 | dependencies = dependency_paths.split(' -> ') 61 | if len(dependencies) <= SCA_SHORTCUT_DEPENDENCY_PATHS: 62 | result += dependency_paths 63 | else: 64 | result += f'{dependencies[0]} -> ... -> {dependencies[-1]}' 65 | result += '\n' 66 | 67 | return result.rstrip().rstrip(',') 68 | -------------------------------------------------------------------------------- /cycode/cli/utils/task_timer.py: -------------------------------------------------------------------------------- 1 | from _thread import interrupt_main 2 | from threading import Event, Thread 3 | from types import TracebackType 4 | from typing import Callable, Optional 5 | 6 | 7 | class FunctionContext: 8 | def __init__(self, function: Callable, args: Optional[list] = None, kwargs: Optional[dict] = None) -> None: 9 | self.function = function 10 | self.args = args or [] 11 | self.kwargs = kwargs or {} 12 | 13 | 14 | class TimerThread(Thread): 15 | """Custom thread class for executing timer in the background. 16 | 17 | Members: 18 | timeout - the amount of time to count until timeout in seconds 19 | quit_function (Mandatory) - function to perform when reaching to timeout 20 | """ 21 | 22 | def __init__(self, timeout: int, quit_function: FunctionContext) -> None: 23 | Thread.__init__(self) 24 | self._timeout = timeout 25 | self._quit_function = quit_function 26 | self.event = Event() 27 | 28 | def run(self) -> None: 29 | self._run_quit_function_on_timeout() 30 | 31 | def stop(self) -> None: 32 | self.event.set() 33 | 34 | def _run_quit_function_on_timeout(self) -> None: 35 | self.event.wait(self._timeout) 36 | if not self.event.is_set(): 37 | self._call_quit_function() 38 | self.stop() 39 | 40 | def _call_quit_function(self) -> None: 41 | self._quit_function.function(*self._quit_function.args, **self._quit_function.kwargs) 42 | 43 | 44 | class TimeoutAfter: 45 | """A task wrapper for controlling how much time a task should be run before timing out. 46 | 47 | Use Example: 48 | with TimeoutAfter(5, repeat_function=FunctionContext(x), repeat_interval=2): 49 | 50 | 51 | Members: 52 | timeout - the amount of time to count until timeout in seconds 53 | quit_function (Optional) - function to perform when reaching to timeout, 54 | the default option is to interrupt main thread 55 | """ 56 | 57 | def __init__(self, timeout: int, quit_function: Optional[FunctionContext] = None) -> None: 58 | self.timeout = timeout 59 | self._quit_function = quit_function or FunctionContext(function=self.timeout_function) 60 | self.timer = TimerThread(timeout, quit_function=self._quit_function) 61 | 62 | def __enter__(self) -> None: 63 | if self.timeout: 64 | self.timer.start() 65 | 66 | def __exit__( 67 | self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] 68 | ) -> None: 69 | if self.timeout: 70 | self.timer.stop() 71 | 72 | # catch the exception of interrupt_main before exiting 73 | # the with statement and throw timeout error instead 74 | if exc_type is KeyboardInterrupt: 75 | raise TimeoutError(f'Task timed out after {self.timeout} seconds') 76 | 77 | def timeout_function(self) -> None: 78 | interrupt_main() 79 | -------------------------------------------------------------------------------- /cycode/cli/utils/yaml_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Hashable 3 | from typing import Any, TextIO 4 | 5 | import yaml 6 | 7 | 8 | def _deep_update(source: dict[Hashable, Any], overrides: dict[Hashable, Any]) -> dict[Hashable, Any]: 9 | for key, value in overrides.items(): 10 | if isinstance(value, dict) and value: 11 | source[key] = _deep_update(source.get(key, {}), value) 12 | else: 13 | source[key] = overrides[key] 14 | 15 | return source 16 | 17 | 18 | def _yaml_safe_load(file: TextIO) -> dict[Hashable, Any]: 19 | # loader.get_single_data could return None 20 | loaded_file = yaml.safe_load(file) 21 | if loaded_file is None: 22 | return {} 23 | 24 | return loaded_file 25 | 26 | 27 | def read_yaml_file(filename: str) -> dict[Hashable, Any]: 28 | if not os.path.exists(filename): 29 | return {} 30 | 31 | with open(filename, encoding='UTF-8') as file: 32 | return _yaml_safe_load(file) 33 | 34 | 35 | def write_yaml_file(filename: str, content: dict[Hashable, Any]) -> None: 36 | with open(filename, 'w', encoding='UTF-8') as file: 37 | yaml.safe_dump(content, file) 38 | 39 | 40 | def update_yaml_file(filename: str, content: dict[Hashable, Any]) -> None: 41 | write_yaml_file(filename, _deep_update(read_yaml_file(filename), content)) 42 | -------------------------------------------------------------------------------- /cycode/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Optional 4 | from urllib.parse import urlparse 5 | 6 | from cycode.cli import consts 7 | from cycode.cyclient import config_dev 8 | 9 | DEFAULT_CONFIGURATION = { 10 | consts.TIMEOUT_ENV_VAR_NAME: 300, 11 | consts.LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, 12 | config_dev.DEV_MODE_ENV_VAR_NAME: 'false', 13 | } 14 | 15 | configuration = dict(DEFAULT_CONFIGURATION, **os.environ) 16 | 17 | 18 | def get_val_as_string(key: str) -> str: 19 | return configuration.get(key) 20 | 21 | 22 | def get_val_as_bool(key: str, default: bool = False) -> bool: 23 | if key not in configuration: 24 | return default 25 | 26 | return configuration[key].lower() in {'true', '1', 'yes', 'y', 'on', 'enabled'} 27 | 28 | 29 | def get_val_as_int(key: str) -> Optional[int]: 30 | val = configuration.get(key) 31 | if not val: 32 | return None 33 | 34 | try: 35 | return int(val) 36 | except ValueError: 37 | return None 38 | 39 | 40 | def is_valid_url(url: str) -> bool: 41 | try: 42 | parsed_url = urlparse(url) 43 | return all([parsed_url.scheme, parsed_url.netloc]) 44 | except ValueError: 45 | return False 46 | -------------------------------------------------------------------------------- /cycode/cyclient/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/cycode/cyclient/__init__.py -------------------------------------------------------------------------------- /cycode/cyclient/auth_client.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from requests import Request, Response 4 | 5 | from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError 6 | from cycode.cyclient import config, models 7 | from cycode.cyclient.cycode_client import CycodeClient 8 | 9 | 10 | class AuthClient: 11 | AUTH_CONTROLLER_PATH = 'api/v1/device-auth' 12 | 13 | def __init__(self) -> None: 14 | self.cycode_client = CycodeClient() 15 | 16 | @staticmethod 17 | def build_login_url(code_challenge: str, session_id: str) -> str: 18 | query_params = {'source': 'cycode_cli', 'code_challenge': code_challenge, 'session_id': session_id} 19 | return Request(url=f'{config.cycode_app_url}/account/sign-in', params=query_params).prepare().url 20 | 21 | def start_session(self, code_challenge: str) -> models.AuthenticationSession: 22 | path = f'{self.AUTH_CONTROLLER_PATH}/start' 23 | body = {'code_challenge': code_challenge} 24 | response = self.cycode_client.post(url_path=path, body=body) 25 | return self.parse_start_session_response(response) 26 | 27 | def get_api_token(self, session_id: str, code_verifier: str) -> Optional[models.ApiTokenGenerationPollingResponse]: 28 | path = f'{self.AUTH_CONTROLLER_PATH}/token' 29 | body = {'session_id': session_id, 'code_verifier': code_verifier} 30 | try: 31 | response = self.cycode_client.post(url_path=path, body=body, hide_response_content_log=True) 32 | return self.parse_api_token_polling_response(response) 33 | except (RequestHttpError, HttpUnauthorizedError) as e: 34 | return self.parse_api_token_polling_response(e.response) 35 | except Exception: 36 | return None 37 | 38 | @staticmethod 39 | def parse_start_session_response(response: Response) -> models.AuthenticationSession: 40 | return models.AuthenticationSessionSchema().load(response.json()) 41 | 42 | @staticmethod 43 | def parse_api_token_polling_response(response: Response) -> Optional[models.ApiTokenGenerationPollingResponse]: 44 | try: 45 | return models.ApiTokenGenerationPollingResponseSchema().load(response.json()) 46 | except Exception: 47 | return None 48 | -------------------------------------------------------------------------------- /cycode/cyclient/client_creator.py: -------------------------------------------------------------------------------- 1 | from cycode.cyclient.config import dev_mode 2 | from cycode.cyclient.config_dev import DEV_CYCODE_API_URL 3 | from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient 4 | from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient 5 | from cycode.cyclient.report_client import ReportClient 6 | from cycode.cyclient.scan_client import ScanClient 7 | from cycode.cyclient.scan_config_base import DefaultScanConfig, DevScanConfig 8 | 9 | 10 | def create_scan_client(client_id: str, client_secret: str, hide_response_log: bool) -> ScanClient: 11 | if dev_mode: 12 | client = CycodeDevBasedClient(DEV_CYCODE_API_URL) 13 | scan_config = DevScanConfig() 14 | else: 15 | client = CycodeTokenBasedClient(client_id, client_secret) 16 | scan_config = DefaultScanConfig() 17 | 18 | return ScanClient(client, scan_config, hide_response_log) 19 | 20 | 21 | def create_report_client(client_id: str, client_secret: str, _: bool) -> ReportClient: 22 | client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) 23 | return ReportClient(client) 24 | -------------------------------------------------------------------------------- /cycode/cyclient/config.py: -------------------------------------------------------------------------------- 1 | from cycode.cli import consts 2 | from cycode.cli.user_settings.configuration_manager import ConfigurationManager 3 | from cycode.config import get_val_as_bool, get_val_as_int, get_val_as_string, is_valid_url 4 | from cycode.cyclient import config_dev 5 | from cycode.cyclient.logger import logger 6 | 7 | configuration_manager = ConfigurationManager() 8 | 9 | cycode_api_url = configuration_manager.get_cycode_api_url() 10 | if not is_valid_url(cycode_api_url): 11 | logger.warning( 12 | 'Invalid Cycode API URL: %s, using default value (%s)', cycode_api_url, consts.DEFAULT_CYCODE_API_URL 13 | ) 14 | cycode_api_url = consts.DEFAULT_CYCODE_API_URL 15 | 16 | 17 | cycode_app_url = configuration_manager.get_cycode_app_url() 18 | if not is_valid_url(cycode_app_url): 19 | logger.warning( 20 | 'Invalid Cycode APP URL: %s, using default value (%s)', cycode_app_url, consts.DEFAULT_CYCODE_APP_URL 21 | ) 22 | cycode_app_url = consts.DEFAULT_CYCODE_APP_URL 23 | 24 | 25 | def _is_on_premise_installation(cycode_domain: str) -> bool: 26 | return not cycode_api_url.endswith(cycode_domain) 27 | 28 | 29 | on_premise_installation = _is_on_premise_installation(consts.DEFAULT_CYCODE_DOMAIN) 30 | 31 | timeout = get_val_as_int(consts.CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) 32 | if not timeout: 33 | timeout = get_val_as_int(consts.TIMEOUT_ENV_VAR_NAME) 34 | 35 | dev_mode = get_val_as_bool(config_dev.DEV_MODE_ENV_VAR_NAME) 36 | dev_tenant_id = get_val_as_string(config_dev.DEV_TENANT_ID_ENV_VAR_NAME) 37 | -------------------------------------------------------------------------------- /cycode/cyclient/config_dev.py: -------------------------------------------------------------------------------- 1 | DEV_CYCODE_API_URL = 'http://localhost' 2 | DEV_MODE_ENV_VAR_NAME = 'DEV_MODE' 3 | DEV_TENANT_ID_ENV_VAR_NAME = 'DEV_TENANT_ID' 4 | -------------------------------------------------------------------------------- /cycode/cyclient/cycode_client.py: -------------------------------------------------------------------------------- 1 | from cycode.cyclient import config 2 | from cycode.cyclient.cycode_client_base import CycodeClientBase 3 | 4 | 5 | class CycodeClient(CycodeClientBase): 6 | def __init__(self) -> None: 7 | super().__init__(config.cycode_api_url) 8 | self.timeout = config.timeout 9 | -------------------------------------------------------------------------------- /cycode/cyclient/cycode_dev_based_client.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from cycode.cyclient.config import dev_tenant_id 4 | from cycode.cyclient.cycode_client_base import CycodeClientBase 5 | 6 | """ 7 | Send requests with api token 8 | """ 9 | 10 | 11 | class CycodeDevBasedClient(CycodeClientBase): 12 | def __init__(self, api_url: str) -> None: 13 | super().__init__(api_url) 14 | 15 | def get_request_headers(self, additional_headers: Optional[dict] = None, **_) -> dict[str, str]: 16 | headers = super().get_request_headers(additional_headers=additional_headers) 17 | headers['X-Tenant-Id'] = dev_tenant_id 18 | 19 | return headers 20 | 21 | def build_full_url(self, url: str, endpoint: str) -> str: 22 | return f'{url}:{endpoint}' 23 | -------------------------------------------------------------------------------- /cycode/cyclient/headers.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from typing import Optional 3 | from uuid import uuid4 4 | 5 | from cycode import __version__ 6 | from cycode.cli import consts 7 | from cycode.cli.user_settings.configuration_manager import ConfigurationManager 8 | from cycode.cli.utils.sentry import add_correlation_id_to_scope 9 | from cycode.cyclient.logger import logger 10 | 11 | 12 | def get_cli_user_agent() -> str: 13 | """Return base User-Agent of CLI. 14 | 15 | Example: CycodeCLI/0.2.3 (OS: Darwin; Arch: arm64; Python: 3.8.16; InstallID: *uuid4*) 16 | """ 17 | version = __version__ 18 | 19 | os = platform.system() 20 | arch = platform.machine() 21 | python_version = platform.python_version() 22 | 23 | install_id = ConfigurationManager().get_or_create_installation_id() 24 | 25 | return f'{consts.APP_NAME}/{version} (OS: {os}; Arch: {arch}; Python: {python_version}; InstallID: {install_id})' 26 | 27 | 28 | class _CorrelationId: 29 | _id: Optional[str] = None 30 | 31 | def get_correlation_id(self) -> str: 32 | """Get correlation ID. 33 | 34 | Notes: 35 | Used across all requests to correlate logs and metrics. 36 | It doesn't depend on client instances. 37 | Lifetime is the same as the process. 38 | 39 | """ 40 | if self._id is None: 41 | # example: 16fd2706-8baf-433b-82eb-8c7fada847da 42 | self._id = str(uuid4()) 43 | logger.debug('Correlation ID: %s', self._id) 44 | 45 | add_correlation_id_to_scope(self._id) 46 | 47 | return self._id 48 | 49 | 50 | get_correlation_id = _CorrelationId().get_correlation_id 51 | -------------------------------------------------------------------------------- /cycode/cyclient/logger.py: -------------------------------------------------------------------------------- 1 | from cycode.logger import get_logger 2 | 3 | logger = get_logger('CyClient') 4 | -------------------------------------------------------------------------------- /cycode/cyclient/scan_config_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from cycode.cli import consts 4 | 5 | 6 | class ScanConfigBase(ABC): 7 | @abstractmethod 8 | def get_service_name(self, scan_type: str) -> str: ... 9 | 10 | @staticmethod 11 | def get_async_scan_type(scan_type: str) -> str: 12 | if scan_type == consts.SECRET_SCAN_TYPE: 13 | return 'Secrets' 14 | if scan_type == consts.IAC_SCAN_TYPE: 15 | return 'InfraConfiguration' 16 | 17 | return scan_type.upper() 18 | 19 | @staticmethod 20 | def get_async_entity_type(scan_type: str) -> str: 21 | if scan_type == consts.SECRET_SCAN_TYPE: 22 | return 'ZippedFile' 23 | # we are migrating to "zippedfile" entity type. will be used later 24 | return 'repository' 25 | 26 | @abstractmethod 27 | def get_detections_prefix(self) -> str: ... 28 | 29 | 30 | class DevScanConfig(ScanConfigBase): 31 | def get_service_name(self, scan_type: str) -> str: 32 | return '5004' # scan service 33 | 34 | def get_detections_prefix(self) -> str: 35 | return '5016' # detections service 36 | 37 | 38 | class DefaultScanConfig(ScanConfigBase): 39 | def get_service_name(self, scan_type: str) -> str: 40 | return 'scans' # scan service 41 | 42 | def get_detections_prefix(self) -> str: 43 | return 'detections' 44 | -------------------------------------------------------------------------------- /cycode/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import NamedTuple, Optional, Union 4 | 5 | import click 6 | import typer 7 | from rich.logging import RichHandler 8 | 9 | from cycode.cli import consts 10 | from cycode.cli.console import console_err 11 | from cycode.config import get_val_as_string 12 | 13 | 14 | def _set_io_encodings() -> None: 15 | # set io encoding (for Windows) 16 | sys.stdout.reconfigure(encoding='UTF-8') 17 | sys.stderr.reconfigure(encoding='UTF-8') 18 | 19 | 20 | _set_io_encodings() 21 | 22 | _RICH_LOGGING_HANDLER = RichHandler(console=console_err, rich_tracebacks=True, tracebacks_suppress=[click, typer]) 23 | 24 | logging.basicConfig( 25 | level=logging.INFO, 26 | format='[%(name)s] %(message)s', 27 | handlers=[_RICH_LOGGING_HANDLER], 28 | ) 29 | 30 | logging.getLogger('urllib3').setLevel(logging.WARNING) 31 | logging.getLogger('werkzeug').setLevel(logging.WARNING) 32 | logging.getLogger('schedule').setLevel(logging.WARNING) 33 | logging.getLogger('kubernetes').setLevel(logging.WARNING) 34 | logging.getLogger('binaryornot').setLevel(logging.WARNING) 35 | logging.getLogger('chardet').setLevel(logging.WARNING) 36 | logging.getLogger('git.cmd').setLevel(logging.WARNING) 37 | logging.getLogger('git.util').setLevel(logging.WARNING) 38 | 39 | 40 | class CreatedLogger(NamedTuple): 41 | logger: logging.Logger 42 | control_level_in_runtime: bool 43 | 44 | 45 | _CREATED_LOGGERS: set[CreatedLogger] = set() 46 | 47 | 48 | def get_logger_level() -> Optional[Union[int, str]]: 49 | config_level = get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) 50 | return logging.getLevelName(config_level) 51 | 52 | 53 | def get_logger(logger_name: Optional[str] = None, control_level_in_runtime: bool = True) -> logging.Logger: 54 | new_logger = logging.getLogger(logger_name) 55 | new_logger.setLevel(get_logger_level()) 56 | 57 | _CREATED_LOGGERS.add(CreatedLogger(logger=new_logger, control_level_in_runtime=control_level_in_runtime)) 58 | 59 | return new_logger 60 | 61 | 62 | def set_logging_level(level: int) -> None: 63 | for created_logger in _CREATED_LOGGERS: 64 | if created_logger.control_level_in_runtime: 65 | created_logger.logger.setLevel(level) 66 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | com.apple.security.cs.disable-library-validation 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /images/allow_cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/allow_cli.png -------------------------------------------------------------------------------- /images/authorize_cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/authorize_cli.png -------------------------------------------------------------------------------- /images/cycode_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/cycode_login.png -------------------------------------------------------------------------------- /images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/image1.png -------------------------------------------------------------------------------- /images/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/image2.png -------------------------------------------------------------------------------- /images/image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/image3.png -------------------------------------------------------------------------------- /images/image4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/image4.png -------------------------------------------------------------------------------- /images/new_settings_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/new_settings_screenshot.png -------------------------------------------------------------------------------- /images/on-demand-scans-main-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/on-demand-scans-main-page.png -------------------------------------------------------------------------------- /images/sca_report_url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/sca_report_url.png -------------------------------------------------------------------------------- /images/scan_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/scan_details.png -------------------------------------------------------------------------------- /images/successfully_auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/images/successfully_auth.png -------------------------------------------------------------------------------- /pyinstaller.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | # Run `poetry run pyinstaller pyinstaller.spec` to generate the binary. 3 | # Set the env var `CYCODE_ONEDIR_MODE` to generate a single directory instead of a single file. 4 | 5 | _INIT_FILE_PATH = os.path.join('cycode', '__init__.py') 6 | _CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME') 7 | _ONEDIR_MODE = os.environ.get('CYCODE_ONEDIR_MODE') is not None 8 | 9 | # save the prev content of __init__ file 10 | with open(_INIT_FILE_PATH, 'r', encoding='UTF-8') as file: 11 | prev_content = file.read() 12 | 13 | import dunamai as _dunamai 14 | 15 | VERSION_PLACEHOLDER = '0.0.0' 16 | CLI_VERSION = _dunamai.get_version('cycode', first_choice=_dunamai.Version.from_git).serialize( 17 | metadata=False, bump=True, style=_dunamai.Style.Pep440 18 | ) 19 | 20 | # write the version from Git Tag to freeze the value and don't depend on Git 21 | with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: 22 | file.write(prev_content.replace(VERSION_PLACEHOLDER, CLI_VERSION)) 23 | 24 | a = Analysis( 25 | scripts=['cycode/cli/main.py'], 26 | excludes=['tests'], 27 | ) 28 | 29 | exe_args = [PYZ(a.pure, a.zipped_data), a.scripts, a.binaries, a.zipfiles, a.datas] 30 | if _ONEDIR_MODE: 31 | exe_args = [PYZ(a.pure), a.scripts] 32 | 33 | exe = EXE( 34 | *exe_args, 35 | name='cycode-cli', 36 | exclude_binaries=bool(_ONEDIR_MODE), 37 | target_arch=None, 38 | codesign_identity=_CODESIGN_IDENTITY, 39 | entitlements_file='entitlements.plist', 40 | ) 41 | 42 | if _ONEDIR_MODE: 43 | coll = COLLECT(exe, a.binaries, a.datas, name='cycode-cli') 44 | 45 | # rollback the prev content of the __init__ file 46 | with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: 47 | file.write(prev_content) 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from cycode.cyclient.models import K8SResource 2 | 3 | PODS_MOCK = [ 4 | K8SResource('pod_name_1', 'pod', 'default', {}), 5 | K8SResource('pod_name_2', 'pod', 'default', {}), 6 | K8SResource('pod_name_3', 'pod', 'default', {}), 7 | K8SResource('pod_name_4', 'pod', 'default', {}), 8 | K8SResource('pod_name_5', 'pod', 'default', {}), 9 | K8SResource('cycode_pod_name_1', 'pod', 'cycode', {}), 10 | K8SResource('cycode_pod_name_2', 'pod', 'cycode', {}), 11 | K8SResource('cycode_pod_name_3', 'pod', 'cycode', {}), 12 | K8SResource('cycode_pod_name_4', 'pod', 'cycode', {}), 13 | K8SResource('cycode_pod_name_5', 'pod', 'cycode', {}), 14 | K8SResource('cycode_pod_name_6', 'pod', 'cycode', {}), 15 | ] 16 | 17 | POD_MOCK = { 18 | 'metadata': { 19 | 'name': 'pod-template-123xyz', 20 | 'namespace': 'default', 21 | 'ownerReferences': [ 22 | { 23 | 'kind': 'Deployment', 24 | 'name': 'nginx-deployment', 25 | } 26 | ], 27 | } 28 | } 29 | 30 | K8S_POD_MOCK = K8SResource('pod-template-123xyz', 'pod', 'default', POD_MOCK) 31 | -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/__init__.py -------------------------------------------------------------------------------- /tests/cli/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/commands/__init__.py -------------------------------------------------------------------------------- /tests/cli/commands/configure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/commands/configure/__init__.py -------------------------------------------------------------------------------- /tests/cli/commands/scan/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/commands/scan/__init__.py -------------------------------------------------------------------------------- /tests/cli/commands/test_check_latest_version_on_close.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from typer.testing import CliRunner 5 | 6 | from cycode import __version__ 7 | from cycode.cli.app import app 8 | from cycode.cli.cli_types import OutputTypeOption 9 | from cycode.cli.utils.version_checker import VersionChecker 10 | from tests.conftest import CLI_ENV_VARS 11 | 12 | _NEW_LATEST_VERSION = '999.0.0' # Simulate a newer version available 13 | _UPDATE_MESSAGE_PART = 'new release of cycode cli is available' 14 | 15 | 16 | @patch.object(VersionChecker, 'check_for_update') 17 | def test_version_check_with_json_output(mock_check_update: patch) -> None: 18 | # When output is JSON, version check should be skipped 19 | mock_check_update.return_value = _NEW_LATEST_VERSION 20 | 21 | args = ['--output', OutputTypeOption.JSON, 'version'] 22 | result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) 23 | 24 | # Version check message should not be present in JSON output 25 | assert _UPDATE_MESSAGE_PART not in result.output.lower() 26 | mock_check_update.assert_not_called() 27 | 28 | 29 | @pytest.fixture 30 | def mock_auth_info() -> 'patch': 31 | # Mock the authorization info to avoid API calls 32 | with patch('cycode.cli.apps.auth.auth_common.get_authorization_info', return_value=None) as mock: 33 | yield mock 34 | 35 | 36 | @pytest.mark.parametrize('command', ['version', 'status']) 37 | @patch.object(VersionChecker, 'check_for_update') 38 | def test_version_check_for_special_commands(mock_check_update: patch, mock_auth_info: patch, command: str) -> None: 39 | # Version and status commands should always check the version without cache 40 | mock_check_update.return_value = _NEW_LATEST_VERSION 41 | 42 | result = CliRunner().invoke(app, [command], env=CLI_ENV_VARS) 43 | 44 | # Version information should be present in output 45 | assert _UPDATE_MESSAGE_PART in result.output.lower() 46 | # Version check must be called without a cache 47 | mock_check_update.assert_called_once_with(__version__, False) 48 | 49 | 50 | @patch.object(VersionChecker, 'check_for_update') 51 | def test_version_check_with_text_output(mock_check_update: patch) -> None: 52 | # Regular commands with text output should check the version using cache 53 | mock_check_update.return_value = _NEW_LATEST_VERSION 54 | 55 | args = ['version'] 56 | result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) 57 | 58 | # Version check message should be present in JSON output 59 | assert _UPDATE_MESSAGE_PART in result.output.lower() 60 | 61 | 62 | @patch.object(VersionChecker, 'check_for_update') 63 | def test_version_check_disabled(mock_check_update: patch) -> None: 64 | # When --no-update-notifier is used, version check should be skipped 65 | mock_check_update.return_value = _NEW_LATEST_VERSION 66 | 67 | args = ['--no-update-notifier', 'version'] 68 | result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) 69 | 70 | # Version check message should not be present 71 | assert _UPDATE_MESSAGE_PART not in result.output.lower() 72 | mock_check_update.assert_not_called() 73 | -------------------------------------------------------------------------------- /tests/cli/commands/test_main_command.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING 3 | from uuid import uuid4 4 | 5 | import pytest 6 | import responses 7 | from typer.testing import CliRunner 8 | 9 | from cycode.cli import consts 10 | from cycode.cli.app import app 11 | from cycode.cli.cli_types import OutputTypeOption, ScanTypeOption 12 | from cycode.cli.utils.git_proxy import git_proxy 13 | from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH 14 | from tests.cyclient.mocked_responses.scan_client import mock_remote_config_responses, mock_scan_async_responses 15 | 16 | _PATH_TO_SCAN = TEST_FILES_PATH.joinpath('zip_content').absolute() 17 | 18 | if TYPE_CHECKING: 19 | from cycode.cyclient.scan_client import ScanClient 20 | 21 | 22 | def _is_json(plain: str) -> bool: 23 | try: 24 | json.loads(plain) 25 | return True 26 | except (ValueError, TypeError): 27 | return False 28 | 29 | 30 | @responses.activate 31 | @pytest.mark.parametrize('output', [OutputTypeOption.TEXT, OutputTypeOption.JSON]) 32 | def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token_response: responses.Response) -> None: 33 | scan_type = consts.SECRET_SCAN_TYPE 34 | scan_id = uuid4() 35 | 36 | mock_scan_async_responses(responses, scan_type, scan_client, scan_id, ZIP_CONTENT_PATH) 37 | responses.add(api_token_response) 38 | 39 | args = ['--output', output, 'scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] 40 | env = {'PYTEST_TEST_UNIQUE_ID': str(scan_id), **CLI_ENV_VARS} 41 | result = CliRunner().invoke(app, args, env=env) 42 | 43 | except_json = output == 'json' 44 | 45 | assert _is_json(result.output) == except_json 46 | 47 | if except_json: 48 | output = json.loads(result.output) 49 | assert 'scan_ids' in output 50 | else: 51 | assert 'violation:' in result.output 52 | 53 | 54 | @responses.activate 55 | def test_optional_git_with_path_scan(scan_client: 'ScanClient', api_token_response: responses.Response) -> None: 56 | mock_scan_async_responses(responses, consts.SECRET_SCAN_TYPE, scan_client, uuid4(), ZIP_CONTENT_PATH) 57 | responses.add(api_token_response) 58 | 59 | # fake env without Git executable 60 | git_proxy._set_dummy_git_proxy() 61 | 62 | args = ['--output', 'json', 'scan', 'path', str(_PATH_TO_SCAN)] 63 | result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) 64 | 65 | # do NOT expect error about not found Git executable 66 | assert 'GIT_PYTHON_GIT_EXECUTABLE' not in result.output 67 | 68 | # reset the git proxy 69 | git_proxy._set_git_proxy() 70 | 71 | 72 | @responses.activate 73 | def test_required_git_with_path_repository(scan_client: 'ScanClient', api_token_response: responses.Response) -> None: 74 | mock_remote_config_responses(responses, ScanTypeOption.SECRET, scan_client) 75 | responses.add(api_token_response) 76 | 77 | # fake env without Git executable 78 | git_proxy._set_dummy_git_proxy() 79 | 80 | args = ['--output', OutputTypeOption.JSON, 'scan', 'repository', str(_PATH_TO_SCAN)] 81 | result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) 82 | 83 | # expect error about not found Git executable 84 | assert 'GIT_PYTHON_GIT_EXECUTABLE' in result.output 85 | 86 | # reset the git proxy 87 | git_proxy._set_git_proxy() 88 | -------------------------------------------------------------------------------- /tests/cli/commands/version/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/commands/version/__init__.py -------------------------------------------------------------------------------- /tests/cli/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/exceptions/__init__.py -------------------------------------------------------------------------------- /tests/cli/exceptions/test_handle_scan_errors.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | import click 4 | import pytest 5 | import typer 6 | from requests import Response 7 | from rich.traceback import Traceback 8 | 9 | from cycode.cli.cli_types import OutputTypeOption 10 | from cycode.cli.console import console_err 11 | from cycode.cli.exceptions import custom_exceptions 12 | from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception 13 | from cycode.cli.printers import ConsolePrinter 14 | from cycode.cli.utils.git_proxy import git_proxy 15 | 16 | if TYPE_CHECKING: 17 | from _pytest.monkeypatch import MonkeyPatch 18 | 19 | 20 | @pytest.fixture 21 | def ctx() -> typer.Context: 22 | ctx = typer.Context(click.Command('path'), obj={'verbose': False, 'output': OutputTypeOption.TEXT}) 23 | ctx.obj['console_printer'] = ConsolePrinter(ctx) 24 | return ctx 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ('exception', 'expected_soft_fail'), 29 | [ 30 | (custom_exceptions.RequestHttpError(400, 'msg', Response()), True), 31 | (custom_exceptions.ScanAsyncError('msg'), True), 32 | (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), 33 | (custom_exceptions.ZipTooLargeError(1000), True), 34 | (custom_exceptions.TfplanKeyError('msg'), True), 35 | (git_proxy.get_invalid_git_repository_error()(), None), 36 | ], 37 | ) 38 | def test_handle_exception_soft_fail( 39 | ctx: typer.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool 40 | ) -> None: 41 | with ctx: 42 | handle_scan_exception(ctx, exception) 43 | 44 | assert ctx.obj.get('did_fail') is True 45 | assert ctx.obj.get('soft_fail') is expected_soft_fail 46 | 47 | 48 | def test_handle_exception_unhandled_error(ctx: typer.Context) -> None: 49 | with ctx, pytest.raises(typer.Exit): 50 | handle_scan_exception(ctx, ValueError('test')) 51 | 52 | assert ctx.obj.get('did_fail') is True 53 | assert ctx.obj.get('soft_fail') is None 54 | 55 | 56 | def test_handle_exception_click_error(ctx: typer.Context) -> None: 57 | with ctx, pytest.raises(click.ClickException): 58 | handle_scan_exception(ctx, click.ClickException('test')) 59 | 60 | assert ctx.obj.get('did_fail') is True 61 | assert ctx.obj.get('soft_fail') is None 62 | 63 | 64 | def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: 65 | ctx = typer.Context(click.Command('path'), obj={'verbose': True, 'output': OutputTypeOption.TEXT}) 66 | ctx.obj['console_printer'] = ConsolePrinter(ctx) 67 | 68 | error_text = 'test' 69 | 70 | def mock_console_print(obj: Any, *_, **__) -> None: 71 | if isinstance(obj, str): 72 | assert 'Correlation ID:' in obj 73 | else: 74 | assert isinstance(obj, Traceback) 75 | assert error_text in str(obj.trace) 76 | 77 | monkeypatch.setattr(console_err, 'print', mock_console_print) 78 | 79 | with pytest.raises(typer.Exit): 80 | handle_scan_exception(ctx, ValueError(error_text)) 81 | -------------------------------------------------------------------------------- /tests/cli/files_collector/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/files_collector/__init__.py -------------------------------------------------------------------------------- /tests/cli/files_collector/iac/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/files_collector/iac/__init__.py -------------------------------------------------------------------------------- /tests/cli/files_collector/iac/test_tf_content_generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from cycode.cli.files_collector.iac import tf_content_generator 4 | from cycode.cli.utils.path_utils import get_file_content, get_immediate_subdirectories 5 | from tests.conftest import TEST_FILES_PATH 6 | 7 | _PATH_TO_EXAMPLES = os.path.join(TEST_FILES_PATH, 'tf_content_generator_files') 8 | 9 | 10 | def test_generate_tf_content_from_tfplan() -> None: 11 | examples_directories = get_immediate_subdirectories(_PATH_TO_EXAMPLES) 12 | for example in examples_directories: 13 | tfplan_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tfplan.json')) 14 | tf_expected_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tf_content.txt')) 15 | tf_content = tf_content_generator.generate_tf_content_from_tfplan(example, tfplan_content) 16 | 17 | assert tf_content == tf_expected_content 18 | -------------------------------------------------------------------------------- /tests/cli/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cli/models/__init__.py -------------------------------------------------------------------------------- /tests/cli/models/test_severity.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.cli_types import SeverityOption 2 | 3 | 4 | def test_get_member_weight() -> None: 5 | assert SeverityOption.get_member_weight('INFO') == 0 6 | assert SeverityOption.get_member_weight('LOW') == 1 7 | assert SeverityOption.get_member_weight('MEDIUM') == 2 8 | assert SeverityOption.get_member_weight('HIGH') == 3 9 | assert SeverityOption.get_member_weight('CRITICAL') == 4 10 | 11 | assert SeverityOption.get_member_weight('NON_EXISTENT') == -1 12 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | import pytest 5 | import responses 6 | 7 | from cycode.cli.user_settings.credentials_manager import CredentialsManager 8 | from cycode.cyclient.client_creator import create_scan_client 9 | from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient 10 | from cycode.cyclient.scan_client import ScanClient 11 | 12 | # not real JWT with userId and tenantId fields 13 | _EXPECTED_API_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJ1c2VySWQiOiJibGFibGEiLCJ0ZW5hbnRJZCI6ImJsYWJsYSJ9.8RfoWBfciuj8nwc7UB8uOUJchVuaYpYlgf1G2QHiWTk' # noqa: E501 14 | 15 | _CLIENT_ID = 'b1234568-0eaa-1234-beb8-6f0c12345678' 16 | _CLIENT_SECRET = 'a12345a-42b2-1234-3bdd-c0130123456' 17 | 18 | CLI_ENV_VARS = {'CYCODE_CLIENT_ID': _CLIENT_ID, 'CYCODE_CLIENT_SECRET': _CLIENT_SECRET} 19 | 20 | TEST_FILES_PATH = Path(__file__).parent.joinpath('test_files').absolute() 21 | MOCKED_RESPONSES_PATH = Path(__file__).parent.joinpath('cyclient/mocked_responses/data').absolute() 22 | ZIP_CONTENT_PATH = TEST_FILES_PATH.joinpath('zip_content').absolute() 23 | 24 | 25 | @pytest.fixture(scope='session') 26 | def test_files_path() -> Path: 27 | return TEST_FILES_PATH 28 | 29 | 30 | @pytest.fixture(scope='session') 31 | def scan_client() -> ScanClient: 32 | return create_scan_client(_CLIENT_ID, _CLIENT_SECRET, hide_response_log=False) 33 | 34 | 35 | def create_token_based_client( 36 | client_id: Optional[str] = None, client_secret: Optional[str] = None 37 | ) -> CycodeTokenBasedClient: 38 | CredentialsManager.FILE_NAME = 'unit-tests-credentials.yaml' 39 | 40 | if client_id is None: 41 | client_id = _CLIENT_ID 42 | if client_secret is None: 43 | client_secret = _CLIENT_SECRET 44 | 45 | return CycodeTokenBasedClient(client_id, client_secret) 46 | 47 | 48 | @pytest.fixture(scope='session') 49 | def token_based_client() -> CycodeTokenBasedClient: 50 | return create_token_based_client() 51 | 52 | 53 | @pytest.fixture(scope='session') 54 | def api_token_url(token_based_client: CycodeTokenBasedClient) -> str: 55 | return f'{token_based_client.api_url}/api/v1/auth/api-token' 56 | 57 | 58 | @pytest.fixture(scope='session') 59 | def api_token_response(api_token_url: str) -> responses.Response: 60 | return responses.Response( 61 | method=responses.POST, 62 | url=api_token_url, 63 | json={ 64 | 'token': _EXPECTED_API_TOKEN, 65 | 'refresh_token': '12345678-0c68-1234-91ba-a13123456789', 66 | 'expires_in': 86400, 67 | }, 68 | status=200, 69 | ) 70 | 71 | 72 | @pytest.fixture(scope='session') 73 | @responses.activate 74 | def api_token(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> str: 75 | responses.add(api_token_response) 76 | return token_based_client.get_access_token() 77 | -------------------------------------------------------------------------------- /tests/cyclient/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cyclient/__init__.py -------------------------------------------------------------------------------- /tests/cyclient/mocked_responses/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cyclient/mocked_responses/__init__.py -------------------------------------------------------------------------------- /tests/cyclient/mocked_responses/data/detection_rules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "classification_data": [ 4 | { 5 | "severity": "High", 6 | "classification_rule_id": "e4e826bd-5820-4cc9-ae5b-bbfceb500a21" 7 | } 8 | ], 9 | "detection_rule_id": "26ab3395-2522-4061-a50a-c69c2d622ca1" 10 | }, 11 | { 12 | "classification_data": [ 13 | { 14 | "severity": "High", 15 | "classification_rule_id": "e4e826bd-5820-4cc9-ae5b-bbfceb500a22" 16 | } 17 | ], 18 | "detection_rule_id": "12345678-aea1-4304-a6e9-012345678901" 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /tests/cyclient/mocked_responses/data/detections.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "source_policy_name": "Secrets detection", 4 | "source_policy_type": "SensitiveContent", 5 | "source_entity_name": null, 6 | "source_entity_id": null, 7 | "detection_type_id": "7dff932a-418f-47ee-abb0-703e0f6592cd", 8 | "root_id": null, 9 | "status": "Open", 10 | "status_updated_at": null, 11 | "status_reason": null, 12 | "status_change_message": null, 13 | "source_entity_type": "Audit", 14 | "detection_details": { 15 | "organization_name": null, 16 | "organization_id": "", 17 | "sha512": "6e6c867188c04340d9ecfa1b7e56a356e605f2a70fbda865f11b4a57eb07e634", 18 | "provider": "CycodeCli", 19 | "concrete_provider": "CycodeCli", 20 | "length": 55, 21 | "start_position": 19, 22 | "line": 0, 23 | "commit_id": null, 24 | "member_id": "", 25 | "member_name": "", 26 | "member_email": "", 27 | "author_name": "", 28 | "author_email": "", 29 | "branch_name": "", 30 | "committer_name": "", 31 | "committed_at": "0001-01-01T00:00:00+00:00", 32 | "file_path": "%FILEPATH%", 33 | "file_name": "secrets.py", 34 | "file_extension": ".py", 35 | "url": null, 36 | "should_resolve_upon_branch_deletion": false, 37 | "position_in_line": 19, 38 | "repository_name": null, 39 | "repository_id": null, 40 | "old_detection_id": "f35c42f99d3712d4593d5ee16a9ceb36ca9fb20b33e68edd0e00847e6a02a7b6" 41 | }, 42 | "severity": "Medium", 43 | "remediable": false, 44 | "correlation_message": "Secret of type 'Slack Token' was found in filename 'secrets.py' within '' repository", 45 | "provider": "CycodeCli", 46 | "scan_id": "%SCAN_ID%", 47 | "assignee_id": null, 48 | "type": "slack-token", 49 | "is_hidden": false, 50 | "tags": [], 51 | "detection_rule_id": "26ab3395-2522-4061-a50a-c69c2d622ca1", 52 | "classification": null, 53 | "priority": 0, 54 | "metadata": null, 55 | "labels": [], 56 | "detection_id": "f35c42f99d3712d4593d5ee16a9ceb36ca9fb20b33e68edd0e00847e6a02a7b6", 57 | "internal_note": null, 58 | "sdlc_stages": [ 59 | "Code", 60 | "Container Registry", 61 | "Productivity Tools", 62 | "Cloud", 63 | "Build" 64 | ], 65 | "policy_labels": [], 66 | "category": "SecretDetection", 67 | "sub_category": "SensitiveContent", 68 | "sub_category_v2": "Messaging Systems", 69 | "policy_tags": [], 70 | "remediations": [], 71 | "instruction_details": { 72 | "instruction_name_to_single_id_map": null, 73 | "instruction_name_to_multiple_ids_map": null, 74 | "instruction_tags": null 75 | }, 76 | "external_detection_references": [], 77 | "project_ids": [], 78 | "tenant_id": "123456-663f-4e27-9170-e559c2379292", 79 | "id": "123456-895a-4830-b6c1-b948e99b71a4", 80 | "created_date": "2023-10-25T11:07:14.7516793+00:00", 81 | "updated_date": "2023-10-25T11:07:14.7616063+00:00" 82 | } 83 | ] -------------------------------------------------------------------------------- /tests/cyclient/scan_config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/cyclient/scan_config/__init__.py -------------------------------------------------------------------------------- /tests/cyclient/scan_config/test_default_scan_config.py: -------------------------------------------------------------------------------- 1 | from cycode.cli import consts 2 | from cycode.cyclient.scan_config_base import DefaultScanConfig 3 | 4 | 5 | def test_get_service_name() -> None: 6 | default_scan_config = DefaultScanConfig() 7 | 8 | assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == 'scans' 9 | assert default_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == 'scans' 10 | assert default_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == 'scans' 11 | assert default_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' 12 | 13 | 14 | def test_get_detections_prefix() -> None: 15 | default_scan_config = DefaultScanConfig() 16 | 17 | assert default_scan_config.get_detections_prefix() == 'detections' 18 | -------------------------------------------------------------------------------- /tests/cyclient/scan_config/test_dev_scan_config.py: -------------------------------------------------------------------------------- 1 | from cycode.cli import consts 2 | from cycode.cyclient.scan_config_base import DevScanConfig 3 | 4 | 5 | def test_get_service_name() -> None: 6 | dev_scan_config = DevScanConfig() 7 | 8 | assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == '5004' 9 | assert dev_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == '5004' 10 | assert dev_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == '5004' 11 | assert dev_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == '5004' 12 | 13 | 14 | def test_get_detections_prefix() -> None: 15 | dev_scan_config = DevScanConfig() 16 | 17 | assert dev_scan_config.get_detections_prefix() == '5016' 18 | -------------------------------------------------------------------------------- /tests/cyclient/test_client.py: -------------------------------------------------------------------------------- 1 | from cycode.cyclient import config 2 | from cycode.cyclient.cycode_client import CycodeClient 3 | 4 | 5 | def test_init_values_from_config() -> None: 6 | client = CycodeClient() 7 | 8 | assert client.api_url == config.cycode_api_url 9 | assert client.timeout == config.timeout 10 | -------------------------------------------------------------------------------- /tests/cyclient/test_client_base.py: -------------------------------------------------------------------------------- 1 | from cycode.cyclient import config 2 | from cycode.cyclient.cycode_client_base import CycodeClientBase 3 | from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id 4 | 5 | 6 | def test_mandatory_headers() -> None: 7 | expected_headers = { 8 | 'User-Agent': get_cli_user_agent(), 9 | 'X-Correlation-Id': get_correlation_id(), 10 | } 11 | 12 | client = CycodeClientBase(config.cycode_api_url) 13 | 14 | assert expected_headers == client.MANDATORY_HEADERS 15 | 16 | 17 | def test_get_request_headers() -> None: 18 | client = CycodeClientBase(config.cycode_api_url) 19 | 20 | assert client.get_request_headers() == client.MANDATORY_HEADERS 21 | 22 | 23 | def test_get_request_headers_with_additional() -> None: 24 | client = CycodeClientBase(config.cycode_api_url) 25 | 26 | additional_headers = {'Authorize': 'Token test'} 27 | expected_headers = {**client.MANDATORY_HEADERS, **additional_headers} 28 | 29 | assert client.get_request_headers(additional_headers) == expected_headers 30 | 31 | 32 | def test_build_full_url() -> None: 33 | url = config.cycode_api_url 34 | client = CycodeClientBase(url) 35 | 36 | endpoint = 'test' 37 | expected_url = f'{url}/{endpoint}' 38 | 39 | assert client.build_full_url(url, endpoint) == expected_url 40 | -------------------------------------------------------------------------------- /tests/cyclient/test_config.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from cycode.cli.consts import DEFAULT_CYCODE_DOMAIN 4 | from cycode.cyclient.config import _is_on_premise_installation 5 | 6 | if TYPE_CHECKING: 7 | from _pytest.monkeypatch import MonkeyPatch 8 | 9 | 10 | def test_is_on_premise_installation(monkeypatch: 'MonkeyPatch') -> None: 11 | monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'api.cycode.com') 12 | assert not _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) 13 | monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'api.eu.cycode.com') 14 | assert not _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) 15 | 16 | monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'cycode.google.com') 17 | assert _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) 18 | monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'cycode.blabla.google.com') 19 | assert _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) 20 | 21 | monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'api.cycode.com') 22 | assert _is_on_premise_installation('blabla') 23 | monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'cycode.blabla.google.com') 24 | assert _is_on_premise_installation('blabla') 25 | -------------------------------------------------------------------------------- /tests/cyclient/test_dev_based_client.py: -------------------------------------------------------------------------------- 1 | from cycode.cyclient import config 2 | from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient 3 | 4 | 5 | def test_get_request_headers() -> None: 6 | client = CycodeDevBasedClient(config.cycode_api_url) 7 | 8 | dev_based_headers = {'X-Tenant-Id': config.dev_tenant_id} 9 | expected_headers = {**client.MANDATORY_HEADERS, **dev_based_headers} 10 | 11 | assert client.get_request_headers() == expected_headers 12 | 13 | 14 | def test_build_full_url() -> None: 15 | url = config.cycode_api_url 16 | client = CycodeDevBasedClient(url) 17 | 18 | endpoint = 'test' 19 | expected_url = f'{url}:{endpoint}' 20 | 21 | assert client.build_full_url(url, endpoint) == expected_url 22 | -------------------------------------------------------------------------------- /tests/test_code_scanner.py: -------------------------------------------------------------------------------- 1 | import os 2 | from uuid import uuid4 3 | 4 | import pytest 5 | import responses 6 | 7 | from cycode.cli import consts 8 | from cycode.cli.apps.scan.code_scanner import ( 9 | _try_get_aggregation_report_url_if_needed, 10 | ) 11 | from cycode.cli.cli_types import ScanTypeOption 12 | from cycode.cli.files_collector.excluder import excluder 13 | from cycode.cyclient.scan_client import ScanClient 14 | from tests.conftest import TEST_FILES_PATH 15 | from tests.cyclient.mocked_responses.scan_client import ( 16 | get_scan_aggregation_report_url, 17 | get_scan_aggregation_report_url_response, 18 | ) 19 | 20 | 21 | def test_is_relevant_file_to_scan_sca() -> None: 22 | path = os.path.join(TEST_FILES_PATH, 'package.json') 23 | assert excluder._is_relevant_file_to_scan(consts.SCA_SCAN_TYPE, path) is True 24 | 25 | 26 | @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) 27 | def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( 28 | scan_type: ScanTypeOption, scan_client: ScanClient 29 | ) -> None: 30 | aggregation_id = uuid4().hex 31 | scan_parameter = {'aggregation_id': aggregation_id} 32 | result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) 33 | assert result is None 34 | 35 | 36 | @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) 37 | def test_try_get_aggregation_report_url_if_no_aggregation_id_needed_return_none( 38 | scan_type: ScanTypeOption, scan_client: ScanClient 39 | ) -> None: 40 | scan_parameter = {'report': True} 41 | result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) 42 | assert result is None 43 | 44 | 45 | @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) 46 | @responses.activate 47 | def test_try_get_aggregation_report_url_if_needed_return_result( 48 | scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response 49 | ) -> None: 50 | aggregation_id = uuid4() 51 | scan_parameter = {'report': True, 'aggregation_id': aggregation_id} 52 | url = get_scan_aggregation_report_url(aggregation_id, scan_client, scan_type) 53 | responses.add(api_token_response) # mock token based client 54 | responses.add(get_scan_aggregation_report_url_response(url, aggregation_id)) 55 | 56 | scan_aggregation_report_url_response = scan_client.get_scan_aggregation_report_url(str(aggregation_id), scan_type) 57 | 58 | result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) 59 | assert result == scan_aggregation_report_url_response.report_url 60 | -------------------------------------------------------------------------------- /tests/test_files/.test_env: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_TOKEN=923445010:AAGWKwWTNx_6RAuRdcp2kWax5_JltwkF2Lw 2 | -------------------------------------------------------------------------------- /tests/test_files/hello.txt: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /tests/test_files/hello/random.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/test_files/hello/random.txt -------------------------------------------------------------------------------- /tests/test_files/package.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/test_files/package.json -------------------------------------------------------------------------------- /tests/test_files/tf_content_generator_files/tfplan-destroy-example/tf_content.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/test_files/tf_content_generator_files/tfplan-destroy-example/tf_content.txt -------------------------------------------------------------------------------- /tests/test_files/tf_content_generator_files/tfplan-false-var/tf_content.txt: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "efrat-env-var-test" { 2 | bucket = "efrat-env-var-test" 3 | force_destroy = false 4 | tags = null 5 | timeouts = null 6 | } 7 | 8 | resource "aws_s3_bucket_public_access_block" "efrat-env-var-test" { 9 | block_public_acls = false 10 | block_public_policy = true 11 | ignore_public_acls = false 12 | restrict_public_buckets = true 13 | } 14 | 15 | -------------------------------------------------------------------------------- /tests/test_files/tf_content_generator_files/tfplan-null-example/tf_content.txt: -------------------------------------------------------------------------------- 1 | resource "null_resource" "empty" { 2 | triggers = null 3 | } 4 | 5 | -------------------------------------------------------------------------------- /tests/test_files/tf_content_generator_files/tfplan-null-example/tfplan.json: -------------------------------------------------------------------------------- 1 | { 2 | "format_version": "1.0", 3 | "terraform_version": "1.1.9", 4 | "variables": { 5 | "environment": { 6 | "value": "test" 7 | } 8 | }, 9 | "planned_values": { 10 | "root_module": { 11 | "resources": [ 12 | { 13 | "address": "null_resource.empty", 14 | "mode": "managed", 15 | "type": "null_resource", 16 | "name": "empty", 17 | "provider_name": "registry.terraform.io/hashicorp/null", 18 | "schema_version": 0, 19 | "values": { 20 | "triggers": null 21 | }, 22 | "sensitive_values": {} 23 | } 24 | ] 25 | } 26 | }, 27 | "resource_changes": [ 28 | { 29 | "address": "null_resource.empty", 30 | "mode": "managed", 31 | "type": "null_resource", 32 | "name": "empty", 33 | "provider_name": "registry.terraform.io/hashicorp/null", 34 | "change": { 35 | "actions": [ 36 | "create" 37 | ], 38 | "before": null, 39 | "after": { 40 | "triggers": null 41 | }, 42 | "after_unknown": { 43 | "id": true 44 | }, 45 | "before_sensitive": false, 46 | "after_sensitive": {} 47 | } 48 | } 49 | ], 50 | "configuration": { 51 | "provider_config": { 52 | "aws": { 53 | "name": "aws", 54 | "version_constraint": ">= 4.12.1" 55 | } 56 | }, 57 | "root_module": { 58 | "resources": [ 59 | { 60 | "address": "null_resource.empty", 61 | "mode": "managed", 62 | "type": "null_resource", 63 | "name": "empty", 64 | "provider_config_key": "null", 65 | "schema_version": 0 66 | } 67 | ], 68 | "variables": { 69 | "environment": { 70 | "description": "The name of the deployment environment" 71 | } 72 | } 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /tests/test_files/tf_content_generator_files/tfplan-update-example/tf_content.txt: -------------------------------------------------------------------------------- 1 | resource "aws_codebuild_project" "some_projed" { 2 | arn = "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working" 3 | artifacts = [{"artifact_identifier": "", "encryption_disabled": true, "location": "terra-ci-artifacts-eu-west-1-000002", "name": "why-my-project-not-working", "namespace_type": "NONE", "override_artifact_name": true, "packaging": "NONE", "path": "", "type": "S3"}] 4 | badge_enabled = false 5 | badge_url = "" 6 | build_timeout = 10 7 | cache = [{"location": "", "modes": [], "type": "NO_CACHE"}] 8 | description = "Deploy environment configuration" 9 | encryption_key = "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3" 10 | environment = [{"certificate": "", "compute_type": "BUILD_GENERAL1_SMALL", "environment_variable": [], "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", "image_pull_credentials_type": "CODEBUILD", "privileged_mode": false, "registry_credential": [], "type": "LINUX_CONTAINER"}] 11 | id = "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working" 12 | logs_config = [{"cloudwatch_logs": [{"group_name": "", "status": "ENABLED", "stream_name": ""}], "s3_logs": [{"encryption_disabled": false, "location": "", "status": "DISABLED"}]}] 13 | name = "why-my-project-not-working" 14 | queued_timeout = 480 15 | secondary_artifacts = [] 16 | secondary_sources = [] 17 | service_role = "arn:aws:iam::719261439472:role/terra_ci_job" 18 | source = [{"auth": [], "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", "git_clone_depth": 1, "git_submodules_config": [], "insecure_ssl": false, "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", "report_build_status": false, "type": "GITHUB"}] 19 | source_version = "" 20 | tags = {} 21 | vpc_config = [] 22 | } 23 | 24 | resource "aws_iam_user" "ci" { 25 | arn = "arn:aws:iam::719261439472:user/ci" 26 | force_destroy = false 27 | id = "ci" 28 | name = "ci" 29 | path = "/" 30 | permissions_boundary = null 31 | tags = {} 32 | unique_id = "AIDA2O52SSXYORYI4EPXD" 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tests/test_files/zip_content/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/test_files/zip_content/__init__.py -------------------------------------------------------------------------------- /tests/test_files/zip_content/sast.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | requests.get('https://slack.com/api/conversations.list', verify=False) # noqa: S113, S501 4 | -------------------------------------------------------------------------------- /tests/test_files/zip_content/secrets.py: -------------------------------------------------------------------------------- 1 | slack_bot_token = 'xoxb-1234518014707-1234518014707-M14123412341234Fra15WS' 2 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from cycode.cyclient.models import InternalMetadata, K8SResource, ResourcesCollection 2 | from tests import PODS_MOCK 3 | 4 | 5 | def test_batch_resources_to_json() -> None: 6 | batch = ResourcesCollection('pod', 'default', PODS_MOCK, 77777) 7 | json_dict = batch.to_json() 8 | assert 'resources' in json_dict 9 | assert 'namespace' in json_dict 10 | assert 'total_count' in json_dict 11 | assert 'type' in json_dict 12 | assert json_dict['total_count'] == 77777 13 | assert json_dict['type'] == 'pod' 14 | assert json_dict['namespace'] == 'default' 15 | assert json_dict['resources'][0]['name'] == 'pod_name_1' 16 | 17 | 18 | def test_internal_metadata_to_json() -> None: 19 | resource = K8SResource('nginx-template-123-456', 'pod', 'cycode', {}) 20 | resource.internal_metadata = InternalMetadata('nginx-template', 'deployment') 21 | batch = ResourcesCollection('pod', 'cycode', [resource], 1) 22 | json_dict = batch.to_json() 23 | internal_metadata = json_dict['resources'][0]['internal_metadata'] 24 | assert internal_metadata['root_entity_name'] == 'nginx-template' 25 | assert internal_metadata['root_entity_type'] == 'deployment' 26 | -------------------------------------------------------------------------------- /tests/test_performance_get_all_files.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import logging 3 | import os 4 | import timeit 5 | from pathlib import Path 6 | from typing import Union 7 | 8 | logger = logging.getLogger(__name__) 9 | logging.basicConfig(level=logging.INFO) 10 | 11 | 12 | def filter_files(paths: list[Union[Path, str]]) -> list[str]: 13 | return [str(path) for path in paths if os.path.isfile(path)] 14 | 15 | 16 | def get_all_files_glob(path: Union[Path, str]) -> list[str]: 17 | # DOESN'T RETURN HIDDEN FILES. CAN'T BE USED 18 | # and doesn't show the best performance 19 | if not str(path).endswith(os.sep): 20 | path = f'{path}{os.sep}' 21 | 22 | return filter_files(glob.glob(f'{path}**', recursive=True)) 23 | 24 | 25 | def get_all_files_walk(path: str) -> list[str]: 26 | files = [] 27 | 28 | for root, _, filenames in os.walk(path): 29 | for filename in filenames: 30 | files.append(os.path.join(root, filename)) 31 | 32 | return files 33 | 34 | 35 | def get_all_files_listdir(path: str) -> list[str]: 36 | files = [] 37 | 38 | def _(sub_path: str) -> None: 39 | items = os.listdir(sub_path) 40 | 41 | for item in items: 42 | item_path = os.path.join(sub_path, item) 43 | 44 | if os.path.isfile(item_path): 45 | files.append(item_path) 46 | elif os.path.isdir(item_path): 47 | _(item_path) 48 | 49 | _(path) 50 | return files 51 | 52 | 53 | def get_all_files_rglob(path: str) -> list[str]: 54 | return filter_files(list(Path(path).rglob(r'*'))) 55 | 56 | 57 | def test_get_all_files_performance(test_files_path: str) -> None: 58 | results: dict[str, tuple[int, float]] = {} 59 | for func in { 60 | get_all_files_rglob, 61 | get_all_files_listdir, 62 | get_all_files_walk, 63 | }: 64 | name = func.__name__ 65 | start_time = timeit.default_timer() 66 | 67 | files_count = len(func(test_files_path)) 68 | 69 | executed_time = timeit.default_timer() - start_time 70 | results[name] = (files_count, executed_time) 71 | 72 | logger.info('Time result %s: %s', name, executed_time) 73 | logger.info('Files count %s: %s', name, files_count) 74 | 75 | files_counts = [result[0] for result in results.values()] 76 | assert len(set(files_counts)) == 1 # all should be equal 77 | 78 | logger.info('Benchmark TOP with (%s) files:', files_counts[0]) 79 | for func_name, result in sorted(results.items(), key=lambda x: x[1][1]): 80 | logger.info('- %s: %s', func_name, result[1]) 81 | 82 | # according to my (MarshalX) local tests, the fastest is get_all_files_walk 83 | 84 | 85 | if __name__ == '__main__': 86 | # provide a path with thousands of files 87 | huge_dir_path = '/Users/ilyasiamionau/projects/cycode/' 88 | test_get_all_files_performance(huge_dir_path) 89 | 90 | # Output: 91 | # INFO:__main__:Benchmark TOP with (94882) files: 92 | # INFO:__main__:- get_all_files_walk: 0.717258458 93 | # INFO:__main__:- get_all_files_listdir: 1.4648628330000002 94 | # INFO:__main__:- get_all_files_rglob: 2.368291458 95 | -------------------------------------------------------------------------------- /tests/test_zip_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from cycode.cli.utils.path_utils import concat_unique_id 4 | 5 | 6 | def test_concat_unique_id_to_file_with_leading_slash() -> None: 7 | filename = os.path.join('path', 'to', 'file') # we should care about slash characters in tests 8 | unique_id = 'unique_id' 9 | 10 | expected_path = os.path.join(unique_id, filename) 11 | 12 | filename = os.sep + filename 13 | assert concat_unique_id(filename, unique_id) == expected_path 14 | 15 | 16 | def test_concat_unique_id_to_file_without_leading_slash() -> None: 17 | filename = os.path.join('path', 'to', 'file') # we should care about slash characters in tests 18 | unique_id = 'unique_id' 19 | 20 | expected_path = os.path.join(unique_id, *filename.split('/')) 21 | 22 | assert concat_unique_id(filename, unique_id) == expected_path 23 | -------------------------------------------------------------------------------- /tests/user_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/user_settings/__init__.py -------------------------------------------------------------------------------- /tests/user_settings/test_configuration_manager.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | from unittest.mock import Mock 3 | 4 | from cycode.cli.consts import DEFAULT_CYCODE_API_URL 5 | from cycode.cli.user_settings.configuration_manager import ConfigurationManager 6 | 7 | if TYPE_CHECKING: 8 | from pytest_mock import MockerFixture 9 | 10 | """ 11 | we check for base url in the three places, in the following order: 12 | 1. environment vars 13 | 2. local file config 14 | 3. global file config 15 | """ 16 | ENV_VARS_BASE_URL_VALUE = 'url_from_env_vars' 17 | LOCAL_CONFIG_FILE_BASE_URL_VALUE = 'url_from_local_config_file' 18 | GLOBAL_CONFIG_BASE_URL_VALUE = 'url_from_global_config_file' 19 | 20 | 21 | def test_get_base_url_from_environment_variable(mocker: 'MockerFixture') -> None: 22 | # Arrange 23 | configuration_manager = _configure_mocks( 24 | mocker, ENV_VARS_BASE_URL_VALUE, LOCAL_CONFIG_FILE_BASE_URL_VALUE, GLOBAL_CONFIG_BASE_URL_VALUE 25 | ) 26 | 27 | # Act 28 | result = configuration_manager.get_cycode_api_url() 29 | 30 | # Assert 31 | assert result == ENV_VARS_BASE_URL_VALUE 32 | 33 | 34 | def test_get_base_url_from_local_config(mocker: 'MockerFixture') -> None: 35 | # Arrange 36 | configuration_manager = _configure_mocks( 37 | mocker, None, LOCAL_CONFIG_FILE_BASE_URL_VALUE, GLOBAL_CONFIG_BASE_URL_VALUE 38 | ) 39 | 40 | # Act 41 | result = configuration_manager.get_cycode_api_url() 42 | 43 | # Assert 44 | assert result == LOCAL_CONFIG_FILE_BASE_URL_VALUE 45 | 46 | 47 | def test_get_base_url_from_global_config(mocker: 'MockerFixture') -> None: 48 | # Arrange 49 | configuration_manager = _configure_mocks(mocker, None, None, GLOBAL_CONFIG_BASE_URL_VALUE) 50 | 51 | # Act 52 | result = configuration_manager.get_cycode_api_url() 53 | 54 | # Assert 55 | assert result == GLOBAL_CONFIG_BASE_URL_VALUE 56 | 57 | 58 | def test_get_base_url_not_configured(mocker: 'MockerFixture') -> None: 59 | # Arrange 60 | configuration_manager = _configure_mocks(mocker, None, None, None) 61 | 62 | # Act 63 | result = configuration_manager.get_cycode_api_url() 64 | 65 | # Assert 66 | assert result == DEFAULT_CYCODE_API_URL 67 | 68 | 69 | def _configure_mocks( 70 | mocker: 'MockerFixture', 71 | expected_env_var_base_url: Optional[str], 72 | expected_local_config_file_base_url: Optional[str], 73 | expected_global_config_file_base_url: Optional[str], 74 | ) -> ConfigurationManager: 75 | mocker.patch.object( 76 | ConfigurationManager, 'get_api_url_from_environment_variables', return_value=expected_env_var_base_url 77 | ) 78 | configuration_manager = ConfigurationManager() 79 | configuration_manager.local_config_file_manager = Mock() 80 | configuration_manager.local_config_file_manager.get_api_url.return_value = expected_local_config_file_base_url 81 | configuration_manager.global_config_file_manager = Mock() 82 | configuration_manager.global_config_file_manager.get_api_url.return_value = expected_global_config_file_base_url 83 | 84 | return configuration_manager 85 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycodehq/cycode-cli/9a9084314532f5b9781e9ee30fa982153873e56a/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/test_git_proxy.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import git as real_git 5 | import pytest 6 | 7 | from cycode.cli.utils.git_proxy import _GIT_ERROR_MESSAGE, GitProxyError, _DummyGitProxy, _GitProxy, get_git_proxy 8 | 9 | 10 | def test_get_git_proxy() -> None: 11 | proxy = get_git_proxy(git_module=None) 12 | assert isinstance(proxy, _DummyGitProxy) 13 | 14 | proxy2 = get_git_proxy(git_module=real_git) 15 | assert isinstance(proxy2, _GitProxy) 16 | 17 | 18 | def test_dummy_git_proxy() -> None: 19 | proxy = _DummyGitProxy() 20 | 21 | with pytest.raises(RuntimeError) as exc: 22 | proxy.get_repo() 23 | assert str(exc.value) == _GIT_ERROR_MESSAGE 24 | 25 | with pytest.raises(RuntimeError) as exc2: 26 | proxy.get_null_tree() 27 | assert str(exc2.value) == _GIT_ERROR_MESSAGE 28 | 29 | assert proxy.get_git_command_error() is GitProxyError 30 | assert proxy.get_invalid_git_repository_error() is GitProxyError 31 | 32 | 33 | def test_git_proxy() -> None: 34 | proxy = _GitProxy() 35 | 36 | repo = proxy.get_repo(os.getcwd(), search_parent_directories=True) 37 | assert isinstance(repo, real_git.Repo) 38 | 39 | assert proxy.get_null_tree() is real_git.NULL_TREE 40 | 41 | assert proxy.get_git_command_error() is real_git.GitCommandError 42 | assert proxy.get_invalid_git_repository_error() is real_git.InvalidGitRepositoryError 43 | 44 | with tempfile.TemporaryDirectory() as tmpdir: 45 | with pytest.raises(real_git.InvalidGitRepositoryError): 46 | proxy.get_repo(tmpdir) 47 | with pytest.raises(proxy.get_invalid_git_repository_error()): 48 | proxy.get_repo(tmpdir) 49 | 50 | with pytest.raises(real_git.GitCommandError): 51 | repo.git.show('blabla') 52 | with pytest.raises(proxy.get_git_command_error()): 53 | repo.git.show('blabla') 54 | -------------------------------------------------------------------------------- /tests/utils/test_path_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from cycode.cli.utils.path_utils import is_sub_path 4 | from tests.conftest import TEST_FILES_PATH 5 | 6 | 7 | def test_is_sub_path_both_paths_are_same() -> None: 8 | path = os.path.join(TEST_FILES_PATH, 'hello') 9 | sub_path = os.path.join(TEST_FILES_PATH, 'hello') 10 | assert is_sub_path(path, sub_path) is True 11 | 12 | 13 | def test_is_sub_path_path_is_not_subpath() -> None: 14 | path = os.path.join(TEST_FILES_PATH, 'hello') 15 | sub_path = os.path.join(TEST_FILES_PATH, 'hello.txt') 16 | assert is_sub_path(path, sub_path) is False 17 | 18 | 19 | def test_is_sub_path_path_is_subpath() -> None: 20 | path = os.path.join(TEST_FILES_PATH, 'hello') 21 | sub_path = os.path.join(TEST_FILES_PATH, 'hello', 'random.txt') 22 | assert is_sub_path(path, sub_path) is True 23 | 24 | 25 | def test_is_sub_path_path_not_exists() -> None: 26 | path = os.path.join(TEST_FILES_PATH, 'goodbye') 27 | sub_path = os.path.join(TEST_FILES_PATH, 'hello', 'random.txt') 28 | assert is_sub_path(path, sub_path) is False 29 | 30 | 31 | def test_is_sub_path_subpath_not_exists() -> None: 32 | path = os.path.join(TEST_FILES_PATH, 'hello', 'random.txt') 33 | sub_path = os.path.join(TEST_FILES_PATH, 'goodbye') 34 | assert is_sub_path(path, sub_path) is False 35 | -------------------------------------------------------------------------------- /tests/utils/test_string_utils.py: -------------------------------------------------------------------------------- 1 | from cycode.cli.utils.string_utils import shortcut_dependency_paths 2 | 3 | 4 | def test_shortcut_dependency_paths_list_single_dependencies() -> None: 5 | dependency_paths = 'A, A -> B, A -> B -> C' 6 | expected_result = 'A\nA -> B\nA -> ... -> C' 7 | assert shortcut_dependency_paths(dependency_paths) == expected_result 8 | --------------------------------------------------------------------------------