From 5edfa763f7c41d3ecb0621a474dd5d2ed6a22179 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sat, 23 Aug 2025 16:50:17 -0700 Subject: [PATCH 01/11] feat: migrate to uv package manager and implement version management system - Replace pip with uv for faster dependency resolution and modern Python packaging - Add pyproject.toml with project configuration and dependencies - Update Dockerfile to use uv sync --frozen for reproducible builds - Implement comprehensive version management system: - Create src/version.py for runtime version imports - Add .hooks/version-check.py pre-commit hook with auto-bump functionality - Support both patch and dev version bumping (.devN) - Sync versions between pyproject.toml and version.py automatically - Include PyPI conflict detection - Update documentation with uv installation and usage instructions - Add version management setup and usage to README - Remove requirements.txt (replaced by pyproject.toml + uv.lock) - Update version to 1.0.18 - Maintain full compatibility with existing Docker workflow and GitHub Actions Dependencies: All previous dependencies preserved in pyproject.toml --- .gitignore | 3 + .hooks/setup.py | 55 ++++ .hooks/version-check.py | 128 ++++++++ .python-version | 1 + Dockerfile | 16 +- README.md | 41 ++- pyproject.toml | 21 ++ requirements.txt | 4 - src/socket_external_tools_runner.py | 1 + src/version.py | 1 + uv.lock | 437 ++++++++++++++++++++++++++++ 11 files changed, 698 insertions(+), 10 deletions(-) create mode 100755 .hooks/setup.py create mode 100755 .hooks/version-check.py create mode 100644 .python-version create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 src/version.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index aeb8181..c93525e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ markdown_security_temp.md .DS_Store *.pyc test.py + +# Note: requirements.txt is no longer needed - using pyproject.toml + uv.lock instead +# Version files are auto-managed by .hooks/version-check.py *.cpython-312.pyc` file_generator.py .env diff --git a/.hooks/setup.py b/.hooks/setup.py new file mode 100755 index 0000000..9d11ec6 --- /dev/null +++ b/.hooks/setup.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Setup script to install pre-commit hooks for version management. +""" +import pathlib +import subprocess +import sys + +def setup_pre_commit_hook(): + """Set up the pre-commit hook for version checking.""" + git_hooks_dir = pathlib.Path(".git/hooks") + pre_commit_hook = git_hooks_dir / "pre-commit" + + if not git_hooks_dir.exists(): + print("❌ .git/hooks directory not found. Are you in a git repository?") + sys.exit(1) + + hook_content = '''#!/bin/bash +# Version check pre-commit hook +python3 .hooks/version-check.py +''' + + # Create or update the pre-commit hook + if pre_commit_hook.exists(): + print("⚠️ Pre-commit hook already exists.") + response = input("Do you want to overwrite it? (y/N): ") + if response.lower() != 'y': + print("❌ Aborted.") + sys.exit(1) + + pre_commit_hook.write_text(hook_content) + pre_commit_hook.chmod(0o755) + + print("✅ Pre-commit hook installed successfully!") + print("Now version changes will be automatically checked on each commit.") + print("") + print("Usage:") + print(" Normal commit: Will auto-bump patch version if unchanged") + print(" Dev mode: python3 .hooks/version-check.py --dev") + +def main(): + if "--install-hook" in sys.argv: + setup_pre_commit_hook() + else: + print("Version management setup script") + print("") + print("Options:") + print(" --install-hook Install pre-commit hook for version checking") + print("") + print("Manual usage:") + print(" python3 .hooks/version-check.py # Check and auto-bump if needed") + print(" python3 .hooks/version-check.py --dev # Use dev versioning") + +if __name__ == "__main__": + main() diff --git a/.hooks/version-check.py b/.hooks/version-check.py new file mode 100755 index 0000000..ac682d5 --- /dev/null +++ b/.hooks/version-check.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import subprocess +import pathlib +import re +import sys +import urllib.request +import json + +VERSION_FILE = pathlib.Path("src/version.py") +PYPROJECT_FILE = pathlib.Path("pyproject.toml") + +VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]") +PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"$', re.MULTILINE) +# Update this URL to match your actual PyPI package if you publish it +PYPI_API = "https://pypi.org/pypi/security-wrapper/json" + +def read_version_from_version_file(path: pathlib.Path) -> str: + if not path.exists(): + print(f"❌ Version file {path} does not exist") + sys.exit(1) + content = path.read_text() + match = VERSION_PATTERN.search(content) + if not match: + print(f"❌ Could not find __version__ in {path}") + sys.exit(1) + return match.group(1) + +def read_version_from_pyproject(path: pathlib.Path) -> str: + if not path.exists(): + print(f"❌ pyproject.toml file {path} does not exist") + sys.exit(1) + content = path.read_text() + match = PYPROJECT_PATTERN.search(content) + if not match: + print(f"❌ Could not find version in {path}") + sys.exit(1) + return match.group(1) + +def read_version_from_git(path: str) -> str: + try: + output = subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True) + match = VERSION_PATTERN.search(output) + if not match: + return None + return match.group(1) + except subprocess.CalledProcessError: + return None + +def bump_patch_version(version: str) -> str: + if ".dev" in version: + version = version.split(".dev")[0] + parts = version.split(".") + parts[-1] = str(int(parts[-1]) + 1) + return ".".join(parts) + +def fetch_existing_versions() -> set: + try: + with urllib.request.urlopen(PYPI_API) as response: + data = json.load(response) + return set(data.get("releases", {}).keys()) + except Exception as e: + print(f"⚠️ Warning: Failed to fetch existing versions from PyPI: {e}") + return set() + +def find_next_available_dev_version(base_version: str) -> str: + existing_versions = fetch_existing_versions() + for i in range(1, 100): + candidate = f"{base_version}.dev{i}" + if candidate not in existing_versions: + return candidate + print("❌ Could not find available .devN slot after 100 attempts.") + sys.exit(1) + +def inject_version(version: str): + print(f"🔁 Updating version to: {version}") + + # Update version.py + VERSION_FILE.write_text(f'__version__ = "{version}"\n') + + # Update pyproject.toml + pyproject = PYPROJECT_FILE.read_text() + if PYPROJECT_PATTERN.search(pyproject): + new_pyproject = PYPROJECT_PATTERN.sub(f'version = "{version}"', pyproject) + PYPROJECT_FILE.write_text(new_pyproject) + print(f"✅ Updated {PYPROJECT_FILE}") + else: + print(f"⚠️ Could not find version field in {PYPROJECT_FILE}") + +def check_version_sync(): + """Ensure version.py and pyproject.toml are in sync""" + version_py = read_version_from_version_file(VERSION_FILE) + version_toml = read_version_from_pyproject(PYPROJECT_FILE) + + if version_py != version_toml: + print(f"❌ Version mismatch: {VERSION_FILE} has {version_py}, {PYPROJECT_FILE} has {version_toml}") + print("🔁 Syncing versions...") + inject_version(version_toml) # Use pyproject.toml as source of truth + return version_toml + + return version_py + +def main(): + dev_mode = "--dev" in sys.argv + + # Ensure versions are synced + current_version = check_version_sync() + previous_version = read_version_from_git("src/version.py") + + print(f"Current: {current_version}, Previous: {previous_version}") + + if current_version == previous_version: + if dev_mode: + base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version + new_version = find_next_available_dev_version(base_version) + inject_version(new_version) + print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + sys.exit(0) + else: + new_version = bump_patch_version(current_version) + inject_version(new_version) + print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + sys.exit(1) + else: + print("✅ Version already bumped — proceeding.") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/Dockerfile b/Dockerfile index 7602255..aee51f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,9 @@ COPY src/core /core COPY entrypoint.sh / ENV PATH=$PATH:/usr/local/go/bin +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + # Setup Golang RUN curl -sfL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz > go1.23.2.linux-amd64.tar.gz RUN rm -rf /usr/local/go && tar -C /usr/local -xzf go1.23.2.linux-amd64.tar.gz @@ -21,8 +24,9 @@ RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/ # Install trufflehog RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin -# Install Bandit -RUN pip install bandit +# Install Bandit using uv as a tool +RUN uv tool install bandit +ENV PATH="/root/.local/bin:$PATH" # Install eslint RUN apt-get update && apt-get install -y curl && \ @@ -34,9 +38,11 @@ RUN apt-get update && apt-get install -y curl && \ RUN chmod +x /entrypoint.sh -COPY requirements.txt /scripts/requirements.txt -# Install Python dependencies from requirements.txt -RUN pip install -r /scripts/requirements.txt +COPY pyproject.toml uv.lock /scripts/ +# Install Python dependencies using uv +WORKDIR /scripts +RUN uv sync --frozen +ENV PATH="/scripts/.venv/bin:$PATH" # Define entrypoint ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 71b4e1f..7c1645c 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,26 @@ jobs: You can run the security-wrapper locally using Docker. This is useful for testing changes or scanning code outside of GitHub Actions. +### Prerequisites + +This project uses [uv](https://docs.astral.sh/uv/) for Python package management. Install it with: + +```sh +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +### Local Python Development + +For local Python development without Docker: + +```sh +# Install dependencies +uv sync + +# Run the security wrapper directly +uv run python src/socket_external_tools_runner.py +``` + ### Build the Docker Image ```sh @@ -111,8 +131,27 @@ docker run --rm --name security-wrapper \ socketdev/security-wrapper ``` +## Version Management + +This project uses automated version management with uv and pyproject.toml: + +- **Version Source**: `pyproject.toml` is the source of truth for version numbers +- **Runtime Version**: `src/version.py` is auto-synced and imported by the application +- **Pre-commit Hooks**: Automatic version checking and bumping via `.hooks/version-check.py` + +### Setup Version Management + +```sh +# Install the pre-commit hook +python3 .hooks/setup.py --install-hook + +# Manual version checking +python3 .hooks/version-check.py # Auto-bump patch version if unchanged +python3 .hooks/version-check.py --dev # Create dev versions (1.0.18.dev1, etc.) +``` + **Notes:** - You can adjust the environment variables to enable/disable specific scanners. - For image scanning, Docker-in-Docker must be enabled, and you may need to add a `docker pull` step before running. - Results will be printed to the console or output as JSON, depending on `INPUT_SOCKET_CONSOLE_MODE`. -- You can also run the wrapper directly with Bash and Python for rapid local development (see `entrypoint.sh`). +- You can also run the wrapper directly with Bash and Python/uv for rapid local development (see `entrypoint.sh`). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..537ba07 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "security-wrapper" +version = "1.0.18" +description = "Security tools scanning wrapper" +requires-python = ">=3.9" +dependencies = [ + "mdutils~=1.6.0", + "PyGithub~=2.4.0", + "requests~=2.32.3", + "tabulate~=0.9.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.uv] +dev-dependencies = [] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ca30165..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -mdutils~=1.6.0 -PyGithub~=2.4.0 -requests~=2.32.3 -tabulate~=0.9.0 \ No newline at end of file diff --git a/src/socket_external_tools_runner.py b/src/socket_external_tools_runner.py index bac4488..3ddcfe3 100644 --- a/src/socket_external_tools_runner.py +++ b/src/socket_external_tools_runner.py @@ -3,6 +3,7 @@ import os import glob import inspect +from version import __version__ from core import marker from core.connectors.bandit import Bandit from core.connectors.gosec import Gosec diff --git a/src/version.py b/src/version.py new file mode 100644 index 0000000..441304c --- /dev/null +++ b/src/version.py @@ -0,0 +1 @@ +__version__ = "1.0.18" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0243700 --- /dev/null +++ b/uv.lock @@ -0,0 +1,437 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload-time = "2024-09-04T20:45:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload-time = "2024-09-04T20:45:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload-time = "2024-09-04T20:45:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload-time = "2024-09-04T20:45:20.226Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" }, + { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" }, + { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" }, + { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, + { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, + { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, + { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/56/d2/4482d97c948c029be08cb29854a91bd2ae8da7eb9c4152461f1244dcea70/cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012", size = 3576812, upload-time = "2025-08-05T23:59:04.833Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/55fc238fcaa122855442604b8badb2d442367dfbd5a7ca4bb0bd346e263a/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d", size = 4141694, upload-time = "2025-08-05T23:59:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/3ea4fa6fbe51baf3903806a0241c666b04c73d2358a3ecce09ebee8b9622/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d", size = 4375010, upload-time = "2025-08-05T23:59:08.14Z" }, + { url = "https://files.pythonhosted.org/packages/50/42/ec5a892d82d2a2c29f80fc19ced4ba669bca29f032faf6989609cff1f8dc/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da", size = 4141377, upload-time = "2025-08-05T23:59:09.584Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d7/246c4c973a22b9c2931999da953a2c19cae7c66b9154c2d62ffed811225e/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db", size = 4374609, upload-time = "2025-08-05T23:59:11.923Z" }, + { url = "https://files.pythonhosted.org/packages/78/6d/c49ccf243f0a1b0781c2a8de8123ee552f0c8a417c6367a24d2ecb7c11b3/cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18", size = 3322156, upload-time = "2025-08-05T23:59:13.597Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" }, +] + +[[package]] +name = "deprecated" +version = "1.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "mdutils" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/ec/6240f147530a2c8d362ed3f2f7985aca92cda68c25ffc2fc216504b17148/mdutils-1.6.0.tar.gz", hash = "sha256:647f3cf00df39fee6c57fa6738dc1160fce1788276b5530c87d43a70cdefdaf1", size = 22881, upload-time = "2023-04-29T14:39:34.994Z" } + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pygithub" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a0/1e8b8ca88df9857836f5bf8e3ee15dfb810d19814ef700b12f99ce11f691/pygithub-2.4.0.tar.gz", hash = "sha256:6601e22627e87bac192f1e2e39c6e6f69a43152cfb8f307cee575879320b3051", size = 3476673, upload-time = "2024-08-26T06:49:44.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/f3/e185613c411757c0c18b904ea2db173f2872397eddf444a3fe8cdde47077/PyGithub-2.4.0-py3-none-any.whl", hash = "sha256:81935aa4bdc939fba98fee1cb47422c09157c56a27966476ff92775602b9ee24", size = 362599, upload-time = "2024-08-26T06:49:42.351Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "security-wrapper" +version = "1.0.18" +source = { editable = "." } +dependencies = [ + { name = "mdutils" }, + { name = "pygithub" }, + { name = "requests" }, + { name = "tabulate" }, +] + +[package.metadata] +requires-dist = [ + { name = "mdutils", specifier = "~=1.6.0" }, + { name = "pygithub", specifier = "~=2.4.0" }, + { name = "requests", specifier = "~=2.32.3" }, + { name = "tabulate", specifier = "~=0.9.0" }, +] + +[package.metadata.requires-dev] +dev = [] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/41/be/be9b3b0a461ee3e30278706f3f3759b9b69afeedef7fe686036286c04ac6/wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc", size = 53485, upload-time = "2025-08-12T05:51:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a8/8f61d6b8f526efc8c10e12bf80b4206099fea78ade70427846a37bc9cbea/wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9", size = 38675, upload-time = "2025-08-12T05:51:42.885Z" }, + { url = "https://files.pythonhosted.org/packages/48/f1/23950c29a25637b74b322f9e425a17cc01a478f6afb35138ecb697f9558d/wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d", size = 38956, upload-time = "2025-08-12T05:52:03.149Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/dd0791943613885f62619f18ee6107e6133237a6b6ed8a9ecfac339d0b4f/wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a", size = 81745, upload-time = "2025-08-12T05:52:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/bb2d19bd1a614cc4f438abac13ae26c57186197920432d2a915183b15a8b/wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139", size = 82833, upload-time = "2025-08-12T05:52:27.738Z" }, + { url = "https://files.pythonhosted.org/packages/8d/eb/66579aea6ad36f07617fedca8e282e49c7c9bab64c63b446cfe4f7f47a49/wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df", size = 81889, upload-time = "2025-08-12T05:52:29.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/9c/a56b5ac0e2473bdc3fb11b22dd69ff423154d63861cf77911cdde5e38fd2/wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b", size = 81344, upload-time = "2025-08-12T05:52:50.869Z" }, + { url = "https://files.pythonhosted.org/packages/93/4c/9bd735c42641d81cb58d7bfb142c58f95c833962d15113026705add41a07/wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81", size = 36462, upload-time = "2025-08-12T05:53:19.623Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ea/0b72f29cb5ebc16eb55c57dc0c98e5de76fc97f435fd407f7d409459c0a6/wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f", size = 38740, upload-time = "2025-08-12T05:53:18.271Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8b/9eae65fb92321e38dbfec7719b87d840a4b92fde83fd1bbf238c5488d055/wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f", size = 36806, upload-time = "2025-08-12T05:52:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] From 4b3dac1d1b9918481167aae9d8b4d5c4496b4362 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sat, 23 Aug 2025 16:51:44 -0700 Subject: [PATCH 02/11] Implemented pre commit hook --- pyproject.toml | 2 +- src/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 537ba07..786f7db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.18" +version = "1.0.19" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ diff --git a/src/version.py b/src/version.py index 441304c..b931c6a 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.18" +__version__ = "1.0.19" From b27efd5b9737857032043b7b805a987fe48f0190 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sat, 23 Aug 2025 16:59:26 -0700 Subject: [PATCH 03/11] Add Socket NPM & Python CLI to Docker container - Install Socket NPM CLI globally via npm - Install Socket Python CLI via uv tool install - Both CLIs will be available for security scanning workflows - Version bumped to 1.0.20 --- Dockerfile | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index aee51f8..53459b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,10 @@ RUN apt-get update && apt-get install -y curl && \ apt-get install -y nodejs && \ npm install -g eslint eslint-plugin-security @typescript-eslint/parser @typescript-eslint/eslint-plugin +# Install Socket CLI tools +RUN npm install -g socket +RUN uv tool install socketsecurity + # Copy the entrypoint script and make it executable RUN chmod +x /entrypoint.sh diff --git a/pyproject.toml b/pyproject.toml index 786f7db..cddc082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.19" +version = "1.0.20" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ From c6ef61342384fa1df124631fc9278677249ceb19 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sat, 23 Aug 2025 16:59:47 -0700 Subject: [PATCH 04/11] Added version file --- src/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.py b/src/version.py index b931c6a..6e3c058 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.19" +__version__ = "1.0.20" From 3a72f6e4bf8ce9ee7f0b5b198185f2aab224ab04 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sun, 24 Aug 2025 20:01:27 -0700 Subject: [PATCH 05/11] feat: Add Socket Security integrations and fix scan failure detection Socket Reachability & SCA Integration: - Add Socket reachability scanning with configurable org parameter - Add Socket SCA scanning with configurable files parameter - Create connector classes for Socket and SocketSCA tools - Add environment variable support for Socket API keys Bug Fix - Scan Failure Detection: - Fix critical issue where Socket SCA scan failures weren't causing build failures - Implement proper JSON extraction from mixed log/JSON output using regex pattern - Add file path resolution using TEMP_OUTPUT_DIR environment variable - Add explicit scan failure detection logic that exits with code 1 - Create SocketSCAEvent class for proper event handling in console output Technical Improvements: - Update entrypoint.sh with Socket CLI integration and JSON parsing - Enhance socket_external_tools_runner.py with scan failure detection - Add comprehensive error handling and logging - Update action.yml with new Socket-related input parameters - Add Python version management with .python-version and pyproject.toml - Include test files for validating Socket SCA failure scenarios The fix ensures that when Socket SCA returns scan_failed: true with critical alerts, the system properly exits with error code 1 and displays appropriate error messaging instead of incorrectly reporting 'No issues detected'. --- .hooks/setup.py | 55 +++ .hooks/version-check.py | 128 ++++++ .python-version | 1 + Dockerfile | 11 +- README.md | 22 +- action.yml | 28 ++ debug_test.py | 79 ++++ entrypoint.sh | 104 +++++ pyproject.toml | 21 + src/core/connectors/classes.py | 277 +++++++++++++ src/core/connectors/socket/__init__.py | 74 ++++ src/core/connectors/socket_sca/__init__.py | 77 ++++ src/core/plugins/console/console.py | 27 +- src/socket_external_tools_runner.py | 47 ++- src/version.py | 1 + test_fix.py | 56 +++ test_real_failure.py | 84 ++++ test_socket_output_with_logs.txt | 15 + uv.lock | 437 +++++++++++++++++++++ 19 files changed, 1528 insertions(+), 16 deletions(-) create mode 100755 .hooks/setup.py create mode 100755 .hooks/version-check.py create mode 100644 .python-version create mode 100644 debug_test.py create mode 100644 pyproject.toml create mode 100644 src/core/connectors/socket/__init__.py create mode 100644 src/core/connectors/socket_sca/__init__.py create mode 100644 src/version.py create mode 100644 test_fix.py create mode 100644 test_real_failure.py create mode 100644 test_socket_output_with_logs.txt create mode 100644 uv.lock diff --git a/.hooks/setup.py b/.hooks/setup.py new file mode 100755 index 0000000..9d11ec6 --- /dev/null +++ b/.hooks/setup.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Setup script to install pre-commit hooks for version management. +""" +import pathlib +import subprocess +import sys + +def setup_pre_commit_hook(): + """Set up the pre-commit hook for version checking.""" + git_hooks_dir = pathlib.Path(".git/hooks") + pre_commit_hook = git_hooks_dir / "pre-commit" + + if not git_hooks_dir.exists(): + print("❌ .git/hooks directory not found. Are you in a git repository?") + sys.exit(1) + + hook_content = '''#!/bin/bash +# Version check pre-commit hook +python3 .hooks/version-check.py +''' + + # Create or update the pre-commit hook + if pre_commit_hook.exists(): + print("⚠️ Pre-commit hook already exists.") + response = input("Do you want to overwrite it? (y/N): ") + if response.lower() != 'y': + print("❌ Aborted.") + sys.exit(1) + + pre_commit_hook.write_text(hook_content) + pre_commit_hook.chmod(0o755) + + print("✅ Pre-commit hook installed successfully!") + print("Now version changes will be automatically checked on each commit.") + print("") + print("Usage:") + print(" Normal commit: Will auto-bump patch version if unchanged") + print(" Dev mode: python3 .hooks/version-check.py --dev") + +def main(): + if "--install-hook" in sys.argv: + setup_pre_commit_hook() + else: + print("Version management setup script") + print("") + print("Options:") + print(" --install-hook Install pre-commit hook for version checking") + print("") + print("Manual usage:") + print(" python3 .hooks/version-check.py # Check and auto-bump if needed") + print(" python3 .hooks/version-check.py --dev # Use dev versioning") + +if __name__ == "__main__": + main() diff --git a/.hooks/version-check.py b/.hooks/version-check.py new file mode 100755 index 0000000..ac682d5 --- /dev/null +++ b/.hooks/version-check.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import subprocess +import pathlib +import re +import sys +import urllib.request +import json + +VERSION_FILE = pathlib.Path("src/version.py") +PYPROJECT_FILE = pathlib.Path("pyproject.toml") + +VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]") +PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"$', re.MULTILINE) +# Update this URL to match your actual PyPI package if you publish it +PYPI_API = "https://pypi.org/pypi/security-wrapper/json" + +def read_version_from_version_file(path: pathlib.Path) -> str: + if not path.exists(): + print(f"❌ Version file {path} does not exist") + sys.exit(1) + content = path.read_text() + match = VERSION_PATTERN.search(content) + if not match: + print(f"❌ Could not find __version__ in {path}") + sys.exit(1) + return match.group(1) + +def read_version_from_pyproject(path: pathlib.Path) -> str: + if not path.exists(): + print(f"❌ pyproject.toml file {path} does not exist") + sys.exit(1) + content = path.read_text() + match = PYPROJECT_PATTERN.search(content) + if not match: + print(f"❌ Could not find version in {path}") + sys.exit(1) + return match.group(1) + +def read_version_from_git(path: str) -> str: + try: + output = subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True) + match = VERSION_PATTERN.search(output) + if not match: + return None + return match.group(1) + except subprocess.CalledProcessError: + return None + +def bump_patch_version(version: str) -> str: + if ".dev" in version: + version = version.split(".dev")[0] + parts = version.split(".") + parts[-1] = str(int(parts[-1]) + 1) + return ".".join(parts) + +def fetch_existing_versions() -> set: + try: + with urllib.request.urlopen(PYPI_API) as response: + data = json.load(response) + return set(data.get("releases", {}).keys()) + except Exception as e: + print(f"⚠️ Warning: Failed to fetch existing versions from PyPI: {e}") + return set() + +def find_next_available_dev_version(base_version: str) -> str: + existing_versions = fetch_existing_versions() + for i in range(1, 100): + candidate = f"{base_version}.dev{i}" + if candidate not in existing_versions: + return candidate + print("❌ Could not find available .devN slot after 100 attempts.") + sys.exit(1) + +def inject_version(version: str): + print(f"🔁 Updating version to: {version}") + + # Update version.py + VERSION_FILE.write_text(f'__version__ = "{version}"\n') + + # Update pyproject.toml + pyproject = PYPROJECT_FILE.read_text() + if PYPROJECT_PATTERN.search(pyproject): + new_pyproject = PYPROJECT_PATTERN.sub(f'version = "{version}"', pyproject) + PYPROJECT_FILE.write_text(new_pyproject) + print(f"✅ Updated {PYPROJECT_FILE}") + else: + print(f"⚠️ Could not find version field in {PYPROJECT_FILE}") + +def check_version_sync(): + """Ensure version.py and pyproject.toml are in sync""" + version_py = read_version_from_version_file(VERSION_FILE) + version_toml = read_version_from_pyproject(PYPROJECT_FILE) + + if version_py != version_toml: + print(f"❌ Version mismatch: {VERSION_FILE} has {version_py}, {PYPROJECT_FILE} has {version_toml}") + print("🔁 Syncing versions...") + inject_version(version_toml) # Use pyproject.toml as source of truth + return version_toml + + return version_py + +def main(): + dev_mode = "--dev" in sys.argv + + # Ensure versions are synced + current_version = check_version_sync() + previous_version = read_version_from_git("src/version.py") + + print(f"Current: {current_version}, Previous: {previous_version}") + + if current_version == previous_version: + if dev_mode: + base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version + new_version = find_next_available_dev_version(base_version) + inject_version(new_version) + print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + sys.exit(0) + else: + new_version = bump_patch_version(current_version) + inject_version(new_version) + print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + sys.exit(1) + else: + print("✅ Version already bumped — proceeding.") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/Dockerfile b/Dockerfile index 7602255..780e6fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use the official Python image as a base -FROM python:3.9 +FROM python:3.12 COPY src/socket_external_tools_runner.py / COPY src/core /core COPY entrypoint.sh / @@ -17,18 +17,17 @@ RUN curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh # Install Trivy RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.18.3 -#Install Trufflehog -# Install trufflehog +# Install Trufflehog RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin # Install Bandit -RUN pip install bandit +RUN pip install bandit socketsecurity # Install eslint RUN apt-get update && apt-get install -y curl && \ - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y nodejs && \ - npm install -g eslint eslint-plugin-security @typescript-eslint/parser @typescript-eslint/eslint-plugin + npm install -g eslint eslint-plugin-security @typescript-eslint/parser @typescript-eslint/eslint-plugin socket # Copy the entrypoint script and make it executable RUN chmod +x /entrypoint.sh diff --git a/README.md b/README.md index 71b4e1f..15eba46 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,18 @@ # Security Tools Scanning -The purpose of this action is to run various security tools, process their output, and then comment the results on a PR. It is expected to only run this on PRs +The purpose of this action is to run various security tools, process their output, and then comment the results on a PR. It is expected to only run this on PRs. + +## Supported Security Tools + +- **Bandit** - Python SAST analysis +- **Gosec** - Golang SAST analysis +- **ESLint** - JavaScript/TypeScript SAST analysis +- **Trivy** - Container image and Dockerfile vulnerability scanning +- **Trufflehog** - Secret scanning +- **Socket** - Dependency reachability analysis and supply chain risk scanning + - Uses `socket scan reach` to identify which vulnerable code paths are actually reachable in your application + - Includes stack trace information for reachable vulnerabilities to help with remediation + - Note: This is different from Socket SCA scanning which analyzes all dependencies ## Example Usage @@ -33,11 +45,16 @@ jobs: dockerfile_enabled: true image_enabled: true secret_scanning_enabled: true + socket_scanning_enabled: true # Trivy Configuration docker_images: "image:latest,test/image2:latest" dockerfiles: "Dockerfile,relative/path/Dockerfile" + # Socket Configuration + socket_org: "your-socket-org" # Required when socket_scanning_enabled is true + socket_api_key: ${{ secrets.SOCKET_API_KEY }} + # Exclusion settings trufflehog_exclude_dir: "node_modules/*,vendor,.git/*,.idea" trufflehog_show_unverified: False @@ -106,6 +123,9 @@ docker run --rm --name security-wrapper \ -e "INPUT_PYTHON_SAST_ENABLED=true" \ -e "PYTHONUNBUFFERED=1" \ -e "INPUT_SECRET_SCANNING_ENABLED=true" \ + -e "INPUT_SOCKET_SCANNING_ENABLED=true" \ + -e "INPUT_SOCKET_ORG=your-socket-org" \ # Required when socket_scanning_enabled is true + -e "INPUT_SOCKET_API_KEY=your-socket-api-key" \ -e "SOCKET_SCM_DISABLED=true" \ -e "INPUT_SOCKET_CONSOLE_MODE=json" \ socketdev/security-wrapper diff --git a/action.yml b/action.yml index 25baf30..09c3cfa 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,16 @@ inputs: required: false default: "false" + socket_scanning_enabled: + description: "Enable Socket reachability scanning" + required: false + default: "false" + + socket_sca_enabled: + description: "Enable Socket SCA (Software Composition Analysis) scanning" + required: false + default: "false" + # Docker Configuration docker_images: description: "Comma-separated list of Docker images to scan" @@ -65,6 +75,24 @@ inputs: required: false default: "false" + # Socket Configuration + socket_org: + description: "Socket organization for reachability scanning (required if socket_scanning_enabled is true)" + required: false + default: "" + socket_api_key: + description: "Socket API key for authentication" + required: false + default: "" + socket_security_api_key: + description: "Socket Security API key for SCA scanning (required if socket_sca_enabled is true)" + required: false + default: "" + socket_sca_files: + description: "Comma-separated list of manifest files to scan (e.g., package.json,requirements.txt,go.mod)" + required: false + default: "" + # Bandit Configuration bandit_exclude_dir: description: "Comma-separated list of directories to exclude in Bandit" diff --git a/debug_test.py b/debug_test.py new file mode 100644 index 0000000..94e0134 --- /dev/null +++ b/debug_test.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import sys +import os +import json +import tempfile + +# Add the src directory to the Python path +sys.path.insert(0, '/Users/douglascoburn/socket.dev/github/security-tools/src') + +def test_socket_sca_failure(): + """Test that Socket SCA scan failures are properly detected""" + + # Create a temporary directory for test files + with tempfile.TemporaryDirectory() as temp_dir: + # Create the socket_sca_output.json file with failure data + with open('/Users/douglascoburn/socket.dev/github/security-tools/test_socket_sca_failure.json', 'r') as f: + failure_data = json.load(f) + + socket_sca_file = os.path.join(temp_dir, 'socket_sca_output.json') + with open(socket_sca_file, 'w') as f: + json.dump(failure_data, f) + + # Set the required environment variables + original_temp_dir = os.environ.get('TEMP_OUTPUT_DIR') + original_scm_disabled = os.environ.get('SOCKET_SCM_DISABLED') + + os.environ['TEMP_OUTPUT_DIR'] = temp_dir + os.environ['SOCKET_SCM_DISABLED'] = 'true' + + try: + # Import and test the load_json function first + from socket_external_tools_runner import load_json, TOOL_CLASSES + + print(f"Testing load_json with file: {socket_sca_file}") + socket_sca_data = load_json("socket_sca_output.json", "SocketSCA") + print(f"Loaded data: {socket_sca_data}") + + if socket_sca_data: + print(f"scan_failed: {socket_sca_data.get('scan_failed', False)}") + print(f"new_alerts: {len(socket_sca_data.get('new_alerts', []))}") + + # Check if socket_sca is in TOOL_CLASSES + print(f"TOOL_CLASSES: {list(TOOL_CLASSES.keys())}") + + # Test the SocketSCA processor + if "socket_sca" in TOOL_CLASSES: + from core.connectors.socket_sca import SocketSCA + processor = SocketSCA() + events = processor.process_output(socket_sca_data, temp_dir, "SocketSCA") + print(f"Generated events: {len(events.get('events', []))}") + for event in events.get('events', []): + print(f"Event: {event.__dict__}") + else: + print("socket_sca not in TOOL_CLASSES - checking environment variables") + for var in ["INPUT_NODEJS_SCA_ENABLED", "INPUT_PYTHON_SCA_ENABLED"]: + print(f"{var}: {os.getenv(var, 'not set')}") + + except Exception as e: + print(f"Exception during testing: {e}") + import traceback + traceback.print_exc() + return False + finally: + # Restore original environment + if original_temp_dir: + os.environ['TEMP_OUTPUT_DIR'] = original_temp_dir + elif 'TEMP_OUTPUT_DIR' in os.environ: + del os.environ['TEMP_OUTPUT_DIR'] + + if original_scm_disabled: + os.environ['SOCKET_SCM_DISABLED'] = original_scm_disabled + elif 'SOCKET_SCM_DISABLED' in os.environ: + del os.environ['SOCKET_SCM_DISABLED'] + + return True + +if __name__ == '__main__': + test_socket_sca_failure() diff --git a/entrypoint.sh b/entrypoint.sh index 222c815..7bcb18f 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -13,6 +13,12 @@ GOSEC_RULES=${INPUT_GOSEC_RULES:-} TRIVY_EXCLUDE_DIR=${INPUT_TRIVY_EXCLUDE_DIR:-} TRIVY_RULES=${INPUT_TRIVY_RULES:-} +# Socket configuration +SOCKET_ORG=${INPUT_SOCKET_ORG:-} +SOCKET_API_KEY=${INPUT_SOCKET_API_KEY:-} +SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_SECURITY_API_KEY:-} +SOCKET_SCA_FILES=${INPUT_SOCKET_SCA_FILES:-} + # Set output directory for temp files if [[ -n "$OUTPUT_DIR" ]]; then TEMP_OUTPUT_DIR="$OUTPUT_DIR" @@ -58,6 +64,104 @@ if [[ "$INPUT_SECRET_SCANNING_ENABLED" == "true" ]]; then eval $trufflehog_cmd || : fi +# Run Socket Reachability Scanning if enabled +if [[ "$INPUT_SOCKET_SCANNING_ENABLED" == "true" ]]; then + echo "Running Socket Reachability Scanning" + + # Set Socket API key environment variable + if [[ -n "$INPUT_SOCKET_API_KEY" ]]; then + export SOCKET_SECURITY_API_KEY="$INPUT_SOCKET_API_KEY" + else + echo "Warning: Socket API key not provided. Socket scanning may fail." + fi + + # Require organization to be specified + if [[ -z "$INPUT_SOCKET_ORG" ]]; then + echo "Error: Socket organization is required when socket_scanning_enabled is true" + echo "Please set the socket_org input parameter" + exit 1 + fi + + SOCKET_ORG="$INPUT_SOCKET_ORG" + + # Run socket scan reach command from within the workspace directory + echo "Changing to workspace directory: $GITHUB_WORKSPACE" + cd "$GITHUB_WORKSPACE" || { echo "Failed to change to workspace directory"; exit 1; } + socket_cmd="socket scan reach --json --org $SOCKET_ORG ." + echo "Running: $socket_cmd" + eval $socket_cmd || echo "Socket scan failed or socket CLI not available" + + # Debug: Show the contents of .socket.facts.json if it exists + # if [[ -f ".socket.facts.json" ]]; then + # echo "=== Contents of .socket.facts.json ===" + # cat ".socket.facts.json" + # echo "=== End of .socket.facts.json ===" + # fi + + # The .socket.facts.json file is already in the correct location (GITHUB_WORKSPACE) + # No need to move it since the Python runner will look for it here + echo "Socket facts file location: $GITHUB_WORKSPACE/.socket.facts.json" +fi + +# Run Socket SCA Scanning if enabled +if [[ "$INPUT_SOCKET_SCA_ENABLED" == "true" ]]; then + echo "Running Socket SCA (Software Composition Analysis) Scanning" + + # Set Socket Security API key environment variable + if [[ -n "$INPUT_SOCKET_SECURITY_API_KEY" ]]; then + export SOCKET_SECURITY_API_KEY="$INPUT_SOCKET_SECURITY_API_KEY" + else + echo "Error: Socket Security API key is required when socket_sca_enabled is true" + echo "Please set the socket_security_api_key input parameter" + exit 1 + fi + + # Build socketcli command + socketcli_cmd="socketcli --enable-json --enable-diff --target-path $GITHUB_WORKSPACE --files \"['requirements.txt']\"" + + # Add specific files if provided + if [[ -n "$INPUT_SOCKET_SCA_FILES" ]]; then + # Convert comma-separated list to JSON array format + IFS=',' read -ra SCA_FILES <<< "$INPUT_SOCKET_SCA_FILES" + files_json="[" + for i in "${!SCA_FILES[@]}"; do + if [[ $i -gt 0 ]]; then + files_json+=", " + fi + files_json+="\"${SCA_FILES[$i]}\"" + done + files_json+="]" + socketcli_cmd+=" --files \"$files_json\"" + fi + + echo "Running: $socketcli_cmd" + # Capture output regardless of exit code, as Socket CLI may output JSON to stderr even on failure + set +e # Temporarily disable exit on error + temp_output_file=$(mktemp) + eval $socketcli_cmd > "$temp_output_file" 2>&1 + socketcli_exit_code=$? + set -e # Re-enable exit on error + + if [[ $socketcli_exit_code -ne 0 ]]; then + echo "Socket SCA scan exited with code $socketcli_exit_code" + fi + + # Extract JSON from the output (Socket CLI outputs JSON after log messages) + # Look for lines that start with a timestamp followed by JSON + if grep -q '": {' "$temp_output_file"; then + # Extract the JSON part (everything after the timestamp that contains JSON) + # Use a more specific pattern to remove the timestamp: YYYY-MM-DD HH:MM:SS,mmm: + grep '": {' "$temp_output_file" | tail -1 | sed 's/^[0-9-]*[[:space:]]*[0-9:,]*:[[:space:]]*{/{/' > "$TEMP_OUTPUT_DIR/socket_sca_output.json" + else + # If no JSON found, create a failure JSON + echo "No valid JSON output from Socket SCA, creating failure JSON" + echo '{"scan_failed": true, "new_alerts": [], "error": "Socket SCA command failed or produced invalid output"}' > "$TEMP_OUTPUT_DIR/socket_sca_output.json" + fi + + # Clean up temp file + rm -f "$temp_output_file" +fi + # POSIX-compatible file collection (replace mapfile) scan_files=() if [[ "$INPUT_SCAN_ALL" == "true" ]]; then diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cddc082 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "security-wrapper" +version = "1.0.20" +description = "Security tools scanning wrapper" +requires-python = ">=3.9" +dependencies = [ + "mdutils~=1.6.0", + "PyGithub~=2.4.0", + "requests~=2.32.3", + "tabulate~=0.9.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.uv] +dev-dependencies = [] diff --git a/src/core/connectors/classes.py b/src/core/connectors/classes.py index 9be19e4..085c0de 100644 --- a/src/core/connectors/classes.py +++ b/src/core/connectors/classes.py @@ -199,3 +199,280 @@ def __init__(self, **kwargs): self.data_source_id = data_source.get("ID", "") self.data_source_name = data_source.get("Name", "") self.data_source_url = data_source.get("URL", "") + +class SocketReachabilityTestResult(BaseTestResult): + def __init__(self, **kwargs): + self.component = kwargs.get("component", {}) + self.tier1_scan_id = kwargs.get("tier1_scan_id", "") + self.plugin_name = kwargs.get("plugin_name", "SocketReachability") + + # Extract component information from Socket reachability format + self.name = self.component.get("name", "unknown") + self.version = self.component.get("version", "unknown") + self.ecosystem = self.component.get("type", "npm") # Changed from "ecosystem" to "type" + + # Socket reachability specific fields + self.license = self.component.get("license", "") + self.direct = self.component.get("direct", False) + + # Handle Socket reachability results - updated to match actual CLI output + self.vulnerabilities = self.component.get("vulnerabilities", []) + self.reachability_data = self.component.get("reachability", []) + + # Set severity based on reachable vulnerabilities + self.severity = self._determine_severity() + self.issue_text = self._generate_issue_text() + + # Set file information + self.file = self._determine_file() + self.line = 1 # Default line number + + super().__init__(**kwargs) + + def _determine_severity(self): + """Determine severity based on reachable vulnerabilities.""" + if not self.vulnerabilities: + return "INFO" + + # Check for reachable vulnerabilities + for vuln in self.vulnerabilities: + reachability_info = vuln.get("reachabilityData", {}) + if reachability_info and not reachability_info.get("undeterminableReachability", True): + # If we have determinable reachability data, this is likely important + return "HIGH" + + # Check reachability array for any reachable items + for reach_item in self.reachability_data: + reachability_list = reach_item.get("reachability", []) + for reach in reachability_list: + if reach.get("type") == "reachable": + return "HIGH" + + return "MEDIUM" + + def _generate_issue_text(self): + """Generate a description of the reachable issues found.""" + if not self.vulnerabilities and not self.reachability_data: + return f"Component {self.name}@{self.version} analyzed for reachability - no issues found" + + issue_details = [] + + # Process vulnerabilities with reachability data + for vuln in self.vulnerabilities: + ghsa_id = vuln.get("ghsaId", "unknown") + reachability_info = vuln.get("reachabilityData", {}) + + if reachability_info: + pattern = reachability_info.get("pattern", []) + undeterminable = reachability_info.get("undeterminableReachability", True) + + if not undeterminable: + issue_detail = f"- Vulnerability {ghsa_id}: Has determinable reachability" + if pattern: + issue_detail += f" (pattern: {', '.join(pattern)})" + issue_details.append(issue_detail) + + # Process reachability data + for reach_item in self.reachability_data: + ghsa_id = reach_item.get("ghsa_id", "unknown") + reachability_list = reach_item.get("reachability", []) + + for reach in reachability_list: + if reach.get("type") == "reachable": + analysis_level = reach.get("analysisLevel", "unknown") + matches = reach.get("matches", []) + + issue_detail = f"- {ghsa_id}: REACHABLE ({analysis_level} analysis)" + + # Add match information + if matches: + match_info = [] + for match_chain in matches[:2]: # Limit to first 2 match chains + if match_chain and len(match_chain) > 0: + first_match = match_chain[0] + package = first_match.get("package", "unknown") + source_loc = first_match.get("sourceLocation", {}) + filename = source_loc.get("filename", "unknown") + line = source_loc.get("start", {}).get("line", "unknown") + confidence = first_match.get("confidence", 0) + + match_info.append(f"{package} at {filename}:{line} (confidence: {confidence})") + + if match_info: + issue_detail += f"\n Found in: {'; '.join(match_info)}" + + issue_details.append(issue_detail) + + if not issue_details: + return f"Component {self.name}@{self.version} analyzed for reachability - no reachable vulnerabilities found" + + base_text = f"Socket reachability analysis for {self.name}@{self.version}:" + return base_text + "\n" + "\n".join(issue_details) + + def _determine_file(self): + """Determine the file associated with this component.""" + ecosystem = self.ecosystem.lower() + if ecosystem == "npm" or ecosystem == "node": + return "package.json" + elif ecosystem == "pypi" or ecosystem == "python": + return "requirements.txt" + elif ecosystem == "go": + return "go.mod" + elif ecosystem == "ruby": + return "Gemfile" + else: + return "dependency_manifest" + + def has_reachability_alerts(self): + """Check if this component has reachable security alerts.""" + # Check if there are vulnerabilities with reachability data + for vuln in self.vulnerabilities: + reachability_info = vuln.get("reachabilityData", {}) + if reachability_info and not reachability_info.get("undeterminableReachability", True): + return True + + # Check if there are any reachable items in the reachability data + for reach_item in self.reachability_data: + reachability_list = reach_item.get("reachability", []) + for reach in reachability_list: + if reach.get("type") == "reachable": + return True + + return False + + def get_stack_traces_summary(self): + """Get a formatted summary of all stack traces for this component.""" + if not self.stack_traces: + return "" + + summary = f"Stack traces for {self.name}@{self.version}:\n" + for i, trace in enumerate(self.stack_traces[:3]): # Limit to first 3 traces + summary += f"\nTrace {i+1}:\n" + if isinstance(trace, list): + for frame in trace[:5]: # Limit to first 5 frames per trace + summary += f" at {frame}\n" + if len(trace) > 5: + summary += f" ... and {len(trace) - 5} more frames\n" + elif isinstance(trace, str): + summary += f" {trace}\n" + + if len(self.stack_traces) > 3: + summary += f"\n... and {len(self.stack_traces) - 3} more traces" + + return summary + + +class SocketSCATestResult(BaseTestResult): + def __init__(self, **kwargs): + self.alert = kwargs.get("alert", {}) + self.full_scan_id = kwargs.get("full_scan_id", "") + self.diff_url = kwargs.get("diff_url", "") + self.plugin_name = kwargs.get("plugin_name", "SocketSCA") + + # Extract alert information from Socket SCA format + self.package_name = self.alert.get("pkg_name", "unknown") + self.package_version = self.alert.get("pkg_version", "unknown") + self.package_type = self.alert.get("pkg_type", "unknown") + self.package_id = self.alert.get("pkg_id", "") + + # Socket SCA specific fields + self.alert_type = self.alert.get("type", "unknown") + self.severity = self.alert.get("severity", "unknown").upper() + self.description = self.alert.get("description", "") + self.title = self.alert.get("title", "") + self.suggestion = self.alert.get("suggestion", "") + self.next_step_title = self.alert.get("next_step_title", "") + self.purl = self.alert.get("purl", "") + self.url = self.alert.get("url", "") + self.manifests = self.alert.get("manifests", "") + self.introduced_by = self.alert.get("introduced_by", []) + + # Alert properties (props field contains additional context) + self.props = self.alert.get("props", {}) + + # Flags + self.error = self.alert.get("error", False) + self.warn = self.alert.get("warn", False) + self.monitor = self.alert.get("monitor", False) + self.ignore = self.alert.get("ignore", False) + + # Generate issue text + self.issue_text = self._generate_issue_text() + + # Set file information based on manifests + self.file = self._determine_file() + self.line = 1 # Default line number + + super().__init__(**kwargs) + + def _generate_issue_text(self): + """Generate a description of the Socket SCA alert.""" + base_text = f"Socket SCA Alert: {self.title}" + + details = [] + details.append(f"Package: {self.package_name}@{self.package_version} ({self.package_type})") + details.append(f"Type: {self.alert_type}") + details.append(f"Severity: {self.severity}") + + if self.description: + details.append(f"Description: {self.description}") + + if self.suggestion: + details.append(f"Suggestion: {self.suggestion}") + + if self.url: + details.append(f"More info: {self.url}") + + # Add props information if available + if self.props: + if "note" in self.props: + details.append(f"Note: {self.props['note']}") + if "notes" in self.props: + details.append(f"Notes: {self.props['notes']}") + if "confidence" in self.props: + details.append(f"Confidence: {self.props['confidence']}") + + # Add introduction path + if self.introduced_by: + intro_paths = [] + for path in self.introduced_by: + if isinstance(path, list) and len(path) >= 2: + intro_paths.append(f"{path[0]} via {path[1]}") + elif isinstance(path, list) and len(path) == 1: + intro_paths.append(path[0]) + else: + intro_paths.append(str(path)) + if intro_paths: + details.append(f"Introduced by: {', '.join(intro_paths)}") + + if self.diff_url: + details.append(f"Diff URL: {self.diff_url}") + + return base_text + "\n" + "\n".join([f" {detail}" for detail in details]) + + def _determine_file(self): + """Determine the file associated with this alert based on manifests.""" + if self.manifests: + # Return the first manifest file mentioned + return self.manifests.split(",")[0].strip() + + # Fallback based on package type + package_type = self.package_type.lower() + if package_type == "npm": + return "package.json" + elif package_type == "pypi": + return "requirements.txt" + elif package_type == "go": + return "go.mod" + elif package_type == "ruby": + return "Gemfile" + else: + return "dependency_manifest" + + def meets_severity_criteria(self, required_severities): + """Check if this alert meets the severity criteria.""" + return self.severity in required_severities + + def is_critical_alert(self): + """Check if this is a critical security alert.""" + return self.severity == "CRITICAL" and self.error diff --git a/src/core/connectors/socket/__init__.py b/src/core/connectors/socket/__init__.py new file mode 100644 index 0000000..3362606 --- /dev/null +++ b/src/core/connectors/socket/__init__.py @@ -0,0 +1,74 @@ +from core.connectors.classes import SocketReachabilityTestResult +from core import BaseTool +import json + + +class Socket(BaseTool): + result_class = SocketReachabilityTestResult + result_key = "components" + default_severities = {"CRITICAL", "HIGH"} + + @classmethod + def process_output(cls, data: dict, cwd: str, plugin_name: str = "SocketReachability") -> dict: + """Processes Socket reachability scan output and ensures compatibility with create_output.""" + metrics = { + "tests": {}, + "severities": {}, + "output": [], + "events": [] + } + + components = data.get(cls.result_key, []) + tier1_scan_id = data.get("tier1ReachabilityScanId", "") + + print(f"DEBUG: Processing {len(components)} components for Socket reachability") + + components_with_alerts = 0 + components_with_vulnerabilities = 0 + components_with_reachability = 0 + + for component in components: + # Skip empty components + if not component: + continue + + # Debug: Check what this component has + has_vulnerabilities = bool(component.get("vulnerabilities")) + has_reachability = bool(component.get("reachability")) + + if has_vulnerabilities: + components_with_vulnerabilities += 1 + if has_reachability: + components_with_reachability += 1 + + # Create a test result for each component with reachability data + test_result = cls.result_class( + component=component, + tier1_scan_id=tier1_scan_id, + cwd=cwd, + plugin_name=plugin_name + ) + + # Include components that have reachability alerts OR vulnerabilities + if test_result.has_reachability_alerts() or has_vulnerabilities or has_reachability: + components_with_alerts += 1 + test_name = cls.get_test_name(test_result) + + metrics["tests"].setdefault(test_name, 0) + metrics["tests"][test_name] += 1 + + metrics["output"].append(test_result) + metrics["events"].append(test_result) + + print(f"DEBUG: Found {components_with_vulnerabilities} components with vulnerabilities") + print(f"DEBUG: Found {components_with_reachability} components with reachability data") + print(f"DEBUG: Including {components_with_alerts} components in results") + + return metrics + + @staticmethod + def get_test_name(test_result): + """Generate a test name based on the Socket reachability analysis.""" + component_name = getattr(test_result, 'name', 'unknown') + severity = getattr(test_result, 'severity', 'unknown') + return f"SocketReachability_{component_name}_{severity}" diff --git a/src/core/connectors/socket_sca/__init__.py b/src/core/connectors/socket_sca/__init__.py new file mode 100644 index 0000000..0351afe --- /dev/null +++ b/src/core/connectors/socket_sca/__init__.py @@ -0,0 +1,77 @@ +from core.connectors.classes import SocketSCATestResult +from core import BaseTool +import json + + +class SocketSCA(BaseTool): + result_class = SocketSCATestResult + result_key = "new_alerts" + default_severities = {"CRITICAL", "HIGH"} + + @classmethod + def process_output(cls, data: dict, cwd: str, plugin_name: str = "SocketSCA") -> dict: + """Processes Socket SCA scan output and ensures compatibility with create_output.""" + metrics = { + "tests": {}, + "severities": {}, + "output": [], + "events": [] + } + + # Check if scan failed + scan_failed = data.get("scan_failed", False) + new_alerts = data.get(cls.result_key, []) + full_scan_id = data.get("full_scan_id", "") + diff_url = data.get("diff_url", "") + + # If scan failed, create a failure result + if scan_failed: + failure_result = cls.result_class( + alert={"type": "scan_failure", "severity": "critical", "description": "Socket SCA scan failed"}, + full_scan_id=full_scan_id, + diff_url=diff_url, + cwd=cwd, + plugin_name=plugin_name + ) + + # Always include scan failures regardless of severity filter + test_name = cls.get_test_name(failure_result) + metrics["tests"].setdefault(test_name, 0) + metrics["tests"][test_name] += 1 + metrics["output"].append(failure_result) + metrics["events"].append(failure_result) + + # Process new alerts + for alert in new_alerts: + # Skip empty alerts + if not alert: + continue + + # Create a test result for each alert + test_result = cls.result_class( + alert=alert, + full_scan_id=full_scan_id, + diff_url=diff_url, + cwd=cwd, + plugin_name=plugin_name + ) + + # Include alerts that meet severity criteria + if test_result.meets_severity_criteria(cls.default_severities): + test_name = cls.get_test_name(test_result) + + metrics["tests"].setdefault(test_name, 0) + metrics["tests"][test_name] += 1 + + metrics["output"].append(test_result) + metrics["events"].append(test_result) + + return metrics + + @staticmethod + def get_test_name(test_result): + """Generate a test name based on the Socket SCA analysis.""" + package_name = getattr(test_result, 'package_name', 'unknown') + alert_type = getattr(test_result, 'alert_type', 'unknown') + severity = getattr(test_result, 'severity', 'unknown') + return f"SocketSCA_{package_name}_{alert_type}_{severity}" diff --git a/src/core/plugins/console/console.py b/src/core/plugins/console/console.py index 8f02d0d..ba43839 100644 --- a/src/core/plugins/console/console.py +++ b/src/core/plugins/console/console.py @@ -118,6 +118,25 @@ def __init__(self, event: dict): self.CweIDs = event.get("CweIDs", []) +class SocketSCAEvent(BaseEvent): + def __init__(self, event: dict): + super().__init__( + issue_text=event.get("description", "Socket SCA scan issue"), + test_name=event.get("title", "Socket SCA Analysis"), + more_info=event.get("url", "No remediation guide available"), + Message=event.get("description", "Socket SCA scan issue"), + FilePath=event.get("manifests", "Unknown"), + Timestamp=event.get("timestamp", datetime.now(timezone.utc).isoformat()), + Plugin="Socket SCA", + Severity=event.get("severity", "Unknown") + ) + self.pkg_name = event.get("pkg_name", "Unknown") + self.pkg_version = event.get("pkg_version", "Unknown") + self.pkg_type = event.get("pkg_type", "Unknown") + self.alert_type = event.get("type", "Unknown") + self.suggestion = event.get("suggestion", "No suggestion available") + + class Console: def __init__(self, mode: str = 'console'): """ @@ -142,6 +161,10 @@ def normalize_events(raw_events: list, plugin: str) -> list: events.append(ESLintEvent(event.__dict__)) elif 'trivy' in plugin: events.append(TrivyEvent(event.__dict__)) + elif plugin == 'socket': + events.append(BanditEvent(event.__dict__)) # Use BanditEvent as a generic event for now + elif plugin == 'socket_sca': + events.append(SocketSCAEvent(event.__dict__)) else: print(f"Unknown event type {plugin}") return events @@ -155,9 +178,9 @@ def print_events(self, events: list, plugin: str) -> None: :param output_type: 'console', 'markdown', or 'json' :return: Formatted string (markdown/console) or JSON array (str) """ - # msg = f"No events to process for {plugin} plugin. Skipping output." + msg = f"No events to process for {plugin} plugin. Skipping output." if not events or len(events) == 0: - # print(msg) + print(msg) return normalized = Console.normalize_events(events, plugin) diff --git a/src/socket_external_tools_runner.py b/src/socket_external_tools_runner.py index bac4488..8e55b36 100644 --- a/src/socket_external_tools_runner.py +++ b/src/socket_external_tools_runner.py @@ -9,6 +9,8 @@ from core.connectors.trufflehog import Trufflehog from core.connectors.trivy import TrivyImage, TrivyDockerfile from core.connectors.eslint import ESLint +from core.connectors.socket import Socket +from core.connectors.socket_sca import SocketSCA from core.load_plugins import load_sumo_logic_plugin, load_ms_sentinel_plugin, load_console_plugin from tabulate import tabulate @@ -100,34 +102,55 @@ def consolidate_trivy_results(pattern: str) -> dict: if os.getenv("INPUT_JAVASCRIPT_SAST_ENABLED", "false").lower() == "true": TOOL_CLASSES["eslint"] = ESLint TOOL_NAMES["eslint"] = "ESLint" +if os.getenv("INPUT_SOCKET_SCANNING_ENABLED", "false").lower() == "true": + TOOL_CLASSES["socket"] = Socket + TOOL_NAMES["socket"] = "SocketReachability" +if os.getenv("INPUT_SOCKET_SCA_ENABLED", "false").lower() == "true": + TOOL_CLASSES["socket_sca"] = SocketSCA + TOOL_NAMES["socket_sca"] = "SocketSCA" def main(): + # Get the output directory for temp files + temp_output_dir = os.getenv("TEMP_OUTPUT_DIR", ".") + + def get_output_file_path(filename): + """Get the full path to an output file based on TEMP_OUTPUT_DIR""" + return os.path.join(temp_output_dir, filename) + # Load results only for enabled tools results = {} if "bandit" in TOOL_CLASSES: - bandit_data = load_json("bandit_output.json", "Bandit") + bandit_data = load_json(get_output_file_path("bandit_output.json"), "Bandit") if bandit_data: results["bandit"] = bandit_data if "gosec" in TOOL_CLASSES: - gosec_data = load_json("gosec_output.json", "Gosec") + gosec_data = load_json(get_output_file_path("gosec_output.json"), "Gosec") if gosec_data: results["gosec"] = gosec_data if "trufflehog" in TOOL_CLASSES: - trufflehog_data = load_json("trufflehog_output.json", "Trufflehog") + trufflehog_data = load_json(get_output_file_path("trufflehog_output.json"), "Trufflehog") if trufflehog_data: results["trufflehog"] = trufflehog_data if "trivy_image" in TOOL_CLASSES: - trivy_image_data = consolidate_trivy_results("trivy_image_*.json") + trivy_image_data = consolidate_trivy_results(get_output_file_path("trivy_image_*.json")) if trivy_image_data and trivy_image_data.get("Results"): results["trivy_image"] = trivy_image_data if "trivy_dockerfile" in TOOL_CLASSES: - trivy_dockerfile_data = consolidate_trivy_results("trivy_dockerfile_*.json") + trivy_dockerfile_data = consolidate_trivy_results(get_output_file_path("trivy_dockerfile_*.json")) if trivy_dockerfile_data and trivy_dockerfile_data.get("Results"): results["trivy_dockerfile"] = trivy_dockerfile_data if "eslint" in TOOL_CLASSES: - eslint_data = load_json("eslint_output.json", "ESLint") + eslint_data = load_json(get_output_file_path("eslint_output.json"), "ESLint") if eslint_data: results["eslint"] = eslint_data + if "socket" in TOOL_CLASSES: + socket_data = load_json(".socket.facts.json", "Socket") + if socket_data: + results["socket"] = socket_data + if "socket_sca" in TOOL_CLASSES: + socket_sca_data = load_json(get_output_file_path("socket_sca_output.json"), "SocketSCA") + if socket_sca_data: + results["socket_sca"] = socket_sca_data if any(results.values()): if not SCM_DISABLED: @@ -167,7 +190,14 @@ def main(): TOOL_CLASSES[key].default_severities = SEVERITIES tool_events[key] = TOOL_CLASSES[key].process_output(data, cwd, TOOL_NAMES[key]) - if len(tool_events) > 0: + # Check for scan failures that should force an exit regardless of other conditions + scan_failed = False + for key, data in results.items(): + if isinstance(data, dict) and data.get("scan_failed", False): + print(f"{TOOL_NAMES.get(key, key)} scan failed") + scan_failed = True + + if len(tool_events) > 0 or scan_failed: # Only show integration messages if there is at least one event total_events = sum(len(events.get("events", [])) for events in tool_events.values()) if total_events > 0: @@ -190,6 +220,9 @@ def main(): if console_output: print(errors) if (errors := console_output.print_events(events.get("output", []), key)) else [] + + if scan_failed: + print("Security scan failed - exiting with error") exit(1) else: print("No issues detected with Socket Security Tools") diff --git a/src/version.py b/src/version.py new file mode 100644 index 0000000..6e3c058 --- /dev/null +++ b/src/version.py @@ -0,0 +1 @@ +__version__ = "1.0.20" diff --git a/test_fix.py b/test_fix.py new file mode 100644 index 0000000..0364261 --- /dev/null +++ b/test_fix.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +import sys +import os +import json +import tempfile +import shutil + +# Add the src directory to the Python path +sys.path.insert(0, '/Users/douglascoburn/socket.dev/github/security-tools/src') + +from socket_external_tools_runner import main + +def test_socket_sca_failure(): + """Test that Socket SCA scan failures are properly detected and cause exit(1)""" + + # Create a temporary directory for test files + with tempfile.TemporaryDirectory() as temp_dir: + # Create the socket_sca_output.json file with failure data + with open('/Users/douglascoburn/socket.dev/github/security-tools/test_socket_sca_failure.json', 'r') as f: + failure_data = json.load(f) + + socket_sca_file = os.path.join(temp_dir, 'socket_sca_output.json') + with open(socket_sca_file, 'w') as f: + json.dump(failure_data, f) + + # Set the TEMP_OUTPUT_DIR environment variable + original_temp_dir = os.environ.get('TEMP_OUTPUT_DIR') + os.environ['TEMP_OUTPUT_DIR'] = temp_dir + + try: + # This should exit with code 1 due to scan failure + print("Testing Socket SCA failure handling...") + main() + print("ERROR: main() should have exited with code 1!") + return False + except SystemExit as e: + if e.code == 1: + print("SUCCESS: main() correctly exited with code 1 for scan failure") + return True + else: + print(f"ERROR: main() exited with code {e.code}, expected 1") + return False + except Exception as e: + print(f"ERROR: Unexpected exception: {e}") + return False + finally: + # Restore original environment + if original_temp_dir: + os.environ['TEMP_OUTPUT_DIR'] = original_temp_dir + elif 'TEMP_OUTPUT_DIR' in os.environ: + del os.environ['TEMP_OUTPUT_DIR'] + +if __name__ == '__main__': + success = test_socket_sca_failure() + sys.exit(0 if success else 1) diff --git a/test_real_failure.py b/test_real_failure.py new file mode 100644 index 0000000..36cfed6 --- /dev/null +++ b/test_real_failure.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 + +import sys +import os +import json +import tempfile +import shutil + +# Add the src directory to the Python path +sys.path.insert(0, '/Users/douglascoburn/socket.dev/github/security-tools/src') + +def test_real_socket_sca_failure(): + """Test with the actual Socket SCA failure output provided by the user""" + + # Create a temporary directory for test files + with tempfile.TemporaryDirectory() as temp_dir: + # Copy the real Socket SCA failure JSON to the temp directory + with open('/Users/douglascoburn/socket.dev/github/security-tools/real_socket_sca_failure.json', 'r') as f: + failure_data = json.load(f) + + socket_sca_file = os.path.join(temp_dir, 'socket_sca_output.json') + with open(socket_sca_file, 'w') as f: + json.dump(failure_data, f) + + print(f"Created test file: {socket_sca_file}") + print(f"File contents: {json.dumps(failure_data, indent=2)[:200]}...") + + # Set the required environment variables + original_env = {} + test_env = { + 'TEMP_OUTPUT_DIR': temp_dir, + 'SOCKET_SCM_DISABLED': 'true', + 'INPUT_SOCKET_SCA_ENABLED': 'true' # Enable Socket SCA processing + } + + for key, value in test_env.items(): + original_env[key] = os.environ.get(key) + os.environ[key] = value + + try: + # Import and test step by step + from socket_external_tools_runner import main, TOOL_CLASSES + + print(f"TOOL_CLASSES keys: {list(TOOL_CLASSES.keys())}") + + if "socket_sca" not in TOOL_CLASSES: + print("ERROR: socket_sca not in TOOL_CLASSES") + print("Available environment variables:") + for key in ['INPUT_NODEJS_SCA_ENABLED', 'INPUT_PYTHON_SCA_ENABLED', 'INPUT_SOCKET_SCA_ENABLED']: + print(f" {key}: {os.environ.get(key, 'not set')}") + return False + + # This should exit with code 1 due to scan failure and critical alerts + print("Testing Socket SCA failure handling with real data...") + try: + main() + print("ERROR: main() should have exited with code 1!") + return False + except SystemExit as e: + if e.code == 1: + print("SUCCESS: main() correctly exited with code 1 for scan failure") + return True + else: + print(f"ERROR: main() exited with code {e.code}, expected 1") + return False + + except Exception as e: + print(f"Exception during testing: {e}") + import traceback + traceback.print_exc() + return False + finally: + # Restore original environment + for key, original_value in original_env.items(): + if original_value is not None: + os.environ[key] = original_value + elif key in os.environ: + del os.environ[key] + + return True + +if __name__ == '__main__': + success = test_real_socket_sca_failure() + sys.exit(0 if success else 1) diff --git a/test_socket_output_with_logs.txt b/test_socket_output_with_logs.txt new file mode 100644 index 0000000..3632227 --- /dev/null +++ b/test_socket_output_with_logs.txt @@ -0,0 +1,15 @@ +2025-08-25 02:45:21,862: Starting Socket Security CLI version 2.2.5 +2025-08-25 02:45:22,580: API Mode +2025-08-25 02:45:24,043: Total files found: 3 +2025-08-25 02:45:24,159: Creating new full scan +2025-08-25 02:45:25,466: Total time to create new full scan: 1.31 +2025-08-25 02:45:30,038: Full scans are ready in 0.21 seconds +2025-08-25 02:45:30,038: Comparing scans - Head scan ID: be5b5393-8b64-46c4-9d6f-fa26dfc111b9, New scan ID: fa4ae079-be21-4ebb-8212-e387c8f1d853 +2025-08-25 02:45:32,870: Diff Report Gathered in 2.83 seconds +2025-08-25 02:45:32,871: Diff report artifact counts: +2025-08-25 02:45:32,871: Added: 263 +2025-08-25 02:45:32,871: Removed: 0 +2025-08-25 02:45:32,871: Unchanged: 821 +2025-08-25 02:45:32,871: Replaced: 0 +2025-08-25 02:45:32,871: Updated: 0 +2025-08-25 02:45:42,714: {"scan_failed": true, "new_alerts": [{"pkg_type": "npm", "pkg_name": "jsonwebtoken-esm-plugin", "pkg_version": "1.0.0", "pkg_id": "26530702491", "props": {"id": 595355, "note": "This code is malicious information-stealing malware."}, "key": "QjBVDpguRZJldlZd1beEiklXZHFolnSnuX1LEOtuF6wQ", "type": "malware", "severity": "critical", "description": "This package is malware. We have asked the package registry to remove it.", "title": "Known malware", "suggestion": "It is strongly recommended that malware is removed from your codebase.", "next_step_title": "What is known malware?", "introduced_by": [["direct", "workspace/package.json"]], "purl": "jsonwebtoken-esm-plugin@1.0.0", "url": "https://socket.dev/npm/package/jsonwebtoken-esm-plugin/overview/1.0.0", "manifests": "workspace/package.json", "error": true, "warn": false, "monitor": false, "ignore": false}], "full_scan_id": "71d3e758-e1a1-49b9-b06a-af78738cc9c3", "diff_url": "https://socket.dev/dashboard/org/socketdev-demo/diff/61cd3bb0-85c0-47bd-89a7-014d68d21123/71d3e758-e1a1-49b9-b06a-af78738cc9c3"} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0243700 --- /dev/null +++ b/uv.lock @@ -0,0 +1,437 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload-time = "2024-09-04T20:45:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload-time = "2024-09-04T20:45:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload-time = "2024-09-04T20:45:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload-time = "2024-09-04T20:45:20.226Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" }, + { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" }, + { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" }, + { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, + { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, + { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, + { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/56/d2/4482d97c948c029be08cb29854a91bd2ae8da7eb9c4152461f1244dcea70/cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012", size = 3576812, upload-time = "2025-08-05T23:59:04.833Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/55fc238fcaa122855442604b8badb2d442367dfbd5a7ca4bb0bd346e263a/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d", size = 4141694, upload-time = "2025-08-05T23:59:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/3ea4fa6fbe51baf3903806a0241c666b04c73d2358a3ecce09ebee8b9622/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d", size = 4375010, upload-time = "2025-08-05T23:59:08.14Z" }, + { url = "https://files.pythonhosted.org/packages/50/42/ec5a892d82d2a2c29f80fc19ced4ba669bca29f032faf6989609cff1f8dc/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da", size = 4141377, upload-time = "2025-08-05T23:59:09.584Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d7/246c4c973a22b9c2931999da953a2c19cae7c66b9154c2d62ffed811225e/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db", size = 4374609, upload-time = "2025-08-05T23:59:11.923Z" }, + { url = "https://files.pythonhosted.org/packages/78/6d/c49ccf243f0a1b0781c2a8de8123ee552f0c8a417c6367a24d2ecb7c11b3/cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18", size = 3322156, upload-time = "2025-08-05T23:59:13.597Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" }, +] + +[[package]] +name = "deprecated" +version = "1.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "mdutils" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/ec/6240f147530a2c8d362ed3f2f7985aca92cda68c25ffc2fc216504b17148/mdutils-1.6.0.tar.gz", hash = "sha256:647f3cf00df39fee6c57fa6738dc1160fce1788276b5530c87d43a70cdefdaf1", size = 22881, upload-time = "2023-04-29T14:39:34.994Z" } + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pygithub" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a0/1e8b8ca88df9857836f5bf8e3ee15dfb810d19814ef700b12f99ce11f691/pygithub-2.4.0.tar.gz", hash = "sha256:6601e22627e87bac192f1e2e39c6e6f69a43152cfb8f307cee575879320b3051", size = 3476673, upload-time = "2024-08-26T06:49:44.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/f3/e185613c411757c0c18b904ea2db173f2872397eddf444a3fe8cdde47077/PyGithub-2.4.0-py3-none-any.whl", hash = "sha256:81935aa4bdc939fba98fee1cb47422c09157c56a27966476ff92775602b9ee24", size = 362599, upload-time = "2024-08-26T06:49:42.351Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "security-wrapper" +version = "1.0.18" +source = { editable = "." } +dependencies = [ + { name = "mdutils" }, + { name = "pygithub" }, + { name = "requests" }, + { name = "tabulate" }, +] + +[package.metadata] +requires-dist = [ + { name = "mdutils", specifier = "~=1.6.0" }, + { name = "pygithub", specifier = "~=2.4.0" }, + { name = "requests", specifier = "~=2.32.3" }, + { name = "tabulate", specifier = "~=0.9.0" }, +] + +[package.metadata.requires-dev] +dev = [] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/41/be/be9b3b0a461ee3e30278706f3f3759b9b69afeedef7fe686036286c04ac6/wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc", size = 53485, upload-time = "2025-08-12T05:51:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a8/8f61d6b8f526efc8c10e12bf80b4206099fea78ade70427846a37bc9cbea/wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9", size = 38675, upload-time = "2025-08-12T05:51:42.885Z" }, + { url = "https://files.pythonhosted.org/packages/48/f1/23950c29a25637b74b322f9e425a17cc01a478f6afb35138ecb697f9558d/wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d", size = 38956, upload-time = "2025-08-12T05:52:03.149Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/dd0791943613885f62619f18ee6107e6133237a6b6ed8a9ecfac339d0b4f/wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a", size = 81745, upload-time = "2025-08-12T05:52:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/bb2d19bd1a614cc4f438abac13ae26c57186197920432d2a915183b15a8b/wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139", size = 82833, upload-time = "2025-08-12T05:52:27.738Z" }, + { url = "https://files.pythonhosted.org/packages/8d/eb/66579aea6ad36f07617fedca8e282e49c7c9bab64c63b446cfe4f7f47a49/wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df", size = 81889, upload-time = "2025-08-12T05:52:29.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/9c/a56b5ac0e2473bdc3fb11b22dd69ff423154d63861cf77911cdde5e38fd2/wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b", size = 81344, upload-time = "2025-08-12T05:52:50.869Z" }, + { url = "https://files.pythonhosted.org/packages/93/4c/9bd735c42641d81cb58d7bfb142c58f95c833962d15113026705add41a07/wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81", size = 36462, upload-time = "2025-08-12T05:53:19.623Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ea/0b72f29cb5ebc16eb55c57dc0c98e5de76fc97f435fd407f7d409459c0a6/wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f", size = 38740, upload-time = "2025-08-12T05:53:18.271Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8b/9eae65fb92321e38dbfec7719b87d840a4b92fde83fd1bbf238c5488d055/wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f", size = 36806, upload-time = "2025-08-12T05:52:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] From 5b5daea7805d4481b98cf3e6c334d3afabc8b6d5 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sun, 24 Aug 2025 20:02:32 -0700 Subject: [PATCH 06/11] Removed test files --- pyproject.toml | 2 +- src/version.py | 2 +- test_fix.py | 56 --------------------- test_real_failure.py | 84 -------------------------------- test_socket_output_with_logs.txt | 15 ------ 5 files changed, 2 insertions(+), 157 deletions(-) delete mode 100644 test_fix.py delete mode 100644 test_real_failure.py delete mode 100644 test_socket_output_with_logs.txt diff --git a/pyproject.toml b/pyproject.toml index cddc082..7055de7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.20" +version = "1.0.21" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ diff --git a/src/version.py b/src/version.py index 6e3c058..c916e68 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.20" +__version__ = "1.0.21" diff --git a/test_fix.py b/test_fix.py deleted file mode 100644 index 0364261..0000000 --- a/test_fix.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import os -import json -import tempfile -import shutil - -# Add the src directory to the Python path -sys.path.insert(0, '/Users/douglascoburn/socket.dev/github/security-tools/src') - -from socket_external_tools_runner import main - -def test_socket_sca_failure(): - """Test that Socket SCA scan failures are properly detected and cause exit(1)""" - - # Create a temporary directory for test files - with tempfile.TemporaryDirectory() as temp_dir: - # Create the socket_sca_output.json file with failure data - with open('/Users/douglascoburn/socket.dev/github/security-tools/test_socket_sca_failure.json', 'r') as f: - failure_data = json.load(f) - - socket_sca_file = os.path.join(temp_dir, 'socket_sca_output.json') - with open(socket_sca_file, 'w') as f: - json.dump(failure_data, f) - - # Set the TEMP_OUTPUT_DIR environment variable - original_temp_dir = os.environ.get('TEMP_OUTPUT_DIR') - os.environ['TEMP_OUTPUT_DIR'] = temp_dir - - try: - # This should exit with code 1 due to scan failure - print("Testing Socket SCA failure handling...") - main() - print("ERROR: main() should have exited with code 1!") - return False - except SystemExit as e: - if e.code == 1: - print("SUCCESS: main() correctly exited with code 1 for scan failure") - return True - else: - print(f"ERROR: main() exited with code {e.code}, expected 1") - return False - except Exception as e: - print(f"ERROR: Unexpected exception: {e}") - return False - finally: - # Restore original environment - if original_temp_dir: - os.environ['TEMP_OUTPUT_DIR'] = original_temp_dir - elif 'TEMP_OUTPUT_DIR' in os.environ: - del os.environ['TEMP_OUTPUT_DIR'] - -if __name__ == '__main__': - success = test_socket_sca_failure() - sys.exit(0 if success else 1) diff --git a/test_real_failure.py b/test_real_failure.py deleted file mode 100644 index 36cfed6..0000000 --- a/test_real_failure.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import os -import json -import tempfile -import shutil - -# Add the src directory to the Python path -sys.path.insert(0, '/Users/douglascoburn/socket.dev/github/security-tools/src') - -def test_real_socket_sca_failure(): - """Test with the actual Socket SCA failure output provided by the user""" - - # Create a temporary directory for test files - with tempfile.TemporaryDirectory() as temp_dir: - # Copy the real Socket SCA failure JSON to the temp directory - with open('/Users/douglascoburn/socket.dev/github/security-tools/real_socket_sca_failure.json', 'r') as f: - failure_data = json.load(f) - - socket_sca_file = os.path.join(temp_dir, 'socket_sca_output.json') - with open(socket_sca_file, 'w') as f: - json.dump(failure_data, f) - - print(f"Created test file: {socket_sca_file}") - print(f"File contents: {json.dumps(failure_data, indent=2)[:200]}...") - - # Set the required environment variables - original_env = {} - test_env = { - 'TEMP_OUTPUT_DIR': temp_dir, - 'SOCKET_SCM_DISABLED': 'true', - 'INPUT_SOCKET_SCA_ENABLED': 'true' # Enable Socket SCA processing - } - - for key, value in test_env.items(): - original_env[key] = os.environ.get(key) - os.environ[key] = value - - try: - # Import and test step by step - from socket_external_tools_runner import main, TOOL_CLASSES - - print(f"TOOL_CLASSES keys: {list(TOOL_CLASSES.keys())}") - - if "socket_sca" not in TOOL_CLASSES: - print("ERROR: socket_sca not in TOOL_CLASSES") - print("Available environment variables:") - for key in ['INPUT_NODEJS_SCA_ENABLED', 'INPUT_PYTHON_SCA_ENABLED', 'INPUT_SOCKET_SCA_ENABLED']: - print(f" {key}: {os.environ.get(key, 'not set')}") - return False - - # This should exit with code 1 due to scan failure and critical alerts - print("Testing Socket SCA failure handling with real data...") - try: - main() - print("ERROR: main() should have exited with code 1!") - return False - except SystemExit as e: - if e.code == 1: - print("SUCCESS: main() correctly exited with code 1 for scan failure") - return True - else: - print(f"ERROR: main() exited with code {e.code}, expected 1") - return False - - except Exception as e: - print(f"Exception during testing: {e}") - import traceback - traceback.print_exc() - return False - finally: - # Restore original environment - for key, original_value in original_env.items(): - if original_value is not None: - os.environ[key] = original_value - elif key in os.environ: - del os.environ[key] - - return True - -if __name__ == '__main__': - success = test_real_socket_sca_failure() - sys.exit(0 if success else 1) diff --git a/test_socket_output_with_logs.txt b/test_socket_output_with_logs.txt deleted file mode 100644 index 3632227..0000000 --- a/test_socket_output_with_logs.txt +++ /dev/null @@ -1,15 +0,0 @@ -2025-08-25 02:45:21,862: Starting Socket Security CLI version 2.2.5 -2025-08-25 02:45:22,580: API Mode -2025-08-25 02:45:24,043: Total files found: 3 -2025-08-25 02:45:24,159: Creating new full scan -2025-08-25 02:45:25,466: Total time to create new full scan: 1.31 -2025-08-25 02:45:30,038: Full scans are ready in 0.21 seconds -2025-08-25 02:45:30,038: Comparing scans - Head scan ID: be5b5393-8b64-46c4-9d6f-fa26dfc111b9, New scan ID: fa4ae079-be21-4ebb-8212-e387c8f1d853 -2025-08-25 02:45:32,870: Diff Report Gathered in 2.83 seconds -2025-08-25 02:45:32,871: Diff report artifact counts: -2025-08-25 02:45:32,871: Added: 263 -2025-08-25 02:45:32,871: Removed: 0 -2025-08-25 02:45:32,871: Unchanged: 821 -2025-08-25 02:45:32,871: Replaced: 0 -2025-08-25 02:45:32,871: Updated: 0 -2025-08-25 02:45:42,714: {"scan_failed": true, "new_alerts": [{"pkg_type": "npm", "pkg_name": "jsonwebtoken-esm-plugin", "pkg_version": "1.0.0", "pkg_id": "26530702491", "props": {"id": 595355, "note": "This code is malicious information-stealing malware."}, "key": "QjBVDpguRZJldlZd1beEiklXZHFolnSnuX1LEOtuF6wQ", "type": "malware", "severity": "critical", "description": "This package is malware. We have asked the package registry to remove it.", "title": "Known malware", "suggestion": "It is strongly recommended that malware is removed from your codebase.", "next_step_title": "What is known malware?", "introduced_by": [["direct", "workspace/package.json"]], "purl": "jsonwebtoken-esm-plugin@1.0.0", "url": "https://socket.dev/npm/package/jsonwebtoken-esm-plugin/overview/1.0.0", "manifests": "workspace/package.json", "error": true, "warn": false, "monitor": false, "ignore": false}], "full_scan_id": "71d3e758-e1a1-49b9-b06a-af78738cc9c3", "diff_url": "https://socket.dev/dashboard/org/socketdev-demo/diff/61cd3bb0-85c0-47bd-89a7-014d68d21123/71d3e758-e1a1-49b9-b06a-af78738cc9c3"} From cf607df410b40a2a063a5872d18d78d686ec484c Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sun, 24 Aug 2025 20:28:47 -0700 Subject: [PATCH 07/11] WIP: Current changes before merging jira support branch --- .gitignore | 3 +- README.md | 11 + entrypoint.sh | 27 ++ pyproject.toml | 2 +- src/core/socket_facts_consolidator.py | 548 ++++++++++++++++++++++++++ src/core/socket_facts_processor.py | 234 +++++++++++ src/socket_external_tools_runner.py | 194 ++++++--- src/version.py | 2 +- 8 files changed, 969 insertions(+), 52 deletions(-) create mode 100644 src/core/socket_facts_consolidator.py create mode 100644 src/core/socket_facts_processor.py diff --git a/.gitignore b/.gitignore index aeb8181..2206f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ test.py file_generator.py .env *.md -test_results \ No newline at end of file +test_results +local_tests \ No newline at end of file diff --git a/README.md b/README.md index 15eba46..d4f1273 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ The purpose of this action is to run various security tools, process their output, and then comment the results on a PR. It is expected to only run this on PRs. +## New: Consolidated Socket Facts Format + +Starting with version 2.0.0, all security tool results are consolidated into a unified `.socket.facts.json` format. This provides: + +- **Unified Processing**: All security findings in a single, consistent format +- **Enhanced Integration**: Easier integration with Socket's dependency analysis +- **Custom Components**: Support for organization-specific component types +- **Backward Compatibility**: Existing workflows continue to work unchanged + +The consolidated format extends Socket's dependency data with external security findings from SAST scanners, secret scanners, and container scanners. + ## Supported Security Tools - **Bandit** - Python SAST analysis diff --git a/entrypoint.sh b/entrypoint.sh index 7bcb18f..c5adf72 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -315,6 +315,33 @@ fi if [ "$LOCAL_TESTING" != "true" ]; then cd "$WORKSPACE" fi + +# Consolidate all security tool results into .socket.facts.json format +echo "Consolidating security tool results into .socket.facts.json format" +if [[ "$DEV_MODE" == "true" ]]; then + CONSOLIDATOR_SCRIPT_PATH="$WORKSPACE/src/core/socket_facts_consolidator.py" +else + CONSOLIDATOR_SCRIPT_PATH="$WORKSPACE/socket_facts_consolidator.py" +fi + +# Consolidate all security tool results into .socket.facts.json format +echo "Consolidating security tool results into .socket.facts.json format" +if [[ "$DEV_MODE" == "true" ]]; then + CONSOLIDATOR_SCRIPT_DIR="$WORKSPACE/src" +else + CONSOLIDATOR_SCRIPT_DIR="$WORKSPACE" +fi + +python -c " +import sys +import os +sys.path.insert(0, '$CONSOLIDATOR_SCRIPT_DIR') +from core.socket_facts_consolidator import SocketFactsConsolidator +consolidator = SocketFactsConsolidator('$GITHUB_WORKSPACE') +consolidator.save_consolidated_facts('$GITHUB_WORKSPACE/.socket.facts.json') +print('Successfully consolidated security tool results into .socket.facts.json') +" || echo "Warning: Could not consolidate results, continuing with individual tool processing" + # Run the Python script from the correct directory and path if [[ -n "$PY_SCRIPT_PATH" ]]; then FINAL_PY_SCRIPT_PATH="$PY_SCRIPT_PATH" diff --git a/pyproject.toml b/pyproject.toml index 7055de7..d9268ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.21" +version = "1.0.22" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ diff --git a/src/core/socket_facts_consolidator.py b/src/core/socket_facts_consolidator.py new file mode 100644 index 0000000..e1562bb --- /dev/null +++ b/src/core/socket_facts_consolidator.py @@ -0,0 +1,548 @@ +""" +Socket Facts Consolidator + +This module consolidates results from various security tools (SAST, Secret Scanning, +Container Scanning, etc.) into a unified .socket.facts.json format. This allows all +tool results to be processed consistently by the runner. + +The format follows the Socket Facts schema with an "alerts" extension for non-package +security findings like SAST issues, secrets, and container vulnerabilities. +""" + +import json +import os +import uuid +from typing import Dict, List, Any, Optional +from datetime import datetime, timezone + + +class SocketFactsConsolidator: + """Consolidates security tool results into Socket Facts format.""" + + def __init__(self, workspace_path: str = "."): + self.workspace_path = workspace_path + self.consolidated_facts = { + "components": [] + } + + def load_existing_socket_facts(self, facts_file_path: str = ".socket.facts.json") -> Dict[str, Any]: + """Load existing socket facts file if it exists.""" + try: + with open(facts_file_path, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {"components": []} + + def consolidate_all_results(self, temp_output_dir: str = ".") -> Dict[str, Any]: + """Consolidate all security tool results into a single socket facts format.""" + # Start with existing socket facts (from Socket tools) + socket_facts_path = os.path.join(self.workspace_path, ".socket.facts.json") + consolidated = self.load_existing_socket_facts(socket_facts_path) + + # Add external security findings as synthetic components with alerts + external_components = [] + + # Process SAST results + external_components.extend(self._process_bandit_results(temp_output_dir)) + external_components.extend(self._process_gosec_results(temp_output_dir)) + external_components.extend(self._process_eslint_results(temp_output_dir)) + + # Process Secret Scanning results + external_components.extend(self._process_trufflehog_results(temp_output_dir)) + + # Process Container Scanning results + external_components.extend(self._process_trivy_results(temp_output_dir)) + + # Process Socket SCA results + external_components.extend(self._process_socket_sca_results(temp_output_dir)) + + # Add external components to consolidated facts + if external_components: + consolidated["components"].extend(external_components) + + return consolidated + + def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: + """Process Bandit SAST results into socket facts format.""" + bandit_file = os.path.join(temp_output_dir, "bandit_output.json") + if not os.path.exists(bandit_file): + return [] + + try: + with open(bandit_file, 'r') as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return [] + + results = data.get("results", []) + if not results: + return [] + + # Group by file to create components + file_components = {} + + for issue in results: + filename = issue.get("filename", "unknown") + # Normalize filename relative to workspace + if filename.startswith("./"): + filename = filename[2:] + + if filename not in file_components: + file_components[filename] = { + "id": str(uuid.uuid4()), + "type": "external-sast-python", + "name": f"bandit-scan-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:external-sast/bandit@1.0.0", + "direct": True, + "dev": False, + "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "alerts": [] + } + + # Create alert for this issue + alert = { + "type": "external-sast-python", + "severity": self._map_bandit_severity(issue.get("issue_severity", "UNKNOWN")), + "generatedBy": "bandit", + "props": { + "name": issue.get("test_name", "unknown"), + "description": issue.get("issue_text", ""), + "test_id": issue.get("test_id", ""), + "confidence": issue.get("issue_confidence", ""), + "cwe": issue.get("issue_cwe", {}), + "more_info": issue.get("more_info", "") + }, + "location": { + "file": filename, + "start": issue.get("line_number", 1), + "end": issue.get("line_number", 1), + "column_start": issue.get("col_offset", 0), + "column_end": issue.get("end_col_offset", 0) + } + } + + file_components[filename]["alerts"].append(alert) + + return list(file_components.values()) + + def _process_gosec_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: + """Process Gosec SAST results into socket facts format.""" + gosec_file = os.path.join(temp_output_dir, "gosec_output.json") + if not os.path.exists(gosec_file): + return [] + + try: + with open(gosec_file, 'r') as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return [] + + issues = data.get("Issues", []) + if not issues: + return [] + + # Group by file to create components + file_components = {} + + for issue in issues: + filename = issue.get("file", "unknown") + # Normalize filename relative to workspace + if filename.startswith("./"): + filename = filename[2:] + + if filename not in file_components: + file_components[filename] = { + "id": str(uuid.uuid4()), + "type": "external-sast-golang", + "name": f"gosec-scan-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:external-sast/gosec@1.0.0", + "direct": True, + "dev": False, + "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "alerts": [] + } + + # Create alert for this issue + alert = { + "type": "external-sast-golang", + "severity": self._map_gosec_severity(issue.get("severity", "UNKNOWN")), + "generatedBy": "gosec", + "props": { + "name": issue.get("rule_id", "unknown"), + "description": issue.get("details", ""), + "confidence": issue.get("confidence", ""), + "cwe": issue.get("cwe", {}), + "nosec": issue.get("nosec", False) + }, + "location": { + "file": filename, + "start": int(issue.get("line", 1)), + "end": int(issue.get("line", 1)), + "column_start": int(issue.get("column", 0)), + "column_end": int(issue.get("column", 0)) + } + } + + file_components[filename]["alerts"].append(alert) + + return list(file_components.values()) + + def _process_eslint_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: + """Process ESLint SAST results into socket facts format.""" + eslint_file = os.path.join(temp_output_dir, "eslint_output.json") + if not os.path.exists(eslint_file): + return [] + + try: + with open(eslint_file, 'r') as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return [] + + if not isinstance(data, list): + return [] + + # Group by file to create components + file_components = {} + + for file_result in data: + filename = file_result.get("filePath", "unknown") + # Normalize filename relative to workspace + if filename.startswith("./"): + filename = filename[2:] + + messages = file_result.get("messages", []) + if not messages: + continue + + if filename not in file_components: + file_components[filename] = { + "id": str(uuid.uuid4()), + "type": "external-sast-javascript", + "name": f"eslint-scan-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:external-sast/eslint@1.0.0", + "direct": True, + "dev": False, + "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "alerts": [] + } + + for message in messages: + # Only process error-level issues + if message.get("severity", 0) < 2: + continue + + alert = { + "type": "external-sast-javascript", + "severity": "medium", # ESLint errors are typically medium severity + "generatedBy": "eslint", + "props": { + "name": message.get("ruleId", "unknown"), + "description": message.get("message", ""), + "nodeType": message.get("nodeType", ""), + "source": message.get("source", "") + }, + "location": { + "file": filename, + "start": message.get("line", 1), + "end": message.get("endLine", message.get("line", 1)), + "column_start": message.get("column", 0), + "column_end": message.get("endColumn", message.get("column", 0)) + } + } + + file_components[filename]["alerts"].append(alert) + + return list(file_components.values()) + + def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: + """Process Trufflehog secret scanning results into socket facts format.""" + trufflehog_file = os.path.join(temp_output_dir, "trufflehog_output.json") + if not os.path.exists(trufflehog_file): + return [] + + try: + # Trufflehog outputs NDJSON format + secrets = [] + with open(trufflehog_file, 'r') as f: + for line in f: + line = line.strip() + if line: + try: + secrets.append(json.loads(line)) + except json.JSONDecodeError: + continue + except FileNotFoundError: + return [] + + if not secrets: + return [] + + # Group by file to create components + file_components = {} + + for secret in secrets: + source_metadata = secret.get("SourceMetadata", {}) + data = source_metadata.get("Data", {}) + filename = data.get("Filesystem", {}).get("file", "unknown") + + # Normalize filename relative to workspace + if filename.startswith("./"): + filename = filename[2:] + + if filename not in file_components: + file_components[filename] = { + "id": str(uuid.uuid4()), + "type": "external-secrets", + "name": f"trufflehog-scan-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:external-secrets/trufflehog@1.0.0", + "direct": True, + "dev": False, + "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "alerts": [] + } + + # Create alert for this secret + alert = { + "type": "external-secrets", + "severity": "high", # Secrets are typically high severity + "generatedBy": "trufflehog", + "props": { + "name": secret.get("DetectorName", "unknown"), + "description": f"Secret detected: {secret.get('DetectorName', 'unknown')}", + "verified": secret.get("Verified", False), + "raw": secret.get("Raw", "")[:100] + "..." if len(secret.get("Raw", "")) > 100 else secret.get("Raw", "") + }, + "location": { + "file": filename, + "start": data.get("Filesystem", {}).get("line", 1), + "end": data.get("Filesystem", {}).get("line", 1) + } + } + + file_components[filename]["alerts"].append(alert) + + return list(file_components.values()) + + def _process_trivy_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: + """Process Trivy container and dockerfile scanning results into socket facts format.""" + components = [] + + # Process Trivy image scan results + import glob + for trivy_file in glob.glob(os.path.join(temp_output_dir, "trivy_image_*.json")): + components.extend(self._process_single_trivy_file(trivy_file, "external-container-image")) + + # Process Trivy dockerfile scan results + for trivy_file in glob.glob(os.path.join(temp_output_dir, "trivy_dockerfile_*.json")): + components.extend(self._process_single_trivy_file(trivy_file, "external-container-dockerfile")) + + return components + + def _process_single_trivy_file(self, trivy_file: str, scan_type: str) -> List[Dict[str, Any]]: + """Process a single Trivy result file.""" + try: + with open(trivy_file, 'r') as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return [] + + results = data.get("Results", []) + if not results: + return [] + + components = [] + filename = os.path.basename(trivy_file) + + for result in results: + target = result.get("Target", filename) + vulnerabilities = result.get("Vulnerabilities", []) + misconfigurations = result.get("Misconfigurations", []) + + if not vulnerabilities and not misconfigurations: + continue + + component = { + "id": str(uuid.uuid4()), + "type": scan_type, + "name": f"trivy-scan-{target.replace('/', '-').replace(':', '-')}", + "version": "1.0.0", + "purl": f"pkg:{scan_type}/trivy@1.0.0", + "direct": True, + "dev": False, + "manifestFiles": [{"file": target, "start": 1, "end": 1}], + "alerts": [] + } + + # Process vulnerabilities + for vuln in vulnerabilities: + alert = { + "type": scan_type, + "severity": self._map_trivy_severity(vuln.get("Severity", "UNKNOWN")), + "generatedBy": "trivy", + "props": { + "name": vuln.get("VulnerabilityID", "unknown"), + "description": vuln.get("Description", ""), + "pkgName": vuln.get("PkgName", ""), + "installedVersion": vuln.get("InstalledVersion", ""), + "fixedVersion": vuln.get("FixedVersion", ""), + "references": vuln.get("References", []) + }, + "location": { + "file": target + } + } + component["alerts"].append(alert) + + # Process misconfigurations + for misconf in misconfigurations: + alert = { + "type": scan_type, + "severity": self._map_trivy_severity(misconf.get("Severity", "UNKNOWN")), + "generatedBy": "trivy", + "props": { + "name": misconf.get("ID", "unknown"), + "description": misconf.get("Description", ""), + "title": misconf.get("Title", ""), + "message": misconf.get("Message", ""), + "resolution": misconf.get("Resolution", ""), + "references": misconf.get("References", []) + }, + "location": { + "file": target, + "start": misconf.get("CauseMetadata", {}).get("StartLine", 1), + "end": misconf.get("CauseMetadata", {}).get("EndLine", 1) + } + } + component["alerts"].append(alert) + + if component["alerts"]: + components.append(component) + + return components + + def _process_socket_sca_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: + """Process Socket SCA results into socket facts format.""" + socket_sca_file = os.path.join(temp_output_dir, "socket_sca_output.json") + if not os.path.exists(socket_sca_file): + return [] + + try: + with open(socket_sca_file, 'r') as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return [] + + # Check if scan failed + if data.get("scan_failed", False): + return [{ + "id": str(uuid.uuid4()), + "type": "external-socket-sca", + "name": "socket-sca-scan-failed", + "version": "1.0.0", + "purl": "pkg:external-socket-sca/socket-sca@1.0.0", + "direct": True, + "dev": False, + "manifestFiles": [{"file": ".", "start": 1, "end": 1}], + "alerts": [{ + "type": "external-socket-sca", + "severity": "critical", + "generatedBy": "socket-sca", + "props": { + "name": "scan-failure", + "description": data.get("error", "Socket SCA scan failed"), + "scan_failed": True + }, + "location": {"file": "."} + }] + }] + + new_alerts = data.get("new_alerts", []) + if not new_alerts: + return [] + + # Group alerts by package/file + package_components = {} + + for alert in new_alerts: + package_name = alert.get("package", "unknown") + + if package_name not in package_components: + package_components[package_name] = { + "id": str(uuid.uuid4()), + "type": "external-socket-sca", + "name": f"socket-sca-{package_name}", + "version": alert.get("version", "unknown"), + "purl": f"pkg:external-socket-sca/{package_name}@{alert.get('version', 'unknown')}", + "direct": True, + "dev": False, + "manifestFiles": [{"file": alert.get("file", "unknown"), "start": 1, "end": 1}], + "alerts": [] + } + + socket_alert = { + "type": "external-socket-sca", + "severity": self._map_socket_sca_severity(alert.get("severity", "unknown")), + "generatedBy": "socket-sca", + "props": { + "name": alert.get("type", "unknown"), + "description": alert.get("description", ""), + "category": alert.get("category", ""), + "subcategory": alert.get("subcategory", "") + }, + "location": { + "file": alert.get("file", "unknown") + } + } + + package_components[package_name]["alerts"].append(socket_alert) + + return list(package_components.values()) + + def _map_bandit_severity(self, severity: str) -> str: + """Map Bandit severity to standard levels.""" + severity_map = { + "HIGH": "critical", + "MEDIUM": "high", + "LOW": "medium" + } + return severity_map.get(severity.upper(), "medium") + + def _map_gosec_severity(self, severity: str) -> str: + """Map Gosec severity to standard levels.""" + severity_map = { + "HIGH": "critical", + "MEDIUM": "high", + "LOW": "medium" + } + return severity_map.get(severity.upper(), "medium") + + def _map_trivy_severity(self, severity: str) -> str: + """Map Trivy severity to standard levels.""" + severity_map = { + "CRITICAL": "critical", + "HIGH": "high", + "MEDIUM": "medium", + "LOW": "low" + } + return severity_map.get(severity.upper(), "medium") + + def _map_socket_sca_severity(self, severity: str) -> str: + """Map Socket SCA severity to standard levels.""" + severity_map = { + "CRITICAL": "critical", + "HIGH": "high", + "MEDIUM": "medium", + "LOW": "low" + } + return severity_map.get(severity.upper(), "medium") + + def save_consolidated_facts(self, output_path: str = ".socket.facts.json") -> None: + """Save the consolidated facts to file.""" + consolidated = self.consolidate_all_results() + with open(output_path, 'w') as f: + json.dump(consolidated, f, indent=2) diff --git a/src/core/socket_facts_processor.py b/src/core/socket_facts_processor.py new file mode 100644 index 0000000..4b5ddfb --- /dev/null +++ b/src/core/socket_facts_processor.py @@ -0,0 +1,234 @@ +""" +Socket Facts Processor + +This module processes the consolidated .socket.facts.json file that contains both +Socket dependency data and external security tool results (SAST, secrets, container scans). +It extracts alerts and processes them using the existing tool connector classes. +""" + +import json +import os +from typing import Dict, List, Any, Optional +from core.connectors.classes import BaseTestResult + + +class SocketFactsAlert(BaseTestResult): + """Represents an alert from the consolidated socket facts.""" + + def __init__(self, **kwargs): + # Set default values + self.alert_type = "" + self.severity = "" + self.generated_by = "" + self.name = "" + self.description = "" + self.file = "" + self.line = 1 + self.plugin_name = "" + + # Call parent constructor + super().__init__(**kwargs) + + # Extract alert-specific fields from props if available + props = kwargs.get('props', {}) + if props: + for key, value in props.items(): + if not hasattr(self, key): # Don't override existing attributes + setattr(self, key, value) + + # Extract location information + location = kwargs.get('location', {}) + if location: + self.file = location.get('file', self.file) + self.line = location.get('start', self.line) + self.line_number = self.line # For compatibility + + # Set other fields from alert data + self.alert_type = kwargs.get('type', self.alert_type) + self.severity = kwargs.get('severity', self.severity) + self.generated_by = kwargs.get('generatedBy', self.generated_by) + + # Ensure we have required fields for compatibility + if not hasattr(self, 'issue_severity'): + self.issue_severity = self.severity.upper() + if not hasattr(self, 'filename'): + self.filename = self.file + + +class SocketFactsProcessor: + """Processes consolidated socket facts file for security alerts.""" + + def __init__(self): + self.default_severities = {"CRITICAL"} + + def load_socket_facts(self, facts_file_path: str = ".socket.facts.json") -> Dict[str, Any]: + """Load the consolidated socket facts file.""" + try: + with open(facts_file_path, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {"components": []} + + def extract_alerts_by_type(self, facts_data: Dict[str, Any], alert_type_prefix: str) -> List[Dict[str, Any]]: + """Extract all alerts of a specific type from socket facts.""" + alerts = [] + + for component in facts_data.get("components", []): + component_alerts = component.get("alerts", []) + for alert in component_alerts: + if alert.get("type", "").startswith(alert_type_prefix): + # Add component context to alert + alert_with_context = alert.copy() + alert_with_context["component"] = { + "id": component.get("id"), + "name": component.get("name"), + "type": component.get("type"), + "version": component.get("version") + } + alerts.append(alert_with_context) + + return alerts + + def process_sast_alerts(self, facts_data: Dict[str, Any], language: str, cwd: str, plugin_name: str) -> Dict[str, Any]: + """Process SAST alerts for a specific language.""" + alert_type = f"external-sast-{language}" + alerts = self.extract_alerts_by_type(facts_data, alert_type) + + metrics = { + "tests": {}, + "severities": {}, + "output": [], + "events": [] + } + + for alert_data in alerts: + # Create alert object + alert = SocketFactsAlert( + cwd=cwd, + **alert_data + ) + alert.plugin_name = plugin_name + + # Filter by severity + if alert.issue_severity.upper() not in self.default_severities: + continue + + # Update metrics + test_name = f"{plugin_name}_{alert.name}_{alert.severity}" + metrics["tests"].setdefault(test_name, 0) + metrics["tests"][test_name] += 1 + + metrics["output"].append(alert) + metrics["events"].append(json.dumps(alert.__dict__)) + + return metrics + + def process_secret_alerts(self, facts_data: Dict[str, Any], cwd: str, plugin_name: str) -> Dict[str, Any]: + """Process secret scanning alerts.""" + alerts = self.extract_alerts_by_type(facts_data, "external-secrets") + + metrics = { + "tests": {}, + "severities": {}, + "output": [], + "events": [] + } + + for alert_data in alerts: + # Create alert object + alert = SocketFactsAlert( + cwd=cwd, + **alert_data + ) + alert.plugin_name = plugin_name + + # Filter by severity + if alert.issue_severity.upper() not in self.default_severities: + continue + + # Update metrics + test_name = f"{plugin_name}_{alert.name}_{alert.severity}" + metrics["tests"].setdefault(test_name, 0) + metrics["tests"][test_name] += 1 + + metrics["output"].append(alert) + metrics["events"].append(json.dumps(alert.__dict__)) + + return metrics + + def process_container_alerts(self, facts_data: Dict[str, Any], scan_type: str, cwd: str, plugin_name: str) -> Dict[str, Any]: + """Process container/dockerfile scanning alerts.""" + alert_type = f"external-container-{scan_type}" + alerts = self.extract_alerts_by_type(facts_data, alert_type) + + metrics = { + "tests": {}, + "severities": {}, + "output": [], + "events": [] + } + + for alert_data in alerts: + # Create alert object + alert = SocketFactsAlert( + cwd=cwd, + **alert_data + ) + alert.plugin_name = plugin_name + + # Filter by severity + if alert.issue_severity.upper() not in self.default_severities: + continue + + # Update metrics + test_name = f"{plugin_name}_{alert.name}_{alert.severity}" + metrics["tests"].setdefault(test_name, 0) + metrics["tests"][test_name] += 1 + + metrics["output"].append(alert) + metrics["events"].append(json.dumps(alert.__dict__)) + + return metrics + + def process_socket_sca_alerts(self, facts_data: Dict[str, Any], cwd: str, plugin_name: str) -> Dict[str, Any]: + """Process Socket SCA alerts.""" + alerts = self.extract_alerts_by_type(facts_data, "external-socket-sca") + + metrics = { + "tests": {}, + "severities": {}, + "output": [], + "events": [] + } + + # Check for scan failures + scan_failed = False + for alert_data in alerts: + if alert_data.get("props", {}).get("scan_failed", False): + scan_failed = True + break + + if scan_failed: + return {"scan_failed": True, "tests": {}, "severities": {}, "output": [], "events": []} + + for alert_data in alerts: + # Create alert object + alert = SocketFactsAlert( + cwd=cwd, + **alert_data + ) + alert.plugin_name = plugin_name + + # Filter by severity + if alert.issue_severity.upper() not in self.default_severities: + continue + + # Update metrics + test_name = f"{plugin_name}_{alert.name}_{alert.severity}" + metrics["tests"].setdefault(test_name, 0) + metrics["tests"][test_name] += 1 + + metrics["output"].append(alert) + metrics["events"].append(json.dumps(alert.__dict__)) + + return metrics diff --git a/src/socket_external_tools_runner.py b/src/socket_external_tools_runner.py index 8e55b36..517166b 100644 --- a/src/socket_external_tools_runner.py +++ b/src/socket_external_tools_runner.py @@ -11,6 +11,7 @@ from core.connectors.eslint import ESLint from core.connectors.socket import Socket from core.connectors.socket_sca import SocketSCA +from core.socket_facts_processor import SocketFactsProcessor from core.load_plugins import load_sumo_logic_plugin, load_ms_sentinel_plugin, load_console_plugin from tabulate import tabulate @@ -117,40 +118,99 @@ def get_output_file_path(filename): """Get the full path to an output file based on TEMP_OUTPUT_DIR""" return os.path.join(temp_output_dir, filename) - # Load results only for enabled tools - results = {} - if "bandit" in TOOL_CLASSES: - bandit_data = load_json(get_output_file_path("bandit_output.json"), "Bandit") - if bandit_data: - results["bandit"] = bandit_data - if "gosec" in TOOL_CLASSES: - gosec_data = load_json(get_output_file_path("gosec_output.json"), "Gosec") - if gosec_data: - results["gosec"] = gosec_data - if "trufflehog" in TOOL_CLASSES: - trufflehog_data = load_json(get_output_file_path("trufflehog_output.json"), "Trufflehog") - if trufflehog_data: - results["trufflehog"] = trufflehog_data - if "trivy_image" in TOOL_CLASSES: - trivy_image_data = consolidate_trivy_results(get_output_file_path("trivy_image_*.json")) - if trivy_image_data and trivy_image_data.get("Results"): - results["trivy_image"] = trivy_image_data - if "trivy_dockerfile" in TOOL_CLASSES: - trivy_dockerfile_data = consolidate_trivy_results(get_output_file_path("trivy_dockerfile_*.json")) - if trivy_dockerfile_data and trivy_dockerfile_data.get("Results"): - results["trivy_dockerfile"] = trivy_dockerfile_data - if "eslint" in TOOL_CLASSES: - eslint_data = load_json(get_output_file_path("eslint_output.json"), "ESLint") - if eslint_data: - results["eslint"] = eslint_data - if "socket" in TOOL_CLASSES: - socket_data = load_json(".socket.facts.json", "Socket") - if socket_data: - results["socket"] = socket_data - if "socket_sca" in TOOL_CLASSES: - socket_sca_data = load_json(get_output_file_path("socket_sca_output.json"), "SocketSCA") - if socket_sca_data: - results["socket_sca"] = socket_sca_data + # Check if we have a consolidated .socket.facts.json file + socket_facts_path = ".socket.facts.json" + if os.path.exists(socket_facts_path): + print("Using consolidated .socket.facts.json format") + + # Initialize facts processor + facts_processor = SocketFactsProcessor() + facts_processor.default_severities = SEVERITIES + facts_data = facts_processor.load_socket_facts(socket_facts_path) + + # Process results from consolidated facts + results = {} + + # Process Socket dependency data (original socket facts) + socket_components = [c for c in facts_data.get("components", []) if c.get("type") in ["npm", "pypi", "go", "maven", "nuget"]] + if socket_components: + # Create a socket facts data structure with only dependency components + socket_data = {"components": socket_components} + if socket_data: + results["socket"] = socket_data + + # Process external security tool alerts from facts + if "bandit" in TOOL_CLASSES: + bandit_metrics = facts_processor.process_sast_alerts(facts_data, "python", os.getcwd(), "Bandit") + if bandit_metrics.get("output"): + results["bandit"] = bandit_metrics + + if "gosec" in TOOL_CLASSES: + gosec_metrics = facts_processor.process_sast_alerts(facts_data, "golang", os.getcwd(), "Gosec") + if gosec_metrics.get("output"): + results["gosec"] = gosec_metrics + + if "eslint" in TOOL_CLASSES: + eslint_metrics = facts_processor.process_sast_alerts(facts_data, "javascript", os.getcwd(), "ESLint") + if eslint_metrics.get("output"): + results["eslint"] = eslint_metrics + + if "trufflehog" in TOOL_CLASSES: + trufflehog_metrics = facts_processor.process_secret_alerts(facts_data, os.getcwd(), "Trufflehog") + if trufflehog_metrics.get("output"): + results["trufflehog"] = trufflehog_metrics + + if "trivy_image" in TOOL_CLASSES: + trivy_image_metrics = facts_processor.process_container_alerts(facts_data, "image", os.getcwd(), "TrivyImageScanning") + if trivy_image_metrics.get("output"): + results["trivy_image"] = trivy_image_metrics + + if "trivy_dockerfile" in TOOL_CLASSES: + trivy_dockerfile_metrics = facts_processor.process_container_alerts(facts_data, "dockerfile", os.getcwd(), "TrivyDockerfileScanning") + if trivy_dockerfile_metrics.get("output"): + results["trivy_dockerfile"] = trivy_dockerfile_metrics + + if "socket_sca" in TOOL_CLASSES: + socket_sca_metrics = facts_processor.process_socket_sca_alerts(facts_data, os.getcwd(), "SocketSCA") + if socket_sca_metrics.get("output") or socket_sca_metrics.get("scan_failed"): + results["socket_sca"] = socket_sca_metrics + + else: + print("Using legacy individual tool output format") + # Fallback to legacy processing if no consolidated facts file + results = {} + if "bandit" in TOOL_CLASSES: + bandit_data = load_json(get_output_file_path("bandit_output.json"), "Bandit") + if bandit_data: + results["bandit"] = bandit_data + if "gosec" in TOOL_CLASSES: + gosec_data = load_json(get_output_file_path("gosec_output.json"), "Gosec") + if gosec_data: + results["gosec"] = gosec_data + if "trufflehog" in TOOL_CLASSES: + trufflehog_data = load_json(get_output_file_path("trufflehog_output.json"), "Trufflehog") + if trufflehog_data: + results["trufflehog"] = trufflehog_data + if "trivy_image" in TOOL_CLASSES: + trivy_image_data = consolidate_trivy_results(get_output_file_path("trivy_image_*.json")) + if trivy_image_data and trivy_image_data.get("Results"): + results["trivy_image"] = trivy_image_data + if "trivy_dockerfile" in TOOL_CLASSES: + trivy_dockerfile_data = consolidate_trivy_results(get_output_file_path("trivy_dockerfile_*.json")) + if trivy_dockerfile_data and trivy_dockerfile_data.get("Results"): + results["trivy_dockerfile"] = trivy_dockerfile_data + if "eslint" in TOOL_CLASSES: + eslint_data = load_json(get_output_file_path("eslint_output.json"), "ESLint") + if eslint_data: + results["eslint"] = eslint_data + if "socket" in TOOL_CLASSES: + socket_data = load_json(".socket.facts.json", "Socket") + if socket_data: + results["socket"] = socket_data + if "socket_sca" in TOOL_CLASSES: + socket_sca_data = load_json(get_output_file_path("socket_sca_output.json"), "SocketSCA") + if socket_sca_data: + results["socket_sca"] = socket_sca_data if any(results.values()): if not SCM_DISABLED: @@ -162,21 +222,50 @@ def get_output_file_path(filename): tool_marker = marker.replace("REPLACE_ME", TOOL_NAMES[key]) tool_class = TOOL_CLASSES[key] tool_class.default_severities = SEVERITIES - supports_show_unverified = "show_unverified" in inspect.signature(tool_class.process_output).parameters - if supports_show_unverified: - show_unverified = os.getenv("INPUT_TRUFFLEHOG_SHOW_UNVERIFIED", "false").lower() == "true" - tool_outputs[key], tool_results = tool_class.create_output( - data, - tool_marker, - scm.github.repo, - scm.github.commit, - scm.github.cwd, - show_unverified=show_unverified - ) + + # Handle consolidated facts vs legacy data differently + if key == "socket" and "components" in data: + # For socket dependency data, use the original create_output method + supports_show_unverified = "show_unverified" in inspect.signature(tool_class.process_output).parameters + if supports_show_unverified: + show_unverified = os.getenv("INPUT_TRUFFLEHOG_SHOW_UNVERIFIED", "false").lower() == "true" + tool_outputs[key], tool_results = tool_class.create_output( + data, + tool_marker, + scm.github.repo, + scm.github.commit, + scm.github.cwd, + show_unverified=show_unverified + ) + else: + tool_outputs[key], tool_results = tool_class.create_output( + data, tool_marker, scm.github.repo, scm.github.commit, scm.github.cwd + ) + elif isinstance(data, dict) and "output" in data: + # For consolidated security tool data, create output from processed alerts + tool_outputs[key] = { + "events": data.get("output", []), + "output": [str(alert) for alert in data.get("output", [])] + } + tool_results = "\n".join(tool_outputs[key]["output"]) else: - tool_outputs[key], tool_results = tool_class.create_output( - data, tool_marker, scm.github.repo, scm.github.commit, scm.github.cwd - ) + # Legacy processing for individual tool outputs + supports_show_unverified = "show_unverified" in inspect.signature(tool_class.process_output).parameters + if supports_show_unverified: + show_unverified = os.getenv("INPUT_TRUFFLEHOG_SHOW_UNVERIFIED", "false").lower() == "true" + tool_outputs[key], tool_results = tool_class.create_output( + data, + tool_marker, + scm.github.repo, + scm.github.commit, + scm.github.cwd, + show_unverified=show_unverified + ) + else: + tool_outputs[key], tool_results = tool_class.create_output( + data, tool_marker, scm.github.repo, scm.github.commit, scm.github.cwd + ) + tool_events[key] = tool_outputs[key].get("events", []) if tool_events[key]: scm.github.post_comment(TOOL_NAMES[key], tool_marker, tool_results) @@ -188,7 +277,14 @@ def get_output_file_path(filename): if key not in TOOL_CLASSES or not data: continue TOOL_CLASSES[key].default_severities = SEVERITIES - tool_events[key] = TOOL_CLASSES[key].process_output(data, cwd, TOOL_NAMES[key]) + + # Handle consolidated facts vs legacy data differently + if isinstance(data, dict) and "output" in data: + # For consolidated security tool data, we already have processed events + tool_events[key] = {"events": data.get("output", [])} + else: + # Legacy processing for individual tool outputs + tool_events[key] = TOOL_CLASSES[key].process_output(data, cwd, TOOL_NAMES[key]) # Check for scan failures that should force an exit regardless of other conditions scan_failed = False diff --git a/src/version.py b/src/version.py index c916e68..edb5a8e 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.21" +__version__ = "1.0.22" From 86995e51d49a7df130b048082e7ef34ced55f151 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Mon, 25 Aug 2025 02:16:29 -0700 Subject: [PATCH 08/11] Merged changes from other branch --- .gitignore | 1 + README.md | 8 + action.yml | 56 +++ debug_test.py | 79 ---- entrypoint.sh | 6 + pyproject.toml | 2 +- src/core/connectors/socket_sca/__init__.py | 3 + src/core/load_plugins.py | 107 ++++- src/core/plugins/jira/__init__.py | 1 + src/core/plugins/jira/jira.py | 462 +++++++++++++++++++++ src/core/plugins/slack/__init__.py | 1 + src/core/plugins/slack/slack.py | 97 +++++ src/core/plugins/teams/__init__.py | 1 + src/core/plugins/teams/teams.py | 86 ++++ src/core/plugins/webhook/__init__.py | 1 + src/core/plugins/webhook/webhook.py | 69 +++ src/core/socket_facts_consolidator.py | 279 +++++++++++++ src/socket_external_tools_runner.py | 64 ++- src/version.py | 2 +- 19 files changed, 1240 insertions(+), 85 deletions(-) delete mode 100644 debug_test.py create mode 100644 src/core/plugins/jira/__init__.py create mode 100644 src/core/plugins/jira/jira.py create mode 100644 src/core/plugins/slack/__init__.py create mode 100644 src/core/plugins/slack/slack.py create mode 100644 src/core/plugins/teams/__init__.py create mode 100644 src/core/plugins/teams/teams.py create mode 100644 src/core/plugins/webhook/__init__.py create mode 100644 src/core/plugins/webhook/webhook.py diff --git a/.gitignore b/.gitignore index d137eb5..25463a6 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ file_generator.py .env *.md test_results +local_tests/* local_tests \ No newline at end of file diff --git a/README.md b/README.md index b2999da..ac857ae 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ jobs: image_enabled: true secret_scanning_enabled: true socket_scanning_enabled: true + socket_scanning_enabled: true # Trivy Configuration docker_images: "image:latest,test/image2:latest" @@ -66,6 +67,10 @@ jobs: socket_org: "your-socket-org" # Required when socket_scanning_enabled is true socket_api_key: ${{ secrets.SOCKET_API_KEY }} + # Socket Configuration + socket_org: "your-socket-org" # Required when socket_scanning_enabled is true + socket_api_key: ${{ secrets.SOCKET_API_KEY }} + # Exclusion settings trufflehog_exclude_dir: "node_modules/*,vendor,.git/*,.idea" trufflehog_show_unverified: False @@ -157,6 +162,9 @@ docker run --rm --name security-wrapper \ -e "INPUT_SOCKET_SCANNING_ENABLED=true" \ -e "INPUT_SOCKET_ORG=your-socket-org" \ # Required when socket_scanning_enabled is true -e "INPUT_SOCKET_API_KEY=your-socket-api-key" \ + -e "INPUT_SOCKET_SCANNING_ENABLED=true" \ + -e "INPUT_SOCKET_ORG=your-socket-org" \ # Required when socket_scanning_enabled is true + -e "INPUT_SOCKET_API_KEY=your-socket-api-key" \ -e "SOCKET_SCM_DISABLED=true" \ -e "INPUT_SOCKET_CONSOLE_MODE=json" \ socketdev/security-wrapper diff --git a/action.yml b/action.yml index 09c3cfa..5657e2c 100644 --- a/action.yml +++ b/action.yml @@ -157,6 +157,62 @@ inputs: required: false default: "REPLACE_ME" + # Jira Configuration + jira_enabled: + description: "Enable Jira ticket creation" + required: false + default: "false" + jira_url: + description: "Jira instance URL" + required: false + default: "" + jira_email: + description: "Jira user email" + required: false + default: "" + jira_api_token: + description: "Jira API token" + required: false + default: "" + jira_project: + description: "Jira project key" + required: false + default: "" + + # Slack Configuration + slack_enabled: + description: "Enable Slack notifications" + required: false + default: "false" + slack_webhook_url: + description: "Slack webhook URL" + required: false + default: "" + + # Teams Configuration + teams_enabled: + description: "Enable Microsoft Teams notifications" + required: false + default: "false" + teams_webhook_url: + description: "Teams webhook URL" + required: false + default: "" + + # Webhook Configuration + webhook_enabled: + description: "Enable generic webhook notifications" + required: false + default: "false" + webhook_url: + description: "Webhook URL" + required: false + default: "" + webhook_headers: + description: "Custom webhook headers as JSON string" + required: false + default: "" + # Scan Scope Configuration scan_all: description: "If true, always scan the whole directory regardless of git or file list." diff --git a/debug_test.py b/debug_test.py deleted file mode 100644 index 94e0134..0000000 --- a/debug_test.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import os -import json -import tempfile - -# Add the src directory to the Python path -sys.path.insert(0, '/Users/douglascoburn/socket.dev/github/security-tools/src') - -def test_socket_sca_failure(): - """Test that Socket SCA scan failures are properly detected""" - - # Create a temporary directory for test files - with tempfile.TemporaryDirectory() as temp_dir: - # Create the socket_sca_output.json file with failure data - with open('/Users/douglascoburn/socket.dev/github/security-tools/test_socket_sca_failure.json', 'r') as f: - failure_data = json.load(f) - - socket_sca_file = os.path.join(temp_dir, 'socket_sca_output.json') - with open(socket_sca_file, 'w') as f: - json.dump(failure_data, f) - - # Set the required environment variables - original_temp_dir = os.environ.get('TEMP_OUTPUT_DIR') - original_scm_disabled = os.environ.get('SOCKET_SCM_DISABLED') - - os.environ['TEMP_OUTPUT_DIR'] = temp_dir - os.environ['SOCKET_SCM_DISABLED'] = 'true' - - try: - # Import and test the load_json function first - from socket_external_tools_runner import load_json, TOOL_CLASSES - - print(f"Testing load_json with file: {socket_sca_file}") - socket_sca_data = load_json("socket_sca_output.json", "SocketSCA") - print(f"Loaded data: {socket_sca_data}") - - if socket_sca_data: - print(f"scan_failed: {socket_sca_data.get('scan_failed', False)}") - print(f"new_alerts: {len(socket_sca_data.get('new_alerts', []))}") - - # Check if socket_sca is in TOOL_CLASSES - print(f"TOOL_CLASSES: {list(TOOL_CLASSES.keys())}") - - # Test the SocketSCA processor - if "socket_sca" in TOOL_CLASSES: - from core.connectors.socket_sca import SocketSCA - processor = SocketSCA() - events = processor.process_output(socket_sca_data, temp_dir, "SocketSCA") - print(f"Generated events: {len(events.get('events', []))}") - for event in events.get('events', []): - print(f"Event: {event.__dict__}") - else: - print("socket_sca not in TOOL_CLASSES - checking environment variables") - for var in ["INPUT_NODEJS_SCA_ENABLED", "INPUT_PYTHON_SCA_ENABLED"]: - print(f"{var}: {os.getenv(var, 'not set')}") - - except Exception as e: - print(f"Exception during testing: {e}") - import traceback - traceback.print_exc() - return False - finally: - # Restore original environment - if original_temp_dir: - os.environ['TEMP_OUTPUT_DIR'] = original_temp_dir - elif 'TEMP_OUTPUT_DIR' in os.environ: - del os.environ['TEMP_OUTPUT_DIR'] - - if original_scm_disabled: - os.environ['SOCKET_SCM_DISABLED'] = original_scm_disabled - elif 'SOCKET_SCM_DISABLED' in os.environ: - del os.environ['SOCKET_SCM_DISABLED'] - - return True - -if __name__ == '__main__': - test_socket_sca_failure() diff --git a/entrypoint.sh b/entrypoint.sh index c5adf72..ccb7f6e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -19,6 +19,12 @@ SOCKET_API_KEY=${INPUT_SOCKET_API_KEY:-} SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_SECURITY_API_KEY:-} SOCKET_SCA_FILES=${INPUT_SOCKET_SCA_FILES:-} +# Socket configuration +SOCKET_ORG=${INPUT_SOCKET_ORG:-} +SOCKET_API_KEY=${INPUT_SOCKET_API_KEY:-} +SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_SECURITY_API_KEY:-} +SOCKET_SCA_FILES=${INPUT_SOCKET_SCA_FILES:-} + # Set output directory for temp files if [[ -n "$OUTPUT_DIR" ]]; then TEMP_OUTPUT_DIR="$OUTPUT_DIR" diff --git a/pyproject.toml b/pyproject.toml index f9ff65d..752ec33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.23" +version = "1.0.24" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ diff --git a/src/core/connectors/socket_sca/__init__.py b/src/core/connectors/socket_sca/__init__.py index 0351afe..7d25ae8 100644 --- a/src/core/connectors/socket_sca/__init__.py +++ b/src/core/connectors/socket_sca/__init__.py @@ -34,7 +34,10 @@ def process_output(cls, data: dict, cwd: str, plugin_name: str = "SocketSCA") -> plugin_name=plugin_name ) +<<<<<<< HEAD # Always include scan failures regardless of severity filter +======= +>>>>>>> cb8a152 (Fixing the s3 workflow) test_name = cls.get_test_name(failure_result) metrics["tests"].setdefault(test_name, 0) metrics["tests"][test_name] += 1 diff --git a/src/core/load_plugins.py b/src/core/load_plugins.py index 411219c..6d0a027 100644 --- a/src/core/load_plugins.py +++ b/src/core/load_plugins.py @@ -2,6 +2,10 @@ from core.plugins.sumologic import Sumologic from core.plugins.microsoft_sentinel import Sentinel from core.plugins.console import Console +from core.plugins.jira import Jira +from core.plugins.slack import Slack +from core.plugins.teams import Teams +from core.plugins.webhook import Webhook def load_sumo_logic_plugin(): @@ -45,9 +49,9 @@ def load_ms_sentinel_plugin(): def load_console_plugin(): """ - Loads the Microsoft Sentinel plugin if it is enabled and properly configured. + Loads the Console plugin if it is enabled and properly configured. - :return: Instance of the Microsoft Sentinel class or None if not enabled/configured. + :return: Instance of the Console class or None if not enabled/configured. """ console_enabled = os.getenv("INPUT_CONSOLE_ENABLED", "false").lower() == "true" if not console_enabled: @@ -56,4 +60,101 @@ def load_console_plugin(): SOCKET_CONSOLE_MODE = os.getenv("INPUT_SOCKET_CONSOLE_MODE", "console").lower() - return Console(mode=SOCKET_CONSOLE_MODE) \ No newline at end of file + return Console(mode=SOCKET_CONSOLE_MODE) + + +def load_jira_plugin(): + """ + Loads the Jira plugin if it is enabled and properly configured. + + :return: Instance of the Jira class or None if not enabled/configured. + """ + jira_enabled = os.getenv("INPUT_JIRA_ENABLED", "false").lower() == "true" + if not jira_enabled: + return None + + jira_url = os.getenv("INPUT_JIRA_URL") + jira_email = os.getenv("INPUT_JIRA_EMAIL") + jira_api_token = os.getenv("INPUT_JIRA_API_TOKEN") + jira_project = os.getenv("INPUT_JIRA_PROJECT") + + if not all([jira_url, jira_email, jira_api_token, jira_project]): + print("Jira environment variables are not properly configured!") + return None + + config = { + "enabled": True, + "url": jira_url, + "email": jira_email, + "api_token": jira_api_token, + "project": jira_project + } + + return Jira(config) + + +def load_slack_plugin(): + """ + Loads the Slack plugin if it is enabled and properly configured. + + :return: Instance of the Slack class or None if not enabled/configured. + """ + slack_enabled = os.getenv("INPUT_SLACK_ENABLED", "false").lower() == "true" + if not slack_enabled: + return None + + slack_webhook_url = os.getenv("INPUT_SLACK_WEBHOOK_URL") + + if not slack_webhook_url: + print("Slack webhook URL is not properly configured!") + return None + + return Slack(slack_webhook_url) + + +def load_teams_plugin(): + """ + Loads the Teams plugin if it is enabled and properly configured. + + :return: Instance of the Teams class or None if not enabled/configured. + """ + teams_enabled = os.getenv("INPUT_TEAMS_ENABLED", "false").lower() == "true" + if not teams_enabled: + return None + + teams_webhook_url = os.getenv("INPUT_TEAMS_WEBHOOK_URL") + + if not teams_webhook_url: + print("Teams webhook URL is not properly configured!") + return None + + return Teams(teams_webhook_url) + + +def load_webhook_plugin(): + """ + Loads the Webhook plugin if it is enabled and properly configured. + + :return: Instance of the Webhook class or None if not enabled/configured. + """ + webhook_enabled = os.getenv("INPUT_WEBHOOK_ENABLED", "false").lower() == "true" + if not webhook_enabled: + return None + + webhook_url = os.getenv("INPUT_WEBHOOK_URL") + + if not webhook_url: + print("Webhook URL is not properly configured!") + return None + + # Optional headers configuration + headers = {"Content-Type": "application/json"} + custom_headers = os.getenv("INPUT_WEBHOOK_HEADERS") + if custom_headers: + try: + import json + headers.update(json.loads(custom_headers)) + except json.JSONDecodeError: + print("Warning: Failed to parse custom webhook headers, using defaults.") + + return Webhook(webhook_url, headers) \ No newline at end of file diff --git a/src/core/plugins/jira/__init__.py b/src/core/plugins/jira/__init__.py new file mode 100644 index 0000000..b0c8d72 --- /dev/null +++ b/src/core/plugins/jira/__init__.py @@ -0,0 +1 @@ +from .jira import Jira diff --git a/src/core/plugins/jira/jira.py b/src/core/plugins/jira/jira.py new file mode 100644 index 0000000..e2c9b1f --- /dev/null +++ b/src/core/plugins/jira/jira.py @@ -0,0 +1,462 @@ +import requests +import base64 +import json +from typing import Dict, List, Any, Optional +from core import log + + +class Jira: + def __init__(self, config: dict): + """ + Initializes the Jira client with configuration. + + :param config: Dictionary containing Jira configuration (url, email, api_token, project) + """ + self.config = config + self.url = config.get("url") + self.email = config.get("email") + self.api_token = config.get("api_token") + self.project = config.get("project") + + def send_consolidated_security_alerts(self, facts_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Process consolidated socket facts and create/update Jira tickets for security issues. + + :param facts_data: Consolidated socket facts data + :return: A dict with response information + """ + if not self.config.get("enabled", False): + return {"status": "disabled"} + + log.debug("Jira Plugin Enabled - Processing consolidated security alerts") + + # Extract repository and branch information + repository = facts_data.get("repository", "unknown-repo") + branch = facts_data.get("branch", "unknown-branch") + + # Create ticket summary + ticket_summary = f"Socket Security Issues detected in {repository} - {branch}" + + # Check if ticket already exists + existing_ticket = self._find_existing_ticket(ticket_summary) + + # Get all alerts from the facts data + all_alerts = self._extract_alerts_from_facts(facts_data) + + if not all_alerts: + log.info("No security alerts found in facts data") + return {"status": "success", "message": "No alerts to process"} + + # Get new alerts if we have previous scan data + new_alerts = facts_data.get("new_alerts", all_alerts) + + if existing_ticket: + # Update existing ticket with new alerts only + if new_alerts: + log.info(f"Found existing ticket {existing_ticket['key']}, adding {len(new_alerts)} new alerts") + return self._add_comment_to_ticket(existing_ticket["key"], new_alerts, repository, branch) + else: + log.info(f"Found existing ticket {existing_ticket['key']}, no new alerts to add") + return {"status": "success", "message": "No new alerts to add", "issue_key": existing_ticket["key"]} + else: + # Create new ticket with all alerts + log.info(f"Creating new ticket with {len(all_alerts)} security alerts") + return self._create_new_ticket(ticket_summary, all_alerts, repository, branch) + + def _find_existing_ticket(self, summary: str) -> Optional[Dict[str, Any]]: + """ + Search for existing Jira ticket with the given summary. + + :param summary: The ticket summary to search for + :return: Ticket data if found, None otherwise + """ + try: + auth = base64.b64encode(f"{self.email}:{self.api_token}".encode()).decode() + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json" + } + + # Search for open tickets in the project + # We'll filter by summary in the response since JQL exact matching can be tricky + jql = f'project = "{self.project}" AND status != "Done" AND status != "Closed" AND status != "Resolved" ORDER BY created DESC' + search_url = f"{self.url}/rest/api/3/search" + + params = { + "jql": jql, + "fields": "key,summary,status", + "maxResults": 50 # Get more results to search through + } + + response = requests.get(search_url, headers=headers, params=params) + if response.status_code == 200: + search_results = response.json() + issues = search_results.get("issues", []) + + # Filter by exact summary match in the response + for issue in issues: + issue_summary = issue.get("fields", {}).get("summary", "") + if issue_summary == summary: + log.info(f"Found existing ticket: {issue['key']} with exact summary match") + return issue + + log.info(f"No existing ticket found with exact summary: {summary}") + else: + log.warning(f"Failed to search for existing tickets: {response.status_code} - {response.text}") + + except Exception as e: + log.error(f"Error searching for existing ticket: {str(e)}") + + return None + + def _extract_alerts_from_facts(self, facts_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Extract all alerts from the facts data components. + + :param facts_data: Consolidated socket facts data + :return: List of all alerts + """ + all_alerts = [] + + for component in facts_data.get("components", []): + component_alerts = component.get("alerts", []) + for alert in component_alerts: + # Add component context to alert + alert_with_context = alert.copy() + alert_with_context["component_name"] = component.get("name", "unknown") + alert_with_context["component_type"] = component.get("type", "unknown") + all_alerts.append(alert_with_context) + + return all_alerts + + def _create_new_ticket(self, summary: str, alerts: List[Dict[str, Any]], repository: str, branch: str) -> Dict[str, Any]: + """ + Create a new Jira ticket with security alerts. + + :param summary: Ticket summary + :param alerts: List of security alerts + :param repository: Repository name + :param branch: Branch name + :return: Result dictionary + """ + auth = base64.b64encode(f"{self.email}:{self.api_token}".encode()).decode() + + # Build description in ADF format + description_adf = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": f"Security issues detected in repository {repository} on branch {branch}:"} + ] + }, + self._create_alerts_table(alerts) + ] + } + + payload = { + "fields": { + "project": {"key": self.project}, + "summary": summary, + "description": description_adf, + "issuetype": {"name": "Task"}, + "labels": ["security", "socket-scan", f"repo-{repository}", f"branch-{branch}"] + } + } + + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json" + } + + jira_url = f"{self.url}/rest/api/3/issue" + + try: + response = requests.post(jira_url, json=payload, headers=headers) + if response.status_code >= 300: + log.error(f"Jira error {response.status_code}: {response.text}") + return {"status": "error", "message": response.text} + else: + issue_key = response.json().get('key') + log.info(f"Jira ticket created: {issue_key}") + return {"status": "success", "issue_key": issue_key, "created": True} + except Exception as e: + log.error(f"Failed to create Jira ticket: {str(e)}") + return {"status": "error", "message": str(e)} + + def _add_comment_to_ticket(self, issue_key: str, new_alerts: List[Dict[str, Any]], repository: str, branch: str) -> Dict[str, Any]: + """ + Add a comment with new alerts to an existing ticket. + + :param issue_key: Jira ticket key + :param new_alerts: List of new security alerts + :param repository: Repository name + :param branch: Branch name + :return: Result dictionary + """ + auth = base64.b64encode(f"{self.email}:{self.api_token}".encode()).decode() + + # Build comment in ADF format + comment_adf = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": f"New security issues detected ({len(new_alerts)} alerts):"} + ] + }, + self._create_alerts_table(new_alerts) + ] + } + + payload = { + "body": comment_adf + } + + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json" + } + + comment_url = f"{self.url}/rest/api/3/issue/{issue_key}/comment" + + try: + response = requests.post(comment_url, json=payload, headers=headers) + if response.status_code >= 300: + log.error(f"Jira comment error {response.status_code}: {response.text}") + return {"status": "error", "message": response.text} + else: + log.info(f"Added comment to Jira ticket: {issue_key}") + return {"status": "success", "issue_key": issue_key, "comment_added": True} + except Exception as e: + log.error(f"Failed to add comment to Jira ticket: {str(e)}") + return {"status": "error", "message": str(e)} + + def _create_alerts_table(self, alerts: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Create an ADF table from security alerts with consistent formatting. + + :param alerts: List of security alerts + :return: ADF table structure + """ + def make_cell(text): + return { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": str(text) if text is not None else ""}] + } + ] + } + + # Header row with consistent columns + header_row = { + "type": "tableRow", + "content": [ + make_cell("Tool Type"), + make_cell("Rule/Test Name"), + make_cell("Severity"), + make_cell("File"), + make_cell("Line"), + make_cell("Description") + ] + } + + rows = [header_row] + + for alert in alerts: + # Extract alert information with consistent field mapping + tool_type = self._get_tool_type_display(alert.get("type", "unknown")) + rule_name = self._extract_rule_name(alert) + severity = alert.get("severity", "unknown").upper() + file_path = self._extract_file_path(alert) + line_number = self._extract_line_number(alert) + description = self._extract_description(alert) + + row = { + "type": "tableRow", + "content": [ + make_cell(tool_type), + make_cell(rule_name), + make_cell(severity), + make_cell(file_path), + make_cell(line_number), + make_cell(description) + ] + } + rows.append(row) + + return { + "type": "table", + "content": rows + } + + def _get_tool_type_display(self, alert_type: str) -> str: + """Convert alert type to user-friendly tool display name.""" + type_mapping = { + "external-sast-python": "sast-python", + "external-sast-golang": "sast-golang", + "external-sast-javascript": "sast-javascript", + "external-secrets": "secrets", + "external-container-image": "container-image", + "external-container-dockerfile": "container-dockerfile", + "external-socket-sca": "socket-sca" + } + return type_mapping.get(alert_type, alert_type) + + def _extract_rule_name(self, alert: Dict[str, Any]) -> str: + """Extract rule/test name from alert.""" + props = alert.get("props", {}) + return ( + props.get("name") or + props.get("test_name") or + props.get("rule_id") or + alert.get("generatedBy", "unknown") + ) + + def _extract_file_path(self, alert: Dict[str, Any]) -> str: + """Extract file path from alert.""" + location = alert.get("location", {}) + return location.get("file", "unknown") + + def _extract_line_number(self, alert: Dict[str, Any]) -> str: + """Extract line number from alert.""" + location = alert.get("location", {}) + line = location.get("start") or location.get("line") + return str(line) if line is not None else "" + + def _extract_description(self, alert: Dict[str, Any]) -> str: + """Extract description from alert.""" + props = alert.get("props", {}) + return ( + props.get("description") or + props.get("issue_text") or + props.get("details") or + props.get("message") or + "No description available" + )[:200] + ("..." if len(str(props.get("description", ""))) > 200 else "") + + def send_events(self, events: list, plugin_name: str) -> dict: + """ + Will iterate through events and send to Jira as issues + :param events: A list containing the events to send + :param plugin_name: A string of the plugin name to use for the issue title + :return: A dict with response information + """ + if not self.config.get("enabled", False): + return {"status": "disabled"} + + log.debug("Jira Plugin Enabled") + + # Create a summary of all events + summary = f"Security Issues found from {plugin_name}" + + # Build description in ADF format + description_adf = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": f"Security issues were found by {plugin_name}:"} + ] + }, + self.create_adf_table_from_events(events) + ] + } + + log.debug("Sending Jira Issue") + + # Build and send the Jira issue + auth = base64.b64encode( + f"{self.email}:{self.api_token}".encode() + ).decode() + + payload = { + "fields": { + "project": {"key": self.project}, + "summary": summary, + "description": description_adf, + "issuetype": {"name": "Task"} + } + } + + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json" + } + + jira_url = f"{self.url}/rest/api/3/issue" + log.debug(f"Jira URL: {jira_url}") + + try: + response = requests.post(jira_url, json=payload, headers=headers) + if response.status_code >= 300: + log.error(f"Jira error {response.status_code}: {response.text}") + return {"status": "error", "message": response.text} + else: + issue_key = response.json().get('key') + log.info(f"Jira ticket created: {issue_key}") + return {"status": "success", "issue_key": issue_key} + except Exception as e: + log.error(f"Failed to send to Jira: {str(e)}") + return {"status": "error", "message": str(e)} + + @staticmethod + def create_adf_table_from_events(events): + """ + Creates an ADF (Atlassian Document Format) table from a list of events + """ + def make_cell(text): + return { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": str(text)}] + } + ] + } + + # Header row + header_row = { + "type": "tableRow", + "content": [ + make_cell("Severity"), + make_cell("Issue"), + make_cell("Test Name"), + make_cell("File"), + make_cell("Message") + ] + } + + rows = [header_row] + + for event in events: + if hasattr(event, 'as_dict'): + event_dict = event.as_dict() + else: + event_dict = event + + row = { + "type": "tableRow", + "content": [ + make_cell(event_dict.get("Severity", "Unknown")), + make_cell(event_dict.get("issue_text", "Unknown")), + make_cell(event_dict.get("test_name", "Unknown")), + make_cell(event_dict.get("filename", "Unknown")), + make_cell(event_dict.get("Message", "Unknown")) + ] + } + rows.append(row) + + return { + "type": "table", + "content": rows + } diff --git a/src/core/plugins/slack/__init__.py b/src/core/plugins/slack/__init__.py new file mode 100644 index 0000000..af15e78 --- /dev/null +++ b/src/core/plugins/slack/__init__.py @@ -0,0 +1 @@ +from .slack import Slack diff --git a/src/core/plugins/slack/slack.py b/src/core/plugins/slack/slack.py new file mode 100644 index 0000000..662738a --- /dev/null +++ b/src/core/plugins/slack/slack.py @@ -0,0 +1,97 @@ +import requests +from core import log + + +class Slack: + def __init__(self, webhook_url: str): + """ + Initializes the Slack client with webhook URL. + + :param webhook_url: The Slack webhook URL + """ + self.webhook_url = webhook_url + + def send_events(self, events: list, plugin_name: str) -> dict: + """ + Will iterate through events and send to Slack + :param events: A list containing the events to send + :param plugin_name: A string of the plugin name + :return: A dict with response information + """ + if not events: + log.debug("No events to notify via Slack.") + return {"status": "no_events"} + + log.debug("Slack Plugin Enabled") + + message_blocks = self.create_slack_blocks_from_events(events, plugin_name) + log.debug(f"Sending message to {self.webhook_url}") + + try: + response = requests.post( + self.webhook_url, + json={"blocks": message_blocks} + ) + + if response.status_code >= 400: + log.error("Slack error %s: %s", response.status_code, response.text) + return {"status": "error", "message": response.text} + else: + log.info("Successfully sent events to Slack") + return {"status": "success"} + except Exception as e: + log.error(f"Failed to send to Slack: {str(e)}") + return {"status": "error", "message": str(e)} + + @staticmethod + def create_slack_blocks_from_events(events: list, plugin_name: str): + """ + Creates Slack blocks from a list of events + """ + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Security issues found by {plugin_name}*" + } + }, + {"type": "divider"} + ] + + for event in events: + if hasattr(event, 'as_dict'): + event_dict = event.as_dict() + else: + event_dict = event + + severity = event_dict.get("Severity", "Unknown") + issue_text = event_dict.get("issue_text", "Unknown") + test_name = event_dict.get("test_name", "Unknown") + filename = event_dict.get("filename", "Unknown") + message = event_dict.get("Message", "Unknown") + + # Use emoji based on severity + severity_emoji = { + "HIGH": "🔴", + "MEDIUM": "🟡", + "LOW": "🟢", + "CRITICAL": "🚨" + }.get(severity.upper(), "⚠️") + + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"{severity_emoji} *{issue_text}*\n" + f"*Test:* `{test_name}`\n" + f"*File:* `{filename}`\n" + f"*Severity:* {severity}\n" + f"*Message:* {message}" + ) + } + }) + blocks.append({"type": "divider"}) + + return blocks diff --git a/src/core/plugins/teams/__init__.py b/src/core/plugins/teams/__init__.py new file mode 100644 index 0000000..7885643 --- /dev/null +++ b/src/core/plugins/teams/__init__.py @@ -0,0 +1 @@ +from .teams import Teams diff --git a/src/core/plugins/teams/teams.py b/src/core/plugins/teams/teams.py new file mode 100644 index 0000000..962786c --- /dev/null +++ b/src/core/plugins/teams/teams.py @@ -0,0 +1,86 @@ +import requests +from core import log + + +class Teams: + def __init__(self, webhook_url: str): + """ + Initializes the Teams client with webhook URL. + + :param webhook_url: The Teams webhook URL + """ + self.webhook_url = webhook_url + + def send_events(self, events: list, plugin_name: str) -> dict: + """ + Will iterate through events and send to Microsoft Teams + :param events: A list containing the events to send + :param plugin_name: A string of the plugin name + :return: A dict with response information + """ + if not events: + log.debug("No events to notify via Teams.") + return {"status": "no_events"} + + log.debug("Teams Plugin Enabled") + + message = self.create_teams_message_from_events(events, plugin_name) + log.debug(f"Sending message to {self.webhook_url}") + + try: + response = requests.post( + self.webhook_url, + json=message + ) + + if response.status_code >= 400: + log.error("Teams error %s: %s", response.status_code, response.text) + return {"status": "error", "message": response.text} + else: + log.info("Successfully sent events to Teams") + return {"status": "success"} + except Exception as e: + log.error(f"Failed to send to Teams: {str(e)}") + return {"status": "error", "message": str(e)} + + @staticmethod + def create_teams_message_from_events(events: list, plugin_name: str): + """ + Creates a Teams message from a list of events + """ + summary = f"Security issues found by {plugin_name}" + + # Create facts array for the message card + facts = [] + + for i, event in enumerate(events): + if hasattr(event, 'as_dict'): + event_dict = event.as_dict() + else: + event_dict = event + + facts.extend([ + {"name": f"Issue #{i+1}", "value": event_dict.get("issue_text", "Unknown")}, + {"name": "Severity", "value": event_dict.get("Severity", "Unknown")}, + {"name": "Test", "value": event_dict.get("test_name", "Unknown")}, + {"name": "File", "value": event_dict.get("filename", "Unknown")}, + {"name": "Message", "value": event_dict.get("Message", "Unknown")[:100] + "..." if len(event_dict.get("Message", "")) > 100 else event_dict.get("Message", "Unknown")} + ]) + + # Add separator between events (except for the last one) + if i < len(events) - 1: + facts.append({"name": "---", "value": "---"}) + + message = { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "themeColor": "FF0000", # Red color for security issues + "summary": summary, + "sections": [{ + "activityTitle": summary, + "activitySubtitle": f"Found {len(events)} security issue(s)", + "facts": facts + }] + } + + return message diff --git a/src/core/plugins/webhook/__init__.py b/src/core/plugins/webhook/__init__.py new file mode 100644 index 0000000..81bb99b --- /dev/null +++ b/src/core/plugins/webhook/__init__.py @@ -0,0 +1 @@ +from .webhook import Webhook diff --git a/src/core/plugins/webhook/webhook.py b/src/core/plugins/webhook/webhook.py new file mode 100644 index 0000000..928cb8b --- /dev/null +++ b/src/core/plugins/webhook/webhook.py @@ -0,0 +1,69 @@ +import requests +from core import log + + +class Webhook: + def __init__(self, url: str, headers: dict = None): + """ + Initializes the Webhook client with URL and optional headers. + + :param url: The webhook URL + :param headers: Optional headers to include in requests + """ + self.url = url + self.headers = headers or {"Content-Type": "application/json"} + + def send_events(self, events: list, plugin_name: str) -> dict: + """ + Will iterate through events and send to webhook + :param events: A list containing the events to send + :param plugin_name: A string of the plugin name + :return: A dict with response information + """ + if not events: + log.debug("No events to send via webhook.") + return {"status": "no_events"} + + log.debug("Webhook Plugin Enabled") + + payload = self.create_webhook_payload_from_events(events, plugin_name) + log.debug(f"Sending message to {self.url}") + + try: + response = requests.post( + self.url, + json=payload, + headers=self.headers + ) + + if response.status_code >= 400: + log.error("Webhook error %s: %s", response.status_code, response.text) + return {"status": "error", "message": response.text} + else: + log.info("Successfully sent events to webhook") + return {"status": "success"} + except Exception as e: + log.error(f"Failed to send to webhook: {str(e)}") + return {"status": "error", "message": str(e)} + + @staticmethod + def create_webhook_payload_from_events(events: list, plugin_name: str): + """ + Creates a webhook payload from a list of events + """ + payload = { + "plugin": plugin_name, + "timestamp": None, # Will be set by the receiving system if needed + "events_count": len(events), + "events": [] + } + + for event in events: + if hasattr(event, 'as_dict'): + event_dict = event.as_dict() + else: + event_dict = event + + payload["events"].append(event_dict) + + return payload diff --git a/src/core/socket_facts_consolidator.py b/src/core/socket_facts_consolidator.py index e1562bb..6f2b3f5 100644 --- a/src/core/socket_facts_consolidator.py +++ b/src/core/socket_facts_consolidator.py @@ -7,14 +7,48 @@ The format follows the Socket Facts schema with an "alerts" extension for non-package security findings like SAST issues, secrets, and container vulnerabilities. + +Git Repository Information: +The consolidator automatically adds repository information to the facts file including: +- repository: The repository name +- branch: The current branch or "detached-head" for CI environments +- scan_timestamp: ISO 8601 timestamp of when the scan was performed + +Environment Variable Overrides: +- SOCKET_REPOSITORY_NAME or GITHUB_REPOSITORY: Override repository name +- SOCKET_BRANCH_NAME, GITHUB_REF_NAME, GITHUB_HEAD_REF: Override branch name + +S3 Storage Support: +Optional S3-compatible storage for facts files with change detection: +- SOCKET_S3_ENABLED: Set to 'true' to enable S3 storage +- SOCKET_S3_BUCKET: S3 bucket name +- SOCKET_S3_ACCESS_KEY: S3 access key +- SOCKET_S3_SECRET_KEY: S3 secret key +- SOCKET_S3_ENDPOINT: S3 endpoint (defaults to AWS S3) +- SOCKET_S3_REGION: S3 region (defaults to us-east-1) + +Files are stored as: bucket/repo/branch/.socket.facts.json +The consolidator compares with previous scans and identifies new alerts. + +GitHub Actions Support: +The consolidator handles detached HEAD states common in CI environments and +automatically extracts repository/branch information from GitHub Actions +environment variables when available. """ import json import os +import subprocess import uuid from typing import Dict, List, Any, Optional from datetime import datetime, timezone +try: + from light_s3_client import Client + S3_AVAILABLE = True +except ImportError: + S3_AVAILABLE = False + class SocketFactsConsolidator: """Consolidates security tool results into Socket Facts format.""" @@ -24,6 +58,51 @@ def __init__(self, workspace_path: str = "."): self.consolidated_facts = { "components": [] } + # S3 configuration + self.s3_enabled = self._is_s3_enabled() + self.s3_client = self._init_s3_client() if self.s3_enabled else None + self.s3_bucket = os.environ.get('SOCKET_S3_BUCKET', 'security-wrapper') + self.s3_endpoint = os.environ.get('SOCKET_S3_ENDPOINT') + + def _is_s3_enabled(self) -> bool: + """Check if S3 upload is enabled and properly configured.""" + return ( + S3_AVAILABLE and + bool(os.environ.get('SOCKET_S3_ENABLED', '').lower() in ('true', '1', 'yes')) and + bool(os.environ.get('SOCKET_S3_BUCKET')) and + bool(os.environ.get('SOCKET_S3_ACCESS_KEY')) and + bool(os.environ.get('SOCKET_S3_SECRET_KEY')) + ) + + def _init_s3_client(self) -> Optional[Any]: + """Initialize S3 client if enabled and available.""" + if not S3_AVAILABLE: + return None + + try: + endpoint = os.environ.get('SOCKET_S3_ENDPOINT') + region = os.environ.get('SOCKET_S3_REGION', 'us-east-1') + access_key = os.environ.get('SOCKET_S3_ACCESS_KEY') + secret_key = os.environ.get('SOCKET_S3_SECRET_KEY') + + if endpoint: + # Use server parameter for custom endpoints (like MinIO) but still provide region + return Client( + server=endpoint, + region=region, + access_key=access_key, + secret_key=secret_key + ) + else: + # Use region for AWS S3 (default) + return Client( + region=region, + access_key=access_key, + secret_key=secret_key + ) + except Exception as e: + print(f"Warning: Failed to initialize S3 client: {e}") + return None def load_existing_socket_facts(self, facts_file_path: str = ".socket.facts.json") -> Dict[str, Any]: """Load existing socket facts file if it exists.""" @@ -33,12 +112,199 @@ def load_existing_socket_facts(self, facts_file_path: str = ".socket.facts.json" except (FileNotFoundError, json.JSONDecodeError): return {"components": []} + def _get_git_repository_info(self) -> Dict[str, Any]: + """Get git repository information including repo name, branch, and timestamp.""" + repo_info = {} + + # Check for environment variable overrides first + env_repo = os.environ.get('SOCKET_REPOSITORY_NAME') or os.environ.get('GITHUB_REPOSITORY') + env_branch = os.environ.get('SOCKET_BRANCH_NAME') or os.environ.get('GITHUB_REF_NAME') + + # Get repository name + if env_repo: + # For GITHUB_REPOSITORY, extract just the repo name (owner/repo -> repo) + repo_info["repository"] = env_repo.split('/')[-1] if '/' in env_repo else env_repo + else: + # Try to get from git remote + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=self.workspace_path, + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + remote_url = result.stdout.strip() + # Extract repo name from URL (handles both SSH and HTTPS) + if remote_url: + # Remove .git suffix if present + if remote_url.endswith('.git'): + remote_url = remote_url[:-4] + # Extract repo name from the end of the URL + repo_name = remote_url.split('/')[-1] + repo_info["repository"] = repo_name + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError, OSError): + # Git command failed or git not available - this is OK + pass + + # Get branch name + if env_branch: + repo_info["branch"] = env_branch + else: + # Try to get from git + try: + # First try to get the current branch + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=self.workspace_path, + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + branch = result.stdout.strip() + # Handle detached HEAD state (common in CI/GitHub Actions) + if branch == "HEAD": + # Try to get the branch from GitHub environment variables + if os.environ.get('GITHUB_HEAD_REF'): # For pull requests + repo_info["branch"] = os.environ.get('GITHUB_HEAD_REF') + elif os.environ.get('GITHUB_REF'): # For pushes + github_ref = os.environ.get('GITHUB_REF') + if github_ref.startswith('refs/heads/'): + repo_info["branch"] = github_ref.replace('refs/heads/', '') + else: + repo_info["branch"] = "detached-head" + else: + repo_info["branch"] = "detached-head" + else: + repo_info["branch"] = branch + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError, OSError): + # Git command failed or git not available - this is OK + pass + + # Add scan timestamp + repo_info["scan_timestamp"] = datetime.now(timezone.utc).isoformat() + + return repo_info + + def _get_s3_key(self, repository: str, branch: str) -> str: + """Generate S3 key in format: repo/branch/.socket.facts.json""" + return f"{repository}/{branch}/.socket.facts.json" + + def _download_previous_facts(self, repository: str, branch: str) -> Optional[Dict[str, Any]]: + """Download previous facts file from S3 if it exists.""" + if not self.s3_enabled or not self.s3_client: + return None + + try: + s3_key = self._get_s3_key(repository, branch) + # Use download_file method and read from temporary file + import tempfile + with tempfile.NamedTemporaryFile(mode='r', delete=True) as temp_file: + success = self.s3_client.download_file(self.s3_bucket, s3_key, temp_file.name) + if success: + with open(temp_file.name, 'r') as f: + content = f.read() + return json.loads(content) + else: + return None + except Exception as e: + print(f"Info: No previous facts file found in S3 or error downloading: {e}") + return None + + def _upload_facts_to_s3(self, facts: Dict[str, Any], repository: str, branch: str) -> bool: + """Upload facts file to S3.""" + if not self.s3_enabled or not self.s3_client: + return False + + try: + s3_key = self._get_s3_key(repository, branch) + facts_json = json.dumps(facts, indent=2) + + # Convert to bytes for upload + facts_bytes = facts_json.encode('utf-8') + + success = self.s3_client.upload_fileobj(facts_bytes, self.s3_bucket, s3_key) + + if success: + print(f"Successfully uploaded facts to S3: s3://{self.s3_bucket}/{s3_key}") + return True + else: + print(f"Failed to upload facts to S3") + return False + except Exception as e: + print(f"Error uploading facts to S3: {e}") + return False + + def _find_new_alerts(self, current_facts: Dict[str, Any], previous_facts: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Find new alerts by comparing current facts with previous facts.""" + if not previous_facts: + # If no previous facts, all alerts are new + return self._extract_all_alerts(current_facts) + + current_alerts = self._extract_all_alerts(current_facts) + previous_alerts = self._extract_all_alerts(previous_facts) + + # Create a set of previous alert signatures for comparison + previous_signatures = set() + for alert in previous_alerts: + signature = self._create_alert_signature(alert) + previous_signatures.add(signature) + + # Find new alerts + new_alerts = [] + for alert in current_alerts: + signature = self._create_alert_signature(alert) + if signature not in previous_signatures: + new_alerts.append(alert) + + return new_alerts + + def _extract_all_alerts(self, facts: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract all alerts from facts components.""" + all_alerts = [] + for component in facts.get("components", []): + component_alerts = component.get("alerts", []) + for alert in component_alerts: + # Add component context to alert + alert_with_context = alert.copy() + alert_with_context["component_name"] = component.get("name", "unknown") + alert_with_context["component_type"] = component.get("type", "unknown") + all_alerts.append(alert_with_context) + return all_alerts + + def _create_alert_signature(self, alert: Dict[str, Any]) -> str: + """Create a unique signature for an alert to enable comparison.""" + # Use key fields that uniquely identify an alert + signature_parts = [ + alert.get("type", ""), + alert.get("title", ""), + alert.get("description", ""), + alert.get("component_name", ""), + str(alert.get("manifestFiles", [])), # Convert to string for hashing + ] + return "|".join(signature_parts) + def consolidate_all_results(self, temp_output_dir: str = ".") -> Dict[str, Any]: """Consolidate all security tool results into a single socket facts format.""" # Start with existing socket facts (from Socket tools) socket_facts_path = os.path.join(self.workspace_path, ".socket.facts.json") consolidated = self.load_existing_socket_facts(socket_facts_path) + # Add git repository information at the top level + repo_info = self._get_git_repository_info() + consolidated.update(repo_info) + + # Get repository and branch for S3 operations + repository = consolidated.get("repository", "unknown-repo") + branch = consolidated.get("branch", "unknown-branch") + + # Download previous facts from S3 if enabled + previous_facts = None + if self.s3_enabled: + previous_facts = self._download_previous_facts(repository, branch) + # Add external security findings as synthetic components with alerts external_components = [] @@ -60,6 +326,19 @@ def consolidate_all_results(self, temp_output_dir: str = ".") -> Dict[str, Any]: if external_components: consolidated["components"].extend(external_components) + # Find new alerts if we have previous facts + if previous_facts: + new_alerts = self._find_new_alerts(consolidated, previous_facts) + consolidated["new_alerts"] = new_alerts + consolidated["new_alerts_count"] = len(new_alerts) + print(f"Found {len(new_alerts)} new alerts since last scan") + else: + print("No previous facts found - all alerts are considered new") + + # Upload current facts to S3 if enabled + if self.s3_enabled: + self._upload_facts_to_s3(consolidated, repository, branch) + return consolidated def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: diff --git a/src/socket_external_tools_runner.py b/src/socket_external_tools_runner.py index bc0aee3..f9001cb 100644 --- a/src/socket_external_tools_runner.py +++ b/src/socket_external_tools_runner.py @@ -13,7 +13,15 @@ from core.connectors.socket import Socket from core.connectors.socket_sca import SocketSCA from core.socket_facts_processor import SocketFactsProcessor -from core.load_plugins import load_sumo_logic_plugin, load_ms_sentinel_plugin, load_console_plugin +from core.load_plugins import ( + load_sumo_logic_plugin, + load_ms_sentinel_plugin, + load_console_plugin, + load_jira_plugin, + load_slack_plugin, + load_teams_plugin, + load_webhook_plugin +) from tabulate import tabulate logging.basicConfig(level=logging.INFO) @@ -82,6 +90,10 @@ def consolidate_trivy_results(pattern: str) -> dict: sumo_client = load_sumo_logic_plugin() ms_sentinel = load_ms_sentinel_plugin() console_output = load_console_plugin() +jira_client = load_jira_plugin() +slack_client = load_slack_plugin() +teams_client = load_teams_plugin() +webhook_client = load_webhook_plugin() # Dynamically build tool classes and names based on enabled inputs TOOL_CLASSES = {} @@ -176,6 +188,23 @@ def get_output_file_path(filename): if socket_sca_metrics.get("output") or socket_sca_metrics.get("scan_failed"): results["socket_sca"] = socket_sca_metrics + # Process consolidated facts for Jira integration + if jira_client: + # Check if there are any security alerts in the facts data + total_alerts = sum(len(component.get("alerts", [])) for component in facts_data.get("components", [])) + if total_alerts > 0: + print("Processing consolidated security alerts for Jira integration.") + jira_result = jira_client.send_consolidated_security_alerts(facts_data) + if jira_result.get("status") == "error": + print(f"Jira error: {jira_result.get('message', 'Unknown error')}") + elif jira_result.get("status") == "success": + if jira_result.get("created"): + print(f"Created new Jira ticket: {jira_result.get('issue_key')}") + elif jira_result.get("comment_added"): + print(f"Added new alerts to existing Jira ticket: {jira_result.get('issue_key')}") + else: + print(f"Jira ticket up to date: {jira_result.get('issue_key', 'No new alerts')}") + else: print("Using legacy individual tool output format") # Fallback to legacy processing if no consolidated facts file @@ -304,11 +333,21 @@ def get_output_file_path(filename): print("Issues detected with Security Tools. Please check Microsoft Sentinel Events") if console_output: print("Issues detected with Security Tools.") + if jira_client: + print("Issues detected with Security Tools. Creating Jira tickets.") + if slack_client: + print("Issues detected with Security Tools. Sending Slack notifications.") + if teams_client: + print("Issues detected with Security Tools. Sending Teams notifications.") + if webhook_client: + print("Issues detected with Security Tools. Sending webhook notifications.") for key, events in tool_events.items(): tool_name = f"SocketSecurityTools-{TOOL_NAMES[key]}" formatted_events = [json.dumps(event, default=lambda o: o.to_json()) for event in events.get("events", [])] + event_objects = events.get("events", []) + if sumo_client: print(errors) if (errors := sumo_client.send_events(formatted_events, tool_name)) else [] @@ -317,6 +356,29 @@ def get_output_file_path(filename): if console_output: print(errors) if (errors := console_output.print_events(events.get("output", []), key)) else [] + + # New plugins that work with event objects + if jira_client: + # Only use legacy processing if we don't have consolidated facts + if not os.path.exists(".socket.facts.json"): + result = jira_client.send_events(event_objects, tool_name) + if result.get("status") == "error": + print(f"Jira error: {result.get('message', 'Unknown error')}") + + if slack_client: + result = slack_client.send_events(event_objects, tool_name) + if result.get("status") == "error": + print(f"Slack error: {result.get('message', 'Unknown error')}") + + if teams_client: + result = teams_client.send_events(event_objects, tool_name) + if result.get("status") == "error": + print(f"Teams error: {result.get('message', 'Unknown error')}") + + if webhook_client: + result = webhook_client.send_events(event_objects, tool_name) + if result.get("status") == "error": + print(f"Webhook error: {result.get('message', 'Unknown error')}") if scan_failed: print("Security scan failed - exiting with error") diff --git a/src/version.py b/src/version.py index 89e4f67..0018828 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.23" +__version__ = "1.0.24" From 22dba00b4611c679fdf28ddbbaa1aa760e7e87e1 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Mon, 25 Aug 2025 11:12:04 -0700 Subject: [PATCH 09/11] feat: Major security wrapper improvements - S3 integration, PURL standardization, and consolidated facts processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔧 Core Infrastructure: - Add light-s3-client 0.0.30 dependency for S3-compatible storage - Implement S3 upload/download with change detection for .socket.facts.json - Update Docker container structure with proper working directories - Fix entrypoint.sh duplicate declarations and consolidation logic 📊 Data Format Standardization: - Standardize tool types to format like 'sast-bandit', 'secrets-trufflehog', 'container-trivy' - Implement proper PURL format: pkg:ecosystem/name@version?type=tool-type - Add PURL creation for Socket components that initially lack purl fields - Update version to 1.0.25 across all components 🔄 Socket Facts Consolidation: - Add _process_socket_vulnerabilities method to create alerts from Socket reachability data - Implement comprehensive security tool result consolidation into .socket.facts.json - Add new_alerts field with S3-based change detection to prevent duplicate processing - Support for all security tools: Bandit, Gosec, ESLint, Trufflehog, Trivy, Socket SCA, Socket Reachability 🎯 Plugin Integration Improvements: - Update Jira plugin with enhanced table format including Tool and Source columns - Add _extract_tool_from_purl() and _clean_purl_source() methods to all plugins - Implement send_consolidated_security_alerts() across Jira, Slack, Teams, Webhook plugins - Fix Jira authentication and project access with better error handling 🐛 Bug Fixes: - Fix Socket SCA JSON extraction from timestamped output - Resolve merge conflict in socket_sca connector - Fix Trufflehog file path normalization and line number extraction - Improve error handling and debug output across all components This update establishes a robust foundation for security tool integration with proper data standardization, S3-based state management, and comprehensive plugin support for external integrations. --- Dockerfile | 17 +- entrypoint.sh | 43 +-- pyproject.toml | 3 +- src/core/connectors/socket_sca/__init__.py | 3 - src/core/plugins/jira/jira.py | 111 +++++++- src/core/plugins/slack/slack.py | 189 +++++++++++++ src/core/plugins/teams/teams.py | 236 ++++++++++++++++ src/core/plugins/webhook/webhook.py | 182 ++++++++++++ src/core/socket_facts_consolidator.py | 190 ++++++++++--- src/socket_external_tools_runner.py | 311 ++++++++------------- src/version.py | 2 +- 11 files changed, 1016 insertions(+), 271 deletions(-) diff --git a/Dockerfile b/Dockerfile index da944d4..e846859 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,13 @@ # Use the official Python image as a base FROM python:3.12 -COPY src/socket_external_tools_runner.py / -COPY src/core /core -COPY entrypoint.sh / + +# Create application directory +WORKDIR /socket-security-tools + +COPY src/socket_external_tools_runner.py /socket-security-tools/ +COPY src/version.py /socket-security-tools/ +COPY src/core /socket-security-tools/core +COPY entrypoint.sh /socket-security-tools/ ENV PATH=$PATH:/usr/local/go/bin # Install uv @@ -38,14 +43,14 @@ RUN npm install -g socket RUN uv tool install socketsecurity # Copy the entrypoint script and make it executable -RUN chmod +x /entrypoint.sh +RUN chmod +x /socket-security-tools/entrypoint.sh COPY pyproject.toml uv.lock /scripts/ # Install Python dependencies using uv WORKDIR /scripts -RUN uv sync --frozen +RUN uv sync --frozen && uv pip install light-s3-client ENV PATH="/scripts/.venv/bin:$PATH" # Define entrypoint -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/socket-security-tools/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index ccb7f6e..15201af 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,13 +16,7 @@ TRIVY_RULES=${INPUT_TRIVY_RULES:-} # Socket configuration SOCKET_ORG=${INPUT_SOCKET_ORG:-} SOCKET_API_KEY=${INPUT_SOCKET_API_KEY:-} -SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_SECURITY_API_KEY:-} -SOCKET_SCA_FILES=${INPUT_SOCKET_SCA_FILES:-} - -# Socket configuration -SOCKET_ORG=${INPUT_SOCKET_ORG:-} -SOCKET_API_KEY=${INPUT_SOCKET_API_KEY:-} -SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_SECURITY_API_KEY:-} +export SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_SECURITY_API_KEY:-} SOCKET_SCA_FILES=${INPUT_SOCKET_SCA_FILES:-} # Set output directory for temp files @@ -153,14 +147,17 @@ if [[ "$INPUT_SOCKET_SCA_ENABLED" == "true" ]]; then fi # Extract JSON from the output (Socket CLI outputs JSON after log messages) - # Look for lines that start with a timestamp followed by JSON - if grep -q '": {' "$temp_output_file"; then - # Extract the JSON part (everything after the timestamp that contains JSON) - # Use a more specific pattern to remove the timestamp: YYYY-MM-DD HH:MM:SS,mmm: - grep '": {' "$temp_output_file" | tail -1 | sed 's/^[0-9-]*[[:space:]]*[0-9:,]*:[[:space:]]*{/{/' > "$TEMP_OUTPUT_DIR/socket_sca_output.json" + # Look for the final JSON output line which contains the complete result + if grep -q '^[0-9-]*[[:space:]]*[0-9:,]*:[[:space:]]*{' "$temp_output_file"; then + # Extract the JSON part (everything after the timestamp) + # Use a more specific pattern to remove the timestamp and get the JSON: YYYY-MM-DD HH:MM:SS,mmm: {...} + grep '^[0-9-]*[[:space:]]*[0-9:,]*:[[:space:]]*{' "$temp_output_file" | tail -1 | sed 's/^[0-9-]*[[:space:]]*[0-9:,]*:[[:space:]]*//' > "$TEMP_OUTPUT_DIR/socket_sca_output.json" + echo "Successfully extracted Socket SCA JSON output" else # If no JSON found, create a failure JSON echo "No valid JSON output from Socket SCA, creating failure JSON" + echo "Output file contents:" + cat "$temp_output_file" echo '{"scan_failed": true, "new_alerts": [], "error": "Socket SCA command failed or produced invalid output"}' > "$TEMP_OUTPUT_DIR/socket_sca_output.json" fi @@ -326,16 +323,10 @@ fi echo "Consolidating security tool results into .socket.facts.json format" if [[ "$DEV_MODE" == "true" ]]; then CONSOLIDATOR_SCRIPT_PATH="$WORKSPACE/src/core/socket_facts_consolidator.py" -else - CONSOLIDATOR_SCRIPT_PATH="$WORKSPACE/socket_facts_consolidator.py" -fi - -# Consolidate all security tool results into .socket.facts.json format -echo "Consolidating security tool results into .socket.facts.json format" -if [[ "$DEV_MODE" == "true" ]]; then CONSOLIDATOR_SCRIPT_DIR="$WORKSPACE/src" else - CONSOLIDATOR_SCRIPT_DIR="$WORKSPACE" + CONSOLIDATOR_SCRIPT_PATH="$WORKSPACE/socket_facts_consolidator.py" + CONSOLIDATOR_SCRIPT_DIR="/socket-security-tools" fi python -c " @@ -346,6 +337,16 @@ from core.socket_facts_consolidator import SocketFactsConsolidator consolidator = SocketFactsConsolidator('$GITHUB_WORKSPACE') consolidator.save_consolidated_facts('$GITHUB_WORKSPACE/.socket.facts.json') print('Successfully consolidated security tool results into .socket.facts.json') + +# Debug: Check if consolidation worked +import json +try: + with open('$GITHUB_WORKSPACE/.socket.facts.json', 'r') as f: + data = json.load(f) + total_alerts = sum(len(component.get('alerts', [])) for component in data.get('components', [])) + print(f'DEBUG: Consolidated facts has {len(data.get(\"components\", []))} components and {total_alerts} alerts') +except Exception as e: + print(f'DEBUG: Error reading consolidated facts: {e}') " || echo "Warning: Could not consolidate results, continuing with individual tool processing" # Run the Python script from the correct directory and path @@ -354,7 +355,7 @@ if [[ -n "$PY_SCRIPT_PATH" ]]; then elif [[ "$DEV_MODE" == "true" ]]; then FINAL_PY_SCRIPT_PATH="$WORKSPACE/src/socket_external_tools_runner.py" else - FINAL_PY_SCRIPT_PATH="$WORKSPACE/socket_external_tools_runner.py" + FINAL_PY_SCRIPT_PATH="/socket-security-tools/socket_external_tools_runner.py" fi if [[ -f "$FINAL_PY_SCRIPT_PATH" ]]; then diff --git a/pyproject.toml b/pyproject.toml index 752ec33..5bf48d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.24" +version = "1.0.25" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ @@ -8,6 +8,7 @@ dependencies = [ "PyGithub~=2.4.0", "requests~=2.32.3", "tabulate~=0.9.0", + "light-s3-client~=0.0.30", ] [build-system] diff --git a/src/core/connectors/socket_sca/__init__.py b/src/core/connectors/socket_sca/__init__.py index 7d25ae8..0351afe 100644 --- a/src/core/connectors/socket_sca/__init__.py +++ b/src/core/connectors/socket_sca/__init__.py @@ -34,10 +34,7 @@ def process_output(cls, data: dict, cwd: str, plugin_name: str = "SocketSCA") -> plugin_name=plugin_name ) -<<<<<<< HEAD # Always include scan failures regardless of severity filter -======= ->>>>>>> cb8a152 (Fixing the s3 workflow) test_name = cls.get_test_name(failure_result) metrics["tests"].setdefault(test_name, 0) metrics["tests"][test_name] += 1 diff --git a/src/core/plugins/jira/jira.py b/src/core/plugins/jira/jira.py index e2c9b1f..c84f944 100644 --- a/src/core/plugins/jira/jira.py +++ b/src/core/plugins/jira/jira.py @@ -26,40 +26,51 @@ def send_consolidated_security_alerts(self, facts_data: Dict[str, Any]) -> Dict[ :return: A dict with response information """ if not self.config.get("enabled", False): + print("Jira plugin is not enabled") return {"status": "disabled"} + print("Jira Plugin Enabled - Processing consolidated security alerts") log.debug("Jira Plugin Enabled - Processing consolidated security alerts") # Extract repository and branch information repository = facts_data.get("repository", "unknown-repo") branch = facts_data.get("branch", "unknown-branch") + print(f"Repository: {repository}, Branch: {branch}") # Create ticket summary ticket_summary = f"Socket Security Issues detected in {repository} - {branch}" + print(f"Ticket summary: {ticket_summary}") # Check if ticket already exists + print("Checking for existing tickets...") existing_ticket = self._find_existing_ticket(ticket_summary) # Get all alerts from the facts data all_alerts = self._extract_alerts_from_facts(facts_data) + print(f"Found {len(all_alerts)} total alerts in facts data") if not all_alerts: + print("No security alerts found in facts data") log.info("No security alerts found in facts data") return {"status": "success", "message": "No alerts to process"} # Get new alerts if we have previous scan data new_alerts = facts_data.get("new_alerts", all_alerts) + print(f"New alerts to process: {len(new_alerts)}") if existing_ticket: # Update existing ticket with new alerts only if new_alerts: + print(f"Found existing ticket {existing_ticket['key']}, adding {len(new_alerts)} new alerts") log.info(f"Found existing ticket {existing_ticket['key']}, adding {len(new_alerts)} new alerts") return self._add_comment_to_ticket(existing_ticket["key"], new_alerts, repository, branch) else: + print(f"Found existing ticket {existing_ticket['key']}, no new alerts to add") log.info(f"Found existing ticket {existing_ticket['key']}, no new alerts to add") return {"status": "success", "message": "No new alerts to add", "issue_key": existing_ticket["key"]} else: # Create new ticket with all alerts + print(f"Creating new ticket with {len(all_alerts)} security alerts") log.info(f"Creating new ticket with {len(all_alerts)} security alerts") return self._create_new_ticket(ticket_summary, all_alerts, repository, branch) @@ -125,6 +136,9 @@ def _extract_alerts_from_facts(self, facts_data: Dict[str, Any]) -> List[Dict[st alert_with_context = alert.copy() alert_with_context["component_name"] = component.get("name", "unknown") alert_with_context["component_type"] = component.get("type", "unknown") + alert_with_context["component_purl"] = component.get("purl", "") + alert_with_context["tool"] = component.get("type", "unknown") # Tool is the component type + alert_with_context["source"] = component.get("purl", "") # Source is the PURL all_alerts.append(alert_with_context) return all_alerts @@ -166,6 +180,7 @@ def _create_new_ticket(self, summary: str, alerts: List[Dict[str, Any]], reposit } } + auth = base64.b64encode(f"{self.email}:{self.api_token}".encode()).decode() headers = { "Authorization": f"Basic {auth}", "Content-Type": "application/json" @@ -174,15 +189,61 @@ def _create_new_ticket(self, summary: str, alerts: List[Dict[str, Any]], reposit jira_url = f"{self.url}/rest/api/3/issue" try: + print(f"Making Jira API request to: {jira_url}") + print(f"Project: {self.project}") + print(f"Summary: {summary}") + print(f"Email: {self.email}") + print(f"API Token starts with: {self.api_token[:10]}...") + + # First, test authentication by getting project info + + # Test project access + project_url = f"{self.url}/rest/api/3/project/{self.project}" + print(f"Testing project access: {project_url}") + test_response = requests.get(project_url, headers=headers) + print(f"Project access response: {test_response.status_code}") + + if test_response.status_code == 200: + project_data = test_response.json() + print(f"Project name: {project_data.get('name', 'Unknown')}") + print(f"Project key: {project_data.get('key', 'Unknown')}") + else: + print(f"Project access failed: {test_response.text}") + + # Get available issue types for this project + issue_types_url = f"{self.url}/rest/api/3/issue/createmeta?projectKeys={self.project}" + print(f"Getting issue types: {issue_types_url}") + issue_types_response = requests.get(issue_types_url, headers=headers) + print(f"Issue types response: {issue_types_response.status_code}") + + if issue_types_response.status_code == 200: + meta_data = issue_types_response.json() + projects = meta_data.get('projects', []) + if projects: + issue_types = projects[0].get('issuetypes', []) + print(f"Available issue types: {[it.get('name') for it in issue_types]}") + + # Use the first available issue type instead of hardcoded "Task" + if issue_types: + issue_type_name = issue_types[0].get('name', 'Task') + print(f"Using issue type: {issue_type_name}") + payload["fields"]["issuetype"] = {"name": issue_type_name} + else: + print(f"Could not get issue types: {issue_types_response.text}") + response = requests.post(jira_url, json=payload, headers=headers) + print(f"Jira API response status: {response.status_code}") if response.status_code >= 300: + print(f"Jira error {response.status_code}: {response.text}") log.error(f"Jira error {response.status_code}: {response.text}") return {"status": "error", "message": response.text} else: issue_key = response.json().get('key') + print(f"✅ Jira ticket created successfully: {issue_key}") log.info(f"Jira ticket created: {issue_key}") return {"status": "success", "issue_key": issue_key, "created": True} except Exception as e: + print(f"❌ Failed to create Jira ticket: {str(e)}") log.error(f"Failed to create Jira ticket: {str(e)}") return {"status": "error", "message": str(e)} @@ -258,11 +319,12 @@ def make_cell(text): header_row = { "type": "tableRow", "content": [ - make_cell("Tool Type"), + make_cell("Tool"), make_cell("Rule/Test Name"), make_cell("Severity"), make_cell("File"), make_cell("Line"), + make_cell("Source"), make_cell("Description") ] } @@ -271,21 +333,23 @@ def make_cell(text): for alert in alerts: # Extract alert information with consistent field mapping - tool_type = self._get_tool_type_display(alert.get("type", "unknown")) + tool = self._extract_tool_from_purl(alert.get("source", alert.get("component_purl", ""))) or alert.get("tool", alert.get("component_type", "unknown")) rule_name = self._extract_rule_name(alert) severity = alert.get("severity", "unknown").upper() file_path = self._extract_file_path(alert) line_number = self._extract_line_number(alert) + source = self._clean_purl_source(alert.get("source", alert.get("component_purl", ""))) description = self._extract_description(alert) row = { "type": "tableRow", "content": [ - make_cell(tool_type), + make_cell(tool), make_cell(rule_name), make_cell(severity), make_cell(file_path), make_cell(line_number), + make_cell(source), make_cell(description) ] } @@ -341,6 +405,47 @@ def _extract_description(self, alert: Dict[str, Any]) -> str: "No description available" )[:200] + ("..." if len(str(props.get("description", ""))) > 200 else "") + def _extract_tool_from_purl(self, purl: str) -> str: + """Extract tool type from PURL's type parameter.""" + if not purl or "?type=" not in purl: + return "" + + try: + # Extract the type parameter from the PURL + # Format: pkg:ecosystem/name@version?type=tool-type + type_part = purl.split("?type=")[1] + # Handle multiple parameters by taking only the first one + tool_type = type_part.split("&")[0] + return tool_type + except (IndexError, AttributeError): + return "" + + def _clean_purl_source(self, purl: str) -> str: + """Clean PURL by removing type parameter and empty query string.""" + if not purl: + return "" + + try: + # Remove the type parameter + if "?type=" in purl: + # Split on ?type= and take the first part + base_purl = purl.split("?type=")[0] + + # Check if there are other parameters after type= + type_section = purl.split("?type=")[1] + if "&" in type_section: + # There are other parameters, reconstruct with remaining params + remaining_params = "&".join(type_section.split("&")[1:]) + return f"{base_purl}?{remaining_params}" + else: + # No other parameters, return clean base PURL + return base_purl + else: + # No type parameter, return as is + return purl + except (IndexError, AttributeError): + return purl + def send_events(self, events: list, plugin_name: str) -> dict: """ Will iterate through events and send to Jira as issues diff --git a/src/core/plugins/slack/slack.py b/src/core/plugins/slack/slack.py index 662738a..67ff175 100644 --- a/src/core/plugins/slack/slack.py +++ b/src/core/plugins/slack/slack.py @@ -1,4 +1,5 @@ import requests +from typing import Dict, List, Any from core import log @@ -11,6 +12,153 @@ def __init__(self, webhook_url: str): """ self.webhook_url = webhook_url + def send_consolidated_security_alerts(self, facts_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Process consolidated socket facts and send security alerts to Slack. + + :param facts_data: Consolidated socket facts data + :return: A dict with response information + """ + log.debug("Slack Plugin Enabled - Processing consolidated security alerts") + + # Extract repository and branch information + repository = facts_data.get("repository", "unknown-repo") + branch = facts_data.get("branch", "unknown-branch") + + # Get new alerts from the facts data + new_alerts = facts_data.get("new_alerts", []) + if not new_alerts: + log.info("No new security alerts to send to Slack") + return {"status": "success", "message": "No new alerts to process"} + + # Extract alerts from facts components + all_alerts = self._extract_alerts_from_facts(facts_data) + alerts_to_send = new_alerts if new_alerts else all_alerts + + if not alerts_to_send: + log.info("No security alerts found in facts data") + return {"status": "success", "message": "No alerts to process"} + + log.info(f"Sending {len(alerts_to_send)} security alerts to Slack") + + # Create Slack message blocks + message_blocks = self._create_slack_blocks_from_consolidated_alerts(alerts_to_send, repository, branch) + + try: + response = requests.post( + self.webhook_url, + json={"blocks": message_blocks} + ) + + if response.status_code >= 400: + log.error("Slack error %s: %s", response.status_code, response.text) + return {"status": "error", "message": response.text} + else: + log.info("Successfully sent consolidated security alerts to Slack") + return {"status": "success"} + except Exception as e: + log.error(f"Failed to send consolidated alerts to Slack: {str(e)}") + return {"status": "error", "message": str(e)} + + def _extract_alerts_from_facts(self, facts_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Extract all alerts from the facts data components. + + :param facts_data: Consolidated socket facts data + :return: List of all alerts + """ + all_alerts = [] + + for component in facts_data.get("components", []): + component_alerts = component.get("alerts", []) + for alert in component_alerts: + # Add component context to alert + alert_with_context = alert.copy() + alert_with_context["component_name"] = component.get("name", "unknown") + alert_with_context["component_type"] = component.get("type", "unknown") + alert_with_context["component_purl"] = component.get("purl", "") + alert_with_context["tool"] = component.get("type", "unknown") # Tool is the component type + alert_with_context["source"] = component.get("purl", "") # Source is the PURL + all_alerts.append(alert_with_context) + + return all_alerts + + def _create_slack_blocks_from_consolidated_alerts(self, alerts: List[Dict[str, Any]], repository: str, branch: str) -> List[Dict[str, Any]]: + """ + Create Slack message blocks from consolidated security alerts. + + :param alerts: List of security alerts + :param repository: Repository name + :param branch: Branch name + :return: List of Slack message blocks + """ + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"🔐 *Security Issues Detected in {repository}*\n*Branch:* `{branch}`\n*Total Alerts:* {len(alerts)}" + } + }, + {"type": "divider"} + ] + + for alert in alerts: + # Extract alert information + tool = self._extract_tool_from_purl(alert.get("source", alert.get("component_purl", ""))) or alert.get("tool", alert.get("component_type", "unknown")) + severity = alert.get("severity", "unknown").upper() + source = self._clean_purl_source(alert.get("source", alert.get("component_purl", ""))) + + # Extract rule/test name + props = alert.get("props", {}) + rule_name = ( + props.get("name") or + props.get("test_name") or + props.get("rule_id") or + alert.get("generatedBy", "unknown") + ) + + # Extract file path and line + location = alert.get("location", {}) + file_path = location.get("file", "unknown") + line_number = location.get("start") or location.get("line") + line_text = f" (line {line_number})" if line_number else "" + + # Extract description + description = ( + props.get("description") or + props.get("issue_text") or + props.get("details") or + props.get("message") or + "No description available" + )[:150] + ("..." if len(str(description)) > 150 else "") + + # Use emoji based on severity + severity_emoji = { + "CRITICAL": "🚨", + "HIGH": "🔴", + "MEDIUM": "🟡", + "LOW": "🟢" + }.get(severity, "⚠️") + + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"{severity_emoji} *{rule_name}*\n" + f"*Tool:* `{tool}`\n" + f"*File:* `{file_path}{line_text}`\n" + f"*Severity:* {severity}\n" + f"*Source:* `{source}`\n" + f"*Description:* {description}" + ) + } + }) + blocks.append({"type": "divider"}) + + return blocks + def send_events(self, events: list, plugin_name: str) -> dict: """ Will iterate through events and send to Slack @@ -43,6 +191,47 @@ def send_events(self, events: list, plugin_name: str) -> dict: log.error(f"Failed to send to Slack: {str(e)}") return {"status": "error", "message": str(e)} + def _extract_tool_from_purl(self, purl: str) -> str: + """Extract tool type from PURL's type parameter.""" + if not purl or "?type=" not in purl: + return "" + + try: + # Extract the type parameter from the PURL + # Format: pkg:ecosystem/name@version?type=tool-type + type_part = purl.split("?type=")[1] + # Handle multiple parameters by taking only the first one + tool_type = type_part.split("&")[0] + return tool_type + except (IndexError, AttributeError): + return "" + + def _clean_purl_source(self, purl: str) -> str: + """Clean PURL by removing type parameter and empty query string.""" + if not purl: + return "" + + try: + # Remove the type parameter + if "?type=" in purl: + # Split on ?type= and take the first part + base_purl = purl.split("?type=")[0] + + # Check if there are other parameters after type= + type_section = purl.split("?type=")[1] + if "&" in type_section: + # There are other parameters, reconstruct with remaining params + remaining_params = "&".join(type_section.split("&")[1:]) + return f"{base_purl}?{remaining_params}" + else: + # No other parameters, return clean base PURL + return base_purl + else: + # No type parameter, return as is + return purl + except (IndexError, AttributeError): + return purl + @staticmethod def create_slack_blocks_from_events(events: list, plugin_name: str): """ diff --git a/src/core/plugins/teams/teams.py b/src/core/plugins/teams/teams.py index 962786c..9f31b63 100644 --- a/src/core/plugins/teams/teams.py +++ b/src/core/plugins/teams/teams.py @@ -1,4 +1,5 @@ import requests +from typing import Dict, List, Any from core import log @@ -11,6 +12,241 @@ def __init__(self, webhook_url: str): """ self.webhook_url = webhook_url + def send_consolidated_security_alerts(self, facts_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Process consolidated socket facts and send security alerts to Microsoft Teams. + + :param facts_data: Consolidated socket facts data + :return: A dict with response information + """ + log.debug("Teams Plugin Enabled - Processing consolidated security alerts") + + # Extract repository and branch information + repository = facts_data.get("repository", "unknown-repo") + branch = facts_data.get("branch", "unknown-branch") + + # Get new alerts from the facts data + new_alerts = facts_data.get("new_alerts", []) + if not new_alerts: + log.info("No new security alerts to send to Teams") + return {"status": "success", "message": "No new alerts to process"} + + # Extract alerts from facts components + all_alerts = self._extract_alerts_from_facts(facts_data) + alerts_to_send = new_alerts if new_alerts else all_alerts + + if not alerts_to_send: + log.info("No security alerts found in facts data") + return {"status": "success", "message": "No alerts to process"} + + log.info(f"Sending {len(alerts_to_send)} security alerts to Teams") + + # Create Teams message + message = self._create_teams_message_from_consolidated_alerts(alerts_to_send, repository, branch) + + try: + response = requests.post( + self.webhook_url, + json=message + ) + + if response.status_code >= 400: + log.error("Teams error %s: %s", response.status_code, response.text) + return {"status": "error", "message": response.text} + else: + log.info("Successfully sent consolidated security alerts to Teams") + return {"status": "success"} + except Exception as e: + log.error(f"Failed to send consolidated alerts to Teams: {str(e)}") + return {"status": "error", "message": str(e)} + + def _extract_alerts_from_facts(self, facts_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Extract all alerts from the facts data components. + + :param facts_data: Consolidated socket facts data + :return: List of all alerts + """ + all_alerts = [] + + for component in facts_data.get("components", []): + component_alerts = component.get("alerts", []) + for alert in component_alerts: + # Add component context to alert + alert_with_context = alert.copy() + alert_with_context["component_name"] = component.get("name", "unknown") + alert_with_context["component_type"] = component.get("type", "unknown") + alert_with_context["component_purl"] = component.get("purl", "") + alert_with_context["tool"] = component.get("type", "unknown") # Tool is the component type + alert_with_context["source"] = component.get("purl", "") # Source is the PURL + all_alerts.append(alert_with_context) + + return all_alerts + + def _create_teams_message_from_consolidated_alerts(self, alerts: List[Dict[str, Any]], repository: str, branch: str) -> Dict[str, Any]: + """ + Create Teams message from consolidated security alerts. + + :param alerts: List of security alerts + :param repository: Repository name + :param branch: Branch name + :return: Teams message payload + """ + # Create summary section + facts = [ + {"name": "Repository", "value": repository}, + {"name": "Branch", "value": branch}, + {"name": "Total Alerts", "value": str(len(alerts))} + ] + + # Create sections for each alert + sections = [ + { + "activityTitle": f"🔐 Security Issues Detected in {repository}", + "activitySubtitle": f"Branch: {branch}", + "facts": facts + } + ] + + # Add each alert as a section + for i, alert in enumerate(alerts[:10]): # Limit to 10 alerts to avoid message size limits + # Extract alert information + tool = self._extract_tool_from_purl(alert.get("source", alert.get("component_purl", ""))) or alert.get("tool", alert.get("component_type", "unknown")) + severity = alert.get("severity", "unknown").upper() + source = self._clean_purl_source(alert.get("source", alert.get("component_purl", ""))) + + # Extract rule/test name + props = alert.get("props", {}) + rule_name = ( + props.get("name") or + props.get("test_name") or + props.get("rule_id") or + alert.get("generatedBy", "unknown") + ) + + # Extract file path and line + location = alert.get("location", {}) + file_path = location.get("file", "unknown") + line_number = location.get("start") or location.get("line") + file_location = f"{file_path}" + (f" (line {line_number})" if line_number else "") + + # Extract description + description = ( + props.get("description") or + props.get("issue_text") or + props.get("details") or + props.get("message") or + "No description available" + )[:200] + ("..." if len(str(description)) > 200 else "") + + # Use emoji based on severity + severity_emoji = { + "CRITICAL": "🚨", + "HIGH": "🔴", + "MEDIUM": "🟡", + "LOW": "🟢" + }.get(severity, "⚠️") + + sections.append({ + "activityTitle": f"{severity_emoji} {rule_name}", + "facts": [ + {"name": "Tool", "value": tool}, + {"name": "Severity", "value": severity}, + {"name": "File", "value": file_location}, + {"name": "Source", "value": source}, + {"name": "Description", "value": description} + ] + }) + + if len(alerts) > 10: + sections.append({ + "activityTitle": f"⚠️ Additional Alerts", + "facts": [ + {"name": "Note", "value": f"Showing 10 of {len(alerts)} total alerts. Check full report for complete details."} + ] + }) + + return { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "themeColor": "FF5722", # Red-orange color for security alerts + "summary": f"Security Issues Detected in {repository}", + "sections": sections + } + + def send_events(self, events: list, plugin_name: str) -> dict: + """ + Will iterate through events and send to Microsoft Teams + :param events: A list containing the events to send + :param plugin_name: A string of the plugin name + :return: A dict with response information + """ + if not events: + log.debug("No events to notify via Teams.") + return {"status": "no_events"} + + log.debug("Teams Plugin Enabled") + + message = self.create_teams_message_from_events(events, plugin_name) + log.debug(f"Sending message to {self.webhook_url}") + + try: + response = requests.post( + self.webhook_url, + json=message + ) + + if response.status_code >= 400: + log.error("Teams error %s: %s", response.status_code, response.text) + return {"status": "error", "message": response.text} + else: + log.info("Successfully sent events to Teams") + return {"status": "success"} + except Exception as e: + log.error(f"Failed to send to Teams: {str(e)}") + return {"status": "error", "message": str(e)} + + def _extract_tool_from_purl(self, purl: str) -> str: + """Extract tool type from PURL's type parameter.""" + if not purl or "?type=" not in purl: + return "" + + try: + # Extract the type parameter from the PURL + # Format: pkg:ecosystem/name@version?type=tool-type + type_part = purl.split("?type=")[1] + # Handle multiple parameters by taking only the first one + tool_type = type_part.split("&")[0] + return tool_type + except (IndexError, AttributeError): + return "" + + def _clean_purl_source(self, purl: str) -> str: + """Clean PURL by removing type parameter and empty query string.""" + if not purl: + return "" + + try: + # Remove the type parameter + if "?type=" in purl: + # Split on ?type= and take the first part + base_purl = purl.split("?type=")[0] + + # Check if there are other parameters after type= + type_section = purl.split("?type=")[1] + if "&" in type_section: + # There are other parameters, reconstruct with remaining params + remaining_params = "&".join(type_section.split("&")[1:]) + return f"{base_purl}?{remaining_params}" + else: + # No other parameters, return clean base PURL + return base_purl + else: + # No type parameter, return as is + return purl + except (IndexError, AttributeError): + return purl + def send_events(self, events: list, plugin_name: str) -> dict: """ Will iterate through events and send to Microsoft Teams diff --git a/src/core/plugins/webhook/webhook.py b/src/core/plugins/webhook/webhook.py index 928cb8b..f3edabe 100644 --- a/src/core/plugins/webhook/webhook.py +++ b/src/core/plugins/webhook/webhook.py @@ -1,4 +1,5 @@ import requests +from typing import Dict, List, Any from core import log @@ -13,6 +14,187 @@ def __init__(self, url: str, headers: dict = None): self.url = url self.headers = headers or {"Content-Type": "application/json"} + def send_consolidated_security_alerts(self, facts_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Process consolidated socket facts and send security alerts via webhook. + + :param facts_data: Consolidated socket facts data + :return: A dict with response information + """ + log.debug("Webhook Plugin Enabled - Processing consolidated security alerts") + + # Extract repository and branch information + repository = facts_data.get("repository", "unknown-repo") + branch = facts_data.get("branch", "unknown-branch") + + # Get new alerts from the facts data + new_alerts = facts_data.get("new_alerts", []) + if not new_alerts: + log.info("No new security alerts to send via webhook") + return {"status": "success", "message": "No new alerts to process"} + + # Extract alerts from facts components + all_alerts = self._extract_alerts_from_facts(facts_data) + alerts_to_send = new_alerts if new_alerts else all_alerts + + if not alerts_to_send: + log.info("No security alerts found in facts data") + return {"status": "success", "message": "No alerts to process"} + + log.info(f"Sending {len(alerts_to_send)} security alerts via webhook") + + # Create webhook payload + payload = self._create_webhook_payload_from_consolidated_alerts(alerts_to_send, repository, branch) + + try: + response = requests.post( + self.url, + json=payload, + headers=self.headers + ) + + if response.status_code >= 400: + log.error("Webhook error %s: %s", response.status_code, response.text) + return {"status": "error", "message": response.text} + else: + log.info("Successfully sent consolidated security alerts via webhook") + return {"status": "success"} + except Exception as e: + log.error(f"Failed to send consolidated alerts via webhook: {str(e)}") + return {"status": "error", "message": str(e)} + + def _extract_alerts_from_facts(self, facts_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Extract all alerts from the facts data components. + + :param facts_data: Consolidated socket facts data + :return: List of all alerts + """ + all_alerts = [] + + for component in facts_data.get("components", []): + component_alerts = component.get("alerts", []) + for alert in component_alerts: + # Add component context to alert + alert_with_context = alert.copy() + alert_with_context["component_name"] = component.get("name", "unknown") + alert_with_context["component_type"] = component.get("type", "unknown") + alert_with_context["component_purl"] = component.get("purl", "") + alert_with_context["tool"] = component.get("type", "unknown") # Tool is the component type + alert_with_context["source"] = component.get("purl", "") # Source is the PURL + all_alerts.append(alert_with_context) + + return all_alerts + + def _create_webhook_payload_from_consolidated_alerts(self, alerts: List[Dict[str, Any]], repository: str, branch: str) -> Dict[str, Any]: + """ + Create webhook payload from consolidated security alerts. + + :param alerts: List of security alerts + :param repository: Repository name + :param branch: Branch name + :return: Webhook payload + """ + # Format alerts for webhook + formatted_alerts = [] + + for alert in alerts: + # Extract alert information + tool = self._extract_tool_from_purl(alert.get("source", alert.get("component_purl", ""))) or alert.get("tool", alert.get("component_type", "unknown")) + severity = alert.get("severity", "unknown") + source = self._clean_purl_source(alert.get("source", alert.get("component_purl", ""))) + + # Extract rule/test name + props = alert.get("props", {}) + rule_name = ( + props.get("name") or + props.get("test_name") or + props.get("rule_id") or + alert.get("generatedBy", "unknown") + ) + + # Extract file path and line + location = alert.get("location", {}) + file_path = location.get("file", "unknown") + line_number = location.get("start") or location.get("line") + + # Extract description + description = ( + props.get("description") or + props.get("issue_text") or + props.get("details") or + props.get("message") or + "No description available" + ) + + formatted_alert = { + "tool": tool, + "rule_name": rule_name, + "severity": severity, + "file": file_path, + "line": line_number, + "source": source, + "description": description, + "generated_by": alert.get("generatedBy", "unknown"), + "alert_type": alert.get("type", "unknown"), + "component_name": alert.get("component_name", "unknown") + } + + # Include any additional properties + if props: + formatted_alert["properties"] = props + + formatted_alerts.append(formatted_alert) + + return { + "event_type": "security_alerts", + "repository": repository, + "branch": branch, + "total_alerts": len(alerts), + "alerts": formatted_alerts + } + + def _extract_tool_from_purl(self, purl: str) -> str: + """Extract tool type from PURL's type parameter.""" + if not purl or "?type=" not in purl: + return "" + + try: + # Extract the type parameter from the PURL + # Format: pkg:ecosystem/name@version?type=tool-type + type_part = purl.split("?type=")[1] + # Handle multiple parameters by taking only the first one + tool_type = type_part.split("&")[0] + return tool_type + except (IndexError, AttributeError): + return "" + + def _clean_purl_source(self, purl: str) -> str: + """Clean PURL by removing type parameter and empty query string.""" + if not purl: + return "" + + try: + # Remove the type parameter + if "?type=" in purl: + # Split on ?type= and take the first part + base_purl = purl.split("?type=")[0] + + # Check if there are other parameters after type= + type_section = purl.split("?type=")[1] + if "&" in type_section: + # There are other parameters, reconstruct with remaining params + remaining_params = "&".join(type_section.split("&")[1:]) + return f"{base_purl}?{remaining_params}" + else: + # No other parameters, return clean base PURL + return base_purl + else: + # No type parameter, return as is + return purl + except (IndexError, AttributeError): + return purl + def send_events(self, events: list, plugin_name: str) -> dict: """ Will iterate through events and send to webhook diff --git a/src/core/socket_facts_consolidator.py b/src/core/socket_facts_consolidator.py index 6f2b3f5..35ce19a 100644 --- a/src/core/socket_facts_consolidator.py +++ b/src/core/socket_facts_consolidator.py @@ -296,6 +296,9 @@ def consolidate_all_results(self, temp_output_dir: str = ".") -> Dict[str, Any]: repo_info = self._get_git_repository_info() consolidated.update(repo_info) + # Process Socket vulnerabilities and reachability data into alerts + self._process_socket_vulnerabilities(consolidated) + # Get repository and branch for S3 operations repository = consolidated.get("repository", "unknown-repo") branch = consolidated.get("branch", "unknown-branch") @@ -333,14 +336,108 @@ def consolidate_all_results(self, temp_output_dir: str = ".") -> Dict[str, Any]: consolidated["new_alerts_count"] = len(new_alerts) print(f"Found {len(new_alerts)} new alerts since last scan") else: - print("No previous facts found - all alerts are considered new") + # No previous facts - all alerts are considered new + all_alerts = [] + for component in consolidated.get("components", []): + all_alerts.extend(component.get("alerts", [])) + consolidated["new_alerts"] = all_alerts + consolidated["new_alerts_count"] = len(all_alerts) + print(f"No previous facts found - all {len(all_alerts)} alerts are considered new") # Upload current facts to S3 if enabled if self.s3_enabled: self._upload_facts_to_s3(consolidated, repository, branch) return consolidated - + + def _process_socket_vulnerabilities(self, consolidated: Dict[str, Any]): + """Process Socket vulnerabilities and reachability data into alerts format.""" + components_with_vulnerabilities = 0 + total_alerts_added = 0 + + for component in consolidated.get("components", []): + vulnerabilities = component.get("vulnerabilities", []) + reachability_data = component.get("reachability", []) + + if not vulnerabilities: + continue + + components_with_vulnerabilities += 1 + component_alerts = [] + + # Update component for socket-reachability type if it has vulnerabilities + original_purl = component.get("purl", "") + + if not original_purl: + # Create PURL if it doesn't exist + ecosystem = component.get("type", "unknown") # npm, pypi, etc. + name = component.get("name", "unknown") + version = component.get("version", "unknown") + original_purl = f"pkg:{ecosystem}/{name}@{version}" + + if "?type=" not in original_purl: + # Add socket-reachability type to PURL + component["purl"] = f"{original_purl}?type=socket-reachability" + component["type"] = "socket-reachability" + + # Create reachability lookup for faster access + reachability_lookup = {} + for reach in reachability_data: + ghsa_id = reach.get("ghsa_id") + if ghsa_id: + reachability_lookup[ghsa_id] = reach + + # Process each vulnerability + for vuln in vulnerabilities: + ghsa_id = vuln.get("ghsaId") + if not ghsa_id: + continue + + # Get reachability info for this vulnerability + reachability_info = reachability_lookup.get(ghsa_id, {}) + reachability_matches = reachability_info.get("reachability", []) + + # Check if vulnerability is reachable + is_reachable = any( + reach.get("type") == "reachable" + for reach in reachability_matches + ) + + # Create alert for this vulnerability + alert = { + "type": "socket-reachability", + "severity": "high" if is_reachable else "medium", # Reachable vulns are higher severity + "generatedBy": "socket", + "props": { + "name": ghsa_id, + "description": f"Vulnerability {ghsa_id} in {component.get('name', 'unknown')} {component.get('version', 'unknown')}", + "pkgName": component.get("name", "unknown"), + "installedVersion": component.get("version", "unknown"), + "range": vuln.get("range", "unknown"), + "reachable": is_reachable, + "reachabilityData": reachability_info.get("reachabilityData", {}) if is_reachable else None + }, + "location": { + "files": component.get("manifestFiles", []) + } + } + + # Add reachability match details if available + if is_reachable and reachability_matches: + alert["props"]["reachabilityMatches"] = reachability_matches + + component_alerts.append(alert) + total_alerts_added += 1 + + # Add alerts to component + if component_alerts: + component["alerts"] = component.get("alerts", []) + component_alerts + + print(f"DEBUG: Processing {len(consolidated.get('components', []))} components for Socket reachability") + print(f"DEBUG: Found {components_with_vulnerabilities} components with vulnerabilities") + print(f"DEBUG: Found {len([c for c in consolidated.get('components', []) if c.get('reachability')])} components with reachability data") + print(f"DEBUG: Including {components_with_vulnerabilities} components in results") + def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: """Process Bandit SAST results into socket facts format.""" bandit_file = os.path.join(temp_output_dir, "bandit_output.json") @@ -369,10 +466,10 @@ def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "external-sast-python", + "type": "sast-bandit", "name": f"bandit-scan-{filename.replace('/', '-')}", - "version": "1.0.0", - "purl": f"pkg:external-sast/bandit@1.0.0", + "version": "1.0.25", + "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=sast-bandit", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], @@ -381,7 +478,7 @@ def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: # Create alert for this issue alert = { - "type": "external-sast-python", + "type": "sast-bandit", "severity": self._map_bandit_severity(issue.get("issue_severity", "UNKNOWN")), "generatedBy": "bandit", "props": { @@ -433,10 +530,10 @@ def _process_gosec_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "external-sast-golang", + "type": "sast-gosec", "name": f"gosec-scan-{filename.replace('/', '-')}", - "version": "1.0.0", - "purl": f"pkg:external-sast/gosec@1.0.0", + "version": "1.0.25", + "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=sast-gosec", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], @@ -445,7 +542,7 @@ def _process_gosec_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: # Create alert for this issue alert = { - "type": "external-sast-golang", + "type": "sast-gosec", "severity": self._map_gosec_severity(issue.get("severity", "UNKNOWN")), "generatedBy": "gosec", "props": { @@ -499,10 +596,10 @@ def _process_eslint_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "external-sast-javascript", + "type": "sast-eslint", "name": f"eslint-scan-{filename.replace('/', '-')}", - "version": "1.0.0", - "purl": f"pkg:external-sast/eslint@1.0.0", + "version": "1.0.25", + "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=sast-eslint", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], @@ -515,7 +612,7 @@ def _process_eslint_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: continue alert = { - "type": "external-sast-javascript", + "type": "sast-eslint", "severity": "medium", # ESLint errors are typically medium severity "generatedBy": "eslint", "props": { @@ -541,6 +638,7 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An """Process Trufflehog secret scanning results into socket facts format.""" trufflehog_file = os.path.join(temp_output_dir, "trufflehog_output.json") if not os.path.exists(trufflehog_file): + print("DEBUG: Trufflehog output file not found") return [] try: @@ -554,7 +652,9 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An secrets.append(json.loads(line)) except json.JSONDecodeError: continue + print(f"DEBUG: Loaded {len(secrets)} secrets from Trufflehog") except FileNotFoundError: + print("DEBUG: Trufflehog file not found during processing") return [] if not secrets: @@ -567,18 +667,25 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An source_metadata = secret.get("SourceMetadata", {}) data = source_metadata.get("Data", {}) filename = data.get("Filesystem", {}).get("file", "unknown") + line_number = data.get("Filesystem", {}).get("line", 1) # Normalize filename relative to workspace - if filename.startswith("./"): + if filename.startswith("/workspace/"): + filename = filename[11:] # Remove /workspace/ prefix + elif filename.startswith("./"): filename = filename[2:] + # Skip if filename is still absolute or unknown + if filename.startswith("/") or filename == "unknown": + continue + if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "external-secrets", + "type": "secrets-trufflehog", "name": f"trufflehog-scan-{filename.replace('/', '-')}", - "version": "1.0.0", - "purl": f"pkg:external-secrets/trufflehog@1.0.0", + "version": "1.0.25", + "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=secrets-trufflehog", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], @@ -587,26 +694,29 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An # Create alert for this secret alert = { - "type": "external-secrets", + "type": "secrets-trufflehog", "severity": "high", # Secrets are typically high severity "generatedBy": "trufflehog", "props": { "name": secret.get("DetectorName", "unknown"), - "description": f"Secret detected: {secret.get('DetectorName', 'unknown')}", + "description": f"Secret detected: {secret.get('DetectorName', 'unknown')} in {filename}", "verified": secret.get("Verified", False), - "raw": secret.get("Raw", "")[:100] + "..." if len(secret.get("Raw", "")) > 100 else secret.get("Raw", "") + "detector_type": secret.get("DetectorType", ""), + "source_name": secret.get("SourceName", ""), + "raw_preview": secret.get("Raw", "")[:50] + "..." if len(secret.get("Raw", "")) > 50 else secret.get("Raw", "") }, "location": { "file": filename, - "start": data.get("Filesystem", {}).get("line", 1), - "end": data.get("Filesystem", {}).get("line", 1) + "start": line_number, + "end": line_number } } file_components[filename]["alerts"].append(alert) + print(f"DEBUG: Processed {len(secrets)} Trufflehog secrets into {len(file_components)} components") return list(file_components.values()) - + def _process_trivy_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: """Process Trivy container and dockerfile scanning results into socket facts format.""" components = [] @@ -614,11 +724,11 @@ def _process_trivy_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: # Process Trivy image scan results import glob for trivy_file in glob.glob(os.path.join(temp_output_dir, "trivy_image_*.json")): - components.extend(self._process_single_trivy_file(trivy_file, "external-container-image")) + components.extend(self._process_single_trivy_file(trivy_file, "container-trivy")) # Process Trivy dockerfile scan results for trivy_file in glob.glob(os.path.join(temp_output_dir, "trivy_dockerfile_*.json")): - components.extend(self._process_single_trivy_file(trivy_file, "external-container-dockerfile")) + components.extend(self._process_single_trivy_file(trivy_file, "dockerfile-trivy")) return components @@ -649,8 +759,8 @@ def _process_single_trivy_file(self, trivy_file: str, scan_type: str) -> List[Di "id": str(uuid.uuid4()), "type": scan_type, "name": f"trivy-scan-{target.replace('/', '-').replace(':', '-')}", - "version": "1.0.0", - "purl": f"pkg:{scan_type}/trivy@1.0.0", + "version": "1.0.25", + "purl": f"pkg:private/{target.replace('/', '-').replace(':', '-')}@1.0.25?type={scan_type}", "direct": True, "dev": False, "manifestFiles": [{"file": target, "start": 1, "end": 1}], @@ -720,15 +830,15 @@ def _process_socket_sca_results(self, temp_output_dir: str) -> List[Dict[str, An if data.get("scan_failed", False): return [{ "id": str(uuid.uuid4()), - "type": "external-socket-sca", + "type": "sca-socket", "name": "socket-sca-scan-failed", - "version": "1.0.0", - "purl": "pkg:external-socket-sca/socket-sca@1.0.0", + "version": "1.0.25", + "purl": "pkg:private/socket-sca-scan@1.0.25?type=sca-socket", "direct": True, "dev": False, "manifestFiles": [{"file": ".", "start": 1, "end": 1}], "alerts": [{ - "type": "external-socket-sca", + "type": "sca-socket", "severity": "critical", "generatedBy": "socket-sca", "props": { @@ -749,14 +859,22 @@ def _process_socket_sca_results(self, temp_output_dir: str) -> List[Dict[str, An for alert in new_alerts: package_name = alert.get("package", "unknown") + ecosystem = alert.get("ecosystem", "unknown") # e.g., npm, pypi, etc. + version = alert.get("version", "unknown") if package_name not in package_components: + # Create proper PURL for real packages with socket-sca type + if ecosystem != "unknown" and package_name != "unknown": + purl = f"pkg:{ecosystem}/{package_name}@{version}?type=sca-socket" + else: + purl = f"pkg:private/socket-sca-{package_name}@1.0.25?type=sca-socket" + package_components[package_name] = { "id": str(uuid.uuid4()), - "type": "external-socket-sca", + "type": "sca-socket", "name": f"socket-sca-{package_name}", - "version": alert.get("version", "unknown"), - "purl": f"pkg:external-socket-sca/{package_name}@{alert.get('version', 'unknown')}", + "version": version, + "purl": purl, "direct": True, "dev": False, "manifestFiles": [{"file": alert.get("file", "unknown"), "start": 1, "end": 1}], @@ -764,7 +882,7 @@ def _process_socket_sca_results(self, temp_output_dir: str) -> List[Dict[str, An } socket_alert = { - "type": "external-socket-sca", + "type": "sca-socket", "severity": self._map_socket_sca_severity(alert.get("severity", "unknown")), "generatedBy": "socket-sca", "props": { diff --git a/src/socket_external_tools_runner.py b/src/socket_external_tools_runner.py index f9001cb..3119c68 100644 --- a/src/socket_external_tools_runner.py +++ b/src/socket_external_tools_runner.py @@ -1,7 +1,6 @@ import json import logging import os -import glob import inspect from version import __version__ from core import marker @@ -13,6 +12,7 @@ from core.connectors.socket import Socket from core.connectors.socket_sca import SocketSCA from core.socket_facts_processor import SocketFactsProcessor +from core.socket_facts_consolidator import SocketFactsConsolidator from core.load_plugins import ( load_sumo_logic_plugin, load_ms_sentinel_plugin, @@ -63,29 +63,26 @@ def print_tool_events_summary(tool_events): print(tabulate(summary, headers="keys", tablefmt="fancy_grid")) -def load_json(filename, connector: str) -> dict: - """Loads JSON or NDJSON files, handling Trufflehog's NDJSON format.""" - try: - with open(filename, 'r') as file: - if connector.lower() == "trufflehog": - return {"Issues": [json.loads(line) for line in file]} - else: - return json.load(file) - except json.JSONDecodeError: - print(f"No results found for {connector}") - return {} - except FileNotFoundError: - print(f"No results found for {connector}") - return {} +def print_tool_events_summary(tool_events): + """ + Prints a summary of tool event results in a tabular format. + """ + output_file_name = os.getenv("OUTPUT_FILE_NAME", "security_tools_summary.json") + summary = [] + if not tool_events: + print("\nNo issues were detected by any tools.") + return + + for tool_name, events in tool_events.items(): + summary.append({ + "Tool": tool_name.capitalize(), + "Issues Detected": len(events.get("events", [])), + "Details": f"See {tool_name}_output.json" # Reference output file + }) + + print("\nSecurity Tools Summary:\n") + print(tabulate(summary, headers="keys", tablefmt="fancy_grid")) -def consolidate_trivy_results(pattern: str) -> dict: - """Consolidates multiple Trivy result JSONs into a single structure.""" - consolidated_results = {"Results": []} - for filename in glob.glob(pattern): - data = load_json(filename, "Trivy") - if "Results" in data: - consolidated_results["Results"].extend(data["Results"]) - return consolidated_results sumo_client = load_sumo_logic_plugin() ms_sentinel = load_ms_sentinel_plugin() @@ -127,20 +124,27 @@ def main(): # Get the output directory for temp files temp_output_dir = os.getenv("TEMP_OUTPUT_DIR", ".") - def get_output_file_path(filename): - """Get the full path to an output file based on TEMP_OUTPUT_DIR""" - return os.path.join(temp_output_dir, filename) - # Check if we have a consolidated .socket.facts.json file socket_facts_path = ".socket.facts.json" if os.path.exists(socket_facts_path): print("Using consolidated .socket.facts.json format") - # Initialize facts processor + # Initialize facts processor for processing alerts facts_processor = SocketFactsProcessor() facts_processor.default_severities = SEVERITIES + + # Load the facts data (consolidator already handled S3 download/upload and new alert detection) facts_data = facts_processor.load_socket_facts(socket_facts_path) + # Ensure new_alerts field exists (fallback if consolidator didn't set it) + if "new_alerts" not in facts_data: + all_alerts = [] + for component in facts_data.get("components", []): + all_alerts.extend(component.get("alerts", [])) + facts_data["new_alerts"] = all_alerts + facts_data["new_alerts_count"] = len(all_alerts) + print(f"DEBUG: new_alerts not found in facts data, treating all {len(all_alerts)} alerts as new") + # Process results from consolidated facts results = {} @@ -189,12 +193,17 @@ def get_output_file_path(filename): results["socket_sca"] = socket_sca_metrics # Process consolidated facts for Jira integration + print(f"DEBUG: About to check Jira client: {jira_client is not None}") if jira_client: - # Check if there are any security alerts in the facts data - total_alerts = sum(len(component.get("alerts", [])) for component in facts_data.get("components", [])) + # Check if there are any new security alerts in the facts data + new_alerts = facts_data.get("new_alerts", []) + total_alerts = len(new_alerts) + print(f"DEBUG: New alerts found: {total_alerts}") if total_alerts > 0: - print("Processing consolidated security alerts for Jira integration.") + print("Processing new security alerts for Jira integration.") + print("DEBUG: Calling jira_client.send_consolidated_security_alerts()") jira_result = jira_client.send_consolidated_security_alerts(facts_data) + print(f"DEBUG: Jira result: {jira_result}") if jira_result.get("status") == "error": print(f"Jira error: {jira_result.get('message', 'Unknown error')}") elif jira_result.get("status") == "success": @@ -204,118 +213,67 @@ def get_output_file_path(filename): print(f"Added new alerts to existing Jira ticket: {jira_result.get('issue_key')}") else: print(f"Jira ticket up to date: {jira_result.get('issue_key', 'No new alerts')}") + else: + print("DEBUG: No new alerts found, skipping Jira integration") + else: + print("DEBUG: Jira client is None, skipping Jira integration") - else: - print("Using legacy individual tool output format") - # Fallback to legacy processing if no consolidated facts file - results = {} - if "bandit" in TOOL_CLASSES: - bandit_data = load_json(get_output_file_path("bandit_output.json"), "Bandit") - if bandit_data: - results["bandit"] = bandit_data - if "gosec" in TOOL_CLASSES: - gosec_data = load_json(get_output_file_path("gosec_output.json"), "Gosec") - if gosec_data: - results["gosec"] = gosec_data - if "trufflehog" in TOOL_CLASSES: - trufflehog_data = load_json(get_output_file_path("trufflehog_output.json"), "Trufflehog") - if trufflehog_data: - results["trufflehog"] = trufflehog_data - if "trivy_image" in TOOL_CLASSES: - trivy_image_data = consolidate_trivy_results(get_output_file_path("trivy_image_*.json")) - if trivy_image_data and trivy_image_data.get("Results"): - results["trivy_image"] = trivy_image_data - if "trivy_dockerfile" in TOOL_CLASSES: - trivy_dockerfile_data = consolidate_trivy_results(get_output_file_path("trivy_dockerfile_*.json")) - if trivy_dockerfile_data and trivy_dockerfile_data.get("Results"): - results["trivy_dockerfile"] = trivy_dockerfile_data - if "eslint" in TOOL_CLASSES: - eslint_data = load_json(get_output_file_path("eslint_output.json"), "ESLint") - if eslint_data: - results["eslint"] = eslint_data - if "socket" in TOOL_CLASSES: - socket_data = load_json(".socket.facts.json", "Socket") - if socket_data: - results["socket"] = socket_data - if "socket_sca" in TOOL_CLASSES: - socket_sca_data = load_json(get_output_file_path("socket_sca_output.json"), "SocketSCA") - if socket_sca_data: - results["socket_sca"] = socket_sca_data - - if any(results.values()): - if not SCM_DISABLED: - scm = SCM() # type: ignore - tool_outputs = {} - tool_events = {} - for key, data in results.items(): - if data: - tool_marker = marker.replace("REPLACE_ME", TOOL_NAMES[key]) - tool_class = TOOL_CLASSES[key] - tool_class.default_severities = SEVERITIES - - # Handle consolidated facts vs legacy data differently - if key == "socket" and "components" in data: - # For socket dependency data, use the original create_output method - supports_show_unverified = "show_unverified" in inspect.signature(tool_class.process_output).parameters - if supports_show_unverified: - show_unverified = os.getenv("INPUT_TRUFFLEHOG_SHOW_UNVERIFIED", "false").lower() == "true" - tool_outputs[key], tool_results = tool_class.create_output( - data, - tool_marker, - scm.github.repo, - scm.github.commit, - scm.github.cwd, - show_unverified=show_unverified - ) - else: - tool_outputs[key], tool_results = tool_class.create_output( - data, tool_marker, scm.github.repo, scm.github.commit, scm.github.cwd - ) - elif isinstance(data, dict) and "output" in data: - # For consolidated security tool data, create output from processed alerts - tool_outputs[key] = { - "events": data.get("output", []), - "output": [str(alert) for alert in data.get("output", [])] - } - tool_results = "\n".join(tool_outputs[key]["output"]) - else: - # Legacy processing for individual tool outputs - supports_show_unverified = "show_unverified" in inspect.signature(tool_class.process_output).parameters - if supports_show_unverified: - show_unverified = os.getenv("INPUT_TRUFFLEHOG_SHOW_UNVERIFIED", "false").lower() == "true" - tool_outputs[key], tool_results = tool_class.create_output( - data, - tool_marker, - scm.github.repo, - scm.github.commit, - scm.github.cwd, - show_unverified=show_unverified - ) - else: - tool_outputs[key], tool_results = tool_class.create_output( - data, tool_marker, scm.github.repo, scm.github.commit, scm.github.cwd - ) - - tool_events[key] = tool_outputs[key].get("events", []) - if tool_events[key]: - scm.github.post_comment(TOOL_NAMES[key], tool_marker, tool_results) - print("Issues detected with Security Tools. Please check PR comments") + # Process consolidated facts for Slack integration + print(f"DEBUG: About to check Slack client: {slack_client is not None}") + if slack_client: + new_alerts = facts_data.get("new_alerts", []) + total_alerts = len(new_alerts) + if total_alerts > 0: + print("Processing new security alerts for Slack integration.") + slack_result = slack_client.send_consolidated_security_alerts(facts_data) + print(f"DEBUG: Slack result: {slack_result}") + if slack_result.get("status") == "error": + print(f"Slack error: {slack_result.get('message', 'Unknown error')}") + elif slack_result.get("status") == "success": + print("Successfully sent alerts to Slack") + else: + print("DEBUG: No new alerts found, skipping Slack integration") else: - tool_events = {} - cwd = GIT_DIR if GIT_DIR else os.getcwd() - for key, data in results.items(): - if key not in TOOL_CLASSES or not data: - continue - TOOL_CLASSES[key].default_severities = SEVERITIES - - # Handle consolidated facts vs legacy data differently - if isinstance(data, dict) and "output" in data: - # For consolidated security tool data, we already have processed events - tool_events[key] = {"events": data.get("output", [])} - else: - # Legacy processing for individual tool outputs - tool_events[key] = TOOL_CLASSES[key].process_output(data, cwd, TOOL_NAMES[key]) - + print("DEBUG: Slack client is None, skipping Slack integration") + + # Process consolidated facts for Teams integration + print(f"DEBUG: About to check Teams client: {teams_client is not None}") + if teams_client: + new_alerts = facts_data.get("new_alerts", []) + total_alerts = len(new_alerts) + if total_alerts > 0: + print("Processing new security alerts for Teams integration.") + teams_result = teams_client.send_consolidated_security_alerts(facts_data) + print(f"DEBUG: Teams result: {teams_result}") + if teams_result.get("status") == "error": + print(f"Teams error: {teams_result.get('message', 'Unknown error')}") + elif teams_result.get("status") == "success": + print("Successfully sent alerts to Teams") + else: + print("DEBUG: No new alerts found, skipping Teams integration") + else: + print("DEBUG: Teams client is None, skipping Teams integration") + + # Process consolidated facts for Webhook integration + print(f"DEBUG: About to check Webhook client: {webhook_client is not None}") + if webhook_client: + new_alerts = facts_data.get("new_alerts", []) + total_alerts = len(new_alerts) + if total_alerts > 0: + print("Processing new security alerts for Webhook integration.") + webhook_result = webhook_client.send_consolidated_security_alerts(facts_data) + print(f"DEBUG: Webhook result: {webhook_result}") + if webhook_result.get("status") == "error": + print(f"Webhook error: {webhook_result.get('message', 'Unknown error')}") + elif webhook_result.get("status") == "success": + print("Successfully sent alerts via Webhook") + else: + print("DEBUG: No new alerts found, skipping Webhook integration") + else: + print("DEBUG: Webhook client is None, skipping Webhook integration") + + # Note: S3 upload was already handled by the consolidator in entrypoint.sh + # Check for scan failures that should force an exit regardless of other conditions scan_failed = False for key, data in results.items(): @@ -323,68 +281,21 @@ def get_output_file_path(filename): print(f"{TOOL_NAMES.get(key, key)} scan failed") scan_failed = True - if len(tool_events) > 0 or scan_failed: - # Only show integration messages if there is at least one event - total_events = sum(len(events.get("events", [])) for events in tool_events.values()) - if total_events > 0: - if sumo_client: - print("Issues detected with Security Tools. Please check Sumologic Events") - if ms_sentinel: - print("Issues detected with Security Tools. Please check Microsoft Sentinel Events") - if console_output: - print("Issues detected with Security Tools.") - if jira_client: - print("Issues detected with Security Tools. Creating Jira tickets.") - if slack_client: - print("Issues detected with Security Tools. Sending Slack notifications.") - if teams_client: - print("Issues detected with Security Tools. Sending Teams notifications.") - if webhook_client: - print("Issues detected with Security Tools. Sending webhook notifications.") - - for key, events in tool_events.items(): - tool_name = f"SocketSecurityTools-{TOOL_NAMES[key]}" - formatted_events = [json.dumps(event, default=lambda o: o.to_json()) for event in - events.get("events", [])] - event_objects = events.get("events", []) - - if sumo_client: - print(errors) if (errors := sumo_client.send_events(formatted_events, tool_name)) else [] - - if ms_sentinel: - print(errors) if (errors := ms_sentinel.send_events(formatted_events, tool_name)) else [] - - if console_output: - print(errors) if (errors := console_output.print_events(events.get("output", []), key)) else [] - - # New plugins that work with event objects - if jira_client: - # Only use legacy processing if we don't have consolidated facts - if not os.path.exists(".socket.facts.json"): - result = jira_client.send_events(event_objects, tool_name) - if result.get("status") == "error": - print(f"Jira error: {result.get('message', 'Unknown error')}") - - if slack_client: - result = slack_client.send_events(event_objects, tool_name) - if result.get("status") == "error": - print(f"Slack error: {result.get('message', 'Unknown error')}") - - if teams_client: - result = teams_client.send_events(event_objects, tool_name) - if result.get("status") == "error": - print(f"Teams error: {result.get('message', 'Unknown error')}") - - if webhook_client: - result = webhook_client.send_events(event_objects, tool_name) - if result.get("status") == "error": - print(f"Webhook error: {result.get('message', 'Unknown error')}") - if scan_failed: print("Security scan failed - exiting with error") - exit(1) + exit(1) + + # Check if there are any new security alerts to report + new_alerts_count = facts_data.get("new_alerts_count", 0) + if new_alerts_count > 0: + print(f"Security issues detected - {new_alerts_count} new alerts found - check consolidated integrations (Jira, Slack, Teams, Webhook)") + exit(1) + else: + print("No new security issues detected with Socket Security Tools") + else: - print("No issues detected with Socket Security Tools") + print("No consolidated .socket.facts.json file found - exiting") + return if __name__ == "__main__": main() diff --git a/src/version.py b/src/version.py index 0018828..9b719b6 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.24" +__version__ = "1.0.25" From 3df030910732250b10f4a3e5d572406bf65a7e33 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Mon, 25 Aug 2025 18:30:29 -0700 Subject: [PATCH 10/11] refactor: implement Socket Facts schema constraints for component types - Socket Reachability/SCA components now use ecosystem types (npm, pypi, etc.) - SAST, secrets, and container scanning use generic component type - Group alerts by file using qualifiers for shared context - Move code blocks into alert properties instead of separate fields - Multiple alerts per file consolidated into single components - Preserve Socket vulnerability data with ecosystem-specific typing This aligns the consolidated facts format with the injectable alert artifact schema constraints while maintaining backwards compatibility for Socket tooling that expects ecosystem-typed components. --- pyproject.toml | 2 +- src/core/socket_facts_consolidator.py | 221 ++++++++++++++++++-------- src/version.py | 2 +- 3 files changed, 155 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5bf48d3..07f53b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.25" +version = "1.0.26" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ diff --git a/src/core/socket_facts_consolidator.py b/src/core/socket_facts_consolidator.py index 35ce19a..84a4110 100644 --- a/src/core/socket_facts_consolidator.py +++ b/src/core/socket_facts_consolidator.py @@ -365,20 +365,15 @@ def _process_socket_vulnerabilities(self, consolidated: Dict[str, Any]): components_with_vulnerabilities += 1 component_alerts = [] - # Update component for socket-reachability type if it has vulnerabilities - original_purl = component.get("purl", "") + # Keep the original ecosystem type (npm, pypi, etc.) for Socket components + # per the constraint that Socket Reachability and SCA should use ecosystem types + original_type = component.get("type", "unknown") - if not original_purl: - # Create PURL if it doesn't exist - ecosystem = component.get("type", "unknown") # npm, pypi, etc. - name = component.get("name", "unknown") - version = component.get("version", "unknown") - original_purl = f"pkg:{ecosystem}/{name}@{version}" - - if "?type=" not in original_purl: - # Add socket-reachability type to PURL - component["purl"] = f"{original_purl}?type=socket-reachability" - component["type"] = "socket-reachability" + # Add qualifiers to indicate this is Socket-processed data + if "qualifiers" not in component: + component["qualifiers"] = {} + component["qualifiers"]["socket_processed"] = True + component["qualifiers"]["has_vulnerability_data"] = True # Create reachability lookup for faster access reachability_lookup = {} @@ -403,9 +398,9 @@ def _process_socket_vulnerabilities(self, consolidated: Dict[str, Any]): for reach in reachability_matches ) - # Create alert for this vulnerability + # Create alert for this vulnerability using the original ecosystem type alert = { - "type": "socket-reachability", + "type": original_type, # Keep ecosystem type (npm, pypi, etc.) "severity": "high" if is_reachable else "medium", # Reachable vulns are higher severity "generatedBy": "socket", "props": { @@ -415,7 +410,13 @@ def _process_socket_vulnerabilities(self, consolidated: Dict[str, Any]): "installedVersion": component.get("version", "unknown"), "range": vuln.get("range", "unknown"), "reachable": is_reachable, - "reachabilityData": reachability_info.get("reachabilityData", {}) if is_reachable else None + "vulnerability_details": { + "ghsa_id": ghsa_id, + "range": vuln.get("range", "unknown"), + "reachability_pattern": vuln.get("reachabilityData", {}).get("pattern", []), + "undeterminable_reachability": vuln.get("reachabilityData", {}).get("undeterminableReachability", False) + }, + "reachabilityData": vuln.get("reachabilityData", {}) if is_reachable else None }, "location": { "files": component.get("manifestFiles", []) @@ -454,7 +455,7 @@ def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if not results: return [] - # Group by file to create components + # Group by file to create components with file as qualifier file_components = {} for issue in results: @@ -466,19 +467,24 @@ def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "sast-bandit", - "name": f"bandit-scan-{filename.replace('/', '-')}", - "version": "1.0.25", - "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=sast-bandit", + "type": "generic", # Generic type for SAST + "name": f"sast-bandit-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:generic/sast-bandit@1.0.0", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "qualifiers": { + "file": filename, + "tool": "bandit", + "scan_type": "sast" + }, "alerts": [] } - # Create alert for this issue + # Create alert for this issue with code block in properties alert = { - "type": "sast-bandit", + "type": "generic", "severity": self._map_bandit_severity(issue.get("issue_severity", "UNKNOWN")), "generatedBy": "bandit", "props": { @@ -487,7 +493,14 @@ def _process_bandit_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: "test_id": issue.get("test_id", ""), "confidence": issue.get("issue_confidence", ""), "cwe": issue.get("issue_cwe", {}), - "more_info": issue.get("more_info", "") + "more_info": issue.get("more_info", ""), + "code_block": { + "start_line": issue.get("line_number", 1), + "end_line": issue.get("line_number", 1), + "column_start": issue.get("col_offset", 0), + "column_end": issue.get("end_col_offset", 0), + "code": issue.get("code", "") + } }, "location": { "file": filename, @@ -518,7 +531,7 @@ def _process_gosec_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if not issues: return [] - # Group by file to create components + # Group by file to create components with file as qualifier file_components = {} for issue in issues: @@ -530,19 +543,24 @@ def _process_gosec_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "sast-gosec", - "name": f"gosec-scan-{filename.replace('/', '-')}", - "version": "1.0.25", - "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=sast-gosec", + "type": "generic", # Generic type for SAST + "name": f"sast-gosec-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:generic/sast-gosec@1.0.0", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "qualifiers": { + "file": filename, + "tool": "gosec", + "scan_type": "sast" + }, "alerts": [] } - # Create alert for this issue + # Create alert for this issue with code block in properties alert = { - "type": "sast-gosec", + "type": "generic", "severity": self._map_gosec_severity(issue.get("severity", "UNKNOWN")), "generatedBy": "gosec", "props": { @@ -550,7 +568,14 @@ def _process_gosec_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: "description": issue.get("details", ""), "confidence": issue.get("confidence", ""), "cwe": issue.get("cwe", {}), - "nosec": issue.get("nosec", False) + "nosec": issue.get("nosec", False), + "code_block": { + "start_line": int(issue.get("line", 1)), + "end_line": int(issue.get("line", 1)), + "column_start": int(issue.get("column", 0)), + "column_end": int(issue.get("column", 0)), + "code": issue.get("code", "") + } }, "location": { "file": filename, @@ -580,7 +605,7 @@ def _process_eslint_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if not isinstance(data, list): return [] - # Group by file to create components + # Group by file to create components with file as qualifier file_components = {} for file_result in data: @@ -596,13 +621,18 @@ def _process_eslint_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "sast-eslint", - "name": f"eslint-scan-{filename.replace('/', '-')}", - "version": "1.0.25", - "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=sast-eslint", + "type": "generic", # Generic type for SAST + "name": f"sast-eslint-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:generic/sast-eslint@1.0.0", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "qualifiers": { + "file": filename, + "tool": "eslint", + "scan_type": "sast" + }, "alerts": [] } @@ -612,14 +642,21 @@ def _process_eslint_results(self, temp_output_dir: str) -> List[Dict[str, Any]]: continue alert = { - "type": "sast-eslint", + "type": "generic", "severity": "medium", # ESLint errors are typically medium severity "generatedBy": "eslint", "props": { "name": message.get("ruleId", "unknown"), "description": message.get("message", ""), "nodeType": message.get("nodeType", ""), - "source": message.get("source", "") + "source": message.get("source", ""), + "code_block": { + "start_line": message.get("line", 1), + "end_line": message.get("endLine", message.get("line", 1)), + "column_start": message.get("column", 0), + "column_end": message.get("endColumn", message.get("column", 0)), + "code": message.get("source", "") + } }, "location": { "file": filename, @@ -660,7 +697,7 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An if not secrets: return [] - # Group by file to create components + # Group by file to create components with file as qualifier file_components = {} for secret in secrets: @@ -682,19 +719,24 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An if filename not in file_components: file_components[filename] = { "id": str(uuid.uuid4()), - "type": "secrets-trufflehog", - "name": f"trufflehog-scan-{filename.replace('/', '-')}", - "version": "1.0.25", - "purl": f"pkg:private/{filename.replace('/', '-')}@1.0.25?type=secrets-trufflehog", + "type": "generic", # Generic type for secrets + "name": f"secrets-trufflehog-{filename.replace('/', '-')}", + "version": "1.0.0", + "purl": f"pkg:generic/secrets-trufflehog@1.0.0", "direct": True, "dev": False, "manifestFiles": [{"file": filename, "start": 1, "end": 1}], + "qualifiers": { + "file": filename, + "tool": "trufflehog", + "scan_type": "secrets" + }, "alerts": [] } # Create alert for this secret alert = { - "type": "secrets-trufflehog", + "type": "generic", "severity": "high", # Secrets are typically high severity "generatedBy": "trufflehog", "props": { @@ -703,7 +745,12 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An "verified": secret.get("Verified", False), "detector_type": secret.get("DetectorType", ""), "source_name": secret.get("SourceName", ""), - "raw_preview": secret.get("Raw", "")[:50] + "..." if len(secret.get("Raw", "")) > 50 else secret.get("Raw", "") + "raw_preview": secret.get("Raw", "")[:50] + "..." if len(secret.get("Raw", "")) > 50 else secret.get("Raw", ""), + "code_block": { + "start_line": line_number, + "end_line": line_number, + "code": secret.get("Raw", "")[:100] + "..." if len(secret.get("Raw", "")) > 100 else secret.get("Raw", "") + } }, "location": { "file": filename, @@ -757,20 +804,25 @@ def _process_single_trivy_file(self, trivy_file: str, scan_type: str) -> List[Di component = { "id": str(uuid.uuid4()), - "type": scan_type, + "type": "generic", # Generic type for container scanning "name": f"trivy-scan-{target.replace('/', '-').replace(':', '-')}", - "version": "1.0.25", - "purl": f"pkg:private/{target.replace('/', '-').replace(':', '-')}@1.0.25?type={scan_type}", + "version": "1.0.0", + "purl": f"pkg:generic/trivy@1.0.0", "direct": True, "dev": False, "manifestFiles": [{"file": target, "start": 1, "end": 1}], + "qualifiers": { + "target": target, + "tool": "trivy", + "scan_type": scan_type.replace("-trivy", "") # container or dockerfile + }, "alerts": [] } # Process vulnerabilities for vuln in vulnerabilities: alert = { - "type": scan_type, + "type": "generic", "severity": self._map_trivy_severity(vuln.get("Severity", "UNKNOWN")), "generatedBy": "trivy", "props": { @@ -779,7 +831,13 @@ def _process_single_trivy_file(self, trivy_file: str, scan_type: str) -> List[Di "pkgName": vuln.get("PkgName", ""), "installedVersion": vuln.get("InstalledVersion", ""), "fixedVersion": vuln.get("FixedVersion", ""), - "references": vuln.get("References", []) + "references": vuln.get("References", []), + "vulnerability_details": { + "package": vuln.get("PkgName", ""), + "installed_version": vuln.get("InstalledVersion", ""), + "fixed_version": vuln.get("FixedVersion", ""), + "vulnerability_id": vuln.get("VulnerabilityID", "") + } }, "location": { "file": target @@ -790,7 +848,7 @@ def _process_single_trivy_file(self, trivy_file: str, scan_type: str) -> List[Di # Process misconfigurations for misconf in misconfigurations: alert = { - "type": scan_type, + "type": "generic", "severity": self._map_trivy_severity(misconf.get("Severity", "UNKNOWN")), "generatedBy": "trivy", "props": { @@ -799,7 +857,12 @@ def _process_single_trivy_file(self, trivy_file: str, scan_type: str) -> List[Di "title": misconf.get("Title", ""), "message": misconf.get("Message", ""), "resolution": misconf.get("Resolution", ""), - "references": misconf.get("References", []) + "references": misconf.get("References", []), + "code_block": { + "start_line": misconf.get("CauseMetadata", {}).get("StartLine", 1), + "end_line": misconf.get("CauseMetadata", {}).get("EndLine", 1), + "code": misconf.get("CauseMetadata", {}).get("Code", "") + } }, "location": { "file": target, @@ -830,15 +893,20 @@ def _process_socket_sca_results(self, temp_output_dir: str) -> List[Dict[str, An if data.get("scan_failed", False): return [{ "id": str(uuid.uuid4()), - "type": "sca-socket", + "type": "generic", # Generic type for scan failures "name": "socket-sca-scan-failed", - "version": "1.0.25", - "purl": "pkg:private/socket-sca-scan@1.0.25?type=sca-socket", + "version": "1.0.0", + "purl": "pkg:generic/socket-sca-scan@1.0.0", "direct": True, "dev": False, "manifestFiles": [{"file": ".", "start": 1, "end": 1}], + "qualifiers": { + "tool": "socket-sca", + "scan_type": "sca", + "status": "failed" + }, "alerts": [{ - "type": "sca-socket", + "type": "generic", "severity": "critical", "generatedBy": "socket-sca", "props": { @@ -854,7 +922,7 @@ def _process_socket_sca_results(self, temp_output_dir: str) -> List[Dict[str, An if not new_alerts: return [] - # Group alerts by package/file + # Group alerts by package/ecosystem package_components = {} for alert in new_alerts: @@ -862,41 +930,58 @@ def _process_socket_sca_results(self, temp_output_dir: str) -> List[Dict[str, An ecosystem = alert.get("ecosystem", "unknown") # e.g., npm, pypi, etc. version = alert.get("version", "unknown") - if package_name not in package_components: - # Create proper PURL for real packages with socket-sca type + # Use package name + ecosystem as the key to properly group + component_key = f"{ecosystem}:{package_name}" + + if component_key not in package_components: + # For Socket SCA, use the actual ecosystem type (npm, pypi, etc.) + # instead of generic, following the constraint that Socket types should be ecosystem types + component_type = ecosystem if ecosystem != "unknown" else "generic" + + # Create proper PURL for real packages if ecosystem != "unknown" and package_name != "unknown": - purl = f"pkg:{ecosystem}/{package_name}@{version}?type=sca-socket" + purl = f"pkg:{ecosystem}/{package_name}@{version}" else: - purl = f"pkg:private/socket-sca-{package_name}@1.0.25?type=sca-socket" + purl = f"pkg:generic/socket-sca-{package_name}@1.0.0" - package_components[package_name] = { + package_components[component_key] = { "id": str(uuid.uuid4()), - "type": "sca-socket", - "name": f"socket-sca-{package_name}", + "type": component_type, # Use ecosystem type for Socket SCA/Reachability + "name": package_name, "version": version, "purl": purl, "direct": True, "dev": False, "manifestFiles": [{"file": alert.get("file", "unknown"), "start": 1, "end": 1}], + "qualifiers": { + "tool": "socket-sca", + "scan_type": "sca", + "ecosystem": ecosystem + }, "alerts": [] } socket_alert = { - "type": "sca-socket", + "type": component_type if ecosystem != "unknown" else "generic", "severity": self._map_socket_sca_severity(alert.get("severity", "unknown")), "generatedBy": "socket-sca", "props": { "name": alert.get("type", "unknown"), "description": alert.get("description", ""), "category": alert.get("category", ""), - "subcategory": alert.get("subcategory", "") + "subcategory": alert.get("subcategory", ""), + "package_details": { + "package": package_name, + "ecosystem": ecosystem, + "version": version + } }, "location": { "file": alert.get("file", "unknown") } } - package_components[package_name]["alerts"].append(socket_alert) + package_components[component_key]["alerts"].append(socket_alert) return list(package_components.values()) diff --git a/src/version.py b/src/version.py index 9b719b6..8c79b2e 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.25" +__version__ = "1.0.26" From 36a63fc74e4578fa08d53c71cba50d410d8ca328 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Tue, 9 Sep 2025 21:20:22 -0700 Subject: [PATCH 11/11] feat: Add Socket SCA integration with multi-platform alert notifications - Add Socket SCA (Software Composition Analysis) scanning capability - Implement consolidated notification system with Jira, Slack, Teams, and Webhook plugins - Add comprehensive alert processing pipeline for external security tools - Update action.yml with new input parameters for SCA and notification configs - Enhance entrypoint.sh with Socket SCA environment variable handling - Create modular plugin architecture for extensible notification backends - Add Socket SCA connector with proper alert filtering and severity handling - Update README with integration configuration examples and setup instructions - Improve consolidated facts processing for multi-tool security alerts This adds a complete notification ecosystem for security findings, allowing teams to receive alerts through their preferred channels while maintaining the existing Socket facts format. --- README.md | 36 ++++ entrypoint.sh | 2 +- pyproject.toml | 2 +- src/core/plugins/slack/slack.py | 286 -------------------------- src/core/socket_facts_consolidator.py | 37 ++-- src/version.py | 2 +- 6 files changed, 60 insertions(+), 305 deletions(-) delete mode 100644 src/core/plugins/slack/slack.py diff --git a/README.md b/README.md index ac857ae..73f72f9 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,44 @@ jobs: ms_sentinel_workspace_id: REPLACE_ME ms_sentinel_shared_key: REPLACE_ME + # Slack integration + slack_enabled: true + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + # Scan scope settings scan_all: false # Set to true to always scan the whole directory scan_files: "" # Comma-separated list of files to scan (overrides git diff) ``` +## Integration Configuration + +### Slack Integration + +Send security alerts to Slack channels using webhook integration: + +```yaml +slack_enabled: true +slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} +``` + +To set up Slack webhooks: +1. Go to your Slack workspace settings +2. Create a new Incoming Webhook for your desired channel +3. Copy the webhook URL and add it to your GitHub repository secrets as `SLACK_WEBHOOK_URL` +4. Enable the integration in your workflow with the parameters shown above + +### Other Integrations + +The security wrapper also supports: + +- **Jira Integration**: Create tickets for security findings +- **Microsoft Teams**: Send alerts to Teams channels +- **Webhook**: Send to custom webhook endpoints +- **Sumo Logic**: Forward logs to Sumo Logic +- **Microsoft Sentinel**: Send events to Azure Sentinel + +For configuration details of these integrations, see the source code in `src/core/plugins/`. + ## Local Development & Testing You can run the security-wrapper locally using Docker. This is useful for testing changes or scanning code outside of GitHub Actions. @@ -167,6 +200,9 @@ docker run --rm --name security-wrapper \ -e "INPUT_SOCKET_API_KEY=your-socket-api-key" \ -e "SOCKET_SCM_DISABLED=true" \ -e "INPUT_SOCKET_CONSOLE_MODE=json" \ + # Optional: Slack integration + # -e "INPUT_SLACK_ENABLED=true" \ + # -e "INPUT_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK" \ socketdev/security-wrapper ``` diff --git a/entrypoint.sh b/entrypoint.sh index 15201af..3408f80 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,7 +16,7 @@ TRIVY_RULES=${INPUT_TRIVY_RULES:-} # Socket configuration SOCKET_ORG=${INPUT_SOCKET_ORG:-} SOCKET_API_KEY=${INPUT_SOCKET_API_KEY:-} -export SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_SECURITY_API_KEY:-} +export SOCKET_SECURITY_API_KEY=${INPUT_SOCKET_API_KEY:-} SOCKET_SCA_FILES=${INPUT_SOCKET_SCA_FILES:-} # Set output directory for temp files diff --git a/pyproject.toml b/pyproject.toml index 07f53b5..0d81569 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "security-wrapper" -version = "1.0.26" +version = "1.0.27" description = "Security tools scanning wrapper" requires-python = ">=3.9" dependencies = [ diff --git a/src/core/plugins/slack/slack.py b/src/core/plugins/slack/slack.py deleted file mode 100644 index 67ff175..0000000 --- a/src/core/plugins/slack/slack.py +++ /dev/null @@ -1,286 +0,0 @@ -import requests -from typing import Dict, List, Any -from core import log - - -class Slack: - def __init__(self, webhook_url: str): - """ - Initializes the Slack client with webhook URL. - - :param webhook_url: The Slack webhook URL - """ - self.webhook_url = webhook_url - - def send_consolidated_security_alerts(self, facts_data: Dict[str, Any]) -> Dict[str, Any]: - """ - Process consolidated socket facts and send security alerts to Slack. - - :param facts_data: Consolidated socket facts data - :return: A dict with response information - """ - log.debug("Slack Plugin Enabled - Processing consolidated security alerts") - - # Extract repository and branch information - repository = facts_data.get("repository", "unknown-repo") - branch = facts_data.get("branch", "unknown-branch") - - # Get new alerts from the facts data - new_alerts = facts_data.get("new_alerts", []) - if not new_alerts: - log.info("No new security alerts to send to Slack") - return {"status": "success", "message": "No new alerts to process"} - - # Extract alerts from facts components - all_alerts = self._extract_alerts_from_facts(facts_data) - alerts_to_send = new_alerts if new_alerts else all_alerts - - if not alerts_to_send: - log.info("No security alerts found in facts data") - return {"status": "success", "message": "No alerts to process"} - - log.info(f"Sending {len(alerts_to_send)} security alerts to Slack") - - # Create Slack message blocks - message_blocks = self._create_slack_blocks_from_consolidated_alerts(alerts_to_send, repository, branch) - - try: - response = requests.post( - self.webhook_url, - json={"blocks": message_blocks} - ) - - if response.status_code >= 400: - log.error("Slack error %s: %s", response.status_code, response.text) - return {"status": "error", "message": response.text} - else: - log.info("Successfully sent consolidated security alerts to Slack") - return {"status": "success"} - except Exception as e: - log.error(f"Failed to send consolidated alerts to Slack: {str(e)}") - return {"status": "error", "message": str(e)} - - def _extract_alerts_from_facts(self, facts_data: Dict[str, Any]) -> List[Dict[str, Any]]: - """ - Extract all alerts from the facts data components. - - :param facts_data: Consolidated socket facts data - :return: List of all alerts - """ - all_alerts = [] - - for component in facts_data.get("components", []): - component_alerts = component.get("alerts", []) - for alert in component_alerts: - # Add component context to alert - alert_with_context = alert.copy() - alert_with_context["component_name"] = component.get("name", "unknown") - alert_with_context["component_type"] = component.get("type", "unknown") - alert_with_context["component_purl"] = component.get("purl", "") - alert_with_context["tool"] = component.get("type", "unknown") # Tool is the component type - alert_with_context["source"] = component.get("purl", "") # Source is the PURL - all_alerts.append(alert_with_context) - - return all_alerts - - def _create_slack_blocks_from_consolidated_alerts(self, alerts: List[Dict[str, Any]], repository: str, branch: str) -> List[Dict[str, Any]]: - """ - Create Slack message blocks from consolidated security alerts. - - :param alerts: List of security alerts - :param repository: Repository name - :param branch: Branch name - :return: List of Slack message blocks - """ - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"🔐 *Security Issues Detected in {repository}*\n*Branch:* `{branch}`\n*Total Alerts:* {len(alerts)}" - } - }, - {"type": "divider"} - ] - - for alert in alerts: - # Extract alert information - tool = self._extract_tool_from_purl(alert.get("source", alert.get("component_purl", ""))) or alert.get("tool", alert.get("component_type", "unknown")) - severity = alert.get("severity", "unknown").upper() - source = self._clean_purl_source(alert.get("source", alert.get("component_purl", ""))) - - # Extract rule/test name - props = alert.get("props", {}) - rule_name = ( - props.get("name") or - props.get("test_name") or - props.get("rule_id") or - alert.get("generatedBy", "unknown") - ) - - # Extract file path and line - location = alert.get("location", {}) - file_path = location.get("file", "unknown") - line_number = location.get("start") or location.get("line") - line_text = f" (line {line_number})" if line_number else "" - - # Extract description - description = ( - props.get("description") or - props.get("issue_text") or - props.get("details") or - props.get("message") or - "No description available" - )[:150] + ("..." if len(str(description)) > 150 else "") - - # Use emoji based on severity - severity_emoji = { - "CRITICAL": "🚨", - "HIGH": "🔴", - "MEDIUM": "🟡", - "LOW": "🟢" - }.get(severity, "⚠️") - - blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"{severity_emoji} *{rule_name}*\n" - f"*Tool:* `{tool}`\n" - f"*File:* `{file_path}{line_text}`\n" - f"*Severity:* {severity}\n" - f"*Source:* `{source}`\n" - f"*Description:* {description}" - ) - } - }) - blocks.append({"type": "divider"}) - - return blocks - - def send_events(self, events: list, plugin_name: str) -> dict: - """ - Will iterate through events and send to Slack - :param events: A list containing the events to send - :param plugin_name: A string of the plugin name - :return: A dict with response information - """ - if not events: - log.debug("No events to notify via Slack.") - return {"status": "no_events"} - - log.debug("Slack Plugin Enabled") - - message_blocks = self.create_slack_blocks_from_events(events, plugin_name) - log.debug(f"Sending message to {self.webhook_url}") - - try: - response = requests.post( - self.webhook_url, - json={"blocks": message_blocks} - ) - - if response.status_code >= 400: - log.error("Slack error %s: %s", response.status_code, response.text) - return {"status": "error", "message": response.text} - else: - log.info("Successfully sent events to Slack") - return {"status": "success"} - except Exception as e: - log.error(f"Failed to send to Slack: {str(e)}") - return {"status": "error", "message": str(e)} - - def _extract_tool_from_purl(self, purl: str) -> str: - """Extract tool type from PURL's type parameter.""" - if not purl or "?type=" not in purl: - return "" - - try: - # Extract the type parameter from the PURL - # Format: pkg:ecosystem/name@version?type=tool-type - type_part = purl.split("?type=")[1] - # Handle multiple parameters by taking only the first one - tool_type = type_part.split("&")[0] - return tool_type - except (IndexError, AttributeError): - return "" - - def _clean_purl_source(self, purl: str) -> str: - """Clean PURL by removing type parameter and empty query string.""" - if not purl: - return "" - - try: - # Remove the type parameter - if "?type=" in purl: - # Split on ?type= and take the first part - base_purl = purl.split("?type=")[0] - - # Check if there are other parameters after type= - type_section = purl.split("?type=")[1] - if "&" in type_section: - # There are other parameters, reconstruct with remaining params - remaining_params = "&".join(type_section.split("&")[1:]) - return f"{base_purl}?{remaining_params}" - else: - # No other parameters, return clean base PURL - return base_purl - else: - # No type parameter, return as is - return purl - except (IndexError, AttributeError): - return purl - - @staticmethod - def create_slack_blocks_from_events(events: list, plugin_name: str): - """ - Creates Slack blocks from a list of events - """ - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*Security issues found by {plugin_name}*" - } - }, - {"type": "divider"} - ] - - for event in events: - if hasattr(event, 'as_dict'): - event_dict = event.as_dict() - else: - event_dict = event - - severity = event_dict.get("Severity", "Unknown") - issue_text = event_dict.get("issue_text", "Unknown") - test_name = event_dict.get("test_name", "Unknown") - filename = event_dict.get("filename", "Unknown") - message = event_dict.get("Message", "Unknown") - - # Use emoji based on severity - severity_emoji = { - "HIGH": "🔴", - "MEDIUM": "🟡", - "LOW": "🟢", - "CRITICAL": "🚨" - }.get(severity.upper(), "⚠️") - - blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"{severity_emoji} *{issue_text}*\n" - f"*Test:* `{test_name}`\n" - f"*File:* `{filename}`\n" - f"*Severity:* {severity}\n" - f"*Message:* {message}" - ) - } - }) - blocks.append({"type": "divider"}) - - return blocks diff --git a/src/core/socket_facts_consolidator.py b/src/core/socket_facts_consolidator.py index 84a4110..ce76145 100644 --- a/src/core/socket_facts_consolidator.py +++ b/src/core/socket_facts_consolidator.py @@ -390,13 +390,18 @@ def _process_socket_vulnerabilities(self, consolidated: Dict[str, Any]): # Get reachability info for this vulnerability reachability_info = reachability_lookup.get(ghsa_id, {}) - reachability_matches = reachability_info.get("reachability", []) + reachability_data = reachability_info.get("reachability", []) - # Check if vulnerability is reachable - is_reachable = any( - reach.get("type") == "reachable" - for reach in reachability_matches - ) + # Extract matches from reachable entries and check if reachable + is_reachable = False + reachability_matches = [] + + for reach_item in reachability_data: + if reach_item and reach_item.get("type") == "reachable": + is_reachable = True + matches = reach_item.get("matches", []) + if matches: + reachability_matches.extend(matches) # Create alert for this vulnerability using the original ecosystem type alert = { @@ -413,10 +418,10 @@ def _process_socket_vulnerabilities(self, consolidated: Dict[str, Any]): "vulnerability_details": { "ghsa_id": ghsa_id, "range": vuln.get("range", "unknown"), - "reachability_pattern": vuln.get("reachabilityData", {}).get("pattern", []), - "undeterminable_reachability": vuln.get("reachabilityData", {}).get("undeterminableReachability", False) + "reachability_pattern": (vuln.get("reachabilityData") or {}).get("pattern", []), + "undeterminable_reachability": (vuln.get("reachabilityData") or {}).get("undeterminableReachability", False) }, - "reachabilityData": vuln.get("reachabilityData", {}) if is_reachable else None + "reachabilityData": (vuln.get("reachabilityData") or {}) if is_reachable else None }, "location": { "files": component.get("manifestFiles", []) @@ -703,8 +708,8 @@ def _process_trufflehog_results(self, temp_output_dir: str) -> List[Dict[str, An for secret in secrets: source_metadata = secret.get("SourceMetadata", {}) data = source_metadata.get("Data", {}) - filename = data.get("Filesystem", {}).get("file", "unknown") - line_number = data.get("Filesystem", {}).get("line", 1) + filename = (data.get("Filesystem") or {}).get("file", "unknown") + line_number = (data.get("Filesystem") or {}).get("line", 1) # Normalize filename relative to workspace if filename.startswith("/workspace/"): @@ -859,15 +864,15 @@ def _process_single_trivy_file(self, trivy_file: str, scan_type: str) -> List[Di "resolution": misconf.get("Resolution", ""), "references": misconf.get("References", []), "code_block": { - "start_line": misconf.get("CauseMetadata", {}).get("StartLine", 1), - "end_line": misconf.get("CauseMetadata", {}).get("EndLine", 1), - "code": misconf.get("CauseMetadata", {}).get("Code", "") + "start_line": (misconf.get("CauseMetadata") or {}).get("StartLine", 1), + "end_line": (misconf.get("CauseMetadata") or {}).get("EndLine", 1), + "code": (misconf.get("CauseMetadata") or {}).get("Code", "") } }, "location": { "file": target, - "start": misconf.get("CauseMetadata", {}).get("StartLine", 1), - "end": misconf.get("CauseMetadata", {}).get("EndLine", 1) + "start": (misconf.get("CauseMetadata") or {}).get("StartLine", 1), + "end": (misconf.get("CauseMetadata") or {}).get("EndLine", 1) } } component["alerts"].append(alert) diff --git a/src/version.py b/src/version.py index 8c79b2e..65d99ec 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = "1.0.26" +__version__ = "1.0.27"