diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4af0054 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,42 @@ +name: Lint + +env: + PYTHON_VERSION: "3.12" + +# Report on every PR, including changes outside the Python test paths. +# Required checks need a result even when a PR changes no Python files. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: lint-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + ruff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - name: ๐Ÿ setup python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: ๐Ÿ› ๏ธ install deps + run: | + python -m pip install --upgrade pip + pip install uv + uv sync --locked --extra dev + - name: ๐Ÿงน ruff check + run: uv run --locked --extra dev ruff check + - name: ๐ŸŽจ ruff format + run: uv run --locked --extra dev ruff format --check diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml index 318a875..ec626d8 100644 --- a/.github/workflows/package-check.yml +++ b/.github/workflows/package-check.yml @@ -34,7 +34,7 @@ jobs: run: python -m pip install ".[test]" "twine>=4.0.0" - name: Run unit tests - run: python -m pytest -q tests/unit + run: python -m pytest -q tests/unit tests/integration/test_all_endpoints.py::TestAllEndpointsMocked - name: Build distributions run: hatch build diff --git a/.gitignore b/.gitignore index 77da6d1..88c03f3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ dist *.dist *.egg-info *.cpython-312.pyc -example-socket-export.py __pycache__/ .coverage .coverage.* diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index 32869c3..b3de6da 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -import subprocess +import json import pathlib import re +import subprocess import sys import urllib.request -import json VERSION_FILE = pathlib.Path("socketdev/version.py") PYPROJECT_FILE = pathlib.Path("pyproject.toml") @@ -16,6 +16,7 @@ PYPI_PROD_API = "https://pypi.org/pypi/socketdev/json" PYPI_TEST_API = "https://test.pypi.org/pypi/socketdev/json" + def read_version_from_version_file(path: pathlib.Path) -> str: content = path.read_text() match = VERSION_PATTERN.search(content) @@ -24,6 +25,7 @@ def read_version_from_version_file(path: pathlib.Path) -> str: 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) @@ -34,6 +36,7 @@ def read_version_from_git(path: str) -> str: except subprocess.CalledProcessError: return None + def bump_patch_version(version: str) -> str: if ".dev" in version: version = version.split(".dev")[0] @@ -41,6 +44,7 @@ def bump_patch_version(version: str) -> str: parts[-1] = str(int(parts[-1]) + 1) return ".".join(parts) + def parse_stable_version(version: str): if not STABLE_VERSION_PATTERN.fullmatch(version): return None @@ -188,16 +192,21 @@ def main(): inject_version(new_version) uv_lock_changed = run_uv_lock() lock_hint = " and uv.lock" if uv_lock_changed else "" - print(f"โš ๏ธ Version {current_version} is already published on PyPI โ€” auto-bumped to {new_version}. Please git add{lock_hint} + commit again.") + print( + f"โš ๏ธ Version {current_version} is already published on PyPI โ€” auto-bumped to {new_version}. Please git add{lock_hint} + commit again." + ) sys.exit(1) uv_lock_changed = run_uv_lock() if uv_lock_changed: - print("โš ๏ธ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.") + print( + "โš ๏ธ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again." + ) sys.exit(1) print("โœ… Version already bumped and uv.lock is up to date โ€” proceeding.") sys.exit(0) + if __name__ == "__main__": main() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..27baed3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: local + hooks: + + # Use the project's locked Ruff version, shared with CI and make lint. + - id: ruff-check + name: ruff check + entry: uv run --locked --extra dev ruff check --force-exclude --fix + language: system + types_or: [python, pyi] + require_serial: true + + - id: ruff-format + name: ruff format + entry: uv run --locked --extra dev ruff format --force-exclude + language: system + types_or: [python, pyi] + require_serial: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bc25b..db54a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 3.6.3 + +### Changed + +- Enforce Ruff linting and formatting in CI and pre-commit, using the locked + development tool version. Add `make lint`, `make lint-fix`, `make format`, + and `make hooks` for contributors. +- Bound function complexity at 12 independent paths and eight arguments, with + explicit exceptions for existing functions and checks for obsolete suppressions. +- Full-scan parameter normalization uses debug logging instead of stdout. +- Refresh mocked endpoint tests for the supported API methods and include them + in the default local and CI test runs. + +### Fixed + +- Repository updates correctly serialize keyword arguments into the request body. +- Report uploads close files opened from paths after the request, including on + failure. Streams supplied by callers remain open. +- Lazy-file validation remains active under `python -O`. + ## 3.6.2 ### Fixed: reject retired API credentials before use diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5775220..5b2b4f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,24 +2,43 @@ ## Development setup -Use Python 3.9 or newer. Create and activate a virtual environment, then -install the package with its development and test dependencies: +Use Python 3.10 or newer and install [uv](https://docs.astral.sh/uv/). From the +repository root, install the locked development and test dependencies: ```bash -python -m venv .venv -source .venv/bin/activate -python -m pip install -e ".[dev,test]" +uv sync --locked --extra dev --extra test +make hooks ``` -Before opening a pull request, run the focused checks for your change. The -complete local check is: +Before opening a pull request, run: ```bash -python -m pytest -hatch build -python -m twine check dist/* +make lint +make test +uv run --locked hatch build +uv run --locked python -m twine check dist/* ``` +## Linting and formatting + +Ruff checks Python source, tests, hooks, examples, and developer scripts. The +rule set lives in `pyproject.toml`; CI, Make targets, and pre-commit use the same +Ruff version from `uv.lock`. + +`make lint-fix` applies safe lint fixes. `make format` formats files with a line +length of 120. Pre-commit applies both to staged Python files. Review and stage +any edits before committing again. CI checks the full repository on every pull +request and push to `main`. + +Functions are limited to cyclomatic complexity 12 and eight arguments. Existing +over-limit functions carry explicit suppressions; split new functions that exceed +the limits. `RUF100` rejects suppressions that become unnecessary. Avoid running +`ruff check --fix` with a narrow `--select` that includes `RUF100`, because it +can remove markers for rules outside that selection. + +Integration tests requiring live credentials are opt-in; see +[tests/README.md](tests/README.md). The default `make test` runs unit tests and mocked endpoint tests. + ## Pull request validation The `Package Check` workflow runs automatically for pull requests. It runs the diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4792d5a --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: lint lint-fix format format-check hooks test + +lint: + uv run --locked --extra dev ruff check + uv run --locked --extra dev ruff format --check + +lint-fix: + uv run --locked --extra dev ruff check --fix + +format: + uv run --locked --extra dev ruff format + +format-check: + uv run --locked --extra dev ruff format --check + +hooks: + uv run --locked --extra dev pre-commit install + +test: + uv run --locked --extra test pytest tests/unit tests/integration/test_all_endpoints.py::TestAllEndpointsMocked diff --git a/example-socket-export.py b/example-socket-export.py index 096d5c0..99ade2a 100644 --- a/example-socket-export.py +++ b/example-socket-export.py @@ -1,7 +1,10 @@ import json -import os import logging +import os +import sys + from socketdev import socketdev + logging.basicConfig(level=logging.INFO) @@ -11,13 +14,13 @@ if len(orgs) > 0: org_id = None org_slug = None - for org_key in orgs['organizations']: - org = orgs['organizations'][org_key] + for org_key in orgs["organizations"]: + org = orgs["organizations"][org_key] org_id = org_key - org_slug = org['slug'] + org_slug = org["slug"] else: print("Something went wrong with getting org info") - exit(1) + sys.exit(1) per_page = 100 response = sdk.repos.get(org_slug, per_page=per_page) next_page = response.get("nextPage") @@ -47,7 +50,7 @@ "pull_request_url": full_scan_metadata.get("pull_request_url"), "committers": full_scan_metadata.get("committers"), "created_at": full_scan_metadata.get("created_at"), - "results": sdk.fullscans.stream(org_slug=org_slug, full_scan_id=head_full_scan_id) or None + "results": sdk.fullscans.stream(org_slug=org_slug, full_scan_id=head_full_scan_id) or None, } socket_results[head_full_scan_id] = full_scan_result diff --git a/pyproject.toml b/pyproject.toml index a337b30..c9512c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,8 @@ core-metadata-version = "2.4" # modern, faster linter and language server. install with `pip install -e ".[dev]"` [project.optional-dependencies] dev = [ - "ruff>=0.3.0", + "ruff==0.17.4", + "pre-commit==4.6.2", "hatch>=1.18.1", "hatchling>=1.27.0", "twine>=4.0.0" @@ -60,7 +61,10 @@ test = [ Homepage = "https://github.com/SocketDev/socket-sdk-python" [tool.ruff] -# Exclude a variety of commonly ignored directories. +# The formatter owns physical layout. E501 and W291/W293 are unselected: +# whitespace inside help text, log messages, and Markdown strings is content. +target-version = "py310" +line-length = 120 exclude = [ ".bzr", ".direnv", @@ -91,19 +95,114 @@ exclude = [ ] [tool.ruff.lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or -# McCabe complexity (`C901`) by default. -select = ["E4", "E7", "E9", "F"] -ignore = [] +select = [ + # --- Baseline correctness ------------------------------------------- + "E4", # pycodestyle: import formatting + "E7", # pycodestyle: statement-level errors + "E9", # pycodestyle: syntax/IO errors + "W605", # invalid escape sequence in a string literal + "F", # Pyflakes: undefined names, unused imports, star imports + "I", # isort: deterministic import order + "PLE", # Pylint errors: unambiguous bugs, never style + + # --- Complexity guardrails ------------------------------------------ + "C901", # McCabe cyclomatic complexity + "PLR0913", # too many arguments in a function definition + + # --- Bug classes that matter for a CLI shipped to other people ------- + "ASYNC", # blocking calls inside async functions + "B", # bugbear: loop-variable capture, missing `raise ... from` + "C4", # comprehension misuse + "DTZ", # naive datetimes: scan timestamps must be tz-aware + "EXE", # shebang / executable-bit mismatches in a packaged wheel + "FLY", # static str.join() that should be an f-string + "FURB", # modern-Python refactors + "ICN", # conventional import aliases + "INT", # gettext f-string traps + "LOG", # logging misuse + "PERF", # avoidable per-iteration work + "PIE", # dead code and redundant constructs + "PLW", # Pylint warnings: unchecked subprocess, loop-var rebinding + "RET", # unreachable or redundant return flow + "RSE", # raise Exception() -- not raise Exception + "RUF", # Ruff-native checks (incl. RUF100, see below) + "S", # bandit: request timeouts, unsafe subprocess, weak randomness + "SIM", # simplifiable branches and context managers + "SLOT", # __slots__ on subclasses of str/tuple/namedtuple + "T10", # leftover breakpoint()/pdb.set_trace() reaching a release + "T20", # stray print(): stdout is a contract for a CLI, not a debug log + "TID", # relative imports + "UP", # pyupgrade: the floor is Python 3.11 + "YTT", # incorrect sys.version comparisons + + # Individually selected because their siblings are style opinions + # (PLR2004 magic values, PLC0415 deliberate lazy imports, TRY003 + # message length) that would cost more than they catch here. + "PLR1714", # repeated equality comparison -> `in` + "PLR1722", # `exit()` -> `sys.exit()`: `exit` only exists when `site` ran + "PLR1730", # if-statement that should be min()/max() + "PLR5501", # else-if that should be elif + "TRY201", # `raise` instead of re-raising the bound name + + # --- Keeping the suppressions honest -------------------------------- + "PGH", # no blanket `# noqa` / `# type: ignore` -- codes required + "RUF100", # unused `# noqa` -- makes the grandfather list self-cleaning +] + +ignore = [ + # Keep expected failures readable without requiring tracebacks. + "TRY400", + "TRY401", + "SIM108", # retain comments on if/else branches + "PERF401", # retain readable loops for multi-line dictionary construction + "S603", # external tools use argument lists; shell=True remains checked + "S607", # external tools intentionally resolve through PATH + "TID252", # package-local relative imports are intentional + "PERF203", # malformed stream entries must not discard valid neighbors +] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] unfixable = [] +# The information glyph is deliberate UI in the PR-comment markup. +allowed-confusables = ["โ„น"] + # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +[tool.ruff.lint.mccabe] +# Existing over-limit functions have explicit suppressions. Split new functions +# that exceed the limit; RUF100 rejects suppressions that become unnecessary. +max-complexity = 12 + +[tool.ruff.lint.pylint] +# Allow ordinary CLI constructors while bounding argument-heavy interfaces. +max-args = 8 + +[tool.ruff.lint.per-file-ignores] +# Tests use assertions, placeholder credentials, and fixed fixture paths. +"tests/**" = [ + "S101", # asserts are the point + "S105", # throwaway credentials in fixtures + "S106", + "S108", # fixed temp paths in fixtures + "T201", # diagnostic prints; pytest captures stdout anyway + "RUF012", # ClassVar annotations on test-local constants are noise + "S110", # integration-test cleanup is best effort + "S112", # enum discovery skips unimportable modules + "SIM105", # cleanup handlers carry explanatory comments +] + +# Developer tooling, not shipped in the wheel. stdout is their entire interface. +".hooks/**" = ["T201", "S310"] # fetches a hardcoded PyPI JSON endpoint +"scripts/**" = ["T201"] +"example-*.py" = ["T201"] +"tests/run_tests.py" = ["T201"] + +[tool.ruff.lint.isort] +known-first-party = ["socketdev"] + [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "double" diff --git a/scripts/check_api_enum_drift.py b/scripts/check_api_enum_drift.py index af9b9c3..da84f2a 100755 --- a/scripts/check_api_enum_drift.py +++ b/scripts/check_api_enum_drift.py @@ -63,7 +63,7 @@ def fetch_spec(url): - with urllib.request.urlopen(url, timeout=60) as response: + with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310 # explicit developer-supplied spec URL if response.status != 200: raise RuntimeError(f"{url} returned HTTP {response.status}") return json.load(response) @@ -106,19 +106,16 @@ def main(): if missing: drifted = True print( - f"::error::{enum_cls.__name__} is missing {len(missing)} value(s) " - f"the API defines: {', '.join(missing)}" + f"::error::{enum_cls.__name__} is missing {len(missing)} value(s) the API defines: {', '.join(missing)}" ) if extra: # Not fatal: the spec omitting a value the SDK accepts is usually a # spec gap, and dropping a member would be a breaking change. print( - f"::warning::{enum_cls.__name__} defines {len(extra)} value(s) " - f"absent from the spec: {', '.join(extra)}" + f"::warning::{enum_cls.__name__} defines {len(extra)} value(s) absent from the spec: {', '.join(extra)}" ) if not missing and not extra: - print(f"ok: {enum_cls.__name__} matches {schema_name} " - f"({len(api_values)} values)") + print(f"ok: {enum_cls.__name__} matches {schema_name} ({len(api_values)} values)") if unmapped: print( @@ -126,10 +123,7 @@ def main(): f"named schema for them: {', '.join(sorted(unmapped))}" ) - print( - f"not applicable (request-only, intentionally strict): " - f"{', '.join(sorted(e.__name__ for e in REQUEST_ONLY))}" - ) + print(f"not applicable (request-only, intentionally strict): {', '.join(sorted(e.__name__ for e in REQUEST_ONLY))}") if drifted: print( diff --git a/socketdev/__init__.py b/socketdev/__init__.py index 2be291f..74e4424 100644 --- a/socketdev/__init__.py +++ b/socketdev/__init__.py @@ -1,12 +1,24 @@ import os + +from socketdev.alertfullscansearch import AlertFullScanSearch +from socketdev.alerts import Alerts +from socketdev.alerttypes import AlertTypes +from socketdev.analytics import Analytics +from socketdev.apitokens import ApiTokens +from socketdev.auditlog import AuditLog +from socketdev.basics import Basics from socketdev.core.api import API from socketdev.core.retired_token import reject_retired_token from socketdev.core.token_selection import select_token from socketdev.dependencies import Dependencies from socketdev.diffscans import DiffScans from socketdev.export import Export +from socketdev.fixes import Fixes from socketdev.fullscans import FullScans from socketdev.historical import Historical +from socketdev.labels import Labels +from socketdev.licensemetadata import LicenseMetadata +from socketdev.log import log as log from socketdev.npm import NPM from socketdev.openapi import OpenAPI from socketdev.org import Orgs @@ -17,33 +29,19 @@ from socketdev.repositories import Repositories from socketdev.sbom import Sbom from socketdev.settings import Settings -from socketdev.triage import Triage -from socketdev.utils import Utils, IntegrationType, INTEGRATION_TYPES -from socketdev.version import __version__ -from socketdev.labels import Labels -from socketdev.licensemetadata import LicenseMetadata +from socketdev.supportedfiles import SupportedFiles +from socketdev.telemetry import Telemetry from socketdev.threatfeed import ThreatFeed -from socketdev.apitokens import ApiTokens -from socketdev.auditlog import AuditLog -from socketdev.analytics import Analytics -from socketdev.alerttypes import AlertTypes -from socketdev.basics import Basics +from socketdev.triage import Triage from socketdev.uploadmanifests import UploadManifests -from socketdev.alertfullscansearch import AlertFullScanSearch -from socketdev.alerts import Alerts -from socketdev.fixes import Fixes -from socketdev.supportedfiles import SupportedFiles +from socketdev.utils import INTEGRATION_TYPES, IntegrationType, Utils +from socketdev.version import __version__ as __version__ from socketdev.webhooks import Webhooks -from socketdev.telemetry import Telemetry -from socketdev.log import log -from typing import Optional __author__ = "socket.dev" -__version__ = __version__ -__all__ = ["socketdev", "Utils", "IntegrationType", "INTEGRATION_TYPES"] +__all__ = ["INTEGRATION_TYPES", "IntegrationType", "Utils", "socketdev"] -global encoded_key encoded_key: str api_url = "https://api.socket.dev/v0" @@ -54,14 +52,20 @@ class socketdev: - def __init__(self, token: Optional[str] = None, timeout: int = 1200, allow_unverified: bool = False, user_agent: Optional[str] = None): + def __init__( + self, + token: str | None = None, + timeout: int = 1200, + allow_unverified: bool = False, + user_agent: str | None = None, + ): token = select_token(token, os.environ) if token is None: raise ValueError( "API token is required. Provide it as a parameter or set one of these environment variables: " "SOCKET_SECURITY_API_TOKEN, SOCKET_SECURITY_API_KEY, SOCKET_API_KEY, SOCKET_API_TOKEN" ) - + reject_retired_token(token) self.api = API() diff --git a/socketdev/alertfullscansearch/__init__.py b/socketdev/alertfullscansearch/__init__.py index 52a8a6c..afbb7e0 100644 --- a/socketdev/alertfullscansearch/__init__.py +++ b/socketdev/alertfullscansearch/__init__.py @@ -22,12 +22,12 @@ def search(self, org_slug: str, **query_params) -> dict: path = f"orgs/{org_slug}/alert-full-scan-search" if query_params: path += "?" + urlencode(query_params) - + response = self.api.do_request(path=path) - + if response.status_code == 200: return response.json() - + log.error(f"Error searching alerts: {response.status_code}") log.error(response.text) return {} diff --git a/socketdev/alerts/__init__.py b/socketdev/alerts/__init__.py index 6ce5682..2ba6e35 100644 --- a/socketdev/alerts/__init__.py +++ b/socketdev/alerts/__init__.py @@ -22,12 +22,12 @@ def get(self, org_slug: str, **query_params) -> dict: path = f"orgs/{org_slug}/alerts" if query_params: path += "?" + urlencode(query_params) - + response = self.api.do_request(path=path) - + if response.status_code == 200: return response.json() - + log.error(f"Error getting alerts: {response.status_code}") log.error(response.text) return {} diff --git a/socketdev/alerttypes/__init__.py b/socketdev/alerttypes/__init__.py index ae6afef..043038a 100644 --- a/socketdev/alerttypes/__init__.py +++ b/socketdev/alerttypes/__init__.py @@ -1,5 +1,5 @@ -import logging import json +import logging from urllib.parse import urlencode log = logging.getLogger("socketdev") @@ -9,28 +9,28 @@ class AlertTypes: def __init__(self, api): self.api = api - def get(self, alert_types: list = None, language: str = "en-US", **kwargs) -> dict: + def get(self, alert_types: list | None = None, language: str = "en-US", **kwargs) -> dict: """ Get alert types metadata. - + Args: alert_types: List of alert type strings to get metadata for language: Language for alert metadata (default: en-US) **kwargs: Additional query parameters - + Returns: dict: API response containing alert types metadata """ path = "alert-types" query_params = {"language": language} query_params.update(kwargs) - + if query_params: path += "?" + urlencode(query_params) - + payload = json.dumps(alert_types or []) response = self.api.do_request(path=path, method="POST", payload=payload) - + if response.status_code == 200: return response.json() log.error(f"Error getting alert types: {response.status_code}") diff --git a/socketdev/analytics/__init__.py b/socketdev/analytics/__init__.py index 4f01de3..9502de5 100644 --- a/socketdev/analytics/__init__.py +++ b/socketdev/analytics/__init__.py @@ -11,15 +11,15 @@ def __init__(self, api): def get_org(self, filter: str, **kwargs) -> list: """ Get organization analytics (deprecated). - + **DEPRECATED**: Use the Historical module methods instead: - sdk.historical.alerts_trend() for alerts analytics - sdk.historical.dependencies_trend() for dependencies analytics - + Args: filter: Analytics filter type **kwargs: Additional query parameters - + Returns: list: API response containing analytics data """ @@ -27,7 +27,7 @@ def get_org(self, filter: str, **kwargs) -> list: path = f"analytics/org/{filter}" if kwargs: path += "?" + urlencode(kwargs) - + response = self.api.do_request(path=path) if response.status_code == 200: return response.json() @@ -38,16 +38,16 @@ def get_org(self, filter: str, **kwargs) -> list: def get_repo(self, name: str, filter: str, **kwargs) -> list: """ Get repository analytics (deprecated). - + **DEPRECATED**: Use the Historical module methods instead: - - sdk.historical.alerts_trend() for alerts analytics + - sdk.historical.alerts_trend() for alerts analytics - sdk.historical.dependencies_trend() for dependencies analytics - + Args: name: Repository name filter: Analytics filter type **kwargs: Additional query parameters - + Returns: list: API response containing analytics data """ @@ -55,7 +55,7 @@ def get_repo(self, name: str, filter: str, **kwargs) -> list: path = f"analytics/repo/{name}/{filter}" if kwargs: path += "?" + urlencode(kwargs) - + response = self.api.do_request(path=path) if response.status_code == 200: return response.json() diff --git a/socketdev/apitokens/__init__.py b/socketdev/apitokens/__init__.py index 03dc41c..44628c6 100644 --- a/socketdev/apitokens/__init__.py +++ b/socketdev/apitokens/__init__.py @@ -1,5 +1,5 @@ -import logging import json +import logging log = logging.getLogger("socketdev") @@ -11,11 +11,11 @@ def __init__(self, api): def create(self, org_slug: str, **kwargs) -> dict: """ Create a new API token. - + Args: org_slug: Organization slug **kwargs: Token configuration parameters - + Returns: dict: API response containing the created token details """ @@ -31,11 +31,11 @@ def create(self, org_slug: str, **kwargs) -> dict: def list(self, org_slug: str, **kwargs) -> dict: """ List API tokens for an organization. - + Args: org_slug: Organization slug **kwargs: Query parameters - + Returns: dict: API response containing list of tokens """ @@ -43,9 +43,10 @@ def list(self, org_slug: str, **kwargs) -> dict: query_params = {} if kwargs: query_params.update(kwargs) - + if query_params: from urllib.parse import urlencode + path += "?" + urlencode(query_params) response = self.api.do_request(path=path, method="GET") if response.status_code == 200: @@ -54,29 +55,29 @@ def list(self, org_slug: str, **kwargs) -> dict: log.error(response.text) return {} - def update(self, org_slug: str, token_id: str = None, **kwargs) -> dict: + def update(self, org_slug: str, token_id: str | None = None, **kwargs) -> dict: """ Update an API token. - + Args: org_slug: Organization slug token_id: Token ID to update (optional, can be in kwargs) **kwargs: Token update parameters - + Returns: dict: API response containing the updated token details """ # Extract token_id from kwargs if not provided as parameter - if token_id is None and 'token_id' in kwargs: - token_id = kwargs.pop('token_id') - + if token_id is None and "token_id" in kwargs: + token_id = kwargs.pop("token_id") + if token_id: path = f"orgs/{org_slug}/api-tokens/{token_id}" method = "PUT" else: path = f"orgs/{org_slug}/api-tokens/update" method = "POST" - + payload = json.dumps(kwargs) if kwargs else "{}" response = self.api.do_request(path=path, method=method, payload=payload) if response.status_code == 200: @@ -88,11 +89,11 @@ def update(self, org_slug: str, token_id: str = None, **kwargs) -> dict: def rotate(self, org_slug: str, **kwargs) -> dict: """ Rotate an API token. - + Args: org_slug: Organization slug **kwargs: Token rotation parameters - + Returns: dict: API response containing the rotated token details """ @@ -108,11 +109,11 @@ def rotate(self, org_slug: str, **kwargs) -> dict: def revoke(self, org_slug: str, **kwargs) -> dict: """ Revoke an API token. - + Args: org_slug: Organization slug **kwargs: Token revocation parameters - + Returns: dict: API response confirming token revocation """ diff --git a/socketdev/auditlog/__init__.py b/socketdev/auditlog/__init__.py index 335343b..304675d 100644 --- a/socketdev/auditlog/__init__.py +++ b/socketdev/auditlog/__init__.py @@ -10,17 +10,18 @@ def __init__(self, api): def get(self, org_slug: str, **kwargs) -> dict: """ Get audit log entries for an organization. - + Args: org_slug: Organization slug **kwargs: Query parameters like limit, cursor, etc. - + Returns: dict: API response containing audit log entries """ path = f"orgs/{org_slug}/audit-log" if kwargs: from urllib.parse import urlencode + path += "?" + urlencode(kwargs) response = self.api.do_request(path=path) if response.status_code == 200: diff --git a/socketdev/basics/__init__.py b/socketdev/basics/__init__.py index a512e73..99010aa 100644 --- a/socketdev/basics/__init__.py +++ b/socketdev/basics/__init__.py @@ -1,6 +1,5 @@ import logging -from typing import Optional, Union -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass log = logging.getLogger("socketdev") @@ -8,6 +7,7 @@ @dataclass class SocketBasicsConfig: """Data class representing Socket Basics configuration settings.""" + pythonSastEnabled: bool = False golangSastEnabled: bool = False javascriptSastEnabled: bool = False @@ -42,10 +42,11 @@ def from_dict(cls, data: dict) -> "SocketBasicsConfig": @dataclass class SocketBasicsResponse: """Data class representing the response from Socket Basics API calls.""" + success: bool status: int - config: Optional[SocketBasicsConfig] = None - message: Optional[str] = None + config: SocketBasicsConfig | None = None + message: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -65,7 +66,7 @@ def from_dict(cls, data: dict) -> "SocketBasicsResponse": class Basics: """ Socket Basics API client for managing CI/CD security scanning configurations. - + Socket Basics is a security scanning suite that includes: - SAST (Static Application Security Testing) for Python, Go, and JavaScript - Secret scanning for hardcoded credentials @@ -76,9 +77,7 @@ class Basics: def __init__(self, api): self.api = api - def get_config( - self, org_slug: str, use_types: bool = False - ) -> Union[dict, SocketBasicsResponse]: + def get_config(self, org_slug: str, use_types: bool = False) -> dict | SocketBasicsResponse: """ Get Socket Basics configuration for an organization. @@ -93,7 +92,7 @@ def get_config( >>> basics = socketdev_client.basics >>> config = basics.get_config("my-org") >>> print(config["pythonSastEnabled"]) - + >>> # Using typed response >>> response = basics.get_config("my-org", use_types=True) >>> print(response.config.pythonSastEnabled) @@ -109,12 +108,7 @@ def get_config( error_message = response.json().get("error", {}).get("message", "Unknown error") log.error(f"Failed to get Socket Basics configuration: {response.status_code}, message: {error_message}") - + if use_types: - return SocketBasicsResponse( - success=False, - status=response.status_code, - config=None, - message=error_message - ) - return {} \ No newline at end of file + return SocketBasicsResponse(success=False, status=response.status_code, config=None, message=error_message) + return {} diff --git a/socketdev/core/api.py b/socketdev/core/api.py index 73692f9..49c25e0 100644 --- a/socketdev/core/api.py +++ b/socketdev/core/api.py @@ -1,25 +1,25 @@ import base64 -from typing import Optional -from socketdev.log import log -from socketdev.core.retired_token import reject_retired_basic_auth_token +import time import requests +from requests.exceptions import ConnectionError, Timeout + from socketdev.core.classes import Response +from socketdev.core.retired_token import reject_retired_basic_auth_token from socketdev.exceptions import ( - APIKeyMissing, - APIFailure, APIAccessDenied, - APIInsufficientQuota, - APIResourceNotFound, - APITimeout, - APIConnectionError, APIBadGateway, + APIConnectionError, + APIFailure, APIInsufficientPermissions, + APIInsufficientQuota, + APIKeyMissing, APIOrganizationNotAllowed, + APIResourceNotFound, + APITimeout, ) +from socketdev.log import log from socketdev.version import __version__ -from requests.exceptions import Timeout, ConnectionError -import time class API: @@ -43,15 +43,15 @@ def set_allow_unverified(self, allow_unverified: bool): def set_user_agent(self, user_agent: str): self.user_agent = user_agent - def do_request( + def do_request( # noqa: C901 self, path: str, headers: dict | None = None, payload: [dict, str] = None, - files: list = None, + files: list | None = None, method: str = "GET", *, - request_timeout: Optional[float] = None, + request_timeout: float | None = None, ) -> Response: if self.encoded_key is None or self.encoded_key == "": raise APIKeyMissing @@ -70,11 +70,14 @@ def format_headers(headers_dict): start_time = time.time() try: - response = requests.request( - method.upper(), url, headers=headers, data=payload, files=files, + method.upper(), + url, + headers=headers, + data=payload, + files=files, timeout=self.request_timeout if request_timeout is None else request_timeout, - verify=not self.allow_unverified + verify=not self.allow_unverified, ) request_duration = time.time() - start_time @@ -89,16 +92,15 @@ def format_headers(headers_dict): if "Insufficient permissions for API method" in error_message: log.error(f"{error_message}{path_str}{headers_str}") raise APIInsufficientPermissions(status_code=403) - elif "Organization not allowed" in error_message: + if "Organization not allowed" in error_message: log.error(f"{error_message}{path_str}{headers_str}") raise APIOrganizationNotAllowed(status_code=403) - elif "Insufficient max quota" in error_message: + if "Insufficient max quota" in error_message: log.error(f"{error_message}{path_str}{headers_str}") raise APIInsufficientQuota(status_code=403) - else: - raise APIAccessDenied(f"{error_message or 'Access denied'}{path_str}{headers_str}", status_code=403) - except ValueError: - raise APIAccessDenied(f"Access denied{path_str}{headers_str}", status_code=403) + raise APIAccessDenied(f"{error_message or 'Access denied'}{path_str}{headers_str}", status_code=403) + except ValueError as error: + raise APIAccessDenied(f"Access denied{path_str}{headers_str}", status_code=403) from error if response.status_code == 404: log.error(f"Path not found {path}{path_str}{headers_str}") raise APIResourceNotFound(status_code=404) @@ -118,7 +120,7 @@ def format_headers(headers_dict): raise APIInsufficientQuota(status_code=429) if response.status_code == 502: log.error(f"Upstream server error{path_str}{headers_str}") - raise APIBadGateway() + raise APIBadGateway if response.status_code >= 400: try: error_json = response.json() @@ -133,14 +135,14 @@ def format_headers(headers_dict): raise APIFailure(error, status_code=response.status_code) return response - except Timeout: + except Timeout as error: request_duration = time.time() - start_time log.error(f"Request timed out after {request_duration:.2f} seconds") - raise APITimeout() + raise APITimeout from error except ConnectionError as error: request_duration = time.time() - start_time log.error(f"Connection error after {request_duration:.2f} seconds: {error}") - raise APIConnectionError() + raise APIConnectionError from error except ( APIAccessDenied, APIInsufficientQuota, @@ -157,4 +159,4 @@ def format_headers(headers_dict): except Exception as error: # Only truly unexpected errors get wrapped in a generic APIFailure log.error(f"Unexpected error: {error}") - raise APIFailure() + raise APIFailure from error diff --git a/socketdev/core/classes.py b/socketdev/core/classes.py index 0d3a37e..365d09c 100644 --- a/socketdev/core/classes.py +++ b/socketdev/core/classes.py @@ -45,17 +45,16 @@ class Package: license_text: str purl: str - def __init__(self, **kwargs): + def __init__(self, **kwargs): # noqa: C901 if kwargs: for key, value in kwargs.items(): setattr(self, key, value) if not hasattr(self, "direct"): self.direct = False - else: - if str(self.direct).lower() == "true": - self.direct = True + elif str(self.direct).lower() == "true": + self.direct = True self.url = f"https://socket.dev/{self.type}/package/{self.name}/overview/{self.version}" - if hasattr(self, 'score'): + if hasattr(self, "score"): self.scores = Score(**self.score) if not hasattr(self, "alerts"): self.alerts = [] @@ -69,12 +68,7 @@ def __init__(self, **kwargs): self.author = [] if not hasattr(self, "size"): self.size = 0 - self.alert_counts = { - "critical": 0, - "high": 0, - "middle": 0, - "low": 0 - } + self.alert_counts = {"critical": 0, "high": 0, "middle": 0, "low": 0} self.error_alerts = [] if not hasattr(self, "license"): self.license = "NoLicenseFound" diff --git a/socketdev/core/dedupe.py b/socketdev/core/dedupe.py index 2e6e5c4..b49ef06 100644 --- a/socketdev/core/dedupe.py +++ b/socketdev/core/dedupe.py @@ -1,6 +1,5 @@ from collections import defaultdict -from typing import Dict, List, Any -from socketdev.log import log +from typing import Any class Dedupe: @@ -16,11 +15,11 @@ def alert_key(alert: dict) -> tuple: alert.get("category"), Dedupe.normalize_file_path(alert.get("file")), alert.get("start"), - alert.get("end") + alert.get("end"), ) @staticmethod - def consolidate_and_merge_alerts(package_group: List[Dict[str, Any]]) -> Dict[str, Any]: + def consolidate_and_merge_alerts(package_group: list[dict[str, Any]]) -> dict[str, Any]: # noqa: C901 def alert_identity(alert: dict) -> tuple: return ( alert.get("type"), @@ -28,10 +27,10 @@ def alert_identity(alert: dict) -> tuple: alert.get("category"), Dedupe.normalize_file_path(alert.get("file")), alert.get("start"), - alert.get("end") + alert.get("end"), ) - alert_map: Dict[tuple, dict] = {} + alert_map: dict[tuple, dict] = {} releases = set() for pkg in package_group: release = pkg.get("release") if pkg.get("release") is not None else pkg.get("type") @@ -51,9 +50,9 @@ def alert_identity(alert: dict) -> tuple: "severity": alert.get("severity"), "releases": [release], "props": alert.get("props", []), - "action": alert.get("action") + "action": alert.get("action"), } - + # Only include optional fields if they exist in the original alert if "category" in alert: consolidated_alert["category"] = alert["category"] @@ -63,42 +62,40 @@ def alert_identity(alert: dict) -> tuple: consolidated_alert["start"] = alert["start"] if "end" in alert: consolidated_alert["end"] = alert["end"] - + alert_map[identity] = consolidated_alert - else: - if release not in alert_map[identity]["releases"]: - alert_map[identity]["releases"].append(release) + elif release not in alert_map[identity]["releases"]: + alert_map[identity]["releases"].append(release) base = package_group[0] base["releases"] = sorted(releases) base["alerts"] = list(alert_map.values()) - + # Use inputPurl if available and complete, otherwise construct proper purl with namespace if "inputPurl" in base and "@" in base["inputPurl"]: # inputPurl has version, use it as-is base["purl"] = base["inputPurl"] else: # Construct purl properly with namespace and version - purl_type = base.get('type', 'unknown') - namespace = base.get('namespace') - name = base.get('name', 'unknown') - version = base.get('version', '0.0.0') - + purl_type = base.get("type", "unknown") + namespace = base.get("namespace") + name = base.get("name", "unknown") + version = base.get("version", "0.0.0") + # Start with inputPurl if available (without version) or construct from scratch - if "inputPurl" in base and not "@" in base["inputPurl"]: + if "inputPurl" in base and "@" not in base["inputPurl"]: # inputPurl exists but lacks version, append it base["purl"] = f"{base['inputPurl']}@{version}" + # Construct complete purl from components + elif namespace: + base["purl"] = f"pkg:{purl_type}/{namespace}/{name}@{version}" else: - # Construct complete purl from components - if namespace: - base["purl"] = f"pkg:{purl_type}/{namespace}/{name}@{version}" - else: - base["purl"] = f"pkg:{purl_type}/{name}@{version}" - + base["purl"] = f"pkg:{purl_type}/{name}@{version}" + return base @staticmethod - def dedupe(packages: List[Dict[str, Any]], batched: bool = True) -> List[Dict[str, Any]]: + def dedupe(packages: list[dict[str, Any]], batched: bool = True) -> list[dict[str, Any]]: # Always group by inputPurl now, but keep the batched parameter for backward compatibility grouped = Dedupe.consolidate_by_input_purl(packages) results = [] @@ -111,10 +108,10 @@ def dedupe(packages: List[Dict[str, Any]], batched: bool = True) -> List[Dict[st return results @staticmethod - def consolidate_by_input_purl(packages: List[Dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + def consolidate_by_input_purl(packages: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: """Group packages by their inputPurl field""" - grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list) - + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + # Handle both list of packages and nested structure if packages and isinstance(packages[0], list): # If we get a nested list, flatten it @@ -125,9 +122,9 @@ def consolidate_by_input_purl(packages: List[Dict[str, Any]]) -> dict[str, list[ else: flat_packages.append(sublist) packages = flat_packages - + for pkg in packages: # inputPurl should always exist now, fallback to purl if not found group_key = pkg.get("inputPurl", pkg.get("purl", str(hash(str(pkg))))) grouped[group_key].append(pkg) - return grouped \ No newline at end of file + return grouped diff --git a/socketdev/core/enums.py b/socketdev/core/enums.py index a2b7d85..03f42c0 100644 --- a/socketdev/core/enums.py +++ b/socketdev/core/enums.py @@ -26,8 +26,7 @@ def unknown_enum_value(enum_name: str, value: object, fallback): turns the would-be ``ValueError`` into a usable member. """ log.warning( - "Unknown %s %r; falling back to %s. " - "Upgrade socketdev to pick up newer values.", + "Unknown %s %r; falling back to %s. Upgrade socketdev to pick up newer values.", enum_name, value, fallback.name, diff --git a/socketdev/core/issues.py b/socketdev/core/issues.py index 027ad98..2a9564c 100644 --- a/socketdev/core/issues.py +++ b/socketdev/core/issues.py @@ -1,2101 +1,2233 @@ import json - __all__ = [ - "AllIssues", - "badEncoding", - "badSemver", - "badSemverDependency", - "bidi", - "binScriptConfusion", - "chronoAnomaly", - "criticalCVE", - "cve", - "debugAccess", - "deprecated", - "deprecatedException", - "explicitlyUnlicensedItem", - "unidentifiedLicense", - "noLicenseFound", - "copyleftLicense", - "nonpermissiveLicense", - "miscLicenseIssues", - "deprecatedLicense", - "didYouMean", - "dynamicRequire", - "emptyPackage", - "envVars", - "extraneousDependency", - "fileDependency", - "filesystemAccess", - "gitDependency", - "gitHubDependency", - "hasNativeCode", - "highEntropyStrings", - "homoglyphs", - "httpDependency", - "installScripts", - "gptSecurity", - "gptAnomaly", - "gptMalware", - "potentialVulnerability", - "invalidPackageJSON", - "invisibleChars", - "licenseChange", - "licenseException", - "longStrings", - "missingTarball", - "majorRefactor", - "malware", - "manifestConfusion", - "mediumCVE", - "mildCVE", - "minifiedFile", - "missingAuthor", - "missingDependency", - "missingLicense", - "mixedLicense", - "ambiguousClassifier", - "modifiedException", - "modifiedLicense", - "networkAccess", - "newAuthor", - "noAuthorData", - "noBugTracker", - "noREADME", - "noRepository", - "noTests", - "noV1", - "noWebsite", - "nonFSFLicense", - "nonOSILicense", - "nonSPDXLicense", - "notice", - "obfuscatedFile", - "obfuscatedRequire", - "peerDependency", - "semverAnomaly", - "shellAccess", - "shellScriptOverride", - "suspiciousString", - "telemetry", - "trivialPackage", - "troll", - "typeModuleCompatibility", - "uncaughtOptionalDependency", - "unclearLicense", - "shrinkwrap", - "unmaintained", - "unpublished", - "unresolvedRequire", - "unsafeCopyright", - "unstableOwnership", - "unusedDependency", - "urlStrings", - "usesEval", - "zeroWidth", - "floatingDependency", - "unpopularPackage", + "AllIssues", + "ambiguousClassifier", + "badEncoding", + "badSemver", + "badSemverDependency", + "bidi", + "binScriptConfusion", + "chronoAnomaly", + "copyleftLicense", + "criticalCVE", + "cve", + "debugAccess", + "deprecated", + "deprecatedException", + "deprecatedLicense", + "didYouMean", + "dynamicRequire", + "emptyPackage", + "envVars", + "explicitlyUnlicensedItem", + "extraneousDependency", + "fileDependency", + "filesystemAccess", + "floatingDependency", + "gitDependency", + "gitHubDependency", + "gptAnomaly", + "gptMalware", + "gptSecurity", + "hasNativeCode", + "highEntropyStrings", + "homoglyphs", + "httpDependency", + "installScripts", + "invalidPackageJSON", + "invisibleChars", + "licenseChange", + "licenseException", + "longStrings", + "majorRefactor", + "malware", + "manifestConfusion", + "mediumCVE", + "mildCVE", + "minifiedFile", + "miscLicenseIssues", + "missingAuthor", + "missingDependency", + "missingLicense", + "missingTarball", + "mixedLicense", + "modifiedException", + "modifiedLicense", + "networkAccess", + "newAuthor", + "noAuthorData", + "noBugTracker", + "noLicenseFound", + "noREADME", + "noRepository", + "noTests", + "noV1", + "noWebsite", + "nonFSFLicense", + "nonOSILicense", + "nonSPDXLicense", + "nonpermissiveLicense", + "notice", + "obfuscatedFile", + "obfuscatedRequire", + "peerDependency", + "potentialVulnerability", + "semverAnomaly", + "shellAccess", + "shellScriptOverride", + "shrinkwrap", + "suspiciousString", + "telemetry", + "trivialPackage", + "troll", + "typeModuleCompatibility", + "uncaughtOptionalDependency", + "unclearLicense", + "unidentifiedLicense", + "unmaintained", + "unpopularPackage", + "unpublished", + "unresolvedRequire", + "unsafeCopyright", + "unstableOwnership", + "unusedDependency", + "urlStrings", + "usesEval", + "zeroWidth", ] + + class badEncoding: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Source files are encoded using a non-standard text encoding." - self.props = {"encoding": "Encoding"} - self.suggestion = "Ensure all published files are encoded using a standard encoding such as UTF8, UTF16, UTF32, SHIFT-JIS, etc." - self.title = "Bad text encoding" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is bad text encoding?" + def __init__(self): + self.description = "Source files are encoded using a non-standard text encoding." + self.props = {"encoding": "Encoding"} + self.suggestion = "Ensure all published files are encoded using a standard encoding such as UTF8, UTF16, UTF32, SHIFT-JIS, etc." + self.title = "Bad text encoding" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is bad text encoding?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class badSemver: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package version is not a valid semantic version (semver)." - self.suggestion = "All versions of all packages on npm should use use a valid semantic version. Publish a new version of the package with a valid semantic version. Semantic version ranges do not work with invalid semantic versions." - self.title = "Bad semver" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is bad semver?" + def __init__(self): + self.description = "Package version is not a valid semantic version (semver)." + self.suggestion = "All versions of all packages on npm should use use a valid semantic version. Publish a new version of the package with a valid semantic version. Semantic version ranges do not work with invalid semantic versions." + self.title = "Bad semver" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is bad semver?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class badSemverDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package has dependencies with an invalid semantic version. This could be a sign of beta, low quality, or unmaintained dependencies." - self.props = {"packageName": "Package name", "packageVersion": "Package version"} - self.suggestion = "Switch to a version of the dependency with valid semver or override the dependency version if it is determined to be problematic." - self.title = "Bad dependency semver" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is bad dependency semver?" + def __init__(self): + self.description = "Package has dependencies with an invalid semantic version. This could be a sign of beta, low quality, or unmaintained dependencies." + self.props = {"packageName": "Package name", "packageVersion": "Package version"} + self.suggestion = "Switch to a version of the dependency with valid semver or override the dependency version if it is determined to be problematic." + self.title = "Bad dependency semver" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is bad dependency semver?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class bidi: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Source files contain bidirectional unicode control characters. This could indicate a Trojan source supply chain attack. See: trojansource.codes for more information." - self.suggestion = "Remove bidirectional unicode control characters, or clearly document what they are used for." - self.title = "Bidirectional unicode control characters" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are bidirectional unicode control characters?" + def __init__(self): + self.description = "Source files contain bidirectional unicode control characters. This could indicate a Trojan source supply chain attack. See: trojansource.codes for more information." + self.suggestion = "Remove bidirectional unicode control characters, or clearly document what they are used for." + self.title = "Bidirectional unicode control characters" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are bidirectional unicode control characters?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class binScriptConfusion: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package has multiple bin scripts with the same name. This can cause non-deterministic behavior when installing or could be a sign of a supply chain attack" - self.props = {"binScript": "Bin script"} - self.suggestion = "Consider removing one of the conflicting packages. Packages should only export bin scripts with their name" - self.title = "Bin script confusion" - self.emoji = "\ud83d\ude35\u200d\ud83d\udcab" - self.nextStepTitle = "What is bin script confusion?" + def __init__(self): + self.description = "This package has multiple bin scripts with the same name. This can cause non-deterministic behavior when installing or could be a sign of a supply chain attack" + self.props = {"binScript": "Bin script"} + self.suggestion = "Consider removing one of the conflicting packages. Packages should only export bin scripts with their name" + self.title = "Bin script confusion" + self.emoji = "\ud83d\ude35\u200d\ud83d\udcab" + self.nextStepTitle = "What is bin script confusion?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class chronoAnomaly: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Semantic versions published out of chronological order." - self.props = {"prevChronoDate": "Previous chronological date", "prevChronoVersion": "Previous chronological version", "prevSemverDate": "Previous semver date", "prevSemverVersion": "Previous semver version"} - self.suggestion = "This could either indicate dependency confusion or a patched vulnerability." - self.title = "Chronological version anomaly" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a chronological version anomaly?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Semantic versions published out of chronological order." + self.props = { + "prevChronoDate": "Previous chronological date", + "prevChronoVersion": "Previous chronological version", + "prevSemverDate": "Previous semver date", + "prevSemverVersion": "Previous semver version", + } + self.suggestion = "This could either indicate dependency confusion or a patched vulnerability." + self.title = "Chronological version anomaly" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a chronological version anomaly?" + + def __str__(self): + return json.dumps(self.__dict__) class criticalCVE: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Contains a Critical Common Vulnerability and Exposure (CVE)." - self.props = {"cveId": "CVE ID", "cwes": "CWEs", "cvss": "CVSS", "description": "Description", "firstPatchedVersionIdentifier": "Patched version", "ghsaId": "GHSA ID", "id": "Id", "severity": "Severity", "title": "Title", "url": "URL", "vulnerableVersionRange": "Vulnerable versions"} - self.suggestion = "Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." - self.title = "Critical CVE" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a critical CVE?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Contains a Critical Common Vulnerability and Exposure (CVE)." + self.props = { + "cveId": "CVE ID", + "cwes": "CWEs", + "cvss": "CVSS", + "description": "Description", + "firstPatchedVersionIdentifier": "Patched version", + "ghsaId": "GHSA ID", + "id": "Id", + "severity": "Severity", + "title": "Title", + "url": "URL", + "vulnerableVersionRange": "Vulnerable versions", + } + self.suggestion = "Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." + self.title = "Critical CVE" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a critical CVE?" + + def __str__(self): + return json.dumps(self.__dict__) class cve: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Contains a high severity Common Vulnerability and Exposure (CVE)." - self.props = {"cveId": "CVE ID", "cwes": "CWEs", "cvss": "CVSS", "description": "Description", "firstPatchedVersionIdentifier": "Patched version", "ghsaId": "GHSA ID", "id": "Id", "severity": "Severity", "title": "Title", "url": "URL", "vulnerableVersionRange": "Vulnerable versions"} - self.suggestion = "Remove or replace dependencies that include known high severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." - self.title = "High CVE" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a CVE?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Contains a high severity Common Vulnerability and Exposure (CVE)." + self.props = { + "cveId": "CVE ID", + "cwes": "CWEs", + "cvss": "CVSS", + "description": "Description", + "firstPatchedVersionIdentifier": "Patched version", + "ghsaId": "GHSA ID", + "id": "Id", + "severity": "Severity", + "title": "Title", + "url": "URL", + "vulnerableVersionRange": "Vulnerable versions", + } + self.suggestion = "Remove or replace dependencies that include known high severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." + self.title = "High CVE" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a CVE?" + + def __str__(self): + return json.dumps(self.__dict__) class debugAccess: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Uses debug, reflection and dynamic code execution features." - self.props = {"module": "Module"} - self.suggestion = "Removing the use of debug will reduce the risk of any reflection and dynamic code execution." - self.title = "Debug access" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is debug access?" + def __init__(self): + self.description = "Uses debug, reflection and dynamic code execution features." + self.props = {"module": "Module"} + self.suggestion = "Removing the use of debug will reduce the risk of any reflection and dynamic code execution." + self.title = "Debug access" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is debug access?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class deprecated: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "The maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed." - self.props = {"reason": "Reason"} - self.suggestion = "Research the state of the package and determine if there are non-deprecated versions that can be used, or if it should be replaced with a new, supported solution." - self.title = "Deprecated" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a deprecated package?" + def __init__(self): + self.description = "The maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed." + self.props = {"reason": "Reason"} + self.suggestion = "Research the state of the package and determine if there are non-deprecated versions that can be used, or if it should be replaced with a new, supported solution." + self.title = "Deprecated" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a deprecated package?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class deprecatedException: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Contains a known deprecated SPDX license exception." - self.props = {"comments": "Comments", "exceptionId": "Exception id"} - self.suggestion = "Fix the license so that it no longer contains deprecated SPDX license exceptions." - self.title = "Deprecated SPDX exception" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a deprecated SPDX exception?" + def __init__(self): + self.description = "(Experimental) Contains a known deprecated SPDX license exception." + self.props = {"comments": "Comments", "exceptionId": "Exception id"} + self.suggestion = "Fix the license so that it no longer contains deprecated SPDX license exceptions." + self.title = "Deprecated SPDX exception" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a deprecated SPDX exception?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class explicitlyUnlicensedItem: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Something was found which is explicitly marked as unlicensed" - self.props = {"location": "Location"} - self.suggestion = "Manually review your policy on such materials" - self.title = "Explicitly Unlicensed Item" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What do I need to know about license files?" + def __init__(self): + self.description = "(Experimental) Something was found which is explicitly marked as unlicensed" + self.props = {"location": "Location"} + self.suggestion = "Manually review your policy on such materials" + self.title = "Explicitly Unlicensed Item" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What do I need to know about license files?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unidentifiedLicense: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Something that seems like a license was found, but its contents could not be matched with a known license" - self.props = {"comments": "Comments", "exceptionId": "Exception id", "location": "Location"} - self.suggestion = "Manually review the license contents." - self.title = "Unidentified License" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What do I need to know about license files?" + def __init__(self): + self.description = "(Experimental) Something that seems like a license was found, but its contents could not be matched with a known license" + self.props = {"comments": "Comments", "exceptionId": "Exception id", "location": "Location"} + self.suggestion = "Manually review the license contents." + self.title = "Unidentified License" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What do I need to know about license files?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noLicenseFound: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) License information could not be found" - self.props = {"comments": "Comments", "exceptionId": "Exception id"} - self.suggestion = "Manually review the licensing" - self.title = "No License Found" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What do I need to know about license files?" + def __init__(self): + self.description = "(Experimental) License information could not be found" + self.props = {"comments": "Comments", "exceptionId": "Exception id"} + self.suggestion = "Manually review the licensing" + self.title = "No License Found" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What do I need to know about license files?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class copyleftLicense: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Copyleft license information was found" - self.props = {"comments": "Comments", "licenseId": "License Identifiers"} - self.suggestion = "Determine whether use of copyleft material works for you" - self.title = "Copyleft License" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What do I need to know about license files?" + def __init__(self): + self.description = "(Experimental) Copyleft license information was found" + self.props = {"comments": "Comments", "licenseId": "License Identifiers"} + self.suggestion = "Determine whether use of copyleft material works for you" + self.title = "Copyleft License" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What do I need to know about license files?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class nonpermissiveLicense: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) A license not known to be considered permissive was found" - self.props = {"comments": "Comments", "licenseId": "License Identifier"} - self.suggestion = "Determine whether use of material not offered under a known permissive license works for you" - self.title = "Non-permissive License" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What do I need to know about license files?" + def __init__(self): + self.description = "(Experimental) A license not known to be considered permissive was found" + self.props = {"comments": "Comments", "licenseId": "License Identifier"} + self.suggestion = "Determine whether use of material not offered under a known permissive license works for you" + self.title = "Non-permissive License" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What do I need to know about license files?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class miscLicenseIssues: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) A package's licensing information has fine-grained problems" - self.props = {"description": "Description", "location": "The location where the issue originates from"} - self.suggestion = "Determine whether use of material not offered under a known permissive license works for you" - self.title = "Nonpermissive License" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What do I need to know about license files?" + def __init__(self): + self.description = "(Experimental) A package's licensing information has fine-grained problems" + self.props = {"description": "Description", "location": "The location where the issue originates from"} + self.suggestion = "Determine whether use of material not offered under a known permissive license works for you" + self.title = "Nonpermissive License" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What do I need to know about license files?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class deprecatedLicense: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "(Experimental) License is deprecated which may have legal implications regarding the package's use." - self.props = {"licenseId": "License id"} - self.suggestion = "Update or change the license to a well-known or updated license." - self.title = "Deprecated license" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a deprecated license?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "(Experimental) License is deprecated which may have legal implications regarding the package's use." + ) + self.props = {"licenseId": "License id"} + self.suggestion = "Update or change the license to a well-known or updated license." + self.title = "Deprecated license" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a deprecated license?" + + def __str__(self): + return json.dumps(self.__dict__) class didYouMean: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package name is similar to other popular packages and may not be the package you want." - self.props = {"alternatePackage": "Alternate package", "detectedAt": "Detected at"} - self.suggestion = "Use care when consuming similarly named packages and ensure that you did not intend to consume a different package. Malicious packages often publish using similar names as existing popular packages." - self.title = "Possible typosquat attack" - self.emoji = "\ud83e\uddd0" - self.nextStepTitle = "What is a typosquat?" + def __init__(self): + self.description = "Package name is similar to other popular packages and may not be the package you want." + self.props = {"alternatePackage": "Alternate package", "detectedAt": "Detected at"} + self.suggestion = "Use care when consuming similarly named packages and ensure that you did not intend to consume a different package. Malicious packages often publish using similar names as existing popular packages." + self.title = "Possible typosquat attack" + self.emoji = "\ud83e\uddd0" + self.nextStepTitle = "What is a typosquat?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class dynamicRequire: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Dynamic require can indicate the package is performing dangerous or unsafe dynamic code execution." - self.suggestion = "Packages should avoid dynamic imports when possible. Audit the use of dynamic require to ensure it is not executing malicious or vulnerable code." - self.title = "Dynamic require" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is dynamic require?" + def __init__(self): + self.description = ( + "Dynamic require can indicate the package is performing dangerous or unsafe dynamic code execution." + ) + self.suggestion = "Packages should avoid dynamic imports when possible. Audit the use of dynamic require to ensure it is not executing malicious or vulnerable code." + self.title = "Dynamic require" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is dynamic require?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class emptyPackage: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package does not contain any code. It may be removed, is name squatting, or the result of a faulty package publish." - self.props = {"linesOfCode": "Lines of code"} - self.suggestion = "Remove dependencies that do not export any code or functionality and ensure the package version includes all of the files it is supposed to." - self.title = "Empty package" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is an empty package?" + def __init__(self): + self.description = "Package does not contain any code. It may be removed, is name squatting, or the result of a faulty package publish." + self.props = {"linesOfCode": "Lines of code"} + self.suggestion = "Remove dependencies that do not export any code or functionality and ensure the package version includes all of the files it is supposed to." + self.title = "Empty package" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is an empty package?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class envVars: - description: str - props: dict - suggestion: str - title: str - emoji: str - capabilityName: str - nextStepTitle: str - - def __init__(self): - self.description = "Package accesses environment variables, which may be a sign of credential stuffing or data theft." - self.props = {"envVars": "Environment variables"} - self.suggestion = "Packages should be clear about which environment variables they access, and care should be taken to ensure they only access environment variables they claim to." - self.title = "Environment variable access" - self.emoji = "\u26a0\ufe0f" - self.capabilityName = "environment" - self.nextStepTitle = "What is environment variable access?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + capabilityName: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "Package accesses environment variables, which may be a sign of credential stuffing or data theft." + ) + self.props = {"envVars": "Environment variables"} + self.suggestion = "Packages should be clear about which environment variables they access, and care should be taken to ensure they only access environment variables they claim to." + self.title = "Environment variable access" + self.emoji = "\u26a0\ufe0f" + self.capabilityName = "environment" + self.nextStepTitle = "What is environment variable access?" + + def __str__(self): + return json.dumps(self.__dict__) class extraneousDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package optionally loads a dependency which is not specified within any of the package.json dependency fields. It may inadvertently be importing dependencies specified by other packages." - self.props = {"name": "Name"} - self.suggestion = "Specify all optionally loaded dependencies in optionalDependencies within package.json." - self.title = "Extraneous dependency" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are extraneous dependencies?" + def __init__(self): + self.description = "Package optionally loads a dependency which is not specified within any of the package.json dependency fields. It may inadvertently be importing dependencies specified by other packages." + self.props = {"name": "Name"} + self.suggestion = "Specify all optionally loaded dependencies in optionalDependencies within package.json." + self.title = "Extraneous dependency" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are extraneous dependencies?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class fileDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Contains a dependency which resolves to a file. This can obfuscate analysis and serves no useful purpose." - self.props = {"filePath": "File path", "packageName": "Package name"} - self.suggestion = "Remove the dependency specified by a file resolution string from package.json and update any bare name imports that referenced it before to use relative path strings." - self.title = "File dependency" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are file dependencies?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "Contains a dependency which resolves to a file. This can obfuscate analysis and serves no useful purpose." + ) + self.props = {"filePath": "File path", "packageName": "Package name"} + self.suggestion = "Remove the dependency specified by a file resolution string from package.json and update any bare name imports that referenced it before to use relative path strings." + self.title = "File dependency" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are file dependencies?" + + def __str__(self): + return json.dumps(self.__dict__) class filesystemAccess: - description: str - props: dict - suggestion: str - title: str - emoji: str - capabilityName: str - nextStepTitle: str - - def __init__(self): - self.description = "Accesses the file system, and could potentially read sensitive data." - self.props = {"module": "Module"} - self.suggestion = "If a package must read the file system, clarify what it will read and ensure it reads only what it claims to. If appropriate, packages can leave file system access to consumers and operate on data passed to it instead." - self.title = "Filesystem access" - self.emoji = "\u26a0\ufe0f" - self.capabilityName = "filesystem" - self.nextStepTitle = "What is filesystem access?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + capabilityName: str + nextStepTitle: str + + def __init__(self): + self.description = "Accesses the file system, and could potentially read sensitive data." + self.props = {"module": "Module"} + self.suggestion = "If a package must read the file system, clarify what it will read and ensure it reads only what it claims to. If appropriate, packages can leave file system access to consumers and operate on data passed to it instead." + self.title = "Filesystem access" + self.emoji = "\u26a0\ufe0f" + self.capabilityName = "filesystem" + self.nextStepTitle = "What is filesystem access?" + + def __str__(self): + return json.dumps(self.__dict__) class gitDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Contains a dependency which resolves to a remote git URL. Dependencies fetched from git URLs are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install." - self.props = {"packageName": "Package name", "url": "URL"} - self.suggestion = "Publish the git dependency to npm or a private package repository and consume it from there." - self.title = "Git dependency" - self.emoji = "\ud83c\udf63" - self.nextStepTitle = "What are git dependencies?" + def __init__(self): + self.description = "Contains a dependency which resolves to a remote git URL. Dependencies fetched from git URLs are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install." + self.props = {"packageName": "Package name", "url": "URL"} + self.suggestion = "Publish the git dependency to npm or a private package repository and consume it from there." + self.title = "Git dependency" + self.emoji = "\ud83c\udf63" + self.nextStepTitle = "What are git dependencies?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class gitHubDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Contains a dependency which resolves to a GitHub URL. Dependencies fetched from GitHub specifiers are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install." - self.props = {"commitsh": "Commit-ish (commit, branch, tag or version)", "githubRepo": "GitHub repo", "githubUser": "GitHub user", "packageName": "Package name"} - self.suggestion = "Publish the GitHub dependency to npm or a private package repository and consume it from there." - self.title = "GitHub dependency" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are GitHub dependencies?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Contains a dependency which resolves to a GitHub URL. Dependencies fetched from GitHub specifiers are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install." + self.props = { + "commitsh": "Commit-ish (commit, branch, tag or version)", + "githubRepo": "GitHub repo", + "githubUser": "GitHub user", + "packageName": "Package name", + } + self.suggestion = ( + "Publish the GitHub dependency to npm or a private package repository and consume it from there." + ) + self.title = "GitHub dependency" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are GitHub dependencies?" + + def __str__(self): + return json.dumps(self.__dict__) class hasNativeCode: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Contains native code which could be a vector to obscure malicious code, and generally decrease the likelihood of reproducible or reliable installs." - self.suggestion = "Ensure that native code bindings are expected. Consumers may consider pure JS and functionally similar alternatives to avoid the challenges and risks associated with native code bindings." - self.title = "Native code" - self.emoji = "\ud83e\udee3" - self.nextStepTitle = "What's wrong with native code?" + def __init__(self): + self.description = "Contains native code which could be a vector to obscure malicious code, and generally decrease the likelihood of reproducible or reliable installs." + self.suggestion = "Ensure that native code bindings are expected. Consumers may consider pure JS and functionally similar alternatives to avoid the challenges and risks associated with native code bindings." + self.title = "Native code" + self.emoji = "\ud83e\udee3" + self.nextStepTitle = "What's wrong with native code?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class highEntropyStrings: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Contains high entropy strings. This could be a sign of encrypted data, leaked secrets or obfuscated code." - self.suggestion = "Please inspect these strings to check if these strings are benign. Maintainers should clarify the purpose and existence of high entropy strings if there is a legitimate purpose." - self.title = "High entropy strings" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are high entropy strings?" + def __init__(self): + self.description = ( + "Contains high entropy strings. This could be a sign of encrypted data, leaked secrets or obfuscated code." + ) + self.suggestion = "Please inspect these strings to check if these strings are benign. Maintainers should clarify the purpose and existence of high entropy strings if there is a legitimate purpose." + self.title = "High entropy strings" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are high entropy strings?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class homoglyphs: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Contains unicode homoglyphs which can be used in supply chain confusion attacks." - self.suggestion = "Remove unicode homoglyphs if they are unnecessary, and audit their presence to confirm legitimate use." - self.title = "Unicode homoglyphs" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are unicode homoglyphs?" + def __init__(self): + self.description = "Contains unicode homoglyphs which can be used in supply chain confusion attacks." + self.suggestion = ( + "Remove unicode homoglyphs if they are unnecessary, and audit their presence to confirm legitimate use." + ) + self.title = "Unicode homoglyphs" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are unicode homoglyphs?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class httpDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Contains a dependency which resolves to a remote HTTP URL which could be used to inject untrusted code and reduce overall package reliability." - self.props = {"packageName": "Package name", "url": "URL"} - self.suggestion = "Publish the HTTP URL dependency to npm or a private package repository and consume it from there." - self.title = "HTTP dependency" - self.emoji = "\ud83e\udd69" - self.nextStepTitle = "What are http dependencies?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Contains a dependency which resolves to a remote HTTP URL which could be used to inject untrusted code and reduce overall package reliability." + self.props = {"packageName": "Package name", "url": "URL"} + self.suggestion = ( + "Publish the HTTP URL dependency to npm or a private package repository and consume it from there." + ) + self.title = "HTTP dependency" + self.emoji = "\ud83e\udd69" + self.nextStepTitle = "What are http dependencies?" + + def __str__(self): + return json.dumps(self.__dict__) class installScripts: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Install scripts are run when the package is installed. The majority of malware in npm is hidden in install scripts." - self.props = {"script": "Script", "source": "Source"} - self.suggestion = "Packages should not be running non-essential scripts during install and there are often solutions to problems people solve with install scripts that can be run at publish time instead." - self.title = "Install scripts" - self.emoji = "\ud83d\udcdc" - self.nextStepTitle = "What is an install script?" + def __init__(self): + self.description = "Install scripts are run when the package is installed. The majority of malware in npm is hidden in install scripts." + self.props = {"script": "Script", "source": "Source"} + self.suggestion = "Packages should not be running non-essential scripts during install and there are often solutions to problems people solve with install scripts that can be run at publish time instead." + self.title = "Install scripts" + self.emoji = "\ud83d\udcdc" + self.nextStepTitle = "What is an install script?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class gptSecurity: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "AI has determined that this package may contain potential security issues or vulnerabilities." - self.props = {"notes": "AI-based analysis of the package's code and behavior", "confidence": "Confidence of this analysis", "severity": "Impact of this threat"} - self.suggestion = "An AI system identified potential security problems in this package. It is advised to review the package thoroughly and assess the potential risks before installation. You may also consider reporting the issue to the package maintainer or seeking alternative solutions with a stronger security posture." - self.title = "AI detected security risk" - self.emoji = "\ud83e\udd16" - self.nextStepTitle = "What are AI detected security risks?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "AI has determined that this package may contain potential security issues or vulnerabilities." + ) + self.props = { + "notes": "AI-based analysis of the package's code and behavior", + "confidence": "Confidence of this analysis", + "severity": "Impact of this threat", + } + self.suggestion = "An AI system identified potential security problems in this package. It is advised to review the package thoroughly and assess the potential risks before installation. You may also consider reporting the issue to the package maintainer or seeking alternative solutions with a stronger security posture." + self.title = "AI detected security risk" + self.emoji = "\ud83e\udd16" + self.nextStepTitle = "What are AI detected security risks?" + + def __str__(self): + return json.dumps(self.__dict__) class gptAnomaly: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "AI has identified unusual behaviors that may pose a security risk." - self.props = {"notes": "AI-based analysis of the package's code and behavior", "confidence": "Confidence of this analysis", "severity": "Impact of this threat", "risk": "Risk level"} - self.suggestion = "An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding." - self.title = "AI detected anomaly" - self.emoji = "\ud83e\udd14" - self.nextStepTitle = "What is an AI detected anomaly?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "AI has identified unusual behaviors that may pose a security risk." + self.props = { + "notes": "AI-based analysis of the package's code and behavior", + "confidence": "Confidence of this analysis", + "severity": "Impact of this threat", + "risk": "Risk level", + } + self.suggestion = "An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding." + self.title = "AI detected anomaly" + self.emoji = "\ud83e\udd14" + self.nextStepTitle = "What is an AI detected anomaly?" + + def __str__(self): + return json.dumps(self.__dict__) class gptMalware: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "AI has identified this package as malware. This is a strong signal that the package may be malicious." - self.props = {"notes": "AI-based analysis of the package's code and behavior", "confidence": "Confidence of this analysis", "severity": "Impact of this behavior"} - self.suggestion = "Given the AI system's identification of this package as malware, extreme caution is advised. It is recommended to avoid downloading or installing this package until the threat is confirmed or flagged as a false positive." - self.title = "AI detected potential malware" - self.emoji = "\ud83e\udd16" - self.nextStepTitle = "What is AI detected malware?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "AI has identified this package as malware. This is a strong signal that the package may be malicious." + ) + self.props = { + "notes": "AI-based analysis of the package's code and behavior", + "confidence": "Confidence of this analysis", + "severity": "Impact of this behavior", + } + self.suggestion = "Given the AI system's identification of this package as malware, extreme caution is advised. It is recommended to avoid downloading or installing this package until the threat is confirmed or flagged as a false positive." + self.title = "AI detected potential malware" + self.emoji = "\ud83e\udd16" + self.nextStepTitle = "What is AI detected malware?" + + def __str__(self): + return json.dumps(self.__dict__) class potentialVulnerability: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Initial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation." - self.props = {"note": "AI detection + human review", "risk": "Risk level"} - self.suggestion = "It is advisable to proceed with caution. Engage in a review of the package's security aspects and consider reaching out to the package maintainer for the latest information or patches." - self.title = "Potential vulnerability" - self.emoji = "\ud83d\udea7" - self.nextStepTitle = "Navigating potential vulnerabilities" + def __init__(self): + self.description = "Initial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation." + self.props = {"note": "AI detection + human review", "risk": "Risk level"} + self.suggestion = "It is advisable to proceed with caution. Engage in a review of the package's security aspects and consider reaching out to the package maintainer for the latest information or patches." + self.title = "Potential vulnerability" + self.emoji = "\ud83d\udea7" + self.nextStepTitle = "Navigating potential vulnerabilities" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class invalidPackageJSON: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package has an invalid manifest file and can cause installation problems if you try to use it." - self.suggestion = "Fix syntax errors in the manifest file and publish a new version. Consumers can use npm overrides to force a version that does not have this problem if one exists." - self.title = "Invalid manifest file" - self.emoji = "\ud83e\udd12" - self.nextStepTitle = "What is an invalid manifest file?" + def __init__(self): + self.description = ( + "Package has an invalid manifest file and can cause installation problems if you try to use it." + ) + self.suggestion = "Fix syntax errors in the manifest file and publish a new version. Consumers can use npm overrides to force a version that does not have this problem if one exists." + self.title = "Invalid manifest file" + self.emoji = "\ud83e\udd12" + self.nextStepTitle = "What is an invalid manifest file?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class invisibleChars: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Source files contain invisible characters. This could indicate source obfuscation or a supply chain attack." - self.suggestion = "Remove invisible characters. If their use is justified, use their visible escaped counterparts." - self.title = "Invisible chars" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are invisible characters?" + def __init__(self): + self.description = "Source files contain invisible characters. This could indicate source obfuscation or a supply chain attack." + self.suggestion = ( + "Remove invisible characters. If their use is justified, use their visible escaped counterparts." + ) + self.title = "Invisible chars" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are invisible characters?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class licenseChange: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package license has recently changed." - self.props = {"newLicenseId": "New license id", "prevLicenseId": "Previous license id"} - self.suggestion = "License changes should be reviewed carefully to inform ongoing use. Packages should avoid making major changes to their license type." - self.title = "License change" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a license change?" + def __init__(self): + self.description = "(Experimental) Package license has recently changed." + self.props = {"newLicenseId": "New license id", "prevLicenseId": "Previous license id"} + self.suggestion = "License changes should be reviewed carefully to inform ongoing use. Packages should avoid making major changes to their license type." + self.title = "License change" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a license change?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class licenseException: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Contains an SPDX license exception." - self.props = {"comments": "Comments", "exceptionId": "Exception id"} - self.suggestion = "License exceptions should be carefully reviewed." - self.title = "License exception" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a license exception?" + def __init__(self): + self.description = "(Experimental) Contains an SPDX license exception." + self.props = {"comments": "Comments", "exceptionId": "Exception id"} + self.suggestion = "License exceptions should be carefully reviewed." + self.title = "License exception" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a license exception?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class longStrings: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Contains long string literals, which may be a sign of obfuscated or packed code." - self.suggestion = "Avoid publishing or consuming obfuscated or bundled code. It makes dependencies difficult to audit and undermines the module resolution system." - self.title = "Long strings" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What's wrong with long strings?" + def __init__(self): + self.description = "Contains long string literals, which may be a sign of obfuscated or packed code." + self.suggestion = "Avoid publishing or consuming obfuscated or bundled code. It makes dependencies difficult to audit and undermines the module resolution system." + self.title = "Long strings" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What's wrong with long strings?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class missingTarball: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package is missing it's tarball. It could be removed from the npm registry or there may have been an error when publishing." - self.suggestion = "This package cannot be analyzed or installed due to missing data." - self.title = "Missing package tarball" - self.emoji = "\u2754" - self.nextStepTitle = "What is a missing tarball?" + def __init__(self): + self.description = "This package is missing it's tarball. It could be removed from the npm registry or there may have been an error when publishing." + self.suggestion = "This package cannot be analyzed or installed due to missing data." + self.title = "Missing package tarball" + self.emoji = "\u2754" + self.nextStepTitle = "What is a missing tarball?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class majorRefactor: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Package has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes." - self.props = {"changedPercent": "Change percentage", "curSize": "Current amount of lines", "linesChanged": "Lines changed", "prevSize": "Previous amount of lines"} - self.suggestion = "Consider waiting before upgrading to see if any issues are discovered, or be prepared to scrutinize any bugs or subtle changes the major refactor may bring. Publishers my consider publishing beta versions of major refactors to limit disruption to parties interested in the new changes." - self.title = "Major refactor" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a major refactor?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Package has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes." + self.props = { + "changedPercent": "Change percentage", + "curSize": "Current amount of lines", + "linesChanged": "Lines changed", + "prevSize": "Previous amount of lines", + } + self.suggestion = "Consider waiting before upgrading to see if any issues are discovered, or be prepared to scrutinize any bugs or subtle changes the major refactor may bring. Publishers my consider publishing beta versions of major refactors to limit disruption to parties interested in the new changes." + self.title = "Major refactor" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a major refactor?" + + def __str__(self): + return json.dumps(self.__dict__) class malware: - description: str - props: dict - title: str - suggestion: str - emoji: str - nextStepTitle: str + description: str + props: dict + title: str + suggestion: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package is malware. We have asked the package registry to remove it." - self.props = {"id": "Id", "note": "Note"} - self.title = "Known malware" - self.suggestion = "It is strongly recommended that malware is removed from your codebase." - self.emoji = "\u2620\ufe0f" - self.nextStepTitle = "What is known malware?" + def __init__(self): + self.description = "This package is malware. We have asked the package registry to remove it." + self.props = {"id": "Id", "note": "Note"} + self.title = "Known malware" + self.suggestion = "It is strongly recommended that malware is removed from your codebase." + self.emoji = "\u2620\ufe0f" + self.nextStepTitle = "What is known malware?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class manifestConfusion: - description: str - props: dict - title: str - suggestion: str - emoji: str - nextStepTitle: str + description: str + props: dict + title: str + suggestion: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package has inconsistent metadata. This could be malicious or caused by an error when publishing the package." - self.props = {"key": "Key", "description": "Description"} - self.title = "Manifest confusion" - self.suggestion = "Packages with inconsistent metadata may be corrupted or malicious." - self.emoji = "\ud83e\udd78" - self.nextStepTitle = "What is manifest confusion?" + def __init__(self): + self.description = "This package has inconsistent metadata. This could be malicious or caused by an error when publishing the package." + self.props = {"key": "Key", "description": "Description"} + self.title = "Manifest confusion" + self.suggestion = "Packages with inconsistent metadata may be corrupted or malicious." + self.emoji = "\ud83e\udd78" + self.nextStepTitle = "What is manifest confusion?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class mediumCVE: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Contains a medium severity Common Vulnerability and Exposure (CVE)." - self.props = {"cveId": "CVE ID", "cwes": "CWEs", "cvss": "CVSS", "description": "Description", "firstPatchedVersionIdentifier": "Patched version", "ghsaId": "GHSA ID", "id": "Id", "severity": "Severity", "title": "Title", "url": "URL", "vulnerableVersionRange": "Vulnerable versions"} - self.suggestion = "Remove or replace dependencies that include known medium severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." - self.title = "Medium CVE" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a medium CVE?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Contains a medium severity Common Vulnerability and Exposure (CVE)." + self.props = { + "cveId": "CVE ID", + "cwes": "CWEs", + "cvss": "CVSS", + "description": "Description", + "firstPatchedVersionIdentifier": "Patched version", + "ghsaId": "GHSA ID", + "id": "Id", + "severity": "Severity", + "title": "Title", + "url": "URL", + "vulnerableVersionRange": "Vulnerable versions", + } + self.suggestion = "Remove or replace dependencies that include known medium severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." + self.title = "Medium CVE" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a medium CVE?" + + def __str__(self): + return json.dumps(self.__dict__) class mildCVE: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Contains a low severity Common Vulnerability and Exposure (CVE)." - self.props = {"cveId": "CVE ID", "cwes": "CWEs", "cvss": "CVSS", "description": "Description", "firstPatchedVersionIdentifier": "Patched version", "ghsaId": "GHSA ID", "id": "Id", "severity": "Severity", "title": "Title", "url": "URL", "vulnerableVersionRange": "Vulnerable versions"} - self.suggestion = "Remove or replace dependencies that include known low severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." - self.title = "Low CVE" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a mild CVE?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Contains a low severity Common Vulnerability and Exposure (CVE)." + self.props = { + "cveId": "CVE ID", + "cwes": "CWEs", + "cvss": "CVSS", + "description": "Description", + "firstPatchedVersionIdentifier": "Patched version", + "ghsaId": "GHSA ID", + "id": "Id", + "severity": "Severity", + "title": "Title", + "url": "URL", + "vulnerableVersionRange": "Vulnerable versions", + } + self.suggestion = "Remove or replace dependencies that include known low severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies." + self.title = "Low CVE" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a mild CVE?" + + def __str__(self): + return json.dumps(self.__dict__) class minifiedFile: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code." - self.props = {"confidence": "Confidence"} - self.suggestion = "In many cases minified code is harmless, however minified code can be used to hide a supply chain attack. Consider not shipping minified code on npm." - self.title = "Minified code" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What's wrong with minified code?" + def __init__(self): + self.description = "This package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code." + self.props = {"confidence": "Confidence"} + self.suggestion = "In many cases minified code is harmless, however minified code can be used to hide a supply chain attack. Consider not shipping minified code on npm." + self.title = "Minified code" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What's wrong with minified code?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class missingAuthor: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "The package was published by an npm account that no longer exists." - self.suggestion = "Packages should have active and identified authors." - self.title = "Non-existent author" - self.emoji = "\ud83e\udee5" - self.nextStepTitle = "What is a non-existent author?" + def __init__(self): + self.description = "The package was published by an npm account that no longer exists." + self.suggestion = "Packages should have active and identified authors." + self.title = "Non-existent author" + self.emoji = "\ud83e\udee5" + self.nextStepTitle = "What is a non-existent author?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class missingDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "A required dependency is not declared in package.json and may prevent the package from working." - self.props = {"name": "Name"} - self.suggestion = "The package should define the missing dependency inside of package.json and publish a new version. Consumers may have to install the missing dependency themselves as long as the dependency remains missing. If the dependency is optional, add it to optionalDependencies and handle the missing case." - self.title = "Missing dependency" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a missing dependency?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "A required dependency is not declared in package.json and may prevent the package from working." + ) + self.props = {"name": "Name"} + self.suggestion = "The package should define the missing dependency inside of package.json and publish a new version. Consumers may have to install the missing dependency themselves as long as the dependency remains missing. If the dependency is optional, add it to optionalDependencies and handle the missing case." + self.title = "Missing dependency" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a missing dependency?" + + def __str__(self): + return json.dumps(self.__dict__) class missingLicense: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package does not have a license and consumption legal status is unknown." - self.suggestion = "A new version of the package should be published that includes a valid SPDX license in a license file, package.json license field or mentioned in the README." - self.title = "Missing license" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a missing license?" + def __init__(self): + self.description = "(Experimental) Package does not have a license and consumption legal status is unknown." + self.suggestion = "A new version of the package should be published that includes a valid SPDX license in a license file, package.json license field or mentioned in the README." + self.title = "Missing license" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a missing license?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class mixedLicense: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package contains multiple licenses." - self.props = {"licenseId": "License Ids"} - self.suggestion = "A new version of the package should be published that includes a single license. Consumers may seek clarification from the package author. Ensure that the license details are consistent across the LICENSE file, package.json license field and license details mentioned in the README." - self.title = "Mixed license" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a mixed license?" + def __init__(self): + self.description = "(Experimental) Package contains multiple licenses." + self.props = {"licenseId": "License Ids"} + self.suggestion = "A new version of the package should be published that includes a single license. Consumers may seek clarification from the package author. Ensure that the license details are consistent across the LICENSE file, package.json license field and license details mentioned in the README." + self.title = "Mixed license" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a mixed license?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class ambiguousClassifier: - props: dict - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + props: dict + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.props = {"classifier": "The classifier"} - self.description = "(Experimental) An ambiguous license classifier was found." - self.suggestion = "A specific license or licenses should be identified" - self.title = "Ambiguous License Classifier" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is an ambiguous license classifier?" + def __init__(self): + self.props = {"classifier": "The classifier"} + self.description = "(Experimental) An ambiguous license classifier was found." + self.suggestion = "A specific license or licenses should be identified" + self.title = "Ambiguous License Classifier" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is an ambiguous license classifier?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class modifiedException: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package contains a modified version of an SPDX license exception. Please read carefully before using this code." - self.props = {"comments": "Comments", "exceptionId": "Exception id", "similarity": "Similarity"} - self.suggestion = "Packages should avoid making modifications to standard license exceptions." - self.title = "Modified license exception" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a modified license exception?" + def __init__(self): + self.description = "(Experimental) Package contains a modified version of an SPDX license exception. Please read carefully before using this code." + self.props = {"comments": "Comments", "exceptionId": "Exception id", "similarity": "Similarity"} + self.suggestion = "Packages should avoid making modifications to standard license exceptions." + self.title = "Modified license exception" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a modified license exception?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class modifiedLicense: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package contains a modified version of an SPDX license. Please read carefully before using this code." - self.props = {"licenseId": "License id", "similarity": "Similarity"} - self.suggestion = "Packages should avoid making modifications to standard licenses." - self.title = "Modified license" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a modified license?" + def __init__(self): + self.description = "(Experimental) Package contains a modified version of an SPDX license. Please read carefully before using this code." + self.props = {"licenseId": "License id", "similarity": "Similarity"} + self.suggestion = "Packages should avoid making modifications to standard licenses." + self.title = "Modified license" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a modified license?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class networkAccess: - description: str - props: dict - suggestion: str - title: str - emoji: str - capabilityName: str - nextStepTitle: str - - def __init__(self): - self.description = "This module accesses the network." - self.props = {"module": "Module"} - self.suggestion = "Packages should remove all network access that is functionally unnecessary. Consumers should audit network access to ensure legitimate use." - self.title = "Network access" - self.emoji = "\u26a0\ufe0f" - self.capabilityName = "network" - self.nextStepTitle = "What is network access?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + capabilityName: str + nextStepTitle: str + + def __init__(self): + self.description = "This module accesses the network." + self.props = {"module": "Module"} + self.suggestion = "Packages should remove all network access that is functionally unnecessary. Consumers should audit network access to ensure legitimate use." + self.title = "Network access" + self.emoji = "\u26a0\ufe0f" + self.capabilityName = "network" + self.nextStepTitle = "What is network access?" + + def __str__(self): + return json.dumps(self.__dict__) class newAuthor: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "A new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package." - self.props = {"newAuthor": "New author", "prevAuthor": "Previous author"} - self.suggestion = "Scrutinize new collaborator additions to packages because they now have the ability to publish code into your dependency tree. Packages should avoid frequent or unnecessary additions or changes to publishing rights." - self.title = "New author" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is new author?" + def __init__(self): + self.description = "A new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package." + self.props = {"newAuthor": "New author", "prevAuthor": "Previous author"} + self.suggestion = "Scrutinize new collaborator additions to packages because they now have the ability to publish code into your dependency tree. Packages should avoid frequent or unnecessary additions or changes to publishing rights." + self.title = "New author" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is new author?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noAuthorData: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package does not specify a list of contributors or an author in package.json." - self.suggestion = "Add a author field or contributors array to package.json." - self.title = "No contributors or author data" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "Why is contributor and author data important?" + def __init__(self): + self.description = "Package does not specify a list of contributors or an author in package.json." + self.suggestion = "Add a author field or contributors array to package.json." + self.title = "No contributors or author data" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "Why is contributor and author data important?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noBugTracker: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package does not have a linked bug tracker in package.json." - self.suggestion = "Add a bugs field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#bugs" - self.title = "No bug tracker" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "Why are bug trackers important?" + def __init__(self): + self.description = "Package does not have a linked bug tracker in package.json." + self.suggestion = ( + "Add a bugs field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#bugs" + ) + self.title = "No bug tracker" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "Why are bug trackers important?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noREADME: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package does not have a README. This may indicate a failed publish or a low quality package." - self.suggestion = "Add a README to to the package and publish a new version." - self.title = "No README" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "Why are READMEs important?" + def __init__(self): + self.description = ( + "Package does not have a README. This may indicate a failed publish or a low quality package." + ) + self.suggestion = "Add a README to to the package and publish a new version." + self.title = "No README" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "Why are READMEs important?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noRepository: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package." - self.suggestion = "Add a repository field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#repository" - self.title = "No repository" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "Why are missing repositories important?" + def __init__(self): + self.description = "Package does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package." + self.suggestion = "Add a repository field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#repository" + self.title = "No repository" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "Why are missing repositories important?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noTests: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package does not have any tests. This is a strong signal of a poorly maintained or low quality package." - self.suggestion = "Add tests and publish a new version of the package. Consumers may look for an alternative package with better testing." - self.title = "No tests" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What does no tests mean?" + def __init__(self): + self.description = ( + "Package does not have any tests. This is a strong signal of a poorly maintained or low quality package." + ) + self.suggestion = "Add tests and publish a new version of the package. Consumers may look for an alternative package with better testing." + self.title = "No tests" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What does no tests mean?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noV1: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package is not semver >=1. This means it is not stable and does not support ^ ranges." - self.suggestion = "If the package sees any general use, it should begin releasing at version 1.0.0 or later to benefit from semver." - self.title = "No v1" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is wrong with semver < v1?" + def __init__(self): + self.description = "Package is not semver >=1. This means it is not stable and does not support ^ ranges." + self.suggestion = "If the package sees any general use, it should begin releasing at version 1.0.0 or later to benefit from semver." + self.title = "No v1" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is wrong with semver < v1?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class noWebsite: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package does not have a website." - self.suggestion = "Add a homepage field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#homepage" - self.title = "No website" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a missing website?" + def __init__(self): + self.description = "Package does not have a website." + self.suggestion = ( + "Add a homepage field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#homepage" + ) + self.title = "No website" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a missing website?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class nonFSFLicense: - description: str - props: dict - title: str - suggestion: str - emoji: str - nextStepTitle: str + description: str + props: dict + title: str + suggestion: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package has a non-FSF-approved license." - self.props = {"licenseId": "License id"} - self.title = "Non FSF license" - self.suggestion = "Consider the terms of the license for your given use case." - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a non FSF license?" + def __init__(self): + self.description = "(Experimental) Package has a non-FSF-approved license." + self.props = {"licenseId": "License id"} + self.title = "Non FSF license" + self.suggestion = "Consider the terms of the license for your given use case." + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a non FSF license?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class nonOSILicense: - description: str - props: dict - title: str - suggestion: str - emoji: str - nextStepTitle: str + description: str + props: dict + title: str + suggestion: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package has a non-OSI-approved license." - self.props = {"licenseId": "License id"} - self.title = "Non OSI license" - self.suggestion = "Consider the terms of the license for your given use case." - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a non OSI license?" + def __init__(self): + self.description = "(Experimental) Package has a non-OSI-approved license." + self.props = {"licenseId": "License id"} + self.title = "Non OSI license" + self.suggestion = "Consider the terms of the license for your given use case." + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a non OSI license?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class nonSPDXLicense: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package contains a non-standard license somewhere. Please read carefully before using." - self.suggestion = "Package should adopt a standard SPDX license consistently across all license locations (LICENSE files, package.json license fields, and READMEs)." - self.title = "Non SPDX license" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a non SPDX license?" + def __init__(self): + self.description = ( + "(Experimental) Package contains a non-standard license somewhere. Please read carefully before using." + ) + self.suggestion = "Package should adopt a standard SPDX license consistently across all license locations (LICENSE files, package.json license fields, and READMEs)." + self.title = "Non SPDX license" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a non SPDX license?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class notice: - description: str - title: str - suggestion: str - emoji: str - nextStepTitle: str + description: str + title: str + suggestion: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package contains a legal notice. This could increase your exposure to legal risk when using this project." - self.title = "Legal notice" - self.suggestion = "Consider the implications of the legal notice for your given use case." - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is a legal notice?" + def __init__(self): + self.description = "(Experimental) Package contains a legal notice. This could increase your exposure to legal risk when using this project." + self.title = "Legal notice" + self.suggestion = "Consider the implications of the legal notice for your given use case." + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is a legal notice?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class obfuscatedFile: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Obfuscated files are intentionally packed to hide their behavior. This could be a sign of malware" - self.props = {"confidence": "Confidence"} - self.suggestion = "Packages should not obfuscate their code. Consider not using packages with obfuscated code" - self.title = "Obfuscated code" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is obfuscated code?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "Obfuscated files are intentionally packed to hide their behavior. This could be a sign of malware" + ) + self.props = {"confidence": "Confidence"} + self.suggestion = "Packages should not obfuscate their code. Consider not using packages with obfuscated code" + self.title = "Obfuscated code" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is obfuscated code?" + + def __str__(self): + return json.dumps(self.__dict__) class obfuscatedRequire: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package accesses dynamic properties of require and may be obfuscating code execution." - self.suggestion = "The package should not access dynamic properties of module. Instead use import or require directly." - self.title = "Obfuscated require" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is obfuscated require?" + def __init__(self): + self.description = "Package accesses dynamic properties of require and may be obfuscating code execution." + self.suggestion = ( + "The package should not access dynamic properties of module. Instead use import or require directly." + ) + self.title = "Obfuscated require" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is obfuscated require?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class peerDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package specifies peer dependencies in package.json." - self.props = {"name": "Name"} - self.suggestion = "Peer dependencies are fragile and can cause major problems across version changes. Be careful when updating this dependency and its peers." - self.title = "Peer dependency" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are peer dependencies?" + def __init__(self): + self.description = "Package specifies peer dependencies in package.json." + self.props = {"name": "Name"} + self.suggestion = "Peer dependencies are fragile and can cause major problems across version changes. Be careful when updating this dependency and its peers." + self.title = "Peer dependency" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are peer dependencies?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class semverAnomaly: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package semver skipped several versions, this could indicate a dependency confusion attack or indicate the intention of disruptive breaking changes or major priority shifts for the project." - self.props = {"newVersion": "New version", "prevVersion": "Previous version"} - self.suggestion = "Packages should follow semantic versions conventions by not skipping subsequent version numbers. Consumers should research the purpose of the skipped version number." - self.title = "Semver anomaly" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are semver anomalies?" + def __init__(self): + self.description = "Package semver skipped several versions, this could indicate a dependency confusion attack or indicate the intention of disruptive breaking changes or major priority shifts for the project." + self.props = {"newVersion": "New version", "prevVersion": "Previous version"} + self.suggestion = "Packages should follow semantic versions conventions by not skipping subsequent version numbers. Consumers should research the purpose of the skipped version number." + self.title = "Semver anomaly" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are semver anomalies?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class shellAccess: - description: str - props: dict - suggestion: str - title: str - emoji: str - capabilityName: str - nextStepTitle: str - - def __init__(self): - self.description = "This module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code." - self.props = {"module": "Module"} - self.suggestion = "Packages should avoid accessing the shell which can reduce portability, and make it easier for malicious shell access to be introduced." - self.title = "Shell access" - self.emoji = "\u26a0\ufe0f" - self.capabilityName = "shell" - self.nextStepTitle = "What is shell access?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + capabilityName: str + nextStepTitle: str + + def __init__(self): + self.description = "This module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code." + self.props = {"module": "Module"} + self.suggestion = "Packages should avoid accessing the shell which can reduce portability, and make it easier for malicious shell access to be introduced." + self.title = "Shell access" + self.emoji = "\u26a0\ufe0f" + self.capabilityName = "shell" + self.nextStepTitle = "What is shell access?" + + def __str__(self): + return json.dumps(self.__dict__) class shellScriptOverride: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package re-exports a well known shell command via an npm bin script. This is possibly a supply chain attack" - self.props = {"binScript": "Bin script"} - self.suggestion = "Packages should not export bin scripts which conflict with well known shell commands" - self.title = "Bin script shell injection" - self.emoji = "\ud83e\udd80" - self.nextStepTitle = "What is bin script shell injection?" + def __init__(self): + self.description = "This package re-exports a well known shell command via an npm bin script. This is possibly a supply chain attack" + self.props = {"binScript": "Bin script"} + self.suggestion = "Packages should not export bin scripts which conflict with well known shell commands" + self.title = "Bin script shell injection" + self.emoji = "\ud83e\udd80" + self.nextStepTitle = "What is bin script shell injection?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class suspiciousString: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "This package contains suspicious text patterns which are commonly associated with bad behavior" - self.props = {"explanation": "Explanation", "pattern": "Pattern"} - self.suggestion = "The package code should be reviewed before installing" - self.title = "Suspicious strings" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are suspicious strings?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "This package contains suspicious text patterns which are commonly associated with bad behavior" + ) + self.props = {"explanation": "Explanation", "pattern": "Pattern"} + self.suggestion = "The package code should be reviewed before installing" + self.title = "Suspicious strings" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are suspicious strings?" + + def __str__(self): + return json.dumps(self.__dict__) class telemetry: - description: str - props: dict - title: str - suggestion: str - emoji: str - nextStepTitle: str + description: str + props: dict + title: str + suggestion: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package contains telemetry which tracks how it is used." - self.props = {"id": "Id", "note": "Note"} - self.title = "Telemetry" - self.suggestion = "Most telemetry comes with settings to disable it. Consider disabling telemetry if you do not want to be tracked." - self.emoji = "\ud83d\udcde" - self.nextStepTitle = "What is telemetry?" + def __init__(self): + self.description = "This package contains telemetry which tracks how it is used." + self.props = {"id": "Id", "note": "Note"} + self.title = "Telemetry" + self.suggestion = "Most telemetry comes with settings to disable it. Consider disabling telemetry if you do not want to be tracked." + self.emoji = "\ud83d\udcde" + self.nextStepTitle = "What is telemetry?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class trivialPackage: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Packages less than 10 lines of code are easily copied into your own project and may not warrant the additional supply chain risk of an external dependency." - self.props = {"linesOfCode": "Lines of code"} - self.suggestion = "Removing this package as a dependency and implementing its logic will reduce supply chain risk." - self.title = "Trivial Package" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are trivial packages?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "Packages less than 10 lines of code are easily copied into your own project and may not warrant the additional supply chain risk of an external dependency." + self.props = {"linesOfCode": "Lines of code"} + self.suggestion = ( + "Removing this package as a dependency and implementing its logic will reduce supply chain risk." + ) + self.title = "Trivial Package" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are trivial packages?" + + def __str__(self): + return json.dumps(self.__dict__) class troll: - description: str - props: dict - title: str - suggestion: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "This package is a joke, parody, or includes undocumented or hidden behavior unrelated to its primary function." - self.props = {"id": "Id", "note": "Note"} - self.title = "Protestware or potentially unwanted behavior" - self.suggestion = "Consider that consuming this package my come along with functionality unrelated to its primary purpose." - self.emoji = "\ud83e\uddcc" - self.nextStepTitle = "What is protestware?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + title: str + suggestion: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = "This package is a joke, parody, or includes undocumented or hidden behavior unrelated to its primary function." + self.props = {"id": "Id", "note": "Note"} + self.title = "Protestware or potentially unwanted behavior" + self.suggestion = ( + "Consider that consuming this package my come along with functionality unrelated to its primary purpose." + ) + self.emoji = "\ud83e\uddcc" + self.nextStepTitle = "What is protestware?" + + def __str__(self): + return json.dumps(self.__dict__) class typeModuleCompatibility: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package is CommonJS, but has a dependency which is type: \"module\". The two are likely incompatible." - self.suggestion = "The package needs to switch to dynamic import on the esmodule dependency, or convert to esm itself. Consumers may experience errors resulting from this incompatibility." - self.title = "CommonJS depending on ESModule" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "Why can't CJS depend on ESM?" + def __init__(self): + self.description = ( + 'Package is CommonJS, but has a dependency which is type: "module". The two are likely incompatible.' + ) + self.suggestion = "The package needs to switch to dynamic import on the esmodule dependency, or convert to esm itself. Consumers may experience errors resulting from this incompatibility." + self.title = "CommonJS depending on ESModule" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "Why can't CJS depend on ESM?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class uncaughtOptionalDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package uses an optional dependency without handling a missing dependency exception. If you install it without the optional dependencies then it could cause runtime errors." - self.props = {"name": "Name"} - self.suggestion = "Package should handle the loading of the dependency when it is not present, or convert the optional dependency into a regular dependency." - self.title = "Uncaught optional dependency" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "Why are uncaught optional dependencies?" + def __init__(self): + self.description = "Package uses an optional dependency without handling a missing dependency exception. If you install it without the optional dependencies then it could cause runtime errors." + self.props = {"name": "Name"} + self.suggestion = "Package should handle the loading of the dependency when it is not present, or convert the optional dependency into a regular dependency." + self.title = "Uncaught optional dependency" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "Why are uncaught optional dependencies?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unclearLicense: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package contains a reference to a license without a matching LICENSE file." - self.props = {"possibleLicenseId": "Possible license id"} - self.suggestion = "Add a LICENSE file that matches the license field in package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#license" - self.title = "Unclear license" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are unclear licenses?" + def __init__(self): + self.description = "Package contains a reference to a license without a matching LICENSE file." + self.props = {"possibleLicenseId": "Possible license id"} + self.suggestion = "Add a LICENSE file that matches the license field in package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#license" + self.title = "Unclear license" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are unclear licenses?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class shrinkwrap: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package contains a shrinkwrap file. This may allow the package to bypass normal install procedures." - self.suggestion = "Packages should never use npm shrinkwrap files due to the dangers they pose." - self.title = "NPM Shrinkwrap" - self.emoji = "\ud83e\uddca" - self.nextStepTitle = "What is a shrinkwrap file?" + def __init__(self): + self.description = ( + "Package contains a shrinkwrap file. This may allow the package to bypass normal install procedures." + ) + self.suggestion = "Packages should never use npm shrinkwrap files due to the dangers they pose." + self.title = "NPM Shrinkwrap" + self.emoji = "\ud83e\uddca" + self.nextStepTitle = "What is a shrinkwrap file?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unmaintained: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package has not been updated in more than 5 years and may be unmaintained. Problems with the package may go unaddressed." - self.props = {"lastPublish": "Last publish"} - self.suggestion = "Package should publish periodic maintenance releases if they are maintained, or deprecate if they have no intention in further maintenance." - self.title = "Unmaintained" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are unmaintained packages?" + def __init__(self): + self.description = "Package has not been updated in more than 5 years and may be unmaintained. Problems with the package may go unaddressed." + self.props = {"lastPublish": "Last publish"} + self.suggestion = "Package should publish periodic maintenance releases if they are maintained, or deprecate if they have no intention in further maintenance." + self.title = "Unmaintained" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are unmaintained packages?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unpublished: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry." - self.props = {"version": "The version that was not found"} - self.suggestion = "Packages can be removed from the registry by manually un-publishing, a security issue removal, or may simply never have been published to the registry. Reliance on these packages will cause problem when they are not found." - self.title = "Unpublished package" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are unpublished packages?" + def __init__(self): + self.description = "Package version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry." + self.props = {"version": "The version that was not found"} + self.suggestion = "Packages can be removed from the registry by manually un-publishing, a security issue removal, or may simply never have been published to the registry. Reliance on these packages will cause problem when they are not found." + self.title = "Unpublished package" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are unpublished packages?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unresolvedRequire: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package imports a file which does not exist and may not work as is. It could also be importing a file that will be created at runtime which could be a vector for running malicious code." - self.suggestion = "Fix imports so that they require declared dependencies or existing files." - self.title = "Unresolved require" - self.emoji = "\ud83d\udd75\ufe0f" - self.nextStepTitle = "What is unresolved require?" + def __init__(self): + self.description = "Package imports a file which does not exist and may not work as is. It could also be importing a file that will be created at runtime which could be a vector for running malicious code." + self.suggestion = "Fix imports so that they require declared dependencies or existing files." + self.title = "Unresolved require" + self.emoji = "\ud83d\udd75\ufe0f" + self.nextStepTitle = "What is unresolved require?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unsafeCopyright: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "(Experimental) Package contains a copyright but no license. Using this package may expose you to legal risk." - self.suggestion = "Clarify the license type by adding a license field to package.json and a LICENSE file." - self.title = "Unsafe copyright" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is unsafe copyright?" + def __init__(self): + self.description = "(Experimental) Package contains a copyright but no license. Using this package may expose you to legal risk." + self.suggestion = "Clarify the license type by adding a license field to package.json and a LICENSE file." + self.title = "Unsafe copyright" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is unsafe copyright?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unstableOwnership: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "A new collaborator has begun publishing package versions. Package stability and security risk may be elevated." - self.props = {"author": "Author"} - self.suggestion = "Try to reduce the amount of authors you depend on to reduce the risk to malicious actors gaining access to your supply chain. Packages should remove inactive collaborators with publishing rights from packages on npm." - self.title = "Unstable ownership" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What is unstable ownership?" + def __init__(self): + self.description = "A new collaborator has begun publishing package versions. Package stability and security risk may be elevated." + self.props = {"author": "Author"} + self.suggestion = "Try to reduce the amount of authors you depend on to reduce the risk to malicious actors gaining access to your supply chain. Packages should remove inactive collaborators with publishing rights from packages on npm." + self.title = "Unstable ownership" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What is unstable ownership?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unusedDependency: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package has unused dependencies. This package depends on code that it does not use. This can increase the attack surface for malware and slow down installation." - self.props = {"name": "Name", "version": "Version"} - self.suggestion = "Packages should only specify dependencies that they use directly." - self.title = "Unused dependency" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are unused dependencies?" + def __init__(self): + self.description = "Package has unused dependencies. This package depends on code that it does not use. This can increase the attack surface for malware and slow down installation." + self.props = {"name": "Name", "version": "Version"} + self.suggestion = "Packages should only specify dependencies that they use directly." + self.title = "Unused dependency" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are unused dependencies?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class urlStrings: - description: str - props: dict - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + props: dict + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "Package contains fragments of external URLs or IP addresses, which may indicate that it covertly exfiltrates data." - self.props = {"urlFragment": "URL Fragment"} - self.suggestion = "Avoid using packages that make connections to the network, since this helps to leak data." - self.title = "URL strings" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are URL strings?" + def __init__(self): + self.description = "Package contains fragments of external URLs or IP addresses, which may indicate that it covertly exfiltrates data." + self.props = {"urlFragment": "URL Fragment"} + self.suggestion = "Avoid using packages that make connections to the network, since this helps to leak data." + self.title = "URL strings" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are URL strings?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class usesEval: - description: str - props: dict - suggestion: str - title: str - emoji: str - capabilityName: str - nextStepTitle: str - - def __init__(self): - self.description = "Package uses eval() which is a dangerous function. This prevents the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior." - self.props = {"evalType": "Eval type"} - self.suggestion = "Avoid packages that use eval, since this could potentially execute any code." - self.title = "Uses eval" - self.emoji = "\u26a0\ufe0f" - self.capabilityName = "eval" - self.nextStepTitle = "What is eval?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + props: dict + suggestion: str + title: str + emoji: str + capabilityName: str + nextStepTitle: str + + def __init__(self): + self.description = "Package uses eval() which is a dangerous function. This prevents the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior." + self.props = {"evalType": "Eval type"} + self.suggestion = "Avoid packages that use eval, since this could potentially execute any code." + self.title = "Uses eval" + self.emoji = "\u26a0\ufe0f" + self.capabilityName = "eval" + self.nextStepTitle = "What is eval?" + + def __str__(self): + return json.dumps(self.__dict__) class zeroWidth: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str - - def __init__(self): - self.description = "Package files contain zero width unicode characters. This could indicate a supply chain attack." - self.suggestion = "Packages should remove unnecessary zero width unicode characters and use their visible counterparts." - self.title = "Zero width unicode chars" - self.emoji = "\u26a0\ufe0f" - self.nextStepTitle = "What are zero width unicode characters?" - - def __str__(self): - return json.dumps(self.__dict__) + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str + + def __init__(self): + self.description = ( + "Package files contain zero width unicode characters. This could indicate a supply chain attack." + ) + self.suggestion = ( + "Packages should remove unnecessary zero width unicode characters and use their visible counterparts." + ) + self.title = "Zero width unicode chars" + self.emoji = "\u26a0\ufe0f" + self.nextStepTitle = "What are zero width unicode characters?" + + def __str__(self): + return json.dumps(self.__dict__) class floatingDependency: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str - props: dict + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str + props: dict - def __init__(self): - self.description = "Package has a dependency with a floating version range. This can cause issues if the dependency publishes a new major version." - self.suggestion = "Packages should specify properly semver ranges to avoid version conflicts." - self.title = "Floating dependency" - self.emoji = "\ud83c\udf88" - self.nextStepTitle = "What are floating dependencies?" - self.props = {"dependency": "Dependency"} + def __init__(self): + self.description = "Package has a dependency with a floating version range. This can cause issues if the dependency publishes a new major version." + self.suggestion = "Packages should specify properly semver ranges to avoid version conflicts." + self.title = "Floating dependency" + self.emoji = "\ud83c\udf88" + self.nextStepTitle = "What are floating dependencies?" + self.props = {"dependency": "Dependency"} - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class unpopularPackage: - description: str - suggestion: str - title: str - emoji: str - nextStepTitle: str + description: str + suggestion: str + title: str + emoji: str + nextStepTitle: str - def __init__(self): - self.description = "This package is not very popular." - self.suggestion = "Unpopular packages may have less maintenance and contain other problems." - self.title = "Unpopular package" - self.emoji = "\ud83c\udfda\ufe0f" - self.nextStepTitle = "What are unpopular packages?" + def __init__(self): + self.description = "This package is not very popular." + self.suggestion = "Unpopular packages may have less maintenance and contain other problems." + self.title = "Unpopular package" + self.emoji = "\ud83c\udfda\ufe0f" + self.nextStepTitle = "What are unpopular packages?" - def __str__(self): - return json.dumps(self.__dict__) + def __str__(self): + return json.dumps(self.__dict__) class AllIssues: - badEncoding: badEncoding - badSemver: badSemver - badSemverDependency: badSemverDependency - bidi: bidi - binScriptConfusion: binScriptConfusion - chronoAnomaly: chronoAnomaly - criticalCVE: criticalCVE - cve: cve - debugAccess: debugAccess - deprecated: deprecated - deprecatedException: deprecatedException - explicitlyUnlicensedItem: explicitlyUnlicensedItem - unidentifiedLicense: unidentifiedLicense - noLicenseFound: noLicenseFound - copyleftLicense: copyleftLicense - nonpermissiveLicense: nonpermissiveLicense - miscLicenseIssues: miscLicenseIssues - deprecatedLicense: deprecatedLicense - didYouMean: didYouMean - dynamicRequire: dynamicRequire - emptyPackage: emptyPackage - envVars: envVars - extraneousDependency: extraneousDependency - fileDependency: fileDependency - filesystemAccess: filesystemAccess - gitDependency: gitDependency - gitHubDependency: gitHubDependency - hasNativeCode: hasNativeCode - highEntropyStrings: highEntropyStrings - homoglyphs: homoglyphs - httpDependency: httpDependency - installScripts: installScripts - gptSecurity: gptSecurity - gptAnomaly: gptAnomaly - gptMalware: gptMalware - potentialVulnerability: potentialVulnerability - invalidPackageJSON: invalidPackageJSON - invisibleChars: invisibleChars - licenseChange: licenseChange - licenseException: licenseException - longStrings: longStrings - missingTarball: missingTarball - majorRefactor: majorRefactor - malware: malware - manifestConfusion: manifestConfusion - mediumCVE: mediumCVE - mildCVE: mildCVE - minifiedFile: minifiedFile - missingAuthor: missingAuthor - missingDependency: missingDependency - missingLicense: missingLicense - mixedLicense: mixedLicense - ambiguousClassifier: ambiguousClassifier - modifiedException: modifiedException - modifiedLicense: modifiedLicense - networkAccess: networkAccess - newAuthor: newAuthor - noAuthorData: noAuthorData - noBugTracker: noBugTracker - noREADME: noREADME - noRepository: noRepository - noTests: noTests - noV1: noV1 - noWebsite: noWebsite - nonFSFLicense: nonFSFLicense - nonOSILicense: nonOSILicense - nonSPDXLicense: nonSPDXLicense - notice: notice - obfuscatedFile: obfuscatedFile - obfuscatedRequire: obfuscatedRequire - peerDependency: peerDependency - semverAnomaly: semverAnomaly - shellAccess: shellAccess - shellScriptOverride: shellScriptOverride - suspiciousString: suspiciousString - telemetry: telemetry - trivialPackage: trivialPackage - troll: troll - typeModuleCompatibility: typeModuleCompatibility - uncaughtOptionalDependency: uncaughtOptionalDependency - unclearLicense: unclearLicense - shrinkwrap: shrinkwrap - unmaintained: unmaintained - unpublished: unpublished - unresolvedRequire: unresolvedRequire - unsafeCopyright: unsafeCopyright - unstableOwnership: unstableOwnership - unusedDependency: unusedDependency - urlStrings: urlStrings - usesEval: usesEval - zeroWidth: zeroWidth - floatingDependency: floatingDependency - unpopularPackage: unpopularPackage - def __init__(self): - self.badEncoding = badEncoding() - self.badSemver = badSemver() - self.badSemverDependency = badSemverDependency() - self.bidi = bidi() - self.binScriptConfusion = binScriptConfusion() - self.chronoAnomaly = chronoAnomaly() - self.criticalCVE = criticalCVE() - self.cve = cve() - self.debugAccess = debugAccess() - self.deprecated = deprecated() - self.deprecatedException = deprecatedException() - self.explicitlyUnlicensedItem = explicitlyUnlicensedItem() - self.unidentifiedLicense = unidentifiedLicense() - self.noLicenseFound = noLicenseFound() - self.copyleftLicense = copyleftLicense() - self.nonpermissiveLicense = nonpermissiveLicense() - self.miscLicenseIssues = miscLicenseIssues() - self.deprecatedLicense = deprecatedLicense() - self.didYouMean = didYouMean() - self.dynamicRequire = dynamicRequire() - self.emptyPackage = emptyPackage() - self.envVars = envVars() - self.extraneousDependency = extraneousDependency() - self.fileDependency = fileDependency() - self.filesystemAccess = filesystemAccess() - self.gitDependency = gitDependency() - self.gitHubDependency = gitHubDependency() - self.hasNativeCode = hasNativeCode() - self.highEntropyStrings = highEntropyStrings() - self.homoglyphs = homoglyphs() - self.httpDependency = httpDependency() - self.installScripts = installScripts() - self.gptSecurity = gptSecurity() - self.gptAnomaly = gptAnomaly() - self.gptMalware = gptMalware() - self.potentialVulnerability = potentialVulnerability() - self.invalidPackageJSON = invalidPackageJSON() - self.invisibleChars = invisibleChars() - self.licenseChange = licenseChange() - self.licenseException = licenseException() - self.longStrings = longStrings() - self.missingTarball = missingTarball() - self.majorRefactor = majorRefactor() - self.malware = malware() - self.manifestConfusion = manifestConfusion() - self.mediumCVE = mediumCVE() - self.mildCVE = mildCVE() - self.minifiedFile = minifiedFile() - self.missingAuthor = missingAuthor() - self.missingDependency = missingDependency() - self.missingLicense = missingLicense() - self.mixedLicense = mixedLicense() - self.ambiguousClassifier = ambiguousClassifier() - self.modifiedException = modifiedException() - self.modifiedLicense = modifiedLicense() - self.networkAccess = networkAccess() - self.newAuthor = newAuthor() - self.noAuthorData = noAuthorData() - self.noBugTracker = noBugTracker() - self.noREADME = noREADME() - self.noRepository = noRepository() - self.noTests = noTests() - self.noV1 = noV1() - self.noWebsite = noWebsite() - self.nonFSFLicense = nonFSFLicense() - self.nonOSILicense = nonOSILicense() - self.nonSPDXLicense = nonSPDXLicense() - self.notice = notice() - self.obfuscatedFile = obfuscatedFile() - self.obfuscatedRequire = obfuscatedRequire() - self.peerDependency = peerDependency() - self.semverAnomaly = semverAnomaly() - self.shellAccess = shellAccess() - self.shellScriptOverride = shellScriptOverride() - self.suspiciousString = suspiciousString() - self.telemetry = telemetry() - self.trivialPackage = trivialPackage() - self.troll = troll() - self.typeModuleCompatibility = typeModuleCompatibility() - self.uncaughtOptionalDependency = uncaughtOptionalDependency() - self.unclearLicense = unclearLicense() - self.shrinkwrap = shrinkwrap() - self.unmaintained = unmaintained() - self.unpublished = unpublished() - self.unresolvedRequire = unresolvedRequire() - self.unsafeCopyright = unsafeCopyright() - self.unstableOwnership = unstableOwnership() - self.unusedDependency = unusedDependency() - self.urlStrings = urlStrings() - self.usesEval = usesEval() - self.zeroWidth = zeroWidth() - self.floatingDependency = floatingDependency() - self.unpopularPackage = unpopularPackage() + badEncoding: badEncoding + badSemver: badSemver + badSemverDependency: badSemverDependency + bidi: bidi + binScriptConfusion: binScriptConfusion + chronoAnomaly: chronoAnomaly + criticalCVE: criticalCVE + cve: cve + debugAccess: debugAccess + deprecated: deprecated + deprecatedException: deprecatedException + explicitlyUnlicensedItem: explicitlyUnlicensedItem + unidentifiedLicense: unidentifiedLicense + noLicenseFound: noLicenseFound + copyleftLicense: copyleftLicense + nonpermissiveLicense: nonpermissiveLicense + miscLicenseIssues: miscLicenseIssues + deprecatedLicense: deprecatedLicense + didYouMean: didYouMean + dynamicRequire: dynamicRequire + emptyPackage: emptyPackage + envVars: envVars + extraneousDependency: extraneousDependency + fileDependency: fileDependency + filesystemAccess: filesystemAccess + gitDependency: gitDependency + gitHubDependency: gitHubDependency + hasNativeCode: hasNativeCode + highEntropyStrings: highEntropyStrings + homoglyphs: homoglyphs + httpDependency: httpDependency + installScripts: installScripts + gptSecurity: gptSecurity + gptAnomaly: gptAnomaly + gptMalware: gptMalware + potentialVulnerability: potentialVulnerability + invalidPackageJSON: invalidPackageJSON + invisibleChars: invisibleChars + licenseChange: licenseChange + licenseException: licenseException + longStrings: longStrings + missingTarball: missingTarball + majorRefactor: majorRefactor + malware: malware + manifestConfusion: manifestConfusion + mediumCVE: mediumCVE + mildCVE: mildCVE + minifiedFile: minifiedFile + missingAuthor: missingAuthor + missingDependency: missingDependency + missingLicense: missingLicense + mixedLicense: mixedLicense + ambiguousClassifier: ambiguousClassifier + modifiedException: modifiedException + modifiedLicense: modifiedLicense + networkAccess: networkAccess + newAuthor: newAuthor + noAuthorData: noAuthorData + noBugTracker: noBugTracker + noREADME: noREADME + noRepository: noRepository + noTests: noTests + noV1: noV1 + noWebsite: noWebsite + nonFSFLicense: nonFSFLicense + nonOSILicense: nonOSILicense + nonSPDXLicense: nonSPDXLicense + notice: notice + obfuscatedFile: obfuscatedFile + obfuscatedRequire: obfuscatedRequire + peerDependency: peerDependency + semverAnomaly: semverAnomaly + shellAccess: shellAccess + shellScriptOverride: shellScriptOverride + suspiciousString: suspiciousString + telemetry: telemetry + trivialPackage: trivialPackage + troll: troll + typeModuleCompatibility: typeModuleCompatibility + uncaughtOptionalDependency: uncaughtOptionalDependency + unclearLicense: unclearLicense + shrinkwrap: shrinkwrap + unmaintained: unmaintained + unpublished: unpublished + unresolvedRequire: unresolvedRequire + unsafeCopyright: unsafeCopyright + unstableOwnership: unstableOwnership + unusedDependency: unusedDependency + urlStrings: urlStrings + usesEval: usesEval + zeroWidth: zeroWidth + floatingDependency: floatingDependency + unpopularPackage: unpopularPackage + + def __init__(self): + self.badEncoding = badEncoding() + self.badSemver = badSemver() + self.badSemverDependency = badSemverDependency() + self.bidi = bidi() + self.binScriptConfusion = binScriptConfusion() + self.chronoAnomaly = chronoAnomaly() + self.criticalCVE = criticalCVE() + self.cve = cve() + self.debugAccess = debugAccess() + self.deprecated = deprecated() + self.deprecatedException = deprecatedException() + self.explicitlyUnlicensedItem = explicitlyUnlicensedItem() + self.unidentifiedLicense = unidentifiedLicense() + self.noLicenseFound = noLicenseFound() + self.copyleftLicense = copyleftLicense() + self.nonpermissiveLicense = nonpermissiveLicense() + self.miscLicenseIssues = miscLicenseIssues() + self.deprecatedLicense = deprecatedLicense() + self.didYouMean = didYouMean() + self.dynamicRequire = dynamicRequire() + self.emptyPackage = emptyPackage() + self.envVars = envVars() + self.extraneousDependency = extraneousDependency() + self.fileDependency = fileDependency() + self.filesystemAccess = filesystemAccess() + self.gitDependency = gitDependency() + self.gitHubDependency = gitHubDependency() + self.hasNativeCode = hasNativeCode() + self.highEntropyStrings = highEntropyStrings() + self.homoglyphs = homoglyphs() + self.httpDependency = httpDependency() + self.installScripts = installScripts() + self.gptSecurity = gptSecurity() + self.gptAnomaly = gptAnomaly() + self.gptMalware = gptMalware() + self.potentialVulnerability = potentialVulnerability() + self.invalidPackageJSON = invalidPackageJSON() + self.invisibleChars = invisibleChars() + self.licenseChange = licenseChange() + self.licenseException = licenseException() + self.longStrings = longStrings() + self.missingTarball = missingTarball() + self.majorRefactor = majorRefactor() + self.malware = malware() + self.manifestConfusion = manifestConfusion() + self.mediumCVE = mediumCVE() + self.mildCVE = mildCVE() + self.minifiedFile = minifiedFile() + self.missingAuthor = missingAuthor() + self.missingDependency = missingDependency() + self.missingLicense = missingLicense() + self.mixedLicense = mixedLicense() + self.ambiguousClassifier = ambiguousClassifier() + self.modifiedException = modifiedException() + self.modifiedLicense = modifiedLicense() + self.networkAccess = networkAccess() + self.newAuthor = newAuthor() + self.noAuthorData = noAuthorData() + self.noBugTracker = noBugTracker() + self.noREADME = noREADME() + self.noRepository = noRepository() + self.noTests = noTests() + self.noV1 = noV1() + self.noWebsite = noWebsite() + self.nonFSFLicense = nonFSFLicense() + self.nonOSILicense = nonOSILicense() + self.nonSPDXLicense = nonSPDXLicense() + self.notice = notice() + self.obfuscatedFile = obfuscatedFile() + self.obfuscatedRequire = obfuscatedRequire() + self.peerDependency = peerDependency() + self.semverAnomaly = semverAnomaly() + self.shellAccess = shellAccess() + self.shellScriptOverride = shellScriptOverride() + self.suspiciousString = suspiciousString() + self.telemetry = telemetry() + self.trivialPackage = trivialPackage() + self.troll = troll() + self.typeModuleCompatibility = typeModuleCompatibility() + self.uncaughtOptionalDependency = uncaughtOptionalDependency() + self.unclearLicense = unclearLicense() + self.shrinkwrap = shrinkwrap() + self.unmaintained = unmaintained() + self.unpublished = unpublished() + self.unresolvedRequire = unresolvedRequire() + self.unsafeCopyright = unsafeCopyright() + self.unstableOwnership = unstableOwnership() + self.unusedDependency = unusedDependency() + self.urlStrings = urlStrings() + self.usesEval = usesEval() + self.zeroWidth = zeroWidth() + self.floatingDependency = floatingDependency() + self.unpopularPackage = unpopularPackage() diff --git a/socketdev/core/retired_token.py b/socketdev/core/retired_token.py index 26fb92f..0fcdb89 100644 --- a/socketdev/core/retired_token.py +++ b/socketdev/core/retired_token.py @@ -1,23 +1,19 @@ import hashlib import hmac -from typing import Optional -RETIRED_TOKEN_SHA256 = "9cab58b24140ee12c08e265729d246f9200f8ef9b1d884f90c06c4e6b69adf18" -RETIRED_TOKEN_ERROR = ( - "This API token has been retired. Create a new API token in Socket " - "and update your configuration." -) +RETIRED_TOKEN_SHA256 = "9cab58b24140ee12c08e265729d246f9200f8ef9b1d884f90c06c4e6b69adf18" # noqa: S105 # public fingerprint or error text +RETIRED_TOKEN_ERROR = "This API token has been retired. Create a new API token in Socket and update your configuration." # noqa: S105 # public fingerprint or error text # Fingerprint identifies a retired credential; it is not a password verifier. -def _matches_retired_digest(token: Optional[str], digest: str) -> bool: +def _matches_retired_digest(token: str | None, digest: str) -> bool: if token is None: return False token_digest = hashlib.sha256(token.strip().encode("utf-8")).hexdigest() return hmac.compare_digest(token_digest, digest) -def _reject_retired_token(token: Optional[str], digest: str) -> None: +def _reject_retired_token(token: str | None, digest: str) -> None: if _matches_retired_digest(token, digest): raise ValueError(RETIRED_TOKEN_ERROR) @@ -26,7 +22,7 @@ def _reject_basic_auth_token(token: str, digest: str) -> None: _reject_retired_token(token.partition(":")[0], digest) -def reject_retired_token(token: Optional[str]) -> None: +def reject_retired_token(token: str | None) -> None: _reject_retired_token(token, RETIRED_TOKEN_SHA256) diff --git a/socketdev/core/token_selection.py b/socketdev/core/token_selection.py index e073640..74eed53 100644 --- a/socketdev/core/token_selection.py +++ b/socketdev/core/token_selection.py @@ -1,4 +1,4 @@ -from typing import Mapping, Optional +from collections.abc import Mapping TOKEN_ENVIRONMENT_VARIABLES = ( "SOCKET_SECURITY_API_TOKEN", @@ -8,9 +8,7 @@ ) -def select_token( - explicit_token: Optional[str], environment: Mapping[str, str] -) -> Optional[str]: +def select_token(explicit_token: str | None, environment: Mapping[str, str]) -> str | None: if explicit_token is not None: return explicit_token return next( diff --git a/socketdev/dependencies/__init__.py b/socketdev/dependencies/__init__.py index dbe4fd7..b5ee6dd 100644 --- a/socketdev/dependencies/__init__.py +++ b/socketdev/dependencies/__init__.py @@ -1,7 +1,9 @@ import json -from urllib.parse import urlencode import logging +from urllib.parse import urlencode + from socketdev.tools import load_files + from ..utils import Utils log = logging.getLogger("socketdev") @@ -13,13 +15,20 @@ class Dependencies: def __init__(self, api): self.api = api - def post(self, files: list, params: dict, use_lazy_loading: bool = True, workspace: str = None, base_path: str = None) -> dict: + def post( + self, + files: list, + params: dict, + use_lazy_loading: bool = True, + workspace: str | None = None, + base_path: str | None = None, + ) -> dict: if use_lazy_loading: loaded_files = Utils.load_files_for_sending_lazy(files, workspace, base_path=base_path) else: loaded_files = [] loaded_files = load_files(files, loaded_files) - + path = "dependencies/upload?" + urlencode(params) response = self.api.do_request(path=path, files=loaded_files, method="POST") if response.status_code == 200: @@ -30,20 +39,27 @@ def post(self, files: list, params: dict, use_lazy_loading: bool = True, workspa log.error(response.text) return result - def get(self, org_slug: str = None, ecosystem: str = None, package: str = None, version: str = None, **kwargs) -> dict: + def get( + self, + org_slug: str | None = None, + ecosystem: str | None = None, + package: str | None = None, + version: str | None = None, + **kwargs, + ) -> dict: # If all specific parameters are provided, use the specific dependency endpoint if org_slug and ecosystem and package and version: path = f"orgs/{org_slug}/dependencies/{ecosystem}/{package}/{version}" response = self.api.do_request(path=path, method="GET") else: # Otherwise use the search endpoint - limit = kwargs.get('limit', 50) - offset = kwargs.get('offset', 0) + limit = kwargs.get("limit", 50) + offset = kwargs.get("offset", 0) path = "dependencies/search" payload = {"limit": limit, "offset": offset} payload_str = json.dumps(payload) response = self.api.do_request(path=path, method="POST", payload=payload_str) - + if response.status_code == 200: result = response.json() else: diff --git a/socketdev/diffscans/__init__.py b/socketdev/diffscans/__init__.py index a1dd2af..c1730f0 100644 --- a/socketdev/diffscans/__init__.py +++ b/socketdev/diffscans/__init__.py @@ -1,19 +1,21 @@ -import json import logging -from typing import Any, Dict, List, Optional, Union +from typing import Any + from ..utils import Utils log = logging.getLogger("socketdev") + class DiffScans: def __init__(self, api): self.api = api - def list(self, org_slug: str, params: Optional[Dict[str, Any]] = None) -> dict: + def list(self, org_slug: str, params: dict[str, Any] | None = None) -> dict: """List all diff scans for an organization.""" path = f"orgs/{org_slug}/diff-scans" if params: import urllib.parse + path += "?" + urllib.parse.urlencode(params) response = self.api.do_request(path=path, method="GET") if response.status_code == 200: @@ -21,7 +23,7 @@ def list(self, org_slug: str, params: Optional[Dict[str, Any]] = None) -> dict: log.error(f"Error listing diff scans: {response.status_code}, message: {response.text}") return {} - def get(self, org_slug: str, diff_scan_id: str, params: Optional[Dict[str, Any]] = None) -> dict: + def get(self, org_slug: str, diff_scan_id: str, params: dict[str, Any] | None = None) -> dict: """Fetch a diff scan by ID. Args: @@ -41,6 +43,7 @@ def get(self, org_slug: str, diff_scan_id: str, params: Optional[Dict[str, Any]] poll until the diff scan is ready. Empty dict on error. """ import urllib.parse + path = f"orgs/{org_slug}/diff-scans/{diff_scan_id}" if params: path += "?" + urllib.parse.urlencode(params, doseq=True) @@ -63,55 +66,68 @@ def get(self, org_slug: str, diff_scan_id: str, params: Optional[Dict[str, Any]] log.error(f"Error fetching diff scan: {response.status_code}, message: {response.text}") return {} - def create_from_repo(self, org_slug: str, repo_slug: str, files: list, params: Optional[Dict[str, Any]] = None, use_lazy_loading: bool = False, workspace: str = None, max_open_files: int = 100, base_path: str = None, base_paths: list = None) -> dict: + def create_from_repo( # noqa: PLR0913 + self, + org_slug: str, + repo_slug: str, + files: list, + params: dict[str, Any] | None = None, + use_lazy_loading: bool = False, + workspace: str | None = None, + max_open_files: int = 100, + base_path: str | None = None, + base_paths: list = None, + ) -> dict: """ Create a diff scan from repo HEAD, uploading files as multipart form data. - + Args: org_slug: Organization slug - repo_slug: Repository slug + repo_slug: Repository slug files: List of file paths to upload for scanning params: Optional query parameters for the request - use_lazy_loading: Whether to use lazy file loading to prevent "too many open files" + use_lazy_loading: Whether to use lazy file loading to prevent "too many open files" errors when uploading large numbers of files (default: False) NOTE: In version 3.0, this will default to True for better performance workspace: Base directory path to make file paths relative to - max_open_files: Maximum number of files to keep open simultaneously when using + max_open_files: Maximum number of files to keep open simultaneously when using lazy loading. Useful for systems with low ulimit values (default: 100) base_path: Optional base path to strip from key names for cleaner file organization base_paths: Optional list of base paths to strip from key names (takes precedence over base_path) - + Returns: dict: API response containing diff scan results - + Note: When use_lazy_loading=True, files are opened only when needed during upload, preventing file descriptor exhaustion. The max_open_files parameter controls how many files can be open simultaneously - set this lower on systems with restrictive ulimits. - + For large file uploads (>100 files), it's recommended to set use_lazy_loading=True. """ import urllib.parse + path = f"orgs/{org_slug}/diff-scans/from-repo/{repo_slug}" if params: # doseq=True so list values (e.g. committers) become repeated params path += "?" + urllib.parse.urlencode(params, doseq=True) - + # Use lazy loading if requested if use_lazy_loading: prepared_files = Utils.load_files_for_sending_lazy(files, workspace, max_open_files, base_path, base_paths) else: prepared_files = files - + response = self.api.do_request(path=path, method="POST", files=prepared_files) if response.status_code in (200, 201): return response.json() log.error(f"Error creating diff scan from repo: {response.status_code}, message: {response.text}") return {} - def create_from_ids(self, org_slug: str, params: Dict[str, Any]) -> dict: + def create_from_ids(self, org_slug: str, params: dict[str, Any]) -> dict: """Create a diff scan from two full scan IDs using query params.""" import urllib.parse + path = f"orgs/{org_slug}/diff-scans/from-ids" if params: path += "?" + urllib.parse.urlencode(params, doseq=True) @@ -134,8 +150,7 @@ def delete(self, org_slug: str, diff_scan_id: str) -> bool: """Delete a diff scan by ID.""" path = f"orgs/{org_slug}/diff-scans/{diff_scan_id}" response = self.api.do_request(path=path, method="DELETE") - if response.status_code == 200: - if "status" in response.json() and response.json()["status"] == "ok": - return True + if response.status_code == 200 and "status" in response.json() and response.json()["status"] == "ok": + return True log.error(f"Error deleting diff scan: {response.status_code}, message: {response.text}") return False diff --git a/socketdev/exceptions.py b/socketdev/exceptions.py index b9c62db..d7845b3 100644 --- a/socketdev/exceptions.py +++ b/socketdev/exceptions.py @@ -1,5 +1,3 @@ -from typing import Optional - # HTTP statuses classified as transient by APIFailure.is_transient_error(): gateway / # availability failures where the request was dropped before the application produced a # definitive response, so retrying the same request may succeed (408 Request Timeout, @@ -10,7 +8,7 @@ class APIFailure(Exception): """Base exception for all Socket API errors""" - def __init__(self, *args, status_code: Optional[int] = None): + def __init__(self, *args, status_code: int | None = None): super().__init__(*args) self.status_code = status_code @@ -34,27 +32,22 @@ class APIKeyMissing(APIFailure): class APIAccessDenied(APIFailure): """Raised when access is denied to the API""" - pass class APIInsufficientPermissions(APIFailure): """Raised when the API token doesn't have required permissions""" - pass class APIOrganizationNotAllowed(APIFailure): """Raised when organization doesn't have access to the feature""" - pass class APIInsufficientQuota(APIFailure): """Raised when access is denied to the API due to quota limits""" - pass class APIResourceNotFound(APIFailure): """Raised when the requested resource is not found""" - pass class APITimeout(APIFailure): diff --git a/socketdev/export/__init__.py b/socketdev/export/__init__.py index 89f642e..a9401e4 100644 --- a/socketdev/export/__init__.py +++ b/socketdev/export/__init__.py @@ -1,18 +1,17 @@ -from urllib.parse import urlencode -from dataclasses import dataclass, asdict -from typing import Optional import logging +from dataclasses import asdict, dataclass +from urllib.parse import urlencode log = logging.getLogger("socketdev") @dataclass class ExportQueryParams: - author: Optional[str] = None - project_group: Optional[str] = None - project_name: Optional[str] = None - project_version: Optional[str] = None - project_id: Optional[str] = None + author: str | None = None + project_group: str | None = None + project_name: str | None = None + project_version: str | None = None + project_id: str | None = None def to_query_params(self) -> str: # Filter out None values and convert to query string @@ -27,7 +26,7 @@ def __init__(self, api): self.api = api def cdx_bom( - self, org_slug: str, id: str, query_params: Optional[ExportQueryParams] = None, use_types: bool = False + self, org_slug: str, id: str, query_params: ExportQueryParams | None = None, use_types: bool = False ) -> dict: """ Export a Socket SBOM as a CycloneDX SBOM @@ -51,7 +50,7 @@ def cdx_bom( return {} def spdx_bom( - self, org_slug: str, id: str, query_params: Optional[ExportQueryParams] = None, use_types: bool = False + self, org_slug: str, id: str, query_params: ExportQueryParams | None = None, use_types: bool = False ) -> dict: """ Export a Socket SBOM as an SPDX SBOM @@ -75,7 +74,7 @@ def spdx_bom( return {} def openvex_bom( - self, org_slug: str, id: str, query_params: Optional[ExportQueryParams] = None, use_types: bool = False + self, org_slug: str, id: str, query_params: ExportQueryParams | None = None, use_types: bool = False ) -> dict: """ Export a Socket SBOM as an OpenVEX SBOM @@ -97,4 +96,3 @@ def openvex_bom( log.error(f"Error exporting OpenVEX BOM: {response.status_code}") log.error(response.text) return {} - diff --git a/socketdev/fixes/__init__.py b/socketdev/fixes/__init__.py index a03be01..b992b47 100644 --- a/socketdev/fixes/__init__.py +++ b/socketdev/fixes/__init__.py @@ -22,12 +22,12 @@ def get(self, org_slug: str, **query_params) -> dict: path = f"orgs/{org_slug}/fixes" if query_params: path += "?" + urlencode(query_params) - + response = self.api.do_request(path=path) - + if response.status_code == 200: return response.json() - + log.error(f"Error getting fixes: {response.status_code}") log.error(response.text) return {} diff --git a/socketdev/fullscans/__init__.py b/socketdev/fullscans/__init__.py index 89526ae..8d21555 100644 --- a/socketdev/fullscans/__init__.py +++ b/socketdev/fullscans/__init__.py @@ -1,15 +1,16 @@ import json import logging -from enum import Enum -from typing import Any, Dict, List, Optional, Union -from dataclasses import dataclass, asdict, field -import urllib.parse import math import time +import urllib.parse +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any + from ..core.dedupe import Dedupe from ..core.enums import unknown_enum_value -from ..utils import IntegrationType, Utils from ..exceptions import APIFailure, APITimeout +from ..utils import IntegrationType, Utils log = logging.getLogger("socketdev") @@ -118,11 +119,11 @@ class ScanType(str, Enum): @dataclass(kw_only=True) class SocketPURL: type: SocketPURL_Type - name: Optional[str] = None - namespace: Optional[str] = None - release: Optional[str] = None - subpath: Optional[str] = None - version: Optional[str] = None + name: str | None = None + namespace: str | None = None + release: str | None = None + subpath: str | None = None + version: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -145,8 +146,8 @@ def from_dict(cls, data: dict) -> "SocketPURL": @dataclass class SocketManifestReference: file: str - start: Optional[int] = None - end: Optional[int] = None + start: int | None = None + end: int | None = None def __getitem__(self, key): return getattr(self, key) @@ -162,19 +163,19 @@ def from_dict(cls, data: dict) -> "SocketManifestReference": @dataclass class FullScanParams: repo: str - org_slug: Optional[str] = None - branch: Optional[str] = None - commit_message: Optional[str] = None - commit_hash: Optional[str] = None - pull_request: Optional[int] = None - committers: Optional[List[str]] = None - integration_type: Optional[IntegrationType] = None - integration_org_slug: Optional[str] = None - make_default_branch: Optional[bool] = None - set_as_pending_head: Optional[bool] = None - tmp: Optional[bool] = None - scan_type: Optional[ScanType] = None - workspace: Optional[str] = None + org_slug: str | None = None + branch: str | None = None + commit_message: str | None = None + commit_hash: str | None = None + pull_request: int | None = None + committers: list[str] | None = None + integration_type: IntegrationType | None = None + integration_org_slug: str | None = None + make_default_branch: bool | None = None + set_as_pending_head: bool | None = None + tmp: bool | None = None + scan_type: ScanType | None = None + workspace: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -213,12 +214,12 @@ class FullScanMetadata: repository_id: str branch: str html_report_url: str - repo: Optional[str] = None - organization_slug: Optional[str] = None - committers: Optional[List[str]] = None - commit_message: Optional[str] = None - commit_hash: Optional[str] = None - pull_request: Optional[int] = None + repo: str | None = None + organization_slug: str | None = None + committers: list[str] | None = None + commit_message: str | None = None + commit_hash: str | None = None + pull_request: int | None = None def __getitem__(self, key): return getattr(self, key) @@ -249,8 +250,8 @@ def from_dict(cls, data: dict) -> "FullScanMetadata": class CreateFullScanResponse: success: bool status: int - data: Optional[FullScanMetadata] = None - message: Optional[str] = None + data: FullScanMetadata | None = None + message: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -273,8 +274,8 @@ def from_dict(cls, data: dict) -> "CreateFullScanResponse": class GetFullScanMetadataResponse: success: bool status: int - data: Optional[FullScanMetadata] = None - message: Optional[str] = None + data: FullScanMetadata | None = None + message: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -295,11 +296,11 @@ def from_dict(cls, data: dict) -> "GetFullScanMetadataResponse": @dataclass(kw_only=True) class SocketArtifactLink: - topLevelAncestors: List[str] + topLevelAncestors: list[str] direct: bool = False - artifact: Optional[Dict] = None - dependencies: Optional[List[str]] = None - manifestFiles: Optional[List[SocketManifestReference]] = None + artifact: dict | None = None + dependencies: list[str] | None = None + manifestFiles: list[SocketManifestReference] | None = None def __getitem__(self, key): return getattr(self, key) @@ -381,7 +382,7 @@ class Alert: file: str start: int end: int - props: Dict[str, Any] + props: dict[str, Any] action: str actionPolicyIndex: int @@ -423,12 +424,12 @@ def from_dict(cls, data: dict) -> "LicenseMatch": @dataclass class LicenseDetail: - authors: List[str] + authors: list[str] errorData: str filepath: str match_strength: int provenance: str - spdxDisj: List[List[LicenseMatch]] + spdxDisj: list[list[LicenseMatch]] def __getitem__(self, key): return getattr(self, key) @@ -445,16 +446,15 @@ def from_dict(cls, data: dict) -> "LicenseDetail": provenance=data["provenance"], filepath=data["filepath"], match_strength=data["match_strength"], - ) @dataclass class AttributionData: purl: str - foundAuthors: List[str] - foundInFilepath: Optional[str] = None - spdxExpr: Optional[str] = None + foundAuthors: list[str] + foundInFilepath: str | None = None + spdxExpr: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -475,7 +475,7 @@ def from_dict(cls, data: dict) -> "AttributionData": @dataclass class LicenseAttribution: attribText: str - attribData: List[AttributionData] + attribData: list[AttributionData] def __getitem__(self, key): return getattr(self, key) @@ -496,12 +496,12 @@ class SocketAlert: type: str severity: SocketIssueSeverity category: SocketCategory - file: Optional[str] = None - start: Optional[int] = None - end: Optional[int] = None - props: Optional[Dict[str, Any]] = None - action: Optional[str] = None - actionPolicyIndex: Optional[int] = None + file: str | None = None + start: int | None = None + end: int | None = None + props: dict[str, Any] | None = None + action: str | None = None + actionPolicyIndex: int | None = None def __getitem__(self, key): return getattr(self, key) @@ -534,25 +534,25 @@ class DiffArtifact: id: str type: str name: str - licenseDetails: List[LicenseDetail] - version: Optional[str] = None - score: Optional[SocketScore] = None - author: List[str] = field(default_factory=list) - alerts: List[SocketAlert] = field(default_factory=list) - license: Optional[str] = None - files: Optional[str] = None - capabilities: Optional[SecurityCapabilities] = None - base: Optional[List[SocketArtifactLink]] = None - head: Optional[List[SocketArtifactLink]] = None - namespace: Optional[str] = None - subpath: Optional[str] = None - artifact_id: Optional[str] = None - artifactId: Optional[str] = None - qualifiers: Optional[Dict[str, Any]] = None - size: Optional[int] = None - state: Optional[str] = None - error: Optional[str] = None - licenseAttrib: Optional[List[LicenseAttribution]] = None + licenseDetails: list[LicenseDetail] + version: str | None = None + score: SocketScore | None = None + author: list[str] = field(default_factory=list) + alerts: list[SocketAlert] = field(default_factory=list) + license: str | None = None + files: str | None = None + capabilities: SecurityCapabilities | None = None + base: list[SocketArtifactLink] | None = None + head: list[SocketArtifactLink] | None = None + namespace: str | None = None + subpath: str | None = None + artifact_id: str | None = None + artifactId: str | None = None + qualifiers: dict[str, Any] | None = None + size: int | None = None + state: str | None = None + error: str | None = None + licenseAttrib: list[LicenseAttribution] | None = None def __getitem__(self, key): return getattr(self, key) @@ -601,19 +601,17 @@ def from_dict(cls, data: dict) -> "DiffArtifact": author=data.get("author", []), state=data.get("state"), error=data.get("error"), - licenseAttrib=license_attrib - if data.get("licenseAttrib") - else None, + licenseAttrib=license_attrib if data.get("licenseAttrib") else None, ) @dataclass class DiffArtifacts: - added: List[DiffArtifact] - removed: List[DiffArtifact] - unchanged: List[DiffArtifact] - replaced: List[DiffArtifact] - updated: List[DiffArtifact] + added: list[DiffArtifact] + removed: list[DiffArtifact] + unchanged: list[DiffArtifact] + replaced: list[DiffArtifact] + updated: list[DiffArtifact] def __getitem__(self, key): return getattr(self, key) @@ -638,10 +636,10 @@ class CommitInfo: branch: str id: str organization_id: str - committers: List[str] - commit_message: Optional[str] = None - commit_hash: Optional[str] = None - pull_request: Optional[int] = None + committers: list[str] + commit_message: str | None = None + commit_hash: str | None = None + pull_request: int | None = None def __getitem__(self, key): return getattr(self, key) @@ -692,8 +690,8 @@ def from_dict(cls, data: dict) -> "FullScanDiffReport": class StreamDiffResponse: success: bool status: int - data: Optional[FullScanDiffReport] = None - message: Optional[str] = None + data: FullScanDiffReport | None = None + message: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -715,14 +713,14 @@ def from_dict(cls, data: dict) -> "StreamDiffResponse": @dataclass(kw_only=True) class SocketArtifact(SocketPURL, SocketArtifactLink): id: str - alerts: List[SocketAlert] - score: Optional[SocketScore] = None - author: Optional[List[str]] = field(default_factory=list) - batchIndex: Optional[int] = None - license: Optional[str] = None - licenseAttrib: Optional[List[LicenseAttribution]] = field(default_factory=list) - licenseDetails: Optional[List[LicenseDetail]] = field(default_factory=list) - size: Optional[int] = None + alerts: list[SocketAlert] + score: SocketScore | None = None + author: list[str] | None = field(default_factory=list) + batchIndex: int | None = None + license: str | None = None + licenseAttrib: list[LicenseAttribution] | None = field(default_factory=list) + licenseDetails: list[LicenseDetail] | None = field(default_factory=list) + size: int | None = None def __getitem__(self, key): return getattr(self, key) @@ -742,14 +740,16 @@ def from_dict(cls, data: dict) -> "SocketArtifact": "subpath": data.get("subpath"), "version": data.get("version"), } - + # Extract Link fields link_data = { "topLevelAncestors": data.get("topLevelAncestors", []), "direct": data.get("direct", False), "artifact": data.get("artifact"), "dependencies": data.get("dependencies"), - "manifestFiles": [SocketManifestReference.from_dict(m) for m in data["manifestFiles"]] if data.get("manifestFiles") else None, + "manifestFiles": [SocketManifestReference.from_dict(m) for m in data["manifestFiles"]] + if data.get("manifestFiles") + else None, } alerts = data.get("alerts") @@ -776,8 +776,8 @@ def from_dict(cls, data: dict) -> "SocketArtifact": class FullScanStreamResponse: success: bool status: int - artifacts: Optional[Dict[str, SocketArtifact]] = None - message: Optional[str] = None + artifacts: dict[str, SocketArtifact] | None = None + message: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -812,18 +812,17 @@ class FullScans: def __init__(self, api): self.api = api - - def get(self, org_slug: str, params: dict, use_types: bool = False) -> Union[dict, GetFullScanMetadataResponse]: + def get(self, org_slug: str, params: dict, use_types: bool = False) -> dict | GetFullScanMetadataResponse: # Check if this is a request for a specific scan by ID - if 'id' in params and len(params) == 1: + if "id" in params and len(params) == 1: # Get specific scan by ID: /orgs/{org_slug}/full-scans/{full_scan_id} - scan_id = params['id'] + scan_id = params["id"] path = f"orgs/{org_slug}/full-scans/{scan_id}" else: # List scans with query parameters: /orgs/{org_slug}/full-scans?params params_arg = urllib.parse.urlencode(params) path = "orgs/" + org_slug + "/full-scans?" + str(params_arg) - + response = self.api.do_request(path=path) if response.status_code == 200: @@ -842,57 +841,55 @@ def get(self, org_slug: str, params: dict, use_types: bool = False) -> Union[dic return {} def post( - self, - files: list, - params: FullScanParams, - use_types: bool = False, - use_lazy_loading: bool = False, - workspace: Optional[str] = None, - max_open_files: int = 100, - base_path: Optional[str] = None, - base_paths: Optional[List[str]] = None - ) -> Union[dict, CreateFullScanResponse]: + self, + files: list, + params: FullScanParams, + use_types: bool = False, + use_lazy_loading: bool = False, + workspace: str | None = None, + max_open_files: int = 100, + base_path: str | None = None, + base_paths: list[str] | None = None, + ) -> dict | CreateFullScanResponse: """ Create a new full scan by uploading manifest files. - + Args: files: List of file paths to upload for scanning params: FullScanParams object containing scan configuration use_types: Whether to return typed response objects (default: False) - use_lazy_loading: Whether to use lazy file loading to prevent "too many open files" + use_lazy_loading: Whether to use lazy file loading to prevent "too many open files" errors when uploading large numbers of files (default: False) NOTE: In version 3.0, this will default to True for better performance workspace: Base directory path to make file paths relative to - max_open_files: Maximum number of files to keep open simultaneously when using + max_open_files: Maximum number of files to keep open simultaneously when using lazy loading. Useful for systems with low ulimit values (default: 100) base_path: Optional base path to strip from key names for cleaner file organization base_paths: Optional list of base paths to strip from key names (takes precedence over base_path) - + Returns: dict or CreateFullScanResponse: API response containing scan results - + Note: When use_lazy_loading=True, files are opened only when needed during upload, preventing file descriptor exhaustion. The max_open_files parameter controls how many files can be open simultaneously - set this lower on systems with restrictive ulimits. - + For large file uploads (>100 files), it's recommended to set use_lazy_loading=True. """ - Utils.validate_integration_type(params.integration_type if params.integration_type else "api") + Utils.validate_integration_type(params.integration_type or "api") org_slug = str(params.org_slug) params_dict = params.to_dict() params_dict.pop("org_slug") # Remove pull_request param if it's None, 0, or not an integer - if hasattr(params, 'pull_request') and ( - params.pull_request is None or - not isinstance(params.pull_request, int) or - params.pull_request == 0 + if hasattr(params, "pull_request") and ( + params.pull_request is None or not isinstance(params.pull_request, int) or params.pull_request == 0 ): - print("Removing pull_request param from FullScanParams as it is None, 0, or not an integer") + log.debug("Removing pull_request param from FullScanParams as it is None, 0, or not an integer") params_dict.pop("pull_request") - if hasattr(params, 'workspace') and params.workspace is None: - print("Removing workspace param from FullScanParams as it is None") + if hasattr(params, "workspace") and params.workspace is None: + log.debug("Removing workspace param from FullScanParams as it is None") params_dict.pop("workspace") params_arg = urllib.parse.urlencode(params_dict) @@ -926,22 +923,21 @@ def delete(self, org_slug: str, full_scan_id: str) -> dict: response = self.api.do_request(path=path, method="DELETE") if response.status_code == 200: - result = response.json() - return result + return response.json() error_message = response.json().get("error", {}).get("message", "Unknown error") log.error(f"Error deleting full scan: {response.status_code}, message: {error_message}") return {} def stream_diff( - self, - org_slug: str, - before: str, - after: str, - use_types: bool = True, - include_license_details: str = "true", - **kwargs, - ) -> Union[dict, StreamDiffResponse]: + self, + org_slug: str, + before: str, + after: str, + use_types: bool = True, + include_license_details: str = "true", + **kwargs, + ) -> dict | StreamDiffResponse: path = f"orgs/{org_slug}/full-scans/diff?before={before}&after={after}&include_license_details={include_license_details}" if kwargs: for key, value in kwargs.items(): @@ -963,24 +959,21 @@ def stream_diff( ) return {} - def stream( + def stream( # noqa: C901 self, org_slug: str, full_scan_id: str, use_types: bool = False, *, - poll_timeout_seconds: Optional[float] = None, + poll_timeout_seconds: float | None = None, poll_initial_interval_seconds: float = 5.0, poll_max_interval_seconds: float = 10.0, - ) -> Union[dict, FullScanStreamResponse]: + ) -> dict | FullScanStreamResponse: request_timeout = self.api.request_timeout timeout = request_timeout if poll_timeout_seconds is None else poll_timeout_seconds values = (timeout, poll_initial_interval_seconds, poll_max_interval_seconds, request_timeout) if any( - isinstance(value, bool) - or not isinstance(value, (int, float)) - or not math.isfinite(value) - or value <= 0 + isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0 for value in values ): raise ValueError("Full scan polling timeout and intervals must be positive finite numbers") @@ -988,8 +981,11 @@ def stream( raise ValueError("Initial polling interval must not exceed maximum polling interval") path = ( - "orgs/" + urllib.parse.quote(org_slug, safe="") + "/full-scans/" - + urllib.parse.quote(full_scan_id, safe="") + "?cached=true" + "orgs/" + + urllib.parse.quote(org_slug, safe="") + + "/full-scans/" + + urllib.parse.quote(full_scan_id, safe="") + + "?cached=true" ) deadline = time.monotonic() + timeout interval = poll_initial_interval_seconds @@ -1024,7 +1020,7 @@ def stream( response.close() result = result.strip('"').strip() for line in result.split("\n"): - if line != '"' and line != "" and line is not None: + if line not in {'"', ""} and line is not None: item = json.loads(line) stream_str.append(item) stream_deduped = Dedupe.dedupe(stream_str, batched=False) @@ -1047,7 +1043,7 @@ def stream( except Exception as e: response.close() - error_message = f"Error parsing stream response: {str(e)}" + error_message = f"Error parsing stream response: {e!s}" log.error(error_message) if use_types: return FullScanStreamResponse.from_dict( @@ -1066,9 +1062,7 @@ def stream( ) return {} - def metadata( - self, org_slug: str, full_scan_id: str, use_types: bool = False - ) -> Union[dict, GetFullScanMetadataResponse]: + def metadata(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> dict | GetFullScanMetadataResponse: path = "orgs/" + org_slug + "/full-scans/" + full_scan_id + "/metadata" response = self.api.do_request(path=path, method="GET") @@ -1091,8 +1085,7 @@ def gfm(self, org_slug: str, before: str, after: str) -> dict: path = "orgs/" + org_slug + f"/full-scans/diff/gfm?before={before}&after={after}" response = self.api.do_request(path=path, method="GET") if response.status_code == 200: - result = response.json() - return result + return response.json() error_message = response.json().get("error", {}).get("message", "Unknown error") log.error(f"Error getting diff scan results: {response.status_code}, message: {error_message}") @@ -1114,25 +1107,23 @@ def finalize_tier1( True if successful, False otherwise """ path = "tier1-reachability-scan/finalize" - payload = json.dumps({ - "tier1_reachability_scan_id": tier1_reachability_scan_id, - "report_run_id": full_scan_id - }) - - response = self.api.do_request( - path=path, - method="POST", - payload=payload - ) + payload = json.dumps({"tier1_reachability_scan_id": tier1_reachability_scan_id, "report_run_id": full_scan_id}) + + response = self.api.do_request(path=path, method="POST", payload=payload) - if response.status_code in (200, 201, 204): - return True - return False + return response.status_code in (200, 201, 204) - def archive(self, tar_files: Optional[Union[str, List[str]]] = None, files: Optional[List[str]] = None, workspace: Optional[str] = None, use_lazy_loading: bool = True, params: Optional[FullScanParams] = None) -> dict: + def archive( + self, + tar_files: str | list[str] | None = None, + files: list[str] | None = None, + workspace: str | None = None, + use_lazy_loading: bool = True, + params: FullScanParams | None = None, + ) -> dict: """ Create a full scan by uploading one or more archives. - + Supported archive formats include .tar, .tar.gz/.tgz, and .zip. Args: @@ -1141,33 +1132,33 @@ def archive(self, tar_files: Optional[Union[str, List[str]]] = None, files: Opti files: List of files to bundle into a .tar.gz and upload (alternative to tar_files) workspace: Base directory path to make file paths relative to when creating tar.gz use_lazy_loading: Whether to use lazy file loading (default: True) - params: FullScanParams object containing scan configuration (repo, org_slug, branch, - commit_message, commit_hash, pull_request, committers, integration_type, + params: FullScanParams object containing scan configuration (repo, org_slug, branch, + commit_message, commit_hash, pull_request, committers, integration_type, integration_org_slug, make_default_branch, set_as_pending_head, tmp) Returns: dict with the full scan creation response - + Raises: ValueError: If neither tar_files nor files is provided, or if both are provided, or if params is None """ if tar_files is None and files is None: raise ValueError("Either tar_files or files must be provided") - + if tar_files is not None and files is not None: raise ValueError("Cannot provide both tar_files and files - choose one") - + if params is None: raise ValueError("params argument is required") - - Utils.validate_integration_type(params.integration_type if params.integration_type else "api") + + Utils.validate_integration_type(params.integration_type or "api") org_slug = str(params.org_slug) params_dict = params.to_dict() params_dict.pop("org_slug") params_arg = urllib.parse.urlencode(params_dict) path = f"orgs/{org_slug}/full-scans/archive?" + str(params_arg) - + # Prepare files for upload if tar_files: # Archive file(s) - use lazy loading to prepare them @@ -1179,13 +1170,13 @@ def archive(self, tar_files: Optional[Union[str, List[str]]] = None, files: Opti upload_files = [] for file_path in files_list: filename = file_path.rsplit("/", 1)[-1] if "/" in file_path else file_path - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: upload_files.append(("file", (filename, f.read()))) else: # Multiple files - bundle into tar.gz log.debug(f"Creating tar.gz archive from {len(files)} files") tar_buffer = Utils.create_tar_gz_from_files(files, workspace) - + # Prepare the tar.gz for upload archive_name = "archive.tar.gz" upload_files = [("file", (archive_name, tar_buffer))] @@ -1239,6 +1230,8 @@ def get_tar_files(self, org_slug: str, full_scan_id: str) -> bytes: if response.status_code == 200: return response.content - error_message = response.json().get("error", {}).get("message", "Unknown error") if response.text else "Unknown error" + error_message = ( + response.json().get("error", {}).get("message", "Unknown error") if response.text else "Unknown error" + ) log.error(f"Error downloading tar files: {response.status_code}, message: {error_message}") return b"" diff --git a/socketdev/historical/__init__.py b/socketdev/historical/__init__.py index eac837d..17939e3 100644 --- a/socketdev/historical/__init__.py +++ b/socketdev/historical/__init__.py @@ -9,7 +9,7 @@ def __init__(self, api): self.api = api self.snapshots = self.Snapshots(api) - def list(self, org_slug: str, query_params: dict = None) -> dict: + def list(self, org_slug: str, query_params: dict | None = None) -> dict: """Get historical alerts list for an organization. Args: @@ -28,7 +28,7 @@ def list(self, org_slug: str, query_params: dict = None) -> dict: log.error(response.text) return {} - def trend(self, org_slug: str, query_params: dict = None) -> dict: + def trend(self, org_slug: str, query_params: dict | None = None) -> dict: """Get historical alert trends data for an org. Args: @@ -47,7 +47,7 @@ def trend(self, org_slug: str, query_params: dict = None) -> dict: log.error(response.text) return {} - def dependencies_trend(self, org_slug: str, query_params: dict = None) -> dict: + def dependencies_trend(self, org_slug: str, query_params: dict | None = None) -> dict: """Get historical dependency trends data for an org. Args: @@ -88,7 +88,7 @@ def create(self, org_slug: str) -> dict: log.error(response.text) return {} - def list(self, org_slug: str, query_params: dict = None) -> dict: + def list(self, org_slug: str, query_params: dict | None = None) -> dict: """List historical snapshots for an organization.""" path = f"orgs/{org_slug}/historical/snapshots" if query_params: diff --git a/socketdev/labels/__init__.py b/socketdev/labels/__init__.py index 2f0c2d1..17d841b 100644 --- a/socketdev/labels/__init__.py +++ b/socketdev/labels/__init__.py @@ -1,11 +1,9 @@ import json import logging -from typing import Any -from urllib.parse import urlencode - log = logging.getLogger("socketdev") + class Setting: def __init__(self, api): self.api = api @@ -21,7 +19,9 @@ def get(self, org_slug: str, label_id: int, setting_key: str): return response.json() error_message = response.json().get("error", {}).get("message", "Unknown error") - log.error(f"Error getting label setting {setting_key} for {label_id}: {response.status_code}, message: {error_message}") + log.error( + f"Error getting label setting {setting_key} for {label_id}: {response.status_code}, message: {error_message}" + ) return {} def put(self, org_slug: str, label_id: int, settings: dict[str, dict[str, dict[str, str]]]): @@ -54,7 +54,7 @@ def __init__(self, api): self.setting = Setting(api) def list(self, org_slug: str): - path = f"orgs/" + org_slug + f"/repos/labels" + path = "orgs/" + org_slug + "/repos/labels" response = self.api.do_request(path=path) if response.status_code == 200: return response.json() @@ -69,8 +69,7 @@ def post(self, org_slug: str, label_name: str) -> dict: response = self.api.do_request(path=path, method="POST", payload=payload) if response.status_code == 201: - result = response.json() - return result + return response.json() error_message = response.json().get("error", {}).get("message", "Unknown error") log.error(f"Failed to create repository label: {response.status_code}, message: {error_message}") @@ -80,8 +79,7 @@ def get(self, org_slug: str, label_id: str) -> dict: path = f"orgs/{org_slug}/repos/labels/{label_id}" response = self.api.do_request(path=path) if response.status_code == 200: - result = response.json() - return result + return response.json() error_message = response.json().get("error", {}).get("message", "Unknown error") log.error(f"Failed to get repository label: {response.status_code}, message: {error_message}") @@ -97,7 +95,6 @@ def delete(self, org_slug: str, label_id: str) -> dict: log.error(f"Error deleting repository label: {response.status_code}, message: {error_message}") return {} - def associate(self, org_slug: str, label_id: int, repo_id: str) -> dict: path = f"orgs/{org_slug}/repos/labels/{label_id}/associate" payload = json.dumps({"repository_id": repo_id}) diff --git a/socketdev/licensemetadata/__init__.py b/socketdev/licensemetadata/__init__.py index 898d091..9e3775e 100644 --- a/socketdev/licensemetadata/__init__.py +++ b/socketdev/licensemetadata/__init__.py @@ -9,8 +9,8 @@ class LicenseMetadata: def __init__(self, api): self.api = api - def post(self, licenses: list, params: dict = None) -> dict: - path = f"license-metadata" + def post(self, licenses: list, params: dict | None = None) -> dict: + path = "license-metadata" if params: query_args = urllib.parse.urlencode(params) path += f"?{query_args}" @@ -18,10 +18,8 @@ def post(self, licenses: list, params: dict = None) -> dict: response = self.api.do_request(path=path, method="POST", payload=payload) if response.status_code == 200: - result = response.json() - return result + return response.json() error_message = response.json().get("error", {}).get("message", "Unknown error") log.error(f"Failed to create license metadata: {response.status_code}, message: {error_message}") return {} - diff --git a/socketdev/log.py b/socketdev/log.py index 4eb155d..3bb3198 100644 --- a/socketdev/log.py +++ b/socketdev/log.py @@ -1,4 +1,4 @@ import logging log = logging.getLogger("socketdev") -log.addHandler(logging.NullHandler()) \ No newline at end of file +log.addHandler(logging.NullHandler()) diff --git a/socketdev/org/__init__.py b/socketdev/org/__init__.py index 5496479..f0cdfe7 100644 --- a/socketdev/org/__init__.py +++ b/socketdev/org/__init__.py @@ -1,5 +1,5 @@ -from typing import TypedDict, Dict import logging +from typing import TypedDict log = logging.getLogger("socketdev") @@ -13,7 +13,7 @@ class Organization(TypedDict): class OrganizationsResponse(TypedDict): - organizations: Dict[str, Organization] + organizations: dict[str, Organization] # Add other fields from the response if needed diff --git a/socketdev/purl/__init__.py b/socketdev/purl/__init__.py index 3c01897..261c440 100644 --- a/socketdev/purl/__init__.py +++ b/socketdev/purl/__init__.py @@ -1,9 +1,10 @@ import json import urllib.parse import warnings -from typing import Optional -from socketdev.log import log + from socketdev.exceptions import APIPartialResponse +from socketdev.log import log + from ..core.dedupe import Dedupe @@ -18,15 +19,15 @@ class Purl: def __init__(self, api): self.api = api - def post( + def post( # noqa: C901 self, license: str = "false", - components: list = None, - org_slug: str = None, - poll: Optional[bool] = None, - timeout_sec: Optional[int] = None, - alerts: Optional[bool] = None, - purl_errors: Optional[bool] = None, + components: list | None = None, + org_slug: str | None = None, + poll: bool | None = None, + timeout_sec: int | None = None, + alerts: bool | None = None, + purl_errors: bool | None = None, strict: bool = False, **kwargs, ) -> list: @@ -145,11 +146,7 @@ def _raise_on_missing(components: list, results: list) -> None: the response's canonical ``purl`` differs. ``purl`` is retained as a fallback, and typed ``purlError`` stream records carry ``inputPurl`` under ``value``. """ - requested = [ - c["purl"] - for c in components - if isinstance(c, dict) and isinstance(c.get("purl"), str) - ] + requested = [c["purl"] for c in components if isinstance(c, dict) and isinstance(c.get("purl"), str)] if not requested: return returned = set() diff --git a/socketdev/report/__init__.py b/socketdev/report/__init__.py index f0331ea..9714485 100644 --- a/socketdev/report/__init__.py +++ b/socketdev/report/__init__.py @@ -1,4 +1,5 @@ import logging +from contextlib import ExitStack from datetime import datetime, timedelta, timezone log = logging.getLogger("socketdev") @@ -10,7 +11,7 @@ class Report: def __init__(self, api): self.api = api - def list(self, from_time: int = None) -> dict: + def list(self, from_time: int | None = None) -> dict: """ This function will return all reports from time specified. :param from_time: Unix epoch time in seconds. Will default self, to 30 days @@ -58,30 +59,31 @@ def supported(self) -> dict: return {} def create(self, files: list) -> dict: - # Handle both file path strings and file tuples - open_files = [] - for file_entry in files: - if isinstance(file_entry, tuple) and len(file_entry) == 2: - name, file_data = file_entry - if isinstance(file_data, tuple) and len(file_data) == 2: - # Format: [("field_name", ("filename", file_obj))] - filename, file_obj = file_data - file_info = (name, (filename, file_obj, "text/plain")) - open_files.append(file_info) + with ExitStack() as stack: + # Handle both file path strings and file tuples + open_files = [] + for file_entry in files: + if isinstance(file_entry, tuple) and len(file_entry) == 2: + name, file_data = file_entry + if isinstance(file_data, tuple) and len(file_data) == 2: + # Format: [("field_name", ("filename", file_obj))] + filename, file_obj = file_data + file_info = (name, (filename, file_obj, "text/plain")) + open_files.append(file_info) + else: + # Format: [("field_name", "file_path")] + file_info = (name, (name, stack.enter_context(open(file_data, "rb")), "text/plain")) + open_files.append(file_info) else: - # Format: [("field_name", "file_path")] - file_info = (name, (name, open(file_data, "rb"), "text/plain")) - open_files.append(file_info) - else: - # Handle other formats if needed - log.error(f"Unexpected file format: {file_entry}") - return {} - - path = "report/upload" - payload = {} - response = self.api.do_request(path=path, method="PUT", files=open_files, payload=payload) - if response.status_code in (200, 201): - return response.json() - log.error(f"Error creating report: {response.status_code}") - log.error(response.text) - return {} + # Handle other formats if needed + log.error(f"Unexpected file format: {file_entry}") + return {} + + path = "report/upload" + payload = {} + response = self.api.do_request(path=path, method="PUT", files=open_files, payload=payload) + if response.status_code in (200, 201): + return response.json() + log.error(f"Error creating report: {response.status_code}") + log.error(response.text) + return {} diff --git a/socketdev/repos/__init__.py b/socketdev/repos/__init__.py index a4268db..27d6ad5 100644 --- a/socketdev/repos/__init__.py +++ b/socketdev/repos/__init__.py @@ -1,7 +1,6 @@ import json import logging -from typing import Optional, Union -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass log = logging.getLogger("socketdev") @@ -18,7 +17,7 @@ class RepositoryInfo: visibility: str archived: bool default_branch: str - slug: Optional[str] = None + slug: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -39,7 +38,7 @@ def from_dict(cls, data: dict) -> "RepositoryInfo": visibility=data.get("visibility", "private"), archived=data.get("archived", False), default_branch=data.get("default_branch", "main"), - slug=data["slug"] + slug=data["slug"], ) @@ -47,8 +46,8 @@ def from_dict(cls, data: dict) -> "RepositoryInfo": class GetRepoResponse: success: bool status: int - data: Optional[RepositoryInfo] = None - message: Optional[str] = None + data: RepositoryInfo | None = None + message: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -98,7 +97,7 @@ def get(self, org_slug: str, **kwargs) -> dict[str, list[dict] | int]: log.error(f"Error getting repositories: {response.status_code}, message: {error_message}") return {} - def repo(self, org_slug: str, repo_name: str, use_types: bool = False) -> Union[dict, GetRepoResponse]: + def repo(self, org_slug: str, repo_name: str, use_types: bool = False) -> dict | GetRepoResponse: path = f"orgs/{org_slug}/repos/{repo_name}" response = self.api.do_request(path=path) @@ -128,10 +127,7 @@ def delete(self, org_slug: str, name: str) -> dict: return {} def post(self, org_slug: str, **kwargs) -> dict: - params = {} - if kwargs: - for key, val in kwargs.items(): - params[key] = val + params = dict(kwargs) if len(params) == 0: return {} @@ -147,10 +143,7 @@ def post(self, org_slug: str, **kwargs) -> dict: return {} def update(self, org_slug: str, repo_name: str, **kwargs) -> dict: - params = {} - if kwargs: - for key, val in kwargs.keys(): - params[key] = val + params = dict(kwargs) if len(params) == 0: return {} diff --git a/socketdev/repositories/__init__.py b/socketdev/repositories/__init__.py index e63faf9..3fadf08 100644 --- a/socketdev/repositories/__init__.py +++ b/socketdev/repositories/__init__.py @@ -1,5 +1,5 @@ -from typing import TypedDict, Union import logging +from typing import TypedDict log = logging.getLogger("socketdev") @@ -17,7 +17,7 @@ class Repositories: def __init__(self, api): self.api = api - def list(self, use_types: bool = False) -> Union[dict, list[Repo]]: + def list(self, use_types: bool = False) -> dict | list[Repo]: path = "repos" response = self.api.do_request(path=path) if response.status_code == 200: diff --git a/socketdev/sbom/__init__.py b/socketdev/sbom/__init__.py index 42cf34e..25c6864 100644 --- a/socketdev/sbom/__init__.py +++ b/socketdev/sbom/__init__.py @@ -1,7 +1,8 @@ import json -from socketdev.core.classes import Package import logging +from socketdev.core.classes import Package + log = logging.getLogger("socketdev") # TODO: Add response type classes for SBOM endpoints @@ -25,7 +26,7 @@ def view(self, report_id: str) -> dict[str, dict]: data.strip('"') data.strip() for line in data.split("\n"): - if line != '"' and line != "" and line is not None: + if line not in {'"', ""} and line is not None: item = json.loads(line) sbom.append(item) for val in sbom: diff --git a/socketdev/settings/__init__.py b/socketdev/settings/__init__.py index 1ba8724..f83ff25 100644 --- a/socketdev/settings/__init__.py +++ b/socketdev/settings/__init__.py @@ -1,7 +1,6 @@ import logging +from dataclasses import asdict, dataclass from enum import Enum -from typing import Dict, Optional, Union -from dataclasses import dataclass, asdict from ..core.enums import unknown_enum_value @@ -43,8 +42,8 @@ def from_dict(cls, data: dict) -> "SecurityPolicyRule": class OrgSecurityPolicyResponse: success: bool status: int - securityPolicyRules: Optional[Dict[str, SecurityPolicyRule]] = None - message: Optional[str] = None + securityPolicyRules: dict[str, SecurityPolicyRule] | None = None + message: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -80,12 +79,11 @@ def create_params_string(self, params: dict) -> str: else: param_str += f"&{name}={value}" - param_str = "?" + param_str.lstrip("&") - return param_str + return "?" + param_str.lstrip("&") def get( self, org_slug: str, custom_rules_only: bool = False, use_types: bool = False - ) -> Union[dict, OrgSecurityPolicyResponse]: + ) -> dict | OrgSecurityPolicyResponse: path = f"orgs/{org_slug}/settings/security-policy" params = {"custom_rules_only": custom_rules_only} params_args = self.create_params_string(params) if custom_rules_only else "" diff --git a/socketdev/supportedfiles/__init__.py b/socketdev/supportedfiles/__init__.py index 4ecdd03..1cdbbb2 100644 --- a/socketdev/supportedfiles/__init__.py +++ b/socketdev/supportedfiles/__init__.py @@ -18,12 +18,12 @@ def get(self, org_slug: str) -> dict: dict containing list of supported file types """ path = f"orgs/{org_slug}/supported-files" - + response = self.api.do_request(path=path) - + if response.status_code == 200: return response.json() - + log.error(f"Error getting supported files: {response.status_code}") log.error(response.text) return {} diff --git a/socketdev/telemetry/__init__.py b/socketdev/telemetry/__init__.py index f182fb2..9d16951 100644 --- a/socketdev/telemetry/__init__.py +++ b/socketdev/telemetry/__init__.py @@ -1,5 +1,5 @@ -import logging import json +import logging log = logging.getLogger("socketdev") @@ -19,12 +19,12 @@ def get_config(self, org_slug: str) -> dict: dict containing telemetry configuration """ path = f"orgs/{org_slug}/telemetry/config" - + response = self.api.do_request(path=path) - + if response.status_code == 200: return response.json() - + log.error(f"Error getting telemetry config: {response.status_code}") log.error(response.text) return {} @@ -42,12 +42,12 @@ def update_config(self, org_slug: str, **kwargs) -> dict: """ path = f"orgs/{org_slug}/telemetry/config" payload = json.dumps(kwargs) if kwargs else "{}" - + response = self.api.do_request(path=path, method="PUT", payload=payload) - + if response.status_code == 200: return response.json() - + log.error(f"Error updating telemetry config: {response.status_code}") log.error(response.text) return {} diff --git a/socketdev/threatfeed/__init__.py b/socketdev/threatfeed/__init__.py index d2d022d..302fc63 100644 --- a/socketdev/threatfeed/__init__.py +++ b/socketdev/threatfeed/__init__.py @@ -8,14 +8,14 @@ class ThreatFeed: def __init__(self, api): self.api = api - def get(self, org_slug: str = None, **kwargs) -> dict: + def get(self, org_slug: str | None = None, **kwargs) -> dict: """ Get threat feed items. - + Args: org_slug: Organization slug (required for the new endpoint) **kwargs: Query parameters like per_page, page_cursor, sort, etc. - + Returns: dict: API response containing threat feed items """ @@ -25,10 +25,10 @@ def get(self, org_slug: str = None, **kwargs) -> dict: else: # Use the deprecated global endpoint path = "threat-feed" - + if kwargs: path += "?" + urlencode(kwargs) - + response = self.api.do_request(path=path) if response.status_code == 200: return response.json() diff --git a/socketdev/tools/__init__.py b/socketdev/tools/__init__.py index 5cdf2e4..e559648 100644 --- a/socketdev/tools/__init__.py +++ b/socketdev/tools/__init__.py @@ -1,6 +1,6 @@ import glob -import sys import platform +import sys def find_package_files(folder: str, file_types: list) -> list: @@ -17,33 +17,29 @@ def find_package_files(folder: str, file_types: list) -> list: def fix_file_path(files) -> list: fixed_files = [] for file in files: - file = file.replace("\\", "/") - fixed_files.append(file) + fixed_files.append(file.replace("\\", "/")) return fixed_files - - -def load_files(files: list, loaded_files: list, workspace: str = None) -> list: - for file in files: - if platform.system() == "Windows": - file = file.replace("\\", "/") +def load_files(files: list, loaded_files: list, workspace: str | None = None) -> list: + for original_file in files: + file = original_file.replace("\\", "/") if platform.system() == "Windows" else original_file if "/" in file: path, name = file.rsplit("/", 1) else: path = "." name = file full_path = f"{path}/{name}" - + # Calculate key based on workspace if provided if workspace and full_path.startswith(workspace): - key = full_path[len(workspace):] + key = full_path[len(workspace) :] key = key.lstrip("/") key = key.lstrip("./") else: key = full_path - - payload = (key, (name, open(full_path, "rb"))) + + payload = (key, (name, open(full_path, "rb"))) # noqa: SIM115 # caller owns returned upload handles loaded_files.append(payload) return loaded_files diff --git a/socketdev/triage/__init__.py b/socketdev/triage/__init__.py index b3a9d96..cf8364b 100644 --- a/socketdev/triage/__init__.py +++ b/socketdev/triage/__init__.py @@ -8,7 +8,7 @@ class Triage: def __init__(self, api): self.api = api - def list_alert_triage(self, org_slug: str, query_params: dict = None) -> dict: + def list_alert_triage(self, org_slug: str, query_params: dict | None = None) -> dict: """Get list of triaged alerts for an organization. Args: diff --git a/socketdev/uploadmanifests/__init__.py b/socketdev/uploadmanifests/__init__.py index dc6b634..b154da2 100644 --- a/socketdev/uploadmanifests/__init__.py +++ b/socketdev/uploadmanifests/__init__.py @@ -1,6 +1,6 @@ -import os import logging -from typing import List, Optional, Union +import os + from ..utils import Utils log = logging.getLogger("socketdev") @@ -10,7 +10,13 @@ class UploadManifests: def __init__(self, api): self.api = api - def _calculate_key_name(self, file_path: str, workspace: Optional[str] = None, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None) -> str: + def _calculate_key_name( # noqa: C901 + self, + file_path: str, + workspace: str | None = None, + base_path: str | None = None, + base_paths: list[str] | None = None, + ) -> str: """ Calculate the key name for a file using the same logic as load_files_for_sending_lazy. This ensures consistency between lazy and non-lazy loading modes. @@ -18,7 +24,7 @@ def _calculate_key_name(self, file_path: str, workspace: Optional[str] = None, b # Normalize file path if "\\" in file_path: file_path = file_path.replace("\\", "/") - + # Normalize paths if workspace and "\\" in workspace: workspace = workspace.replace("\\", "/") @@ -36,31 +42,31 @@ def _calculate_key_name(self, file_path: str, workspace: Optional[str] = None, b for bp in base_paths: normalized_base_path = bp.rstrip("/") + "/" if not bp.endswith("/") else bp if key.startswith(normalized_base_path): - key = key[len(normalized_base_path):] + key = key[len(normalized_base_path) :] path_stripped = True break - elif key.startswith(bp.rstrip("/")): + if key.startswith(bp.rstrip("/")): stripped_base = bp.rstrip("/") if key.startswith(stripped_base + "/") or key == stripped_base: - key = key[len(stripped_base):] + key = key[len(stripped_base) :] key = key.lstrip("/") path_stripped = True break elif base_path: normalized_base_path = base_path.rstrip("/") + "/" if not base_path.endswith("/") else base_path if key.startswith(normalized_base_path): - key = key[len(normalized_base_path):] + key = key[len(normalized_base_path) :] path_stripped = True elif key.startswith(base_path.rstrip("/")): stripped_base = base_path.rstrip("/") if key.startswith(stripped_base + "/") or key == stripped_base: - key = key[len(stripped_base):] + key = key[len(stripped_base) :] key = key.lstrip("/") path_stripped = True # If workspace is provided and no base paths matched, fall back to workspace logic if not path_stripped and workspace and file_path.startswith(workspace): - key = file_path[len(workspace):] + key = file_path[len(workspace) :] # Remove all leading slashes (for absolute paths) while key.startswith("/"): key = key[1:] @@ -76,17 +82,25 @@ def _calculate_key_name(self, file_path: str, workspace: Optional[str] = None, b key = key[1:] # Remove Windows drive letter if present (C:/...) - if len(key) > 2 and key[1] == ':' and (key[2] == '/' or key[2] == '\\'): + if len(key) > 2 and key[1] == ":" and (key[2] == "/" or key[2] == "\\"): key = key[2:] while key.startswith("/"): key = key[1:] return key - def upload_manifest_files(self, org_slug: str, file_paths: List[str], workspace: Optional[str] = None, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None, use_lazy_loading: bool = True) -> str: + def upload_manifest_files( + self, + org_slug: str, + file_paths: list[str], + workspace: str | None = None, + base_path: str | None = None, + base_paths: list[str] | None = None, + use_lazy_loading: bool = True, + ) -> str: """ Upload manifest files to Socket API and return tarHash. - + Args: org_slug: Organization slug file_paths: List of manifest file paths to upload @@ -94,26 +108,23 @@ def upload_manifest_files(self, org_slug: str, file_paths: List[str], workspace: base_path: Optional base path to strip from key names for cleaner file organization base_paths: Optional list of base paths to strip from key names (takes precedence over base_path) use_lazy_loading: Whether to use lazy file loading (default: True) - + Returns: str: The tarHash from the upload response - + Raises: Exception: If upload fails """ # Filter to only existing files valid_files = [f for f in file_paths if os.path.exists(f) and os.path.isfile(f)] - + if not valid_files: raise Exception("No valid manifest files found to upload") - + # Prepare files for upload using the utility function if use_lazy_loading: loaded_files = Utils.load_files_for_sending_lazy( - valid_files, - workspace=workspace, - base_path=base_path, - base_paths=base_paths + valid_files, workspace=workspace, base_path=base_path, base_paths=base_paths ) else: # Fallback to basic file loading if needed @@ -121,21 +132,21 @@ def upload_manifest_files(self, org_slug: str, file_paths: List[str], workspace: for file_path in valid_files: # Use the same key generation logic as lazy loading for consistency key = self._calculate_key_name(file_path, workspace, base_path, base_paths) - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: loaded_files.append((key, (key, f.read()))) - + # Make the upload request path = f"orgs/{org_slug}/upload-manifest-files" response = self.api.do_request(path=path, files=loaded_files, method="POST") - + if response.status_code != 200: raise Exception(f"Upload failed with status {response.status_code}: {response.text}") - + result = response.json() - tar_hash = result.get('tarHash') - + tar_hash = result.get("tarHash") + if not tar_hash: raise Exception("Server did not return a tarHash") - + log.info(f"Successfully uploaded {len(valid_files)} manifest files, tarHash: {tar_hash}") - return tar_hash \ No newline at end of file + return tar_hash diff --git a/socketdev/utils/__init__.py b/socketdev/utils/__init__.py index 74be447..8f04b81 100644 --- a/socketdev/utils/__init__.py +++ b/socketdev/utils/__init__.py @@ -1,11 +1,10 @@ -from typing import Literal, List, Tuple, Optional, Union +import io import logging import os +import tarfile import weakref from threading import Lock -import tarfile -import tempfile -import io +from typing import Literal, cast log = logging.getLogger("socketdev") @@ -18,9 +17,10 @@ class FileDescriptorManager: Global manager to track and limit the number of open file descriptors. Automatically closes least recently used files when limit is reached. """ + _instance = None _lock = Lock() - + def __new__(cls): if cls._instance is None: with cls._lock: @@ -28,20 +28,20 @@ def __new__(cls): cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance - + def __init__(self): if not self._initialized: self.max_open_files = 100 # Default limit, can be overridden self.open_files = [] # List of weakrefs to LazyFileLoader instances self._initialized = True log.debug(f"FileDescriptorManager initialized with default max_open_files={self.max_open_files}") - + def set_max_open_files(self, max_files: int): """Set the maximum number of open files.""" with self._lock: self.max_open_files = max_files log.debug(f"FileDescriptorManager max_open_files set to {self.max_open_files}") - + # If we're now over the limit, close some files while len(self.open_files) >= self.max_open_files: self.open_files = [ref for ref in self.open_files if ref() is not None] @@ -53,13 +53,13 @@ def set_max_open_files(self, max_files: int): log.debug(f"Auto-closed file due to new descriptor limit: {oldest_file.file_path}") else: break - + def register_file_open(self, lazy_file_loader): """Register a file as opened and manage the descriptor limit.""" with self._lock: # Remove any dead weak references self.open_files = [ref for ref in self.open_files if ref() is not None] - + # If we're at the limit, close the oldest file if len(self.open_files) >= self.max_open_files: oldest_ref = self.open_files.pop(0) @@ -67,15 +67,14 @@ def register_file_open(self, lazy_file_loader): if oldest_file is not None and oldest_file._file is not None: oldest_file.close() log.debug(f"Auto-closed file due to descriptor limit: {oldest_file.file_path}") - + # Add the new file to the end of the list self.open_files.append(weakref.ref(lazy_file_loader)) - + def unregister_file(self, lazy_file_loader): """Remove a file from the tracking list when it's closed.""" with self._lock: - self.open_files = [ref for ref in self.open_files - if ref() is not None and ref() is not lazy_file_loader] + self.open_files = [ref for ref in self.open_files if ref() is not None and ref() is not lazy_file_loader] # Global instance @@ -86,12 +85,12 @@ class LazyFileLoader: """ A file-like object that only opens the actual file when needed for reading. This prevents keeping too many file descriptors open simultaneously. - + This class implements the standard file-like interface that requests library expects for multipart uploads, making it a drop-in replacement for regular file objects. """ - + def __init__(self, file_path: str, name: str): self.file_path = file_path self.name = name @@ -99,15 +98,15 @@ def __init__(self, file_path: str, name: str): self._closed = False self._position = 0 self._size = None - + def _ensure_open(self): """Ensure the file is open and seek to the correct position.""" if self._closed: raise ValueError("I/O operation on closed file.") - + if self._file is None: try: - self._file = open(self.file_path, 'rb') + self._file = open(self.file_path, "rb") # noqa: SIM115 # LazyFile.close owns the handle _fd_manager.register_file_open(self) log.debug(f"Opened file for reading: {self.file_path}") # Seek to the current position if we've been reading before @@ -117,48 +116,51 @@ def _ensure_open(self): if e.errno == 24: # Too many open files # Try to force garbage collection to close unused files import gc + gc.collect() # Retry once - self._file = open(self.file_path, 'rb') + self._file = open(self.file_path, "rb") # noqa: SIM115 # LazyFile.close owns the handle _fd_manager.register_file_open(self) log.debug(f"Opened file for reading (after gc): {self.file_path}") if self._position > 0: self._file.seek(self._position) else: raise - + def _get_size(self): """Get file size without keeping file open.""" if self._size is None: self._size = os.path.getsize(self.file_path) return self._size - + def read(self, size: int = -1): """Read from the file, opening it if needed.""" self._ensure_open() - assert self._file is not None + if self._file is None: + raise RuntimeError("Lazy file could not be opened") data = self._file.read(size) self._position = self._file.tell() - + # If we've read the entire file, close it to free the file descriptor if size == -1 or len(data) < size: self.close() - + return data - + def readline(self, size: int = -1): """Read a line from the file.""" self._ensure_open() - assert self._file is not None + if self._file is None: + raise RuntimeError("Lazy file could not be opened") data = self._file.readline(size) self._position = self._file.tell() return data - + def seek(self, offset: int, whence: int = 0): """Seek to a position in the file.""" if self._closed: raise ValueError("I/O operation on closed file.") - + # Calculate new position for tracking if whence == 0: # SEEK_SET self._position = offset @@ -167,28 +169,28 @@ def seek(self, offset: int, whence: int = 0): elif whence == 2: # SEEK_END # We need to open the file to get its size self._ensure_open() - assert self._file is not None + if self._file is None: + raise RuntimeError("Lazy file could not be opened") result = self._file.seek(offset, whence) self._position = self._file.tell() return result - + # If file is already open, seek it too if self._file is not None: - result = self._file.seek(self._position) - return result - + return self._file.seek(self._position) + return self._position - + def tell(self): """Return current file position.""" if self._closed: raise ValueError("I/O operation on closed file.") - + if self._file is not None: self._position = self._file.tell() - + return self._position - + def close(self): """Close the file if it was opened.""" if self._file is not None: @@ -197,35 +199,35 @@ def close(self): self._file = None _fd_manager.unregister_file(self) self._closed = True - + def __enter__(self): return self - + def __exit__(self, exc_type, exc_val, exc_tb): self.close() - + def __len__(self): """Return file size. Requests library uses this for Content-Length.""" return self._get_size() - + @property def closed(self): """Check if the file is closed.""" return self._closed - - @property + + @property def mode(self): """Return the file mode.""" - return 'rb' - + return "rb" + def readable(self): """Return whether the file is readable.""" return not self._closed - + def writable(self): """Return whether the file is writable.""" return False - + def seekable(self): """Return whether the file supports seeking.""" return True @@ -236,13 +238,19 @@ class Utils: def validate_integration_type(integration_type: str) -> IntegrationType: if integration_type not in INTEGRATION_TYPES: raise ValueError(f"Invalid integration type: {integration_type}") - return integration_type # type: ignore - + return cast(IntegrationType, integration_type) + @staticmethod - def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = None, max_open_files: int = 100, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None) -> List[Tuple[str, Tuple[str, LazyFileLoader]]]: + def load_files_for_sending_lazy( # noqa: C901 + files: list[str], + workspace: str | None = None, + max_open_files: int = 100, + base_path: str | None = None, + base_paths: list[str] | None = None, + ) -> list[tuple[str, tuple[str, LazyFileLoader]]]: """ Prepares files for sending to the Socket API using lazy loading. - + This version doesn't open all files immediately, instead it creates LazyFileLoader objects that only open files when they're actually read. This prevents "Too many open files" errors when dealing with large numbers @@ -261,7 +269,7 @@ def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = Non """ # Configure the file descriptor manager with the specified limit _fd_manager.set_max_open_files(max_open_files) - + send_files = [] if workspace and "\\" in workspace: workspace = workspace.replace("\\", "/") @@ -269,22 +277,14 @@ def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = Non base_path = base_path.replace("\\", "/") if base_paths: base_paths = [bp.replace("\\", "/") if "\\" in bp else bp for bp in base_paths] - - for file_path in files: - # Normalize file path - if "\\" in file_path: - file_path = file_path.replace("\\", "/") + + for raw_file_path in files: + file_path = raw_file_path.replace("\\", "/") # Skip directories if os.path.isdir(file_path): continue - # Handle file path splitting safely - if "/" in file_path: - _, name = file_path.rsplit("/", 1) - else: - name = file_path - # Calculate the key name for the form data key = file_path path_stripped = False @@ -294,31 +294,31 @@ def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = Non for bp in base_paths: normalized_base_path = bp.rstrip("/") + "/" if not bp.endswith("/") else bp if key.startswith(normalized_base_path): - key = key[len(normalized_base_path):] + key = key[len(normalized_base_path) :] path_stripped = True break - elif key.startswith(bp.rstrip("/")): + if key.startswith(bp.rstrip("/")): stripped_base = bp.rstrip("/") if key.startswith(stripped_base + "/") or key == stripped_base: - key = key[len(stripped_base):] + key = key[len(stripped_base) :] key = key.lstrip("/") path_stripped = True break elif base_path: normalized_base_path = base_path.rstrip("/") + "/" if not base_path.endswith("/") else base_path if key.startswith(normalized_base_path): - key = key[len(normalized_base_path):] + key = key[len(normalized_base_path) :] path_stripped = True elif key.startswith(base_path.rstrip("/")): stripped_base = base_path.rstrip("/") if key.startswith(stripped_base + "/") or key == stripped_base: - key = key[len(stripped_base):] + key = key[len(stripped_base) :] key = key.lstrip("/") path_stripped = True # If workspace is provided and no base paths matched, fall back to workspace logic if not path_stripped and workspace and file_path.startswith(workspace): - key = file_path[len(workspace):] + key = file_path[len(workspace) :] # Remove all leading slashes (for absolute paths) while key.startswith("/"): key = key[1:] @@ -334,7 +334,7 @@ def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = Non key = key[1:] # Remove Windows drive letter if present (C:/...) - if len(key) > 2 and key[1] == ':' and (key[2] == '/' or key[2] == '\\'): + if len(key) > 2 and key[1] == ":" and (key[2] == "/" or key[2] == "\\"): key = key[2:] while key.startswith("/"): key = key[1:] @@ -346,97 +346,97 @@ def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = Non log.debug(f"Prepared {len(send_files)} files for lazy loading") return send_files - + @staticmethod - def create_tar_gz_from_files(files: List[str], workspace: Optional[str] = None) -> io.BytesIO: + def create_tar_gz_from_files(files: list[str], workspace: str | None = None) -> io.BytesIO: """ Create a tar.gz archive from a list of files. - + Args: files: List of file paths to include in the archive workspace: Base directory path to make paths relative to - + Returns: io.BytesIO: In-memory tar.gz archive """ tar_buffer = io.BytesIO() - + # Normalize workspace path if workspace and "\\" in workspace: workspace = workspace.replace("\\", "/") if workspace: workspace = workspace.rstrip("/") - - with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar: + + with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: for file_path in files: # Normalize file path normalized_path = file_path.replace("\\", "/") if "\\" in file_path else file_path - + # Skip if file doesn't exist if not os.path.exists(normalized_path): log.warning(f"File not found, skipping: {normalized_path}") continue - + # Skip directories if os.path.isdir(normalized_path): log.debug(f"Skipping directory: {normalized_path}") continue - + # Calculate arcname (the name in the archive) arcname = normalized_path if workspace: workspace_with_slash = workspace + "/" if normalized_path.startswith(workspace_with_slash): - arcname = normalized_path[len(workspace_with_slash):] + arcname = normalized_path[len(workspace_with_slash) :] elif normalized_path.startswith(workspace): - arcname = normalized_path[len(workspace):].lstrip("/") - + arcname = normalized_path[len(workspace) :].lstrip("/") + # Clean up relative path prefixes while arcname.startswith("./"): arcname = arcname[2:] while arcname.startswith("../"): arcname = arcname[3:] arcname = arcname.lstrip("/") - + # Remove Windows drive letter if present - if len(arcname) > 2 and arcname[1] == ':' and (arcname[2] == '/' or arcname[2] == '\\'): + if len(arcname) > 2 and arcname[1] == ":" and (arcname[2] == "/" or arcname[2] == "\\"): arcname = arcname[2:].lstrip("/") - + log.debug(f"Adding to archive: {normalized_path} as {arcname}") tar.add(normalized_path, arcname=arcname) - + # Seek to beginning so it can be read tar_buffer.seek(0) log.debug(f"Created tar.gz archive with {len(files)} files") return tar_buffer - + @staticmethod - def prepare_archive_files_for_upload(tar_files: Union[str, List[str]]) -> List[Tuple[str, Tuple[str, LazyFileLoader]]]: + def prepare_archive_files_for_upload(tar_files: str | list[str]) -> list[tuple[str, tuple[str, LazyFileLoader]]]: """ Prepare archive files for upload to the API. - + Args: tar_files: Path or list of paths to archive files (.tar, .tar.gz, .tgz, .zip) - + Returns: List of tuples formatted for requests multipart upload """ files_list = [tar_files] if isinstance(tar_files, str) else tar_files prepared_files = [] - + for file_path in files_list: # Normalize path normalized_path = file_path.replace("\\", "/") if "\\" in file_path else file_path - + # Get filename if "/" in normalized_path: _, filename = normalized_path.rsplit("/", 1) else: filename = normalized_path - + # Create lazy file loader lazy_file = LazyFileLoader(normalized_path, filename) prepared_files.append(("file", (filename, lazy_file))) - + log.debug(f"Prepared {len(prepared_files)} archive files for upload") return prepared_files diff --git a/socketdev/version.py b/socketdev/version.py index 4f82193..6a45bf5 100644 --- a/socketdev/version.py +++ b/socketdev/version.py @@ -1 +1 @@ -__version__ = "3.6.2" +__version__ = "3.6.3" diff --git a/socketdev/webhooks/__init__.py b/socketdev/webhooks/__init__.py index 59e6864..dd7687b 100644 --- a/socketdev/webhooks/__init__.py +++ b/socketdev/webhooks/__init__.py @@ -1,5 +1,5 @@ -import logging import json +import logging from urllib.parse import urlencode log = logging.getLogger("socketdev") @@ -23,12 +23,12 @@ def list(self, org_slug: str, **query_params) -> dict: path = f"orgs/{org_slug}/webhooks" if query_params: path += "?" + urlencode(query_params) - + response = self.api.do_request(path=path) - + if response.status_code == 200: return response.json() - + log.error(f"Error listing webhooks: {response.status_code}") log.error(response.text) return {} @@ -46,12 +46,12 @@ def create(self, org_slug: str, **kwargs) -> dict: """ path = f"orgs/{org_slug}/webhooks" payload = json.dumps(kwargs) if kwargs else "{}" - + response = self.api.do_request(path=path, method="POST", payload=payload) - + if response.status_code in (200, 201): return response.json() - + log.error(f"Error creating webhook: {response.status_code}") log.error(response.text) return {} @@ -68,12 +68,12 @@ def get(self, org_slug: str, webhook_id: str) -> dict: dict containing webhook details """ path = f"orgs/{org_slug}/webhooks/{webhook_id}" - + response = self.api.do_request(path=path) - + if response.status_code == 200: return response.json() - + log.error(f"Error getting webhook: {response.status_code}") log.error(response.text) return {} @@ -92,12 +92,12 @@ def update(self, org_slug: str, webhook_id: str, **kwargs) -> dict: """ path = f"orgs/{org_slug}/webhooks/{webhook_id}" payload = json.dumps(kwargs) if kwargs else "{}" - + response = self.api.do_request(path=path, method="PUT", payload=payload) - + if response.status_code == 200: return response.json() - + log.error(f"Error updating webhook: {response.status_code}") log.error(response.text) return {} @@ -114,12 +114,12 @@ def delete(self, org_slug: str, webhook_id: str) -> dict: dict containing the deletion response """ path = f"orgs/{org_slug}/webhooks/{webhook_id}" - + response = self.api.do_request(path=path, method="DELETE") - + if response.status_code == 200: return response.json() - + log.error(f"Error deleting webhook: {response.status_code}") log.error(response.text) return {} diff --git a/tests/conftest.py b/tests/conftest.py index a72ead3..8569df5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,11 @@ Test configuration and utilities for Socket SDK tests. """ +import json import os import tempfile -import json from pathlib import Path -from typing import Dict, Any +from typing import Any def load_env_file(): @@ -14,10 +14,10 @@ def load_env_file(): env_path = Path(__file__).parent.parent / ".env" if env_path.exists(): with open(env_path) as f: - for line in f: - line = line.strip() - if line and not line.startswith('#') and '=' in line: - key, value = line.split('=', 1) + for raw_line in f: + line = raw_line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) # Only set if not already in environment if key.strip() not in os.environ: os.environ[key.strip()] = value.strip() @@ -29,27 +29,27 @@ def load_env_file(): class TestConfig: """Configuration for Socket SDK tests.""" - + @classmethod def get_api_key(cls) -> str: """Get API key from environment.""" return os.getenv("SOCKET_SECURITY_API_KEY", "") - + @classmethod def get_org_slug(cls) -> str: """Get organization slug from environment.""" return os.getenv("SOCKET_ORG_SLUG", "") - + @classmethod def get_repo_slug(cls) -> str: """Get repository slug from environment.""" return os.getenv("SOCKET_REPO_SLUG", "") - + @classmethod def has_credentials(cls) -> bool: """Check if basic credentials are available.""" return bool(cls.get_api_key() and cls.get_org_slug()) - + @classmethod def has_repo_access(cls) -> bool: """Check if repository access is configured.""" @@ -58,32 +58,29 @@ def has_repo_access(cls) -> bool: class TestDataGenerator: """Generate test data for Socket SDK tests.""" - + @staticmethod - def create_test_package_json(name: str = "test-package", - dependencies: Dict[str, str] = None) -> Dict[str, Any]: + def create_test_package_json( + name: str = "test-package", dependencies: dict[str, str] | None = None + ) -> dict[str, Any]: """Create a test package.json structure.""" if dependencies is None: dependencies = {"lodash": "4.17.21"} - + return { "name": name, "version": "1.0.0", "description": "Test package for Socket SDK integration tests", - "dependencies": dependencies + "dependencies": dependencies, } - + @staticmethod - def create_empty_package_json(name: str = "test-empty-package") -> Dict[str, Any]: + def create_empty_package_json(name: str = "test-empty-package") -> dict[str, Any]: """Create an empty package.json structure.""" - return { - "name": name, - "version": "1.0.0", - "description": "Empty test package for Socket SDK integration tests" - } - + return {"name": name, "version": "1.0.0", "description": "Empty test package for Socket SDK integration tests"} + @staticmethod - def create_malware_package_json(name: str = "test-malware-package") -> Dict[str, Any]: + def create_malware_package_json(name: str = "test-malware-package") -> dict[str, Any]: """Create a package.json with known malware for testing.""" return { "name": name, @@ -91,77 +88,78 @@ def create_malware_package_json(name: str = "test-malware-package") -> Dict[str, "description": "Package with known malware for testing", "dependencies": { "flowframe": "1.0.1" # Known malware package - } + }, } - + @classmethod - def write_package_json(cls, package_data: Dict[str, Any], - file_path: str) -> None: + def write_package_json(cls, package_data: dict[str, Any], file_path: str) -> None: """Write package.json data to a file.""" - with open(file_path, 'w') as f: + with open(file_path, "w") as f: json.dump(package_data, f, indent=2) class TestFileManager: """Manage temporary files for testing.""" - + def __init__(self): self.temp_dir = tempfile.mkdtemp() self.files = [] - - def create_package_json(self, name: str = "package.json", - content: Dict[str, Any] = None) -> str: + + def create_package_json(self, name: str = "package.json", content: dict[str, Any] | None = None) -> str: """Create a temporary package.json file.""" if content is None: content = TestDataGenerator.create_test_package_json() - + file_path = os.path.join(self.temp_dir, name) TestDataGenerator.write_package_json(content, file_path) self.files.append(file_path) return file_path - + def create_empty_package_json(self, name: str = "package-empty.json") -> str: """Create a temporary empty package.json file.""" content = TestDataGenerator.create_empty_package_json() return self.create_package_json(name, content) - + def create_malware_package_json(self, name: str = "package-malware.json") -> str: """Create a temporary malware package.json file.""" content = TestDataGenerator.create_malware_package_json() return self.create_package_json(name, content) - + def cleanup(self): """Clean up temporary files.""" import shutil + if os.path.exists(self.temp_dir): shutil.rmtree(self.temp_dir) - + def __enter__(self): return self - + def __exit__(self, exc_type, exc_val, exc_tb): self.cleanup() def skip_if_no_credentials(test_func): """Decorator to skip tests if credentials are not available.""" + def wrapper(*args, **kwargs): if not TestConfig.has_credentials(): import unittest - raise unittest.SkipTest( - "SOCKET_SECURITY_API_KEY and SOCKET_ORG_SLUG required" - ) + + raise unittest.SkipTest("SOCKET_SECURITY_API_KEY and SOCKET_ORG_SLUG required") return test_func(*args, **kwargs) + return wrapper def skip_if_no_repo_access(test_func): """Decorator to skip tests if repository access is not available.""" + def wrapper(*args, **kwargs): if not TestConfig.has_repo_access(): import unittest - raise unittest.SkipTest( - "SOCKET_SECURITY_API_KEY, SOCKET_ORG_SLUG, and SOCKET_REPO_SLUG required" - ) + + raise unittest.SkipTest("SOCKET_SECURITY_API_KEY, SOCKET_ORG_SLUG, and SOCKET_REPO_SLUG required") return test_func(*args, **kwargs) + return wrapper diff --git a/tests/integration/test_all_endpoints.py b/tests/integration/test_all_endpoints.py index e36f074..bf3e3e8 100644 --- a/tests/integration/test_all_endpoints.py +++ b/tests/integration/test_all_endpoints.py @@ -7,11 +7,12 @@ Run with: python -m pytest tests/integration/test_all_endpoints.py -v """ -import unittest +import json import os import tempfile -import json +import unittest from unittest.mock import Mock, patch + from socketdev import socketdev from socketdev.fullscans import FullScanParams @@ -21,7 +22,7 @@ class TestAllEndpointsMocked(unittest.TestCase): def setUp(self): """Set up test environment with mocked API.""" - self.requests_patcher = patch('socketdev.core.api.requests') + self.requests_patcher = patch("socketdev.core.api.requests") self.mock_requests = self.requests_patcher.start() self.sdk = socketdev(token="test-token") @@ -55,7 +56,7 @@ def _mock_created_response(self, data=None): def test_dependencies_post_mocked(self): """Test dependencies post endpoint.""" self._mock_success_response({"packages": []}) - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() try: @@ -94,15 +95,14 @@ def test_diffscans_create_from_ids_mocked(self): def test_diffscans_create_from_repo_mocked(self): """Test diffscans create_from_repo endpoint.""" self._mock_created_response({"id": "repo-diff-scan"}) - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() try: - result = self.sdk.diffscans.create_from_repo( - "test-org", "test-repo", - [("file", ("package.json", open(f.name, "rb")))], - {"description": "test"} - ) + with open(f.name, "rb") as fixture_file: + result = self.sdk.diffscans.create_from_repo( + "test-org", "test-repo", [("file", ("package.json", fixture_file))], {"description": "test"} + ) self.assertEqual(result["id"], "repo-diff-scan") finally: os.unlink(f.name) @@ -132,11 +132,11 @@ def test_export_spdx_bom_mocked(self): result = self.sdk.export.spdx_bom("test-org", "scan-123") self.assertIn("spdxVersion", result) - def test_export_get_mocked(self): - """Test export get endpoint.""" - self._mock_success_response({"exports": []}) - result = self.sdk.export.get("test-org") - self.assertIn("exports", result) + def test_export_openvex_bom_mocked(self): + """Export OpenVEX documents through the supported export method.""" + self._mock_success_response({"statements": []}) + result = self.sdk.export.openvex_bom("test-org", "scan-123") + self.assertIn("statements", result) # FullScans endpoints def test_fullscans_get_mocked(self): @@ -149,11 +149,12 @@ def test_fullscans_post_mocked(self): """Test fullscans post endpoint.""" self._mock_created_response({"id": "new-scan"}) params = FullScanParams(repo="test-repo", org_slug="test-org") - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() try: - result = self.sdk.fullscans.post([("file", ("package.json", open(f.name, "rb")))], params) + with open(f.name, "rb") as fixture_file: + result = self.sdk.fullscans.post([("file", ("package.json", fixture_file))], params) self.assertEqual(result["id"], "new-scan") finally: os.unlink(f.name) @@ -209,7 +210,7 @@ def test_purl_post_mocked(self): """Test org-scoped purl post endpoint.""" mock_response = Mock() mock_response.status_code = 200 - mock_response.headers = {'content-type': 'application/x-ndjson'} + mock_response.headers = {"content-type": "application/x-ndjson"} mock_response.text = '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", "type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' self.mock_requests.request.return_value = mock_response result = self.sdk.purl.post("false", [{"purl": "pkg:npm/lodash@4.18.1"}], org_slug="test-org") @@ -232,11 +233,12 @@ def test_report_list_mocked(self): def test_report_create_mocked(self): """Test report create endpoint.""" self._mock_created_response({"id": "report-123"}) - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() try: - result = self.sdk.report.create([("file", ("package.json", open(f.name, "rb")))]) + with open(f.name, "rb") as fixture_file: + result = self.sdk.report.create([("file", ("package.json", fixture_file))]) self.assertEqual(result["id"], "report-123") finally: os.unlink(f.name) @@ -313,7 +315,7 @@ def test_apitokens_update_mocked(self): def test_apitokens_list_mocked(self): """Test apitokens list endpoint.""" self._mock_success_response({"tokens": []}) - result = self.sdk.apitokens.list() + result = self.sdk.apitokens.list("test-org") self.assertIn("tokens", result) def test_auditlog_get_mocked(self): @@ -331,25 +333,25 @@ def test_alerttypes_get_mocked(self): def test_labels_get_mocked(self): """Test labels get endpoint.""" self._mock_success_response({"label": "test-label"}) - result = self.sdk.labels.get("test-org", 1, "test-key") + result = self.sdk.labels.get("test-org", "label-1") self.assertIn("label", result) - def test_labels_put_mocked(self): - """Test labels put endpoint.""" - self._mock_success_response({"updated": True}) - result = self.sdk.labels.put("test-org", 1, {"test": {"key": {"value": "data"}}}) + def test_label_settings_put_mocked(self): + """Label settings use the settings endpoint and a created response.""" + self._mock_created_response({"updated": True}) + result = self.sdk.labels.setting.put("test-org", 1, {"test": {"key": {"value": "data"}}}) self.assertIn("updated", result) def test_labels_delete_mocked(self): """Test labels delete endpoint.""" self._mock_success_response({"deleted": True}) - result = self.sdk.labels.delete("test-org", 1, "test-key") + result = self.sdk.labels.delete("test-org", "label-1") self.assertIn("deleted", result) - def test_licensemetadata_get_mocked(self): - """Test licensemetadata get endpoint.""" + def test_licensemetadata_post_mocked(self): + """License metadata is requested through POST.""" self._mock_success_response({"licenses": []}) - result = self.sdk.licensemetadata.get() + result = self.sdk.licensemetadata.post(["MIT"]) self.assertIn("licenses", result) @@ -362,30 +364,25 @@ def setUpClass(cls): cls.api_key = os.getenv("SOCKET_SECURITY_API_KEY", "") cls.org_slug = os.getenv("SOCKET_ORG_SLUG", "") cls.repo_slug = os.getenv("SOCKET_REPO_SLUG", "") - + if not cls.api_key or not cls.org_slug: - raise unittest.SkipTest( - "SOCKET_SECURITY_API_KEY and SOCKET_ORG_SLUG required for integration tests" - ) - + raise unittest.SkipTest("SOCKET_SECURITY_API_KEY and SOCKET_ORG_SLUG required for integration tests") + cls.sdk = socketdev(token=cls.api_key) cls.temp_dir = tempfile.mkdtemp() cls.package_json_path = os.path.join(cls.temp_dir, "package.json") - + # Create test package.json - test_package = { - "name": "test-integration-package", - "version": "1.0.0", - "dependencies": {"lodash": "4.18.1"} - } - with open(cls.package_json_path, 'w') as f: + test_package = {"name": "test-integration-package", "version": "1.0.0", "dependencies": {"lodash": "4.18.1"}} + with open(cls.package_json_path, "w") as f: json.dump(test_package, f, indent=2) @classmethod def tearDownClass(cls): """Clean up test environment.""" import shutil - if hasattr(cls, 'temp_dir'): + + if hasattr(cls, "temp_dir"): shutil.rmtree(cls.temp_dir) def _try_endpoint(self, endpoint_func, *args, **kwargs): @@ -517,10 +514,7 @@ def test_auditlog_get_integration(self): # Dependencies endpoint def test_dependencies_get_integration(self): """Test dependencies get endpoint.""" - result = self._try_endpoint( - self.sdk.dependencies.get, - self.org_slug, "npm", "lodash", "4.18.1" - ) + result = self._try_endpoint(self.sdk.dependencies.get, self.org_slug, "npm", "lodash", "4.18.1") if result: self.assertIsInstance(result, dict) @@ -544,25 +538,21 @@ def test_fullscans_integration(self): branch="test-branch", commit_message="Integration test scan", commit_hash="1234567890abcdef1234567890abcdef12345678", - integration_type="api" + integration_type="api", ) with open(self.package_json_path, "rb") as f: files = [("file", ("package.json", f))] result = self._try_endpoint(self.sdk.fullscans.post, files, params) - + if result and "id" in result: scan_id = result["id"] - + # Try to get the scan - get_result = self._try_endpoint( - self.sdk.fullscans.get, - self.org_slug, - {"id": scan_id} - ) + get_result = self._try_endpoint(self.sdk.fullscans.get, self.org_slug, {"id": scan_id}) if get_result: self.assertIsInstance(get_result, dict) - + # Clean up - try to delete the scan self._try_endpoint(self.sdk.fullscans.delete, self.org_slug, scan_id) diff --git a/tests/integration/test_comprehensive_integration.py b/tests/integration/test_comprehensive_integration.py index 4259b6b..c21736b 100644 --- a/tests/integration/test_comprehensive_integration.py +++ b/tests/integration/test_comprehensive_integration.py @@ -9,11 +9,12 @@ Run with: python -m pytest tests/integration/test_comprehensive_integration.py -v """ -import unittest +import json import os -import tempfile import shutil -import json +import tempfile +import unittest + from socketdev import socketdev from socketdev.fullscans import FullScanParams @@ -27,51 +28,42 @@ def setUpClass(cls): cls.api_key = os.getenv("SOCKET_SECURITY_API_KEY", "") cls.org_slug = os.getenv("SOCKET_ORG_SLUG", "") cls.repo_slug = os.getenv("SOCKET_REPO_SLUG", "") - + # Only require API key and org_slug for most tests missing = [] if not cls.api_key: missing.append("SOCKET_SECURITY_API_KEY") if not cls.org_slug: missing.append("SOCKET_ORG_SLUG") - + if missing: raise RuntimeError( f"Missing required environment variables: {', '.join(missing)}\n" f"SOCKET_REPO_SLUG is optional for some tests." ) - + cls.sdk = socketdev(token=cls.api_key) - + # Prepare temp directory for test files cls.temp_dir = tempfile.mkdtemp() cls.package_json_path = os.path.join(cls.temp_dir, "package.json") cls.package_empty_path = os.path.join(cls.temp_dir, "package-empty.json") - + # Create test package.json files - test_package = { - "name": "test-integration-project", - "version": "1.0.0", - "dependencies": { - "lodash": "4.18.1" - } - } - - empty_package = { - "name": "test-empty-project", - "version": "1.0.0" - } - - with open(cls.package_json_path, 'w') as f: + test_package = {"name": "test-integration-project", "version": "1.0.0", "dependencies": {"lodash": "4.18.1"}} + + empty_package = {"name": "test-empty-project", "version": "1.0.0"} + + with open(cls.package_json_path, "w") as f: json.dump(test_package, f, indent=2) - - with open(cls.package_empty_path, 'w') as f: + + with open(cls.package_empty_path, "w") as f: json.dump(empty_package, f, indent=2) @classmethod def tearDownClass(cls): """Clean up test environment.""" - if hasattr(cls, 'temp_dir'): + if hasattr(cls, "temp_dir"): shutil.rmtree(cls.temp_dir) def setUp(self): @@ -87,7 +79,7 @@ def tearDown(self): self.sdk.diffscans.delete(self.org_slug, diff_scan_id) except Exception: pass # Ignore cleanup errors - + for scan_id in self.created_scan_ids: try: self.sdk.fullscans.delete(self.org_slug, scan_id) @@ -128,7 +120,7 @@ def test_fullscans_basic_workflow(self): """Test basic full scan workflow.""" if not self.repo_slug: self.skipTest("SOCKET_REPO_SLUG not provided") - + params = FullScanParams( org_slug=self.org_slug, repo=self.repo_slug, @@ -137,24 +129,24 @@ def test_fullscans_basic_workflow(self): commit_hash="1234567890abcdef1234567890abcdef12345678", pull_request=999, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) # Create a full scan with open(self.package_json_path, "rb") as f: files = [("file", ("package.json", f))] result = self.sdk.fullscans.post(files=files, params=params) - + self.assertIsInstance(result, dict) self.assertIn("id", result) - + scan_id = result["id"] self.created_scan_ids.append(scan_id) - + # Get the scan scan_info = self.sdk.fullscans.get(self.org_slug, {"id": scan_id}) self.assertIsInstance(scan_info, dict) - + # List scans scans_list = self.sdk.fullscans.list(self.org_slug) self.assertIsInstance(scans_list, dict) @@ -163,7 +155,7 @@ def test_diffscans_complete_workflow(self): """Test complete diff scan workflow.""" if not self.repo_slug: self.skipTest("SOCKET_REPO_SLUG not provided") - + # Create 'before' scan with empty package.json params_before = FullScanParams( org_slug=self.org_slug, @@ -173,17 +165,17 @@ def test_diffscans_complete_workflow(self): commit_hash="before000000000000000000000000000000000000", pull_request=998, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) - + with open(self.package_empty_path, "rb") as f: files_before = [("file", ("package.json", f))] before_result = self.sdk.fullscans.post(files=files_before, params=params_before) - + self.assertIn("id", before_result) before_id = before_result["id"] self.created_scan_ids.append(before_id) - + # Create 'after' scan with dependencies params_after = FullScanParams( org_slug=self.org_slug, @@ -193,41 +185,37 @@ def test_diffscans_complete_workflow(self): commit_hash="after0000000000000000000000000000000000000", pull_request=999, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) - + with open(self.package_json_path, "rb") as f: files_after = [("file", ("package.json", f))] after_result = self.sdk.fullscans.post(files=files_after, params=params_after) - + self.assertIn("id", after_result) after_id = after_result["id"] self.created_scan_ids.append(after_id) - + # Create diff scan from IDs - diff_params = { - "before": before_id, - "after": after_id, - "description": "Integration test diff scan" - } - + diff_params = {"before": before_id, "after": after_id, "description": "Integration test diff scan"} + diff_result = self.sdk.diffscans.create_from_ids(self.org_slug, diff_params) self.assertIsInstance(diff_result, dict) - + # Extract diff scan ID diff_scan_id = ( - diff_result.get("id") or - diff_result.get("diff_scan_id") or - (diff_result.get("diff_scan", {}).get("id") if "diff_scan" in diff_result else None) + diff_result.get("id") + or diff_result.get("diff_scan_id") + or (diff_result.get("diff_scan", {}).get("id") if "diff_scan" in diff_result else None) ) - + self.assertIsNotNone(diff_scan_id) self.created_diff_scan_ids.append(diff_scan_id) - + # Get the diff scan get_result = self.sdk.diffscans.get(self.org_slug, diff_scan_id) self.assertIsInstance(get_result, dict) - + # Get GFM output gfm_result = self.sdk.diffscans.gfm(self.org_slug, diff_scan_id) self.assertIsInstance(gfm_result, dict) @@ -236,27 +224,22 @@ def test_diffscans_from_repo(self): """Test creating diff scans from repository.""" if not self.repo_slug: self.skipTest("SOCKET_REPO_SLUG not provided") - + with open(self.package_json_path, "rb") as f: files = [("file", ("package.json", f))] params = {"description": "Integration test diff scan from repo"} - - result = self.sdk.diffscans.create_from_repo( - self.org_slug, - self.repo_slug, - files, - params - ) - + + result = self.sdk.diffscans.create_from_repo(self.org_slug, self.repo_slug, files, params) + self.assertIsInstance(result, dict) - + # Extract diff scan ID for cleanup diff_scan_id = ( - result.get("id") or - result.get("diff_scan_id") or - (result.get("diff_scan", {}).get("id") if "diff_scan" in result else None) + result.get("id") + or result.get("diff_scan_id") + or (result.get("diff_scan", {}).get("id") if "diff_scan" in result else None) ) - + if diff_scan_id: self.created_diff_scan_ids.append(diff_scan_id) @@ -295,7 +278,7 @@ def test_report_endpoints(self): """Test report endpoints.""" if not self.repo_slug: self.skipTest("SOCKET_REPO_SLUG not provided") - + # List reports reports = self.sdk.report.list(self.org_slug) self.assertIsInstance(reports, dict) diff --git a/tests/integration/test_diffscans_integration.py b/tests/integration/test_diffscans_integration.py index 54ddf6b..87e7eb3 100644 --- a/tests/integration/test_diffscans_integration.py +++ b/tests/integration/test_diffscans_integration.py @@ -1,9 +1,11 @@ -import unittest import os -import tempfile import shutil +import tempfile +import unittest + from socketdev import socketdev -from socketdev.fullscans import FullScanParams, IntegrationType +from socketdev.fullscans import FullScanParams + class TestDiffScansIntegration(unittest.TestCase): @classmethod @@ -12,11 +14,13 @@ def setUpClass(cls): org_slug = os.getenv("SOCKET_ORG_SLUG", "") repo_slug = os.getenv("SOCKET_REPO_SLUG", "") missing = [ - name for name, val in [ + name + for name, val in [ ("SOCKET_SECURITY_API_KEY", api_key), ("SOCKET_ORG_SLUG", org_slug), ("SOCKET_REPO_SLUG", repo_slug), - ] if not val + ] + if not val ] if missing: raise RuntimeError(f"Missing required environment variables: {', '.join(missing)}") @@ -27,10 +31,7 @@ def setUpClass(cls): cls.temp_dir = tempfile.mkdtemp() cls.package_json_path = os.path.join(cls.temp_dir, "package.json") # Copy sample package.json - shutil.copyfile( - os.path.join(os.path.dirname(__file__), "package.json"), - cls.package_json_path - ) + shutil.copyfile(os.path.join(os.path.dirname(__file__), "package.json"), cls.package_json_path) @classmethod def tearDownClass(cls): @@ -45,17 +46,14 @@ def test_full_diff_scan_flow(self): commit_hash="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", pull_request=123, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) # 1. Create 'before' full scan with empty package.json empty_package_path = os.path.join(os.path.dirname(__file__), "package-empty.json") with open(empty_package_path, "rb") as f_before: files_before = [("file", ("package.json", f_before))] - before_result = self.sdk.fullscans.post( - files=files_before, - params=params_before - ) + before_result = self.sdk.fullscans.post(files=files_before, params=params_before) if not before_result or "id" not in before_result: print("Full scan creation failed. Response:", before_result) self.fail(f"Full scan creation failed: {before_result}") @@ -70,16 +68,13 @@ def test_full_diff_scan_flow(self): commit_hash="beefdeadbeefdeadbeefdeadbeefdeadbeefdead", pull_request=124, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) # 2. Create 'after' full scan with malware package.json with open(self.package_json_path, "rb") as f_after: files_after = [("file", ("package.json", f_after))] - after_result = self.sdk.fullscans.post( - files=files_after, - params=params_after - ) + after_result = self.sdk.fullscans.post(files=files_after, params=params_after) if not after_result or "id" not in after_result: print("Full scan creation failed. Response:", after_result) self.fail(f"Full scan creation failed: {after_result}") @@ -90,8 +85,8 @@ def test_full_diff_scan_flow(self): if not before_id or not after_id: print("before_id:", before_id) print("after_id:", after_id) - self.assertIsNotNone(before_id, f"before_id is None. Full scan creation failed.") - self.assertIsNotNone(after_id, f"after_id is None. Full scan creation failed.") + self.assertIsNotNone(before_id, "before_id is None. Full scan creation failed.") + self.assertIsNotNone(after_id, "after_id is None. Full scan creation failed.") diff_body = {"before": before_id, "after": after_id, "description": "Integration test diff scan"} diff_result = self.sdk.diffscans.create_from_ids(self.org_slug, diff_body) @@ -135,11 +130,7 @@ def test_diffscans_create_from_repo(self): result = self.sdk.diffscans.create_from_repo(self.org_slug, self.repo_slug, files, params) self.assertTrue("id" in result or "diff_scan" in result) # Cleanup if possible - diff_scan_id = ( - (result.get("diff_scan", {}) or {}).get("id") - or result.get("id") - or result.get("diff_scan_id") - ) + diff_scan_id = (result.get("diff_scan", {}) or {}).get("id") or result.get("id") or result.get("diff_scan_id") if diff_scan_id: self.sdk.diffscans.delete(self.org_slug, diff_scan_id) @@ -154,7 +145,7 @@ def test_diffscans_get(self): commit_hash="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", pull_request=123, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) empty_package_path = os.path.join(os.path.dirname(__file__), "package-empty.json") with open(empty_package_path, "rb") as f_before: @@ -169,7 +160,7 @@ def test_diffscans_get(self): commit_hash="beefdeadbeefdeadbeefdeadbeefdeadbeefdead", pull_request=124, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) with open(self.package_json_path, "rb") as f_after: files_after = [("file", ("package.json", f_after))] @@ -184,8 +175,8 @@ def test_diffscans_get(self): ) get_result = self.sdk.diffscans.get(self.org_slug, diff_scan_id) self.assertTrue( - ("diff_scan" in get_result and get_result["diff_scan"]["id"] == diff_scan_id) or - (get_result.get("id") == diff_scan_id) + ("diff_scan" in get_result and get_result["diff_scan"]["id"] == diff_scan_id) + or (get_result.get("id") == diff_scan_id) ) # Cleanup self.sdk.diffscans.delete(self.org_slug, diff_scan_id) @@ -203,7 +194,7 @@ def test_diffscans_gfm(self): commit_hash="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", pull_request=123, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) empty_package_path = os.path.join(os.path.dirname(__file__), "package-empty.json") with open(empty_package_path, "rb") as f_before: @@ -218,7 +209,7 @@ def test_diffscans_gfm(self): commit_hash="beefdeadbeefdeadbeefdeadbeefdeadbeefdead", pull_request=124, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) with open(self.package_json_path, "rb") as f_after: files_after = [("file", ("package.json", f_after))] @@ -249,7 +240,7 @@ def test_diffscans_delete(self): commit_hash="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", pull_request=123, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) empty_package_path = os.path.join(os.path.dirname(__file__), "package-empty.json") with open(empty_package_path, "rb") as f_before: @@ -264,7 +255,7 @@ def test_diffscans_delete(self): commit_hash="beefdeadbeefdeadbeefdeadbeefdeadbeefdead", pull_request=124, committers=["integration-tester"], - integration_type="api" + integration_type="api", ) with open(self.package_json_path, "rb") as f_after: files_after = [("file", ("package.json", f_after))] @@ -288,7 +279,7 @@ def test_diffscans_list(self): result = self.sdk.diffscans.list(self.org_slug) self.assertIn("results", result) - def run(self, result=None): + def run(self, result=None): # noqa: C901 try: super().run(result) if self._testMethodName == "test_full_diff_scan_flow": @@ -303,7 +294,7 @@ def run(self, result=None): print("Test delete: success") elif self._testMethodName == "test_diffscans_list": print("Test list: success") - except Exception as e: + except Exception: if self._testMethodName == "test_full_diff_scan_flow": print("Test create_from_ids: failure") elif self._testMethodName == "test_diffscans_create_from_repo": diff --git a/tests/integration/test_new_endpoints.py b/tests/integration/test_new_endpoints.py index 6bac7f9..bbe23b7 100644 --- a/tests/integration/test_new_endpoints.py +++ b/tests/integration/test_new_endpoints.py @@ -7,9 +7,10 @@ Run with: python -m pytest tests/integration/test_new_endpoints.py -v """ -import unittest import os +import unittest from unittest.mock import Mock, patch + from socketdev import socketdev @@ -21,12 +22,10 @@ def setUpClass(cls): """Set up test environment.""" cls.api_key = os.getenv("SOCKET_SECURITY_API_KEY", "") cls.org_slug = os.getenv("SOCKET_ORG_SLUG", "") - + if not cls.api_key or not cls.org_slug: - raise unittest.SkipTest( - "SOCKET_SECURITY_API_KEY and SOCKET_ORG_SLUG required for integration tests" - ) - + raise unittest.SkipTest("SOCKET_SECURITY_API_KEY and SOCKET_ORG_SLUG required for integration tests") + cls.sdk = socketdev(token=cls.api_key) def test_threatfeed_org_endpoint(self): @@ -52,11 +51,7 @@ def test_threatfeed_global_endpoint(self): def test_threatfeed_with_parameters(self): """Test threat feed with query parameters.""" try: - result = self.sdk.threatfeed.get( - org_slug=self.org_slug, - per_page=10, - sort="created_at" - ) + result = self.sdk.threatfeed.get(org_slug=self.org_slug, per_page=10, sort="created_at") self.assertIsInstance(result, dict) self.assertIn("results", result) except Exception as e: @@ -64,7 +59,7 @@ def test_threatfeed_with_parameters(self): def test_analytics_deprecated_warning(self): """Test that analytics methods issue deprecation warnings.""" - with patch('socketdev.analytics.log') as mock_log: + with patch("socketdev.analytics.log") as mock_log: try: self.sdk.analytics.get_org("dependencies") # Should log a deprecation warning @@ -131,37 +126,27 @@ def test_threatfeed_org_scoped_mocked(self): mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { - "results": [ - {"id": "threat1", "type": "malware"}, - {"id": "threat2", "type": "typosquatting"} - ], - "nextPage": None + "results": [{"id": "threat1", "type": "malware"}, {"id": "threat2", "type": "typosquatting"}], + "nextPage": None, } self.mock_api.do_request.return_value = mock_response - + result = self.sdk.threatfeed.get(org_slug="test-org") - + self.assertEqual(len(result["results"]), 2) - self.mock_api.do_request.assert_called_once_with( - path="orgs/test-org/threat-feed" - ) + self.mock_api.do_request.assert_called_once_with(path="orgs/test-org/threat-feed") def test_threatfeed_global_mocked(self): """Test global threat feed endpoint with mocked response.""" mock_response = Mock() mock_response.status_code = 200 - mock_response.json.return_value = { - "results": [{"id": "global-threat", "type": "malware"}], - "nextPage": None - } + mock_response.json.return_value = {"results": [{"id": "global-threat", "type": "malware"}], "nextPage": None} self.mock_api.do_request.return_value = mock_response - + result = self.sdk.threatfeed.get() - + self.assertEqual(len(result["results"]), 1) - self.mock_api.do_request.assert_called_once_with( - path="threat-feed" - ) + self.mock_api.do_request.assert_called_once_with(path="threat-feed") def test_threatfeed_with_params_mocked(self): """Test threat feed with parameters.""" @@ -169,16 +154,10 @@ def test_threatfeed_with_params_mocked(self): mock_response.status_code = 200 mock_response.json.return_value = {"results": [], "nextPage": None} self.mock_api.do_request.return_value = mock_response - - result = self.sdk.threatfeed.get( - org_slug="test-org", - per_page=5, - sort="created_at" - ) - - self.mock_api.do_request.assert_called_once_with( - path="orgs/test-org/threat-feed?per_page=5&sort=created_at" - ) + + self.sdk.threatfeed.get(org_slug="test-org", per_page=5, sort="created_at") + + self.mock_api.do_request.assert_called_once_with(path="orgs/test-org/threat-feed?per_page=5&sort=created_at") def test_threatfeed_error_handling(self): """Test threat feed error handling.""" @@ -186,10 +165,10 @@ def test_threatfeed_error_handling(self): mock_response.status_code = 404 mock_response.text = "Not found" self.mock_api.do_request.return_value = mock_response - - with patch('socketdev.threatfeed.log') as mock_log: + + with patch("socketdev.threatfeed.log") as mock_log: result = self.sdk.threatfeed.get(org_slug="test-org") - + # Should return default structure on error self.assertEqual(result, {"results": [], "nextPage": None}) mock_log.error.assert_called() @@ -198,24 +177,16 @@ def test_apitokens_create_mocked(self): """Test API token creation with mocked response.""" mock_response = Mock() mock_response.status_code = 201 - mock_response.json.return_value = { - "id": "token123", - "name": "test-token", - "created_at": "2025-01-01T00:00:00Z" - } + mock_response.json.return_value = {"id": "token123", "name": "test-token", "created_at": "2025-01-01T00:00:00Z"} self.mock_api.do_request.return_value = mock_response - - result = self.sdk.apitokens.create( - org_slug="test-org", - name="test-token", - description="Test token" - ) - + + result = self.sdk.apitokens.create(org_slug="test-org", name="test-token", description="Test token") + self.assertEqual(result["id"], "token123") self.mock_api.do_request.assert_called_once_with( path="orgs/test-org/api-tokens", method="POST", - payload='{"name": "test-token", "description": "Test token"}' + payload='{"name": "test-token", "description": "Test token"}', ) def test_apitokens_update_mocked(self): @@ -224,18 +195,14 @@ def test_apitokens_update_mocked(self): mock_response.status_code = 200 mock_response.json.return_value = {"id": "token123", "name": "updated-token"} self.mock_api.do_request.return_value = mock_response - - result = self.sdk.apitokens.update( - org_slug="test-org", - token_id="token123", - name="updated-token" - ) - + + result = self.sdk.apitokens.update(org_slug="test-org", token_id="token123", name="updated-token") + self.assertEqual(result["name"], "updated-token") self.mock_api.do_request.assert_called_once_with( path="orgs/test-org/api-tokens/update", method="POST", - payload='{"token_id": "token123", "name": "updated-token"}' + payload='{"token_id": "token123", "name": "updated-token"}', ) def test_analytics_deprecation_warning(self): @@ -244,10 +211,10 @@ def test_analytics_deprecation_warning(self): mock_response.status_code = 200 mock_response.json.return_value = [] self.mock_api.do_request.return_value = mock_response - - with patch('socketdev.analytics.log') as mock_log: - result = self.sdk.analytics.get_org("dependencies") - + + with patch("socketdev.analytics.log") as mock_log: + self.sdk.analytics.get_org("dependencies") + # Should log deprecation warning mock_log.warning.assert_called_once() warning_msg = mock_log.warning.call_args[0][0] @@ -260,10 +227,10 @@ def test_analytics_get_repo_deprecation(self): mock_response.status_code = 200 mock_response.json.return_value = [] self.mock_api.do_request.return_value = mock_response - - with patch('socketdev.analytics.log') as mock_log: - result = self.sdk.analytics.get_repo("test-repo", "alerts") - + + with patch("socketdev.analytics.log") as mock_log: + self.sdk.analytics.get_repo("test-repo", "alerts") + # Should log deprecation warning mock_log.warning.assert_called_once() warning_msg = mock_log.warning.call_args[0][0] @@ -280,16 +247,34 @@ def setUp(self): def test_all_endpoints_have_api_reference(self): """Test that all endpoint modules have the api attribute.""" endpoints = [ - 'dependencies', 'export', 'fullscans', 'historical', - 'npm', 'openapi', 'org', 'purl', 'quota', 'report', 'repos', - 'repositories', 'sbom', 'settings', 'triage', 'labels', - 'licensemetadata', 'diffscans', 'threatfeed', 'apitokens', - 'auditlog', 'analytics', 'alerttypes' + "dependencies", + "export", + "fullscans", + "historical", + "npm", + "openapi", + "org", + "purl", + "quota", + "report", + "repos", + "repositories", + "sbom", + "settings", + "triage", + "labels", + "licensemetadata", + "diffscans", + "threatfeed", + "apitokens", + "auditlog", + "analytics", + "alerttypes", ] - + for endpoint_name in endpoints: endpoint = getattr(self.sdk, endpoint_name) - if hasattr(endpoint, 'api'): # Skip utils which doesn't have api + if hasattr(endpoint, "api"): # Skip utils which doesn't have api self.assertIsNotNone(endpoint.api, f"{endpoint_name} should have api reference") def test_sdk_components_initialization(self): @@ -297,20 +282,38 @@ def test_sdk_components_initialization(self): # This tests that there are no import errors or initialization issues try: sdk = socketdev(token="test-token") - + # Try to access each component components = [ - sdk.dependencies, sdk.export, sdk.fullscans, sdk.historical, - sdk.npm, sdk.openapi, sdk.org, sdk.purl, sdk.quota, - sdk.report, sdk.repos, sdk.repositories, sdk.sbom, - sdk.settings, sdk.triage, sdk.utils, sdk.labels, - sdk.licensemetadata, sdk.diffscans, sdk.threatfeed, - sdk.apitokens, sdk.auditlog, sdk.analytics, sdk.alerttypes + sdk.dependencies, + sdk.export, + sdk.fullscans, + sdk.historical, + sdk.npm, + sdk.openapi, + sdk.org, + sdk.purl, + sdk.quota, + sdk.report, + sdk.repos, + sdk.repositories, + sdk.sbom, + sdk.settings, + sdk.triage, + sdk.utils, + sdk.labels, + sdk.licensemetadata, + sdk.diffscans, + sdk.threatfeed, + sdk.apitokens, + sdk.auditlog, + sdk.analytics, + sdk.alerttypes, ] - + for component in components: self.assertIsNotNone(component) - + except Exception as e: self.fail(f"SDK component initialization failed: {e}") diff --git a/tests/run_tests.py b/tests/run_tests.py old mode 100644 new mode 100755 index d4b4228..820ca9e --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -6,7 +6,7 @@ Usage: cd tests && python run_tests.py --unit # Run unit tests only - cd tests && python run_tests.py --integration # Run integration tests only + cd tests && python run_tests.py --integration # Run integration tests only cd tests && python run_tests.py --all # Run all tests cd tests && python run_tests.py --new-endpoints # Run tests for new endpoints cd tests && python run_tests.py --help # Show help @@ -14,8 +14,8 @@ import argparse import os -import sys import subprocess +import sys from pathlib import Path @@ -25,10 +25,10 @@ def load_env_file(): if env_path.exists(): print(f"๐Ÿ“ Loading environment from {env_path}") with open(env_path) as f: - for line in f: - line = line.strip() - if line and not line.startswith('#') and '=' in line: - key, value = line.split('=', 1) + for raw_line in f: + line = raw_line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) os.environ[key.strip()] = value.strip() return True return False @@ -37,45 +37,45 @@ def load_env_file(): def check_environment(): """Check if the environment is properly configured.""" print("๐Ÿ” Checking environment...") - + # Check if we're in a virtual environment - in_venv = hasattr(sys, 'real_prefix') or ( - hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix - ) - + in_venv = hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix) + if in_venv: print("โœ… Running in virtual environment") else: print("โš ๏ธ Not running in virtual environment") print(" Consider activating .venv: source .venv/bin/activate") - + # Check for pytest try: import pytest + print(f"โœ… pytest available: {pytest.__version__}") except ImportError: print("โŒ pytest not found. Install with: pip install pytest") return False - + # Check for socketdev try: import socketdev + print(f"โœ… socketdev available: {socketdev.__version__}") except ImportError: print("โŒ socketdev not found. Install with: pip install -e .") return False - + return True def check_credentials(): """Check if API credentials are configured.""" print("\n๐Ÿ” Checking credentials...") - + api_key = os.getenv("SOCKET_SECURITY_API_KEY", "") org_slug = os.getenv("SOCKET_ORG_SLUG", "") repo_slug = os.getenv("SOCKET_REPO_SLUG", "") - + if api_key and org_slug: print("โœ… Basic credentials available (API key and org slug)") if repo_slug: @@ -89,71 +89,66 @@ def check_credentials(): if not org_slug: print(" - SOCKET_ORG_SLUG not set") print(" Integration tests will be skipped") - + return bool(api_key and org_slug) def run_unit_tests(): """Run unit tests with mocked API calls.""" print("Running unit tests...") - + # Set test environment os.environ["SOCKET_TEST_MODE"] = "unit" - + cmd = [ - sys.executable, "-m", "pytest", + sys.executable, + "-m", + "pytest", "tests/unit/test_socket_sdk_unit.py", "tests/unit/test_working_endpoints_unit.py", - "-v", "--tb=short" + "-v", + "--tb=short", ] - - result = subprocess.run(cmd, capture_output=True, text=True) + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) print(result.stdout) if result.stderr: print("STDERR:", result.stderr) - + return result.returncode == 0 def run_comprehensive_unit_tests(): """Run comprehensive unit tests for all endpoints (includes failing tests).""" print("Running comprehensive unit tests for all endpoints (includes failing tests)...") - + # Set test environment os.environ["SOCKET_TEST_MODE"] = "unit" - - cmd = [ - sys.executable, "-m", "pytest", - "tests/unit/test_all_endpoints_unit.py", - "-v", "--tb=short" - ] - - result = subprocess.run(cmd, capture_output=True, text=True) + + cmd = [sys.executable, "-m", "pytest", "tests/unit/test_all_endpoints_unit.py", "-v", "--tb=short"] + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) print(result.stdout) if result.stderr: print("STDERR:", result.stderr) - + return result.returncode == 0 def run_all_unit_tests(): """Run all unit tests including problematic endpoints.""" print("Running all unit tests (including problematic endpoints)...") - + # Set test environment os.environ["SOCKET_TEST_MODE"] = "unit" - - cmd = [ - sys.executable, "-m", "pytest", - "tests/unit/", - "-v", "--tb=short" - ] - - result = subprocess.run(cmd, capture_output=True, text=True) + + cmd = [sys.executable, "-m", "pytest", "tests/unit/", "-v", "--tb=short"] + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) print(result.stdout) if result.stderr: print("STDERR:", result.stderr) - + return result.returncode == 0 @@ -161,23 +156,19 @@ def run_integration_tests(): """Run integration tests with real API calls.""" if not validate_environment(): return False - + print("Running integration tests...") - + # Set test environment os.environ["SOCKET_TEST_MODE"] = "integration" - - cmd = [ - sys.executable, "-m", "pytest", - "tests/integration/", - "-v", "--tb=short" - ] - - result = subprocess.run(cmd, capture_output=True, text=True) + + cmd = [sys.executable, "-m", "pytest", "tests/integration/", "-v", "--tb=short"] + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) print(result.stdout) if result.stderr: print("STDERR:", result.stderr) - + return result.returncode == 0 @@ -185,23 +176,19 @@ def run_comprehensive_integration_tests(): """Run comprehensive integration tests for all endpoints.""" if not validate_environment(): return False - + print("Running comprehensive integration tests for all endpoints...") - + # Set test environment os.environ["SOCKET_TEST_MODE"] = "integration" - - cmd = [ - sys.executable, "-m", "pytest", - "tests/integration/test_all_endpoints.py", - "-v", "--tb=short" - ] - - result = subprocess.run(cmd, capture_output=True, text=True) + + cmd = [sys.executable, "-m", "pytest", "tests/integration/test_all_endpoints.py", "-v", "--tb=short"] + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) print(result.stdout) if result.stderr: print("STDERR:", result.stderr) - + return result.returncode == 0 @@ -209,20 +196,16 @@ def run_new_endpoints_tests(): """Run tests for new endpoints.""" if not validate_environment(): return False - + print("Running new endpoints tests...") - - cmd = [ - sys.executable, "-m", "pytest", - "tests/integration/test_new_endpoints.py", - "-v", "--tb=short" - ] - - result = subprocess.run(cmd, capture_output=True, text=True) + + cmd = [sys.executable, "-m", "pytest", "tests/integration/test_new_endpoints.py", "-v", "--tb=short"] + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) print(result.stdout) if result.stderr: print("STDERR:", result.stderr) - + return result.returncode == 0 @@ -230,33 +213,29 @@ def run_legacy_tests(): """Run legacy integration tests.""" if not validate_environment(): return False - + print("Running legacy integration tests...") - - cmd = [ - sys.executable, "-m", "pytest", - "tests/integration/test_diffscans_integration.py", - "-v", "--tb=short" - ] - - result = subprocess.run(cmd, capture_output=True, text=True) + + cmd = [sys.executable, "-m", "pytest", "tests/integration/test_diffscans_integration.py", "-v", "--tb=short"] + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) print(result.stdout) if result.stderr: print("STDERR:", result.stderr) - + return result.returncode == 0 def run_tests(test_type, verbose=False): """ Run the specified test suite. - + Args: test_type: One of 'unit', 'integration', 'all', 'new-endpoints', 'legacy', 'comprehensive-unit', 'all-unit' verbose: Whether to enable verbose output """ success = True - + if test_type == "unit": success = run_unit_tests() elif test_type == "comprehensive-unit": @@ -272,17 +251,17 @@ def run_tests(test_type, verbose=False): elif test_type == "all": print("Running all test suites...") success = ( - run_unit_tests() and - run_integration_tests() and - run_comprehensive_integration_tests() and - run_new_endpoints_tests() and - run_legacy_tests() + run_unit_tests() + and run_integration_tests() + and run_comprehensive_integration_tests() + and run_new_endpoints_tests() + and run_legacy_tests() ) else: print(f"Unknown test type: {test_type}") print("Available options: unit, integration, all, new-endpoints, legacy, comprehensive-unit, all-unit") success = False - + return success @@ -290,19 +269,20 @@ def validate_environment(): """Validate test environment for integration tests.""" # For integration tests, we need both dependencies and credentials print("\n๐Ÿ” Validating environment...") - + # Check dependencies (always needed) deps_ok = True try: import socketdev + print(f"โœ… socketdev available: {socketdev.__version__}") except ImportError: print("โŒ socketdev not found. Install with: pip install -e .") deps_ok = False - + # Check credentials (needed for integration tests) creds_ok = check_credentials() - + return deps_ok and creds_ok @@ -326,28 +306,22 @@ def main(): SOCKET_SECURITY_API_KEY Your Socket.dev API key (required for integration tests) SOCKET_ORG_SLUG Your organization slug (required for integration tests) SOCKET_REPO_SLUG Your repository slug (optional, some tests will be skipped) - """ + """, + ) + + parser.add_argument("--unit", action="store_true", help="Run working unit tests only (no API key required)") + parser.add_argument( + "--comprehensive-unit", action="store_true", help="Run comprehensive unit tests (includes failing tests)" ) - - parser.add_argument("--unit", action="store_true", - help="Run working unit tests only (no API key required)") - parser.add_argument("--comprehensive-unit", action="store_true", - help="Run comprehensive unit tests (includes failing tests)") - parser.add_argument("--all-unit", action="store_true", - help="Run all unit tests including problematic endpoints") - parser.add_argument("--integration", action="store_true", - help="Run comprehensive integration tests") - parser.add_argument("--new-endpoints", action="store_true", - help="Run tests for new endpoints") - parser.add_argument("--legacy", action="store_true", - help="Run legacy integration tests") - parser.add_argument("--all", action="store_true", - help="Run all tests") - parser.add_argument("--quiet", "-q", action="store_true", - help="Run tests in quiet mode") - + parser.add_argument("--all-unit", action="store_true", help="Run all unit tests including problematic endpoints") + parser.add_argument("--integration", action="store_true", help="Run comprehensive integration tests") + parser.add_argument("--new-endpoints", action="store_true", help="Run tests for new endpoints") + parser.add_argument("--legacy", action="store_true", help="Run legacy integration tests") + parser.add_argument("--all", action="store_true", help="Run all tests") + parser.add_argument("--quiet", "-q", action="store_true", help="Run tests in quiet mode") + args = parser.parse_args() - + # Determine test type test_type = "unit" # Default to working unit tests if args.unit: @@ -364,12 +338,12 @@ def main(): test_type = "legacy" elif args.all: test_type = "all" - + verbose = not args.quiet - + print("๐Ÿš€ Socket SDK Python Client Test Runner") print("=" * 40) - + success = run_tests(test_type, verbose) sys.exit(0 if success else 1) diff --git a/tests/support/socket_api_fixture.py b/tests/support/socket_api_fixture.py index d884842..d01c77e 100644 --- a/tests/support/socket_api_fixture.py +++ b/tests/support/socket_api_fixture.py @@ -4,9 +4,9 @@ import json import threading +from collections.abc import Callable from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Callable, Dict, List, Optional, Tuple from urllib.parse import parse_qs, urlsplit @@ -14,8 +14,8 @@ class SocketRequest: method: str path: str - query: Dict[str, List[str]] - headers: Dict[str, str] + query: dict[str, list[str]] + headers: dict[str, str] body: bytes @@ -23,7 +23,7 @@ class SocketRequest: class SocketResponse: status: int payload: object - raw_body: Optional[bytes] = None + raw_body: bytes | None = None Route = Callable[[SocketRequest], SocketResponse] @@ -31,10 +31,10 @@ class SocketResponse: class SocketApiFixture: def __init__(self) -> None: - self.requests: List[SocketRequest] = [] - self._routes: Dict[Tuple[str, str], Route] = {} - self._server: Optional[ThreadingHTTPServer] = None - self._thread: Optional[threading.Thread] = None + self.requests: list[SocketRequest] = [] + self._routes: dict[tuple[str, str], Route] = {} + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None @property def base_url(self) -> str: diff --git a/tests/unit/test_all_endpoints_unit.py b/tests/unit/test_all_endpoints_unit.py index 7e0d36b..8e4f1e7 100644 --- a/tests/unit/test_all_endpoints_unit.py +++ b/tests/unit/test_all_endpoints_unit.py @@ -7,11 +7,12 @@ Run with: python -m pytest tests/unit/test_all_endpoints_unit.py -v """ -import unittest -import tempfile import json import os -from unittest.mock import Mock, patch, mock_open +import tempfile +import unittest +from unittest.mock import Mock, patch + from socketdev import socketdev from socketdev.fullscans import FullScanParams @@ -21,7 +22,7 @@ class TestAllEndpointsUnit(unittest.TestCase): def setUp(self): """Set up test environment with mocked API.""" - self.requests_patcher = patch('socketdev.core.api.requests') + self.requests_patcher = patch("socketdev.core.api.requests") self.mock_requests = self.requests_patcher.start() self.sdk = socketdev(token="test-token") @@ -35,7 +36,7 @@ def _mock_response(self, data=None, status_code=200): data = {"success": True} mock_response = Mock() mock_response.status_code = status_code - mock_response.headers = {'content-type': 'application/json'} + mock_response.headers = {"content-type": "application/json"} mock_response.json.return_value = data mock_response.text = json.dumps(data) self.mock_requests.request.return_value = mock_response @@ -46,24 +47,24 @@ def test_dependencies_post_unit(self): """Test dependencies post with proper file handling.""" expected_data = {"packages": [{"name": "lodash", "version": "4.18.1"}]} self._mock_response(expected_data) - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test-package", "dependencies": {"lodash": "4.18.1"}}, f) f.flush() - + try: # Pass the file path as a string, not a file object files = [f.name] result = self.sdk.dependencies.post(files, {}) - + self.assertEqual(result, expected_data) self.mock_requests.request.assert_called_once() - + # Verify the request was made with correct parameters call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") self.assertIn("/dependencies", call_args[0][1]) - + finally: os.unlink(f.name) @@ -71,9 +72,9 @@ def test_dependencies_get_unit(self): """Test dependencies get with all parameters.""" expected_data = {"dependencies": [{"name": "sub-dependency", "version": "1.0.0"}]} self._mock_response(expected_data) - + result = self.sdk.dependencies.get("test-org", "npm", "lodash", "4.18.1") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -82,15 +83,11 @@ def test_dependencies_get_unit(self): # DiffScans endpoints def test_diffscans_list_unit(self): """Test diffscans list with pagination.""" - expected_data = { - "results": [{"id": "diff-1", "status": "completed"}], - "total": 1, - "page": 1 - } + expected_data = {"results": [{"id": "diff-1", "status": "completed"}], "total": 1, "page": 1} self._mock_response(expected_data) - + result = self.sdk.diffscans.list("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -99,15 +96,15 @@ def test_diffscans_list_unit(self): def test_diffscans_get_unit(self): """Test diffscans get specific scan.""" expected_data = { - "id": "diff-123", + "id": "diff-123", "status": "completed", "created_at": "2025-01-01T00:00:00Z", - "diff": {"added": [], "removed": [], "modified": []} + "diff": {"added": [], "removed": [], "modified": []}, } self._mock_response(expected_data) - + result = self.sdk.diffscans.get("test-org", "diff-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -170,14 +167,10 @@ def test_diffscans_create_from_ids_unit(self): """Test diffscans creation from scan IDs.""" expected_data = {"id": "new-diff-scan", "status": "queued"} self._mock_response(expected_data, 201) - - params = { - "before": "scan-1", - "after": "scan-2", - "description": "Test diff scan" - } + + params = {"before": "scan-1", "after": "scan-2", "description": "Test diff scan"} result = self.sdk.diffscans.create_from_ids("test-org", params) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") @@ -187,22 +180,22 @@ def test_diffscans_create_from_repo_unit(self): """Test diffscans creation from repo files.""" expected_data = {"id": "repo-diff-scan", "status": "queued"} self._mock_response(expected_data, 201) - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() - + try: with open(f.name, "rb") as file_obj: files = [("file", ("package.json", file_obj))] params = {"description": "Test repo diff"} result = self.sdk.diffscans.create_from_repo("test-org", "test-repo", files, params) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") self.assertIn("/orgs/test-org/diff-scans/from-repo/test-repo", call_args[0][1]) - + finally: os.unlink(f.name) @@ -210,7 +203,7 @@ def test_diffscans_create_from_repo_committers_list_unit(self): """Test list-valued params (committers) encode as repeated query params.""" self._mock_response({"id": "repo-diff-scan"}, 201) - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() @@ -233,9 +226,9 @@ def test_diffscans_gfm_unit(self): """Test diffscans GitHub Flavored Markdown export.""" expected_data = {"markdown": "# Diff Report\n\n## Summary\n- Added: 0\n- Removed: 0"} self._mock_response(expected_data) - + result = self.sdk.diffscans.gfm("test-org", "diff-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -244,9 +237,9 @@ def test_diffscans_gfm_unit(self): def test_diffscans_delete_unit(self): """Test diffscans deletion.""" self._mock_response({"status": "ok"}, 200) - + result = self.sdk.diffscans.delete("test-org", "diff-123") - + self.assertTrue(result) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "DELETE") @@ -255,15 +248,11 @@ def test_diffscans_delete_unit(self): # Export endpoints def test_export_cdx_bom_unit(self): """Test CDX BOM export.""" - expected_data = { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "components": [] - } + expected_data = {"bomFormat": "CycloneDX", "specVersion": "1.4", "components": []} self._mock_response(expected_data) - + result = self.sdk.export.cdx_bom("test-org", "scan-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -271,15 +260,11 @@ def test_export_cdx_bom_unit(self): def test_export_spdx_bom_unit(self): """Test SPDX BOM export.""" - expected_data = { - "spdxVersion": "SPDX-2.2", - "SPDXID": "SPDXRef-DOCUMENT", - "packages": [] - } + expected_data = {"spdxVersion": "SPDX-2.2", "SPDXID": "SPDXRef-DOCUMENT", "packages": []} self._mock_response(expected_data) - + result = self.sdk.export.spdx_bom("test-org", "scan-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -290,10 +275,10 @@ def test_fullscans_get_unit(self): """Test fullscans get with various parameter types.""" expected_data = {"id": "scan-123", "status": "completed", "results": []} self._mock_response(expected_data) - + # Test with commit parameter result = self.sdk.fullscans.get("test-org", {"id": "scan-123"}) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -303,30 +288,30 @@ def test_fullscans_post_unit(self): """Test fullscans creation with all parameters.""" expected_data = {"id": "new-scan", "status": "queued"} self._mock_response(expected_data, 201) - + params = FullScanParams( repo="test-repo", org_slug="test-org", branch="main", commit_message="Test scan", commit_hash="abc123", - integration_type="github" + integration_type="github", ) - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() - + try: with open(f.name, "rb") as file_obj: files = [("file", ("package.json", file_obj))] result = self.sdk.fullscans.post(files, params) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") self.assertIn("/full-scans", call_args[0][1]) - + finally: os.unlink(f.name) @@ -334,9 +319,9 @@ def test_fullscans_delete_unit(self): """Test fullscans deletion.""" expected_data = {"status": "deleted"} self._mock_response(expected_data) - + result = self.sdk.fullscans.delete("test-org", "scan-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "DELETE") @@ -345,14 +330,11 @@ def test_fullscans_delete_unit(self): # Historical endpoints def test_historical_list_unit(self): """Test historical list with filtering.""" - expected_data = { - "results": [{"date": "2025-01-01", "count": 5}], - "total": 1 - } + expected_data = {"results": [{"date": "2025-01-01", "count": 5}], "total": 1} self._mock_response(expected_data) - + result = self.sdk.historical.list("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -360,16 +342,11 @@ def test_historical_list_unit(self): def test_historical_trend_unit(self): """Test historical trend analysis.""" - expected_data = { - "trend": [ - {"date": "2025-01-01", "value": 10}, - {"date": "2025-01-02", "value": 15} - ] - } + expected_data = {"trend": [{"date": "2025-01-01", "value": 10}, {"date": "2025-01-02", "value": 15}]} self._mock_response(expected_data) - + result = self.sdk.historical.trend("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -380,9 +357,9 @@ def test_npm_issues_unit(self): """Test npm issues endpoint.""" expected_data = [{"type": "security", "severity": "high", "title": "Test issue"}] self._mock_response(expected_data) - + result = self.sdk.npm.issues("lodash", "4.18.1") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -392,9 +369,9 @@ def test_npm_score_unit(self): """Test npm score endpoint.""" expected_data = [{"category": "security", "value": 85}] self._mock_response(expected_data) - + result = self.sdk.npm.score("lodash", "4.18.1") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -405,9 +382,9 @@ def test_openapi_get_unit(self): """Test OpenAPI specification retrieval.""" expected_data = {"openapi": "3.0.0", "info": {"title": "Socket API"}} self._mock_response(expected_data) - + result = self.sdk.openapi.get() - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -418,9 +395,9 @@ def test_org_get_unit(self): """Test organization retrieval.""" expected_data = {"organizations": {"test-org": {"name": "test-org", "id": "org-123", "plan": "pro"}}} self._mock_response(expected_data) - + result = self.sdk.org.get("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -430,31 +407,33 @@ def test_org_get_unit(self): def test_purl_post_unit(self): """Test org-scoped PURL validation endpoint.""" # Expected final result after deduplication - should match what the dedupe function produces - expected_data = [{ - "inputPurl": "pkg:npm/lodash@4.18.1", - "purl": "pkg:npm/lodash@4.18.1", - "type": "npm", - "name": "lodash", - "version": "4.18.1", - "valid": True, - "alerts": [], - "releases": ["npm"] - }] - + expected_data = [ + { + "inputPurl": "pkg:npm/lodash@4.18.1", + "purl": "pkg:npm/lodash@4.18.1", + "type": "npm", + "name": "lodash", + "version": "4.18.1", + "valid": True, + "alerts": [], + "releases": ["npm"], + } + ] + # Mock the NDJSON response that would come from the actual API # This simulates what the API returns: newline-delimited JSON with SocketArtifact objects mock_ndjson_response = '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", "type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' - + # Mock the response with NDJSON format mock_response = Mock() mock_response.status_code = 200 - mock_response.headers = {'content-type': 'application/x-ndjson'} + mock_response.headers = {"content-type": "application/x-ndjson"} mock_response.text = mock_ndjson_response self.mock_requests.request.return_value = mock_response - + components = [{"purl": "pkg:npm/lodash@4.18.1"}] result = self.sdk.purl.post("false", components, org_slug="test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") @@ -466,7 +445,7 @@ def test_purl_post_unit_legacy_path(self): mock_response = Mock() mock_response.status_code = 200 - mock_response.headers = {'content-type': 'application/x-ndjson'} + mock_response.headers = {"content-type": "application/x-ndjson"} mock_response.text = mock_ndjson_response self.mock_requests.request.return_value = mock_response @@ -481,7 +460,7 @@ def _mock_purl_ndjson(self, ndjson): """Mock a 200 NDJSON purl response and return the mock.""" mock_response = Mock() mock_response.status_code = 200 - mock_response.headers = {'content-type': 'application/x-ndjson'} + mock_response.headers = {"content-type": "application/x-ndjson"} mock_response.text = ndjson self.mock_requests.request.return_value = mock_response return mock_response @@ -645,9 +624,9 @@ def test_quota_get_unit(self): """Test quota retrieval.""" expected_data = {"quota": 1000, "used": 100, "remaining": 900} self._mock_response(expected_data) - + result = self.sdk.quota.get() - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -658,9 +637,9 @@ def test_report_list_unit(self): """Test report listing.""" expected_data = {"reports": [{"id": "rep-1", "status": "completed"}]} self._mock_response(expected_data) - + result = self.sdk.report.list() - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -670,21 +649,21 @@ def test_report_create_unit(self): """Test report creation.""" expected_data = {"id": "report-123", "url": "https://socket.dev/report/report-123"} self._mock_response(expected_data, 200) # API returns 200, not 201 - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() - + try: with open(f.name, "rb") as file_obj: files = [("file", ("package.json", file_obj))] result = self.sdk.report.create(files) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "PUT") # API uses PUT, not POST self.assertIn("/report/upload", call_args[0][1]) # Correct path per OpenAPI - + finally: os.unlink(f.name) @@ -692,9 +671,9 @@ def test_report_view_unit(self): """Test report viewing.""" expected_data = {"id": "report-123", "status": "completed", "results": []} self._mock_response(expected_data) - + result = self.sdk.report.view("report-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -703,9 +682,9 @@ def test_report_view_unit(self): def test_report_delete_unit(self): """Test report deletion.""" self._mock_response({"status": "deleted"}) - + result = self.sdk.report.delete("report-123") - + self.assertTrue(result) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "DELETE") @@ -715,9 +694,9 @@ def test_report_supported_unit(self): """Test supported file types.""" expected_data = {"supported": ["npm", "pypi", "cargo", "maven"]} self._mock_response(expected_data) - + result = self.sdk.report.supported() - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -728,9 +707,9 @@ def test_settings_get_unit(self): """Test settings retrieval.""" expected_data = {"settings": {"notifications": True, "theme": "dark"}} self._mock_response(expected_data) - + result = self.sdk.settings.get("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -741,9 +720,9 @@ def test_triage_list_alert_triage_unit(self): """Test alert triage listing.""" expected_data = {"alerts": [{"id": "alert-1", "status": "open"}]} self._mock_response(expected_data) - + result = self.sdk.triage.list_alert_triage("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -753,10 +732,10 @@ def test_triage_update_alert_triage_unit(self): """Test alert triage updating.""" expected_data = {"result": "Updated"} self._mock_response(expected_data) - + data = {"alertTriage": [{"alertKey": "alert-123", "state": "ignore", "note": "Not applicable"}]} result = self.sdk.triage.update_alert_triage("test-org", data) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") @@ -767,9 +746,9 @@ def test_threatfeed_get_unit(self): """Test threatfeed endpoint.""" expected_data = {"results": [{"id": "threat-1", "type": "malware"}], "nextPage": None} self._mock_response(expected_data) - + result = self.sdk.threatfeed.get("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -779,9 +758,9 @@ def test_analytics_get_org_unit(self): """Test analytics organization endpoint.""" expected_data = [{"date": "2025-01-01", "count": 5}] self._mock_response(expected_data) - + result = self.sdk.analytics.get_org("dependencies") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -791,9 +770,9 @@ def test_analytics_get_repo_unit(self): """Test analytics repository endpoint.""" expected_data = [{"date": "2025-01-01", "count": 3}] self._mock_response(expected_data) - + result = self.sdk.analytics.get_repo("test-repo", "alerts") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -803,9 +782,9 @@ def test_apitokens_create_unit(self): """Test API token creation.""" expected_data = {"id": "token-123", "name": "test-token", "token": "sk_test_..."} self._mock_response(expected_data, 201) - + result = self.sdk.apitokens.create("test-org", name="test-token") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") @@ -815,9 +794,9 @@ def test_apitokens_update_unit(self): """Test API token updating.""" expected_data = {"id": "token-123", "name": "updated-token"} self._mock_response(expected_data) - + result = self.sdk.apitokens.update("test-org", token_id="token-123", name="updated-token") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "PUT") @@ -827,9 +806,9 @@ def test_apitokens_list_unit(self): """Test API token listing.""" expected_data = {"tokens": [{"id": "token-1", "name": "prod-token"}]} self._mock_response(expected_data) - + result = self.sdk.apitokens.list("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -839,9 +818,9 @@ def test_auditlog_get_unit(self): """Test audit log retrieval.""" expected_data = {"logs": [{"id": "log-1", "action": "user.login"}]} self._mock_response(expected_data) - + result = self.sdk.auditlog.get("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -851,9 +830,9 @@ def test_alerttypes_get_unit(self): """Test alert types retrieval.""" expected_data = {"alertTypes": [{"id": "security", "name": "Security Alert"}]} self._mock_response(expected_data) - + result = self.sdk.alerttypes.get() - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") @@ -863,9 +842,9 @@ def test_labels_get_unit(self): """Test labels get endpoint.""" expected_data = {"id": "1", "name": "environment", "created_at": "2025-01-01"} self._mock_response(expected_data) - + result = self.sdk.labels.get("test-org", "1") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -875,10 +854,10 @@ def test_labels_setting_put_unit(self): """Test labels setting put endpoint.""" expected_data = {"updated": True} self._mock_response(expected_data, 201) # Label settings return 201 - + label_data = {"environment": {"production": {"critical": "true"}}} result = self.sdk.labels.setting.put("test-org", 1, label_data) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "PUT") @@ -888,9 +867,9 @@ def test_labels_delete_unit(self): """Test labels delete endpoint.""" expected_data = {"deleted": True} self._mock_response(expected_data) - + result = self.sdk.labels.delete("test-org", "1") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "DELETE") @@ -901,9 +880,9 @@ def test_labels_delete_unit(self): # """Test license metadata retrieval.""" # expected_data = {"licenses": [{"id": "MIT", "name": "MIT License"}]} # self._mock_response(expected_data) - # + # # result = self.sdk.licensemetadata.get() - # + # # self.assertEqual(result, expected_data) # call_args = self.mock_requests.request.call_args # self.assertEqual(call_args[0][0], "GET") diff --git a/tests/unit/test_dedupe.py b/tests/unit/test_dedupe.py index 080ecf9..28335af 100644 --- a/tests/unit/test_dedupe.py +++ b/tests/unit/test_dedupe.py @@ -1,9 +1,9 @@ import unittest + from socketdev.core.dedupe import Dedupe class TestDedupe(unittest.TestCase): - def test_consolidate_by_input_purl(self): """Test that packages are correctly grouped by inputPurl""" # Sample data similar to what the user provided @@ -16,17 +16,17 @@ def test_consolidate_by_input_purl(self): "release": "py3-none-any-whl", "inputPurl": "pkg:pypi/pyonepassword@5.0.0", "batchIndex": 0, - "alerts": [] + "alerts": [], }, { "id": "15594798924", - "name": "pyonepassword", + "name": "pyonepassword", "version": "5.0.0", "type": "pypi", "release": "tar-gz", "inputPurl": "pkg:pypi/pyonepassword@5.0.0", "batchIndex": 0, - "alerts": [] + "alerts": [], }, { "id": "77600911089", @@ -36,7 +36,7 @@ def test_consolidate_by_input_purl(self): "release": "py3-none-any-whl", "inputPurl": "pkg:pypi/socketsecurity", "batchIndex": 0, - "alerts": [] + "alerts": [], }, { "id": "77600911090", @@ -46,23 +46,23 @@ def test_consolidate_by_input_purl(self): "release": "tar-gz", "inputPurl": "pkg:pypi/socketsecurity", "batchIndex": 0, - "alerts": [] - } + "alerts": [], + }, ] - + # Group by inputPurl grouped = Dedupe.consolidate_by_input_purl(packages) - + # Should have 2 groups self.assertEqual(len(grouped), 2) - + # Check pyonepassword group pyonepassword_group = grouped["pkg:pypi/pyonepassword@5.0.0"] self.assertEqual(len(pyonepassword_group), 2) self.assertEqual(pyonepassword_group[0]["name"], "pyonepassword") self.assertEqual(pyonepassword_group[1]["name"], "pyonepassword") - - # Check socketsecurity group + + # Check socketsecurity group socketsecurity_group = grouped["pkg:pypi/socketsecurity"] self.assertEqual(len(socketsecurity_group), 2) self.assertEqual(socketsecurity_group[0]["name"], "socketsecurity") @@ -79,17 +79,17 @@ def test_dedupe_with_input_purl_grouping(self): "release": "py3-none-any-whl", "inputPurl": "pkg:pypi/pyonepassword@5.0.0", "batchIndex": 0, - "alerts": [] + "alerts": [], }, { "id": "15594798924", - "name": "pyonepassword", + "name": "pyonepassword", "version": "5.0.0", "type": "pypi", "release": "tar-gz", "inputPurl": "pkg:pypi/pyonepassword@5.0.0", "batchIndex": 0, - "alerts": [] + "alerts": [], }, { "id": "77600911089", @@ -99,7 +99,7 @@ def test_dedupe_with_input_purl_grouping(self): "release": "py3-none-any-whl", "inputPurl": "pkg:pypi/socketsecurity", "batchIndex": 0, - "alerts": [] + "alerts": [], }, { "id": "77600911090", @@ -109,40 +109,40 @@ def test_dedupe_with_input_purl_grouping(self): "release": "tar-gz", "inputPurl": "pkg:pypi/socketsecurity", "batchIndex": 0, - "alerts": [] - } + "alerts": [], + }, ] - + # Test with new inputPurl grouping (now the default and only method) result = Dedupe.dedupe(packages) - + # Should return 2 deduplicated packages self.assertEqual(len(result), 2) - + # Find each package in results pyonepassword_result = None socketsecurity_result = None - + for pkg in result: if pkg["name"] == "pyonepassword": pyonepassword_result = pkg elif pkg["name"] == "socketsecurity": socketsecurity_result = pkg - + # Both should be present self.assertIsNotNone(pyonepassword_result) self.assertIsNotNone(socketsecurity_result) - + # Check that releases are consolidated self.assertIn("releases", pyonepassword_result) self.assertIn("releases", socketsecurity_result) self.assertEqual(len(pyonepassword_result["releases"]), 2) self.assertEqual(len(socketsecurity_result["releases"]), 2) - + # Check that batchIndex is removed from results self.assertNotIn("batchIndex", pyonepassword_result) self.assertNotIn("batchIndex", socketsecurity_result) - + def test_dedupe_batched_parameter_backward_compatibility(self): """Test that batched parameter is kept for backward compatibility but doesn't change behavior""" packages = [ @@ -154,7 +154,7 @@ def test_dedupe_batched_parameter_backward_compatibility(self): "release": "py3-none-any-whl", "inputPurl": "pkg:pypi/pyonepassword@5.0.0", "batchIndex": 0, - "alerts": [] + "alerts": [], }, { "id": "77600911089", @@ -164,29 +164,29 @@ def test_dedupe_batched_parameter_backward_compatibility(self): "release": "py3-none-any-whl", "inputPurl": "pkg:pypi/socketsecurity@2.2.7", "batchIndex": 0, - "alerts": [] - } + "alerts": [], + }, ] - + # Test with batched=True result_batched_true = Dedupe.dedupe(packages, batched=True) - - # Test with batched=False + + # Test with batched=False result_batched_false = Dedupe.dedupe(packages, batched=False) - + # Both should return 2 results (same behavior regardless of batched parameter) self.assertEqual(len(result_batched_true), 2) self.assertEqual(len(result_batched_false), 2) - + # Results should be the same - names_true = sorted([pkg['name'] for pkg in result_batched_true]) - names_false = sorted([pkg['name'] for pkg in result_batched_false]) + names_true = sorted([pkg["name"] for pkg in result_batched_true]) + names_false = sorted([pkg["name"] for pkg in result_batched_false]) self.assertEqual(names_true, names_false) - + # Should not contain batchIndex for pkg in result_batched_true + result_batched_false: self.assertNotIn("batchIndex", pkg) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_enum_forward_compat.py b/tests/unit/test_enum_forward_compat.py index 948b64e..7dece47 100644 --- a/tests/unit/test_enum_forward_compat.py +++ b/tests/unit/test_enum_forward_compat.py @@ -42,9 +42,7 @@ def _all_enums(): """Every Enum subclass defined under the socketdev package.""" found = {} - for module_info in pkgutil.walk_packages( - socketdev.__path__, prefix="socketdev." - ): + for module_info in pkgutil.walk_packages(socketdev.__path__, prefix="socketdev."): try: module = importlib.import_module(module_info.name) except Exception: # pragma: no cover - an unimportable module is its own bug @@ -67,9 +65,7 @@ class TestEnumForwardCompatibility(unittest.TestCase): def test_enums_are_discovered(self): # Guards against the discovery walk silently finding nothing, which # would make every other test in this file vacuously pass. - self.assertGreaterEqual( - len(_all_enums()), 6, "enum discovery found suspiciously few enums" - ) + self.assertGreaterEqual(len(_all_enums()), 6, "enum discovery found suspiciously few enums") def test_unknown_value_does_not_raise(self): for qualname, enum_cls in sorted(_all_enums().items()): @@ -102,8 +98,7 @@ def test_unknown_value_warns(self): enum_cls(SENTINEL) self.assertTrue( any(enum_cls.__name__ in line for line in captured.output), - f"{qualname} fell back without naming itself in the warning; " - f"got: {captured.output}", + f"{qualname} fell back without naming itself in the warning; got: {captured.output}", ) def test_request_only_enums_stay_strict(self): @@ -115,9 +110,7 @@ def test_request_only_enums_stay_strict(self): for name in sorted(REQUEST_ONLY_ENUMS): with self.subTest(enum=name): enum_cls = by_name.get(name) - self.assertIsNotNone( - enum_cls, f"{name} is exempted but no longer exists" - ) + self.assertIsNotNone(enum_cls, f"{name} is exempted but no longer exists") with self.assertRaises( ValueError, msg=f"{name} is request-only and must reject unknown values", diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 5b305d6..b1c0317 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -76,9 +76,7 @@ class APIServiceUnavailable(APIFailure): self.assertTrue(APIServiceUnavailable(status_code=503).is_transient_error()) def test_message_text_does_not_affect_classification(self): - self.assertFalse( - APIFailure("original_status_code:503 lookalike").is_transient_error() - ) + self.assertFalse(APIFailure("original_status_code:503 lookalike").is_transient_error()) def test_single_message_arg_is_preserved(self): error = APIFailure("something broke", status_code=503) @@ -158,9 +156,7 @@ def test_catch_all_deterministic_statuses(self): self.assertFalse(error.is_transient_error()) def test_timeout_is_transient(self): - error = self._do_request_raising( - APITimeout, side_effect=requests.exceptions.Timeout("timed out") - ) + error = self._do_request_raising(APITimeout, side_effect=requests.exceptions.Timeout("timed out")) self.assertIsNone(error.status_code) self.assertTrue(error.is_transient_error()) diff --git a/tests/unit/test_full_scan_cached_polling_http.py b/tests/unit/test_full_scan_cached_polling_http.py index 29c45f9..c28c233 100644 --- a/tests/unit/test_full_scan_cached_polling_http.py +++ b/tests/unit/test_full_scan_cached_polling_http.py @@ -4,8 +4,8 @@ import pytest from socketdev import socketdev -from socketdev.fullscans import FullScanStreamResponse from socketdev.exceptions import APIAccessDenied, APIResourceNotFound, APITimeout +from socketdev.fullscans import FullScanStreamResponse from tests.support.socket_api_fixture import SocketApiFixture, SocketResponse @@ -61,9 +61,7 @@ def test_transient_error_retries_and_invalid_ndjson_preserves_unsuccessful_respo responses = iter([SocketResponse(503, {"error": {"message": "try again"}}), ndjson()]) api_fixture.route("GET", path, lambda _request: next(responses)) sdk = sdk_for(api_fixture) - assert sdk.fullscans.stream( - "org", "scan", poll_timeout_seconds=0.5, poll_initial_interval_seconds=0.01 - ) == {} + assert sdk.fullscans.stream("org", "scan", poll_timeout_seconds=0.5, poll_initial_interval_seconds=0.01) == {} assert len(api_fixture.requests) == 2 api_fixture.requests.clear() @@ -77,8 +75,11 @@ def test_perpetual_processing_times_out(api_fixture): api_fixture.route("GET", "orgs/org/full-scans/scan", SocketResponse(202, {})) with pytest.raises(APITimeout, match="full scan results"): sdk_for(api_fixture).fullscans.stream( - "org", "scan", poll_timeout_seconds=0.06, - poll_initial_interval_seconds=0.02, poll_max_interval_seconds=0.02, + "org", + "scan", + poll_timeout_seconds=0.06, + poll_initial_interval_seconds=0.02, + poll_max_interval_seconds=0.02, ) @@ -86,8 +87,11 @@ def test_persistent_transient_error_expires_with_poll_budget(api_fixture): api_fixture.route("GET", "orgs/org/full-scans/scan", SocketResponse(503, {})) with pytest.raises(APITimeout, match="full scan results"): sdk_for(api_fixture).fullscans.stream( - "org", "scan", poll_timeout_seconds=0.06, - poll_initial_interval_seconds=0.02, poll_max_interval_seconds=0.02, + "org", + "scan", + poll_timeout_seconds=0.06, + poll_initial_interval_seconds=0.02, + poll_max_interval_seconds=0.02, ) assert len(api_fixture.requests) >= 1 @@ -95,18 +99,44 @@ def test_persistent_transient_error_expires_with_poll_budget(api_fixture): def test_duplicate_artifacts_merge_alerts_and_preserve_score_event(api_fixture): path = "orgs/org/full-scans/scan" common = { - "id": "artifact-1", "type": "pypi", "name": "example", "version": "1.0", - "inputPurl": "pkg:pypi/example@1.0", "score": { - "supplyChain": 1, "quality": 2, "maintenance": 3, - "vulnerability": 4, "license": 5, "overall": 6, + "id": "artifact-1", + "type": "pypi", + "name": "example", + "version": "1.0", + "inputPurl": "pkg:pypi/example@1.0", + "score": { + "supplyChain": 1, + "quality": 2, + "maintenance": 3, + "vulnerability": 4, + "license": 5, + "overall": 6, }, } - first = dict(common, release="wheel", alerts=[{ - "key": "alert-1", "type": "test", "severity": "high", "category": "quality", - }]) - second = dict(common, release="source", alerts=[{ - "key": "alert-1", "type": "test", "severity": "high", "category": "quality", - }]) + first = dict( + common, + release="wheel", + alerts=[ + { + "key": "alert-1", + "type": "test", + "severity": "high", + "category": "quality", + } + ], + ) + second = dict( + common, + release="source", + alerts=[ + { + "key": "alert-1", + "type": "test", + "severity": "high", + "category": "quality", + } + ], + ) api_fixture.route("GET", path, ndjson(first, second)) result = sdk_for(api_fixture).fullscans.stream("org", "scan") @@ -140,7 +170,9 @@ def test_invalid_settings_and_encoded_components(api_fixture): @pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf"), True]) -@pytest.mark.parametrize("parameter", ["poll_timeout_seconds", "poll_initial_interval_seconds", "poll_max_interval_seconds"]) +@pytest.mark.parametrize( + "parameter", ["poll_timeout_seconds", "poll_initial_interval_seconds", "poll_max_interval_seconds"] +) def test_invalid_polling_numbers_fail_before_request(api_fixture, parameter, value): with pytest.raises(ValueError): sdk_for(api_fixture).fullscans.stream("org", "scan", **{parameter: value}) @@ -161,7 +193,10 @@ def test_gives_up_without_sleeping_when_next_poll_cannot_start(api_fixture): start = time.monotonic() with pytest.raises(APITimeout, match="full scan results"): sdk_for(api_fixture).fullscans.stream( - "org", "scan", poll_timeout_seconds=0.5, poll_initial_interval_seconds=5, + "org", + "scan", + poll_timeout_seconds=0.5, + poll_initial_interval_seconds=5, ) assert time.monotonic() - start < 0.4 assert len(api_fixture.requests) == 1 diff --git a/tests/unit/test_repository_updates.py b/tests/unit/test_repository_updates.py new file mode 100644 index 0000000..80ac54c --- /dev/null +++ b/tests/unit/test_repository_updates.py @@ -0,0 +1,16 @@ +import json +from unittest.mock import Mock + +from socketdev.repos import Repos + + +def test_repository_update_serializes_keyword_arguments(): + api = Mock() + api.do_request.return_value.status_code = 200 + api.do_request.return_value.json.return_value = {"name": "widgets"} + + assert Repos(api).update("example", "widgets", description="updated", defaultBranch="main") == {"name": "widgets"} + assert json.loads(api.do_request.call_args.kwargs["payload"]) == { + "description": "updated", + "defaultBranch": "main", + } diff --git a/tests/unit/test_retired_token.py b/tests/unit/test_retired_token.py index c66e29d..4bd8874 100644 --- a/tests/unit/test_retired_token.py +++ b/tests/unit/test_retired_token.py @@ -53,18 +53,18 @@ def test_api_rejects_retired_basic_username_with_password_suffix(): def test_token_selection_preserves_explicit_and_environment_precedence(): - environment = { - name: f"token-{index}" - for index, name in enumerate(TOKEN_ENVIRONMENT_VARIABLES) - } + environment = {name: f"token-{index}" for index, name in enumerate(TOKEN_ENVIRONMENT_VARIABLES)} assert select_token("", environment) == "" assert select_token("explicit", environment) == "explicit" assert select_token(None, environment) == "token-0" - assert select_token( - None, - {TOKEN_ENVIRONMENT_VARIABLES[0]: "", TOKEN_ENVIRONMENT_VARIABLES[1]: "next"}, - ) == "next" + assert ( + select_token( + None, + {TOKEN_ENVIRONMENT_VARIABLES[0]: "", TOKEN_ENVIRONMENT_VARIABLES[1]: "next"}, + ) + == "next" + ) assert select_token(None, {}) is None diff --git a/tests/unit/test_socket_purl_type.py b/tests/unit/test_socket_purl_type.py index 1b53c2c..9855152 100644 --- a/tests/unit/test_socket_purl_type.py +++ b/tests/unit/test_socket_purl_type.py @@ -52,9 +52,7 @@ def test_common_ecosystems_are_recognized(self): self.assertEqual(SocketPURL_Type(value).value, value) def test_unknown_type_falls_back_to_unknown(self): - self.assertEqual( - SocketPURL_Type("someFutureEcosystem"), SocketPURL_Type.UNKNOWN - ) + self.assertEqual(SocketPURL_Type("someFutureEcosystem"), SocketPURL_Type.UNKNOWN) def test_unknown_type_emits_warning(self): with self.assertLogs("socketdev", level=logging.WARNING) as captured: @@ -134,10 +132,7 @@ def test_full_scans_stream_skips_artifact_without_id(self): self.assertTrue(response.success) self.assertEqual(list(response.artifacts), ["good"]) self.assertTrue( - any( - "Skipping artifact without a usable id" in message - for message in captured.output - ), + any("Skipping artifact without a usable id" in message for message in captured.output), f"expected a warning about the skipped artifact, got: {captured.output}", ) self.assertEqual(fixture.requests[0].query, {"cached": ["true"]}) diff --git a/tests/unit/test_socket_sdk_unit.py b/tests/unit/test_socket_sdk_unit.py index 973c046..b05e1fd 100644 --- a/tests/unit/test_socket_sdk_unit.py +++ b/tests/unit/test_socket_sdk_unit.py @@ -1,20 +1,21 @@ """ Unit tests for the Socket SDK Python client. -These tests don't require API keys and test the SDK structure, +These tests don't require API keys and test the SDK structure, initialization, and basic functionality without making actual API calls. Run with: python -m pytest tests/unit/ -v """ -import unittest -import tempfile import json import os -from unittest.mock import Mock, patch, MagicMock +import tempfile +import unittest +from unittest.mock import Mock, patch + from socketdev import socketdev +from socketdev.exceptions import APIResourceNotFound from socketdev.fullscans import FullScanParams, SocketPURL, SocketPURL_Type -from socketdev.utils import IntegrationType class TestSocketSDKUnit(unittest.TestCase): @@ -23,16 +24,36 @@ class TestSocketSDKUnit(unittest.TestCase): def test_sdk_initialization(self): """Test that the SDK initializes correctly with all components.""" sdk = socketdev(token="test-token") - + # Test that all expected components are present expected_components = [ - 'api', 'dependencies', 'export', 'fullscans', 'historical', - 'npm', 'openapi', 'org', 'purl', 'quota', 'report', 'repos', - 'repositories', 'sbom', 'settings', 'triage', 'utils', 'labels', - 'licensemetadata', 'diffscans', 'threatfeed', 'apitokens', - 'auditlog', 'analytics', 'alerttypes' + "api", + "dependencies", + "export", + "fullscans", + "historical", + "npm", + "openapi", + "org", + "purl", + "quota", + "report", + "repos", + "repositories", + "sbom", + "settings", + "triage", + "utils", + "labels", + "licensemetadata", + "diffscans", + "threatfeed", + "apitokens", + "auditlog", + "analytics", + "alerttypes", ] - + for component in expected_components: self.assertTrue(hasattr(sdk, component), f"SDK missing component: {component}") @@ -41,11 +62,11 @@ def test_sdk_initialization_with_allow_unverified(self): # Test default behavior (allow_unverified=False) sdk_default = socketdev(token="test-token") self.assertFalse(sdk_default.api.allow_unverified) - + # Test with allow_unverified=True sdk_unverified = socketdev(token="test-token", allow_unverified=True) self.assertTrue(sdk_unverified.api.allow_unverified) - + # Test with explicit allow_unverified=False sdk_verified = socketdev(token="test-token", allow_unverified=False) self.assertFalse(sdk_verified.api.allow_unverified) @@ -60,9 +81,9 @@ def test_fullscan_params_creation(self): commit_hash="abcd1234", pull_request=123, committers=["test-user"], - integration_type="api" # Use string instead of enum + integration_type="api", # Use string instead of enum ) - + self.assertEqual(params.repo, "test-repo") self.assertEqual(params.org_slug, "test-org") self.assertEqual(params.branch, "main") @@ -74,12 +95,8 @@ def test_fullscan_params_creation(self): def test_fullscan_params_to_dict(self): """Test FullScanParams to_dict method.""" - params = FullScanParams( - repo="test-repo", - org_slug="test-org", - branch="main" - ) - + params = FullScanParams(repo="test-repo", org_slug="test-org", branch="main") + params_dict = params.to_dict() self.assertIsInstance(params_dict, dict) self.assertEqual(params_dict["repo"], "test-repo") @@ -90,14 +107,9 @@ def test_fullscan_params_from_dict(self): """Test FullScanParams from_dict class method.""" # Skip this test for now due to typing issues with integration_type self.skipTest("Integration type handling needs to be fixed in SDK") - - data = { - "repo": "test-repo", - "org_slug": "test-org", - "branch": "main", - "integration_type": "api" - } - + + data = {"repo": "test-repo", "org_slug": "test-org", "branch": "main", "integration_type": "api"} + params = FullScanParams.from_dict(data) self.assertEqual(params.repo, "test-repo") self.assertEqual(params.org_slug, "test-org") @@ -106,13 +118,8 @@ def test_fullscan_params_from_dict(self): def test_socket_purl_creation(self): """Test SocketPURL dataclass creation.""" - purl = SocketPURL( - type=SocketPURL_Type.NPM, - name="lodash", - namespace=None, - release="4.18.1" - ) - + purl = SocketPURL(type=SocketPURL_Type.NPM, name="lodash", namespace=None, release="4.18.1") + self.assertEqual(purl.type, SocketPURL_Type.NPM) self.assertEqual(purl.name, "lodash") self.assertIsNone(purl.namespace) @@ -121,7 +128,7 @@ def test_socket_purl_creation(self): def test_integration_types(self): """Test that all integration types are available.""" from socketdev.utils import INTEGRATION_TYPES - + # INTEGRATION_TYPES is a tuple, not a list self.assertIsInstance(INTEGRATION_TYPES, tuple) self.assertIn("api", INTEGRATION_TYPES) @@ -134,9 +141,9 @@ class TestSocketSDKMocked(unittest.TestCase): def setUp(self): """Set up test environment with mocked API.""" # Patch requests to avoid real API calls - self.requests_patcher = patch('socketdev.core.api.requests') + self.requests_patcher = patch("socketdev.core.api.requests") self.mock_requests = self.requests_patcher.start() - + self.sdk = socketdev(token="test-token") def tearDown(self): @@ -150,9 +157,9 @@ def test_diffscans_list_mocked(self): mock_response.json.return_value = {"results": []} mock_response.headers = {} self.mock_requests.request.return_value = mock_response - + result = self.sdk.diffscans.list("test-org") - + self.assertEqual(result, {"results": []}) self.mock_requests.request.assert_called_once() @@ -163,9 +170,9 @@ def test_diffscans_get_mocked(self): mock_response.json.return_value = {"id": "test-id", "status": "completed"} mock_response.headers = {} self.mock_requests.request.return_value = mock_response - + result = self.sdk.diffscans.get("test-org", "test-id") - + self.assertEqual(result, {"id": "test-id", "status": "completed"}) self.mock_requests.request.assert_called_once() @@ -176,10 +183,10 @@ def test_diffscans_create_from_ids_mocked(self): mock_response.json.return_value = {"id": "new-diff-scan-id"} mock_response.headers = {} self.mock_requests.request.return_value = mock_response - + params = {"before": "scan1", "after": "scan2", "description": "test"} result = self.sdk.diffscans.create_from_ids("test-org", params) - + self.assertEqual(result, {"id": "new-diff-scan-id"}) self.mock_requests.request.assert_called_once() @@ -190,9 +197,9 @@ def test_diffscans_delete_mocked(self): mock_response.json.return_value = {"status": "ok"} mock_response.headers = {} self.mock_requests.request.return_value = mock_response - + result = self.sdk.diffscans.delete("test-org", "test-id") - + self.assertTrue(result) self.mock_requests.request.assert_called_once() @@ -203,10 +210,10 @@ def test_quota_get_mocked(self): mock_response.json.return_value = {"quota": 1000, "used": 100} mock_response.headers = {} self.mock_requests.request.return_value = mock_response - + # Check the actual method signature for quota result = self.sdk.quota.get() # No org_slug parameter for quota - + self.assertEqual(result, {"quota": 1000, "used": 100}) self.mock_requests.request.assert_called_once() @@ -218,21 +225,16 @@ def setUp(self): """Set up test files.""" self.temp_dir = tempfile.mkdtemp() self.package_json_path = os.path.join(self.temp_dir, "package.json") - - test_package = { - "name": "test-package", - "version": "1.0.0", - "dependencies": { - "lodash": "4.18.1" - } - } - - with open(self.package_json_path, 'w') as f: + + test_package = {"name": "test-package", "version": "1.0.0", "dependencies": {"lodash": "4.18.1"}} + + with open(self.package_json_path, "w") as f: json.dump(test_package, f, indent=2) def tearDown(self): """Clean up test files.""" import shutil + shutil.rmtree(self.temp_dir) def test_file_loading_for_upload(self): @@ -255,9 +257,9 @@ class TestSocketSDKErrorHandling(unittest.TestCase): def setUp(self): """Set up test environment with mocked API.""" # Patch requests to control responses - self.requests_patcher = patch('socketdev.core.api.requests') + self.requests_patcher = patch("socketdev.core.api.requests") self.mock_requests = self.requests_patcher.start() - + self.sdk = socketdev(token="test-token") def tearDown(self): @@ -273,9 +275,9 @@ def test_diffscans_list_error_handling(self): mock_response.headers = {} mock_response.json.return_value = {"error": {"message": "Not found"}} self.mock_requests.request.return_value = mock_response - + # This will raise an exception from the SDK, which is expected behavior - with self.assertRaises(Exception): + with self.assertRaises(APIResourceNotFound): self.sdk.diffscans.list("test-org") def test_diffscans_successful_response(self): @@ -285,7 +287,7 @@ def test_diffscans_successful_response(self): mock_response.headers = {} mock_response.json.return_value = {"results": []} self.mock_requests.request.return_value = mock_response - + result = self.sdk.diffscans.list("test-org") self.assertEqual(result, {"results": []}) diff --git a/tests/unit/test_upload_file_ownership.py b/tests/unit/test_upload_file_ownership.py new file mode 100644 index 0000000..53adcb0 --- /dev/null +++ b/tests/unit/test_upload_file_ownership.py @@ -0,0 +1,39 @@ +import io +from unittest.mock import Mock + +import pytest + +from socketdev.report import Report + + +@pytest.mark.parametrize("request_fails", [False, True]) +def test_report_upload_closes_files_opened_from_paths(tmp_path, request_fails): + path = tmp_path / "package.json" + path.write_text("{}") + api = Mock() + uploaded = [] + + def request(**kwargs): + stream = kwargs["files"][0][1][1] + uploaded.append(stream) + assert not stream.closed + assert stream.read() == b"{}" + if request_fails: + raise RuntimeError("request failed") + return Mock(status_code=200, json=lambda: {"id": "report"}) + + api.do_request.side_effect = request + if request_fails: + with pytest.raises(RuntimeError, match="request failed"): + Report(api).create([("package", str(path))]) + else: + assert Report(api).create([("package", str(path))]) == {"id": "report"} + assert uploaded[0].closed + + +def test_report_upload_leaves_caller_owned_stream_open(): + api = Mock() + api.do_request.return_value.status_code = 200 + stream = io.BytesIO(b"{}") + Report(api).create([("package", ("package.json", stream))]) + assert not stream.closed diff --git a/tests/unit/test_upload_manifests.py b/tests/unit/test_upload_manifests.py index e26aa62..4cb7608 100644 --- a/tests/unit/test_upload_manifests.py +++ b/tests/unit/test_upload_manifests.py @@ -1,7 +1,6 @@ -import os -import tempfile import unittest -from unittest.mock import Mock, patch, mock_open +from unittest.mock import Mock, mock_open, patch + from socketdev.uploadmanifests import UploadManifests @@ -13,89 +12,66 @@ def setUp(self): def test_calculate_key_name_with_base_path(self): """Test that key names are calculated correctly with base_path.""" # Test with base_path - key = self.upload_manifests._calculate_key_name( - "/project/frontend/package.json", - base_path="/project" - ) + key = self.upload_manifests._calculate_key_name("/project/frontend/package.json", base_path="/project") self.assertEqual(key, "frontend/package.json") def test_calculate_key_name_with_workspace(self): """Test that key names are calculated correctly with workspace.""" # Test with workspace - key = self.upload_manifests._calculate_key_name( - "/project/frontend/package.json", - workspace="/project/" - ) + key = self.upload_manifests._calculate_key_name("/project/frontend/package.json", workspace="/project/") self.assertEqual(key, "frontend/package.json") def test_calculate_key_name_with_base_paths(self): """Test that key names are calculated correctly with base_paths.""" # Test with base_paths (takes precedence over base_path) key = self.upload_manifests._calculate_key_name( - "/project/frontend/package.json", - base_path="/project", - base_paths=["/different", "/project"] + "/project/frontend/package.json", base_path="/project", base_paths=["/different", "/project"] ) self.assertEqual(key, "frontend/package.json") def test_calculate_key_name_no_stripping(self): """Test that key names default to basename when no stripping options provided.""" # Test without any path stripping - should preserve relative path structure - key = self.upload_manifests._calculate_key_name( - "frontend/package.json" - ) + key = self.upload_manifests._calculate_key_name("frontend/package.json") self.assertEqual(key, "frontend/package.json") def test_calculate_key_name_absolute_path_no_stripping(self): """Test that absolute paths get cleaned up when no stripping options provided.""" - key = self.upload_manifests._calculate_key_name( - "/absolute/path/frontend/package.json" - ) + key = self.upload_manifests._calculate_key_name("/absolute/path/frontend/package.json") self.assertEqual(key, "absolute/path/frontend/package.json") def test_calculate_key_name_windows_paths(self): """Test that Windows paths are handled correctly.""" - key = self.upload_manifests._calculate_key_name( - "C:\\project\\frontend\\package.json", - base_path="C:\\project" - ) + key = self.upload_manifests._calculate_key_name("C:\\project\\frontend\\package.json", base_path="C:\\project") self.assertEqual(key, "frontend/package.json") - @patch('socketdev.uploadmanifests.Utils.load_files_for_sending_lazy') - @patch('os.path.exists') - @patch('os.path.isfile') + @patch("socketdev.uploadmanifests.Utils.load_files_for_sending_lazy") + @patch("os.path.exists") + @patch("os.path.isfile") def test_upload_manifest_files_lazy_loading(self, mock_isfile, mock_exists, mock_lazy_load): """Test that lazy loading preserves key names correctly.""" # Setup mocks mock_exists.return_value = True mock_isfile.return_value = True - mock_lazy_load.return_value = [ - ('frontend/package.json', ('frontend/package.json', Mock())) - ] + mock_lazy_load.return_value = [("frontend/package.json", ("frontend/package.json", Mock()))] mock_response = Mock() mock_response.status_code = 200 - mock_response.json.return_value = {'tarHash': 'test_hash'} + mock_response.json.return_value = {"tarHash": "test_hash"} self.mock_api.do_request.return_value = mock_response # Test lazy loading result = self.upload_manifests.upload_manifest_files( - "test_org", - ["/project/frontend/package.json"], - workspace="/project", - use_lazy_loading=True + "test_org", ["/project/frontend/package.json"], workspace="/project", use_lazy_loading=True ) - self.assertEqual(result, 'test_hash') + self.assertEqual(result, "test_hash") mock_lazy_load.assert_called_once_with( - ["/project/frontend/package.json"], - workspace="/project", - base_path=None, - base_paths=None + ["/project/frontend/package.json"], workspace="/project", base_path=None, base_paths=None ) - @patch('builtins.open', new_callable=mock_open, read_data=b'{"name": "test"}') - @patch('os.path.exists') - @patch('os.path.isfile') + @patch("builtins.open", new_callable=mock_open, read_data=b'{"name": "test"}') + @patch("os.path.exists") + @patch("os.path.isfile") def test_upload_manifest_files_non_lazy_loading(self, mock_isfile, mock_exists, mock_file): """Test that non-lazy loading produces same key names as lazy loading.""" # Setup mocks @@ -103,32 +79,29 @@ def test_upload_manifest_files_non_lazy_loading(self, mock_isfile, mock_exists, mock_isfile.return_value = True mock_response = Mock() mock_response.status_code = 200 - mock_response.json.return_value = {'tarHash': 'test_hash'} + mock_response.json.return_value = {"tarHash": "test_hash"} self.mock_api.do_request.return_value = mock_response # Test non-lazy loading with workspace result = self.upload_manifests.upload_manifest_files( - "test_org", - ["frontend/package.json"], - workspace="/project", - use_lazy_loading=False + "test_org", ["frontend/package.json"], workspace="/project", use_lazy_loading=False ) - self.assertEqual(result, 'test_hash') - + self.assertEqual(result, "test_hash") + # Verify the API was called with the correct file structure call_args = self.mock_api.do_request.call_args - files_arg = call_args[1]['files'] + files_arg = call_args[1]["files"] self.assertEqual(len(files_arg), 1) - + # The key should be 'frontend/package.json' not just 'package.json' - key, (filename, content) = files_arg[0] + key, (filename, _content) = files_arg[0] self.assertEqual(key, "frontend/package.json") self.assertEqual(filename, "frontend/package.json") - @patch('builtins.open', new_callable=mock_open, read_data=b'{"name": "test"}') - @patch('os.path.exists') - @patch('os.path.isfile') + @patch("builtins.open", new_callable=mock_open, read_data=b'{"name": "test"}') + @patch("os.path.exists") + @patch("os.path.isfile") def test_upload_manifest_files_consistency_between_modes(self, mock_isfile, mock_exists, mock_file): """Test that lazy and non-lazy loading produce identical key names.""" # Setup mocks @@ -136,44 +109,36 @@ def test_upload_manifest_files_consistency_between_modes(self, mock_isfile, mock mock_isfile.return_value = True mock_response = Mock() mock_response.status_code = 200 - mock_response.json.return_value = {'tarHash': 'test_hash'} + mock_response.json.return_value = {"tarHash": "test_hash"} self.mock_api.do_request.return_value = mock_response test_files = ["frontend/package.json", "backend/package.json"] - + # Test non-lazy loading - with patch('socketdev.uploadmanifests.Utils.load_files_for_sending_lazy') as mock_lazy_load: + with patch("socketdev.uploadmanifests.Utils.load_files_for_sending_lazy") as mock_lazy_load: mock_lazy_load.return_value = [ - ('frontend/package.json', ('frontend/package.json', Mock())), - ('backend/package.json', ('backend/package.json', Mock())) + ("frontend/package.json", ("frontend/package.json", Mock())), + ("backend/package.json", ("backend/package.json", Mock())), ] - + # Get lazy loading result - self.upload_manifests.upload_manifest_files( - "test_org", - test_files, - use_lazy_loading=True - ) - lazy_call_args = self.mock_api.do_request.call_args[1]['files'] + self.upload_manifests.upload_manifest_files("test_org", test_files, use_lazy_loading=True) + lazy_call_args = self.mock_api.do_request.call_args[1]["files"] # Reset mock self.mock_api.reset_mock() - + # Get non-lazy loading result - self.upload_manifests.upload_manifest_files( - "test_org", - test_files, - use_lazy_loading=False - ) - non_lazy_call_args = self.mock_api.do_request.call_args[1]['files'] + self.upload_manifests.upload_manifest_files("test_org", test_files, use_lazy_loading=False) + non_lazy_call_args = self.mock_api.do_request.call_args[1]["files"] # Compare key names - they should be identical lazy_keys = [item[0] for item in lazy_call_args] non_lazy_keys = [item[0] for item in non_lazy_call_args] - + self.assertEqual(lazy_keys, non_lazy_keys) - self.assertEqual(non_lazy_keys, ['frontend/package.json', 'backend/package.json']) + self.assertEqual(non_lazy_keys, ["frontend/package.json", "backend/package.json"]) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_working_endpoints_unit.py b/tests/unit/test_working_endpoints_unit.py index 3f8112b..c8b5324 100644 --- a/tests/unit/test_working_endpoints_unit.py +++ b/tests/unit/test_working_endpoints_unit.py @@ -7,11 +7,12 @@ Run with: python -m pytest tests/unit/test_working_endpoints_unit.py -v """ -import unittest -import tempfile import json import os +import tempfile +import unittest from unittest.mock import Mock, patch + from socketdev import socketdev from socketdev.fullscans import FullScanParams @@ -21,7 +22,7 @@ class TestWorkingEndpointsUnit(unittest.TestCase): def setUp(self): """Set up test environment with mocked API.""" - self.requests_patcher = patch('socketdev.core.api.requests') + self.requests_patcher = patch("socketdev.core.api.requests") self.mock_requests = self.requests_patcher.start() self.sdk = socketdev(token="test-token") @@ -35,7 +36,7 @@ def _mock_response(self, data=None, status_code=200): data = {"success": True} mock_response = Mock() mock_response.status_code = status_code - mock_response.headers = {'content-type': 'application/json'} + mock_response.headers = {"content-type": "application/json"} mock_response.json.return_value = data mock_response.text = json.dumps(data) self.mock_requests.request.return_value = mock_response @@ -49,9 +50,9 @@ def test_npm_issues_unit(self): """Test NPM issues endpoint - WORKING.""" expected_data = [{"type": "security", "severity": "high"}] self._mock_response(expected_data) - + result = self.sdk.npm.issues("lodash", "4.18.1") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -61,9 +62,9 @@ def test_npm_score_unit(self): """Test NPM score endpoint - WORKING.""" expected_data = [{"category": "security", "value": 85}] self._mock_response(expected_data) - + result = self.sdk.npm.score("lodash", "4.18.1") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -73,9 +74,9 @@ def test_openapi_get_unit(self): """Test OpenAPI specification retrieval - WORKING.""" expected_data = {"openapi": "3.0.0", "info": {"title": "Socket API"}} self._mock_response(expected_data) - + result = self.sdk.openapi.get() - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -85,9 +86,9 @@ def test_quota_get_unit(self): """Test quota retrieval - WORKING.""" expected_data = {"quota": 1000, "used": 100} self._mock_response(expected_data) - + result = self.sdk.quota.get() - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -97,9 +98,9 @@ def test_settings_get_unit(self): """Test settings retrieval - WORKING.""" expected_data = {"settings": {"notifications": True}} self._mock_response(expected_data) - + result = self.sdk.settings.get("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -109,9 +110,9 @@ def test_diffscans_list_unit(self): """Test diffscans list - WORKING.""" expected_data = {"results": []} self._mock_response(expected_data) - + result = self.sdk.diffscans.list("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -121,9 +122,9 @@ def test_diffscans_get_unit(self): """Test diffscans get - WORKING.""" expected_data = {"id": "diff-123", "status": "completed"} self._mock_response(expected_data) - + result = self.sdk.diffscans.get("test-org", "diff-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -133,9 +134,9 @@ def test_diffscans_gfm_unit(self): """Test diffscans GitHub Flavored Markdown - WORKING.""" expected_data = {"markdown": "# Report"} self._mock_response(expected_data) - + result = self.sdk.diffscans.gfm("test-org", "diff-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -145,10 +146,10 @@ def test_diffscans_create_from_ids_unit(self): """Test diffscans creation from IDs - WORKING.""" expected_data = {"id": "new-diff"} self._mock_response(expected_data, 201) - + params = {"before": "scan1", "after": "scan2"} result = self.sdk.diffscans.create_from_ids("test-org", params) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") @@ -157,9 +158,9 @@ def test_historical_list_unit(self): """Test historical list - WORKING.""" expected_data = {"results": []} self._mock_response(expected_data) - + result = self.sdk.historical.list("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -169,9 +170,9 @@ def test_fullscans_delete_unit(self): """Test fullscans deletion - WORKING.""" expected_data = {"status": "deleted"} self._mock_response(expected_data) - + result = self.sdk.fullscans.delete("test-org", "scan-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "DELETE") @@ -180,26 +181,22 @@ def test_fullscans_post_unit(self): """Test fullscans creation - WORKING.""" expected_data = {"id": "new-scan"} self._mock_response(expected_data, 201) - - params = FullScanParams( - repo="test-repo", - org_slug="test-org", - branch="main" - ) - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + params = FullScanParams(repo="test-repo", org_slug="test-org", branch="main") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() - + try: with open(f.name, "rb") as file_obj: files = [("file", ("package.json", file_obj))] result = self.sdk.fullscans.post(files, params) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") - + finally: os.unlink(f.name) @@ -215,7 +212,7 @@ def test_fullscans_post_with_workspace_unit(self): workspace="test-workspace", ) - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() @@ -239,9 +236,9 @@ def test_triage_list_alert_triage_unit(self): """Test triage list alerts - WORKING.""" expected_data = {"alerts": []} self._mock_response(expected_data) - + result = self.sdk.triage.list_alert_triage("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -250,9 +247,9 @@ def test_analytics_get_org_unit(self): """Test analytics org endpoint - WORKING.""" expected_data = [{"date": "2025-01-01", "count": 5}] self._mock_response(expected_data) - + result = self.sdk.analytics.get_org("dependencies") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -261,9 +258,9 @@ def test_analytics_get_repo_unit(self): """Test analytics repo endpoint - WORKING.""" expected_data = [{"date": "2025-01-01", "count": 3}] self._mock_response(expected_data) - + result = self.sdk.analytics.get_repo("test-repo", "alerts") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -272,9 +269,9 @@ def test_apitokens_create_unit(self): """Test API token creation - WORKING.""" expected_data = {"id": "token-123", "name": "test-token"} self._mock_response(expected_data, 201) - + result = self.sdk.apitokens.create("test-org", name="test-token") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") @@ -287,9 +284,9 @@ def test_export_cdx_bom_corrected_unit(self): """Test CDX BOM export - CORRECTED PATH.""" expected_data = {"bomFormat": "CycloneDX"} self._mock_response(expected_data) - + result = self.sdk.export.cdx_bom("test-org", "scan-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -300,9 +297,9 @@ def test_export_spdx_bom_corrected_unit(self): """Test SPDX BOM export - CORRECTED PATH.""" expected_data = {"spdxVersion": "SPDX-2.2"} self._mock_response(expected_data) - + result = self.sdk.export.spdx_bom("test-org", "scan-123") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -313,10 +310,10 @@ def test_dependencies_get_corrected_unit(self): """Test dependencies get - CORRECTED SIGNATURE.""" expected_data = {"dependencies": []} self._mock_response(expected_data) - + # Correct signature: limit and offset only result = self.sdk.dependencies.get(limit=50, offset=0) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") # It's actually POST, not GET @@ -326,9 +323,9 @@ def test_threatfeed_get_corrected_unit(self): """Test threatfeed get - CORRECTED PATH.""" expected_data = {"results": []} self._mock_response(expected_data) - + result = self.sdk.threatfeed.get("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -339,9 +336,9 @@ def test_historical_trend_corrected_unit(self): """Test historical trend - CORRECTED PATH.""" expected_data = {"trend": []} self._mock_response(expected_data) - + result = self.sdk.historical.trend("test-org") - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") @@ -356,21 +353,21 @@ def test_dependencies_post_corrected_unit(self): """Test dependencies post - CORRECTED FILE HANDLING.""" expected_data = {"packages": []} self._mock_response(expected_data) - + # Create a real file and pass just the filename, not tuple - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() - + try: # Pass just filename, not file object result = self.sdk.dependencies.post([f.name], {}) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") self.assertIn("/dependencies/upload", call_args[0][1]) - + finally: os.unlink(f.name) @@ -392,23 +389,23 @@ def test_diffscans_create_from_repo_corrected_unit(self): """Test diffscans creation from repo - CORRECTED PATH.""" expected_data = {"id": "repo-diff"} self._mock_response(expected_data, 201) - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"name": "test", "version": "1.0.0"}, f) f.flush() - + try: with open(f.name, "rb") as file_obj: files = [("file", ("package.json", file_obj))] params = {"description": "Test diff"} result = self.sdk.diffscans.create_from_repo("test-org", "test-repo", files, params) - + self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") # Corrected path based on actual implementation self.assertIn("/orgs/test-org/diff-scans/from-repo/test-repo", call_args[0][1]) - + finally: os.unlink(f.name) @@ -418,7 +415,7 @@ class TestEndpointSignatureDiscovery(unittest.TestCase): def setUp(self): """Set up test environment with mocked API.""" - self.requests_patcher = patch('socketdev.core.api.requests') + self.requests_patcher = patch("socketdev.core.api.requests") self.mock_requests = self.requests_patcher.start() self.sdk = socketdev(token="test-token") @@ -432,7 +429,7 @@ def _mock_response(self, data=None, status_code=200): data = {"success": True} mock_response = Mock() mock_response.status_code = status_code - mock_response.headers = {'content-type': 'application/json'} + mock_response.headers = {"content-type": "application/json"} mock_response.json.return_value = data mock_response.text = json.dumps(data) self.mock_requests.request.return_value = mock_response @@ -442,10 +439,10 @@ def test_org_get_discover_signature(self): """Discover the correct org.get signature.""" expected_data = {"name": "test-org"} self._mock_response(expected_data) - + # Try the call and see what path it actually uses try: - result = self.sdk.org.get("test-org") + self.sdk.org.get("test-org") call_args = self.mock_requests.request.call_args print(f"org.get path: {call_args[0][1]}") print(f"org.get method: {call_args[0][0]}") @@ -456,38 +453,40 @@ def test_labels_discover_signature(self): """Discover the correct labels signatures.""" expected_data = {"label": "test"} self._mock_response(expected_data) - + # Try to discover the actual method names available try: - methods = [attr for attr in dir(self.sdk.labels) if not attr.startswith('_')] + methods = [attr for attr in dir(self.sdk.labels) if not attr.startswith("_")] print(f"labels methods: {methods}") - + # Try get with different signatures - if hasattr(self.sdk.labels, 'get'): + if hasattr(self.sdk.labels, "get"): try: - result = self.sdk.labels.get("test-org", 1) + self.sdk.labels.get("test-org", 1) call_args = self.mock_requests.request.call_args print(f"labels.get(org, id) path: {call_args[0][1]}") except Exception as e: print(f"labels.get(org, id) error: {e}") - + except Exception as e: print(f"labels discovery error: {e}") def test_discover_missing_methods(self): """Test which methods don't exist on various endpoints.""" endpoints_to_check = [ - ('export', ['get']), - ('apitokens', ['list']), - ('licensemetadata', ['get']), - ('labels', ['put']) + ("export", ["get"]), + ("apitokens", ["list"]), + ("licensemetadata", ["get"]), + ("labels", ["put"]), ] - + for endpoint_name, methods in endpoints_to_check: endpoint = getattr(self.sdk, endpoint_name) for method in methods: has_method = hasattr(endpoint, method) - available_methods = [attr for attr in dir(endpoint) if not attr.startswith('_') and callable(getattr(endpoint, attr))] + available_methods = [ + attr for attr in dir(endpoint) if not attr.startswith("_") and callable(getattr(endpoint, attr)) + ] print(f"{endpoint_name}.{method} exists: {has_method}") print(f"{endpoint_name} available methods: {available_methods}") diff --git a/uv.lock b/uv.lock index bd1adc6..0c246d3 100644 --- a/uv.lock +++ b/uv.lock @@ -196,6 +196,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -625,6 +634,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, ] +[[package]] +name = "identify" +version = "2.6.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, +] + [[package]] name = "idna" version = "3.20" @@ -781,6 +799,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, ] +[[package]] +name = "nodeenv" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/99/d27cfc9518f49e4cbc25c683ec7cf8704a9d31311341c291c0e78ddf791b/nodeenv-1.12.0.tar.gz", hash = "sha256:4ea7f88339310afebc3253f2c7a91b94bacf97d73a36a5c108962abdfaca501e", size = 131292, upload-time = "2026-10-31T19:03:18.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/6a/8a9ac7d5e2f7cf53c459bfef90ebc17a2f8cd54b665af96799d4c534840e/nodeenv-1.12.0-py3-none-any.whl", hash = "sha256:80644d65c10cc2be12e3e4b54b3367ff288c7ad24ae75c2dcc65454fc7449d4d", size = 39442, upload-time = "2026-10-31T19:03:17.427Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -829,6 +856,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -918,6 +961,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "readme-renderer" version = "44.0" @@ -983,27 +1090,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, - { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, - { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, - { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, - { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, - { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, - { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, - { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, - { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, - { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/3d154514b5bad104f7e1c13169c2d09aa9959fccdc06eb47610ccdfa83ed/ruff-0.17.4.tar.gz", hash = "sha256:f32d178b50e826e00f6c6c1be3f56333068acb9b67fe167143527fa9b81e890d", size = 5357750, upload-time = "2026-11-05T18:52:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/61/c83c78bbb6108f6c6270a106cf12b54175f4f42da088b9bb880f18e31289/ruff-0.17.4-py3-none-linux_armv6l.whl", hash = "sha256:2b08c4ff98dc234f4a3871a3105353b922fa875ea1073ca31001eb8c06bb4d89", size = 10252195, upload-time = "2026-11-05T18:51:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/0a/12/dd158708378866e8b53e145a465dbc51a7cccb9fa197a74d67eaedde7234/ruff-0.17.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13e781c3c5f663e7fba737eaaeb70dd943dd55171daf316afb25cd5268cfe0c7", size = 10266044, upload-time = "2026-11-05T18:51:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/89a94a8eb11fd505a381ae6f04e190d038d9958695209dc1786ce5cfcda3/ruff-0.17.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ed2f416349944f908f9574215994602242b0836844eba48eec61473033afca0", size = 9432227, upload-time = "2026-11-05T18:51:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/a392dc38b215798ae72e639d228b7f6c02d826a95d1c0ad7c4c0ee49ef5b/ruff-0.17.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ea6e0017c785f7282092b11e46338e4ebed1b9e575ebf2e55a8a1eda968241a", size = 10136900, upload-time = "2026-11-05T18:51:44.562Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8f/a880f60f42005f6ffdf58f77af34c7ee327000b5607f48a2f6a69d8b242c/ruff-0.17.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e061dcc0933c7031a616ac8d66ac540309b2aa21ab558101a8f53e9ac7a1185", size = 10051245, upload-time = "2026-11-05T18:51:46.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/55/98844264108af7d5ec27ca3a9cd5a989094ca657a14599c4b69a537a0e9a/ruff-0.17.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e95a1515161138be3cbd1b06c06e739d8feb38ade35564556999fffc3686e", size = 10704341, upload-time = "2026-11-05T18:51:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/5edf97659c3ac7c73f978003bd314936f671037fdac6b978e9de3e4b2b65/ruff-0.17.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f28cf0dd37cb172fa5a8d2e92e6ada3a9dbd0609f094ed24039929eef0b7524", size = 11394465, upload-time = "2026-11-05T18:51:51.534Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8f/51371ae856b2bef4d15a044a574695fbc0f6ae63aafa42a2a22428070fb6/ruff-0.17.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b21c771b296888e8a35c6f48555f4b44e4483f61675d6ed51810a5203469ce6", size = 10988262, upload-time = "2026-11-05T18:51:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd025509b2e15721b426fc70fd946d71f43b3db3b08e946dfec2d696d911690", size = 10572829, upload-time = "2026-11-05T18:51:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/98/30/860b9dae67432b16b88558a9cb2d3194825fa62838f8b9cc49f5d9022095/ruff-0.17.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:49b77f7b3921669466882a4881168801bdb06ec2cd83fb936049016cf5fdece1", size = 10617545, upload-time = "2026-11-05T18:51:58.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/08/781a4b41a1c83fd6e30fea610d6502a6f94e49cf94693fd37bdcbc6bfe11/ruff-0.17.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3b84eb74f260d1c2dbd236fd9d4c011d4fdd0a52bdf8aea57a10e3a1b71fca5", size = 10111955, upload-time = "2026-11-05T18:52:00.59Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/94b1c7b677120f30839254518924f73a8157e9bcfa200edcd5796fecdb99/ruff-0.17.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:20e43d7a08e712e33067ba887e6574546c79bd4831596ce8f16803ac7ff9ad90", size = 10058355, upload-time = "2026-11-05T18:52:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/27/5f/a825910b9f309336dc33cb8c0774126662c1d2f765699b3e0a1bd538d01f/ruff-0.17.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0d1f4848fd2ec0499be4046db9b7dfcc952666885f00233cc7033515282556a8", size = 10415932, upload-time = "2026-11-05T18:52:06.264Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b8/0796468423413ea4df1a6e2442ae26cf49b8f7dda0eecf246e0d5d0dde54/ruff-0.17.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6007e7f3a0e55b0fbdb947af69cf24c21b4928df6be8ef3e767c69b5ccccf496", size = 10826033, upload-time = "2026-11-05T18:52:08.675Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/d8e407669ffe5014429baf780c2df47e001968a6e4c17f615e2e7b13a9ba/ruff-0.17.4-py3-none-win32.whl", hash = "sha256:3971a78cba76298b284c8321e6d40d93045ed348bec5c369d85099efc97fb7cf", size = 9931993, upload-time = "2026-11-05T18:52:11.169Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/42af8a72d8946e3c9efd2bbf6641e462b6832783b0d043d63ef5b280ba7f/ruff-0.17.4-py3-none-win_amd64.whl", hash = "sha256:67c5a894fc054cf41f75f7afa24815de5a74329d97634126c2a1015740ff4e8c", size = 10292922, upload-time = "2026-11-05T18:52:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/f1dbffcfb8ff25dadbc21f87a8b73c3848abe4abada9d4b08ccef8bec40c/ruff-0.17.4-py3-none-win_arm64.whl", hash = "sha256:181ae38a9a8fb94925bf7d0794327b48d7fd7f4b50cfd84c0e999cc09ef8d131", size = 9889396, upload-time = "2026-11-05T18:52:15.549Z" }, ] [[package]] @@ -1041,6 +1148,7 @@ dependencies = [ dev = [ { name = "hatch" }, { name = "hatchling" }, + { name = "pre-commit" }, { name = "ruff" }, { name = "twine" }, ] @@ -1054,10 +1162,11 @@ requires-dist = [ { name = "certifi", specifier = ">=2026.10.19" }, { name = "hatch", marker = "extra == 'dev'", specifier = ">=1.18.1" }, { name = "hatchling", marker = "extra == 'dev'", specifier = ">=1.27.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.6.2" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" }, { name = "requests", specifier = ">=2.33.0,<3" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.17.4" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "typing-extensions", specifier = ">=4.12.2,<5" }, ]