diff --git a/.github/actions/setup-hatch/action.yml b/.github/actions/setup-hatch/action.yml index 0da5160b..ed59e21f 100644 --- a/.github/actions/setup-hatch/action.yml +++ b/.github/actions/setup-hatch/action.yml @@ -6,8 +6,9 @@ description: >- runs: using: "composite" steps: + # Match the dev extra and build-system backend pins in pyproject.toml. - shell: bash run: | python -m pip install --upgrade pip pip install "virtualenv<20.36" - pip install hatchling==1.27.0 hatch==1.14.0 + pip install hatchling==1.33.0 hatch==1.18.2 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index a609193d..1e41e141 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -150,7 +150,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: $/.github/actions/setup-sfw + - uses: ./.github/actions/setup-sfw with: uv: "true" mode: firewall-free @@ -241,7 +241,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: $/.github/actions/setup-sfw + - uses: ./.github/actions/setup-sfw with: uv: "true" mode: firewall-enterprise @@ -332,7 +332,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: $/.github/actions/setup-sfw + - uses: ./.github/actions/setup-sfw with: node: "true" mode: firewall-free @@ -393,7 +393,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: $/.github/actions/setup-sfw + - uses: ./.github/actions/setup-sfw with: node: "true" mode: firewall-enterprise @@ -454,7 +454,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: $/.github/actions/setup-sfw + - uses: ./.github/actions/setup-sfw with: python: "true" mode: firewall-free @@ -518,7 +518,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: $/.github/actions/setup-sfw + - uses: ./.github/actions/setup-sfw with: python: "true" mode: firewall-enterprise diff --git a/.github/workflows/docker-stable.yml b/.github/workflows/docker-stable.yml index 636ba032..6f36b695 100644 --- a/.github/workflows/docker-stable.yml +++ b/.github/workflows/docker-stable.yml @@ -18,7 +18,7 @@ jobs: persist-credentials: false - name: Set up Docker publishing - uses: $/.github/actions/setup-docker + uses: ./.github/actions/setup-docker with: # Retagging copies manifests between tags; nothing is built. enable-qemu: "false" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..4af0054e --- /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 c80a0419..a6f946a6 100644 --- a/.github/workflows/package-check.yml +++ b/.github/workflows/package-check.yml @@ -28,7 +28,7 @@ jobs: python-version: "3.12" - name: Install build tooling - uses: $/.github/actions/setup-hatch + uses: ./.github/actions/setup-hatch - name: Install distribution validator run: python -m pip install "twine>=4.0.0" diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 7b08efff..581e6f32 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -93,6 +93,9 @@ jobs: core.setOutput('head_sha', pullRequest.head.sha); build: + # Manual builds execute PR code in the default branch's cache scope. + # Keep this job free of cache restore/save steps. Publishing runs separately + # with write permissions and consumes artifacts without checking out PR code. needs: context runs-on: ubuntu-latest timeout-minutes: 10 @@ -112,7 +115,7 @@ jobs: python-version: "3.12" - name: Install build tooling - uses: $/.github/actions/setup-hatch + uses: ./.github/actions/setup-hatch - name: Install distribution validator run: python -m pip install "twine>=4.0.0" @@ -252,7 +255,7 @@ jobs: path: dist - name: Set up Docker publishing - uses: $/.github/actions/setup-docker + uses: ./.github/actions/setup-docker with: # QEMU is required to build the arm64 layer on an amd64 runner. enable-qemu: "true" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index bfde20a3..75041b9e 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -60,26 +60,6 @@ jobs: print('import smoke OK') " - 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 --extra dev - - name: ๐Ÿงน run ruff - run: uv run ruff check - unsupported-python-install: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 545594d9..e843552c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: python-version: '3.13' - name: Install build tooling - uses: $/.github/actions/setup-hatch + uses: ./.github/actions/setup-hatch - name: Get Version id: version @@ -28,7 +28,7 @@ jobs: REF_NAME: ${{ github.ref_name }} run: | RAW_VERSION=$(hatch version) - echo "VERSION=$RAW_VERSION" >> $GITHUB_ENV + echo "VERSION=$RAW_VERSION" >> "$GITHUB_ENV" if [ "v$RAW_VERSION" != "$REF_NAME" ]; then echo "Error: Git tag ($REF_NAME) does not match hatch version (v$RAW_VERSION)" exit 1 @@ -39,12 +39,12 @@ jobs: env: VERSION: ${{ env.VERSION }} run: | - if curl -s -f https://pypi.org/pypi/socketsecurity/$VERSION/json > /dev/null; then + if curl -s -f "https://pypi.org/pypi/socketsecurity/$VERSION/json" > /dev/null; then echo "Version ${VERSION} already exists on PyPI" - echo "pypi_exists=true" >> $GITHUB_OUTPUT + echo "pypi_exists=true" >> "$GITHUB_OUTPUT" else echo "Version ${VERSION} not found on PyPI - proceeding with PyPI deployment" - echo "pypi_exists=false" >> $GITHUB_OUTPUT + echo "pypi_exists=false" >> "$GITHUB_OUTPUT" fi - name: Check Docker image existence @@ -54,9 +54,9 @@ jobs: run: | if curl -s -f "https://hub.docker.com/v2/repositories/socketdev/cli/tags/${VERSION}" > /dev/null; then echo "Docker image socketdev/cli:${VERSION} already exists" - echo "docker_exists=true" >> $GITHUB_OUTPUT + echo "docker_exists=true" >> "$GITHUB_OUTPUT" else - echo "docker_exists=false" >> $GITHUB_OUTPUT + echo "docker_exists=false" >> "$GITHUB_OUTPUT" fi - name: Build package @@ -70,7 +70,7 @@ jobs: uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 - name: Set up Docker publishing - uses: $/.github/actions/setup-docker + uses: ./.github/actions/setup-docker with: dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index ae187338..2c45fbdb 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -29,106 +29,7 @@ jobs: python -m pip install --upgrade pip pip install packaging - # Get version from current PR - PR_VERSION=$(grep -o "__version__.*" socketsecurity/__init__.py | awk '{print $3}' | tr -d "'") - echo "PR_VERSION=$PR_VERSION" >> $GITHUB_ENV - - # Get version from main branch - MAIN_VERSION=$(git show origin/main:socketsecurity/__init__.py | grep -o "__version__.*" | awk '{print $3}' | tr -d "'") - echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV - - export PR_VERSION - export MAIN_VERSION - - # Compare against the latest published PyPI release. - python3 <<'PY' - import json - import os - import subprocess - import tomllib - import urllib.request - from packaging import version - - pr_ver = version.parse(os.environ["PR_VERSION"]) - main_ver = version.parse(os.environ["MAIN_VERSION"]) - - with open("pyproject.toml", "rb") as fh: - pyproject_ver = version.parse(tomllib.load(fh)["project"]["version"]) - - # The version is two hand-maintained literals with nothing deriving one - # from the other: pyproject.toml is what actually gets published, and - # socketsecurity/__init__.py is what the CLI reports as its User-Agent. - # Every comparison below reads only __init__.py, so bumping that alone - # would pass this job and then publish under the old number -- caught - # late, by twine rejecting an existing file, after the merge. Require - # the two to agree before comparing anything. (uv.lock carries a third - # copy, but uv derives it and `uv lock --locked` in python-tests - # already fails when it drifts.) - if pr_ver != pyproject_ver: - print( - f"โŒ Version mismatch inside the PR: pyproject.toml is " - f"{pyproject_ver}, socketsecurity/__init__.py is {pr_ver}. " - f"Bump both." - ) - raise SystemExit(1) - - with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response: - pypi_data = json.load(response) - - published_versions = [] - for raw in pypi_data.get("releases", {}).keys(): - parsed = version.parse(raw) - if not parsed.is_prerelease and not parsed.is_devrelease: - published_versions.append(parsed) - - pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0") - - # The only hard requirement is that the version is ahead of what is - # actually released. Treating main's version as a second floor breaks - # the legitimate case where several PRs share one unreleased release: - # the first bumps main to the new version and the rest ride it without - # bumping again, which is what keeps them under a single changelog - # header. Main is therefore only a floor when this PR moves the - # version -- a change to it must go forwards, never backwards. - if pr_ver <= pypi_ver: - print( - f"โŒ Version {pr_ver} is already published on PyPI " - f"(latest release: {pypi_ver}). Bump it." - ) - raise SystemExit(1) - - # Releases are immutable: once a v release is published, its tag can't - # be moved or reused, even when that release failed and nothing reached PyPI. - # PyPI alone misses that, so a burned tag would only surface when the release - # run fails after the merge. - tag = f"v{os.environ['PR_VERSION']}" - if subprocess.run( - ["git", "rev-parse", "--quiet", "--verify", f"refs/tags/{tag}"], - capture_output=True, - ).returncode == 0: - print( - f"โŒ Tag {tag} already exists, so {pr_ver} can never be released " - f"(latest PyPI release: {pypi_ver}). Bump to a version with no tag." - ) - raise SystemExit(1) - - if pr_ver < main_ver: - print( - f"โŒ Version moves backwards: main is {main_ver}, PR is {pr_ver}." - ) - raise SystemExit(1) - - if pr_ver == main_ver: - print( - f"โœ… Riding main's unreleased {pr_ver} " - f"(latest PyPI release: {pypi_ver})." - ) - else: - print( - f"โœ… Version properly incremented. " - f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" - ) - PY + python3 .hooks/check_version.py - name: Require uv.lock update when pyproject changes run: | diff --git a/.hooks/check_version.py b/.hooks/check_version.py new file mode 100644 index 00000000..4b2be5c1 --- /dev/null +++ b/.hooks/check_version.py @@ -0,0 +1,88 @@ +"""Validate release versions for pull requests that change packaged files.""" + +import json +import os +import re +import subprocess +import tomllib +import urllib.request +from pathlib import Path + +from packaging.version import Version + + +def read_version(source: str) -> Version: + match = re.search(r"^__version__\s*=\s*['\"]([^'\"]+)['\"]", source, re.MULTILINE) + if not match: + raise ValueError("No __version__ declaration found") + return Version(match.group(1)) + + +def latest_pypi_version() -> Version: + with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response: + releases = json.load(response).get("releases", {}) + versions = (Version(raw) for raw in releases) + return max((v for v in versions if not v.is_prerelease and not v.is_devrelease), default=Version("0.0.0")) + + +def main() -> int: + pr_ver = read_version(Path("socketsecurity/__init__.py").read_text()) + main_ver = read_version( + subprocess.check_output(["git", "show", "origin/main:socketsecurity/__init__.py"], text=True) + ) + changed_paths = subprocess.check_output( + ["git", "diff", "--name-only", "origin/main...HEAD"], text=True + ).splitlines() + enforced = any( + name.startswith("socketsecurity/") or name in {"pyproject.toml", "uv.lock"} for name in changed_paths + ) + + if github_env := os.environ.get("GITHUB_ENV"): + with open(github_env, "a", encoding="utf-8") as env_file: + env_file.write(f"PR_VERSION={pr_ver}\nMAIN_VERSION={main_ver}\n") + + def reject(message: str) -> int: + print(message) + if enforced: + return 1 + print("โ„น๏ธ Not enforced: this PR changes no packaged files, so it ships nothing that needs a new version.") + return 0 + + with open("pyproject.toml", "rb") as project_file: + pyproject_ver = Version(tomllib.load(project_file)["project"]["version"]) + if pr_ver != pyproject_ver: + return reject( + f"โŒ Version mismatch inside the PR: pyproject.toml is {pyproject_ver}, " + f"socketsecurity/__init__.py is {pr_ver}. Bump both." + ) + + pypi_ver = latest_pypi_version() + if pr_ver <= pypi_ver: + return reject(f"โŒ Version {pr_ver} is already published on PyPI (latest release: {pypi_ver}). Bump it.") + + # Published release tags are immutable, even if publication to PyPI fails. + tag = f"v{pr_ver}" + if ( + subprocess.run( + ["git", "rev-parse", "--quiet", "--verify", f"refs/tags/{tag}"], capture_output=True, check=False + ).returncode + == 0 + ): + return reject( + f"โŒ Tag {tag} already exists, so {pr_ver} can never be released " + f"(latest PyPI release: {pypi_ver}). Bump to a version with no tag." + ) + + if pr_ver < main_ver: + return reject(f"โŒ Version moves backwards: main is {main_ver}, PR is {pr_ver}.") + + # Multiple pull requests may share main's unreleased version. + if pr_ver == main_ver: + print(f"โœ… Riding main's unreleased {pr_ver} (latest PyPI release: {pypi_ver}).") + else: + print(f"โœ… Version properly incremented. Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py old mode 100644 new mode 100755 index 51835c3f..50d191fe --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -16,6 +16,7 @@ PYPI_PROD_API = "https://pypi.org/pypi/socketsecurity/json" PYPI_TEST_API = "https://test.pypi.org/pypi/socketsecurity/json" + def read_version_from_init(path: pathlib.Path) -> str: content = path.read_text() match = VERSION_PATTERN.search(content) @@ -24,6 +25,7 @@ def read_version_from_init(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 @@ -72,6 +76,7 @@ def fetch_latest_stable_pypi_version(): return None return max(stable_versions) + def find_next_available_dev_version(base_version: str) -> str: existing_versions = fetch_existing_versions(PYPI_TEST_API) for i in range(1, 100): @@ -94,12 +99,13 @@ def find_next_stable_patch_version(current_version: str) -> str: next_parts = (base_parts[0], base_parts[1], base_parts[2] + 1) return format_stable_version(next_parts) + def inject_version(version: str): print(f"๐Ÿ” Updating version to: {version}") # Update __init__.py init_content = INIT_FILE.read_text() - new_init_content = VERSION_PATTERN.sub(f"__version__ = '{version}'", init_content) + new_init_content = VERSION_PATTERN.sub(f'__version__ = "{version}"', init_content) INIT_FILE.write_text(new_init_content) # Update pyproject.toml @@ -190,16 +196,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 index d201e7f5..bacb47be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,4 +6,19 @@ repos: entry: python .hooks/sync_version.py language: python always_run: true - pass_filenames: false \ No newline at end of file + pass_filenames: false + + # 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 810aabd4..7af36bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 2.12.1 + +### Fixed + +- Configuration debug output masks credential fields and integration URL paths, + including nested plugin settings and Slack webhook lists. Slack configuration + environment contents are reported only as set or unset. +- The ignore-command authorization policy remains visible in configuration + debug output so comment-handling decisions can be diagnosed. +- Alert timestamps retain their leading and trailing characters when the + optional timezone suffix is removed. +- Slack, Teams, Jira, webhook, and GitLab commit-status requests use a 30-second + timeout so an unresponsive endpoint cannot hold a scan open indefinitely. +- Jira request failures are logged without interrupting report file creation or + changing the scan's exit code. +- Manifest upload validation also runs under `python -O`. +- Duplicate SBOM package diagnostics use debug logging, keeping stdout available + for machine-readable reports. +- Configuration warnings and errors use the CLI's configured logger. +- Manifest links use the configured source-control type, including when a Socket + organization slug contains a source-control provider's name. + ## 2.12.0 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed9ee2b0..a359169f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,9 +11,16 @@ dependencies: uv sync --all-extras ``` +Install the git hooks once per clone: + +```bash +make hooks +``` + Before opening a pull request, run: ```bash +make lint make test uv run hatch build uv run python -m twine check dist/* @@ -22,6 +29,58 @@ uv run python -m twine check dist/* To develop against a local SDK checkout, set `SOCKET_SDK_PATH` if it is not at `../socketdev`, then run `make first-time-local-setup`. +## Linting + +Ruff reads the rule set from `pyproject.toml`. Its version is pinned in the +project's `dev` extra and resolved in `uv.lock` for local commands, pre-commit, +and CI. + +- `make lint` checks linting and formatting across the repository. +- `make lint-fix` applies safe lint fixes; `make format` formats Python files. +- The pre-commit hooks fix and format the files in a commit. When they modify a + file, review and stage the edits before committing again. +- The `Lint` workflow checks the whole repository on every pull request and + every push to `main`, including `.hooks/`, benchmarks, and end-to-end tooling. + It has no path filter, so it can report on every pull request when configured + as a required check. + +The formatter uses a line length of 120. `E501` and `W291`/`W293` are not +selected: whitespace inside strings can affect help text, logs, and rendered +Markdown. In particular, trailing double-spaces in pull request comment strings +are Markdown hard line breaks. + +### One trap worth knowing + +Never run `ruff check --select --fix` with `RUF100` in the select. +With a narrow select, RUF100 considers every `# noqa` for a *non-selected* rule +to be unused and deletes it -- silently stripping the complexity suppressions +across the repository. Run `make lint-fix`, which uses the full configured rule +set, instead of hand-rolling a `--select`. + +### Complexity limits + +Two rules bound how large a single function may get: + +| Rule | Limit | What it measures | +| --- | --- | --- | +| `C901` | 12 | Cyclomatic complexity: independent paths through a function. | +| `PLR0913` | 8 | Arguments in a function definition. | + +Functions that already exceed these limits carry an explicit +`# noqa: C901` / `# noqa: PLR0913` on their `def` line. Treat these as a refactoring backlog: + +- **Do not add a new suppression.** If a function you are writing trips the + limit, split it. +- **Suppressions clean themselves up.** `RUF100` fails the build on a `# noqa` + that no longer applies, so refactoring a function back under the limit forces + the marker to be removed. + +To see what is left: + +```bash +grep -rn 'noqa: C901\|noqa: PLR0913' socketsecurity/ tests/ +``` + ## Pull request validation The `Package Check` workflow runs automatically for pull requests. It builds @@ -29,6 +88,21 @@ and validates the distributions, smoke-tests the wheel, and uploads the distributions as workflow artifacts. It does not publish a package or Docker image. +## Build tooling and release notes + +Keep the Hatch and Hatchling pins in `.github/actions/setup-hatch/action.yml` +aligned with the `dev` extra and build backend in `pyproject.toml`. + +The `Version Check` workflow enforces release-version checks for changes to +`socketsecurity/`, `pyproject.toml`, or `uv.lock`. A pull request can share +`main`'s unreleased version. CI and documentation changes alone do not require +a package version bump. Version extraction accepts single- and double-quoted +`__version__` declarations. + +Keep changelog entries short and focused on observable CLI, dashboard, or scan +log behavior. Document contributor setup and CI policies in this guide, and +describe workflow implementation changes in the pull request description. + ## Publishing pull request previews Preview publication is intentionally opt-in. Only request previews for code diff --git a/Makefile b/Makefile index c0fb1b01..b6fb52ee 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup sync clean test lint update-lock local-dev first-time-setup dev-setup sync-all first-time-local-setup +.PHONY: setup sync clean test lint lint-fix format format-check hooks update-lock local-dev first-time-setup dev-setup sync-all first-time-local-setup # Environment variable for local SDK path (optional) SOCKET_SDK_PATH ?= ../socketdev @@ -57,6 +57,20 @@ clean: test: uv run pytest +# Installs the git pre-commit hooks (ruff + version sync). +hooks: + uv run --locked --extra dev pre-commit install + +# Run the same Ruff checks as the Lint workflow. lint: - uv run ruff check . - uv run ruff format --check . \ No newline at end of file + 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 diff --git a/benchmarks/manifest_discovery.py b/benchmarks/manifest_discovery.py old mode 100644 new mode 100755 index abc0e8bb..81412728 --- a/benchmarks/manifest_discovery.py +++ b/benchmarks/manifest_discovery.py @@ -47,8 +47,8 @@ def legacy_discover(root: Path) -> set[str]: insensitive = Core.to_case_insensitive_regex(pattern) for candidate in root.rglob(insensitive): if candidate.is_file() and not Core.is_excluded( - str(candidate), - excluded_dirs, + str(candidate), + excluded_dirs, ): results.add(candidate.as_posix()) return results @@ -85,10 +85,7 @@ def main() -> None: ) if legacy_results != new_results: - raise SystemExit( - "Manifest result mismatch: " - f"legacy={len(legacy_results)}, single_pass={len(new_results)}" - ) + raise SystemExit(f"Manifest result mismatch: legacy={len(legacy_results)}, single_pass={len(new_results)}") speedup = legacy_seconds / new_seconds if new_seconds else float("inf") print(f"Manifests: {len(new_results)}") diff --git a/pyproject.toml b/pyproject.toml index b62bd60f..3b2f2ff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.12.0" +version = "2.12.1" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ @@ -85,7 +85,9 @@ show_missing = true skip_empty = true [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. +line-length = 120 exclude = [ ".bzr", ".direnv", @@ -116,25 +118,104 @@ 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", # Current rules - "I", # isort - "F401", # Unused imports - "F403", # Star imports - "F405", # Star imports undefined - "F821", # Undefined names + # --- 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", # git and Coana use argument lists; shell=True remains checked + "S607", # git, node, and other tools intentionally resolve through PATH ] # 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 +] + +# Developer tooling, not shipped in the wheel. stdout is their entire interface. +".hooks/**" = ["T201", "S310"] # fetches a hardcoded PyPI JSON endpoint +"benchmarks/**" = ["T201"] + [tool.ruff.lint.isort] known-first-party = ["socketsecurity"] diff --git a/scripts/build_container.sh b/scripts/build_container.sh index 2e078d40..f60e8d38 100755 --- a/scripts/build_container.sh +++ b/scripts/build_container.sh @@ -1,5 +1,5 @@ #!/bin/sh -VERSION=$(grep -o "__version__.*" socketsecurity/__init__.py | awk '{print $3}' | tr -d "'") +VERSION=$(grep -E '^__version__[[:space:]]*=' socketsecurity/__init__.py | awk '{print $3}' | tr -d "\"'") ENABLE_PYPI_BUILD=$1 STABLE_VERSION=$2 diff --git a/scripts/build_container_flexible.sh b/scripts/build_container_flexible.sh index 4ecbc7ad..01d220d9 100644 --- a/scripts/build_container_flexible.sh +++ b/scripts/build_container_flexible.sh @@ -1,5 +1,5 @@ #!/bin/sh -VERSION=$(grep -o "__version__.*" socketsecurity/__init__.py | awk '{print $3}' | tr -d "'") +VERSION=$(grep -E '^__version__[[:space:]]*=' socketsecurity/__init__.py | awk '{print $3}' | tr -d "\"'") ENABLE_PYPI_BUILD=$1 STABLE_VERSION=$2 GO_VERSION=${GO_VERSION:-"1.21"} diff --git a/scripts/deploy-test-pypi.sh b/scripts/deploy-test-pypi.sh index 408cc92e..0152acb3 100755 --- a/scripts/deploy-test-pypi.sh +++ b/scripts/deploy-test-pypi.sh @@ -4,7 +4,7 @@ # Get version from __init__.py INIT_FILE="socketsecurity/__init__.py" -ORIGINAL_VERSION=$(grep -o "__version__.*" $INIT_FILE | awk '{print $3}' | tr -d "'") +ORIGINAL_VERSION=$(grep -E '^__version__[[:space:]]*=' $INIT_FILE | awk '{print $3}' | tr -d "\"'") BACKUP_FILE="${INIT_FILE}.bak" # Get existing versions from TestPyPI @@ -37,7 +37,7 @@ echo "Deploying version ${VERSION} to Test PyPI" cp $INIT_FILE $BACKUP_FILE # Update version in __init__.py -sed -i.tmp "s/__version__ = '${ORIGINAL_VERSION}'/__version__ = '${VERSION}'/" $INIT_FILE +sed -i.tmp -E "s/__version__ = ['\"]${ORIGINAL_VERSION}['\"]/__version__ = \"${VERSION}\"/" $INIT_FILE rm "${INIT_FILE}.tmp" # Build and upload to test PyPI diff --git a/session.md b/session.md index 5707e679..186c9406 100644 --- a/session.md +++ b/session.md @@ -66,7 +66,7 @@ Keep all existing webhook functionality unchanged. ```python { "channel": "channel-name", # or "C1234567890" - "blocks": blocks + "blocks": blocks, } ``` - Headers: `{"Authorization": f"Bearer {bot_token}", "Content-Type": "application/json"}` diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 203f24cf..54f9ace1 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,4 +1,4 @@ -__author__ = 'socket.dev' -__version__ = '2.12.0' -USER_AGENT_NAME = 'socket-python-cli' -USER_AGENT = f'{USER_AGENT_NAME}/{__version__}' +__author__ = "socket.dev" +__version__ = "2.12.1" +USER_AGENT_NAME = "socket-python-cli" +USER_AGENT = f"{USER_AGENT_NAME}/{__version__}" diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 9e8f1580..3c1cd778 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -2,13 +2,16 @@ import json import logging import os +import sys import tomllib from dataclasses import asdict, dataclass, field -from typing import List, Optional from socketdev import INTEGRATION_TYPES, IntegrationType from socketsecurity import __version__ +from socketsecurity.redaction import redact_mapping + +log = logging.getLogger("socketcli") def get_plugin_config_from_env(prefix: str) -> dict: @@ -30,13 +33,13 @@ def get_plugin_config_from_env(prefix: str) -> dict: COMMIT_MESSAGE_TRUNCATION_MARKER = "..." -def truncate_commit_message(commit_message: Optional[str]) -> Optional[str]: +def truncate_commit_message(commit_message: str | None) -> str | None: """Cap commit_message to a length the full-scan request line can carry.""" if commit_message and len(commit_message) > MAX_COMMIT_MESSAGE_LENGTH: # INFO, not DEBUG: the scan keeps the truncated value, so for a CI job that does # not pass --enable-debug this line is the only explanation of why the message in # the dashboard is clipped. - logging.info( + log.info( f"commit_message truncated from {len(commit_message)} to " f"{MAX_COMMIT_MESSAGE_LENGTH} characters to stay within API request size limits" ) @@ -65,25 +68,26 @@ def load_cli_config_file(config_path: str) -> dict: elif config_path.lower().endswith(".toml"): data = tomllib.load(f) else: - logging.error("--config must be a .json or .toml file") - exit(1) + log.error("--config must be a .json or .toml file") + sys.exit(1) except FileNotFoundError: - logging.error(f"Config file not found: {config_path}") - exit(1) + log.error(f"Config file not found: {config_path}") + sys.exit(1) except (json.JSONDecodeError, tomllib.TOMLDecodeError) as e: - logging.error(f"Invalid config file format: {e}") - exit(1) + log.error(f"Invalid config file format: {e}") + sys.exit(1) if not isinstance(data, dict): - logging.error("Config file must contain a top-level object/table") - exit(1) + log.error("Config file must contain a top-level object/table") + sys.exit(1) scoped = data.get("socketcli") if isinstance(scoped, dict): return scoped return data -def normalize_exclude_paths(value) -> Optional[List[str]]: + +def normalize_exclude_paths(value) -> list[str] | None: """Normalize a --exclude-paths value into a clean list of patterns. Accepts a comma-separated string (CLI) or a list/tuple (e.g. a JSON/TOML --config file @@ -101,7 +105,7 @@ def normalize_exclude_paths(value) -> Optional[List[str]]: return cleaned or None -def validate_exclude_paths(patterns: List[str]) -> None: +def validate_exclude_paths(patterns: list[str]) -> None: """Validate --exclude-paths patterns (mirrors Node's assertValidExcludePaths). Patterns are scan-root-relative globs. Reject the cases coana's --exclude-dirs / fast-glob @@ -114,56 +118,56 @@ def validate_exclude_paths(patterns: List[str]) -> None: for p in patterns: norm = (p or "").strip().replace("\\", "/") if norm.startswith("!"): - logging.error(f"--exclude-paths: negation patterns are not supported: {p!r}") - exit(1) + log.error(f"--exclude-paths: negation patterns are not supported: {p!r}") + sys.exit(1) if norm.startswith("/"): - logging.error(f"--exclude-paths: patterns must be scan-root relative (no leading '/'): {p!r}") - exit(1) + log.error(f"--exclude-paths: patterns must be scan-root relative (no leading '/'): {p!r}") + sys.exit(1) if norm == ".." or norm.startswith("../") or "/../" in norm or norm.endswith("/.."): - logging.error(f"--exclude-paths: '..' path traversal is not allowed: {p!r}") - exit(1) + log.error(f"--exclude-paths: '..' path traversal is not allowed: {p!r}") + sys.exit(1) if norm.rstrip("/") in degenerate: - logging.error(f"--exclude-paths: pattern would exclude everything: {p!r}") - exit(1) + log.error(f"--exclude-paths: pattern would exclude everything: {p!r}") + sys.exit(1) @dataclass class PluginConfig: enabled: bool = False - levels: List[str] = None - config: Optional[dict] = None + levels: list[str] = None + config: dict | None = None @dataclass class CliConfig: api_token: str - repo: Optional[str] + repo: str | None branch: str = "" - committers: Optional[List[str]] = None + committers: list[str] | None = None pr_number: str = "0" pr_number_explicit: bool = False - commit_message: Optional[str] = None + commit_message: str | None = None default_branch: bool = False target_path: str = "./" scm: str = "api" - sbom_file: Optional[str] = None + sbom_file: str | None = None commit_sha: str = "" - base_scan_id: Optional[str] = None - base_commit_sha: Optional[str] = None + base_scan_id: str | None = None + base_commit_sha: str | None = None generate_license: bool = False enable_debug: bool = False allow_unverified: bool = False enable_json: bool = False - json_file: Optional[str] = None + json_file: str | None = None enable_sarif: bool = False - sarif_file: Optional[str] = None + sarif_file: str | None = None sarif_scope: str = "diff" sarif_grouping: str = "instance" sarif_reachability: str = "all" enable_gitlab_security: bool = False - gitlab_security_file: Optional[str] = None - summary_file: Optional[str] = None - report_link_file: Optional[str] = None + gitlab_security_file: str | None = None + summary_file: str | None = None + report_link_file: str | None = None disable_overview: bool = False disable_security_issue: bool = False files: str = None @@ -173,49 +177,49 @@ class CliConfig: ignore_authorization: str = "enforce" # Tri-state log-upload preference: True = --upload-logs, False = --no-upload-logs, # None = neither (server-side override decides). - upload_logs: Optional[bool] = None + upload_logs: bool | None = None strict_blocking: bool = False integration_type: IntegrationType = "api" - integration_org_slug: Optional[str] = None + integration_org_slug: str | None = None pending_head: bool = False enable_diff: bool = False - timeout: Optional[int] = 1200 + timeout: int | None = 1200 exit_code_on_api_error: int = 3 exclude_license_details: bool = False include_module_folders: bool = False repo_is_public: bool = False - excluded_ecosystems: list[str] = field(default_factory=lambda: []) - exclude_paths: Optional[List[str]] = None - included_dirs: List[str] = field(default_factory=lambda: []) + excluded_ecosystems: list[str] = field(default_factory=list) + exclude_paths: list[str] | None = None + included_dirs: list[str] = field(default_factory=list) version: str = __version__ jira_plugin: PluginConfig = field(default_factory=PluginConfig) slack_plugin: PluginConfig = field(default_factory=PluginConfig) - slack_webhook: Optional[str] = None + slack_webhook: str | None = None license_file_name: str = "license_output.json" - save_submitted_files_list: Optional[str] = None - save_manifest_tar: Optional[str] = None - sub_paths: List[str] = field(default_factory=list) - workspace_name: Optional[str] = None - workspace: Optional[str] = None + save_submitted_files_list: str | None = None + save_manifest_tar: str | None = None + sub_paths: list[str] = field(default_factory=list) + workspace_name: str | None = None + workspace: str | None = None # Web URL of the scanned repository, resolved at runtime; see resolve_repository_url. - repository_url: Optional[str] = None + repository_url: str | None = None # Reachability Flags reach: bool = False - reach_version: Optional[str] = None - reach_analysis_memory_limit: Optional[str] = None - reach_analysis_timeout: Optional[str] = None + reach_version: str | None = None + reach_analysis_memory_limit: str | None = None + reach_analysis_timeout: str | None = None reach_disable_analytics: bool = False reach_disable_analysis_splitting: bool = False # Deprecated, kept for backwards compatibility reach_enable_analysis_splitting: bool = False reach_detailed_analysis_log_file: bool = False reach_lazy_mode: bool = False # Deprecated, kept for backwards compatibility - reach_ecosystems: Optional[List[str]] = None - reach_exclude_paths: Optional[List[str]] = None + reach_ecosystems: list[str] | None = None + reach_exclude_paths: list[str] | None = None reach_skip_cache: bool = False - reach_min_severity: Optional[str] = None - reach_output_file: Optional[str] = None - reach_concurrency: Optional[int] = None - reach_additional_params: Optional[List[str]] = None + reach_min_severity: str | None = None + reach_output_file: str | None = None + reach_concurrency: int | None = None + reach_additional_params: list[str] | None = None only_facts_file: bool = False reach_use_only_pregenerated_sboms: bool = False reach_continue_on_analysis_errors: bool = False @@ -229,7 +233,7 @@ class CliConfig: enable_commit_status: bool = False legal: bool = False legal_format: str = "socket" - config_file: Optional[str] = None + config_file: str | None = None def __post_init__(self): # Capped on construction so that every source of commit_message -- the @@ -238,7 +242,7 @@ def __post_init__(self): self.commit_message = truncate_commit_message(self.commit_message) @classmethod - def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': + def from_args(cls, args_list: list[str] | None = None) -> "CliConfig": # noqa: C901 parser = create_argument_parser() pre_parser = argparse.ArgumentParser(add_help=False) @@ -261,26 +265,22 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': integration_type = getattr(args, "integration", "api") pr_number = getattr(args, "pr_number", "0") - if ( - not integration_explicit and - integration_type == "api" and - args.scm in ("github", "gitlab") - ): + if not integration_explicit and integration_type == "api" and args.scm in ("github", "gitlab"): integration_type = args.scm if args.reach_exclude_paths: - logging.warning( + log.warning( "--reach-exclude-paths is deprecated; use --exclude-paths instead. " "It is still honored and unioned with --exclude-paths." ) # Get API token from env or args (check multiple env var names) api_token = ( - os.getenv("SOCKET_SECURITY_API_KEY") or - os.getenv("SOCKET_SECURITY_API_TOKEN") or - os.getenv("SOCKET_API_KEY") or - os.getenv("SOCKET_API_TOKEN") or - args.api_token + os.getenv("SOCKET_SECURITY_API_KEY") + or os.getenv("SOCKET_SECURITY_API_TOKEN") + or os.getenv("SOCKET_API_KEY") + or os.getenv("SOCKET_API_TOKEN") + or args.api_token ) # --sarif-file implies --enable-sarif @@ -293,122 +293,122 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': commit_message = commit_message[1:-1] config_args = { - 'api_token': api_token, - 'repo': args.repo, - 'branch': args.branch, - 'committers': args.committers, - 'pr_number': pr_number, - 'pr_number_explicit': pr_number_explicit, - 'commit_message': commit_message, - 'default_branch': args.default_branch, - 'target_path': os.path.expanduser(args.target_path), - 'scm': args.scm, - 'sbom_file': args.sbom_file, - 'commit_sha': args.commit_sha, - 'base_scan_id': args.base_scan_id, - 'base_commit_sha': args.base_commit_sha, - 'generate_license': args.generate_license, - 'enable_debug': args.enable_debug, - 'enable_diff': args.enable_diff, - 'allow_unverified': args.allow_unverified, - 'enable_json': args.enable_json, - 'json_file': args.json_file, - 'enable_sarif': args.enable_sarif, - 'sarif_file': args.sarif_file, - 'sarif_scope': args.sarif_scope, - 'sarif_grouping': args.sarif_grouping, - 'sarif_reachability': args.sarif_reachability, - 'enable_gitlab_security': args.enable_gitlab_security, - 'gitlab_security_file': args.gitlab_security_file, - 'summary_file': args.summary_file, - 'report_link_file': args.report_link_file, - 'disable_overview': args.disable_overview, - 'disable_security_issue': args.disable_security_issue, - 'files': args.files, - 'ignore_commit_files': args.ignore_commit_files, - 'disable_blocking': args.disable_blocking, - 'disable_ignore': args.disable_ignore, - 'ignore_authorization': args.ignore_authorization, - 'upload_logs': args.upload_logs, - 'strict_blocking': args.strict_blocking, - 'integration_type': integration_type, - 'pending_head': args.pending_head, - 'timeout': args.timeout, - 'exit_code_on_api_error': args.exit_code_on_api_error, - 'exclude_license_details': args.exclude_license_details, - 'include_module_folders': args.include_module_folders, - 'repo_is_public': args.repo_is_public, + "api_token": api_token, + "repo": args.repo, + "branch": args.branch, + "committers": args.committers, + "pr_number": pr_number, + "pr_number_explicit": pr_number_explicit, + "commit_message": commit_message, + "default_branch": args.default_branch, + "target_path": os.path.expanduser(args.target_path), + "scm": args.scm, + "sbom_file": args.sbom_file, + "commit_sha": args.commit_sha, + "base_scan_id": args.base_scan_id, + "base_commit_sha": args.base_commit_sha, + "generate_license": args.generate_license, + "enable_debug": args.enable_debug, + "enable_diff": args.enable_diff, + "allow_unverified": args.allow_unverified, + "enable_json": args.enable_json, + "json_file": args.json_file, + "enable_sarif": args.enable_sarif, + "sarif_file": args.sarif_file, + "sarif_scope": args.sarif_scope, + "sarif_grouping": args.sarif_grouping, + "sarif_reachability": args.sarif_reachability, + "enable_gitlab_security": args.enable_gitlab_security, + "gitlab_security_file": args.gitlab_security_file, + "summary_file": args.summary_file, + "report_link_file": args.report_link_file, + "disable_overview": args.disable_overview, + "disable_security_issue": args.disable_security_issue, + "files": args.files, + "ignore_commit_files": args.ignore_commit_files, + "disable_blocking": args.disable_blocking, + "disable_ignore": args.disable_ignore, + "ignore_authorization": args.ignore_authorization, + "upload_logs": args.upload_logs, + "strict_blocking": args.strict_blocking, + "integration_type": integration_type, + "pending_head": args.pending_head, + "timeout": args.timeout, + "exit_code_on_api_error": args.exit_code_on_api_error, + "exclude_license_details": args.exclude_license_details, + "include_module_folders": args.include_module_folders, + "repo_is_public": args.repo_is_public, "excluded_ecosystems": args.excluded_ecosystems, - 'license_file_name': args.license_file_name, - 'save_submitted_files_list': args.save_submitted_files_list, - 'save_manifest_tar': args.save_manifest_tar, - 'sub_paths': args.sub_paths or [], - 'workspace_name': args.workspace_name, - 'workspace': args.workspace, - 'slack_webhook': args.slack_webhook, - 'reach': args.reach, - 'reach_version': args.reach_version, - 'reach_analysis_timeout': args.reach_analysis_timeout, - 'reach_analysis_memory_limit': args.reach_analysis_memory_limit, - 'reach_disable_analytics': args.reach_disable_analytics, - 'reach_disable_analysis_splitting': args.reach_disable_analysis_splitting, - 'reach_enable_analysis_splitting': args.reach_enable_analysis_splitting, - 'reach_detailed_analysis_log_file': args.reach_detailed_analysis_log_file, - 'reach_lazy_mode': args.reach_lazy_mode, - 'reach_ecosystems': args.reach_ecosystems.split(',') if args.reach_ecosystems else None, - 'reach_exclude_paths': args.reach_exclude_paths.split(',') if args.reach_exclude_paths else None, - 'exclude_paths': normalize_exclude_paths(args.exclude_paths), - 'included_dirs': normalize_exclude_paths(args.include_dirs) or [], - 'reach_skip_cache': args.reach_skip_cache, - 'reach_min_severity': args.reach_min_severity, - 'reach_output_file': args.reach_output_file, - 'reach_concurrency': args.reach_concurrency, - 'reach_additional_params': args.reach_additional_params, - 'only_facts_file': args.only_facts_file, - 'reach_use_only_pregenerated_sboms': args.reach_use_only_pregenerated_sboms, - 'reach_continue_on_analysis_errors': args.reach_continue_on_analysis_errors, - 'reach_continue_on_install_errors': args.reach_continue_on_install_errors, - 'reach_continue_on_missing_lock_files': args.reach_continue_on_missing_lock_files, - 'reach_continue_on_no_source_files': args.reach_continue_on_no_source_files, - 'reach_debug': args.reach_debug, - 'reach_disable_external_tool_checks': args.reach_disable_external_tool_checks, - 'reach_disable_dependency_downloads': args.reach_disable_dependency_downloads, - 'max_purl_batch_size': args.max_purl_batch_size, - 'enable_commit_status': args.enable_commit_status, - 'legal': args.legal or args.legal_format == "fossa", - 'legal_format': args.legal_format, - 'config_file': args.config_file, - 'version': __version__ + "license_file_name": args.license_file_name, + "save_submitted_files_list": args.save_submitted_files_list, + "save_manifest_tar": args.save_manifest_tar, + "sub_paths": args.sub_paths or [], + "workspace_name": args.workspace_name, + "workspace": args.workspace, + "slack_webhook": args.slack_webhook, + "reach": args.reach, + "reach_version": args.reach_version, + "reach_analysis_timeout": args.reach_analysis_timeout, + "reach_analysis_memory_limit": args.reach_analysis_memory_limit, + "reach_disable_analytics": args.reach_disable_analytics, + "reach_disable_analysis_splitting": args.reach_disable_analysis_splitting, + "reach_enable_analysis_splitting": args.reach_enable_analysis_splitting, + "reach_detailed_analysis_log_file": args.reach_detailed_analysis_log_file, + "reach_lazy_mode": args.reach_lazy_mode, + "reach_ecosystems": args.reach_ecosystems.split(",") if args.reach_ecosystems else None, + "reach_exclude_paths": args.reach_exclude_paths.split(",") if args.reach_exclude_paths else None, + "exclude_paths": normalize_exclude_paths(args.exclude_paths), + "included_dirs": normalize_exclude_paths(args.include_dirs) or [], + "reach_skip_cache": args.reach_skip_cache, + "reach_min_severity": args.reach_min_severity, + "reach_output_file": args.reach_output_file, + "reach_concurrency": args.reach_concurrency, + "reach_additional_params": args.reach_additional_params, + "only_facts_file": args.only_facts_file, + "reach_use_only_pregenerated_sboms": args.reach_use_only_pregenerated_sboms, + "reach_continue_on_analysis_errors": args.reach_continue_on_analysis_errors, + "reach_continue_on_install_errors": args.reach_continue_on_install_errors, + "reach_continue_on_missing_lock_files": args.reach_continue_on_missing_lock_files, + "reach_continue_on_no_source_files": args.reach_continue_on_no_source_files, + "reach_debug": args.reach_debug, + "reach_disable_external_tool_checks": args.reach_disable_external_tool_checks, + "reach_disable_dependency_downloads": args.reach_disable_dependency_downloads, + "max_purl_batch_size": args.max_purl_batch_size, + "enable_commit_status": args.enable_commit_status, + "legal": args.legal or args.legal_format == "fossa", + "legal_format": args.legal_format, + "config_file": args.config_file, + "version": __version__, } - if config_args['legal']: - config_args['generate_license'] = True - if not config_args['json_file']: - config_args['json_file'] = "socket-report.json" - if not config_args['summary_file']: - config_args['summary_file'] = "socket-summary.txt" - if not config_args['report_link_file']: - config_args['report_link_file'] = "socket-report-link.txt" - if not config_args['sbom_file']: - config_args['sbom_file'] = "socket-sbom.json" - if config_args['license_file_name'] == "license_output.json": - config_args['license_file_name'] = "socket-license.json" - - if config_args['legal_format'] == "fossa": + if config_args["legal"]: + config_args["generate_license"] = True + if not config_args["json_file"]: + config_args["json_file"] = "socket-report.json" + if not config_args["summary_file"]: + config_args["summary_file"] = "socket-summary.txt" + if not config_args["report_link_file"]: + config_args["report_link_file"] = "socket-report-link.txt" + if not config_args["sbom_file"]: + config_args["sbom_file"] = "socket-sbom.json" + if config_args["license_file_name"] == "license_output.json": + config_args["license_file_name"] = "socket-license.json" + + if config_args["legal_format"] == "fossa": if not args.json_file: - config_args['json_file'] = "fossa-analyze.json" + config_args["json_file"] = "fossa-analyze.json" if not args.summary_file: - config_args['summary_file'] = "fossa-test.txt" + config_args["summary_file"] = "fossa-test.txt" if not args.report_link_file: - config_args['report_link_file'] = "fossa-link.txt" + config_args["report_link_file"] = "fossa-link.txt" if not args.license_file_name: # argparse always provides a default, so this branch is defensive only - config_args['license_file_name'] = "fossa-sbom.json" + config_args["license_file_name"] = "fossa-sbom.json" elif args.license_file_name == "license_output.json": - config_args['license_file_name'] = "fossa-sbom.json" + config_args["license_file_name"] = "fossa-sbom.json" if not args.sbom_file: # FOSSA's "SBOM" artifact is the attribution payload; suppress the extra Socket-only SBOM file by default. - config_args['sbom_file'] = None + config_args["sbom_file"] = None excluded_ecosystems = config_args["excluded_ecosystems"] if isinstance(excluded_ecosystems, list): config_args["excluded_ecosystems"] = excluded_ecosystems @@ -416,52 +416,54 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': try: config_args["excluded_ecosystems"] = json.loads(excluded_ecosystems.replace("'", '"')) except json.JSONDecodeError: - logging.error(f"Unable to parse excluded_ecosystems: {excluded_ecosystems}") - exit(1) + log.error(f"Unable to parse excluded_ecosystems: {excluded_ecosystems}") + sys.exit(1) else: - logging.error(f"Unable to parse excluded_ecosystems: {excluded_ecosystems}") - exit(1) + log.error(f"Unable to parse excluded_ecosystems: {excluded_ecosystems}") + sys.exit(1) # Build Slack plugin config, merging CLI arg with env config slack_config = get_plugin_config_from_env("SOCKET_SLACK") if args.slack_webhook: slack_config["url"] = args.slack_webhook - - config_args.update({ - "jira_plugin": PluginConfig( - enabled=os.getenv("SOCKET_JIRA_ENABLED", "false").lower() == "true", - levels=os.getenv("SOCKET_JIRA_LEVELS", "block,warn").split(","), - config=get_plugin_config_from_env("SOCKET_JIRA") - ), - "slack_plugin": PluginConfig( - enabled=bool(slack_config) or bool(args.slack_webhook), - levels=os.getenv("SOCKET_SLACK_LEVELS", "block,warn").split(","), - config=slack_config - ) - }) + + config_args.update( + { + "jira_plugin": PluginConfig( + enabled=os.getenv("SOCKET_JIRA_ENABLED", "false").lower() == "true", + levels=os.getenv("SOCKET_JIRA_LEVELS", "block,warn").split(","), + config=get_plugin_config_from_env("SOCKET_JIRA"), + ), + "slack_plugin": PluginConfig( + enabled=bool(slack_config) or bool(args.slack_webhook), + levels=os.getenv("SOCKET_SLACK_LEVELS", "block,warn").split(","), + config=slack_config, + ), + } + ) if args.owner: - config_args['integration_org_slug'] = args.owner + config_args["integration_org_slug"] = args.owner # Validate that sub_paths and workspace_name are used together if args.sub_paths and not args.workspace_name: - logging.error("--sub-path requires --workspace-name to be specified") - exit(1) + log.error("--sub-path requires --workspace-name to be specified") + sys.exit(1) if args.workspace_name and not args.sub_paths: - logging.error("--workspace-name requires --sub-path to be specified") - exit(1) + log.error("--workspace-name requires --sub-path to be specified") + sys.exit(1) # The API validates the repo as a slug, which can't contain "/", so an # owner/repo value would otherwise fail later at repository creation. if args.repo and "/" in args.repo: suggested = args.repo.rstrip("/").rsplit("/", 1)[-1] - logging.error( + log.error( f"--repo takes the repository slug without its owner, but got '{args.repo}'. " f"Use --repo {suggested}, and --workspace for the owner if your repositories use one." ) - exit(1) + sys.exit(1) if args.pending_head: - logging.warning( + log.warning( "--pending-head is deprecated and has no effect. A scan becomes the head scan " "when it runs on the default branch; use --default-branch to mark it as such." ) @@ -469,21 +471,21 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': # argparse only enforces the mutually exclusive group for real CLI args; # this also catches both values arriving via a --config file. if args.base_scan_id and args.base_commit_sha: - logging.error("--base-scan-id and --base-commit-sha are mutually exclusive") - exit(1) + log.error("--base-scan-id and --base-commit-sha are mutually exclusive") + sys.exit(1) if args.sarif_scope == "full" and not args.reach: - logging.error("--sarif-scope full requires --reach to be specified") - exit(1) + log.error("--sarif-scope full requires --reach to be specified") + sys.exit(1) if args.sarif_reachability != "all" and not args.reach: - logging.error("--sarif-reachability requires --reach to be specified") - exit(1) + log.error("--sarif-reachability requires --reach to be specified") + sys.exit(1) if args.sarif_grouping == "alert" and args.sarif_scope != "full": - logging.error("--sarif-grouping alert currently requires --sarif-scope full") - exit(1) + log.error("--sarif-grouping alert currently requires --sarif-scope full") + sys.exit(1) if args.sarif_reachability in ("potentially", "reachable-or-potentially") and args.sarif_scope != "full": - logging.error("--sarif-reachability potentially/reachable-or-potentially requires --sarif-scope full") - exit(1) + log.error("--sarif-reachability potentially/reachable-or-potentially requires --sarif-scope full") + sys.exit(1) # Validate --exclude-paths patterns up front (mirrors Node's assertValidExcludePaths). if config_args.get("exclude_paths"): @@ -491,58 +493,59 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': # Validate that only_facts_file requires reach if args.only_facts_file and not args.reach: - logging.error("--only-facts-file requires --reach to be specified") - exit(1) + log.error("--only-facts-file requires --reach to be specified") + sys.exit(1) # Validate that reach_use_only_pregenerated_sboms requires reach if args.reach_use_only_pregenerated_sboms and not args.reach: - logging.error("--reach-use-only-pregenerated-sboms requires --reach to be specified") - exit(1) + log.error("--reach-use-only-pregenerated-sboms requires --reach to be specified") + sys.exit(1) # Validate reach_concurrency is >= 1 if provided if args.reach_concurrency is not None and args.reach_concurrency < 1: - logging.error("--reach-concurrency must be >= 1") - exit(1) + log.error("--reach-concurrency must be >= 1") + sys.exit(1) # Validate max_purl_batch_size is within allowed range if args.max_purl_batch_size < 1 or args.max_purl_batch_size > 9999: - logging.error("--max-purl-batch-size must be between 1 and 9999") - exit(1) + log.error("--max-purl-batch-size must be between 1 and 9999") + sys.exit(1) return cls(**config_args) def to_dict(self) -> dict: return asdict(self) + def to_redacted_dict(self) -> dict: + """Return the configuration with credential fields masked for logging.""" + return redact_mapping(asdict(self)) + + def create_argument_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="socketcli", - description="The Socket Security CLI will get the head scan for the provided repo from Socket, create a new one, and then report any alerts introduced by the changes. Any new alerts will cause the CLI to exit with a non-Zero exit code (1 for error alerts, 5 for warnings)." + description="The Socket Security CLI will get the head scan for the provided repo from Socket, create a new one, and then report any alerts introduced by the changes. Any new alerts will cause the CLI to exit with a non-Zero exit code (1 for error alerts, 5 for warnings).", ) # Authentication - auth_group = parser.add_argument_group('Authentication') + auth_group = parser.add_argument_group("Authentication") auth_group.add_argument( "--config", dest="config_file", metavar="", - help="Path to JSON/TOML file with default CLI options. CLI flags take precedence." + help="Path to JSON/TOML file with default CLI options. CLI flags take precedence.", ) auth_group.add_argument( "--api-token", dest="api_token", metavar="", help="Socket Security API token (can also be set via SOCKET_SECURITY_API_TOKEN env var)", - required=False - ) - auth_group.add_argument( - "--api_token", - dest="api_token", - help=argparse.SUPPRESS + required=False, ) + auth_group.add_argument("--api_token", dest="api_token", help=argparse.SUPPRESS) # Repository info - repo_group = parser.add_argument_group('Repository') + repo_group = parser.add_argument_group("Repository") repo_group.add_argument( "--repo", metavar="", @@ -550,28 +553,23 @@ def create_argument_parser() -> argparse.ArgumentParser: "Repository slug in Socket, without the owner (e.g. my-repo, not my-org/my-repo). " "Defaults to the git remote's repository name. Use --workspace for the owner." ), - required=False + required=False, ) repo_group.add_argument( "--workspace", metavar="", help="The workspace in the Socket Organization that the repository is in to associate with the full scan.", - required=False + required=False, ) repo_group.add_argument( "--repo-is-public", dest="repo_is_public", action="store_true", - help="If set it will flag a new repository creation as public. Defaults to false." - ) - repo_group.add_argument( - "--branch", - metavar="", - help="Branch name", - default="" + help="If set it will flag a new repository creation as public. Defaults to false.", ) + repo_group.add_argument("--branch", metavar="", help="Branch name", default="") - integration_group = parser.add_argument_group('Integration') + integration_group = parser.add_argument_group("Integration") integration_group.add_argument( "--integration", choices=INTEGRATION_TYPES, @@ -581,17 +579,17 @@ def create_argument_parser() -> argparse.ArgumentParser: "Defaults to api; --scm github/gitlab implies the matching integration " "when this option is omitted" ), - default=argparse.SUPPRESS + default=argparse.SUPPRESS, ) integration_group.add_argument( "--owner", metavar="", help="Name of the integration owner, defaults to the socket organization slug", - required=False + required=False, ) # Pull Request and Commit info - pr_group = parser.add_argument_group('Pull Request and Commit') + pr_group = parser.add_argument_group("Pull Request and Commit") pr_group.add_argument( "--pr-number", dest="pr_number", @@ -600,42 +598,15 @@ def create_argument_parser() -> argparse.ArgumentParser: "Pull request number. Auto-detected in supported CI environments when omitted; " "pass 0 explicitly to disable detection" ), - default=argparse.SUPPRESS + default=argparse.SUPPRESS, ) + pr_group.add_argument("--pr_number", dest="pr_number", help=argparse.SUPPRESS, default=argparse.SUPPRESS) + pr_group.add_argument("--commit-message", dest="commit_message", metavar="", help="Commit message") + pr_group.add_argument("--commit_message", dest="commit_message", help=argparse.SUPPRESS) + pr_group.add_argument("--commit-sha", dest="commit_sha", metavar="", default="", help="Commit SHA") + pr_group.add_argument("--commit_sha", dest="commit_sha", help=argparse.SUPPRESS) pr_group.add_argument( - "--pr_number", - dest="pr_number", - help=argparse.SUPPRESS, - default=argparse.SUPPRESS - ) - pr_group.add_argument( - "--commit-message", - dest="commit_message", - metavar="", - help="Commit message" - ) - pr_group.add_argument( - "--commit_message", - dest="commit_message", - help=argparse.SUPPRESS - ) - pr_group.add_argument( - "--commit-sha", - dest="commit_sha", - metavar="", - default="", - help="Commit SHA" - ) - pr_group.add_argument( - "--commit_sha", - dest="commit_sha", - help=argparse.SUPPRESS - ) - pr_group.add_argument( - "--committers", - metavar="", - help="Committer for the commit (comma separated)", - nargs="*" + "--committers", metavar="", help="Committer for the commit (comma separated)", nargs="*" ) base_scan_group = pr_group.add_mutually_exclusive_group() base_scan_group.add_argument( @@ -644,7 +615,7 @@ def create_argument_parser() -> argparse.ArgumentParser: metavar="", default=None, help="Full scan ID to diff the new scan against, overriding the repository's " - "head scan as the baseline. Mutually exclusive with --base-commit-sha." + "head scan as the baseline. Mutually exclusive with --base-commit-sha.", ) base_scan_group.add_argument( "--base-commit-sha", @@ -652,82 +623,60 @@ def create_argument_parser() -> argparse.ArgumentParser: metavar="", default=None, help="Commit SHA to diff the new scan against, overriding the repository's head " - "scan as the baseline. The CLI uses the most recent matching full scan, or " - "the nearest scanned first-parent ancestor within 100 local commits when " - "the commit itself was not scanned. Also sets the range changed-file " - "detection reads, so a manifest changed anywhere between this commit and " - "HEAD is seen. Mutually exclusive with --base-scan-id." + "scan as the baseline. The CLI uses the most recent matching full scan, or " + "the nearest scanned first-parent ancestor within 100 local commits when " + "the commit itself was not scanned. Also sets the range changed-file " + "detection reads, so a manifest changed anywhere between this commit and " + "HEAD is seen. Mutually exclusive with --base-scan-id.", ) # Path and File options - path_group = parser.add_argument_group('Path and File') - path_group.add_argument( - "--target-path", - dest="target_path", - metavar="", - default="./", - help="Target path for analysis" - ) + path_group = parser.add_argument_group("Path and File") path_group.add_argument( - "--target_path", - dest="target_path", - help=argparse.SUPPRESS - ) - path_group.add_argument( - "--sbom-file", - dest="sbom_file", - metavar="", - help="SBOM file path" - ) - path_group.add_argument( - "--sbom_file", - dest="sbom_file", - help=argparse.SUPPRESS + "--target-path", dest="target_path", metavar="", default="./", help="Target path for analysis" ) + path_group.add_argument("--target_path", dest="target_path", help=argparse.SUPPRESS) + path_group.add_argument("--sbom-file", dest="sbom_file", metavar="", help="SBOM file path") + path_group.add_argument("--sbom_file", dest="sbom_file", help=argparse.SUPPRESS) path_group.add_argument( "--license-file-name", dest="license_file_name", default="license_output.json", metavar="", - help="SBOM file path" + help="SBOM file path", ) path_group.add_argument( "--save-submitted-files-list", dest="save_submitted_files_list", metavar="", - help="Save list of submitted file names to JSON file for debugging purposes" + help="Save list of submitted file names to JSON file for debugging purposes", ) path_group.add_argument( "--save-manifest-tar", dest="save_manifest_tar", metavar="", - help="Save all manifest files to a compressed tar.gz archive with original directory structure" - ) - path_group.add_argument( - "--files", - metavar="", - default="[]", - help="Files to analyze (JSON array string)" + help="Save all manifest files to a compressed tar.gz archive with original directory structure", ) + path_group.add_argument("--files", metavar="", default="[]", help="Files to analyze (JSON array string)") path_group.add_argument( "--sub-path", dest="sub_paths", metavar="", action="append", - help="Sub-path within target-path for manifest file scanning (can be specified multiple times). All sub-paths will be combined into a single workspace scan while preserving git context from target-path" + help="Sub-path within target-path for manifest file scanning (can be specified multiple times). All sub-paths will be combined into a single workspace scan while preserving git context from target-path", ) path_group.add_argument( "--workspace-name", - dest="workspace_name", + dest="workspace_name", metavar="", - help="Workspace name suffix to append to repository name (repo-name-workspace_name)" + help="Workspace name suffix to append to repository name (repo-name-workspace_name)", ) path_group.add_argument( "--excluded-ecosystems", default="[]", dest="excluded_ecosystems", - help="List of ecosystems to exclude from analysis (JSON array string)" + help="List of ecosystems to exclude from analysis (JSON array string)", ) path_group.add_argument( @@ -735,8 +684,8 @@ def create_argument_parser() -> argparse.ArgumentParser: dest="exclude_paths", metavar="", help="Comma-separated paths/globs to exclude from BOTH manifest discovery and " - "reachability analysis (e.g. 'tests/**,packages/legacy,*.spec.ts'). " - "Supersedes --reach-exclude-paths." + "reachability analysis (e.g. 'tests/**,packages/legacy,*.spec.ts'). " + "Supersedes --reach-exclude-paths.", ) path_group.add_argument( @@ -744,33 +693,20 @@ def create_argument_parser() -> argparse.ArgumentParser: dest="include_dirs", metavar="", help="Comma-separated directory names that are excluded from manifest discovery by " - "default but should be scanned (e.g. 'build,dist'). Names are matched against any " - "path segment, mirroring the default exclude list. Defaults excluded: " - "node_modules, bower_components, jspm_packages, __pycache__, .venv, venv, build, " - "dist, .tox, .mypy_cache, .pytest_cache, *.egg-info, vendor." + "default but should be scanned (e.g. 'build,dist'). Names are matched against any " + "path segment, mirroring the default exclude list. Defaults excluded: " + "node_modules, bower_components, jspm_packages, __pycache__, .venv, venv, build, " + "dist, .tox, .mypy_cache, .pytest_cache, *.egg-info, vendor.", ) # Branch and Scan Configuration - config_group = parser.add_argument_group('Branch and Scan Configuration') + config_group = parser.add_argument_group("Branch and Scan Configuration") config_group.add_argument( - "--default-branch", - dest="default_branch", - action="store_true", - help="Make this branch the default branch" - ) - config_group.add_argument( - "--default_branch", - dest="default_branch", - action="store_true", - help=argparse.SUPPRESS + "--default-branch", dest="default_branch", action="store_true", help="Make this branch the default branch" ) + config_group.add_argument("--default_branch", dest="default_branch", action="store_true", help=argparse.SUPPRESS) # Deprecated no-op, still accepted so existing pipelines don't fail to parse. - config_group.add_argument( - "--pending-head", - dest="pending_head", - action="store_true", - help=argparse.SUPPRESS - ) + config_group.add_argument("--pending-head", dest="pending_head", action="store_true", help=argparse.SUPPRESS) config_group.add_argument( "--ignore-authorization", dest="ignore_authorization", @@ -782,122 +718,84 @@ def create_argument_parser() -> argparse.ArgumentParser: "a warning when the provider cannot report the commenter's access. " "'strict' rejects the command in that case instead. " "'off' honors a command from any commenter." - ) - ) - config_group.add_argument( - "--pending_head", - dest="pending_head", - action="store_true", - help=argparse.SUPPRESS + ), ) + config_group.add_argument("--pending_head", dest="pending_head", action="store_true", help=argparse.SUPPRESS) # Output Configuration - output_group = parser.add_argument_group('Output Configuration') + output_group = parser.add_argument_group("Output Configuration") output_group.add_argument( - "--generate-license", - dest="generate_license", - action="store_true", - help="Generate license information" + "--generate-license", dest="generate_license", action="store_true", help="Generate license information" ) output_group.add_argument( - "--generate_license", - dest="generate_license", - action="store_true", - help=argparse.SUPPRESS + "--generate_license", dest="generate_license", action="store_true", help=argparse.SUPPRESS ) + output_group.add_argument("--enable-debug", dest="enable_debug", action="store_true", help="Enable debug logging") + output_group.add_argument("--enable_debug", dest="enable_debug", action="store_true", help=argparse.SUPPRESS) + output_group.add_argument("--enable-json", dest="enable_json", action="store_true", help="Output in JSON format") output_group.add_argument( - "--enable-debug", - dest="enable_debug", - action="store_true", - help="Enable debug logging" - ) - output_group.add_argument( - "--enable_debug", - dest="enable_debug", - action="store_true", - help=argparse.SUPPRESS - ) - output_group.add_argument( - "--enable-json", - dest="enable_json", - action="store_true", - help="Output in JSON format" - ) - output_group.add_argument( - "--json-file", - dest="json_file", - metavar="", - help="Output file path for JSON report" + "--json-file", dest="json_file", metavar="", help="Output file path for JSON report" ) output_group.add_argument( "--enable-sarif", dest="enable_sarif", action="store_true", - help="Enable SARIF output of results instead of table or JSON format" + help="Enable SARIF output of results instead of table or JSON format", ) output_group.add_argument( "--sarif-file", dest="sarif_file", metavar="", default=None, - help="Output file path for SARIF report (implies --enable-sarif)" + help="Output file path for SARIF report (implies --enable-sarif)", ) output_group.add_argument( "--sarif-scope", dest="sarif_scope", choices=["diff", "full"], default="diff", - help="Scope SARIF output to diff alerts (default) or full reachability facts data (requires --reach)" + help="Scope SARIF output to diff alerts (default) or full reachability facts data (requires --reach)", ) output_group.add_argument( "--sarif-grouping", dest="sarif_grouping", choices=["instance", "alert"], default="instance", - help="SARIF result grouping mode: instance (default) or alert (full scope only)" + help="SARIF result grouping mode: instance (default) or alert (full scope only)", ) output_group.add_argument( "--sarif-reachability", dest="sarif_reachability", choices=["all", "reachable", "potentially", "reachable-or-potentially"], default="all", - help="Reachability filter for SARIF output (requires --reach when not 'all')" + help="Reachability filter for SARIF output (requires --reach when not 'all')", ) output_group.add_argument( "--enable-gitlab-security", dest="enable_gitlab_security", action="store_true", - help="Enable GitLab Security Dashboard output format (Dependency Scanning report)" + help="Enable GitLab Security Dashboard output format (Dependency Scanning report)", ) output_group.add_argument( "--gitlab-security-file", dest="gitlab_security_file", metavar="", default="gl-dependency-scanning-report.json", - help="Output file path for GitLab Security report (default: gl-dependency-scanning-report.json)" + help="Output file path for GitLab Security report (default: gl-dependency-scanning-report.json)", ) output_group.add_argument( - "--summary-file", - dest="summary_file", - metavar="", - help="Output file path for a plain-text summary report" + "--summary-file", dest="summary_file", metavar="", help="Output file path for a plain-text summary report" ) output_group.add_argument( "--report-link-file", dest="report_link_file", metavar="", - help="Output file path for the Socket report link" + help="Output file path for the Socket report link", ) output_group.add_argument( - "--disable-overview", - dest="disable_overview", - action="store_true", - help="Disable overview output" + "--disable-overview", dest="disable_overview", action="store_true", help="Disable overview output" ) output_group.add_argument( - "--disable_overview", - dest="disable_overview", - action="store_true", - help=argparse.SUPPRESS + "--disable_overview", dest="disable_overview", action="store_true", help=argparse.SUPPRESS ) output_group.add_argument( "--exclude-license-details", @@ -908,63 +806,54 @@ def create_argument_parser() -> argparse.ArgumentParser: "As of 2.4.0 the internal diff request always omits license details " "(they were unused there and bloated large-repo responses), so this " "flag now only affects the report link, not diff performance." - ) + ), ) output_group.add_argument( "--max-purl-batch-size", dest="max_purl_batch_size", type=int, default=5000, - help="Maximum batch size for PURL endpoint calls when generating license info (default: 5000, min: 1, max: 9999)" + help="Maximum batch size for PURL endpoint calls when generating license info (default: 5000, min: 1, max: 9999)", ) output_group.add_argument( "--disable-security-issue", dest="disable_security_issue", action="store_true", - help="Disable security issue checks" + help="Disable security issue checks", ) output_group.add_argument( - "--disable_security_issue", - dest="disable_security_issue", - action="store_true", - help=argparse.SUPPRESS + "--disable_security_issue", dest="disable_security_issue", action="store_true", help=argparse.SUPPRESS ) output_group.add_argument( "--enable-commit-status", dest="enable_commit_status", action="store_true", - help="Report scan result as a commit status on GitLab (requires GitLab SCM)" + help="Report scan result as a commit status on GitLab (requires GitLab SCM)", ) output_group.add_argument( - "--enable_commit_status", - dest="enable_commit_status", - action="store_true", - help=argparse.SUPPRESS + "--enable_commit_status", dest="enable_commit_status", action="store_true", help=argparse.SUPPRESS ) # Plugin Configuration - plugin_group = parser.add_argument_group('Plugin Configuration') + plugin_group = parser.add_argument_group("Plugin Configuration") plugin_group.add_argument( "--slack-webhook", dest="slack_webhook", metavar="", - help="Slack webhook URL for notifications (automatically enables Slack plugin)" + help="Slack webhook URL for notifications (automatically enables Slack plugin)", ) # Advanced Configuration - advanced_group = parser.add_argument_group('Advanced Configuration') + advanced_group = parser.add_argument_group("Advanced Configuration") advanced_group.add_argument( "--ignore-commit-files", dest="ignore_commit_files", action="store_true", - help="Compare against the baseline regardless of which files changed, scanning every manifest in the target path" + help="Compare against the baseline regardless of which files changed, scanning every manifest in the target path", ) advanced_group.add_argument( - "--ignore_commit_files", - dest="ignore_commit_files", - action="store_true", - help=argparse.SUPPRESS + "--ignore_commit_files", dest="ignore_commit_files", action="store_true", help=argparse.SUPPRESS ) advanced_group.add_argument( "--disable-blocking", @@ -977,24 +866,16 @@ def create_argument_parser() -> argparse.ArgumentParser: ), ) advanced_group.add_argument( - "--disable_blocking", - dest="disable_blocking", - action="store_true", - help=argparse.SUPPRESS + "--disable_blocking", dest="disable_blocking", action="store_true", help=argparse.SUPPRESS ) advanced_group.add_argument( "--disable-ignore", dest="disable_ignore", action="store_true", help="Disable support for @SocketSecurity ignore commands in PR comments. " - "Alerts cannot be suppressed via comments when this flag is set." - ) - advanced_group.add_argument( - "--disable_ignore", - dest="disable_ignore", - action="store_true", - help=argparse.SUPPRESS + "Alerts cannot be suppressed via comments when this flag is set.", ) + advanced_group.add_argument("--disable_ignore", dest="disable_ignore", action="store_true", help=argparse.SUPPRESS) log_upload_group = advanced_group.add_mutually_exclusive_group() log_upload_group.add_argument( "--upload-logs", @@ -1002,9 +883,9 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_const", const=True, help="Upload the CLI's log output to the Socket backend for this run. " - "When set, the CLI registers the run with share_logs=true and streams " - "its log records in 5s batches. Default off. Mutually exclusive with " - "--no-upload-logs." + "When set, the CLI registers the run with share_logs=true and streams " + "its log records in 5s batches. Default off. Mutually exclusive with " + "--no-upload-logs.", ) log_upload_group.add_argument( "--no-upload-logs", @@ -1012,33 +893,24 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_const", const=False, help="Explicitly opt out of uploading CLI logs to the Socket backend, even " - "when an org-level override would otherwise enable it. Mutually " - "exclusive with --upload-logs." + "when an org-level override would otherwise enable it. Mutually " + "exclusive with --upload-logs.", ) advanced_group.add_argument( "--strict-blocking", dest="strict_blocking", action="store_true", - help="Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode." + help="Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode.", ) advanced_group.add_argument( "--enable-diff", dest="enable_diff", action="store_true", - help="Enable diff mode even when using --integration api (forces diff mode without SCM integration)" + help="Enable diff mode even when using --integration api (forces diff mode without SCM integration)", ) + advanced_group.add_argument("--scm", metavar="", default="api", help="Source control management type") advanced_group.add_argument( - "--scm", - metavar="", - default="api", - help="Source control management type" - ) - advanced_group.add_argument( - "--timeout", - type=int, - metavar="", - help="Timeout in seconds for API requests", - required=False + "--timeout", type=int, metavar="", help="Timeout in seconds for API requests", required=False ) advanced_group.add_argument( "--exit-code-on-api-error", @@ -1053,213 +925,191 @@ def create_argument_parser() -> argparse.ArgumentParser: "CI -- e.g. set to a Buildkite soft_fail code. NOTE: --disable-blocking " "forces exit 0 for ALL outcomes and therefore overrides this flag; do not " "combine the two if you want the custom code to take effect." - ) + ), ) advanced_group.add_argument( - "--allow-unverified", - action="store_true", - help="Disable SSL certificate verification for API requests" + "--allow-unverified", action="store_true", help="Disable SSL certificate verification for API requests" ) advanced_group.add_argument( - "--legal", - dest="legal", - action="store_true", - help="Enable legal/compliance-friendly defaults and file outputs" + "--legal", dest="legal", action="store_true", help="Enable legal/compliance-friendly defaults and file outputs" ) advanced_group.add_argument( "--legal-format", dest="legal_format", choices=["socket", "fossa"], default="socket", - help="Select the legal artifact format. 'socket' keeps Socket-native outputs; 'fossa' emits compatibility-shaped JSON artifacts." + help="Select the legal artifact format. 'socket' keeps Socket-native outputs; 'fossa' emits compatibility-shaped JSON artifacts.", ) config_group.add_argument( "--include-module-folders", dest="include_module_folders", action="store_true", default=False, - help="Enabling including module folders like node_modules" + help="Enabling including module folders like node_modules", ) # Reachability Configuration - reachability_group = parser.add_argument_group('Reachability Analysis') - reachability_group.add_argument( - "--reach", - dest="reach", - action="store_true", - help="Enable reachability analysis" - ) + reachability_group = parser.add_argument_group("Reachability Analysis") + reachability_group.add_argument("--reach", dest="reach", action="store_true", help="Enable reachability analysis") reachability_group.add_argument( "--reach-version", dest="reach_version", metavar="", help="Version of the Coana CLI to use. Defaults to the version pinned to this CLI " - "release; pass 'latest' to always use the newest published version (opt-in " - "auto-update), or an explicit version (e.g. '1.2.3') to pin it." + "release; pass 'latest' to always use the newest published version (opt-in " + "auto-update), or an explicit version (e.g. '1.2.3') to pin it.", ) reachability_group.add_argument( "--reach-analysis-timeout", dest="reach_analysis_timeout", metavar="", - help="Set the timeout for each reachability analysis run, e.g. 90s, 10m or 1h. (default: 10m)" + help="Set the timeout for each reachability analysis run, e.g. 90s, 10m or 1h. (default: 10m)", ) # Backwards-compatible alias for the pre-alignment name. Kept working, hidden from help. - reachability_group.add_argument( - "--reach-timeout", - dest="reach_analysis_timeout", - help=argparse.SUPPRESS - ) + reachability_group.add_argument("--reach-timeout", dest="reach_analysis_timeout", help=argparse.SUPPRESS) reachability_group.add_argument( "--reach-analysis-memory-limit", dest="reach_analysis_memory_limit", metavar="", - help="Set the memory limit for each reachability analysis run, e.g. 512MB or 8GB. (default: 8GB)" + help="Set the memory limit for each reachability analysis run, e.g. 512MB or 8GB. (default: 8GB)", ) # Backwards-compatible alias for the pre-alignment name. Kept working, hidden from help. - reachability_group.add_argument( - "--reach-memory-limit", - dest="reach_analysis_memory_limit", - help=argparse.SUPPRESS - ) + reachability_group.add_argument("--reach-memory-limit", dest="reach_analysis_memory_limit", help=argparse.SUPPRESS) reachability_group.add_argument( "--reach-ecosystems", dest="reach_ecosystems", metavar="", - help="Ecosystems to analyze for reachability (comma-separated, e.g., 'npm,pypi')" + help="Ecosystems to analyze for reachability (comma-separated, e.g., 'npm,pypi')", ) reachability_group.add_argument( "--reach-exclude-paths", dest="reach_exclude_paths", metavar="", help="[DEPRECATED: use --exclude-paths] Paths to exclude from reachability analysis " - "(comma-separated). Still honored and unioned with --exclude-paths." + "(comma-separated). Still honored and unioned with --exclude-paths.", ) reachability_group.add_argument( "--reach-min-severity", dest="reach_min_severity", metavar="", - help="Minimum severity level for reachability analysis (info, low, moderate, high, critical)" + help="Minimum severity level for reachability analysis (info, low, moderate, high, critical)", ) reachability_group.add_argument( "--reach-skip-cache", dest="reach_skip_cache", action="store_true", - help="Skip cache usage for reachability analysis" + help="Skip cache usage for reachability analysis", ) reachability_group.add_argument( "--reach-disable-analytics", dest="reach_disable_analytics", action="store_true", - help="Disable analytics sharing for reachability analysis" + help="Disable analytics sharing for reachability analysis", ) reachability_group.add_argument( "--reach-disable-analysis-splitting", dest="reach_disable_analysis_splitting", action="store_true", - help=argparse.SUPPRESS # Deprecated, kept for backwards compatibility (no-op) + help=argparse.SUPPRESS, # Deprecated, kept for backwards compatibility (no-op) ) reachability_group.add_argument( "--reach-enable-analysis-splitting", dest="reach_enable_analysis_splitting", action="store_true", - help="Enable analysis splitting/bucketing for reachability analysis (disabled by default). This is a legacy feature for improving performance" + help="Enable analysis splitting/bucketing for reachability analysis (disabled by default). This is a legacy feature for improving performance", ) reachability_group.add_argument( "--reach-detailed-analysis-log-file", dest="reach_detailed_analysis_log_file", action="store_true", - help="Create a detailed analysis log file for reachability analysis. The output path is written to stdout" + help="Create a detailed analysis log file for reachability analysis. The output path is written to stdout", ) reachability_group.add_argument( "--reach-lazy-mode", dest="reach_lazy_mode", action="store_true", - help=argparse.SUPPRESS # Deprecated, kept for backwards compatibility (no-op) + help=argparse.SUPPRESS, # Deprecated, kept for backwards compatibility (no-op) ) reachability_group.add_argument( "--reach-output-file", dest="reach_output_file", metavar="", default=".socket.facts.json", - help="Output file path for reachability analysis results (default: .socket.facts.json)" + help="Output file path for reachability analysis results (default: .socket.facts.json)", ) reachability_group.add_argument( "--reach-concurrency", dest="reach_concurrency", type=int, metavar="", - help="Concurrency level for reachability analysis (must be >= 1; defaults to the coana CLI's own default, currently 1)" + help="Concurrency level for reachability analysis (must be >= 1; defaults to the coana CLI's own default, currently 1)", ) reachability_group.add_argument( "--reach-additional-params", dest="reach_additional_params", - nargs='+', + nargs="+", metavar="", - help="Additional parameters to pass to the coana CLI (e.g., --reach-additional-params --other-param value --another-param value2)" + help="Additional parameters to pass to the coana CLI (e.g., --reach-additional-params --other-param value --another-param value2)", ) reachability_group.add_argument( "--only-facts-file", dest="only_facts_file", action="store_true", - help="Submit only the .socket.facts.json file when creating full scan (requires --reach)" + help="Submit only the .socket.facts.json file when creating full scan (requires --reach)", ) reachability_group.add_argument( "--reach-use-only-pregenerated-sboms", dest="reach_use_only_pregenerated_sboms", action="store_true", - help="When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. (requires --reach)" + help="When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project. (requires --reach)", ) reachability_group.add_argument( "--reach-continue-on-analysis-errors", dest="reach_continue_on_analysis_errors", action="store_true", - help=argparse.SUPPRESS + help=argparse.SUPPRESS, ) reachability_group.add_argument( "--reach-continue-on-install-errors", dest="reach_continue_on_install_errors", action="store_true", - help=argparse.SUPPRESS + help=argparse.SUPPRESS, ) reachability_group.add_argument( "--reach-continue-on-missing-lock-files", dest="reach_continue_on_missing_lock_files", action="store_true", - help=argparse.SUPPRESS + help=argparse.SUPPRESS, ) reachability_group.add_argument( "--reach-continue-on-no-source-files", dest="reach_continue_on_no_source_files", action="store_true", - help=argparse.SUPPRESS + help=argparse.SUPPRESS, ) reachability_group.add_argument( "--reach-debug", dest="reach_debug", action="store_true", help="Enable debug output for the reachability analysis (passes --debug to the coana CLI). " - "Independent of the global --enable-debug flag." + "Independent of the global --enable-debug flag.", ) reachability_group.add_argument( "--reach-disable-external-tool-checks", dest="reach_disable_external_tool_checks", action="store_true", help="Disable coana's external tool availability checks during reachability analysis " - "(passes --disable-external-tool-checks to the coana CLI)." + "(passes --disable-external-tool-checks to the coana CLI).", ) reachability_group.add_argument( "--reach-disable-dependency-downloads", dest="reach_disable_dependency_downloads", action="store_true", help="Only analyze dependencies that are already installed (e.g. in the project's .venv, " - "vendor directory or module cache); missing ones count as install failures " - "(passes --disable-dependency-downloads to the coana CLI)." + "vendor directory or module cache); missing ones count as install failures " + "(passes --disable-dependency-downloads to the coana CLI).", ) - parser.add_argument( - '--version', - action='version', - version=f'%(prog)s {__version__}' - ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") return parser diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index f78f6c19..78874af1 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1,4 +1,5 @@ import atexit +import contextlib import copy import fnmatch import importlib @@ -12,9 +13,10 @@ import tarfile import tempfile import time +from collections.abc import Iterator from dataclasses import asdict from pathlib import PurePath -from typing import TYPE_CHECKING, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple +from typing import TYPE_CHECKING, ClassVar, NamedTuple, Optional if TYPE_CHECKING: from socketsecurity.config import CliConfig @@ -41,10 +43,10 @@ __all__ = [ + "USER_AGENT", "Core", - "log", "__version__", - "USER_AGENT", + "log", ] version = __version__ @@ -145,7 +147,7 @@ # Temp dirs holding placeholder facts files (see Core.empty_head_scan_file). Call sites unlink # the file itself once the upload finishes; the now-empty directory is removed at process exit # so a run that raises mid-scan doesn't leak one. -_PLACEHOLDER_FACTS_DIRS: List[str] = [] +_PLACEHOLDER_FACTS_DIRS: list[str] = [] @atexit.register @@ -179,10 +181,10 @@ class ManifestPatterns(NamedTuple): flat as the API's pattern list grows. """ - literal_basenames: Set[str] - basename_globs: List[str] - path_globs: List[str] - candidate_basenames: Set[str] + literal_basenames: set[str] + basename_globs: list[str] + path_globs: list[str] + candidate_basenames: set[str] candidate_basename_regex: Optional["re.Pattern"] @property @@ -194,31 +196,28 @@ def is_empty(self) -> bool: class Core: """Main class for interacting with Socket Security API and processing scan results.""" - ALERT_TYPE_TO_CAPABILITY = { + ALERT_TYPE_TO_CAPABILITY: ClassVar[dict[str, str]] = { "envVars": "Environment Variables", "networkAccess": "Network Access", "filesystemAccess": "File System Access", "shellAccess": "Shell Access", "usesEval": "Uses Eval", - "unsafe": "Unsafe" + "unsafe": "Unsafe", } config: SocketConfig sdk: socketdev - cli_config: Optional['CliConfig'] + cli_config: Optional["CliConfig"] - def __init__(self, config: SocketConfig, sdk: socketdev, cli_config: Optional['CliConfig'] = None) -> None: + def __init__(self, config: SocketConfig, sdk: socketdev, cli_config: Optional["CliConfig"] = None) -> None: """Initialize Core with configuration and SDK instance.""" self.config = config self.sdk = sdk self.cli_config = cli_config - self._supported_patterns: Optional[Dict] = None + self._supported_patterns: dict | None = None org_start_time = time.perf_counter() self.set_org_vars() - log.info( - "Organization initialization completed in " - f"{time.perf_counter() - org_start_time:.2f}s" - ) + log.info(f"Organization initialization completed in {time.perf_counter() - org_start_time:.2f}s") def set_org_vars(self) -> None: """Sets the main shared configuration variables for organization access.""" @@ -231,17 +230,17 @@ def set_org_vars(self) -> None: self.config.full_scan_path = f"{base_path}/full-scans" self.config.repository_path = f"{base_path}/repos" - def get_org_id_slug(self) -> Tuple[str, str]: + def get_org_id_slug(self) -> tuple[str, str]: """Gets the Org ID and Org Slug for the API Token.""" response = self.sdk.org.get(use_types=True) - organizations: Dict[str, Organization] = response.get("organizations", {}) + organizations: dict[str, Organization] = response.get("organizations", {}) if len(organizations) == 1: org_id = next(iter(organizations)) - return org_id, organizations[org_id]['slug'] + return org_id, organizations[org_id]["slug"] return None, None - def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: + def get_sbom_data(self, full_scan_id: str) -> dict[str, SocketArtifact]: """Returns SBOM artifacts for a full scan keyed by artifact ID.""" response = self.sdk.fullscans.stream(self.config.org_slug, full_scan_id, use_types=True) if not response.success: @@ -249,9 +248,7 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: # API error (exit code 3 by default) rather than empty reports. log.error(f"Failed to get SBOM data for full-scan {full_scan_id}") log.error(response.message) - raise APIFailure( - f"Failed to get SBOM data for full-scan {full_scan_id}: {response.message}" - ) + raise APIFailure(f"Failed to get SBOM data for full-scan {full_scan_id}: {response.message}") if not hasattr(response, "artifacts") or not response.artifacts: return {} artifacts = { @@ -262,7 +259,7 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: Core.warn_if_invalid_facts_marker(len(artifacts) != len(response.artifacts)) return artifacts - def get_sbom_data_list(self, artifacts_dict: Dict[str, SocketArtifact]) -> list[SocketArtifact]: + def get_sbom_data_list(self, artifacts_dict: dict[str, SocketArtifact]) -> list[SocketArtifact]: """Converts artifacts dictionary to a list.""" return list(artifacts_dict.values()) @@ -320,34 +317,35 @@ def create_sbom_output(self, diff: Diff) -> dict: return {} @staticmethod - def expand_brace_pattern(pattern: str) -> List[str]: + def expand_brace_pattern(pattern: str) -> list[str]: """ Recursively expands brace expressions (e.g., {a,b,c}) into separate patterns, supporting nested braces. """ - def recursive_expand(pat: str) -> List[str]: + + def recursive_expand(pat: str) -> list[str]: stack = [] for i, c in enumerate(pat): - if c == '{': + if c == "{": stack.append(i) - elif c == '}' and stack: + elif c == "}" and stack: start = stack.pop() if not stack: # Found the outermost pair before = pat[:start] - after = pat[i+1:] - inner = pat[start+1:i] + after = pat[i + 1 :] + inner = pat[start + 1 : i] # Split on commas not inside nested braces options = [] depth = 0 last = 0 for j, ch in enumerate(inner): - if ch == '{': + if ch == "{": depth += 1 - elif ch == '}': + elif ch == "}": depth -= 1 - elif ch == ',' and depth == 0: + elif ch == "," and depth == 0: options.append(inner[last:j]) - last = j+1 + last = j + 1 options.append(inner[last:]) results = [] for opt in options: @@ -355,15 +353,13 @@ def recursive_expand(pat: str) -> List[str]: results.extend(recursive_expand(expanded)) return results return [pat] + return recursive_expand(pattern) @staticmethod - def is_excluded(file_path: str, excluded_dirs: Set[str]) -> bool: + def is_excluded(file_path: str, excluded_dirs: set[str]) -> bool: parts = os.path.normpath(file_path).split(os.sep) - for part in parts: - if part in excluded_dirs: - return True - return False + return any(part in excluded_dirs for part in parts) @staticmethod def _exclude_glob_to_regex(pattern: str) -> str: @@ -384,10 +380,10 @@ def _exclude_glob_to_regex(pattern: str) -> str: out.append("(?:[^/]+/)*") # '**/' -> zero or more path segments i += 3 else: - out.append(".*") # '**' at end / before non-slash -> any, incl '/' + out.append(".*") # '**' at end / before non-slash -> any, incl '/' i += 2 else: - out.append("[^/]*") # '*' -> within a single path segment + out.append("[^/]*") # '*' -> within a single path segment i += 1 elif c == "?": out.append("[^/]") @@ -399,14 +395,14 @@ def _exclude_glob_to_regex(pattern: str) -> str: return "".join(out) @staticmethod - def compile_exclude_paths(patterns: Optional[List[str]]) -> List["re.Pattern"]: + def compile_exclude_paths(patterns: list[str] | None) -> list["re.Pattern"]: """Compile --exclude-paths globs into anchored regexes (compiled once per scan). Each pattern ``P`` is expanded the way Node feeds fast-glob's ``ignore``: ``P`` (a file- or dir-shaped exact match) plus ``P/**`` (its subtree), unless ``P`` already ends with ``/**``. Validation of the patterns happens earlier, in CliConfig.from_args. """ - compiled: List["re.Pattern"] = [] + compiled: list[re.Pattern] = [] for raw in patterns or []: p = (raw or "").strip().replace("\\", "/").rstrip("/") if not p: @@ -416,17 +412,17 @@ def compile_exclude_paths(patterns: Optional[List[str]]) -> List["re.Pattern"]: return compiled @staticmethod - def path_matches_exclude_regexes(rel_path: str, regexes: List["re.Pattern"]) -> bool: + def path_matches_exclude_regexes(rel_path: str, regexes: list["re.Pattern"]) -> bool: rp = rel_path.replace(os.sep, "/").replace("\\", "/") return any(r.match(rp) for r in regexes) @staticmethod - def matches_exclude_paths(file_path: str, base_path: str, patterns: List[str]) -> bool: + def matches_exclude_paths(file_path: str, base_path: str, patterns: list[str]) -> bool: """Convenience matcher (compiles patterns per call); used in tests/ad-hoc checks.""" rel_path = os.path.relpath(file_path, base_path).replace(os.sep, "/") return Core.path_matches_exclude_regexes(rel_path, Core.compile_exclude_paths(patterns)) - def save_submitted_files_list(self, files: List[str], output_path: str) -> None: + def save_submitted_files_list(self, files: list[str], output_path: str) -> None: """ Save the list of submitted file names to a JSON file for debugging. @@ -438,7 +434,7 @@ def save_submitted_files_list(self, files: List[str], output_path: str) -> None: # Calculate total size of all files total_size_bytes = 0 valid_files = [] - + for file_path in files: try: if os.path.exists(file_path) and os.path.isfile(file_path): @@ -451,33 +447,35 @@ def save_submitted_files_list(self, files: List[str], output_path: str) -> None: except OSError as e: log.warning(f"Error accessing file {file_path}: {e}") valid_files.append(file_path) # Still include in list for debugging - + # Convert bytes to human-readable format def format_bytes(bytes_value): """Convert bytes to human readable format""" - for unit in ['B', 'KB', 'MB', 'GB']: + for unit in ["B", "KB", "MB", "GB"]: if bytes_value < 1024.0: return f"{bytes_value:.2f} {unit}" bytes_value /= 1024.0 return f"{bytes_value:.2f} TB" - + file_data = { "timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), "total_files": len(valid_files), "total_size_bytes": total_size_bytes, "total_size_human": format_bytes(total_size_bytes), - "files": sorted(valid_files) + "files": sorted(valid_files), } - - with open(output_path, 'w', encoding='utf-8') as f: + + with open(output_path, "w", encoding="utf-8") as f: json.dump(file_data, f, indent=2, ensure_ascii=False) - - log.info(f"Saved list of {len(valid_files)} submitted files ({file_data['total_size_human']}) to: {output_path}") - + + log.info( + f"Saved list of {len(valid_files)} submitted files ({file_data['total_size_human']}) to: {output_path}" + ) + except Exception as e: log.error(f"Failed to save submitted files list to {output_path}: {e}") - def save_manifest_tar(self, files: List[str], output_path: str, base_dir: str) -> None: + def save_manifest_tar(self, files: list[str], output_path: str, base_dir: str) -> None: """ Save all manifest files to a compressed tar.gz archive with original directory structure. @@ -495,7 +493,7 @@ def save_manifest_tar(self, files: List[str], output_path: str, base_dir: str) - log.info(f"Creating manifest tar.gz file: {output_path}") log.debug(f"Base directory: {base_dir}") - with tarfile.open(output_path, 'w:gz') as tar: + with tarfile.open(output_path, "w:gz") as tar: for file_path in files: if not os.path.exists(file_path): log.warning(f"File not found, skipping: {file_path}") @@ -512,33 +510,33 @@ def save_manifest_tar(self, files: List[str], output_path: str, base_dir: str) - log.warning(f"File outside base dir, using basename: {file_path} -> {arcname}") # Normalize archive name to use forward slashes - arcname = arcname.replace(os.sep, '/') + arcname = arcname.replace(os.sep, "/") log.debug(f"Adding to tar: {file_path} -> {arcname}") tar.add(file_path, arcname=arcname) # Get tar file size for logging tar_size = os.path.getsize(output_path) - + def format_bytes(bytes_value): """Convert bytes to human readable format""" - for unit in ['B', 'KB', 'MB', 'GB']: + for unit in ["B", "KB", "MB", "GB"]: if bytes_value < 1024.0: return f"{bytes_value:.2f} {unit}" bytes_value /= 1024.0 return f"{bytes_value:.2f} TB" tar_size_human = format_bytes(tar_size) - log.info(f"Successfully created tar.gz with {len(files)} files ({tar_size_human}, {tar_size:,} bytes): {output_path}") + log.info( + f"Successfully created tar.gz with {len(files)} files ({tar_size_human}, {tar_size:,} bytes): {output_path}" + ) except Exception as e: log.error(f"Failed to save manifest tar.gz to {output_path}: {e}") @staticmethod def _prepare_manifest_patterns( - patterns: Dict, - ecosystems: Optional[List[str]], - excluded_ecosystems: List[str] + patterns: dict, ecosystems: list[str] | None, excluded_ecosystems: list[str] ) -> "ManifestPatterns": """Prepare case-folded manifest patterns for a single filesystem walk. @@ -550,9 +548,9 @@ def _prepare_manifest_patterns( """ included_ecosystems = set(ecosystems) if ecosystems is not None else None excluded = set(excluded_ecosystems) - literal_basenames: Set[str] = set() - basename_globs: Set[str] = set() - path_globs: Set[str] = set() + literal_basenames: set[str] = set() + basename_globs: set[str] = set() + path_globs: set[str] = set() for ecosystem, ecosystem_patterns in patterns.items(): if included_ecosystems is not None and ecosystem not in included_ecosystems: @@ -594,7 +592,7 @@ def _prepare_manifest_patterns( ) @staticmethod - def _compile_basename_globs(globs: Set[str]) -> Optional["re.Pattern"]: + def _compile_basename_globs(globs: set[str]) -> Optional["re.Pattern"]: """Compile basename globs into a single alternation, or None if there are none. fnmatch.translate anchors the tail with ``\\Z`` and re.match anchors the head, @@ -602,9 +600,7 @@ def _compile_basename_globs(globs: Set[str]) -> Optional["re.Pattern"]: """ if not globs: return None - return re.compile( - "|".join(f"(?:{fnmatch.translate(glob)})" for glob in sorted(globs)) - ) + return re.compile("|".join(f"(?:{fnmatch.translate(glob)})" for glob in sorted(globs))) @staticmethod def _basename_could_match(normalized_name: str, patterns: "ManifestPatterns") -> bool: @@ -636,11 +632,11 @@ def _matches_manifest_pattern(relative_path: str, patterns: "ManifestPatterns") return any(candidate.match(pattern) for pattern in patterns.path_globs) @staticmethod - def _matches_excluded_directory(directory_name: str, excluded_dirs: Set[str]) -> bool: + def _matches_excluded_directory(directory_name: str, excluded_dirs: set[str]) -> bool: """Match configured directory exclusions, including entries such as ``*.egg-info``.""" return any(fnmatch.fnmatchcase(directory_name, pattern) for pattern in excluded_dirs) - def find_files(self, path: str, ecosystems: Optional[List[str]] = None) -> List[str]: + def find_files(self, path: str, ecosystems: list[str] | None = None) -> list[str]: # noqa: C901 """ Finds supported manifest files in the given path. @@ -653,7 +649,7 @@ def find_files(self, path: str, ecosystems: Optional[List[str]] = None) -> List[ """ log.debug("Starting manifest discovery") start_time = time.perf_counter() - files: Set[str] = set() + files: set[str] = set() # Unified --exclude-paths: filter discovered manifests by the same paths/globs that are # forwarded to coana's --exclude-dirs. Only consulted when the user supplied the flag. @@ -693,18 +689,18 @@ def handle_walk_error(error: OSError) -> None: log.debug(f"Unable to inspect path during manifest discovery: {error}") for current_root, directory_names, file_names in os.walk( - path, - topdown=True, - followlinks=False, - onerror=handle_walk_error, + path, + topdown=True, + followlinks=False, + onerror=handle_walk_error, ): directories_visited += 1 kept_directories = [] for directory_name in directory_names: if directory_name == ".git" or Core._matches_excluded_directory( - directory_name, - excluded_dirs, + directory_name, + excluded_dirs, ): directories_pruned += 1 continue @@ -733,8 +729,8 @@ def handle_walk_error(error: OSError) -> None: if not Core._matches_manifest_pattern(relative_path, manifest_patterns): continue if exclude_regexes and Core.path_matches_exclude_regexes( - relative_path, - exclude_regexes, + relative_path, + exclude_regexes, ): continue if os.path.isfile(file_path): @@ -758,8 +754,12 @@ def handle_walk_error(error: OSError) -> None: ulimit_check = check_file_count_against_ulimit(file_count) if ulimit_check["can_check"]: if ulimit_check["would_exceed"]: - log.debug(f"Found {file_count} manifest files, which may exceed the file descriptor limit (ulimit -n = {ulimit_check['soft_limit']})") - log.debug(f"Available file descriptors: {ulimit_check['available_fds']} (after {ulimit_check['buffer_size']} buffer)") + log.debug( + f"Found {file_count} manifest files, which may exceed the file descriptor limit (ulimit -n = {ulimit_check['soft_limit']})" + ) + log.debug( + f"Available file descriptors: {ulimit_check['available_fds']} (after {ulimit_check['buffer_size']} buffer)" + ) log.debug(f"Recommendation: {ulimit_check['recommendation']}") log.debug("This may cause 'Too many open files' errors during processing") else: @@ -769,7 +769,7 @@ def handle_walk_error(error: OSError) -> None: return file_list - def find_sbom_files(self, path: str) -> List[str]: + def find_sbom_files(self, path: str) -> list[str]: """ Finds only pre-generated SBOM files (CDX and SPDX) in the given path. @@ -783,10 +783,10 @@ def find_sbom_files(self, path: str) -> List[str]: List of found CDX and SPDX file paths. """ log.debug("Starting Find SBOM Files (CDX and SPDX only)") - sbom_ecosystems = ['cdx', 'spdx'] + sbom_ecosystems = ["cdx", "spdx"] return self.find_files(path, ecosystems=sbom_ecosystems) - def get_supported_patterns(self) -> Dict: + def get_supported_patterns(self) -> dict: """ Gets supported file patterns from the Socket API. @@ -818,10 +818,7 @@ def get_supported_patterns(self) -> Dict: if source == "api": self._supported_patterns = patterns elapsed = time.perf_counter() - start_time - log.info( - "Supported manifest patterns loaded in " - f"{elapsed:.2f}s: source={source}, ecosystems={len(patterns)}" - ) + log.info(f"Supported manifest patterns loaded in {elapsed:.2f}s: source={source}, ecosystems={len(patterns)}") return patterns def has_manifest_files(self, files: list) -> bool: @@ -841,10 +838,11 @@ def has_manifest_files(self, files: list) -> bool: log.error(f"Error getting supported patterns from API: {e}") log.warning("Falling back to local patterns") from .utils import socket_globs as fallback_patterns + patterns = fallback_patterns # Normalize all file paths for matching - norm_files = [f.replace('\\', '/').lstrip('./') for f in files] + norm_files = [f.replace("\\", "/").lstrip("./") for f in files] for ecosystem in patterns: ecosystem_patterns = patterns[ecosystem] @@ -860,17 +858,17 @@ def has_manifest_files(self, files: list) -> bool: return True # Also try with **/ prefix to match files in subdirectories # (e.g. "src/requirements.txt" matching "*requirements.txt") - if '/' not in exp_pat and PurePath(file).match(f"**/{exp_pat}"): + if "/" not in exp_pat and PurePath(file).match(f"**/{exp_pat}"): return True return False def check_file_count_limit(self, file_count: int) -> dict: """ Check if the given file count would exceed the system's file descriptor limit. - + Args: file_count: Number of files to check - + Returns: Dictionary with check results including recommendations """ @@ -890,10 +888,10 @@ def to_case_insensitive_regex(input_string: str) -> str: Example: "pipfile" -> "[Pp][Ii][Pp][Ff][Ii][Ll][Ee]" """ - return ''.join(f'[{char.lower()}{char.upper()}]' if char.isalpha() else char for char in input_string) + return "".join(f"[{char.lower()}{char.upper()}]" if char.isalpha() else char for char in input_string) @staticmethod - def empty_head_scan_file() -> List[str]: + def empty_head_scan_file() -> list[str]: """ Creates a temporary placeholder manifest for scans with no manifest files. @@ -912,11 +910,11 @@ def empty_head_scan_file() -> List[str]: """ # Own directory per call so concurrent runs can't clobber each other's placeholder; # the basename must stay exactly SOCKET_FACTS_FILENAME to pass the API's validator. - temp_dir = tempfile.mkdtemp(prefix='socket_baseline_') + temp_dir = tempfile.mkdtemp(prefix="socket_baseline_") _PLACEHOLDER_FACTS_DIRS.append(temp_dir) temp_path = os.path.join(temp_dir, SOCKET_FACTS_FILENAME) - with open(temp_path, 'w') as f: + with open(temp_path, "w") as f: f.write(SOCKET_FACTS_EMPTY_DOCUMENT) log.debug(f"Created temporary placeholder facts file for baseline scan: {temp_path}") @@ -946,10 +944,10 @@ def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: log.debug(f"Facts file not found: {facts_file_path}") return False - with open(facts_file_path, 'r') as f: + with open(facts_file_path) as f: facts = json.load(f) - tier1_scan_id = facts.get('tier1ReachabilityScanId') + tier1_scan_id = facts.get("tier1ReachabilityScanId") if not tier1_scan_id: log.debug(f"No tier1ReachabilityScanId found in {facts_file_path}") return False @@ -957,12 +955,12 @@ def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: tier1_scan_id = tier1_scan_id.strip() log.debug(f"Found tier1ReachabilityScanId: {tier1_scan_id}") - except (json.JSONDecodeError, IOError) as e: + except (OSError, json.JSONDecodeError) as e: log.debug(f"Failed to read tier1ReachabilityScanId from {facts_file_path}: {e}") return False # Call the SDK to finalize the full application reachability scan, retrying transient failures with backoff. - last_error: Optional[Exception] = None + last_error: Exception | None = None for attempt in range(1, TIER1_FINALIZE_MAX_ATTEMPTS + 1): try: success = self.sdk.fullscans.finalize_tier1( @@ -971,7 +969,9 @@ def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: ) if success: - log.debug(f"Successfully finalized full application reachability scan {tier1_scan_id} for full scan {full_scan_id}") + log.debug( + f"Successfully finalized full application reachability scan {tier1_scan_id} for full scan {full_scan_id}" + ) return True log.debug( @@ -1042,14 +1042,12 @@ def _compress_facts_file(source_path: str) -> str: # Don't leave a half-written .br behind for the caller to miss (it only tracks # the path for cleanup once this returns). Remove it, then re-raise so the caller # falls back to uploading the plain file. - try: + with contextlib.suppress(OSError): os.unlink(target_path) - except OSError: - pass raise return target_path - def _compress_facts_files_for_upload(self, files: List[str]) -> Tuple[List[str], List[str]]: + def _compress_facts_files_for_upload(self, files: list[str]) -> tuple[list[str], list[str]]: """Replace any ``.socket.facts.json`` upload entry with a brotli-compressed ``.br`` sibling. The Socket full-scan endpoint transparently decompresses a multipart part named @@ -1069,8 +1067,8 @@ def _compress_facts_files_for_upload(self, files: List[str]) -> Tuple[List[str], list to upload and ``temp_paths`` are compressed files the caller must delete once the upload completes. """ - upload_files: List[str] = [] - temp_paths: List[str] = [] + upload_files: list[str] = [] + temp_paths: list[str] = [] for file_path in files: try: if ( @@ -1089,13 +1087,13 @@ def _compress_facts_files_for_upload(self, files: List[str]) -> Tuple[List[str], continue except Exception as e: # Never let compression break an upload: fall back to the plain file. - log.warning( - f"Failed to brotli-compress facts file {file_path}, uploading uncompressed: {e}" - ) + log.warning(f"Failed to brotli-compress facts file {file_path}, uploading uncompressed: {e}") upload_files.append(file_path) return upload_files, temp_paths - def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: Optional[List[str]] = None) -> FullScan: + def create_full_scan( + self, files: list[str], params: FullScanParams, base_paths: list[str] | None = None + ) -> FullScan: """ Creates a new full scan via the Socket API. @@ -1125,7 +1123,14 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: # below) outlive every attempt. for attempt, backoff_seconds in enumerate(FULL_SCAN_UPLOAD_BACKOFF_SCHEDULE_SECONDS, start=1): try: - res = self.sdk.fullscans.post(upload_files, params, use_types=True, use_lazy_loading=True, max_open_files=50, base_paths=base_paths) + res = self.sdk.fullscans.post( + upload_files, + params, + use_types=True, + use_lazy_loading=True, + max_open_files=50, + base_paths=base_paths, + ) break except APIFailure as error: if error.status_code in REQUEST_TOO_LARGE_STATUS_CODES: @@ -1141,13 +1146,12 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: "--commit-message or shorten other scan metadata." ) raise APIFailure( - f"Full scan request rejected as too large (HTTP {error.status_code}). " - f"{guidance}\n{error}", + f"Full scan request rejected as too large (HTTP {error.status_code}). {guidance}\n{error}", status_code=error.status_code, ) from error if backoff_seconds is None or not error.is_transient_error(): raise - wait_seconds = backoff_seconds + random.uniform( + wait_seconds = backoff_seconds + random.uniform( # noqa: S311 0, FULL_SCAN_UPLOAD_BACKOFF_JITTER_SECONDS ) # SDK error messages can span many lines (path + response headers); the @@ -1177,11 +1181,10 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: # Finalize full application reachability scan if reachability analysis was enabled if self.cli_config and self.cli_config.reach: - facts_file_path = os.path.join( - self.cli_config.target_path or ".", - self.cli_config.reach_output_file + facts_file_path = os.path.join(self.cli_config.target_path or ".", self.cli_config.reach_output_file) + log.debug( + f"Reachability analysis enabled, finalizing full application reachability scan for full scan {full_scan.id}" ) - log.debug(f"Reachability analysis enabled, finalizing full application reachability scan for full scan {full_scan.id}") try: success = self.finalize_tier1_scan(full_scan.id, facts_file_path) if success: @@ -1195,18 +1198,17 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: @staticmethod def _log_scan_configuration( - paths: List[str], - params: FullScanParams, - files: List[str], - manifest_source: str, - base_paths: Optional[List[str]] = None, + paths: list[str], + params: FullScanParams, + files: list[str], + manifest_source: str, + base_paths: list[str] | None = None, ) -> None: """Log aggregate scan inputs without exposing submitted manifest paths.""" base_path = base_paths[0] if base_paths else (paths[0] if paths else ".") absolute_base_path = os.path.abspath(base_path) relative_roots = [ - os.path.relpath(os.path.abspath(path), absolute_base_path).replace("\\", "/") - for path in paths + os.path.relpath(os.path.abspath(path), absolute_base_path).replace("\\", "/") for path in paths ] or ["."] log.info( "Scan configuration: " @@ -1217,15 +1219,15 @@ def _log_scan_configuration( f"manifests={len(files)} manifest_source={manifest_source}" ) - def create_full_scan_with_report_url( - self, - paths: List[str], - params: FullScanParams, - no_change: bool = False, - save_files_list_path: Optional[str] = None, - save_manifest_tar_path: Optional[str] = None, - base_paths: Optional[List[str]] = None, - explicit_files: Optional[List[str]] = None + def create_full_scan_with_report_url( # noqa: C901 + self, + paths: list[str], + params: FullScanParams, + no_change: bool = False, + save_files_list_path: str | None = None, + save_manifest_tar_path: str | None = None, + base_paths: list[str] | None = None, + explicit_files: list[str] | None = None, ) -> Diff: """Create a new full scan and return with html_report_url. @@ -1242,11 +1244,7 @@ def create_full_scan_with_report_url( Dict with full scan data including html_report_url """ log.debug(f"starting create_full_scan_with_report_url with no_change: {no_change}") - diff = Diff( - id="NO_SCAN_RAN", - report_url="", - diff_url="" - ) + diff = Diff(id="NO_SCAN_RAN", report_url="", diff_url="") if no_change: return diff @@ -1267,15 +1265,15 @@ def create_full_scan_with_report_url( manifest_source="provided" if explicit_files is not None else "discovered", base_paths=base_paths, ) - + # Save submitted files list if requested if save_files_list_path and all_files: self.save_submitted_files_list(all_files, save_files_list_path) - + # Save manifest tar.gz if requested (use first path as base) if save_manifest_tar_path and all_files and paths: self.save_manifest_tar(all_files, save_manifest_tar_path, paths[0]) - + # If no supported files found, create empty scan if not all_files: log.info("No supported manifest files found - creating empty scan") @@ -1286,7 +1284,7 @@ def create_full_scan_with_report_url( new_full_scan = self.create_full_scan(empty_files, params, base_paths=base_paths) new_scan_end = time.time() log.info(f"Total time to create empty full scan: {new_scan_end - new_scan_start:.2f}") - + # Clean up the temporary empty file for temp_file in empty_files: try: @@ -1294,14 +1292,12 @@ def create_full_scan_with_report_url( log.debug(f"Cleaned up temporary file: {temp_file}") except OSError as e: log.warning(f"Failed to clean up temporary file {temp_file}: {e}") - except Exception as e: + except Exception: # Clean up temp files even if scan creation fails for temp_file in empty_files: - try: + with contextlib.suppress(OSError): os.unlink(temp_file) - except OSError: - pass - raise e + raise else: try: # Create new scan @@ -1320,13 +1316,8 @@ def create_full_scan_with_report_url( diff.id = new_full_scan.id diff.is_full_scan = True - needs_alerts = ( - self.cli_config is not None - and ( - self.cli_config.enable_gitlab_security - or self.cli_config.enable_json - or self.cli_config.enable_sarif - ) + needs_alerts = self.cli_config is not None and ( + self.cli_config.enable_gitlab_security or self.cli_config.enable_json or self.cli_config.enable_sarif ) # --generate-license (and --legal-format fossa, which it gates) enumerates # diff.packages rather than the alert list, so a full scan has to carry the @@ -1334,9 +1325,7 @@ def create_full_scan_with_report_url( # this, an SCM branch pipeline writes an attribution file with zero packages. # Keep in sync with _requires_unchanged_artifacts, which lists the same # consumers for the comparison path. - needs_license_artifacts = ( - self.cli_config is not None and self.cli_config.generate_license - ) + needs_license_artifacts = self.cli_config is not None and self.cli_config.generate_license if needs_alerts or needs_license_artifacts: log.info("Output format requires SBOM data, fetching it for the full scan") @@ -1349,16 +1338,14 @@ def create_full_scan_with_report_url( diff.packages = packages if needs_alerts: - all_alerts_collection: Dict[str, List[Issue]] = {} - for package_id, package in packages.items(): + all_alerts_collection: dict[str, list[Issue]] = {} + for package in packages.values(): self.add_package_alerts_to_collection( - package=package, - alerts_collection=all_alerts_collection, - packages=packages + package=package, alerts_collection=all_alerts_collection, packages=packages ) - consolidated: Set[str] = set() - for alert_key, alerts in all_alerts_collection.items(): + consolidated: set[str] = set() + for alerts in all_alerts_collection.values(): for alert in alerts: alert_str = f"{alert.purl},{alert.type}" if (alert.error or alert.warn) and alert_str not in consolidated: @@ -1368,8 +1355,7 @@ def create_full_scan_with_report_url( sbom_end = time.time() log.info( - f"Fetched {len(packages)} packages and {len(diff.new_alerts)} alerts " - f"in {sbom_end - sbom_start:.2f}s" + f"Fetched {len(packages)} packages and {len(diff.new_alerts)} alerts in {sbom_end - sbom_start:.2f}s" ) else: diff.packages = {} @@ -1390,9 +1376,7 @@ def _add_license_details(self, packages: dict[str, Package]) -> dict[str, Packag qualified_name = package.name if package.namespace: qualified_name = f"{package.namespace.strip('/')}/{qualified_name}" - packages_by_purl[ - f"{package.type}/{qualified_name}@{package.version}" - ] = package + packages_by_purl[f"{package.type}/{qualified_name}@{package.version}"] = package self.get_license_text_via_purl( packages_by_purl, batch_size=batch_size, @@ -1431,7 +1415,7 @@ def create_packages_dict(self, sbom_artifacts: list[SocketArtifact]) -> dict[str for artifact in sbom_artifacts: package = Package.from_socket_artifact(asdict(artifact)) if package.id in packages: - print("Duplicate package?") + log.debug(f"Duplicate package in SBOM artifacts: {package.id}") else: package.license_text = self.get_package_license_text(package) packages[package.id] = package @@ -1485,9 +1469,8 @@ def get_package_license_text(self, package: Package) -> str: return "" license_raw = package.license - data = self.sdk.licensemetadata.post([license_raw], {'includetext': 'true'}) - license_str = data[0].get('text') if data and len(data) == 1 else "" - return license_str + data = self.sdk.licensemetadata.post([license_raw], {"includetext": "true"}) + return data[0].get("text") if data and len(data) == 1 else "" def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-branch") -> RepositoryInfo: """ @@ -1520,21 +1503,19 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br except APIFailure: log.warning(f"Failed to get repository {repo_slug}, attempting to create it") try: - create_response = self.sdk.repos.post( self.config.org_slug, name=repo_slug, default_branch=default_branch, - visibility=self.config.repo_visibility + visibility=self.config.repo_visibility, ) # Check if the response is empty (failure) or has content (success) if not create_response: log.error("Failed to create repository: empty response") raise Exception("Failed to create repository: empty response") - else: - response = self.sdk.repos.repo(self.config.org_slug, repo_slug, use_types=True) - return response.data + response = self.sdk.repos.repo(self.config.org_slug, repo_slug, use_types=True) + return response.data except APIFailure as e: status = f" (HTTP {e.status_code})" if e.status_code else "" @@ -1544,16 +1525,16 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br log.error(f"API failure while creating repository {repo_slug}{status}: {reason}") if e.status_code == 403 and not isinstance(e, (APIInsufficientQuota, APIOrganizationNotAllowed)): log.error("A 403 here usually means the API token is missing the repo:create scope.") - sys.exit(2) # Exit here with code 2. Code 1 indicates a successfully-detected security issue. + sys.exit(2) # Exit here with code 2. Code 1 indicates a successfully-detected security issue. return response.data def get_head_scan_for_repo( - self, - repo_slug: str, - workspace: Optional[str] = None, - scan_type: Optional[str] = None, - ) -> Optional[str]: + self, + repo_slug: str, + workspace: str | None = None, + scan_type: str | None = None, + ) -> str | None: """ Gets the head scan ID for a repository. @@ -1590,14 +1571,14 @@ def get_head_scan_for_repo( if scan_type: query_params["scan_type"] = Core.query_param_value(scan_type) for results in self._full_scan_result_pages( - query_params, - f"Failed to list matching full scans for repo {repo_slug}", + query_params, + f"Failed to list matching full scans for repo {repo_slug}", ): scan_id = Core.newest_persisted_scan_id(results) if scan_id: return scan_id return None - return repo_info.head_full_scan_id if repo_info.head_full_scan_id else None + return repo_info.head_full_scan_id or None @staticmethod def query_param_value(value): @@ -1611,7 +1592,7 @@ def query_param_value(value): return getattr(value, "value", value) @staticmethod - def newest_persisted_scan_id(results: List[dict]) -> Optional[str]: + def newest_persisted_scan_id(results: list[dict]) -> str | None: """ Returns the newest scan ID from a full scan listing, skipping temporary scans. @@ -1636,10 +1617,10 @@ def newest_persisted_scan_id(results: List[dict]) -> Optional[str]: return None def _full_scan_result_pages( - self, - query_params: dict, - failure_message: str, - ) -> Iterator[List[dict]]: + self, + query_params: dict, + failure_message: str, + ) -> Iterator[list[dict]]: """Yields successful full-scan listing pages and rejects failed lookups.""" request_params = dict(query_params) seen_pages = {str(request_params.get("page", 1))} @@ -1661,13 +1642,11 @@ def _full_scan_result_pages( page_key = str(next_page) if page_key in seen_pages: - raise APIFailure( - f"{failure_message}: full-scan pagination repeated page {next_page}" - ) + raise APIFailure(f"{failure_message}: full-scan pagination repeated page {next_page}") seen_pages.add(page_key) request_params = {**query_params, "page": next_page} - def first_parent_commits(self, start_commit_sha: str, max_count: int) -> List[str]: + def first_parent_commits(self, start_commit_sha: str, max_count: int) -> list[str]: """ Lists a commit and its first-parent ancestors, newest first. @@ -1699,12 +1678,12 @@ def first_parent_commits(self, start_commit_sha: str, max_count: int) -> List[st return [line.strip() for line in output.splitlines() if line.strip()] def find_baseline_scan_for_ancestor( - self, - repo_slug: str, - commit_sha: str, - workspace: Optional[str] = None, - scan_type: Optional[str] = None, - ) -> Tuple[Optional[str], Optional[str], int]: + self, + repo_slug: str, + commit_sha: str, + workspace: str | None = None, + scan_type: str | None = None, + ) -> tuple[str | None, str | None, int]: """ Finds the nearest ancestor of a commit that does have a full scan. @@ -1746,8 +1725,8 @@ def find_baseline_scan_for_ancestor( scans_by_commit = {} ancestor_set = set(ancestors) for results in self._full_scan_result_pages( - query_params, - f"Failed to list ancestor full scans for repo {repo_slug}", + query_params, + f"Failed to list ancestor full scans for repo {repo_slug}", ): for result in results: if not isinstance(result, dict) or result.get("tmp"): @@ -1755,11 +1734,7 @@ def find_baseline_scan_for_ancestor( result_commit = result.get("commit_hash") scan_id = result.get("id") # Newest first, so the first scan seen for a commit is the one to keep. - if ( - result_commit in ancestor_set - and scan_id - and result_commit not in scans_by_commit - ): + if result_commit in ancestor_set and scan_id and result_commit not in scans_by_commit: scans_by_commit[result_commit] = scan_id if not scans_by_commit: @@ -1772,12 +1747,8 @@ def find_baseline_scan_for_ancestor( return None, None, 0 def get_full_scan_id_by_commit( - self, - repo_slug: str, - commit_sha: str, - workspace: Optional[str] = None, - scan_type: Optional[str] = None - ) -> Optional[str]: + self, repo_slug: str, commit_sha: str, workspace: str | None = None, scan_type: str | None = None + ) -> str | None: """ Finds the most recent full scan for a repository + commit SHA. @@ -1807,15 +1778,15 @@ def get_full_scan_id_by_commit( query_params["scan_type"] = Core.query_param_value(scan_type) for results in self._full_scan_result_pages( - query_params, - f"Failed to list full scans for commit {commit_sha} in repo {repo_slug}", + query_params, + f"Failed to list full scans for commit {commit_sha} in repo {repo_slug}", ): scan_id = Core.newest_persisted_scan_id(results) if scan_id: return scan_id return None - def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: + def resolve_base_full_scan_id(self, params: FullScanParams) -> str | None: """ Resolves the baseline full scan ID to diff a new scan against. @@ -1831,10 +1802,7 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: has no head scan yet (caller creates an empty baseline scan). """ if self.cli_config and self.cli_config.base_scan_id: - log.info( - "Baseline selected: source=explicit-scan " - f"scan_id={json.dumps(self.cli_config.base_scan_id)}" - ) + log.info(f"Baseline selected: source=explicit-scan scan_id={json.dumps(self.cli_config.base_scan_id)}") return self.cli_config.base_scan_id if self.cli_config and self.cli_config.base_commit_sha: @@ -1892,19 +1860,14 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: # Returning None would make the caller create an empty baseline scan, # reporting every dependency as newly added, so fail loudly like the # --base-commit-sha path above. - log.error( - f"Failed to resolve the matching head scan for repo {params.repo}: {error}" - ) + log.error(f"Failed to resolve the matching head scan for repo {params.repo}: {error}") if self.cli_config is None: raise if self.cli_config.disable_blocking: sys.exit(0) sys.exit(self.cli_config.exit_code_on_api_error) if scan_id: - log.info( - "Baseline selected: source=repository-head " - f"scan_id={json.dumps(scan_id)}" - ) + log.info(f"Baseline selected: source=repository-head scan_id={json.dumps(scan_id)}") return scan_id @staticmethod @@ -1918,41 +1881,41 @@ def update_package_values(pkg: Package) -> Package: def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: int = 5000) -> dict: """Get license attribution and details via PURL endpoint in batches. - + Args: packages: Dictionary of packages to get license info for batch_size: Maximum number of packages to process per API call (1-9999) - + Returns: Updated packages dictionary with licenseAttrib and licenseDetails populated """ # Validate batch size batch_size = max(1, min(9999, batch_size)) - + # Build list of all components all_components = [] for purl in packages: full_purl = f"pkg:/{purl}" all_components.append({"purl": full_purl}) - + # Process in batches total_components = len(all_components) log.debug(f"Processing {total_components} packages in batches of {batch_size}") - + for i in range(0, total_components, batch_size): - batch_components = all_components[i:i + batch_size] + batch_components = all_components[i : i + batch_size] batch_num = (i // batch_size) + 1 total_batches = (total_components + batch_size - 1) // batch_size log.debug(f"Processing batch {batch_num}/{total_batches} ({len(batch_components)} packages)") - + results = self.sdk.purl.post( license=True, components=batch_components, org_slug=self.config.org_slug, licenseattrib=True, - licensedetails=True + licensedetails=True, ) - + purl_packages = [] for result in results: ecosystem = result["type"] @@ -1967,14 +1930,11 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in if purl not in purl_packages and purl in packages: packages[purl].licenseAttrib = licenseAttrib packages[purl].licenseDetails = licenseDetails - + return packages def get_diff_scan_artifacts( - self, - head_full_scan_id: str, - new_full_scan_id: str, - external_href: Optional[str] = None + self, head_full_scan_id: str, new_full_scan_id: str, external_href: str | None = None ) -> DiffArtifacts: """Compare two full scans via the diff-scans endpoints, polling for the result. @@ -2045,8 +2005,7 @@ def get_diff_scan_artifacts( diff_scan_id = diff_scan.get("id") if not diff_scan_id: raise Exception( - "Error creating or resolving diff scan: " - f"unexpected response: {str(response_summary)[:500]}" + f"Error creating or resolving diff scan: unexpected response: {str(response_summary)[:500]}" ) # Logged at INFO, not debug: this is the only identifier that ties a slow or # failed comparison in a CI log back to a server-side diff scan, and it is @@ -2115,18 +2074,16 @@ def get_diff_scan_artifacts( break if time.monotonic() >= deadline: raise Exception( - f"Timed out waiting for diff scan {diff_scan_id} after " - f"{DIFF_SCAN_POLL_TIMEOUT_SECONDS:.0f} seconds" + f"Timed out waiting for diff scan {diff_scan_id} after {DIFF_SCAN_POLL_TIMEOUT_SECONDS:.0f} seconds" ) log.debug(f"Diff scan {diff_scan_id} still processing, polling again in {interval:.0f}s") time.sleep(interval) last_interval = interval interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS) - return DiffArtifacts.from_dict({ - key: artifacts_dict.get(key) or [] - for key in ("added", "removed", "unchanged", "replaced", "updated") - }) + return DiffArtifacts.from_dict( + {key: artifacts_dict.get(key) or [] for key in ("added", "removed", "unchanged", "replaced", "updated")} + ) def _requires_unchanged_artifacts(self) -> bool: """Whether any enabled output reads the unchanged half of a comparison. @@ -2161,13 +2118,13 @@ def _requires_unchanged_artifacts(self) -> bool: or getattr(config, "legal_format", "socket") == "fossa" ) - def get_added_and_removed_packages( - self, - head_full_scan_id: str, - new_full_scan_id: str, - include_license_details: bool = False, - external_href: Optional[str] = None - ) -> Tuple[Dict[str, Package], Dict[str, Package], Dict[str, Package]]: + def get_added_and_removed_packages( # noqa: C901 + self, + head_full_scan_id: str, + new_full_scan_id: str, + include_license_details: bool = False, + external_href: str | None = None, + ) -> tuple[dict[str, Package], dict[str, Package], dict[str, Package]]: """ Get packages that were added and removed between scans. @@ -2223,29 +2180,25 @@ def get_added_and_removed_packages( f"Diff scan comparison failed with {type(error).__name__}({error_summary}), " "falling back to the streaming scan comparison" ) - log.info( - "Diff comparison mode: requested=diff-scan effective=streaming " - f"reason={type(error).__name__}" - ) + log.info(f"Diff comparison mode: requested=diff-scan effective=streaming reason={type(error).__name__}") if diff_artifacts is None: try: - diff_artifacts = ( - self.sdk.fullscans.stream_diff( - self.config.org_slug, - head_full_scan_id, - new_full_scan_id, - use_types=True, - include_license_details=str(include_license_details).lower() - ).data.artifacts - ) + diff_artifacts = self.sdk.fullscans.stream_diff( + self.config.org_slug, + head_full_scan_id, + new_full_scan_id, + use_types=True, + include_license_details=str(include_license_details).lower(), + ).data.artifacts except APIFailure as e: log.error(f"API Error: {e}") # API failures are mapped to the configured infrastructure exit code by cli(). raise except Exception as e: import traceback - log.error(f"Error getting diff report: {str(e)}") + + log.error(f"Error getting diff report: {e!s}") log.error(f"Stack trace:\n{traceback.format_exc()}") raise @@ -2256,7 +2209,7 @@ def get_added_and_removed_packages( # Drop it from every bucket before the counts below, which should describe what the # CLI actually reports on. marker_found = False - buckets: Dict[str, List] = {} + buckets: dict[str, list] = {} for name in ("added", "removed", "unchanged", "replaced", "updated"): bucket = getattr(diff_artifacts, name) buckets[name] = [a for a in bucket if not Core.is_invalid_facts_marker(a)] @@ -2271,9 +2224,9 @@ def get_added_and_removed_packages( removed_artifacts = buckets["removed"] + buckets["replaced"] unchanged_artifacts = buckets["unchanged"] - added_packages: Dict[str, Package] = {} - removed_packages: Dict[str, Package] = {} - packages: Dict[str, Package] = {} + added_packages: dict[str, Package] = {} + removed_packages: dict[str, Package] = {} + packages: dict[str, Package] = {} for artifact in added_artifacts: try: pkg = Package.from_diff_artifact(asdict(artifact)) @@ -2316,19 +2269,19 @@ def get_added_and_removed_packages( packages = self.get_license_text_via_purl(packages, batch_size=batch_size) else: log.debug("Skipping PURL endpoint call (--generate-license not set)") - + return added_packages, removed_packages, packages - def create_new_diff( - self, - paths: List[str], - params: FullScanParams, - no_change: bool = False, - save_files_list_path: Optional[str] = None, - save_manifest_tar_path: Optional[str] = None, - base_paths: Optional[List[str]] = None, - explicit_files: Optional[List[str]] = None, - external_href: Optional[str] = None + def create_new_diff( # noqa: C901 + self, + paths: list[str], + params: FullScanParams, + no_change: bool = False, + save_files_list_path: str | None = None, + save_manifest_tar_path: str | None = None, + base_paths: list[str] | None = None, + explicit_files: list[str] | None = None, + external_href: str | None = None, ) -> Diff: """Create a new diff using the Socket SDK. @@ -2364,15 +2317,15 @@ def create_new_diff( manifest_source="provided" if explicit_files is not None else "discovered", base_paths=base_paths, ) - + # Save submitted files list if requested if save_files_list_path and all_files: self.save_submitted_files_list(all_files, save_files_list_path) - + # Save manifest tar.gz if requested (use first path as base) if save_manifest_tar_path and all_files and paths: self.save_manifest_tar(all_files, save_manifest_tar_path, paths[0]) - + # If no supported files found, create empty scan for comparison scan_files = all_files if not all_files: @@ -2387,23 +2340,20 @@ def create_new_diff( if head_full_scan_id is None: log.info("No previous scan found - creating empty baseline scan") new_params = copy.deepcopy(params.__dict__) - new_params.pop('include_license_details') + new_params.pop("include_license_details") tmp_params = FullScanParams(**new_params) tmp_params.include_license_details = params.include_license_details tmp_params.tmp = True tmp_params.set_as_pending_head = False tmp_params.make_default_branch = False - + # Create baseline scan with empty file empty_files = Core.empty_head_scan_file() try: head_full_scan = self.create_full_scan(empty_files, tmp_params, base_paths=base_paths) head_full_scan_id = head_full_scan.id - log.info( - "Baseline selected: source=empty " - f"scan_id={json.dumps(head_full_scan_id)}" - ) - + log.info(f"Baseline selected: source=empty scan_id={json.dumps(head_full_scan_id)}") + # Clean up the temporary empty file for temp_file in empty_files: try: @@ -2411,20 +2361,18 @@ def create_new_diff( log.debug(f"Cleaned up temporary file: {temp_file}") except OSError as e: log.warning(f"Failed to clean up temporary file {temp_file}: {e}") - except Exception as e: + except Exception: # Clean up temp files even if scan creation fails for temp_file in empty_files: - try: + with contextlib.suppress(OSError): os.unlink(temp_file) - except OSError: - pass - raise e + raise # Create new scan temp_files_to_cleanup = [] if not all_files: # We're using empty scan files temp_files_to_cleanup = scan_files - + try: new_scan_start = time.time() new_full_scan = self.create_full_scan(scan_files, params, base_paths=base_paths) @@ -2434,22 +2382,19 @@ def create_new_diff( log.error(f"API Error: {e}") # Clean up temp files if any for temp_file in temp_files_to_cleanup: - try: + with contextlib.suppress(OSError): os.unlink(temp_file) - except OSError: - pass # API failures are mapped to the configured infrastructure exit code by cli(). raise except Exception as e: import traceback - log.error(f"Error creating new full scan: {str(e)}") + + log.error(f"Error creating new full scan: {e!s}") log.error(f"Stack trace:\n{traceback.format_exc()}") # Clean up temp files if any for temp_file in temp_files_to_cleanup: - try: + with contextlib.suppress(OSError): os.unlink(temp_file) - except OSError: - pass raise finally: # Clean up temporary empty files if they were created @@ -2469,11 +2414,7 @@ def create_new_diff( # the response and risks the truncation crash on large repos. The # user flag still controls the dashboard report URL below; it just no # longer gates this internal diff payload. - ( - added_packages, - removed_packages, - packages - ) = self.get_added_and_removed_packages( + (added_packages, removed_packages, packages) = self.get_added_and_removed_packages( head_full_scan_id, new_full_scan.id, include_license_details=False, @@ -2482,7 +2423,8 @@ def create_new_diff( # Separate unchanged packages from added/removed for --strict-blocking support unchanged_packages = { - pkg_id: pkg for pkg_id, pkg in packages.items() + pkg_id: pkg + for pkg_id, pkg in packages.items() if pkg_id not in added_packages and pkg_id not in removed_packages } @@ -2511,10 +2453,10 @@ def create_new_diff( def create_diff_report( self, - added_packages: Dict[str, Package], - removed_packages: Dict[str, Package], - unchanged_packages: Optional[Dict[str, Package]] = None, - direct_only: bool = True + added_packages: dict[str, Package], + removed_packages: dict[str, Package], + unchanged_packages: dict[str, Package] | None = None, + direct_only: bool = True, ) -> Diff: """ Creates a diff report comparing two sets of packages. @@ -2537,9 +2479,9 @@ def create_diff_report( """ diff = Diff() - alerts_in_added_packages: Dict[str, List[Issue]] = {} - alerts_in_removed_packages: Dict[str, List[Issue]] = {} - alerts_in_unchanged_packages: Dict[str, List[Issue]] = {} + alerts_in_added_packages: dict[str, list[Issue]] = {} + alerts_in_removed_packages: dict[str, list[Issue]] = {} + alerts_in_unchanged_packages: dict[str, list[Issue]] = {} seen_packages = { "added": set(), @@ -2559,9 +2501,7 @@ def create_diff_report( seen_packages[change_type].add(base_purl) self.add_package_alerts_to_collection( - package=package, - alerts_collection=alerts_in_added_packages, - packages=added_packages + package=package, alerts_collection=alerts_in_added_packages, packages=added_packages ) for package_id, package in removed_packages.items(): @@ -2575,9 +2515,7 @@ def create_diff_report( seen_packages[change_type].add(base_purl) self.add_package_alerts_to_collection( - package=package, - alerts_collection=alerts_in_removed_packages, - packages=removed_packages + package=package, alerts_collection=alerts_in_removed_packages, packages=removed_packages ) # Process unchanged packages for --strict-blocking support @@ -2588,25 +2526,16 @@ def create_diff_report( continue self.add_package_alerts_to_collection( - package=package, - alerts_collection=alerts_in_unchanged_packages, - packages=unchanged_packages + package=package, alerts_collection=alerts_in_unchanged_packages, packages=unchanged_packages ) - diff.new_alerts = Core.get_new_alerts( - alerts_in_added_packages, - alerts_in_removed_packages - ) + diff.new_alerts = Core.get_new_alerts(alerts_in_added_packages, alerts_in_removed_packages) # Get unchanged alerts (for --strict-blocking mode) - diff.unchanged_alerts = Core.get_unchanged_alerts( - alerts_in_unchanged_packages - ) + diff.unchanged_alerts = Core.get_unchanged_alerts(alerts_in_unchanged_packages) # Get removed alerts (for completeness) - diff.removed_alerts = Core.get_removed_alerts( - alerts_in_removed_packages - ) + diff.removed_alerts = Core.get_removed_alerts(alerts_in_removed_packages) diff.new_capabilities = Core.get_capabilities_for_added_packages(added_packages) @@ -2631,7 +2560,7 @@ def create_purl(self, package_id: str, packages: dict[str, Package]) -> Purl: """ package = packages[package_id] introduced_by = Core.get_source_data(package, packages) - purl = Purl( + return Purl( id=package.id, name=package.name, version=package.version, @@ -2643,10 +2572,8 @@ def create_purl(self, package_id: str, packages: dict[str, Package]) -> Purl: transitives=package.transitives, url=package.url, purl=package.purl, - scores=package.score + scores=package.score, ) - return purl - @staticmethod def get_source_data(package: Package, packages: dict) -> list: @@ -2708,12 +2635,10 @@ def add_purl_capabilities(diff: Diff) -> None: for attribute in ("new_packages", "updated_packages"): packages = [] for purl in getattr(diff, attribute): + updated = purl if purl.id in diff.new_capabilities: - purl = Purl( - **{**purl.__dict__, - "capabilities": diff.new_capabilities[purl.id]} - ) - packages.append(purl) + updated = Purl(**{**purl.__dict__, "capabilities": diff.new_capabilities[purl.id]}) + packages.append(updated) setattr(diff, attribute, packages) def add_package_alerts_to_collection(self, package: Package, alerts_collection: dict, packages: dict) -> dict: @@ -2728,12 +2653,9 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: Returns: Updated alerts collection dictionary """ - default_props = type('EmptyProps', (), { - 'description': "", - 'title': "", - 'suggestion': "", - 'nextStepTitle': "" - })() + default_props = type( + "EmptyProps", (), {"description": "", "title": "", "suggestion": "", "nextStepTitle": ""} + )() for alert_item in package.alerts: alert = Alert(**alert_item) @@ -2752,7 +2674,7 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: title = "License Policy Violation" if not title: title = _humanize_alert_type(alert.type) - + issue_alert = Issue( pkg_type=package.type, pkg_name=package.name, @@ -2770,12 +2692,12 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: manifest_files=package.manifestFiles or [], direct=bool(package.direct), purl=package.purl, - url=package.url + url=package.url, ) # Use action from API (from security policy, label policy, triage, etc.) - if 'action' in alert_item and alert_item['action']: - action = alert_item['action'] + if alert_item.get("action"): + action = alert_item["action"] setattr(issue_alert, action, True) if issue_alert.key not in alerts_collection: @@ -2800,12 +2722,12 @@ def save_file(file_name: str, content: str) -> None: try: with open(file_name, "w") as f: f.write(content) - except IOError as e: + except OSError as e: log.error(f"Failed to save file {file_name}: {e}") raise @staticmethod - def get_capabilities_for_added_packages(added_packages: Dict[str, Package]) -> Dict[str, List[str]]: + def get_capabilities_for_added_packages(added_packages: dict[str, Package]) -> dict[str, list[str]]: """ Maps added packages to their capabilities based on their alerts. @@ -2815,7 +2737,7 @@ def get_capabilities_for_added_packages(added_packages: Dict[str, Package]) -> D Returns: Dictionary mapping package IDs to their capability lists """ - capabilities: Dict[str, List[str]] = {} + capabilities: dict[str, list[str]] = {} for package_id, package in added_packages.items(): for alert in package.alerts: @@ -2831,10 +2753,10 @@ def get_capabilities_for_added_packages(added_packages: Dict[str, Package]) -> D @staticmethod def get_new_alerts( - added_package_alerts: Dict[str, List[Issue]], - removed_package_alerts: Dict[str, List[Issue]], - ignore_readded: bool = True - ) -> List[Issue]: + added_package_alerts: dict[str, list[Issue]], + removed_package_alerts: dict[str, list[Issue]], + ignore_readded: bool = True, + ) -> list[Issue]: """ Find alerts that are new or changed between added and removed packages. @@ -2846,7 +2768,7 @@ def get_new_alerts( Returns: List of newly found alerts """ - alerts: List[Issue] = [] + alerts: list[Issue] = [] consolidated_alerts = set() for alert_key in added_package_alerts: @@ -2856,10 +2778,9 @@ def get_new_alerts( # Consolidate by package and alert type, not by manifest details alert_str = f"{alert.purl},{alert.type}" - if alert.error or alert.warn: - if alert_str not in consolidated_alerts: - alerts.append(alert) - consolidated_alerts.add(alert_str) + if (alert.error or alert.warn) and alert_str not in consolidated_alerts: + alerts.append(alert) + consolidated_alerts.add(alert_str) else: new_alerts = added_package_alerts[alert_key] removed_alerts = removed_package_alerts[alert_key] @@ -2872,17 +2793,18 @@ def get_new_alerts( # 1. Alert isn't in removed packages (or we're not ignoring readded alerts) # 2. We haven't already recorded this alert # 3. It's an error or warning - if (not ignore_readded or alert not in removed_alerts) and alert_str not in consolidated_alerts: - if alert.error or alert.warn: - alerts.append(alert) - consolidated_alerts.add(alert_str) + if ( + (not ignore_readded or alert not in removed_alerts) + and alert_str not in consolidated_alerts + and (alert.error or alert.warn) + ): + alerts.append(alert) + consolidated_alerts.add(alert_str) return alerts @staticmethod - def get_unchanged_alerts( - unchanged_package_alerts: Dict[str, List[Issue]] - ) -> List[Issue]: + def get_unchanged_alerts(unchanged_package_alerts: dict[str, list[Issue]]) -> list[Issue]: """ Extract all alerts from unchanged packages that are errors or warnings. @@ -2895,7 +2817,7 @@ def get_unchanged_alerts( Returns: List of all error/warning alerts from unchanged packages """ - alerts: List[Issue] = [] + alerts: list[Issue] = [] consolidated_alerts = set() for alert_key in unchanged_package_alerts: @@ -2911,9 +2833,7 @@ def get_unchanged_alerts( return alerts @staticmethod - def get_removed_alerts( - removed_package_alerts: Dict[str, List[Issue]] - ) -> List[Issue]: + def get_removed_alerts(removed_package_alerts: dict[str, list[Issue]]) -> list[Issue]: """ Extract all alerts from removed packages. @@ -2925,7 +2845,7 @@ def get_removed_alerts( Returns: List of all alerts from removed packages """ - alerts: List[Issue] = [] + alerts: list[Issue] = [] consolidated_alerts = set() for alert_key in removed_package_alerts: diff --git a/socketsecurity/core/alert_selection.py b/socketsecurity/core/alert_selection.py index 132be294..646a2a36 100644 --- a/socketsecurity/core/alert_selection.py +++ b/socketsecurity/core/alert_selection.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any from socketsecurity.core.classes import Diff, Issue from socketsecurity.core.helper.socket_facts_loader import ( @@ -11,7 +11,7 @@ from socketsecurity.core.messages import Messages -def select_diff_alerts(diff: Diff, strict_blocking: bool = False) -> List[Issue]: +def select_diff_alerts(diff: Diff, strict_blocking: bool = False) -> list[Issue]: """Select diff alerts for output rendering. In strict blocking mode, include unchanged alerts so rendered output aligns @@ -23,7 +23,7 @@ def select_diff_alerts(diff: Diff, strict_blocking: bool = False) -> List[Issue] return selected -def clone_diff_with_selected_alerts(diff: Diff, selected_alerts: List[Issue]) -> Diff: +def clone_diff_with_selected_alerts(diff: Diff, selected_alerts: list[Issue]) -> Diff: """Clone a diff object while replacing new_alerts with selected alerts.""" selected_diff = Diff( new_alerts=selected_alerts, @@ -43,9 +43,9 @@ def clone_diff_with_selected_alerts(diff: Diff, selected_alerts: List[Issue]) -> def load_components_with_alerts( - target_path: Optional[str], - reach_output_file: Optional[str], -) -> Optional[List[Dict[str, Any]]]: + target_path: str | None, + reach_output_file: str | None, +) -> list[dict[str, Any]] | None: facts_file = reach_output_file or ".socket.facts.json" facts_file_path = str(Path(target_path or ".") / facts_file) facts_data = load_socket_facts(facts_file_path) @@ -60,9 +60,7 @@ def _normalize_purl(purl: str) -> str: if not purl: return "" normalized = purl.strip().lower().replace("%40", "@") - if normalized.startswith("pkg:"): - normalized = normalized[4:] - return normalized + return normalized.removeprefix("pkg:") def _normalize_vuln_id(vuln_id: str) -> str: @@ -71,7 +69,7 @@ def _normalize_vuln_id(vuln_id: str) -> str: return vuln_id.strip().upper() -def _normalize_pkg_key(pkg_type: str, pkg_name: str, pkg_version: str) -> Tuple[str, str, str]: +def _normalize_pkg_key(pkg_type: str, pkg_name: str, pkg_version: str) -> tuple[str, str, str]: return ( (pkg_type or "").strip().lower(), (pkg_name or "").strip().lower(), @@ -79,8 +77,8 @@ def _normalize_pkg_key(pkg_type: str, pkg_name: str, pkg_version: str) -> Tuple[ ) -def _extract_issue_vuln_ids(issue: Issue) -> Set[str]: - ids: Set[str] = set() +def _extract_issue_vuln_ids(issue: Issue) -> set[str]: + ids: set[str] = set() props = getattr(issue, "props", None) or {} for key in ("ghsaId", "ghsa_id", "cveId", "cve_id"): value = props.get(key) @@ -95,7 +93,7 @@ def _is_potentially_reachable(reachability: str, undeterminable: bool = False) - return normalized in potential_states or undeterminable -def _matches_selector(states: Set[str], selector: str) -> bool: +def _matches_selector(states: set[str], selector: str) -> bool: selected = (selector or "all").strip().lower() if selected == "all": return True @@ -110,14 +108,37 @@ def _matches_selector(states: Set[str], selector: str) -> bool: return True +def _index_reachability( + container: dict[Any, dict[str, set[str]]], + key: Any, + vuln_ids: set[str], + reachability: str, +) -> None: + if key not in container: + container[key] = {} + vuln_key = next(iter(vuln_ids)) if len(vuln_ids) == 1 else "*" + if vuln_key not in container[key]: + container[key][vuln_key] = set() + container[key][vuln_key].add(reachability) + if vuln_ids and vuln_key == "*": + for vuln_id in vuln_ids: + if vuln_id not in container[key]: + container[key][vuln_id] = set() + container[key][vuln_id].add(reachability) + if not vuln_ids: + if "*" not in container[key]: + container[key]["*"] = set() + container[key]["*"].add(reachability) + + def _build_reachability_index( - components_with_alerts: Optional[List[Dict[str, Any]]], -) -> Optional[Tuple[Dict[str, Dict[str, Set[str]]], Dict[Tuple[str, str, str], Dict[str, Set[str]]]]]: + components_with_alerts: list[dict[str, Any]] | None, +) -> tuple[dict[str, dict[str, set[str]]], dict[tuple[str, str, str], dict[str, set[str]]]] | None: if not components_with_alerts: return None - by_purl: Dict[str, Dict[str, Set[str]]] = {} - by_pkg: Dict[Tuple[str, str, str], Dict[str, Set[str]]] = {} + by_purl: dict[str, dict[str, set[str]]] = {} + by_pkg: dict[tuple[str, str, str], dict[str, set[str]]] = {} for component in components_with_alerts: component_alerts = component.get("alerts", []) @@ -126,7 +147,7 @@ def _build_reachability_index( namespace = (component.get("namespace") or "").strip() name = (component.get("name") or component.get("id") or "").strip() - pkg_names: Set[str] = {name} + pkg_names: set[str] = {name} if namespace: pkg_names.add(f"{namespace}/{name}") @@ -140,39 +161,22 @@ def _build_reachability_index( vuln_ids = {v for v in vuln_ids if v} purl = _normalize_purl(props.get("purl", "")) - def _add(container: Dict[Any, Dict[str, Set[str]]], key: Any) -> None: - if key not in container: - container[key] = {} - vuln_key = next(iter(vuln_ids)) if len(vuln_ids) == 1 else "*" - if vuln_key not in container[key]: - container[key][vuln_key] = set() - container[key][vuln_key].add(reachability) - if vuln_ids and vuln_key == "*": - for vuln_id in vuln_ids: - if vuln_id not in container[key]: - container[key][vuln_id] = set() - container[key][vuln_id].add(reachability) - if not vuln_ids: - if "*" not in container[key]: - container[key]["*"] = set() - container[key]["*"].add(reachability) - if purl: - _add(by_purl, purl) + _index_reachability(by_purl, purl, vuln_ids, reachability) for pkg_name in pkg_names: pkg_key = _normalize_pkg_key(pkg_type, pkg_name, pkg_version) - _add(by_pkg, pkg_key) + _index_reachability(by_pkg, pkg_key, vuln_ids, reachability) return by_purl, by_pkg def _alert_reachability_states( alert: Issue, - by_purl: Dict[str, Dict[str, Set[str]]], - by_pkg: Dict[Tuple[str, str, str], Dict[str, Set[str]]], -) -> Set[str]: - states: Set[str] = set() + by_purl: dict[str, dict[str, set[str]]], + by_pkg: dict[tuple[str, str, str], dict[str, set[str]]], +) -> set[str]: + states: set[str] = set() alert_ids = _extract_issue_vuln_ids(alert) alert_purl = _normalize_purl(getattr(alert, "purl", "")) pkg_key = _normalize_pkg_key( @@ -181,8 +185,8 @@ def _alert_reachability_states( getattr(alert, "pkg_version", ""), ) - def _collect(index: Dict[Any, Dict[str, Set[str]]], key: Any) -> Set[str]: - found: Set[str] = set() + def _collect(index: dict[Any, dict[str, set[str]]], key: Any) -> set[str]: + found: set[str] = set() mapping = index.get(key, {}) if not mapping: return found @@ -206,13 +210,13 @@ def _collect(index: Dict[Any, Dict[str, Set[str]]], key: Any) -> Set[str]: def filter_alerts_by_reachability( - alerts: List[Issue], + alerts: list[Issue], selector: str, - target_path: Optional[str], - reach_output_file: Optional[str], - logger: Optional[logging.Logger] = None, + target_path: str | None, + reach_output_file: str | None, + logger: logging.Logger | None = None, fallback_to_blocking_for_reachable: bool = True, -) -> List[Issue]: +) -> list[Issue]: """ Filter issue alerts by reachability selector using .socket.facts.json data. @@ -233,7 +237,7 @@ def filter_alerts_by_reachability( return [] by_purl, by_pkg = reachability_index - filtered: List[Issue] = [] + filtered: list[Issue] = [] for alert in alerts: states = _alert_reachability_states(alert, by_purl, by_pkg) if _matches_selector(states, normalized_selector): diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index fd8fabcd..d9d761c1 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -1,7 +1,7 @@ import json import logging from dataclasses import dataclass, field -from typing import Dict, List, Optional, TypedDict +from typing import TypedDict from socketdev.fullscans import ( FullScanMetadata, @@ -20,22 +20,23 @@ NAMESPACE_REQUIRED_TYPES = frozenset({"maven"}) __all__ = [ - "Report", - "Score", - "Package", - "Issue", - "YamlFile", "Alert", - "FullScan", - "Repository", + "Comment", "Diff", + "FullScan", + "Issue", + "Package", "Purl", - "Comment" + "Report", + "Repository", + "Score", + "YamlFile", ] + class Report: """Represents a Socket Security scan report for a repository.""" - + branch: str commit: str id: str @@ -54,20 +55,20 @@ def __init__(self, **kwargs): setattr(self, key, value) if not hasattr(self, "processed"): self.processed = False - if hasattr(self, "pull_requests"): - if self.pull_requests is not None: - self.pull_requests = json.loads(str(self.pull_requests)) + if hasattr(self, "pull_requests") and self.pull_requests is not None: + self.pull_requests = json.loads(str(self.pull_requests)) def __str__(self): return json.dumps(self.__dict__) + class Score: """ Represents Socket Security scores for a package or repository. - + All scores are normalized to 0-100 range, converting from 0-1 if needed. """ - + supplyChain: float quality: float maintenance: float @@ -91,7 +92,7 @@ def __str__(self): def to_dict(self) -> dict: """ Convert Score object to dictionary with default values. - + Returns: Dictionary containing all score values, defaulting to 0 if not set """ @@ -101,44 +102,47 @@ def to_dict(self) -> dict: "maintenance": self.maintenance if hasattr(self, "maintenance") else 0, "license": self.license if hasattr(self, "license") else 0, "overall": self.overall if hasattr(self, "overall") else 0, - "vulnerability": self.vulnerability if hasattr(self, "vulnerability") else 0 + "vulnerability": self.vulnerability if hasattr(self, "vulnerability") else 0, } + class AlertCounts(TypedDict): """Type definition for counting alerts by severity level.""" + critical: int high: int middle: int low: int + @dataclass(kw_only=True) -class Package(): +class Package: """ Represents a package detected in a Socket Security scan. - + Inherits from SocketArtifactLink to maintain connection to dependency tree. Adds additional fields for package-specific information. """ - + # Common properties from both artifact types type: str name: str version: str - release: Optional[str] = None - diffType: Optional[str] = None + release: str | None = None + diffType: str | None = None id: str - author: List[str] = field(default_factory=list) + author: list[str] = field(default_factory=list) score: SocketScore - alerts: List[SocketAlert] - size: Optional[int] = None - license: Optional[str] = None - namespace: Optional[str] = None - topLevelAncestors: Optional[List[str]] = None - direct: Optional[bool] = False - manifestFiles: Optional[List[SocketManifestReference]] = None - dependencies: Optional[List[str]] = None - artifact: Optional[SocketArtifactLink] = None - + alerts: list[SocketAlert] + size: int | None = None + license: str | None = None + namespace: str | None = None + topLevelAncestors: list[str] | None = None + direct: bool | None = False + manifestFiles: list[SocketManifestReference] | None = None + dependencies: list[str] | None = None + artifact: SocketArtifactLink | None = None + # Package-specific fields license_text: str = "" purl: str = "" @@ -146,9 +150,8 @@ class Package(): url: str = "" # Artifact-specific fields - licenseDetails: Optional[list] = None - licenseAttrib: Optional[List] = None - + licenseDetails: list | None = None + licenseAttrib: list | None = None @staticmethod def normalize_type(package_type) -> str: @@ -161,7 +164,7 @@ def normalize_type(package_type) -> str: return getattr(package_type, "value", package_type) @staticmethod - def socket_url(package_type, namespace: Optional[str], name: str, version: str) -> str: + def socket_url(package_type, namespace: str | None, name: str, version: str) -> str: """ Builds the socket.dev package overview URL for a package. @@ -191,10 +194,10 @@ def socket_url(package_type, namespace: Optional[str], name: str, version: str) def from_socket_artifact(cls, data: dict) -> "Package": """ Create a Package from a SocketArtifact dictionary. - + Args: data: Dictionary containing SocketArtifact data - + Returns: New Package instance """ @@ -222,20 +225,20 @@ def from_socket_artifact(cls, data: dict) -> "Package": artifact=data.get("artifact"), purl=purl, url=url, - namespace=namespace or None + namespace=namespace or None, ) @classmethod def from_diff_artifact(cls, data: dict) -> "Package": """ Create a Package from a DiffArtifact dictionary. - + Args: data: Dictionary containing DiffArtifact data - + Returns: New Package instance - + Raises: ValueError: If reference data cannot be found in DiffArtifact """ @@ -289,12 +292,13 @@ def from_diff_artifact(cls, data: dict) -> "Package": manifestFiles=ref.get("manifestFiles", []), dependencies=ref.get("dependencies"), artifact=ref.get("artifact"), - namespace=data.get('namespace', None), + namespace=data.get("namespace"), release=ref.get("release", None), diffType=ref.get("diffType", diff_type), ) -class Issue: + +class Issue: # noqa: PLW1641 pkg_type: str pkg_name: str pkg_version: str @@ -323,13 +327,13 @@ class Issue: manifest_files: list direct: bool - def __init__(self, **kwargs): + def __init__(self, **kwargs): # noqa: C901 if kwargs: for key, value in kwargs.items(): setattr(self, key, value) if hasattr(self, "created_at"): - self.created_at = self.created_at.strip(" (Coordinated Universal Time)") + self.created_at = self.created_at.removesuffix(" (Coordinated Universal Time)") if not hasattr(self, "manifest_files"): self.manifest_files = [] if not hasattr(self, "direct"): @@ -342,7 +346,7 @@ def __init__(self, **kwargs): self.introduced_by = [] else: for item in self.introduced_by: - pkg, manifest = item + _pkg, manifest = item self.manifests += f"{manifest};" self.manifests = self.manifests.rstrip(";") if not hasattr(self, "error"): @@ -367,10 +371,10 @@ def __ne__(self, other): class YamlFile: """ Represents a YAML configuration file with associated alerts. - + Stores metadata about the file and any security alerts found during scanning. """ - + path: str name: str team: list @@ -394,19 +398,17 @@ def __str__(self): issue: Issue issue = self.alerts[issue_key]["issue"] manifests = self.alerts[issue_key]["manifests"] - new_alert = { - "issue": json.loads(str(issue)), - "manifests": manifests - } + new_alert = {"issue": json.loads(str(issue)), "manifests": manifests} alerts[issue_key] = new_alert dump_object = self dump_object.alerts = alerts return json.dumps(dump_object.__dict__) + class Alert: """Represents a security alert with its type, severity, and associated properties.""" - + key: str type: str severity: str @@ -427,10 +429,10 @@ def __str__(self): class FullScan(FullScanMetadata): """ Represents a complete Socket Security scan of a repository. - + Inherits from FullScanMetadata and adds fields for SBOM artifacts and package data. """ - + sbom_artifacts: list[SocketArtifact] packages: dict[str, Package] @@ -447,7 +449,7 @@ def __str__(self): class Repository: """Represents a source code repository with its metadata and scan results.""" - + id: str created_at: str updated_at: str @@ -467,13 +469,14 @@ def __init__(self, **kwargs): def __str__(self): return json.dumps(self.__dict__) + class Purl: """ Represents a Package URL (PURL) with extended metadata. - + Includes package identification, authorship, and dependency information. """ - + id: str name: str version: str @@ -483,7 +486,7 @@ class Purl: size: int transitives: int introduced_by: list - capabilities: List[str] + capabilities: list[str] is_new: bool author_url: str url: str @@ -508,11 +511,11 @@ def __init__(self, **kwargs): def generate_author_data(authors: list, ecosystem: str) -> str: """ Creates markdown-formatted links to author profiles. - + Args: authors: List of author names ecosystem: Package ecosystem (npm, pypi, etc.) - + Returns: Comma-separated string of markdown links to author profiles """ @@ -520,8 +523,7 @@ def generate_author_data(authors: list, ecosystem: str) -> str: for author in authors: author_url = f"https://socket.dev/{ecosystem}/user/{author}" authors_str += f"[{author}]({author_url})," - authors_str = authors_str.rstrip(",") - return authors_str + return authors_str.rstrip(",") def __str__(self): return json.dumps(self.__dict__) @@ -529,7 +531,7 @@ def __str__(self): def to_dict(self) -> dict: """ Convert Purl object to a dictionary representation. - + Returns: Dictionary containing all Purl attributes """ @@ -547,23 +549,23 @@ def to_dict(self) -> dict: "is_new": self.is_new, "author_url": self.author_url, "url": self.url, - "purl": self.purl + "purl": self.purl, } class Diff: """ Represents differences between two Socket Security scans. - + Tracks changes in packages, capabilities, and security alerts between scans. """ - + new_packages: list[Purl] updated_packages: list[Purl] removed_packages: list[Purl] replaced_packages: list[Purl] packages: dict[str, Package] - new_capabilities: Dict[str, List[str]] + new_capabilities: dict[str, list[str]] new_alerts: list[Issue] unchanged_alerts: list[Issue] removed_alerts: list[Issue] @@ -575,7 +577,7 @@ class Diff: is_full_scan: bool alerts_fetched: bool - def __init__(self, **kwargs): + def __init__(self, **kwargs): # noqa: C901 if kwargs: for key, value in kwargs.items(): setattr(self, key, value) @@ -606,7 +608,7 @@ def __str__(self): def to_dict(self) -> dict: """ Convert Diff object to a dictionary representation. - + Returns: Dictionary containing all Diff attributes with nested objects converted """ @@ -617,18 +619,23 @@ def to_dict(self) -> dict: "removed_packages": [p.to_dict() for p in self.removed_packages], "replaced_packages": [p.to_dict() for p in self.replaced_packages], "new_alerts": [alert.__dict__ for alert in self.new_alerts], - "unchanged_alerts": [alert.__dict__ for alert in self.unchanged_alerts] if hasattr(self, "unchanged_alerts") else [], - "removed_alerts": [alert.__dict__ for alert in self.removed_alerts] if hasattr(self, "removed_alerts") else [], + "unchanged_alerts": [alert.__dict__ for alert in self.unchanged_alerts] + if hasattr(self, "unchanged_alerts") + else [], + "removed_alerts": [alert.__dict__ for alert in self.removed_alerts] + if hasattr(self, "removed_alerts") + else [], "id": self.id, "sbom": self.sbom if hasattr(self, "sbom") else [], "packages": {k: v.to_dict() for k, v in self.packages.items()} if hasattr(self, "packages") else {}, "report_url": self.report_url if hasattr(self, "report_url") else None, - "diff_url": self.diff_url if hasattr(self, "diff_url") else None + "diff_url": self.diff_url if hasattr(self, "diff_url") else None, } + class Comment: """Represents a GitHub comment with its metadata and content.""" - + url: str html_url: str issue_url: str diff --git a/socketsecurity/core/cli_client.py b/socketsecurity/core/cli_client.py index afa13470..3fd3365d 100644 --- a/socketsecurity/core/cli_client.py +++ b/socketsecurity/core/cli_client.py @@ -1,7 +1,6 @@ import base64 import json import logging -from typing import Dict, List, Optional, Union import requests @@ -12,6 +11,7 @@ logger = logging.getLogger("socketdev") + class CliClient: def __init__(self, config: SocketConfig): self.config = config @@ -19,23 +19,23 @@ def __init__(self, config: SocketConfig): @staticmethod def _encode_key(token: str) -> str: - return base64.b64encode(f"{token}:".encode()).decode('ascii') + return base64.b64encode(f"{token}:".encode()).decode("ascii") def request( self, path: str, method: str = "GET", - headers: Optional[Dict] = None, - payload: Optional[Union[Dict, str]] = None, - files: Optional[List] = None, - base_url: Optional[str] = None + headers: dict | None = None, + payload: dict | str | None = None, + files: list | None = None, + base_url: str | None = None, ) -> requests.Response: url = f"{base_url or self.config.api_url}/{path}" default_headers = { - 'Authorization': f"Basic {self._encoded_key}", - 'User-Agent': USER_AGENT, - "accept": "application/json" + "Authorization": f"Basic {self._encoded_key}", + "User-Agent": USER_AGENT, + "accept": "application/json", } headers = headers or default_headers @@ -48,7 +48,7 @@ def request( data=payload, files=files, timeout=self.config.timeout, - verify=not self.config.allow_unverified_ssl + verify=not self.config.allow_unverified_ssl, ) response.raise_for_status() @@ -66,7 +66,7 @@ def request( raise APIFailure(f"Request failed: {detail}", status_code=status_code) from e @staticmethod - def _server_error_message(response: Optional[requests.Response]) -> Optional[str]: + def _server_error_message(response: requests.Response | None) -> str | None: """The explanation the Socket API gives in its ``{"error": {"message": ...}}`` body.""" if response is None: return None @@ -76,17 +76,17 @@ def _server_error_message(response: Optional[requests.Response]) -> Optional[str return None return message if isinstance(message, str) and message else None - def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None: + def post_telemetry_events(self, org_slug: str, events: list[dict]) -> None: """Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget โ€” logs errors but never raises.""" logger.debug(f"Sending {len(events)} telemetry event(s) to v0/orgs/{org_slug}/telemetry") for i, event in enumerate(events): try: - logger.debug(f"Telemetry event {i+1}/{len(events)}: {json.dumps(event)}") + logger.debug(f"Telemetry event {i + 1}/{len(events)}: {json.dumps(event)}") resp = self.request( path=f"orgs/{org_slug}/telemetry", method="POST", payload=json.dumps(event), ) - logger.debug(f"Telemetry event {i+1}/{len(events)} sent: status={resp.status_code}") + logger.debug(f"Telemetry event {i + 1}/{len(events)} sent: status={resp.status_code}") except Exception as e: - logger.warning(f"Failed to send telemetry event {i+1}/{len(events)}: {e}") + logger.warning(f"Failed to send telemetry event {i + 1}/{len(events)}: {e}") diff --git a/socketsecurity/core/cli_run.py b/socketsecurity/core/cli_run.py index cea3bfb4..234eda5e 100644 --- a/socketsecurity/core/cli_run.py +++ b/socketsecurity/core/cli_run.py @@ -17,7 +17,6 @@ import json import logging -from typing import Optional from .cli_client import CliClient @@ -27,8 +26,8 @@ def register_cli_run( client: CliClient, client_version: str, - upload_logs: Optional[bool], -) -> Optional[str]: + upload_logs: bool | None, +) -> str | None: """Register a CLI run with the backend. `upload_logs` is the user's tri-state preference (True / False / None); @@ -43,11 +42,13 @@ def register_cli_run( resp = client.request( path="python-cli-runs", method="POST", - payload=json.dumps({ - "client_version": client_version, - "share_logs": upload_logs is True, - "decline_logs": upload_logs is False, - }), + payload=json.dumps( + { + "client_version": client_version, + "share_logs": upload_logs is True, + "decline_logs": upload_logs is False, + } + ), ) body = resp.json() if not body.get("log_streaming_enabled"): @@ -67,7 +68,7 @@ def finalize_cli_run( client: CliClient, run_id: str, status: str = "success", - report_run_id: Optional[str] = None, + report_run_id: str | None = None, ) -> None: try: client.request( diff --git a/socketsecurity/core/exceptions.py b/socketsecurity/core/exceptions.py index b2112ee0..4f9621fa 100644 --- a/socketsecurity/core/exceptions.py +++ b/socketsecurity/core/exceptions.py @@ -1,23 +1,21 @@ from socketdev.exceptions import APIFailure as SdkAPIFailure __all__ = [ - "APIFailure", - "APIKeyMissing", "APIAccessDenied", + "APICloudflareError", + "APIFailure", "APIInsufficientQuota", + "APIKeyMissing", "APIResourceNotFound", - "APICloudflareError" ] class APICloudflareError(Exception): """Raised when there is an error using the API related to cloudflare""" - pass class APIKeyMissing(Exception): """Raised when the api key is not passed and the headers are empty""" - pass class APIFailure(SdkAPIFailure): @@ -28,23 +26,19 @@ class APIFailure(SdkAPIFailure): ``except APIFailure`` importing the SDK's -- which every handler in socketsecurity.core does -- and would not carry the SDK class's status code. """ - pass class APIAccessDenied(Exception): """Raised when access is denied to the API""" - pass class APIInsufficientQuota(Exception): """Raised when access is denied to the API""" - pass class APIResourceNotFound(Exception): """Raised when access is denied to the API""" - pass + class RequestTimeoutExceeded(Exception): """Raised when access is denied to the API""" - pass diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index 98dc4121..964d606f 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -19,7 +19,7 @@ class Git: path: str base_commit_sha: str | None - def __init__(self, path: str, base_commit_sha: str | None = None): + def __init__(self, path: str, base_commit_sha: str | None = None): # noqa: C901 """ Reads the repository state a scan is built from. @@ -36,14 +36,13 @@ def __init__(self, path: str, base_commit_sha: str | None = None): self._default_branch_name: str | None = None self.ensure_safe_directory(path) self.repo = Repo(path) - assert self.repo self.head = self.repo.head - + # Use CI environment SHA if available, otherwise fall back to current HEAD commit - github_sha = os.getenv('GITHUB_SHA') - gitlab_sha = os.getenv('CI_COMMIT_SHA') - bitbucket_sha = os.getenv('BITBUCKET_COMMIT') - buildkite_sha = os.getenv('BUILDKITE_COMMIT') + github_sha = os.getenv("GITHUB_SHA") + gitlab_sha = os.getenv("CI_COMMIT_SHA") + bitbucket_sha = os.getenv("BITBUCKET_COMMIT") + buildkite_sha = os.getenv("BUILDKITE_COMMIT") ci_commits = ( ("BUILDKITE_COMMIT", buildkite_sha), ("GITHUB_SHA", github_sha), @@ -54,7 +53,7 @@ def __init__(self, path: str, base_commit_sha: str | None = None): ((source, sha) for source, sha in ci_commits if sha), (None, None), ) - + if ci_sha: try: self.commit = self.repo.commit(ci_sha) @@ -62,54 +61,54 @@ def __init__(self, path: str, base_commit_sha: str | None = None): except Exception as error: log.debug(f"Failed to get commit from CI environment: {error}") # Use the actual current HEAD commit, not the head reference's commit - self.commit = self.repo.commit('HEAD') + self.commit = self.repo.commit("HEAD") log.debug(f"Using current HEAD commit: {self.commit.hexsha}") else: # Use the actual current HEAD commit, not the head reference's commit - self.commit = self.repo.commit('HEAD') + self.commit = self.repo.commit("HEAD") log.debug(f"Using current HEAD commit: {self.commit.hexsha}") - + log.debug(f"Final commit being used: {self.commit.hexsha}") log.debug(f"Commit author: {self.commit.author.name} <{self.commit.author.email}>") log.debug(f"Commit committer: {self.commit.committer.name} <{self.commit.committer.email}>") - + # Extract repository name from git remote, with fallback to default self.remote_url: str | None = None try: remote_url = self.repo.remotes.origin.url self.remote_url = remote_url - self.repo_name = remote_url.split('.git')[0].split('/')[-1] + self.repo_name = remote_url.split(".git")[0].split("/")[-1] log.debug(f"Repository name detected from git remote: {self.repo_name}") except Exception as error: log.debug(f"Failed to get repository name from git remote: {error}") self.repo_name = "socket-default-repo" log.debug(f"Using default repository name: {self.repo_name}") - + # Branch detection with priority: CI Variables -> Git Properties -> Default # Note: CLI arguments are handled in socketcli.py and take highest priority - + # First, try CI environment variables (most accurate in CI environments) ci_branch = None - + # GitLab CI variables - gitlab_branch = os.getenv('CI_COMMIT_BRANCH') or os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') - + gitlab_branch = os.getenv("CI_COMMIT_BRANCH") or os.getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") + # GitHub Actions variables - github_ref = os.getenv('GITHUB_REF') # e.g., 'refs/heads/main' + github_ref = os.getenv("GITHUB_REF") # e.g., 'refs/heads/main' github_branch = None - if github_ref and github_ref.startswith('refs/heads/'): - github_branch = github_ref.replace('refs/heads/', '') - + if github_ref and github_ref.startswith("refs/heads/"): + github_branch = github_ref.replace("refs/heads/", "") + # Bitbucket Pipelines variables - bitbucket_branch = os.getenv('BITBUCKET_BRANCH') + bitbucket_branch = os.getenv("BITBUCKET_BRANCH") # Buildkite branch (the source branch for pull-request builds) - buildkite_branch = os.getenv('BUILDKITE_BRANCH') - + buildkite_branch = os.getenv("BUILDKITE_BRANCH") + # Prefer the native environment when Buildkite is driving the job. This # also avoids requiring Buildkite users to emulate GitHub Actions vars. ci_branch = buildkite_branch or gitlab_branch or github_branch or bitbucket_branch - + if ci_branch: self.branch = ci_branch if buildkite_branch: @@ -128,34 +127,34 @@ def __init__(self, path: str, base_commit_sha: str | None = None): log.debug(f"Branch detected from git reference: {self.branch}") except Exception as error: log.debug(f"Failed to get branch from git reference: {error}") - + # Fallback: try to detect branch from git commands (works in detached HEAD) git_detected_branch = None try: # Try git name-rev first (most reliable for detached HEAD) - result = self.repo.git.name_rev('--name-only', 'HEAD') - if result and result != 'undefined': + result = self.repo.git.name_rev("--name-only", "HEAD") + if result and result != "undefined": # Strip name-rev suffix operators (~N, ^N, or combinations # like master~3^2). These characters are forbidden in git # ref names, so cutting at the first occurrence can never # truncate a real branch name. - result = re.split(r'[~^]', result, maxsplit=1)[0] + result = re.split(r"[~^]", result, maxsplit=1)[0] # Clean up the result (remove any prefixes like 'remotes/origin/') - git_detected_branch = result.split('/')[-1] + git_detected_branch = result.split("/")[-1] log.debug(f"Branch detected from git name-rev: {git_detected_branch}") except Exception as git_error: log.debug(f"git name-rev failed: {git_error}") - + if not git_detected_branch: try: # Fallback: try git describe --all --exact-match - result = self.repo.git.describe('--all', '--exact-match', 'HEAD') - if result and result.startswith('heads/'): - git_detected_branch = result.replace('heads/', '') + result = self.repo.git.describe("--all", "--exact-match", "HEAD") + if result and result.startswith("heads/"): + git_detected_branch = result.replace("heads/", "") log.debug(f"Branch detected from git describe: {git_detected_branch}") except Exception as git_error: log.debug(f"git describe failed: {git_error}") - + if git_detected_branch: self.branch = git_detected_branch log.debug(f"Branch detected from git commands: {self.branch}") @@ -175,15 +174,15 @@ def __init__(self, path: str, base_commit_sha: str | None = None): detected = False detection_source = "single-commit" - github_base_ref = os.getenv('GITHUB_BASE_REF') - github_head_ref = os.getenv('GITHUB_HEAD_REF') - github_event_name = os.getenv('GITHUB_EVENT_NAME') - github_before_sha = os.getenv('GITHUB_EVENT_BEFORE') # previous commit for push - github_sha = os.getenv('GITHUB_SHA') # current commit + github_base_ref = os.getenv("GITHUB_BASE_REF") + github_head_ref = os.getenv("GITHUB_HEAD_REF") + github_event_name = os.getenv("GITHUB_EVENT_NAME") + github_before_sha = os.getenv("GITHUB_EVENT_BEFORE") # previous commit for push + github_sha = os.getenv("GITHUB_SHA") # current commit - buildkite_pr = os.getenv('BUILDKITE_PULL_REQUEST') - buildkite_base_ref = os.getenv('BUILDKITE_PULL_REQUEST_BASE_BRANCH') - buildkite_head_ref = os.getenv('BUILDKITE_BRANCH') + buildkite_pr = os.getenv("BUILDKITE_PULL_REQUEST") + buildkite_base_ref = os.getenv("BUILDKITE_PULL_REQUEST_BASE_BRANCH") + buildkite_head_ref = os.getenv("BUILDKITE_BRANCH") # An explicitly supplied base commit states the comparison range outright, # so it is honored before any inference from CI environment variables. @@ -214,7 +213,7 @@ def __init__(self, path: str, base_commit_sha: str | None = None): ) if detected: detection_source = "buildkite-pr" - elif github_event_name == 'pull_request' and github_base_ref: + elif github_event_name == "pull_request" and github_base_ref: detected = self._detect_pull_request_changes( provider="GitHub", base_ref=github_base_ref, @@ -223,16 +222,16 @@ def __init__(self, path: str, base_commit_sha: str | None = None): if detected: detection_source = "github-pr" # Commits to default branch (push events) - elif github_event_name == 'push' and github_before_sha and github_sha: + elif github_event_name == "push" and github_before_sha and github_sha: try: - diff_files = self.repo.git.diff('--name-only', f'{github_before_sha}..{github_sha}') + diff_files = self.repo.git.diff("--name-only", f"{github_before_sha}..{github_sha}") self.show_files = diff_files.splitlines() log.debug(f"Changed files detected via git diff (GitHub push): {self.show_files}") detected = True detection_source = "github-push" except Exception as error: log.debug(f"Failed to get changed files via git diff (GitHub push): {error}") - elif github_event_name == 'push': + elif github_event_name == "push": try: self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() log.debug(f"Changed files detected via git show (GitHub push fallback): {self.show_files}") @@ -242,8 +241,8 @@ def __init__(self, path: str, base_commit_sha: str | None = None): log.debug(f"Failed to get changed files via git show (GitHub push fallback): {error}") # GitLab CI Merge Request context if not detected: - gitlab_target = os.getenv('CI_MERGE_REQUEST_TARGET_BRANCH_NAME') - gitlab_source = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') + gitlab_target = os.getenv("CI_MERGE_REQUEST_TARGET_BRANCH_NAME") + gitlab_source = os.getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") if gitlab_target and gitlab_source: detected = self._detect_pull_request_changes( provider="GitLab", @@ -254,9 +253,9 @@ def __init__(self, path: str, base_commit_sha: str | None = None): detection_source = "gitlab-mr" # Bitbucket Pipelines PR context if not detected: - bitbucket_pr_id = os.getenv('BITBUCKET_PR_ID') - bitbucket_source = os.getenv('BITBUCKET_BRANCH') - bitbucket_dest = os.getenv('BITBUCKET_PR_DESTINATION_BRANCH') + bitbucket_pr_id = os.getenv("BITBUCKET_PR_ID") + bitbucket_source = os.getenv("BITBUCKET_BRANCH") + bitbucket_dest = os.getenv("BITBUCKET_PR_DESTINATION_BRANCH") # BITBUCKET_BRANCH is the source branch in PR builds if bitbucket_pr_id and bitbucket_source and bitbucket_dest: detected = self._detect_pull_request_changes( @@ -298,14 +297,11 @@ def __init__(self, path: str, base_commit_sha: str | None = None): f"{time.perf_counter() - changed_files_start:.2f}s: " f"source={detection_source}, files={len(self.changed_files)}" ) - + # Determine if this commit is on the default branch # This considers both GitHub Actions detached HEAD and regular branch situations self.is_default_branch = self._is_commit_and_branch_default() - log.info( - "Git initialization completed in " - f"{time.perf_counter() - initialization_start:.2f}s" - ) + log.info(f"Git initialization completed in {time.perf_counter() - initialization_start:.2f}s") @staticmethod def _is_buildkite_pull_request(pull_request: str | None) -> bool: @@ -324,8 +320,8 @@ def _resolve_ref(self, ref: str | None) -> str | None: for candidate in candidates: try: return self.repo.commit(candidate).hexsha - except Exception: - continue + except Exception as error: + log.debug(f"Could not resolve git ref {candidate}: {error}") return None def _fetch_ref(self, ref: str, reason: str) -> str | None: @@ -354,11 +350,11 @@ def _fetch_ref(self, ref: str, reason: str) -> str | None: return None def _detect_pull_request_changes( - self, - provider: str, - base_ref: str, - head_ref: str | None, - use_merge_base: bool = True, + self, + provider: str, + base_ref: str, + head_ref: str | None, + use_merge_base: bool = True, ) -> bool: """Detect a base-to-head range locally, fetching only refs needed to complete it.""" base_commit = self._resolve_ref(base_ref) @@ -374,19 +370,20 @@ def _detect_pull_request_changes( try: diff_files = self.repo.git.diff("--name-only", diff_range) self.show_files = diff_files.splitlines() - log.debug( - f"Changed files detected via local git diff ({provider}): {self.show_files}" - ) + log.debug(f"Changed files detected via local git diff ({provider}): {self.show_files}") return True except Exception as local_error: log.debug(f"Local {provider} pull-request diff failed: {local_error}") # A shallow checkout can contain both tips but not their merge base. In # that case refresh only the two relevant branch histories and retry. - base_commit = self._fetch_ref( - base_ref, - f"{provider} pull-request history incomplete", - ) or base_commit + base_commit = ( + self._fetch_ref( + base_ref, + f"{provider} pull-request history incomplete", + ) + or base_commit + ) if head_ref: self._fetch_ref( head_ref, @@ -399,45 +396,40 @@ def _detect_pull_request_changes( f"{base_commit}{range_separator}{head_commit}", ) self.show_files = diff_files.splitlines() - log.debug( - f"Changed files detected after targeted fetch ({provider}): {self.show_files}" - ) + log.debug(f"Changed files detected after targeted fetch ({provider}): {self.show_files}") return True except Exception as retry_error: log.debug(f"Targeted {provider} pull-request diff failed: {retry_error}") return False - def _is_commit_and_branch_default(self) -> bool: + def _is_commit_and_branch_default(self) -> bool: # noqa: C901 """ Check if both the commit is on the default branch AND we're processing the default branch. This handles GitHub Actions detached HEAD state properly. - + Returns: True if commit is on default branch and we're processing the default branch """ try: - github_ref = os.getenv('GITHUB_REF') # e.g., 'refs/heads/main' or 'refs/pull/123/merge' - gitlab_branch = os.getenv('CI_COMMIT_BRANCH') - gitlab_mr_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') - bitbucket_branch = os.getenv('BITBUCKET_BRANCH') - bitbucket_pr = os.getenv('BITBUCKET_PR_ID') - buildkite_branch = os.getenv('BUILDKITE_BRANCH') - buildkite_pr = os.getenv('BUILDKITE_PULL_REQUEST') + github_ref = os.getenv("GITHUB_REF") # e.g., 'refs/heads/main' or 'refs/pull/123/merge' + gitlab_branch = os.getenv("CI_COMMIT_BRANCH") + gitlab_mr_branch = os.getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") + bitbucket_branch = os.getenv("BITBUCKET_BRANCH") + bitbucket_pr = os.getenv("BITBUCKET_PR_ID") + buildkite_branch = os.getenv("BUILDKITE_BRANCH") + buildkite_pr = os.getenv("BUILDKITE_PULL_REQUEST") # PR and non-branch builds cannot become the default branch head. # Decide that locally before default-branch lookup contacts origin. if buildkite_branch: if self._is_buildkite_pull_request(buildkite_pr): - log.debug( - f"Processing Buildkite pull request from branch: {buildkite_branch}, " - "not default branch" - ) + log.debug(f"Processing Buildkite pull request from branch: {buildkite_branch}, not default branch") return False elif github_ref: - if github_ref.startswith('refs/pull/'): + if github_ref.startswith("refs/pull/"): log.debug("Processing a pull request, not default branch") return False - if not github_ref.startswith('refs/heads/'): + if not github_ref.startswith("refs/heads/"): log.debug(f"Non-branch ref: {github_ref}, not default branch") return False elif gitlab_branch or gitlab_mr_branch: @@ -458,51 +450,53 @@ def _is_commit_and_branch_default(self) -> bool: default_branch_name = self.get_default_branch_name() is_default = buildkite_branch == default_branch_name log.debug( - f"Buildkite branch: {buildkite_branch}, Default: {default_branch_name}, " - f"Is default: {is_default}" + f"Buildkite branch: {buildkite_branch}, Default: {default_branch_name}, Is default: {is_default}" ) return is_default # Handle GitHub Actions - elif github_ref: + if github_ref: log.debug(f"GitHub ref: {github_ref}") - branch_from_ref = github_ref.removeprefix('refs/heads/') + branch_from_ref = github_ref.removeprefix("refs/heads/") default_branch_name = self.get_default_branch_name() is_default = branch_from_ref == default_branch_name - log.debug(f"Branch from GITHUB_REF: {branch_from_ref}, Default: {default_branch_name}, Is default: {is_default}") + log.debug( + f"Branch from GITHUB_REF: {branch_from_ref}, Default: {default_branch_name}, Is default: {is_default}" + ) return is_default - + # Handle GitLab CI - elif gitlab_branch or gitlab_mr_branch: + if gitlab_branch or gitlab_mr_branch: current_branch = gitlab_mr_branch or gitlab_branch default_branch_name = self.get_default_branch_name() is_default = current_branch == default_branch_name log.debug(f"GitLab branch: {current_branch}, Default: {default_branch_name}, Is default: {is_default}") return is_default - + # Handle Bitbucket Pipelines - elif bitbucket_branch: + if bitbucket_branch: default_branch_name = self.get_default_branch_name() is_default = bitbucket_branch == default_branch_name - log.debug(f"Bitbucket branch: {bitbucket_branch}, Default: {default_branch_name}, Is default: {is_default}") + log.debug( + f"Bitbucket branch: {bitbucket_branch}, Default: {default_branch_name}, Is default: {is_default}" + ) return is_default - else: - # Not in GitHub Actions, use local development logic - # For local development, we consider it "default branch" if: - # 1. Currently on the default branch, OR - # 2. The commit is reachable from the default branch (part of default branch history) - - is_on_default = self.is_on_default_branch() - if is_on_default: - log.debug("Currently on default branch locally") - return True - - # Even if on feature branch, if commit is on default branch, consider it default - # This handles cases where feature branch was created from or merged to default - is_commit_default = self.is_commit_on_default_branch() - log.debug(f"Not on default branch locally, but commit is on default branch: {is_commit_default}") - return is_commit_default - + # Not in GitHub Actions, use local development logic + # For local development, we consider it "default branch" if: + # 1. Currently on the default branch, OR + # 2. The commit is reachable from the default branch (part of default branch history) + + is_on_default = self.is_on_default_branch() + if is_on_default: + log.debug("Currently on default branch locally") + return True + + # Even if on feature branch, if commit is on default branch, consider it default + # This handles cases where feature branch was created from or merged to default + is_commit_default = self.is_commit_on_default_branch() + log.debug(f"Not on default branch locally, but commit is on default branch: {is_commit_default}") + return is_commit_default + except Exception as error: log.debug(f"Error determining if commit and branch are default: {error}") return False @@ -511,7 +505,7 @@ def _is_commit_and_branch_default(self) -> bool: def commit_str(self) -> str: """Return commit SHA as a string""" return self.commit.hexsha - + def get_formatted_committer(self) -> str: """ Get the committer in the preferred order: @@ -520,63 +514,63 @@ def get_formatted_committer(self) -> str: 3. Git username (extracted from email patterns like GitHub noreply) 4. Git email address 5. Git author name (fallback) - + Returns: Formatted committer string """ # Check for CI/CD environment usernames first # GitHub Actions - github_actor = os.getenv('GITHUB_ACTOR') + github_actor = os.getenv("GITHUB_ACTOR") if github_actor: log.debug(f"Using GitHub actor as committer: {github_actor}") return github_actor - + # GitLab CI - gitlab_user_login = os.getenv('GITLAB_USER_LOGIN') + gitlab_user_login = os.getenv("GITLAB_USER_LOGIN") if gitlab_user_login: log.debug(f"Using GitLab user login as committer: {gitlab_user_login}") return gitlab_user_login - + # Bitbucket Pipelines - bitbucket_step_triggerer_uuid = os.getenv('BITBUCKET_STEP_TRIGGERER_UUID') + bitbucket_step_triggerer_uuid = os.getenv("BITBUCKET_STEP_TRIGGERER_UUID") if bitbucket_step_triggerer_uuid: log.debug(f"Using Bitbucket step triggerer as committer: {bitbucket_step_triggerer_uuid}") return bitbucket_step_triggerer_uuid - + # Fall back to commit author/committer details # Priority 3: Try to extract git username from email patterns first if self.author and self.author.email and self.author.email.strip(): email = self.author.email.strip() - + # If it's a GitHub noreply email, try to extract username - if email.endswith('@users.noreply.github.com'): + if email.endswith("@users.noreply.github.com"): # Pattern: number+username@users.noreply.github.com - email_parts = email.split('@')[0] - if '+' in email_parts: - username = email_parts.split('+')[1] + email_parts = email.split("@")[0] + if "+" in email_parts: + username = email_parts.split("+")[1] log.debug(f"Extracted GitHub username from noreply email: {username}") return username - + # Priority 4: Use email if available if self.author and self.author.email and self.author.email.strip(): email = self.author.email.strip() log.debug(f"Using commit author email as committer: {email}") return email - + # Priority 5: Fall back to author name as last resort if self.author and self.author.name and self.author.name.strip(): name = self.author.name.strip() log.debug(f"Using commit author name as fallback committer: {name}") return name - + # Ultimate fallback log.debug("Using fallback committer: unknown") return "unknown" - + def _is_merge_commit(self) -> bool: """ Check if the current commit is a merge commit. - + Returns: True if this is a merge commit (has multiple parents), False otherwise """ @@ -588,14 +582,14 @@ def _is_merge_commit(self) -> bool: except Exception as error: log.debug(f"Error checking if commit is merge commit: {error}") return False - + def _detect_merge_commit_changes(self) -> bool: """ Detect changed files in a merge commit using git diff with parent. - + This method handles the case where git show --name-only doesn't work for merge commits (expected Git behavior). - + Returns: True if detection was successful, False otherwise """ @@ -603,15 +597,15 @@ def _detect_merge_commit_changes(self) -> bool: if not self._is_merge_commit(): log.debug("Not a merge commit, skipping merge commit detection") return False - + # For merge commits, we need to diff against a parent # We'll use the first parent (typically the target branch) if not self.commit.parents: log.debug("Merge commit has no parents - cannot perform merge-aware diff") return False - + parent_commit = self.commit.parents[0] - + # Verify parent commit is accessible try: parent_sha = parent_commit.hexsha @@ -620,26 +614,28 @@ def _detect_merge_commit_changes(self) -> bool: except Exception as parent_error: log.error(f"Cannot resolve parent commit {parent_sha}: {parent_error}") return False - + # Use git diff to show changes from parent to merge commit - diff_range = f'{parent_sha}..{self.commit.hexsha}' + diff_range = f"{parent_sha}..{self.commit.hexsha}" log.debug(f"Attempting merge commit diff: git diff --name-only {diff_range}") - - diff_files = self.repo.git.diff('--name-only', diff_range) + + diff_files = self.repo.git.diff("--name-only", diff_range) self.show_files = diff_files.splitlines() - + log.debug(f"Changed files detected via git diff (merge commit): {self.show_files}") - log.info(f"Changed file detection: method=merge-diff, source=merge-commit-fallback, files={len(self.show_files)}") + log.info( + f"Changed file detection: method=merge-diff, source=merge-commit-fallback, files={len(self.show_files)}" + ) return True - + except Exception as error: log.debug(f"Failed to detect merge commit changes: {error}") return False - + def get_default_branch_name(self) -> str: """ Get the default branch name from the remote origin. - + Returns: Default branch name (e.g., 'main', 'master') """ @@ -648,7 +644,7 @@ def get_default_branch_name(self) -> str: return self._default_branch_name def _detect_default_branch_name(self) -> str: - for variable in ('CI_DEFAULT_BRANCH', 'BUILDKITE_PIPELINE_DEFAULT_BRANCH'): + for variable in ("CI_DEFAULT_BRANCH", "BUILDKITE_PIPELINE_DEFAULT_BRANCH"): default_branch = os.getenv(variable) if default_branch: log.debug(f"Default branch detected from {variable}: {default_branch}") @@ -662,10 +658,7 @@ def _detect_default_branch_name(self) -> str: log.debug(f"Could not determine default branch from origin/HEAD: {error}") # CI checkouts such as actions/checkout fetch a single branch and leave no origin/HEAD. - default_branch = ( - self._default_branch_from_github_event() - or self._default_branch_from_remote() - ) + default_branch = self._default_branch_from_github_event() or self._default_branch_from_remote() if default_branch: return default_branch @@ -673,17 +666,17 @@ def _detect_default_branch_name(self) -> str: remote_refs = {str(ref) for ref in self.repo.remotes.origin.refs} except Exception: remote_refs = set() - for branch_name in ['main', 'master']: - if f'origin/{branch_name}' in remote_refs: + for branch_name in ["main", "master"]: + if f"origin/{branch_name}" in remote_refs: log.debug(f"Using fallback default branch: {branch_name}") return branch_name log.debug("Using final fallback default branch: main") - return 'main' + return "main" @staticmethod def _default_branch_from_github_event() -> str | None: - event_path = os.getenv('GITHUB_EVENT_PATH') + event_path = os.getenv("GITHUB_EVENT_PATH") if not event_path: return None try: @@ -717,10 +710,7 @@ def _default_branch_from_remote(self) -> str | None: output, _ = process.communicate(timeout=REMOTE_HEAD_TIMEOUT_SECONDS) except subprocess.TimeoutExpired: self._kill_process_tree(process) - log.debug( - f"Querying origin for its default branch timed out after " - f"{REMOTE_HEAD_TIMEOUT_SECONDS}s" - ) + log.debug(f"Querying origin for its default branch timed out after {REMOTE_HEAD_TIMEOUT_SECONDS}s") return None if process.returncode != 0: log.debug(f"Querying origin for its default branch exited with {process.returncode}") @@ -741,6 +731,7 @@ def _kill_process_tree(process: subprocess.Popen) -> None: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + check=False, ) else: os.killpg(process.pid, signal.SIGKILL) @@ -753,17 +744,17 @@ def _kill_process_tree(process: subprocess.Popen) -> None: process.wait(timeout=5) except subprocess.TimeoutExpired: log.debug("git ls-remote did not exit after being killed") - + def is_commit_on_default_branch(self) -> bool: """ Check if the current commit is reachable from the default branch. - + Returns: True if current commit is on the default branch, False otherwise """ try: default_branch = self.get_default_branch_name() - + # Get the default branch's HEAD commit try: # Try remote branch first @@ -772,31 +763,31 @@ def is_commit_on_default_branch(self) -> bool: except Exception: # Fallback to local branch try: - default_branch_ref = self.repo.heads[default_branch] + default_branch_ref = self.repo.heads[default_branch] default_branch_commit = default_branch_ref.commit except Exception: log.debug(f"Could not find default branch '{default_branch}' locally or remotely") return False - + # Check if current commit is the same as default branch HEAD if self.commit.hexsha == default_branch_commit.hexsha: log.debug("Current commit is the HEAD of the default branch") return True - + # Check if current commit is an ancestor of the default branch HEAD # This means the commit is reachable from the default branch is_ancestor = self.repo.is_ancestor(self.commit, default_branch_commit) log.debug(f"Current commit is ancestor of default branch: {is_ancestor}") return is_ancestor - + except Exception as error: log.debug(f"Error checking if commit is on default branch: {error}") return False - + def is_on_default_branch(self) -> bool: """ Check if we're currently on the default branch (not just if commit is reachable). - + Returns: True if currently on the default branch, False otherwise """ @@ -805,14 +796,16 @@ def is_on_default_branch(self) -> bool: if self.repo.head.is_detached: log.debug("In detached HEAD state, not on any branch") return False - + current_branch_name = self.repo.active_branch.name default_branch_name = self.get_default_branch_name() - + is_default = current_branch_name == default_branch_name - log.debug(f"Current branch: {current_branch_name}, Default branch: {default_branch_name}, Is default: {is_default}") + log.debug( + f"Current branch: {current_branch_name}, Default branch: {default_branch_name}, Is default: {is_default}" + ) return is_default - + except Exception as error: log.debug(f"Error checking if on default branch: {error}") return False @@ -820,18 +813,20 @@ def is_on_default_branch(self) -> bool: @staticmethod def ensure_safe_directory(path: str) -> None: # Ensure the repo is marked as safe for git (prevents SHA empty/dubious ownership errors) - try : + try: import subprocess + abs_path = os.path.abspath(path) # Get all safe directories - result = subprocess.run([ - "git", "config", "--global", "--get-all", "safe.directory" - ], capture_output=True, text=True) + result = subprocess.run( + ["git", "config", "--global", "--get-all", "safe.directory"], + capture_output=True, + text=True, + check=False, + ) safe_dirs = result.stdout.splitlines() if result.returncode == 0 else [] if abs_path not in safe_dirs: - subprocess.run([ - "git", "config", "--global", "--add", "safe.directory", abs_path - ], check=True) + subprocess.run(["git", "config", "--global", "--add", "safe.directory", abs_path], check=True) log.debug(f"Added {abs_path} to git safe.directory config.") else: log.debug(f"{abs_path} already present in git safe.directory config.") diff --git a/socketsecurity/core/git_remote.py b/socketsecurity/core/git_remote.py index eb5b9c02..637a4b25 100644 --- a/socketsecurity/core/git_remote.py +++ b/socketsecurity/core/git_remote.py @@ -5,8 +5,8 @@ GitHub comment adapter and pull request context resolution need to recover the slug from it, so the parsing lives here rather than in either caller. """ + import re -from typing import Optional, Tuple from urllib.parse import urlparse # git@host:owner/repo - the scp-like syntax urlparse cannot handle. The negative @@ -14,7 +14,7 @@ _SCP_LIKE_REMOTE = re.compile(r"^(?:[^@/]+@)?([^:/]+):(?!//)(.+)$") -def parse_git_remote(value: Optional[str]) -> Tuple[Optional[str], Optional[str]]: +def parse_git_remote(value: str | None) -> tuple[str | None, str | None]: """Split a git remote URL into its host and its repository path. Returns ``(host, path)``, or ``(None, None)`` when the value is not a usable @@ -26,8 +26,7 @@ def parse_git_remote(value: Optional[str]) -> Tuple[Optional[str], Optional[str] if not value: return None, None url = value.strip().rstrip("/") - if url.endswith(".git"): - url = url[:-4] + url = url.removesuffix(".git") match = _SCP_LIKE_REMOTE.match(url) if match: diff --git a/socketsecurity/core/helper/__init__.py b/socketsecurity/core/helper/__init__.py index 224f3cc7..0d8306d1 100644 --- a/socketsecurity/core/helper/__init__.py +++ b/socketsecurity/core/helper/__init__.py @@ -12,52 +12,45 @@ def parse_gfm_section(html_content): Parse a GitHub-Flavored Markdown section containing a table and surrounding content. Returns a dict with "before_html", "columns", "rows_html", and "after_html". """ - html = markdown.markdown(html_content, extensions=['extra']) + html = markdown.markdown(html_content, extensions=["extra"]) soup = BeautifulSoup(html, "html.parser") - table = soup.find('table') + table = soup.find("table") if not table: # If no table, treat entire content as before_html - return {"before_html": html, "columns": [], "rows_html": [], "after_html": ''} + return {"before_html": html, "columns": [], "rows_html": [], "after_html": ""} # Collect HTML before the table before_parts = [str(elem) for elem in table.find_previous_siblings()] - before_html = ''.join(reversed(before_parts)) + before_html = "".join(reversed(before_parts)) # Collect HTML after the table after_parts = [str(elem) for elem in table.find_next_siblings()] - after_html = ''.join(after_parts) + after_html = "".join(after_parts) # Extract table headers - headers = [th.get_text(strip=True) for th in table.find_all('th')] + headers = [th.get_text(strip=True) for th in table.find_all("th")] # Extract table rows (skip header) rows_html = [] - for tr in table.find_all('tr')[1:]: - cells = [str(td) for td in tr.find_all('td')] + for tr in table.find_all("tr")[1:]: + cells = [str(td) for td in tr.find_all("td")] rows_html.append(cells) - return { - "before_html": before_html, - "columns": headers, - "rows_html": rows_html, - "after_html": after_html - } + return {"before_html": before_html, "columns": headers, "rows_html": rows_html, "after_html": after_html} @staticmethod def parse_cell(html_td): """Convert a table cell HTML into plain text or a dict for links/images.""" soup = BeautifulSoup(html_td, "html.parser") - a = soup.find('a') + a = soup.find("a") if a: - cell = {"url": a.get('href', '')} - img = a.find('img') + cell = {"url": a.get("href", "")} + img = a.find("img") if img: - cell.update({ - "img_src": img.get('src', ''), - "title": img.get('title', ''), - "link_text": a.get_text(strip=True) - }) + cell.update( + {"img_src": img.get("src", ""), "title": img.get("title", ""), "link_text": a.get_text(strip=True)} + ) else: cell["link_text"] = a.get_text(strip=True) return cell @@ -72,7 +65,7 @@ def parse_html_parts(html_fragment): - {"link": "url", "text": "..."} - {"img_src": "url", "alt": "...", "title": "..."} """ - soup = BeautifulSoup(html_fragment, 'html.parser') + soup = BeautifulSoup(html_fragment, "html.parser") parts = [] def handle_element(elem): @@ -81,16 +74,14 @@ def handle_element(elem): if text and not all(ch in string.punctuation for ch in text): parts.append({"text": text}) elif isinstance(elem, Tag): - if elem.name == 'a': - href = elem.get('href', '') + if elem.name == "a": + href = elem.get("href", "") txt = elem.get_text(strip=True) parts.append({"link": href, "text": txt}) - elif elem.name == 'img': - parts.append({ - "img_src": elem.get('src', ''), - "alt": elem.get('alt', ''), - "title": elem.get('title', '') - }) + elif elem.name == "img": + parts.append( + {"img_src": elem.get("src", ""), "alt": elem.get("alt", ""), "title": elem.get("title", "")} + ) else: # Recurse into children for nested tags for child in elem.children: @@ -109,13 +100,13 @@ def section_to_json(section_result): """ # Build JSON rows for the table table_rows = [] - cols = section_result.get('columns', []) - for row_html in section_result.get('rows_html', []): + cols = section_result.get("columns", []) + for row_html in section_result.get("rows_html", []): cells = [Helper.parse_cell(cell_html) for cell_html in row_html] - table_rows.append(dict(zip(cols, cells))) + table_rows.append(dict(zip(cols, cells, strict=False))) return { - "before": Helper.parse_html_parts(section_result.get('before_html', '')), + "before": Helper.parse_html_parts(section_result.get("before_html", "")), "table": table_rows, - "after": Helper.parse_html_parts(section_result.get('after_html', '')) - } \ No newline at end of file + "after": Helper.parse_html_parts(section_result.get("after_html", "")), + } diff --git a/socketsecurity/core/helper/socket_facts_loader.py b/socketsecurity/core/helper/socket_facts_loader.py index 26c1ae25..b49943d2 100644 --- a/socketsecurity/core/helper/socket_facts_loader.py +++ b/socketsecurity/core/helper/socket_facts_loader.py @@ -4,30 +4,30 @@ import logging from copy import deepcopy from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any logger = logging.getLogger(__name__) -def load_socket_facts(file_path: str = ".socket.facts.json") -> Optional[Dict[str, Any]]: +def load_socket_facts(file_path: str = ".socket.facts.json") -> dict[str, Any] | None: """ Load a .socket.facts.json file into a dictionary. - + The .socket.facts.json file is generated by the Socket CLI reachability analysis - and contains component dependency information, vulnerability data, and + and contains component dependency information, vulnerability data, and reachability analysis results. - + Args: file_path: Path to the .socket.facts.json file. Defaults to ".socket.facts.json" in the current directory. - + Returns: Dict containing the parsed JSON data with keys like: - components: List of dependency components with vulnerabilities and reachability info - tier1ReachabilityScanId: The scan ID for this reachability analysis - + Returns None if the file doesn't exist or cannot be parsed. - + Example structure: { "components": [ @@ -48,31 +48,31 @@ def load_socket_facts(file_path: str = ".socket.facts.json") -> Optional[Dict[st } """ facts_path = Path(file_path) - + if not facts_path.exists(): logger.warning(f"Socket facts file not found: {file_path}") return None - + try: - with facts_path.open('r', encoding='utf-8') as f: + with facts_path.open("r", encoding="utf-8") as f: data = json.load(f) - + logger.debug(f"Successfully loaded socket facts from {file_path}") - + # Validate expected structure if not isinstance(data, dict): logger.warning(f"Socket facts file has unexpected format: expected dict, got {type(data)}") return None - - if 'components' not in data: + + if "components" not in data: logger.warning("Socket facts file missing 'components' key") - + return data - + except json.JSONDecodeError as e: logger.error(f"Failed to parse JSON from {file_path}: {e}") return None - except IOError as e: + except OSError as e: logger.error(f"Failed to read {file_path}: {e}") return None except Exception as e: @@ -80,293 +80,280 @@ def load_socket_facts(file_path: str = ".socket.facts.json") -> Optional[Dict[st return None -def get_components_with_vulnerabilities(facts_data: Dict[str, Any]) -> list: +def get_components_with_vulnerabilities(facts_data: dict[str, Any]) -> list: """ Extract components that have vulnerabilities from socket facts data. - + Note: The .socket.facts.json file contains 'vulnerabilities' and 'reachability' data separately. This function returns components that have vulnerabilities defined. - + Args: facts_data: Dictionary loaded from .socket.facts.json - + Returns: List of component dictionaries that have vulnerabilities """ - if not facts_data or 'components' not in facts_data: + if not facts_data or "components" not in facts_data: return [] - - components = facts_data.get('components', []) - components_with_vulns = [ - comp for comp in components - if comp.get('vulnerabilities') and len(comp.get('vulnerabilities', [])) > 0 - ] - - return components_with_vulns + components = facts_data.get("components", []) + return [comp for comp in components if comp.get("vulnerabilities") and len(comp.get("vulnerabilities", [])) > 0] -def get_scan_id(facts_data: Dict[str, Any]) -> Optional[str]: + +def get_scan_id(facts_data: dict[str, Any]) -> str | None: """ Extract the tier1ReachabilityScanId from socket facts data. - + Args: facts_data: Dictionary loaded from .socket.facts.json - + Returns: The scan ID string if present, None otherwise """ if not facts_data: return None - - scan_id = facts_data.get('tier1ReachabilityScanId') + + scan_id = facts_data.get("tier1ReachabilityScanId") return scan_id.strip() if scan_id else None -def _make_purl(component: Dict[str, Any]) -> str: +def _make_purl(component: dict[str, Any]) -> str: """Construct a package URL (purl) from a component entry.""" - pkg_type = component.get('type', '') - namespace = component.get('namespace', '') - name = component.get('name') or component.get('id', '') - version = component.get('version', '') - + pkg_type = component.get("type", "") + namespace = component.get("namespace", "") + name = component.get("name") or component.get("id", "") + version = component.get("version", "") + if not name: - return '' - + return "" + if namespace: # Percent-encode @ in namespace for purl spec compliance - ns_encoded = namespace.replace('@', '%40') + ns_encoded = namespace.replace("@", "%40") purl = f"pkg:{pkg_type}/{ns_encoded}/{name}" else: purl = f"pkg:{pkg_type}/{name}" - + if version: purl = f"{purl}@{version}" - + return purl -def _determine_reachability(vulnerability: Dict[str, Any], component: Dict[str, Any]) -> Dict[str, Any]: +def _determine_reachability(vulnerability: dict[str, Any], component: dict[str, Any]) -> dict[str, Any]: # noqa: C901 """ Determine the reachability state for a vulnerability on a component. - + Args: vulnerability: Vulnerability dict from component's vulnerabilities array component: Component dict containing reachability data - + Returns: Dict with keys: - type: 'reachable', 'unreachable', 'unknown', 'error', or 'not_applicable' - undeterminableReachability: bool - trace: list of formatted trace strings """ - result = { - 'type': 'unknown', - 'undeterminableReachability': False, - 'trace': [] - } - - vuln_id = vulnerability.get('ghsaId') or vulnerability.get('cveId') + result = {"type": "unknown", "undeterminableReachability": False, "trace": []} + + vuln_id = vulnerability.get("ghsaId") or vulnerability.get("cveId") if not vuln_id: return result - + # Check for undeterminable reachability in the vulnerability data - reach_data = vulnerability.get('reachabilityData') or {} - if reach_data.get('undeterminableReachability'): - result['undeterminableReachability'] = True - result['type'] = 'unknown' - + reach_data = vulnerability.get("reachabilityData") or {} + if reach_data.get("undeterminableReachability"): + result["undeterminableReachability"] = True + result["type"] = "unknown" + # Find matching reachability entry in component - reachability_list = component.get('reachability', []) + reachability_list = component.get("reachability", []) matched_reach = None - + for reach_entry in reachability_list: - if reach_entry.get('ghsa_id') == vuln_id: + if reach_entry.get("ghsa_id") == vuln_id: matched_reach = reach_entry break - + if not matched_reach: # No reachability data found for this vulnerability - if result['undeterminableReachability']: + if result["undeterminableReachability"]: return result # Check if this vulnerability applies to this component version - if 'reachabilityData' in vulnerability: + if "reachabilityData" in vulnerability: # Has reachability data structure but no match - might not apply - result['type'] = 'not_applicable' + result["type"] = "not_applicable" return result - + # Process reachability matches - reach_items = matched_reach.get('reachability', []) + reach_items = matched_reach.get("reachability", []) if not reach_items: return result - + # Take the first reachability entry (usually most relevant) reach_info = reach_items[0] - reach_type = reach_info.get('type', 'unknown') - result['type'] = reach_type - + reach_type = reach_info.get("type", "unknown") + result["type"] = reach_type + # Build trace for reachable vulnerabilities - if reach_type == 'reachable': - matches = reach_info.get('matches', []) + if reach_type == "reachable": + matches = reach_info.get("matches", []) for match_group in matches: if not match_group: continue - + for i, frame in enumerate(match_group): - pkg = frame.get('package', '') - src_loc = frame.get('sourceLocation', {}) - filename = src_loc.get('filename', '') - start = src_loc.get('start', {}) - line = start.get('line') - col = start.get('column') - end = src_loc.get('end', {}) - end_line = end.get('line') - end_col = end.get('column') - + pkg = frame.get("package", "") + src_loc = frame.get("sourceLocation", {}) + filename = src_loc.get("filename", "") + start = src_loc.get("start", {}) + line = start.get("line") + col = start.get("column") + end = src_loc.get("end", {}) + end_line = end.get("line") + end_col = end.get("column") + if i == 0: # First frame - use filename as primary if filename: loc = filename if line is not None: if end_line is not None and end_line != line: - loc = f"{filename} {line}:{col if col else ''}-{end_line}:{end_col if end_col else ''}" + loc = f"{filename} {line}:{col or ''}-{end_line}:{end_col or ''}" else: - loc = f"{filename} {line}:{col if col else ''}" - result['trace'].append(loc) - else: - # Subsequent frames - show package/module reference - if pkg or filename: - entry = pkg if pkg else filename - if line is not None: - entry = f" -> {entry} {line}:{col if col else ''}" - else: - entry = f" -> {entry}" - result['trace'].append(entry) - + loc = f"{filename} {line}:{col or ''}" + result["trace"].append(loc) + # Subsequent frames - show package/module reference + elif pkg or filename: + entry = pkg or filename + entry = f" -> {entry} {line}:{col or ''}" if line is not None else f" -> {entry}" + result["trace"].append(entry) + # Add final line showing the vulnerable component - comp_name = component.get('name') or component.get('id') - comp_ver = component.get('version') + comp_name = component.get("name") or component.get("id") + comp_ver = component.get("version") if comp_name: final_line = f" -> {comp_name}@{comp_ver}" if comp_ver else f" -> {comp_name}" - result['trace'].append(final_line) - + result["trace"].append(final_line) + return result -def convert_to_alerts(components: List[Dict[str, Any]]) -> List[Dict[str, Any]]: +def convert_to_alerts(components: list[dict[str, Any]]) -> list[dict[str, Any]]: # noqa: C901 """ Convert components with vulnerabilities into components with alerts. - - This function processes the raw .socket.facts.json format (with 'vulnerabilities' - and 'reachability' arrays) and converts them into an 'alerts' format suitable + + This function processes the raw .socket.facts.json format (with 'vulnerabilities' + and 'reachability' arrays) and converts them into an 'alerts' format suitable for formatters and notifications. - + Args: components: List of component dicts from .socket.facts.json - + Returns: List of component dicts with 'alerts' field added (original components unchanged) """ components_with_alerts = [] - + for comp in components: - vulns = comp.get('vulnerabilities', []) + vulns = comp.get("vulnerabilities", []) if not vulns: continue - + alerts = [] for vuln in vulns: - vuln_id = vuln.get('ghsaId') or vuln.get('cveId') or 'Unknown' - + vuln_id = vuln.get("ghsaId") or vuln.get("cveId") or "Unknown" + # Extract severity - sev_val = vuln.get('severity', '') - severity = 'unknown' - + sev_val = vuln.get("severity", "") + severity = "unknown" + # Handle both numeric and string severities try: if isinstance(sev_val, (int, float)): score = float(sev_val) if score >= 9.0: - severity = 'critical' + severity = "critical" elif score >= 7.0: - severity = 'high' + severity = "high" elif score >= 4.0: - severity = 'medium' + severity = "medium" else: - severity = 'low' + severity = "low" elif isinstance(sev_val, str): # Try to parse as number first - if sev_val.replace('.', '', 1).isdigit(): + if sev_val.replace(".", "", 1).isdigit(): score = float(sev_val) if score >= 9.0: - severity = 'critical' + severity = "critical" elif score >= 7.0: - severity = 'high' + severity = "high" elif score >= 4.0: - severity = 'medium' + severity = "medium" else: - severity = 'low' + severity = "low" else: # Use as-is if it's a string severity severity = sev_val.lower() except (ValueError, TypeError): - severity = 'unknown' - + severity = "unknown" + # Determine reachability reach_info = _determine_reachability(vuln, comp) - + # Skip vulnerabilities that don't apply to this component version - if reach_info.get('type') == 'not_applicable': + if reach_info.get("type") == "not_applicable": continue - + # Build alert purl = _make_purl(comp) - trace_str = '\n'.join(reach_info.get('trace', [])) - reach_type = reach_info.get('type', 'unknown') - + trace_str = "\n".join(reach_info.get("trace", [])) + reach_type = reach_info.get("type", "unknown") + # Map reachability to severity (reachable = critical, unknown/error = high, unreachable = low) final_severity = severity - if reach_type == 'reachable': - final_severity = 'critical' - elif reach_type in ('unknown', 'error') or reach_info.get('undeterminableReachability'): - final_severity = 'high' - elif reach_type == 'unreachable': - final_severity = 'low' - + if reach_type == "reachable": + final_severity = "critical" + elif reach_type in ("unknown", "error") or reach_info.get("undeterminableReachability"): + final_severity = "high" + elif reach_type == "unreachable": + final_severity = "low" + alert = { - 'title': vuln_id, - 'severity': final_severity, - 'type': 'vulnerability', - 'category': 'vulnerability', - 'props': { - 'cveId': vuln.get('cveId'), - 'ghsaId': vuln.get('ghsaId'), - 'range': vuln.get('range'), - 'purl': purl, - 'reachability': reach_type, - 'undeterminableReachability': reach_info.get('undeterminableReachability', False), - 'trace': trace_str, - 'severity': final_severity, - 'original_severity': severity, - } + "title": vuln_id, + "severity": final_severity, + "type": "vulnerability", + "category": "vulnerability", + "props": { + "cveId": vuln.get("cveId"), + "ghsaId": vuln.get("ghsaId"), + "range": vuln.get("range"), + "purl": purl, + "reachability": reach_type, + "undeterminableReachability": reach_info.get("undeterminableReachability", False), + "trace": trace_str, + "severity": final_severity, + "original_severity": severity, + }, } alerts.append(alert) - + if alerts: # Create a copy with alerts added comp_with_alerts = deepcopy(comp) - comp_with_alerts['alerts'] = alerts + comp_with_alerts["alerts"] = alerts components_with_alerts.append(comp_with_alerts) - + return components_with_alerts -def get_component_count(facts_data: Dict[str, Any]) -> Dict[str, int]: +def get_component_count(facts_data: dict[str, Any]) -> dict[str, int]: """ Get statistics about components in the socket facts data. - + Args: facts_data: Dictionary loaded from .socket.facts.json - + Returns: Dictionary with counts: - total: Total number of components @@ -374,14 +361,14 @@ def get_component_count(facts_data: Dict[str, Any]) -> Dict[str, int]: - direct: Direct dependencies - dev: Development dependencies """ - if not facts_data or 'components' not in facts_data: - return {'total': 0, 'with_vulnerabilities': 0, 'direct': 0, 'dev': 0} - - components = facts_data.get('components', []) - + if not facts_data or "components" not in facts_data: + return {"total": 0, "with_vulnerabilities": 0, "direct": 0, "dev": 0} + + components = facts_data.get("components", []) + return { - 'total': len(components), - 'with_vulnerabilities': len([c for c in components if c.get('vulnerabilities')]), - 'direct': len([c for c in components if c.get('direct')]), - 'dev': len([c for c in components if c.get('dev')]) + "total": len(components), + "with_vulnerabilities": len([c for c in components if c.get("vulnerabilities")]), + "direct": len([c for c in components if c.get("direct")]), + "dev": len([c for c in components if c.get("dev")]), } diff --git a/socketsecurity/core/lazy_file_loader.py b/socketsecurity/core/lazy_file_loader.py index a5bfa15a..fd1fd3cf 100644 --- a/socketsecurity/core/lazy_file_loader.py +++ b/socketsecurity/core/lazy_file_loader.py @@ -1,8 +1,8 @@ """ Lazy file loading utilities for efficient manifest file processing. """ + import logging -from typing import List, Tuple log = logging.getLogger("socketdev") @@ -11,50 +11,50 @@ 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 self._file = None self._closed = False self._position = 0 - + 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: - self._file = open(self.file_path, 'rb') + self._file = open(self.file_path, "rb") # noqa: SIM115 log.debug(f"Opened file for reading: {self.file_path}") # Seek to the current position if we've been reading before if self._position > 0: self._file.seek(self._position) - + def read(self, size: int = -1): """Read from the file, opening it if needed.""" self._ensure_open() data = self._file.read(size) self._position = self._file.tell() return data - + def readline(self, size: int = -1): """Read a line from the file.""" self._ensure_open() 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 @@ -66,24 +66,23 @@ def seek(self, offset: int, whence: int = 0): 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: @@ -91,40 +90,40 @@ def close(self): log.debug(f"Closed file: {self.file_path}") self._file = None self._closed = True - + def __enter__(self): return self - + def __exit__(self, exc_type, exc_val, exc_tb): self.close() - + @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 -def load_files_for_sending_lazy(files: List[str], workspace: str) -> List[Tuple[str, Tuple[str, LazyFileLoader]]]: +def load_files_for_sending_lazy(files: list[str], workspace: str) -> 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 @@ -141,14 +140,11 @@ def load_files_for_sending_lazy(files: List[str], workspace: str) -> List[Tuple[ send_files = [] if "\\" in workspace: workspace = workspace.replace("\\", "/") - + for file_path in files: - _, name = file_path.rsplit("/", 1) + _, _name = file_path.rsplit("/", 1) - if file_path.startswith(workspace): - key = file_path[len(workspace):] - else: - key = file_path + key = file_path.removeprefix(workspace) key = key.lstrip("/") key = key.lstrip("./") diff --git a/socketsecurity/core/log_uploader.py b/socketsecurity/core/log_uploader.py index de57df42..adf6785b 100644 --- a/socketsecurity/core/log_uploader.py +++ b/socketsecurity/core/log_uploader.py @@ -17,8 +17,7 @@ import json import logging import threading -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime from .cli_client import CliClient @@ -28,7 +27,7 @@ def _now_str() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] class BatchedLogUploader: @@ -44,7 +43,7 @@ def __init__( self._buf: list = [] self._lock = threading.Lock() self._stop = threading.Event() - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None def add(self, entry: dict) -> None: with self._lock: @@ -102,11 +101,13 @@ def emit(self, record: logging.LogRecord) -> None: if getattr(_FLUSH_GUARD, "active", False): return try: - self._uploader.add({ - "timestamp": _now_str(), - "level": logging.getLevelName(record.levelno), - "message": self.format(record), - "context": self._context, - }) + self._uploader.add( + { + "timestamp": _now_str(), + "level": logging.getLevelName(record.levelno), + "message": self.format(record), + "context": self._context, + } + ) except Exception: self.handleError(record) diff --git a/socketsecurity/core/logging.py b/socketsecurity/core/logging.py index 9e61dae0..1e1978c6 100644 --- a/socketsecurity/core/logging.py +++ b/socketsecurity/core/logging.py @@ -5,7 +5,7 @@ def initialize_logging( level: int = logging.INFO, format: str = "%(asctime)s: %(message)s", socket_logger_name: str = "socketdev", - cli_logger_name: str = "socketcli" + cli_logger_name: str = "socketcli", ) -> tuple[logging.Logger, logging.Logger]: """Initialize logging for Socket Security @@ -24,9 +24,9 @@ def initialize_logging( cli_logger = logging.getLogger(cli_logger_name) cli_logger.setLevel(level) - return socket_logger, cli_logger + def set_debug_mode(enable: bool = False) -> None: """Toggle debug logging across all loggers""" level = logging.DEBUG if enable else logging.INFO diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index b2229f65..1e3824d4 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -2,9 +2,10 @@ import logging import re import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from html import escape from pathlib import Path +from typing import ClassVar from mdutils import MdUtils from prettytable import PrettyTable @@ -13,8 +14,8 @@ log = logging.getLogger("socketcli") -class Messages: +class Messages: @staticmethod def get_patched_version(alert: Issue) -> str: """Return the first patched version exposed by an alert, if any.""" @@ -44,7 +45,7 @@ def map_severity_to_sarif(severity: str) -> str: def get_manifest_file_url(diff: Diff, manifest_path: str, config=None) -> str: """ Generate proper URL for manifest file based on the repository type and diff URL. - + :param diff: Diff object containing diff_url and report_url :param manifest_path: Path to the manifest file (can contain multiple files separated by ';') :param config: Configuration object to determine SCM type @@ -52,64 +53,52 @@ def get_manifest_file_url(diff: Diff, manifest_path: str, config=None) -> str: """ if not manifest_path: return "" - + # Handle multiple manifest files separated by ';' - use the first one - first_manifest = manifest_path.split(';')[0] if ';' in manifest_path else manifest_path - + first_manifest = manifest_path.split(";")[0] if ";" in manifest_path else manifest_path + # Clean up the manifest path - remove build agent paths and normalize clean_path = first_manifest - + # Remove common build agent path prefixes prefixes_to_remove = [ - 'opt/buildagent/work/', - '/opt/buildagent/work/', - 'home/runner/work/', - '/home/runner/work/', + "opt/buildagent/work/", + "/opt/buildagent/work/", + "home/runner/work/", + "/home/runner/work/", ] - + for prefix in prefixes_to_remove: if clean_path.startswith(prefix): # Find the part after the build ID (usually a hash) - parts = clean_path[len(prefix):].split('/', 2) + parts = clean_path[len(prefix) :].split("/", 2) if len(parts) >= 3: clean_path = parts[2] # Take everything after build ID and repo name break - + # Remove leading slashes - clean_path = clean_path.lstrip('/') - - # Determine SCM type from config or diff_url - scm_type = "api" # Default to API - if config and hasattr(config, 'scm'): - scm_type = config.scm.lower() - elif hasattr(diff, 'diff_url') and diff.diff_url: - diff_url = diff.diff_url.lower() - if 'github.com' in diff_url or 'github' in diff_url: - scm_type = "github" - elif 'gitlab' in diff_url: - scm_type = "gitlab" - elif 'bitbucket' in diff_url: - scm_type = "bitbucket" - - # Generate URL based on SCM type using config information - # NEVER use diff.diff_url for SCM URLs - those are Socket URLs for "View report" links - repository_url = getattr(config, 'repository_url', None) if config else None + clean_path = clean_path.lstrip("/") + + # Socket report URLs carry no source-control information. + scm_type = (getattr(config, "scm", None) or "api").lower() + + repository_url = getattr(config, "repository_url", None) if config else None if repository_url and scm_type in ("github", "gitlab", "bitbucket"): - branch = getattr(config, 'branch', None) or 'main' + branch = getattr(config, "branch", None) or "main" blob_path = {"github": "blob", "gitlab": "-/blob", "bitbucket": "src"}[scm_type] return f"{repository_url}/{blob_path}/{branch}/{clean_path}" # Fallback to Socket file view for API or unknown repository types - if hasattr(diff, 'report_url') and diff.report_url: + if hasattr(diff, "report_url") and diff.report_url: # Strip leading slash and URL encode for Socket dashboard - socket_path = clean_path.lstrip('/') - encoded_path = socket_path.replace('/', '%2F') + socket_path = clean_path.lstrip("/") + encoded_path = socket_path.replace("/", "%2F") return f"{diff.report_url}?tab=files&file={encoded_path}" - + return "" @staticmethod - def find_line_in_file(packagename: str, packageversion: str, manifest_file: str) -> tuple: + def find_line_in_file(packagename: str, packageversion: str, manifest_file: str) -> tuple: # noqa: C901 """ Finds the line number and snippet of code for the given package/version in a manifest file. Returns a 2-tuple: (line_number, snippet_or_message). @@ -126,25 +115,19 @@ def find_line_in_file(packagename: str, packageversion: str, manifest_file: str) if file_type in ["package-lock.json", "Pipfile.lock", "composer.lock"]: try: - with open(manifest_file, "r", encoding="utf-8") as f: + with open(manifest_file, encoding="utf-8") as f: raw_text = f.read() log.debug("Read %d characters from %s", len(raw_text), manifest_file) data = json.loads(raw_text) - packages_dict = ( - data.get("packages") - or data.get("default") - or data.get("dependencies") - or {} - ) + packages_dict = data.get("packages") or data.get("default") or data.get("dependencies") or {} log.debug("Found package keys in %s: %s", manifest_file, list(packages_dict.keys())) found_key = None found_info = None for key, value in packages_dict.items(): - if key.endswith(packagename) and "version" in value: - if value["version"] == packageversion: - found_key = key - found_info = value - break + if key.endswith(packagename) and value.get("version") == packageversion: + found_key = key + found_info = value + break if found_key and found_info: needle_key = f'"{found_key}":' lines = raw_text.splitlines() @@ -154,45 +137,44 @@ def find_line_in_file(packagename: str, packageversion: str, manifest_file: str) log.debug("Found match at line %d in %s: %s", i, manifest_file, line.strip()) return i, line.strip() return 1, f'"{found_key}": {found_info}' - else: - return 1, f"{packagename} {packageversion} (not found in {manifest_file})" + return 1, f"{packagename} {packageversion} (not found in {manifest_file})" except (FileNotFoundError, json.JSONDecodeError) as e: log.error("Error reading %s: %s", manifest_file, e) return 1, f"Error reading {manifest_file}" # For pnpm-lock.yaml, use a special regex pattern. if file_type.lower() == "pnpm-lock.yaml": - searchstring = rf'^\s*/{re.escape(packagename)}/{re.escape(packageversion)}:' + searchstring = rf"^\s*/{re.escape(packagename)}/{re.escape(packageversion)}:" else: search_patterns = { - "package.json": rf'"{packagename}":\s*"[\^~]?{re.escape(packageversion)}"', - "yarn.lock": rf'{packagename}@{packageversion}', - "requirements.txt": rf'^{re.escape(packagename)}\s*(?:==|===|!=|>=|<=|~=|\s+)?\s*{re.escape(packageversion)}(?:\s*;.*)?$', - "pyproject.toml": rf'{packagename}\s*=\s*"{re.escape(packageversion)}"', - "Pipfile": rf'"{packagename}"\s*=\s*"{re.escape(packageversion)}"', - "go.mod": rf'require\s+{re.escape(packagename)}\s+{re.escape(packageversion)}', - "go.sum": rf'{re.escape(packagename)}\s+{re.escape(packageversion)}', - "pom.xml": rf'{re.escape(packagename)}\s*{re.escape(packageversion)}', - "build.gradle": rf'implementation\s+"{re.escape(packagename)}:{re.escape(packageversion)}"', - "Gemfile": rf'gem\s+"{re.escape(packagename)}",\s*"{re.escape(packageversion)}"', - "Gemfile.lock": rf'\s+{re.escape(packagename)}\s+\({re.escape(packageversion)}\)', - ".csproj": rf'', - ".fsproj": rf'', - "paket.dependencies": rf'nuget\s+{re.escape(packagename)}\s+{re.escape(packageversion)}', - "Cargo.toml": rf'{re.escape(packagename)}\s*=\s*"{re.escape(packageversion)}"', - "build.sbt": rf'"{re.escape(packagename)}"\s*%\s*"{re.escape(packageversion)}"', - "Podfile": rf'pod\s+"{re.escape(packagename)}",\s*"{re.escape(packageversion)}"', - "Package.swift": rf'\.package\(name:\s*"{re.escape(packagename)}",\s*url:\s*".*?",\s*version:\s*"{re.escape(packageversion)}"\)', - "mix.exs": rf'\{{:{re.escape(packagename)},\s*"{re.escape(packageversion)}"\}}', - "composer.json": rf'"{re.escape(packagename)}":\s*"{re.escape(packageversion)}"', - "conanfile.txt": rf'{re.escape(packagename)}/{re.escape(packageversion)}', - "vcpkg.json": rf'"{re.escape(packagename)}":\s*"{re.escape(packageversion)}"', + "package.json": rf'"{packagename}":\s*"[\^~]?{re.escape(packageversion)}"', + "yarn.lock": rf"{packagename}@{packageversion}", + "requirements.txt": rf"^{re.escape(packagename)}\s*(?:==|===|!=|>=|<=|~=|\s+)?\s*{re.escape(packageversion)}(?:\s*;.*)?$", + "pyproject.toml": rf'{packagename}\s*=\s*"{re.escape(packageversion)}"', + "Pipfile": rf'"{packagename}"\s*=\s*"{re.escape(packageversion)}"', + "go.mod": rf"require\s+{re.escape(packagename)}\s+{re.escape(packageversion)}", + "go.sum": rf"{re.escape(packagename)}\s+{re.escape(packageversion)}", + "pom.xml": rf"{re.escape(packagename)}\s*{re.escape(packageversion)}", + "build.gradle": rf'implementation\s+"{re.escape(packagename)}:{re.escape(packageversion)}"', + "Gemfile": rf'gem\s+"{re.escape(packagename)}",\s*"{re.escape(packageversion)}"', + "Gemfile.lock": rf"\s+{re.escape(packagename)}\s+\({re.escape(packageversion)}\)", + ".csproj": rf'', + ".fsproj": rf'', + "paket.dependencies": rf"nuget\s+{re.escape(packagename)}\s+{re.escape(packageversion)}", + "Cargo.toml": rf'{re.escape(packagename)}\s*=\s*"{re.escape(packageversion)}"', + "build.sbt": rf'"{re.escape(packagename)}"\s*%\s*"{re.escape(packageversion)}"', + "Podfile": rf'pod\s+"{re.escape(packagename)}",\s*"{re.escape(packageversion)}"', + "Package.swift": rf'\.package\(name:\s*"{re.escape(packagename)}",\s*url:\s*".*?",\s*version:\s*"{re.escape(packageversion)}"\)', + "mix.exs": rf'\{{:{re.escape(packagename)},\s*"{re.escape(packageversion)}"\}}', + "composer.json": rf'"{re.escape(packagename)}":\s*"{re.escape(packageversion)}"', + "conanfile.txt": rf"{re.escape(packagename)}/{re.escape(packageversion)}", + "vcpkg.json": rf'"{re.escape(packagename)}":\s*"{re.escape(packageversion)}"', } - searchstring = search_patterns.get(file_type, rf'{re.escape(packagename)}.*{re.escape(packageversion)}') + searchstring = search_patterns.get(file_type, rf"{re.escape(packagename)}.*{re.escape(packageversion)}") log.debug("Using search pattern for %s: %s", file_type, searchstring) try: - with open(manifest_file, 'r', encoding="utf-8") as file: + with open(manifest_file, encoding="utf-8") as file: lines = [line.rstrip("\n") for line in file] log.debug("Total lines in %s: %d", manifest_file, len(lines)) for line_number, line_content in enumerate(lines, start=1): @@ -238,7 +220,7 @@ def get_manifest_type_url(manifest_file: str, pkg_name: str, pkg_version: str) - return f"https://socket.dev/{url_prefix}/package/{pkg_name}/alerts/{pkg_version}" @staticmethod - def create_security_comment_sarif(diff) -> dict: + def create_security_comment_sarif(diff) -> dict: # noqa: C901 """ Create SARIF-compliant output from the diff report, including dynamic URL generation based on manifest type and improved
formatting for GitHub SARIF display. @@ -259,16 +241,14 @@ def create_security_comment_sarif(diff) -> dict: sarif_data = { "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", - "runs": [{ - "tool": { - "driver": { - "name": "Socket Security", - "informationUri": "https://socket.dev", - "rules": [] - } - }, - "results": [] - }] + "runs": [ + { + "tool": { + "driver": {"name": "Socket Security", "informationUri": "https://socket.dev", "rules": []} + }, + "results": [], + } + ], } rules_map = {} @@ -280,7 +260,12 @@ def create_security_comment_sarif(diff) -> dict: base_rule_id = f"{pkg_name}=={pkg_version}" severity = alert.severity - log.debug("Alert %s - introduced_by: %s, manifests: %s", base_rule_id, alert.introduced_by, getattr(alert, 'manifests', None)) + log.debug( + "Alert %s - introduced_by: %s, manifests: %s", + base_rule_id, + alert.introduced_by, + getattr(alert, "manifests", None), + ) manifest_files = [] if alert.introduced_by and isinstance(alert.introduced_by, list): for entry in alert.introduced_by: @@ -289,7 +274,7 @@ def create_security_comment_sarif(diff) -> dict: manifest_files.extend(files) elif isinstance(entry, str): manifest_files.extend([m.strip() for m in entry.split(";") if m.strip()]) - elif hasattr(alert, 'manifests') and alert.manifests: + elif hasattr(alert, "manifests") and alert.manifests: manifest_files = [mf.strip() for mf in alert.manifests.split(";") if mf.strip()] log.debug("Alert %s - extracted manifest_files: %s", base_rule_id, manifest_files) @@ -304,28 +289,29 @@ def create_security_comment_sarif(diff) -> dict: log.debug("Alert %s - Processing manifest file: %s", base_rule_id, mf) socket_url = Messages.get_manifest_type_url(mf, pkg_name, pkg_version) line_number, line_content = Messages.find_line_in_file(pkg_name, pkg_version, mf) - if line_number < 1: - line_number = 1 + line_number = max(line_number, 1) log.debug("Alert %s: Manifest %s, line %d: %s", base_rule_id, mf, line_number, line_content) # Create a unique rule id and name by appending the manifest file. unique_rule_id = f"{base_rule_id} ({mf})" rule_name = f"Alert {base_rule_id} ({mf})" props = {} - if hasattr(alert, 'props') and alert.props: + if hasattr(alert, "props") and alert.props: props = alert.props - suggestion = '' - if hasattr(alert, 'suggestion'): + suggestion = "" + if hasattr(alert, "suggestion"): suggestion = alert.suggestion - alert_title = '' - if hasattr(alert, 'title'): + alert_title = "" + if hasattr(alert, "title"): alert_title = alert.title - description = '' - if hasattr(alert, 'description'): + description = "" + if hasattr(alert, "description"): description = alert.description - short_desc = (f"{props.get('note', '')}

Suggested Action:
{suggestion}" - f"
{socket_url}") - full_desc = "{} - {}".format(alert_title, description.replace('\r\n', '
')) + short_desc = ( + f"{props.get('note', '')}

Suggested Action:
{suggestion}" + f'
{socket_url}' + ) + full_desc = "{} - {}".format(alert_title, description.replace("\r\n", "
")) if unique_rule_id not in rules_map: rules_map[unique_rule_id] = { @@ -334,23 +320,23 @@ def create_security_comment_sarif(diff) -> dict: "shortDescription": {"text": rule_name}, "fullDescription": {"text": full_desc}, "helpUri": socket_url, - "defaultConfiguration": { - "level": Messages.map_severity_to_sarif(severity) - }, + "defaultConfiguration": {"level": Messages.map_severity_to_sarif(severity)}, } result_obj = { "ruleId": unique_rule_id, "message": {"text": short_desc}, - "locations": [{ - "physicalLocation": { - "artifactLocation": {"uri": mf}, - "region": { - "startLine": line_number, - "snippet": {"text": line_content}, - }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": mf}, + "region": { + "startLine": line_number, + "snippet": {"text": line_content}, + }, + } } - }] + ], } results_list.append(result_obj) @@ -384,7 +370,7 @@ def _matches_reachability_filter(reachability: str, selector: str, undeterminabl return True @staticmethod - def create_security_comment_sarif_from_facts( + def create_security_comment_sarif_from_facts( # noqa: C901 components_with_alerts: list, reachability_filter: str = "all", grouping: str = "instance", @@ -400,16 +386,14 @@ def create_security_comment_sarif_from_facts( sarif_data = { "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", - "runs": [{ - "tool": { - "driver": { - "name": "Socket Security", - "informationUri": "https://socket.dev", - "rules": [] - } - }, - "results": [] - }] + "runs": [ + { + "tool": { + "driver": {"name": "Socket Security", "informationUri": "https://socket.dev", "rules": []} + }, + "results": [], + } + ], } rules_map = {} @@ -458,9 +442,8 @@ def create_security_comment_sarif_from_facts( else: rule_id = f"{comp_name}=={comp_version}:{vuln_id}" rule_name = f"Reachability alert {vuln_id} in {comp_name}@{comp_version}" - socket_url = ( - props.get("url") - or Messages.get_manifest_type_url(manifest_uris[0], comp_name, comp_version) + socket_url = props.get("url") or Messages.get_manifest_type_url( + manifest_uris[0], comp_name, comp_version ) if rule_id not in rules_map: @@ -470,15 +453,13 @@ def create_security_comment_sarif_from_facts( "shortDescription": {"text": rule_name}, "fullDescription": {"text": alert.get("title", rule_name)}, "helpUri": socket_url, - "defaultConfiguration": { - "level": Messages.map_severity_to_sarif(severity) - }, + "defaultConfiguration": {"level": Messages.map_severity_to_sarif(severity)}, } message = ( f"Reachability: {reachability}. " f"Suggested Action:
{props.get('range', '')}" - f"
{socket_url}" + f'
{socket_url}' ) if grouping == "alert": @@ -489,15 +470,14 @@ def create_security_comment_sarif_from_facts( grouped_results[alert_key] = { "ruleId": rule_id, "message": {"text": message}, - "locations": [{ - "physicalLocation": { - "artifactLocation": {"uri": first_uri}, - "region": { - "startLine": 1, - "snippet": {"text": f"{comp_name}@{comp_version}"} - }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": first_uri}, + "region": {"startLine": 1, "snippet": {"text": f"{comp_name}@{comp_version}"}}, + } } - }], + ], "properties": { "reachability": reachability, "reachabilityStates": [reachability], @@ -508,7 +488,7 @@ def create_security_comment_sarif_from_facts( "cveId": props.get("cveId"), "source": "socket-facts", "socketAlertKey": alert_key, - } + }, } else: states = set(existing["properties"].get("reachabilityStates", [])) @@ -529,26 +509,30 @@ def create_security_comment_sarif_from_facts( existing["properties"]["purls"] = sorted(purls) else: for uri in manifest_uris: - results_list.append({ - "ruleId": rule_id, - "message": {"text": message}, - "locations": [{ - "physicalLocation": { - "artifactLocation": {"uri": uri}, - "region": { - "startLine": 1, - "snippet": {"text": f"{comp_name}@{comp_version}"} - }, - } - }], - "properties": { - "reachability": reachability, - "purl": props.get("purl"), - "ghsaId": props.get("ghsaId"), - "cveId": props.get("cveId"), - "source": "socket-facts" + results_list.append( + { + "ruleId": rule_id, + "message": {"text": message}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": uri}, + "region": { + "startLine": 1, + "snippet": {"text": f"{comp_name}@{comp_version}"}, + }, + } + } + ], + "properties": { + "reachability": reachability, + "purl": props.get("purl"), + "ghsaId": props.get("ghsaId"), + "cveId": props.get("cveId"), + "source": "socket-facts", + }, } - }) + ) if grouping == "alert": for grouped in grouped_results.values(): @@ -572,12 +556,7 @@ def create_security_comment_json(diff: Diff) -> dict: if alert.error: scan_failed = True break - output = { - "scan_failed": scan_failed, - "new_alerts": [], - "full_scan_id": diff.id, - "diff_url": diff.diff_url - } + output = {"scan_failed": scan_failed, "new_alerts": [], "full_scan_id": diff.id, "diff_url": diff.diff_url} for alert in diff.new_alerts: alert: Issue output["new_alerts"].append(json.loads(str(alert))) @@ -624,7 +603,7 @@ def generate_uuid_from_alert_gitlab(alert: Issue) -> str: unique_str = f"{alert.pkg_name}:{alert.pkg_version}:{alert.type}:{alert.severity}" # Generate UUID5 (deterministic) from namespace and unique string - namespace = uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') # DNS namespace + namespace = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") # DNS namespace return str(uuid.uuid5(namespace, unique_str)) @staticmethod @@ -666,20 +645,17 @@ def extract_identifiers_gitlab(alert: Issue) -> list: values = [values] elif not isinstance(values, (list, tuple)): continue - for value in values: - if not isinstance(value, str) or not value.strip(): + for raw_value in values: + if not isinstance(raw_value, str) or not raw_value.strip(): continue - value = value.strip() + value = raw_value.strip() identifier_key = (identifier_type, value.upper()) if identifier_key in seen: continue seen.add(identifier_key) - identifiers.append({ - "type": identifier_type, - "name": value, - "value": value, - "url": f"{url_prefix}{value}" - }) + identifiers.append( + {"type": identifier_type, "name": value, "value": value, "url": f"{url_prefix}{value}"} + ) return identifiers @@ -712,19 +688,15 @@ def extract_location_gitlab(alert: Issue) -> dict: manifest_file = candidate break - location = { + return { "file": manifest_file or "unknown", "dependency": { - "package": { - "name": alert.pkg_name - }, + "package": {"name": alert.pkg_name}, "version": alert.pkg_version, - "direct": bool(getattr(alert, "direct", False)) - } + "direct": bool(getattr(alert, "direct", False)), + }, } - return location - @staticmethod def create_security_comment_gitlab(diff: Diff) -> dict: """ @@ -748,44 +720,40 @@ def create_security_comment_gitlab(diff: Diff) -> dict: "id": "socket-security", "name": "Socket Security", "version": __version__, - "vendor": { - "name": "Socket" - } + "vendor": {"name": "Socket"}, }, "scanner": { "id": "socket-cli", "name": "Socket CLI", "version": __version__, - "vendor": { - "name": "Socket" - } + "vendor": {"name": "Socket"}, }, "type": "dependency_scanning", - "start_time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S"), - "end_time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S"), - "status": "success" + "start_time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"), + "end_time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"), + "status": "success", }, "vulnerabilities": [], - "dependency_files": [] + "dependency_files": [], } dep_files_map: dict = {} - all_alerts = list(diff.new_alerts) + list(getattr(diff, 'unchanged_alerts', [])) + all_alerts = list(diff.new_alerts) + list(getattr(diff, "unchanged_alerts", [])) for alert in all_alerts: vulnerability = { "id": Messages.generate_uuid_from_alert_gitlab(alert), "category": "dependency_scanning", - "name": alert.title if hasattr(alert, 'title') else f"{alert.type} in {alert.pkg_name}", + "name": alert.title if hasattr(alert, "title") else f"{alert.type} in {alert.pkg_name}", "message": f"{alert.pkg_name}@{alert.pkg_version}: {alert.title if hasattr(alert, 'title') else alert.type}", - "description": alert.description if hasattr(alert, 'description') and alert.description else "", + "description": alert.description if hasattr(alert, "description") and alert.description else "", "severity": Messages.map_socket_severity_to_gitlab(alert.severity), "identifiers": Messages.extract_identifiers_gitlab(alert), - "links": [{"url": alert.url}] if hasattr(alert, 'url') and alert.url else [], - "location": Messages.extract_location_gitlab(alert) + "links": [{"url": alert.url}] if hasattr(alert, "url") and alert.url else [], + "location": Messages.extract_location_gitlab(alert), } - if hasattr(alert, 'suggestion') and alert.suggestion: + if hasattr(alert, "suggestion") and alert.suggestion: vulnerability["solution"] = alert.suggestion gitlab_report["vulnerabilities"].append(vulnerability) @@ -793,18 +761,13 @@ def create_security_comment_gitlab(diff: Diff) -> dict: file_path = vulnerability["location"]["file"] if file_path != "unknown": pkg_manager = Messages._pkg_type_to_package_manager( - alert.pkg_type if hasattr(alert, 'pkg_type') else "" + alert.pkg_type if hasattr(alert, "pkg_type") else "" ) if file_path not in dep_files_map: - dep_files_map[file_path] = { - "path": file_path, - "package_manager": pkg_manager, - "dependencies": [] - } - dep_files_map[file_path]["dependencies"].append({ - "package": {"name": alert.pkg_name}, - "version": alert.pkg_version - }) + dep_files_map[file_path] = {"path": file_path, "package_manager": pkg_manager, "dependencies": []} + dep_files_map[file_path]["dependencies"].append( + {"package": {"name": alert.pkg_name}, "version": alert.pkg_version} + ) gitlab_report["dependency_files"] = list(dep_files_map.values()) @@ -976,7 +939,7 @@ def security_comment_template(diff: Diff, config=None) -> str: """ - show_ignore = not (config and getattr(config, 'disable_ignore', False)) + show_ignore = not (config and getattr(config, "disable_ignore", False)) # Loop through security alerts (non-license), dynamically generating rows for alert in security_alerts: @@ -985,9 +948,9 @@ def security_comment_template(diff: Diff, config=None) -> str: details_open = "" patched_version = Messages.get_patched_version(alert) patched_version_html = ( - "

Patched version: " - f"{Messages.html_text(patched_version)}

" - if patched_version else "" + f"

Patched version: {Messages.html_text(patched_version)}

" + if patched_version + else "" ) # Generate proper manifest URL manifest_url = Messages.get_manifest_file_url(diff, alert.manifests, config) @@ -995,11 +958,15 @@ def security_comment_template(diff: Diff, config=None) -> str: pkg_marker = Messages.comment_marker_text(f"{alert.pkg_name}@{alert.pkg_version}") # Generate a table row for each alert ignore_html = ( - f"

Mark as acceptable risk: To ignore this alert only in this pull request, reply with:
" - f"@SocketSecurity ignore {Messages.html_text(alert.pkg_type)}/{pkg_label}
" - f"Or ignore all future alerts with:
" - f"@SocketSecurity ignore-all

" - ) if show_ignore else "" + ( + f"

Mark as acceptable risk: To ignore this alert only in this pull request, reply with:
" + f"@SocketSecurity ignore {Messages.html_text(alert.pkg_type)}/{pkg_label}
" + f"Or ignore all future alerts with:
" + f"@SocketSecurity ignore-all

" + ) + if show_ignore + else "" + ) comment += f""" @@ -1028,25 +995,21 @@ def security_comment_template(diff: Diff, config=None) -> str: """ # Add license policy violation entries grouped by PURL - for purl_key, alerts in license_groups.items(): + for alerts in license_groups.values(): action = "Block" if any(alert.error for alert in alerts) else "Warn" first_alert = alerts[0] - + # Use orange diamond for license policy violations license_icon = "๐Ÿ”ถ" - - license_label = Messages.html_text( - f"{first_alert.pkg_name}@{first_alert.pkg_version}" - ) - license_marker = Messages.comment_marker_text( - f"{first_alert.pkg_name}@{first_alert.pkg_version}" - ) + + license_label = Messages.html_text(f"{first_alert.pkg_name}@{first_alert.pkg_version}") + license_marker = Messages.comment_marker_text(f"{first_alert.pkg_name}@{first_alert.pkg_version}") # Build license findings list license_findings = [] for alert in alerts: license_findings.append(alert.title) - + comment += f""" @@ -1060,17 +1023,20 @@ def security_comment_template(diff: Diff, config=None) -> str: """ for finding in license_findings: comment += f"
  • {Messages.html_text(finding)}
  • \n" - - + # Generate proper manifest URL for license violations license_manifest_url = Messages.get_manifest_file_url(diff, first_alert.manifests, config) license_ignore_html = ( - f"

    Mark the package as acceptable risk: To ignore this alert only in this pull request, reply with the comment " - f"@SocketSecurity ignore {Messages.html_text(first_alert.pkg_type)}/{license_label}. " - f"You can also ignore all packages with @SocketSecurity ignore-all. " - f"To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    " - ) if show_ignore else "" + ( + f"

    Mark the package as acceptable risk: To ignore this alert only in this pull request, reply with the comment " + f"@SocketSecurity ignore {Messages.html_text(first_alert.pkg_type)}/{license_label}. " + f"You can also ignore all packages with @SocketSecurity ignore-all. " + f"To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    " + ) + if show_ignore + else "" + ) comment += f"""

    From: Manifest File

    โ„น๏ธ Read more on: This package | What is a license policy violation?

    @@ -1112,7 +1078,6 @@ def get_severity_icon(severity: str) -> str: } return severity_map.get(severity.lower(), "https://github-app-statics.socket.dev/severity-0.svg") - @staticmethod def create_next_steps(md: MdUtils, next_steps: dict): """ @@ -1192,48 +1157,25 @@ def create_security_alert_table(diff: Diff, md: MdUtils) -> tuple[MdUtils, list, :param md: MdUtils - Main markdown variable :return: """ - alert_table = [ - "Alert", - "Package", - "Introduced by", - "Manifest File", - "CI" - ] + alert_table = ["Alert", "Package", "Introduced by", "Manifest File", "CI"] num_of_alert_columns = len(alert_table) next_steps = {} ignore_commands = [] for alert in diff.new_alerts: alert: Issue if alert.next_step_title not in next_steps: - next_steps[alert.next_step_title] = [ - alert.description, - alert.suggestion - ] + next_steps[alert.next_step_title] = [alert.description, alert.suggestion] ignore = f"`SocketSecurity ignore {alert.purl}`" if ignore not in ignore_commands: ignore_commands.append(ignore) manifest_str, source_str = Messages.create_sources(alert) purl_url = f"[{alert.purl}]({alert.url})" - if alert.error: - emoji = ':no_entry_sign:' - else: - emoji = ':warning:' - row = [ - alert.title, - purl_url, - source_str, - manifest_str, - emoji - ] + emoji = ":no_entry_sign:" if alert.error else ":warning:" + row = [alert.title, purl_url, source_str, manifest_str, emoji] if row not in alert_table: alert_table.extend(row) num_of_alert_rows = len(diff.new_alerts) + 1 - md.new_table( - columns=num_of_alert_columns, - rows=num_of_alert_rows, - text=alert_table, - text_align="left" - ) + md.new_table(columns=num_of_alert_columns, rows=num_of_alert_rows, text=alert_table, text_align="left") return md, ignore_commands, next_steps @staticmethod @@ -1246,7 +1188,9 @@ def dependency_overview_template(diff: Diff) -> str: md = MdUtils(file_name="markdown_overview_temp.md") md.new_line("") md.new_header(level=1, title="Socket Security: Dependency Overview") - md.new_line("Review the following changes in direct dependencies. Learn more about [socket.dev](https://socket.dev)") + md.new_line( + "Review the following changes in direct dependencies. Learn more about [socket.dev](https://socket.dev)" + ) md.new_line() md = Messages.create_added_table(diff, md) md.create_md_file() @@ -1259,7 +1203,9 @@ def short_dependency_overview_comment(diff: Diff) -> MdUtils: md = MdUtils(file_name="markdown_overview_temp.md") md.new_line("") md.new_header(level=1, title="Socket Security: Dependency Overview") - md.new_line("Review the following changes in direct dependencies. Learn more about [socket.dev](https://socket.dev)") + md.new_line( + "Review the following changes in direct dependencies. Learn more about [socket.dev](https://socket.dev)" + ) md.new_line() md.new_line("The amount of dependency changes were to long for this comment. Please check out the full report") md.new_line(f"To view more information about this report checkout the [Full Report]({diff.diff_url})") @@ -1285,7 +1231,7 @@ def create_remove_line(diff: Diff, md: MdUtils) -> MdUtils: # Change types the shared badge host publishes an image for. Removed and # replaced have no artwork, so they fall back to a bold text label rather than # rendering a broken image; added and updated render the available badges. - DIFF_BADGES = { + DIFF_BADGES: ClassVar[dict[str, str]] = { "Added": "diff-added.svg", "Updated": "diff-updated.svg", } @@ -1298,6 +1244,27 @@ def get_diff_badge(change: str, package_url: str) -> str: return f"**{change}**" return f"[![{change}](https://github-app-statics.socket.dev/{badge})]({package_url})" + @staticmethod + def _score_to_badge(score: float, url: str) -> str: + score_percent = int(score * 100) # Convert to integer percentage + return f"[![{score_percent}](https://github-app-statics.socket.dev/score-{score_percent}.svg)]({url})" + + @staticmethod + def _get_score_for_badge(package: Purl, score_name: str) -> float: + scores = getattr(package, "scores", None) + if isinstance(scores, dict): + raw_score = scores.get(score_name) + else: + raw_score = getattr(scores, score_name, None) if scores is not None else None + + if raw_score is None: + return 1.0 + + score = float(raw_score) + if score > 1: + score = score / 100 + return max(0.0, min(score, 1.0)) + @staticmethod def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: """ @@ -1314,7 +1281,7 @@ def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: "Vulnerability", "Quality", "Maintenance", - "License" + "License", ] num_of_overview_columns = len(overview_table) @@ -1332,32 +1299,16 @@ def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: package_url = f"[{package.purl}]({package.url})" diff_badge = Messages.get_diff_badge(change, package.url) - # Scores dynamically converted to badge URLs and linked - def score_to_badge(score): - score_percent = int(score * 100) # Convert to integer percentage - return f"[![{score_percent}](https://github-app-statics.socket.dev/score-{score_percent}.svg)]({package.url})" - - def get_score_for_badge(score_name: str) -> float: - scores = getattr(package, "scores", None) - if isinstance(scores, dict): - raw_score = scores.get(score_name) - else: - raw_score = getattr(scores, score_name, None) if scores is not None else None - - if raw_score is None: - return 1.0 - - score = float(raw_score) - if score > 1: - score = score / 100 - return max(0.0, min(score, 1.0)) - - # Generate badges for each score type - supply_chain_risk_badge = score_to_badge(get_score_for_badge("supplyChain")) - vulnerability_badge = score_to_badge(get_score_for_badge("vulnerability")) - quality_badge = score_to_badge(get_score_for_badge("quality")) - maintenance_badge = score_to_badge(get_score_for_badge("maintenance")) - license_badge = score_to_badge(get_score_for_badge("license")) + ( + supply_chain_risk_badge, + vulnerability_badge, + quality_badge, + maintenance_badge, + license_badge, + ) = ( + Messages._score_to_badge(Messages._get_score_for_badge(package, name), package.url) + for name in ("supplyChain", "vulnerability", "quality", "maintenance", "license") + ) # Add the row for this package row = [ @@ -1367,7 +1318,7 @@ def get_score_for_badge(score_name: str) -> float: vulnerability_badge, quality_badge, maintenance_badge, - license_badge + license_badge, ] overview_table.extend(row) count += 1 @@ -1377,10 +1328,7 @@ def get_score_for_badge(score_name: str) -> float: # Generate Markdown table md.new_table( - columns=num_of_overview_columns, - rows=num_of_overview_rows, - text=overview_table, - text_align="center" + columns=num_of_overview_columns, rows=num_of_overview_rows, text=overview_table, text_align="center" ) return md @@ -1391,8 +1339,7 @@ def create_purl_link(details: Purl) -> str: :param details: Purl - Details about the package needed to create the URLs :return: """ - package_url = f"[{details.purl}]({details.url})" - return package_url + return f"[{details.purl}]({details.url})" @staticmethod def create_console_security_alert_table(diff: Diff) -> PrettyTable: @@ -1402,15 +1349,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: :return: """ alert_table = PrettyTable( - [ - "Alert", - "Package", - "Patched Version", - "url", - "Introduced by", - "Manifest File", - "CI Status" - ] + ["Alert", "Package", "Patched Version", "url", "Introduced by", "Manifest File", "CI Status"] ) for alert in diff.new_alerts: alert: Issue @@ -1430,7 +1369,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: alert.url, source_str, manifest_str, - state + state, ] alert_table.add_row(row) return alert_table diff --git a/socketsecurity/core/pull_request.py b/socketsecurity/core/pull_request.py index 1804dd6d..7a6127e2 100644 --- a/socketsecurity/core/pull_request.py +++ b/socketsecurity/core/pull_request.py @@ -1,6 +1,6 @@ import re +from collections.abc import Mapping from dataclasses import dataclass -from typing import Mapping, Optional from urllib.parse import urlparse from socketsecurity.core.git_remote import parse_git_remote @@ -9,7 +9,7 @@ @dataclass(frozen=True) class PullRequestContext: number: int = 0 - url: Optional[str] = None + url: str | None = None def parse_pull_request_number(value) -> int: @@ -24,10 +24,10 @@ def parse_pull_request_number(value) -> int: parsed = int(value) except (TypeError, ValueError): return 0 - return parsed if parsed > 0 else 0 + return max(0, parsed) -def _http_url(value: Optional[str]) -> Optional[str]: +def _http_url(value: str | None) -> str | None: """Return ``value`` if it is an http(s) URL with a host, else ``None``. Every URL fragment read out of the CI environment goes through here before it @@ -42,12 +42,11 @@ def _http_url(value: Optional[str]) -> Optional[str]: return url if parsed.scheme in ("http", "https") and parsed.netloc else None -def _repository_url(value: Optional[str]) -> Optional[str]: +def _repository_url(value: str | None) -> str | None: if not value: return None url = value.strip().rstrip("/") - if url.endswith(".git"): - url = url[:-4] + url = url.removesuffix(".git") return _http_url(url) @@ -59,9 +58,7 @@ def _github_number(env: Mapping[str, str]) -> int: return parse_pull_request_number(match.group(1)) if match else 0 -def _github_repository_url( - repo: Optional[str], env: Mapping[str, str], remote_url: Optional[str] = None -) -> Optional[str]: +def _github_repository_url(repo: str | None, env: Mapping[str, str], remote_url: str | None = None) -> str | None: remote_host, remote_path = parse_git_remote(env.get("BUILDKITE_REPO") or remote_url) # config.repo is only ever a bare repository name, so it cannot produce a # slug on its own; it is kept last for callers that pass a full owner/repo. @@ -76,25 +73,18 @@ def _github_repository_url( return f"{server}/{repository.strip('/')}" -def _gitlab_project_url( - repo: Optional[str], env: Mapping[str, str], remote_url: Optional[str] = None -) -> Optional[str]: +def _gitlab_project_url(repo: str | None, env: Mapping[str, str], remote_url: str | None = None) -> str | None: project_url = _repository_url(env.get("CI_PROJECT_URL")) if not project_url: remote_host, remote_path = parse_git_remote(env.get("BUILDKITE_REPO") or remote_url) project_path = env.get("CI_PROJECT_PATH") or remote_path or repo - server = ( - _http_url(env.get("CI_SERVER_URL")) - or (_http_url(f"https://{remote_host}") if remote_host else None) - ) + server = _http_url(env.get("CI_SERVER_URL")) or (_http_url(f"https://{remote_host}") if remote_host else None) if server and project_path and "/" in project_path: project_url = f"{server}/{project_path.strip('/')}" return project_url -def _bitbucket_repository_url( - repo: Optional[str], env: Mapping[str, str], remote_url: Optional[str] = None -) -> Optional[str]: +def _bitbucket_repository_url(repo: str | None, env: Mapping[str, str], remote_url: str | None = None) -> str | None: remote_host, remote_path = parse_git_remote(remote_url) repository = env.get("BITBUCKET_REPO_FULL_NAME") or remote_path or repo if not repository or "/" not in repository: @@ -108,11 +98,11 @@ def _bitbucket_repository_url( def resolve_repository_url( - scm: Optional[str], - repo: Optional[str] = None, - env: Optional[Mapping[str, str]] = None, - remote_url: Optional[str] = None, -) -> Optional[str]: + scm: str | None, + repo: str | None = None, + env: Mapping[str, str] | None = None, + remote_url: str | None = None, +) -> str | None: """Return the web URL of the scanned repository, such as ``https://github.com/owner/repo``. The CI environment wins, then the checkout's ``origin`` remote. ``None`` means the @@ -130,7 +120,7 @@ def resolve_repository_url( return None -def repository_owner(repository_url: Optional[str]) -> str: +def repository_owner(repository_url: str | None) -> str: """Return everything before the repository name in ``repository_url``'s path. That is the owner on GitHub and Bitbucket, and the full group path on GitLab, @@ -142,20 +132,19 @@ def repository_owner(repository_url: Optional[str]) -> str: return path.rsplit("/", 1)[0] if "/" in path else "" -def _github_url(number: int, repo: Optional[str], env: Mapping[str, str]) -> Optional[str]: +def _github_url(number: int, repo: str | None, env: Mapping[str, str]) -> str | None: repository_url = _github_repository_url(repo, env) return f"{repository_url}/pull/{number}" if repository_url else None -def _gitlab_url(number: int, repo: Optional[str], env: Mapping[str, str]) -> Optional[str]: +def _gitlab_url(number: int, repo: str | None, env: Mapping[str, str]) -> str | None: project_url = _gitlab_project_url(repo, env) return f"{project_url}/-/merge_requests/{number}" if project_url else None -def _azure_url(number: int, env: Mapping[str, str], github_pr: bool) -> Optional[str]: +def _azure_url(number: int, env: Mapping[str, str], github_pr: bool) -> str | None: repository_url = _repository_url( - env.get("BUILD_REPOSITORY_URI") or - env.get("SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI") + env.get("BUILD_REPOSITORY_URI") or env.get("SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI") ) if not repository_url: return None @@ -167,10 +156,10 @@ def _azure_url(number: int, env: Mapping[str, str], github_pr: bool) -> Optional def resolve_pull_request_context( integration_type: str, configured_number, - repo: Optional[str], + repo: str | None, *, configured_explicit: bool = False, - env: Optional[Mapping[str, str]] = None, + env: Mapping[str, str] | None = None, ) -> PullRequestContext: """Resolve PR metadata without making provider API calls. @@ -187,10 +176,9 @@ def resolve_pull_request_context( elif provider == "gitlab": number = parse_pull_request_number(environment.get("CI_MERGE_REQUEST_IID")) elif provider == "azure": - number = ( - parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")) or - parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTID")) - ) + number = parse_pull_request_number( + environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER") + ) or parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTID")) if not number: return PullRequestContext() diff --git a/socketsecurity/core/resource_utils.py b/socketsecurity/core/resource_utils.py index dc78c1b8..86757d9c 100644 --- a/socketsecurity/core/resource_utils.py +++ b/socketsecurity/core/resource_utils.py @@ -1,12 +1,14 @@ """ System resource utilities for the Socket Security CLI. """ + import logging # The resource module is only available on Unix-like systems resource_available = False try: import resource + resource_available = True except ImportError: # On Windows, the resource module is not available @@ -51,7 +53,7 @@ def check_file_count_against_ulimit(file_count, buffer_size=100): return { "can_check": False, "error": "Could not determine file descriptor limit", - "safe_to_process": True # Assume safe if we can't check + "safe_to_process": True, # Assume safe if we can't check } available_fds = soft_limit - buffer_size @@ -66,5 +68,7 @@ def check_file_count_against_ulimit(file_count, buffer_size=100): "would_exceed": would_exceed, "safe_to_process": not would_exceed, "buffer_size": buffer_size, - "recommendation": "Consider processing files in batches or increasing ulimit" if would_exceed else "Safe to process all files" + "recommendation": "Consider processing files in batches or increasing ulimit" + if would_exceed + else "Safe to process all files", } diff --git a/socketsecurity/core/scm/base.py b/socketsecurity/core/scm/base.py index 715f6de5..b67ae2d1 100644 --- a/socketsecurity/core/scm/base.py +++ b/socketsecurity/core/scm/base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from typing import Dict -from ..classes import Comment +from socketsecurity.core.classes import Comment + from .client import ScmClient @@ -12,26 +12,22 @@ def __init__(self, client: ScmClient): @abstractmethod def check_event_type(self) -> str: """Determine the type of event (push, pr, comment)""" - pass @abstractmethod def add_socket_comments( self, security_comment: str, overview_comment: str, - comments: Dict[str, Comment], + comments: dict[str, Comment], new_security_comment: bool = True, - new_overview_comment: bool = True + new_overview_comment: bool = True, ) -> None: """Add or update comments on PR""" - pass @abstractmethod - def get_comments_for_pr(self, repo: str, pr: str) -> Dict[str, Comment]: + def get_comments_for_pr(self, repo: str, pr: str) -> dict[str, Comment]: """Get existing comments for PR""" - pass @abstractmethod - def remove_comment_alerts(self, comments: Dict[str, Comment]) -> None: + def remove_comment_alerts(self, comments: dict[str, Comment]) -> None: """Process and remove alerts from comments""" - pass diff --git a/socketsecurity/core/scm/client.py b/socketsecurity/core/scm/client.py index 08769f3f..b7f0dc5f 100644 --- a/socketsecurity/core/scm/client.py +++ b/socketsecurity/core/scm/client.py @@ -1,9 +1,7 @@ from abc import abstractmethod -from typing import Dict from socketsecurity import USER_AGENT - -from ..cli_client import CliClient +from socketsecurity.core.cli_client import CliClient class ScmClient(CliClient): @@ -12,75 +10,52 @@ def __init__(self, token: str, api_url: str): self.api_url = api_url @abstractmethod - def get_headers(self) -> Dict: + def get_headers(self) -> dict: """Each SCM implements its own auth headers""" - pass def request(self, path: str, **kwargs): """Override base request to use SCM-specific headers and base_url""" - headers = kwargs.pop('headers', None) or self.get_headers() - return super().request( - path=path, - headers=headers, - base_url=self.api_url, - **kwargs - ) + headers = kwargs.pop("headers", None) or self.get_headers() + return super().request(path=path, headers=headers, base_url=self.api_url, **kwargs) + class GithubClient(ScmClient): - def get_headers(self) -> Dict: - return { - 'Authorization': f"Bearer {self.token}", - 'User-Agent': USER_AGENT, - "accept": "application/json" - } + def get_headers(self) -> dict: + return {"Authorization": f"Bearer {self.token}", "User-Agent": USER_AGENT, "accept": "application/json"} + class GitlabClient(ScmClient): - def get_headers(self) -> Dict: + def get_headers(self) -> dict: """ Determine the appropriate authentication headers for GitLab API. Uses the same logic as GitlabConfig._get_auth_headers() """ return self._get_gitlab_auth_headers(self.token) - + @staticmethod def _get_gitlab_auth_headers(token: str) -> dict: """ Determine the appropriate authentication headers for GitLab API. - + GitLab supports two authentication patterns: 1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope) 2. Private token (personal access tokens) """ import os - - base_headers = { - 'User-Agent': USER_AGENT, - "accept": "application/json" - } - + + base_headers = {"User-Agent": USER_AGENT, "accept": "application/json"} + # Check if this is a GitLab CI job token - if token == os.getenv('CI_JOB_TOKEN'): - return { - **base_headers, - 'Authorization': f"Bearer {token}" - } - + if token == os.getenv("CI_JOB_TOKEN"): + return {**base_headers, "Authorization": f"Bearer {token}"} + # Check for personal access token pattern - if token.startswith('glpat-'): - return { - **base_headers, - 'Authorization': f"Bearer {token}" - } - + if token.startswith("glpat-"): + return {**base_headers, "Authorization": f"Bearer {token}"} + # Check for OAuth token pattern (typically longer and alphanumeric) if len(token) > 40 and token.isalnum(): - return { - **base_headers, - 'Authorization': f"Bearer {token}" - } - + return {**base_headers, "Authorization": f"Bearer {token}"} + # Default to PRIVATE-TOKEN for other token types - return { - **base_headers, - 'PRIVATE-TOKEN': f"{token}" - } + return {**base_headers, "PRIVATE-TOKEN": f"{token}"} diff --git a/socketsecurity/core/scm/github.py b/socketsecurity/core/scm/github.py index dcfa5607..209ccdc0 100644 --- a/socketsecurity/core/scm/github.py +++ b/socketsecurity/core/scm/github.py @@ -17,6 +17,7 @@ @dataclass class GithubConfig: """Configuration from GitHub environment variables""" + sha: str api_url: str ref_type: str @@ -41,66 +42,58 @@ def _repository_from_buildkite() -> tuple[str, str]: """Return ``(owner, repository)`` from Buildkite's Git repository URL.""" # Comments and statuses belong to the pipeline/base repository, not a # contributor's fork from BUILDKITE_PULL_REQUEST_REPO. - _, repository_path = parse_git_remote( - os.getenv("BUILDKITE_REPO") or os.getenv("BUILDKITE_PULL_REQUEST_REPO") - ) + _, repository_path = parse_git_remote(os.getenv("BUILDKITE_REPO") or os.getenv("BUILDKITE_PULL_REQUEST_REPO")) parts = repository_path.split("/") if repository_path else [] if len(parts) < 2: return "", "" return parts[-2], parts[-1] @classmethod - def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': + def from_env(cls, pr_number: Optional[str] = None) -> "GithubConfig": """Create config from environment variables with optional overrides""" - token = os.getenv('GH_API_TOKEN') + token = os.getenv("GH_API_TOKEN") if not token: log.error("Unable to get Github API Token from GH_API_TOKEN") sys.exit(2) - + is_buildkite = os.getenv("BUILDKITE") == "true" buildkite_pr = os.getenv("BUILDKITE_PULL_REQUEST") - is_buildkite_pr = bool( - is_buildkite - and buildkite_pr - and buildkite_pr.casefold() != "false" - ) + is_buildkite_pr = bool(is_buildkite and buildkite_pr and buildkite_pr.casefold() != "false") # Use explicit/GitHub-compatible values first, then native Buildkite PR context. - pr_number = pr_number or os.getenv('PR_NUMBER') + pr_number = pr_number or os.getenv("PR_NUMBER") if not pr_number and is_buildkite_pr: pr_number = buildkite_pr - + # Add debug logging - sha = os.getenv('GITHUB_SHA') or ( - os.getenv("BUILDKITE_COMMIT", "") if is_buildkite else "" - ) + sha = os.getenv("GITHUB_SHA") or (os.getenv("BUILDKITE_COMMIT", "") if is_buildkite else "") log.debug(f"Loading GitHub integration SHA: {sha}") - event_action = os.getenv('EVENT_ACTION', None) + event_action = os.getenv("EVENT_ACTION", None) if not event_action: - event_path = os.getenv('GITHUB_EVENT_PATH') + event_path = os.getenv("GITHUB_EVENT_PATH") if event_path and os.path.exists(event_path): - with open(event_path, 'r') as f: + with open(event_path) as f: event = json.load(f) - event_action = event.get('action') + event_action = event.get("action") if not event_action and is_buildkite_pr: # Buildkite provides the current PR state, not the originating # GitHub webhook action. A running PR build is equivalent to the # supported synchronize path for comment updates. event_action = "synchronize" - repository = os.getenv('GITHUB_REPOSITORY', '') - owner = os.getenv('GITHUB_REPOSITORY_OWNER', '') - if '/' in repository: - owner = repository.split('/')[0] - repository = repository.split('/')[1] + repository = os.getenv("GITHUB_REPOSITORY", "") + owner = os.getenv("GITHUB_REPOSITORY_OWNER", "") + if "/" in repository: + owner = repository.split("/")[0] + repository = repository.split("/")[1] elif is_buildkite: buildkite_owner, buildkite_repository = cls._repository_from_buildkite() owner = owner or buildkite_owner repository = repository or buildkite_repository - default_branch_env = os.getenv('DEFAULT_BRANCH') + default_branch_env = os.getenv("DEFAULT_BRANCH") # Consider the variable truthy if it exists and isn't explicitly 'false' if default_branch_env is not None: - is_default = default_branch_env.lower() != 'false' + is_default = default_branch_env.lower() != "false" elif is_buildkite: # Require a branch name: comparing two unset variables would otherwise report # every build as the default branch and overwrite the repository's baseline. @@ -113,44 +106,29 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': else: is_default = False - event_name = os.getenv('GITHUB_EVENT_NAME', '') + event_name = os.getenv("GITHUB_EVENT_NAME", "") if not event_name and is_buildkite: event_name = "pull_request" if is_buildkite_pr else "push" return cls( sha=sha, - api_url=os.getenv('GITHUB_API_URL') or ( - "https://api.github.com" if is_buildkite else "" - ), - ref_type=os.getenv('GITHUB_REF_TYPE') or ( - "branch" if is_buildkite else "" - ), + api_url=os.getenv("GITHUB_API_URL") or ("https://api.github.com" if is_buildkite else ""), + ref_type=os.getenv("GITHUB_REF_TYPE") or ("branch" if is_buildkite else ""), event_name=event_name, - workspace=os.getenv('GITHUB_WORKSPACE') or ( - os.getenv("BUILDKITE_BUILD_CHECKOUT_PATH", "") if is_buildkite else "" - ), + workspace=os.getenv("GITHUB_WORKSPACE") + or (os.getenv("BUILDKITE_BUILD_CHECKOUT_PATH", "") if is_buildkite else ""), repository=repository, - ref_name=os.getenv('GITHUB_REF_NAME') or ( - os.getenv("BUILDKITE_BRANCH", "") if is_buildkite else "" - ), + ref_name=os.getenv("GITHUB_REF_NAME") or (os.getenv("BUILDKITE_BRANCH", "") if is_buildkite else ""), default_branch=is_default, is_default_branch=is_default, pr_number=pr_number, - pr_name=os.getenv('PR_NAME'), - commit_message=os.getenv('COMMIT_MESSAGE') or ( - os.getenv("BUILDKITE_MESSAGE") if is_buildkite else None - ), - actor=os.getenv('GITHUB_ACTOR') or ( - os.getenv("BUILDKITE_BUILD_CREATOR", "") if is_buildkite else "" - ), - env=os.getenv('GITHUB_ENV', ''), + pr_name=os.getenv("PR_NAME"), + commit_message=os.getenv("COMMIT_MESSAGE") or (os.getenv("BUILDKITE_MESSAGE") if is_buildkite else None), + actor=os.getenv("GITHUB_ACTOR") or (os.getenv("BUILDKITE_BUILD_CREATOR", "") if is_buildkite else ""), + env=os.getenv("GITHUB_ENV", ""), token=token, owner=owner, event_action=event_action, - headers={ - 'Authorization': f"Bearer {token}", - 'User-Agent': USER_AGENT, - "accept": "application/json" - } + headers={"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT, "accept": "application/json"}, ) @@ -180,8 +158,8 @@ def check_event_type(self) -> str: if not self.config.pr_number: return "main" return "diff" - elif self.config.event_name.lower() == "pull_request": - if self.config.event_action and self.config.event_action.lower() in ['opened', 'synchronize']: + if self.config.event_name.lower() == "pull_request": + if self.config.event_action and self.config.event_action.lower() in ["opened", "synchronize"]: return "diff" log.info(f"Pull Request Action {self.config.event_action} is not a supported type") sys.exit(0) @@ -195,22 +173,14 @@ def post_comment(self, body: str) -> None: path = f"repos/{self.config.owner}/{self.config.repository}/issues/{self.config.pr_number}/comments" payload = json.dumps({"body": body}) self.client.request( - path=path, - payload=payload, - method="POST", - headers=self.config.headers, - base_url=self.config.api_url + path=path, payload=payload, method="POST", headers=self.config.headers, base_url=self.config.api_url ) def update_comment(self, body: str, comment_id: str) -> None: path = f"repos/{self.config.owner}/{self.config.repository}/issues/comments/{comment_id}" payload = json.dumps({"body": body}) self.client.request( - path=path, - payload=payload, - method="PATCH", - headers=self.config.headers, - base_url=self.config.api_url + path=path, payload=payload, method="PATCH", headers=self.config.headers, base_url=self.config.api_url ) def write_new_env(self, name: str, content: str) -> None: @@ -221,11 +191,7 @@ def write_new_env(self, name: str, content: str) -> None: def get_comments_for_pr(self) -> dict: log.debug(f"Getting comments for Repo {self.config.repository} for PR {self.config.pr_number}") path = f"repos/{self.config.owner}/{self.config.repository}/issues/{self.config.pr_number}/comments" - response = self.client.request( - path=path, - headers=self.config.headers, - base_url=self.config.api_url - ) + response = self.client.request(path=path, headers=self.config.headers, base_url=self.config.api_url) raw_comments = Comments.process_response(response) comments = {} @@ -254,10 +220,7 @@ def is_ignore_authorized(self, comment: Comment) -> bool: elif author in self._ignore_permission_cache: permission = self._ignore_permission_cache[author] else: - path = ( - f"repos/{self.config.owner}/{self.config.repository}/" - f"collaborators/{author}/permission" - ) + path = f"repos/{self.config.owner}/{self.config.repository}/collaborators/{author}/permission" try: response = self.client.request( path=path, @@ -265,30 +228,21 @@ def is_ignore_authorized(self, comment: Comment) -> bool: base_url=self.config.api_url, ) result = response.json() - if not isinstance(result, dict) or not isinstance( - result.get("permission"), str - ): + if not isinstance(result, dict) or not isinstance(result.get("permission"), str): log.warning("Unexpected GitHub repository permission response") permission = None else: - permission = ( - result["permission"].casefold() in self.WRITE_PERMISSIONS - ) + permission = result["permission"].casefold() in self.WRITE_PERMISSIONS except APIFailure as error: if getattr(error, "status_code", None) == 404: # The repository was readable when its comments were listed, # so a missing collaborator permission is a definitive denial. permission = False else: - log.warning( - "Could not read GitHub repository permission for " - f"{author}: {error}" - ) + log.warning(f"Could not read GitHub repository permission for {author}: {error}") permission = None except Exception as error: - log.warning( - f"Could not read GitHub repository permission for {author}: {error}" - ) + log.warning(f"Could not read GitHub repository permission for {author}: {error}") permission = None self._ignore_permission_cache[author] = permission @@ -314,7 +268,7 @@ def add_socket_comments( overview_comment: str, comments: dict, new_security_comment: bool = True, - new_overview_comment: bool = True + new_overview_comment: bool = True, ) -> None: if new_overview_comment: log.debug("New Dependency Overview comment") @@ -349,11 +303,7 @@ def post_reaction(self, comment_id: int) -> None: path = f"repos/{self.config.owner}/{self.config.repository}/issues/comments/{comment_id}/reactions" payload = json.dumps({"content": "+1"}) self.client.request( - path=path, - payload=payload, - method="POST", - headers=self.config.headers, - base_url=self.config.api_url + path=path, payload=payload, method="POST", headers=self.config.headers, base_url=self.config.api_url ) def comment_reaction_exists(self, comment_id: int) -> bool: diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index d1fd1119..7165cec3 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -2,7 +2,6 @@ import os import sys from dataclasses import dataclass -from typing import Optional import requests from socketdev.exceptions import APIFailure @@ -13,16 +12,21 @@ from socketsecurity.core.scm_comments import Comments from socketsecurity.socketcli import CliClient +# GitLab API calls here are side-channel (MR settings, commit status); a hang +# must not wedge the pipeline the CLI is reporting into. +REQUEST_TIMEOUT_SECONDS = 30 + @dataclass class GitlabConfig: """Configuration from GitLab environment variables""" + commit_sha: str api_url: str project_dir: str - mr_source_branch: Optional[str] - mr_iid: Optional[str] - mr_project_id: Optional[str] + mr_source_branch: str | None + mr_iid: str | None + mr_project_id: str | None commit_message: str default_branch: str project_name: str @@ -34,97 +38,80 @@ class GitlabConfig: headers: dict @classmethod - def from_env(cls) -> 'GitlabConfig': - token = os.getenv('GITLAB_TOKEN') + def from_env(cls) -> "GitlabConfig": + token = os.getenv("GITLAB_TOKEN") if not token: log.error("Unable to get GitLab API Token from GITLAB_TOKEN") sys.exit(2) - project_name = os.getenv('CI_PROJECT_NAME', '') + project_name = os.getenv("CI_PROJECT_NAME", "") if "/" in project_name: project_name = project_name.rsplit("/")[1] - mr_source_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') - default_branch = os.getenv('CI_DEFAULT_BRANCH', '') + mr_source_branch = os.getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") + default_branch = os.getenv("CI_DEFAULT_BRANCH", "") # Determine which authentication pattern to use headers = cls._get_auth_headers(token) # Prefer source branch SHA (real commit) over CI_COMMIT_SHA which # may be a synthetic merge-result commit in merged-results pipelines. - commit_sha = ( - os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA') or - os.getenv('CI_COMMIT_SHA', '') - ) + commit_sha = os.getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA") or os.getenv("CI_COMMIT_SHA", "") return cls( commit_sha=commit_sha, - api_url=os.getenv('CI_API_V4_URL', ''), - project_dir=os.getenv('CI_PROJECT_DIR', ''), + api_url=os.getenv("CI_API_V4_URL", ""), + project_dir=os.getenv("CI_PROJECT_DIR", ""), mr_source_branch=mr_source_branch, - mr_iid=os.getenv('CI_MERGE_REQUEST_IID'), - mr_project_id=os.getenv('CI_MERGE_REQUEST_PROJECT_ID'), - commit_message=os.getenv('CI_COMMIT_MESSAGE', ''), + mr_iid=os.getenv("CI_MERGE_REQUEST_IID"), + mr_project_id=os.getenv("CI_MERGE_REQUEST_PROJECT_ID"), + commit_message=os.getenv("CI_COMMIT_MESSAGE", ""), default_branch=default_branch, project_name=project_name, - pipeline_source=os.getenv('CI_PIPELINE_SOURCE', ''), - commit_author=os.getenv('CI_COMMIT_AUTHOR', ''), + pipeline_source=os.getenv("CI_PIPELINE_SOURCE", ""), + commit_author=os.getenv("CI_COMMIT_AUTHOR", ""), token=token, repository=project_name, is_default_branch=(mr_source_branch == default_branch if mr_source_branch else False), - headers=headers + headers=headers, ) @staticmethod def _get_auth_headers(token: str) -> dict: """ Determine the appropriate authentication headers for GitLab API. - + GitLab supports two authentication patterns: 1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope) 2. Private token (personal access tokens) - + Logic for token type determination: - CI_JOB_TOKEN: Always use Bearer (GitLab CI job token) - Tokens starting with 'glpat-': Personal access tokens, try Bearer first - OAuth tokens: Use Bearer - Other tokens: Use PRIVATE-TOKEN as fallback """ - base_headers = { - 'User-Agent': USER_AGENT, - "accept": "application/json" - } - + base_headers = {"User-Agent": USER_AGENT, "accept": "application/json"} + # Check if this is a GitLab CI job token - if token == os.getenv('CI_JOB_TOKEN'): + if token == os.getenv("CI_JOB_TOKEN"): log.debug("Using Bearer authentication for GitLab CI job token") - return { - **base_headers, - 'Authorization': f"Bearer {token}" - } - + return {**base_headers, "Authorization": f"Bearer {token}"} + # Check for personal access token pattern - if token.startswith('glpat-'): + if token.startswith("glpat-"): log.debug("Using Bearer authentication for GitLab personal access token") - return { - **base_headers, - 'Authorization': f"Bearer {token}" - } - + return {**base_headers, "Authorization": f"Bearer {token}"} + # Check for OAuth token pattern (typically longer and alphanumeric) if len(token) > 40 and token.isalnum(): log.debug("Using Bearer authentication for potential OAuth token") - return { - **base_headers, - 'Authorization': f"Bearer {token}" - } - + return {**base_headers, "Authorization": f"Bearer {token}"} + # Default to PRIVATE-TOKEN for other token types log.debug("Using PRIVATE-TOKEN authentication for GitLab token") - return { - **base_headers, - 'PRIVATE-TOKEN': f"{token}" - } + return {**base_headers, "PRIVATE-TOKEN": f"{token}"} + class Gitlab: # GitLab access levels: 30 Developer, 40 Maintainer, 50 Owner. Reporter (20) @@ -138,7 +125,7 @@ class Gitlab: def __init__( self, client: CliClient, - config: Optional[GitlabConfig] = None, + config: GitlabConfig | None = None, ignore_authorization: str = "enforce", ): self.config = config or GitlabConfig.from_env() @@ -146,7 +133,7 @@ def __init__( self.ignore_authorization = ignore_authorization # None until the first ignore comment forces a lookup; stays None when the # members API cannot be read, which is the "undetermined" state. - self._member_access: Optional[dict] = None + self._member_access: dict | None = None self._member_lookup_attempted = False def _request_with_fallback(self, **kwargs): @@ -167,13 +154,13 @@ def _request_with_fallback(self, **kwargs): raise log.debug("Authentication failed with initial headers, trying fallback method") - original_headers = kwargs.get('headers', self.config.headers) + original_headers = kwargs.get("headers", self.config.headers) fallback_headers = self._get_fallback_headers(original_headers) if not fallback_headers or fallback_headers == original_headers: raise log.debug("Retrying request with fallback authentication method") - kwargs['headers'] = fallback_headers + kwargs["headers"] = fallback_headers return self.client.request(**kwargs) def _get_fallback_headers(self, original_headers: dict) -> dict: @@ -181,75 +168,55 @@ def _get_fallback_headers(self, original_headers: dict) -> dict: Generate fallback authentication headers. If using Bearer, fallback to PRIVATE-TOKEN and vice versa. """ - base_headers = { - 'User-Agent': USER_AGENT, - "accept": "application/json" - } - + base_headers = {"User-Agent": USER_AGENT, "accept": "application/json"} + # If currently using Bearer, try PRIVATE-TOKEN - if 'Authorization' in original_headers and 'Bearer' in original_headers['Authorization']: + if "Authorization" in original_headers and "Bearer" in original_headers["Authorization"]: log.debug("Falling back from Bearer to PRIVATE-TOKEN authentication") - return { - **base_headers, - 'PRIVATE-TOKEN': f"{self.config.token}" - } - + return {**base_headers, "PRIVATE-TOKEN": f"{self.config.token}"} + # If currently using PRIVATE-TOKEN, try Bearer - elif 'PRIVATE-TOKEN' in original_headers: + if "PRIVATE-TOKEN" in original_headers: log.debug("Falling back from PRIVATE-TOKEN to Bearer authentication") - return { - **base_headers, - 'Authorization': f"Bearer {self.config.token}" - } - + return {**base_headers, "Authorization": f"Bearer {self.config.token}"} + # No fallback available return {} def check_event_type(self) -> str: pipeline_source = self.config.pipeline_source.lower() - if pipeline_source in ["web", 'merge_request_event', "push", "api", 'pipeline']: + if pipeline_source in ["web", "merge_request_event", "push", "api", "pipeline"]: if not self.config.mr_iid: return "main" return "diff" - elif pipeline_source == "issue_comment": + if pipeline_source == "issue_comment": return "comment" - else: - log.error(f"Unknown event type {pipeline_source}") - sys.exit(0) + log.error(f"Unknown event type {pipeline_source}") + sys.exit(0) def post_comment(self, body: str) -> None: path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes" payload = {"body": body} self._request_with_fallback( - path=path, - payload=payload, - method="POST", - headers=self.config.headers, - base_url=self.config.api_url + path=path, payload=payload, method="POST", headers=self.config.headers, base_url=self.config.api_url ) def update_comment(self, body: str, comment_id: str) -> None: path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}" payload = {"body": body} self._request_with_fallback( - path=path, - payload=payload, - method="PUT", - headers=self.config.headers, - base_url=self.config.api_url + path=path, payload=payload, method="PUT", headers=self.config.headers, base_url=self.config.api_url ) def has_thumbsup_reaction(self, comment_id: int) -> bool: """Best-effort check for 'thumbsup' award emoji on a MR note.""" if not self.config.mr_project_id or not self.config.mr_iid: return False - path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji" + path = ( + f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji" + ) try: - response = self._request_with_fallback( - path=path, - headers=self.config.headers, - base_url=self.config.api_url - ) + response = self._request_with_fallback(path=path, headers=self.config.headers, base_url=self.config.api_url) for emoji in response.json(): if emoji.get("name") == "thumbsup": return True @@ -260,11 +227,7 @@ def has_thumbsup_reaction(self, comment_id: int) -> bool: def get_comments_for_pr(self) -> dict: log.debug(f"Getting Gitlab comments for Repo {self.config.repository} for PR {self.config.mr_iid}") path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes" - response = self._request_with_fallback( - path=path, - headers=self.config.headers, - base_url=self.config.api_url - ) + response = self._request_with_fallback(path=path, headers=self.config.headers, base_url=self.config.api_url) raw_comments = Comments.process_response(response) comments = {} if "message" not in raw_comments: @@ -277,7 +240,7 @@ def get_comments_for_pr(self) -> dict: gate = None if self.ignore_authorization == "off" else self.is_ignore_authorized return Comments.check_for_socket_comments(comments, gate) - def _load_member_access(self) -> Optional[dict]: + def _load_member_access(self) -> dict | None: """Map project member user id -> access level, or None if unreadable. ``members/all`` is used rather than a per-user lookup because it answers @@ -294,15 +257,10 @@ def _load_member_access(self) -> Optional[dict]: access: dict = {} for page in range(1, Gitlab.MEMBER_PAGE_LIMIT + 1): - path = ( - f"projects/{self.config.mr_project_id}/members/all" - f"?per_page={Gitlab.MEMBER_PAGE_SIZE}&page={page}" - ) + path = f"projects/{self.config.mr_project_id}/members/all?per_page={Gitlab.MEMBER_PAGE_SIZE}&page={page}" try: response = self._request_with_fallback( - path=path, - headers=self.config.headers, - base_url=self.config.api_url + path=path, headers=self.config.headers, base_url=self.config.api_url ) members = response.json() except Exception as error: @@ -358,12 +316,12 @@ def is_ignore_authorized(self, comment: Comment) -> bool: return access.get(user_id, 0) >= Gitlab.MIN_IGNORE_ACCESS_LEVEL def add_socket_comments( - self, - security_comment: str, - overview_comment: str, - comments: dict, - new_security_comment: bool = True, - new_overview_comment: bool = True + self, + security_comment: str, + overview_comment: str, + comments: dict, + new_security_comment: bool = True, + new_overview_comment: bool = True, ) -> None: existing_overview_comment = comments.get("overview") existing_security_comment = comments.get("security") @@ -396,6 +354,7 @@ def enable_merge_pipeline_check(self) -> None: url, json={"only_allow_merge_if_pipeline_succeeds": True}, headers=self.config.headers, + timeout=REQUEST_TIMEOUT_SECONDS, ) if resp.status_code == 401: fallback = self._get_fallback_headers(self.config.headers) @@ -404,6 +363,7 @@ def enable_merge_pipeline_check(self) -> None: url, json={"only_allow_merge_if_pipeline_succeeds": True}, headers=fallback, + timeout=REQUEST_TIMEOUT_SECONDS, ) if resp.status_code >= 400: log.error(f"GitLab enable merge check API {resp.status_code}: {resp.text}") @@ -412,7 +372,7 @@ def enable_merge_pipeline_check(self) -> None: except Exception as e: log.error(f"Failed to enable merge pipeline check: {e}") - def set_commit_status(self, state: str, description: str, target_url: str = '') -> None: + def set_commit_status(self, state: str, description: str, target_url: str = "") -> None: """Post a commit status to GitLab. state should be 'success' or 'failed'. Uses requests.post with json= directly because CliClient.request sends @@ -433,11 +393,11 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') payload["target_url"] = target_url try: log.debug(f"Posting commit status to {url}") - resp = requests.post(url, json=payload, headers=self.config.headers) + resp = requests.post(url, json=payload, headers=self.config.headers, timeout=REQUEST_TIMEOUT_SECONDS) if resp.status_code == 401: fallback = self._get_fallback_headers(self.config.headers) if fallback: - resp = requests.post(url, json=payload, headers=fallback) + resp = requests.post(url, json=payload, headers=fallback, timeout=REQUEST_TIMEOUT_SECONDS) if resp.status_code >= 400: log.error(f"GitLab commit status API {resp.status_code}: {resp.text}") resp.raise_for_status() @@ -449,7 +409,9 @@ def post_thumbsup_reaction(self, comment_id: int) -> None: """Best-effort: add 'thumbsup' award emoji to a MR note.""" if not self.config.mr_project_id or not self.config.mr_iid: return - path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji" + path = ( + f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji" + ) try: headers = {**self.config.headers, "Content-Type": "application/json"} self._request_with_fallback( @@ -457,7 +419,7 @@ def post_thumbsup_reaction(self, comment_id: int) -> None: payload=json.dumps({"name": "thumbsup"}), method="POST", headers=headers, - base_url=self.config.api_url + base_url=self.config.api_url, ) except Exception as e: log.debug(f"Could not add thumbsup emoji to note {comment_id} (best effort): {e}") diff --git a/socketsecurity/core/scm_comments.py b/socketsecurity/core/scm_comments.py index 3ef0e3a6..cadcfb7c 100644 --- a/socketsecurity/core/scm_comments.py +++ b/socketsecurity/core/scm_comments.py @@ -1,6 +1,6 @@ import json import re -from typing import Callable, Optional +from collections.abc import Callable from requests import Response @@ -43,15 +43,14 @@ def remove_alerts(comments: dict, new_alerts: list) -> list: alert: Issue if ignore_all: break + if any( + Comments.is_ignore(alert.pkg_name, alert.pkg_version, name, version, alert.pkg_type) + for name, version in ignore_commands + ): + log.info(f"Alerts for {alert.pkg_name}@{alert.pkg_version} ignored") else: - if any( - Comments.is_ignore(alert.pkg_name, alert.pkg_version, name, version, alert.pkg_type) - for name, version in ignore_commands - ): - log.info(f"Alerts for {alert.pkg_name}@{alert.pkg_version} ignored") - else: - log.info(f"Adding alert {alert.type} for {alert.pkg_name}@{alert.pkg_version}") - alerts.append(alert) + log.info(f"Adding alert {alert.type} for {alert.pkg_name}@{alert.pkg_version}") + alerts.append(alert) return alerts @staticmethod @@ -84,10 +83,7 @@ def get_ignore_options(comments: dict) -> [bool, list]: return ignore_all, ignore_commands @staticmethod - def is_ignore( - pkg_name: str, pkg_version: str, name: str, version: str, - pkg_type: str = "" - ) -> bool: + def is_ignore(pkg_name: str, pkg_version: str, name: str, version: str, pkg_type: str = "") -> bool: """Match an alert's package against one parsed ignore command. Generated commands are ecosystem-qualified (``npm/lodash@4.17.21``) but @@ -107,7 +103,7 @@ def is_ignore( target_names = {name} if not pkg_type and "/" in name and not name.startswith("@"): target_names.add(name.split("/", 1)[1]) - return bool(package_names & target_names) and (pkg_version == version or version == "*") + return bool(package_names & target_names) and version in (pkg_version, "*") @staticmethod def is_heading_line(line) -> bool: @@ -141,7 +137,7 @@ def process_security_comment(comment: Comment, comments) -> str: return new_body @staticmethod - def parse_alert_table_row(line: str) -> Optional[tuple[str, str, str]]: + def parse_alert_table_row(line: str) -> tuple[str, str, str] | None: """Pull ``(ecosystem, package, version)`` out of a legacy alert table row. Returns None for any row that does not have the expected shape rather than @@ -169,19 +165,17 @@ def parse_alert_table_row(line: str) -> Optional[tuple[str, str, str]]: @staticmethod def process_original_security_comment( - comment: Comment, - ignore_all: bool, - ignore_commands: list[tuple[str, str]] + comment: Comment, ignore_all: bool, ignore_commands: list[tuple[str, str]] ) -> str: start = False lines = [] kept_alert = False - for line in comment.body_list: - line = line.strip() + for raw_line in comment.body_list: + line = raw_line.strip() if "start-socket-alerts-table" in line: start = True lines.append(line) - elif start and "end-socket-alerts-table" not in line and not Comments.is_heading_line(line) and line != '': + elif start and "end-socket-alerts-table" not in line and not Comments.is_heading_line(line) and line != "": parsed = Comments.parse_alert_table_row(line) # ignore_all has to be checked outside the loop: an ignore-all # comment produces no ignore_commands, so a loop-internal check @@ -213,9 +207,7 @@ def process_original_security_comment( @staticmethod def process_updated_security_comment( - comment: Comment, - ignore_all: bool, - ignore_commands: list[tuple[str, str]] + comment: Comment, ignore_all: bool, ignore_commands: list[tuple[str, str]] ) -> str: """ Processes an updated security comment containing an HTML table with alert sections. @@ -233,25 +225,23 @@ def process_updated_security_comment( pkg_name = pkg_version = "" # Track current package and version # Loop through the comment lines - for line in comment.body_list: - # Match on the stripped line but keep the original, so the markup is - # rewritten with the same indentation it was generated with. - line = line.rstrip("\r") + for raw_line in comment.body_list: + # Preserve indentation when rewriting the markup. + line = raw_line.rstrip("\r") stripped = line.strip() # Detect the start of an alert section if stripped.startswith("" in body def test_copy_is_provider_neutral(self): - body = Messages.security_comment_template( - _make_diff([_make_alert()]), _FakeConfig(scm="gitlab") - ) + body = Messages.security_comment_template(_make_diff([_make_alert()]), _FakeConfig(scm="gitlab")) assert "Socket for GitHub" not in body assert "Learn more about [Socket]" in body @@ -193,6 +188,7 @@ def test_no_alerts_without_report_url_omits_the_link(self): # --- Ignore round trip --- + def _security_comment_with(alerts: list, config=None) -> Comment: body = Messages.security_comment_template(_make_diff(alerts), config or _FakeConfig()) return _make_comment(body) @@ -200,10 +196,12 @@ def _security_comment_with(alerts: list, config=None) -> Comment: class TestProcessUpdatedSecurityComment: def _two_alert_comment(self) -> Comment: - return _security_comment_with([ - _make_alert(), - _make_alert(pkg_name="express", pkg_version="4.18.2", purl="pkg:npm/express@4.18.2"), - ]) + return _security_comment_with( + [ + _make_alert(), + _make_alert(pkg_name="express", pkg_version="4.18.2", purl="pkg:npm/express@4.18.2"), + ] + ) def test_partial_ignore_keeps_remaining_alert_render_safe(self): security = self._two_alert_comment() @@ -242,18 +240,22 @@ def test_ignoring_every_alert_individually_collapses_too(self): assert "No dependency alerts to report" in new_body def test_qualified_scoped_package_ignore_matches_comment_marker(self): - security = _security_comment_with([ - _make_alert( - pkg_name="@socketsecurity/example", - purl="pkg:npm/@socketsecurity/example@4.17.21", - ) - ]) + security = _security_comment_with( + [ + _make_alert( + pkg_name="@socketsecurity/example", + purl="pkg:npm/@socketsecurity/example@4.17.21", + ) + ] + ) comments = { "security": security, - "ignore": [_make_comment( - "SocketSecurity ignore npm/@socketsecurity/example@4.17.21", - comment_id=2, - )], + "ignore": [ + _make_comment( + "SocketSecurity ignore npm/@socketsecurity/example@4.17.21", + comment_id=2, + ) + ], } assert "No dependency alerts to report" in Comments.process_security_comment(security, comments) @@ -353,9 +355,7 @@ def test_scoped_package_row_is_ignorable_both_ways(self): class TestExtractReportUrl: def test_strips_the_action_filter(self): - url = Comments.extract_report_url( - "[View full report](https://socket.dev/report/abc?action=error%2Cwarn)" - ) + url = Comments.extract_report_url("[View full report](https://socket.dev/report/abc?action=error%2Cwarn)") assert url == "https://socket.dev/report/abc" def test_returns_empty_when_absent(self): @@ -380,6 +380,7 @@ class _RepoConfig(_FakeConfig): percent-encoded Socket link, and the path never lands in the comment -- so a test using the bare config asserts nothing. """ + repository_url: str = "https://github.com/acme/widgets" branch: str = "main" @@ -396,9 +397,7 @@ class _RepoConfig(_FakeConfig): def _rendered_with_path(path: str) -> str: - return Messages.security_comment_template( - _make_diff([_make_alert(manifests=path)]), _RepoConfig() - ) + return Messages.security_comment_template(_make_diff([_make_alert(manifests=path)]), _RepoConfig()) def test_the_hostile_path_actually_reaches_the_comment(): @@ -417,7 +416,7 @@ def test_hostile_manifest_path_cannot_introduce_markup(name, path): Markdown itself is live.""" body = _rendered_with_path(path) - rendered = [ln for ln in body.split("\n") if "Manifest File" in ln][0] + rendered = next(ln for ln in body.split("\n") if "Manifest File" in ln) value = rendered.split('href="', 1)[1].split('"', 1)[0] for char in ("<", ">", '"'): @@ -433,9 +432,7 @@ def test_quote_in_a_path_cannot_escape_the_href(): def test_hostile_package_name_cannot_close_the_alert_marker(): - body = Messages.security_comment_template( - _make_diff([_make_alert(pkg_name="evil-->x")]), _FakeConfig() - ) + body = Messages.security_comment_template(_make_diff([_make_alert(pkg_name="evil-->x")]), _FakeConfig()) # Exactly the terminator the CLI wrote, and no stray one inside the value. for line in body.split("\n"): diff --git a/tests/unit/test_pull_request_context.py b/tests/unit/test_pull_request_context.py index b1a6dd80..f005b60c 100644 --- a/tests/unit/test_pull_request_context.py +++ b/tests/unit/test_pull_request_context.py @@ -120,9 +120,7 @@ def test_azure_fork_context_uses_target_repository_url(): env={ "SYSTEM_PULLREQUEST_PULLREQUESTID": "17", "BUILD_REPOSITORY_URI": "https://dev.azure.com/acme/platform/_git/widgets", - "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI": ( - "https://dev.azure.com/contributor/forks/_git/widgets" - ), + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI": ("https://dev.azure.com/contributor/forks/_git/widgets"), }, ) @@ -231,15 +229,27 @@ def test_unusable_gitlab_server_url_yields_no_link(server): def test_self_hosted_server_urls_are_still_honored(): - assert resolve_pull_request_context( - "github", "42", None, configured_explicit=True, - env={"GITHUB_SERVER_URL": "https://github.example.com", "GITHUB_REPOSITORY": "acme/widgets"}, - ).url == "https://github.example.com/acme/widgets/pull/42" + assert ( + resolve_pull_request_context( + "github", + "42", + None, + configured_explicit=True, + env={"GITHUB_SERVER_URL": "https://github.example.com", "GITHUB_REPOSITORY": "acme/widgets"}, + ).url + == "https://github.example.com/acme/widgets/pull/42" + ) - assert resolve_pull_request_context( - "gitlab", "42", None, configured_explicit=True, - env={"CI_SERVER_URL": "http://gitlab.internal", "CI_PROJECT_PATH": "acme/platform/widgets"}, - ).url == "http://gitlab.internal/acme/platform/widgets/-/merge_requests/42" + assert ( + resolve_pull_request_context( + "gitlab", + "42", + None, + configured_explicit=True, + env={"CI_SERVER_URL": "http://gitlab.internal", "CI_PROJECT_PATH": "acme/platform/widgets"}, + ).url + == "http://gitlab.internal/acme/platform/widgets/-/merge_requests/42" + ) def test_github_actions_environment_wins_over_the_checkout_remote(): @@ -287,9 +297,7 @@ def test_gitlab_ci_project_url_wins_over_the_checkout_remote(): def test_bare_repository_name_alone_yields_no_url(): """config.repo has no owner segment, so it cannot stand in for a slug.""" - context = resolve_pull_request_context( - "github", "42", "widgets", configured_explicit=True, env={} - ) + context = resolve_pull_request_context("github", "42", "widgets", configured_explicit=True, env={}) assert context.number == 42 assert context.url is None diff --git a/tests/unit/test_reachability.py b/tests/unit/test_reachability.py index f2c37095..0fba5389 100644 --- a/tests/unit/test_reachability.py +++ b/tests/unit/test_reachability.py @@ -3,6 +3,7 @@ A fake cli-wrapper.mjs is installed into a temporary cache with the real coana-cache.mjs, so nothing is mocked. The fake records the args, cwd and environment it was run with. """ + import json import os import subprocess diff --git a/tests/unit/test_redaction.py b/tests/unit/test_redaction.py new file mode 100644 index 00000000..0c2151cd --- /dev/null +++ b/tests/unit/test_redaction.py @@ -0,0 +1,192 @@ +"""Tests for credential redaction in log output. + +Log output can be retained by CI and forwarded elsewhere, so credentials must +not reach it. +""" + +import dataclasses +import json +import logging + +import pytest + +from socketsecurity.config import CliConfig +from socketsecurity.core.classes import Diff +from socketsecurity.output import OutputHandler +from socketsecurity.redaction import REDACTED, is_sensitive_name, redact_mapping, redact_url + +TOKEN = "sk-not-a-real-token-abc123" +# Ignore credentials loaded from a developer's environment or .env file. +_TOKEN_ENV_VARS = ( + "SOCKET_SECURITY_API_KEY", + "SOCKET_SECURITY_API_TOKEN", + "SOCKET_API_KEY", + "SOCKET_API_TOKEN", +) + + +@pytest.fixture(autouse=True) +def _clear_token_env(monkeypatch): + for name in _TOKEN_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +WEBHOOK = "https://hooks.slack.com/services/T00000/B00000/XXXXXXXXsecretXXXXXXXX" + + +@pytest.mark.parametrize( + "name", + [ + "api_token", + "API_TOKEN", + "github_token", + "slack_webhook", + "client_secret", + "password", + "auth_header", + "api_key", + "authorization", + "ignore_authorization_token", + ], +) +def test_credential_field_names_are_recognized(name): + assert is_sensitive_name(name) + + +@pytest.mark.parametrize( + "name", + [ + "repo", + "branch", + "commit_sha", + "target_path", + "scm", + "enable_debug", + "ignore_authorization", + "IGNORE_AUTHORIZATION", + ], +) +def test_ordinary_field_names_are_not_recognized(name): + assert not is_sensitive_name(name) + + +def test_webhook_url_keeps_the_host_and_drops_the_secret_path(): + redacted = redact_url(WEBHOOK) + assert redacted == "https://hooks.slack.com/***redacted***" + assert "secret" not in redacted + + +def test_redact_url_strips_userinfo(): + assert redact_url("https://user:hunter2@example.com:8443/path?q=1") == "https://example.com:8443/***redacted***" + assert "hunter2" not in redact_url("https://user:hunter2@example.com/x") + + +@pytest.mark.parametrize("value", ["https://[broken/path", "https://example.com:bad-port/secret"]) +def test_malformed_urls_are_masked(value): + assert redact_url(value) == REDACTED + assert redact_mapping({"webhook_url": value})["webhook_url"] == REDACTED + + +def test_ipv6_url_keeps_a_valid_host(): + assert redact_url("https://[::1]:8443/secret") == "https://[::1]:8443/***redacted***" + + +@pytest.mark.parametrize("value", ["Not configured", "", None, "not-a-url"]) +def test_non_urls_pass_through_so_placeholders_stay_readable(value): + assert redact_url(value) == value + + +def test_redact_mapping_masks_secrets_and_keeps_everything_else(): + out = redact_mapping({"api_token": TOKEN, "slack_webhook": WEBHOOK, "repo": "acme/widgets", "enable_debug": True}) + assert out["api_token"] == REDACTED + assert out["slack_webhook"] == "https://hooks.slack.com/***redacted***" + assert out["repo"] == "acme/widgets" + assert out["enable_debug"] is True + + +def test_nested_plugin_settings_are_redacted_without_mutating_the_config(): + data = {"plugins": [{"config": {"token": TOKEN, "url": WEBHOOK, "channel": "alerts"}}]} + redacted = redact_mapping(data) + plugin = redacted["plugins"][0]["config"] + assert plugin["token"] == REDACTED + assert plugin["url"] == "https://hooks.slack.com/***redacted***" + assert plugin["channel"] == "alerts" + assert data["plugins"][0]["config"]["token"] == TOKEN + + +@pytest.mark.parametrize("empty", ["", None]) +def test_unset_secrets_are_left_alone(empty): + """ "No token configured" is useful debugging information, not a secret.""" + assert redact_mapping({"api_token": empty})["api_token"] == empty + + +def test_config_to_dict_is_still_a_faithful_serializer(): + config = CliConfig.from_args(["--api-token", TOKEN, "--repo", "widgets"]) + assert config.to_dict()["api_token"] == TOKEN + + +def test_config_to_redacted_dict_masks_the_api_token(): + config = CliConfig.from_args(["--api-token", TOKEN, "--repo", "widgets"]) + assert config.to_redacted_dict()["api_token"] == REDACTED + assert TOKEN not in str(config.to_redacted_dict()) + + +@pytest.mark.parametrize("policy", ["enforce", "strict", "off"]) +def test_ignore_authorization_policy_remains_visible_in_config_debug_output(policy): + config = CliConfig.from_args(["--api-token", TOKEN, "--repo", "widgets", "--ignore-authorization", policy]) + view = config.to_redacted_dict() + assert view["ignore_authorization"] == policy + assert view["api_token"] == REDACTED + assert redact_mapping({"authorization": f"Bearer {TOKEN}"})["authorization"] == REDACTED + + +def test_every_credential_field_on_cliconfig_is_redacted(): + """All credential-named config fields must be masked by the logging view.""" + config = CliConfig.from_args(["--api-token", TOKEN, "--repo", "widgets"]) + sentinels = {} + for field in dataclasses.fields(CliConfig): + if is_sensitive_name(field.name): + sentinel = f"SENTINEL-{field.name}-value" + setattr(config, field.name, sentinel) + sentinels[field.name] = sentinel + + assert sentinels, "expected CliConfig to declare at least one credential field" + rendered = str(config.to_redacted_dict()) + for name, sentinel in sentinels.items(): + assert sentinel not in rendered, f"{name} leaked into the redacted config" + + +def test_the_config_debug_line_does_not_emit_the_token(caplog): + """The configuration debug view must mask credentials.""" + config = CliConfig.from_args(["--api-token", TOKEN, "--repo", "widgets"]) + log = logging.getLogger("socketcli") + with caplog.at_level(logging.DEBUG, logger="socketcli"): + log.debug(f"config: {config.to_redacted_dict()}") + assert TOKEN not in caplog.text + assert REDACTED in caplog.text + + +@pytest.mark.parametrize( + ("urls", "expected_url"), + [ + (WEBHOOK, f"https://hooks.slack.com/{REDACTED}"), + ([WEBHOOK, "https://hooks.slack.com/services/another-secret"], REDACTED), + ([{"url": WEBHOOK, "name": "alerts"}], REDACTED), + ([], "Not configured"), + (None, "Not configured"), + ("", "Not configured"), + ], +) +def test_slack_debug_output_masks_environment_config_and_webhook(monkeypatch, mocker, caplog, urls, expected_url): + monkeypatch.setenv("SOCKET_SLACK_CONFIG_JSON", json.dumps({"url": urls, "token": TOKEN})) + config = CliConfig.from_args(["--api-token", TOKEN, "--repo", "widgets", "--enable-debug"]) + handler = OutputHandler(config, sdk=mocker.Mock()) + mocker.patch.object(handler, "output_console_comments") + with caplog.at_level(logging.DEBUG, logger="socketcli"): + handler.handle_output(Diff(id="scan", diff_url="https://socket.dev/report")) + assert TOKEN not in caplog.text + assert "XXXXXXXXsecretXXXXXXXX" not in caplog.text + assert "another-secret" not in caplog.text + assert "SOCKET_SLACK_CONFIG_JSON environment variable: Set" in caplog.text + assert f"Slack Webhook URL: {expected_url}" in caplog.text + assert config.slack_plugin.config["url"] == urls diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py index bedd2c97..6850fa91 100644 --- a/tests/unit/test_scan_scope.py +++ b/tests/unit/test_scan_scope.py @@ -130,11 +130,7 @@ def test_diff_api_failure_propagates_for_cli_exit_code_mapping(caplog): core.get_diff_scan_artifacts = MagicMock(side_effect=RuntimeError("poll failed")) core.sdk.fullscans.stream_diff.side_effect = APIFailure("comparison failed") - with caplog.at_level("INFO", logger="socketdev"): - with pytest.raises(APIFailure, match="comparison failed"): - core.get_added_and_removed_packages("base-scan", "new-scan") + with caplog.at_level("INFO", logger="socketdev"), pytest.raises(APIFailure, match="comparison failed"): + core.get_added_and_removed_packages("base-scan", "new-scan") - assert ( - "Diff comparison mode: requested=diff-scan effective=streaming " - "reason=RuntimeError" - ) in caplog.messages + assert ("Diff comparison mode: requested=diff-scan effective=streaming reason=RuntimeError") in caplog.messages diff --git a/tests/unit/test_slack_plugin.py b/tests/unit/test_slack_plugin.py index 56c05b5c..786fef1a 100644 --- a/tests/unit/test_slack_plugin.py +++ b/tests/unit/test_slack_plugin.py @@ -26,12 +26,14 @@ def _issue(pkg_name: str, ghsa_id: str, error: bool = False) -> Issue: def test_slack_diff_alerts_include_unchanged_when_strict_blocking(): - plugin = SlackPlugin({ - "enabled": True, - "mode": "webhook", - "url": "https://hooks.slack.com/services/test", - "url_configs": {"default": {}}, - }) + plugin = SlackPlugin( + { + "enabled": True, + "mode": "webhook", + "url": "https://hooks.slack.com/services/test", + "url_configs": {"default": {}}, + } + ) cfg = SimpleNamespace( repo="example-repo", reach=False, @@ -51,8 +53,10 @@ def _capture(diff_arg, _config): captured_titles.extend([a.title for a in diff_arg.new_alerts]) return [{"type": "section", "text": {"type": "mrkdwn", "text": "ok"}}] - with patch.object(SlackPlugin, "create_slack_blocks_from_diff", side_effect=_capture), \ - patch("socketsecurity.plugins.slack.requests.post") as mock_post: + with ( + patch.object(SlackPlugin, "create_slack_blocks_from_diff", side_effect=_capture), + patch("socketsecurity.plugins.slack.requests.post") as mock_post, + ): mock_post.return_value = Mock(status_code=200, text="ok") plugin._send_webhook_alerts(diff, cfg) @@ -62,37 +66,48 @@ def _capture(diff_arg, _config): def test_slack_reachability_alerts_only_uses_facts_reachability(tmp_path): facts_path = tmp_path / ".socket.facts.json" - facts_path.write_text(json.dumps({ - "components": [ - { - "type": "npm", - "name": "reachable-pkg", - "version": "1.0.0", - "vulnerabilities": [{"ghsaId": "GHSA-AAAA-BBBB-CCCC", "severity": "HIGH"}], - "reachability": [{ - "ghsa_id": "GHSA-AAAA-BBBB-CCCC", - "reachability": [{"type": "reachable"}], - }], - }, + facts_path.write_text( + json.dumps( { - "type": "npm", - "name": "unreachable-pkg", - "version": "1.0.0", - "vulnerabilities": [{"ghsaId": "GHSA-DDDD-EEEE-FFFF", "severity": "HIGH"}], - "reachability": [{ - "ghsa_id": "GHSA-DDDD-EEEE-FFFF", - "reachability": [{"type": "unreachable"}], - }], - }, - ], - }), encoding="utf-8") - - plugin = SlackPlugin({ - "enabled": True, - "mode": "webhook", - "url": "https://hooks.slack.com/services/test", - "url_configs": {"default": {"reachability_alerts_only": True}}, - }) + "components": [ + { + "type": "npm", + "name": "reachable-pkg", + "version": "1.0.0", + "vulnerabilities": [{"ghsaId": "GHSA-AAAA-BBBB-CCCC", "severity": "HIGH"}], + "reachability": [ + { + "ghsa_id": "GHSA-AAAA-BBBB-CCCC", + "reachability": [{"type": "reachable"}], + } + ], + }, + { + "type": "npm", + "name": "unreachable-pkg", + "version": "1.0.0", + "vulnerabilities": [{"ghsaId": "GHSA-DDDD-EEEE-FFFF", "severity": "HIGH"}], + "reachability": [ + { + "ghsa_id": "GHSA-DDDD-EEEE-FFFF", + "reachability": [{"type": "unreachable"}], + } + ], + }, + ], + } + ), + encoding="utf-8", + ) + + plugin = SlackPlugin( + { + "enabled": True, + "mode": "webhook", + "url": "https://hooks.slack.com/services/test", + "url_configs": {"default": {"reachability_alerts_only": True}}, + } + ) cfg = SimpleNamespace( repo="example-repo", reach=True, @@ -113,9 +128,11 @@ def _capture(diff_arg, _config): captured_titles.extend([a.title for a in diff_arg.new_alerts]) return [{"type": "section", "text": {"type": "mrkdwn", "text": "ok"}}] - with patch.object(SlackPlugin, "create_slack_blocks_from_diff", side_effect=_capture), \ - patch.object(SlackPlugin, "_send_reachability_alerts"), \ - patch("socketsecurity.plugins.slack.requests.post") as mock_post: + with ( + patch.object(SlackPlugin, "create_slack_blocks_from_diff", side_effect=_capture), + patch.object(SlackPlugin, "_send_reachability_alerts"), + patch("socketsecurity.plugins.slack.requests.post") as mock_post, + ): mock_post.return_value = Mock(status_code=200, text="ok") plugin._send_webhook_alerts(diff, cfg) diff --git a/tests/unit/test_slack_severity_normalization.py b/tests/unit/test_slack_severity_normalization.py index 861fe34c..c5259c62 100644 --- a/tests/unit/test_slack_severity_normalization.py +++ b/tests/unit/test_slack_severity_normalization.py @@ -65,9 +65,7 @@ def test_middle_findings_reach_total_findings(self): self.assertEqual(result[0]["total_findings"], 1) def test_unrecognized_severity_still_falls_back(self): - info = _extract_alert_info( - _component("brand-new-level"), {"severity": "brand-new-level"} - ) + info = _extract_alert_info(_component("brand-new-level"), {"severity": "brand-new-level"}) self.assertEqual(info["severity_order"], 4) self.assertEqual(info["severity_emoji"], "โšช") diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py index 765150e9..e583331e 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -51,8 +51,11 @@ def test_disable_blocking_overrides_exit_code_on_api_error(monkeypatch): code = _run_cli_expecting_exit( monkeypatch, [ - "socketcli", "--api-token", "test", - "--exit-code-on-api-error", "100", + "socketcli", + "--api-token", + "test", + "--exit-code-on-api-error", + "100", "--disable-blocking", ], ) @@ -60,9 +63,7 @@ def test_disable_blocking_overrides_exit_code_on_api_error(monkeypatch): def test_keyboard_interrupt_still_exits_2(monkeypatch): - code = _run_cli_expecting_exit( - monkeypatch, ["socketcli", "--api-token", "test"], boom=KeyboardInterrupt() - ) + code = _run_cli_expecting_exit(monkeypatch, ["socketcli", "--api-token", "test"], boom=KeyboardInterrupt()) assert code == 2 @@ -204,10 +205,7 @@ def test_scan_mode_fallback_log_is_structured(caplog): "no-supported-manifest-in-changed-files", ) - assert ( - "Scan mode: requested=diff effective=full " - "reason=no-supported-manifest-in-changed-files" - ) in caplog.messages + assert ("Scan mode: requested=diff effective=full reason=no-supported-manifest-in-changed-files") in caplog.messages def test_build_license_artifact_payload_without_packages_returns_empty_dict(): @@ -321,22 +319,24 @@ class Config: payload = build_license_artifact_payload(diff, legal_format="fossa", config=Config()) assert payload["project"] == {"name": "owner/repo", "revision": "scan-1"} - assert payload["directDependencies"] == [{ - "authors": [], - "dependencyPaths": ["requests"], - "description": "", - "downloadUrl": "", - "hash": None, - "isGolang": None, - "licenses": [{"attribution": "", "name": "Apache-2.0"}], - "notes": [], - "otherLicenses": [], - "package": "requests", - "projectUrl": "", - "source": "pip", - "title": "requests", - "version": "2.31.0", - }] + assert payload["directDependencies"] == [ + { + "authors": [], + "dependencyPaths": ["requests"], + "description": "", + "downloadUrl": "", + "hash": None, + "isGolang": None, + "licenses": [{"attribution": "", "name": "Apache-2.0"}], + "notes": [], + "otherLicenses": [], + "package": "requests", + "projectUrl": "", + "source": "pip", + "title": "requests", + "version": "2.31.0", + } + ] assert payload["deepDependencies"] == [] assert payload["copyrightsByLicense"] == {} assert payload["licenses"] == {} @@ -353,33 +353,21 @@ class Config: class TestShouldWriteComment: def test_disabled_never_writes_even_when_a_comment_exists(self): - assert should_write_comment( - disabled=True, has_findings=True, update_existing=True - ) is False + assert should_write_comment(disabled=True, has_findings=True, update_existing=True) is False def test_disabled_never_writes_with_no_existing_comment(self): - assert should_write_comment( - disabled=True, has_findings=True, update_existing=False - ) is False + assert should_write_comment(disabled=True, has_findings=True, update_existing=False) is False def test_disabled_wins_over_findings(self): """The flag is not conditional on there being nothing to report.""" - assert should_write_comment( - disabled=True, has_findings=False, update_existing=True - ) is False + assert should_write_comment(disabled=True, has_findings=False, update_existing=True) is False def test_findings_are_written(self): - assert should_write_comment( - disabled=False, has_findings=True, update_existing=False - ) is True + assert should_write_comment(disabled=False, has_findings=True, update_existing=False) is True def test_no_findings_refreshes_an_existing_comment(self): """So a resolved alerts table gets cleared rather than left stale.""" - assert should_write_comment( - disabled=False, has_findings=False, update_existing=True - ) is True + assert should_write_comment(disabled=False, has_findings=False, update_existing=True) is True def test_no_findings_does_not_open_a_new_comment(self): - assert should_write_comment( - disabled=False, has_findings=False, update_existing=False - ) is False + assert should_write_comment(disabled=False, has_findings=False, update_existing=False) is False diff --git a/tests/unit/test_streaming.py b/tests/unit/test_streaming.py index 3b9cbd9b..66a79899 100644 --- a/tests/unit/test_streaming.py +++ b/tests/unit/test_streaming.py @@ -6,50 +6,59 @@ def _make(**overrides): - kwargs = dict( - client=object(), - cli_logger=logging.getLogger(overrides.pop("cli_name", "t-cli")), - sdk_logger=logging.getLogger(overrides.pop("sdk_name", "t-sdk")), - client_version="1.0", - upload_logs=True, - enable_debug=False, - ) + kwargs = { + "client": object(), + "cli_logger": logging.getLogger(overrides.pop("cli_name", "t-cli")), + "sdk_logger": logging.getLogger(overrides.pop("sdk_name", "t-sdk")), + "client_version": "1.0", + "upload_logs": True, + "enable_debug": False, + } kwargs.update(overrides) return StreamingLogs(**kwargs) def test_setup_streaming_is_noop_when_register_fails(caplog): finalize_calls = [] - with caplog.at_level(logging.INFO, logger="t-fail-cli"): - with patch("socketsecurity.core.streaming.register_cli_run", return_value=None), \ - patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda *a, **k: finalize_calls.append(k)): - with _make(cli_name="t-fail-cli", sdk_name="t-fail-sdk") as streaming: - assert isinstance(streaming, StreamingLogs) + with ( + caplog.at_level(logging.INFO, logger="t-fail-cli"), + patch("socketsecurity.core.streaming.register_cli_run", return_value=None), + patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda *a, **k: finalize_calls.append(k)), + _make(cli_name="t-fail-cli", sdk_name="t-fail-sdk") as streaming, + ): + assert isinstance(streaming, StreamingLogs) # No run was registered โ†’ finalize must not be called. assert finalize_calls == [] - assert any( - "CLI run registration completed" in record.message - for record in caplog.records - ) + assert any("CLI run registration completed" in record.message for record in caplog.records) def test_clean_exit_reports_success(): finalize_calls = [] - with patch("socketsecurity.core.streaming.register_cli_run", return_value="run-ok"), \ - patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda c, r, status, report_run_id: finalize_calls.append((status, report_run_id))), \ - patch.object(streaming_mod.BatchedLogUploader, "start"), \ - patch.object(streaming_mod.BatchedLogUploader, "stop"): - with _make(cli_name="t-ok-cli", sdk_name="t-ok-sdk"): - pass + with ( + patch("socketsecurity.core.streaming.register_cli_run", return_value="run-ok"), + patch( + "socketsecurity.core.streaming.finalize_cli_run", + side_effect=lambda c, r, status, report_run_id: finalize_calls.append((status, report_run_id)), + ), + patch.object(streaming_mod.BatchedLogUploader, "start"), + patch.object(streaming_mod.BatchedLogUploader, "stop"), + _make(cli_name="t-ok-cli", sdk_name="t-ok-sdk"), + ): + pass assert finalize_calls == [("success", None)] def test_exception_reports_failure_and_propagates(): finalize_calls = [] - with patch("socketsecurity.core.streaming.register_cli_run", return_value="run-x"), \ - patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda c, r, status, report_run_id: finalize_calls.append((status, report_run_id))), \ - patch.object(streaming_mod.BatchedLogUploader, "start"), \ - patch.object(streaming_mod.BatchedLogUploader, "stop"): + with ( + patch("socketsecurity.core.streaming.register_cli_run", return_value="run-x"), + patch( + "socketsecurity.core.streaming.finalize_cli_run", + side_effect=lambda c, r, status, report_run_id: finalize_calls.append((status, report_run_id)), + ), + patch.object(streaming_mod.BatchedLogUploader, "start"), + patch.object(streaming_mod.BatchedLogUploader, "stop"), + ): raised = False try: with _make(cli_name="t-exc-cli", sdk_name="t-exc-sdk") as streaming: @@ -63,10 +72,15 @@ def test_exception_reports_failure_and_propagates(): def test_keyboard_interrupt_reports_cancelled(): finalize_calls = [] - with patch("socketsecurity.core.streaming.register_cli_run", return_value="run-ki"), \ - patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda c, r, status, report_run_id: finalize_calls.append((status, report_run_id))), \ - patch.object(streaming_mod.BatchedLogUploader, "start"), \ - patch.object(streaming_mod.BatchedLogUploader, "stop"): + with ( + patch("socketsecurity.core.streaming.register_cli_run", return_value="run-ki"), + patch( + "socketsecurity.core.streaming.finalize_cli_run", + side_effect=lambda c, r, status, report_run_id: finalize_calls.append((status, report_run_id)), + ), + patch.object(streaming_mod.BatchedLogUploader, "start"), + patch.object(streaming_mod.BatchedLogUploader, "stop"), + ): try: with _make(cli_name="t-ki-cli", sdk_name="t-ki-sdk"): raise KeyboardInterrupt @@ -77,10 +91,15 @@ def test_keyboard_interrupt_reports_cancelled(): def test_system_exit_zero_is_success_nonzero_is_failure(): statuses = [] - with patch("socketsecurity.core.streaming.register_cli_run", return_value="run-sx"), \ - patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda c, r, status, report_run_id: statuses.append(status)), \ - patch.object(streaming_mod.BatchedLogUploader, "start"), \ - patch.object(streaming_mod.BatchedLogUploader, "stop"): + with ( + patch("socketsecurity.core.streaming.register_cli_run", return_value="run-sx"), + patch( + "socketsecurity.core.streaming.finalize_cli_run", + side_effect=lambda c, r, status, report_run_id: statuses.append(status), + ), + patch.object(streaming_mod.BatchedLogUploader, "start"), + patch.object(streaming_mod.BatchedLogUploader, "stop"), + ): try: with _make(cli_name="t-sx0-cli", sdk_name="t-sx0-sdk"): raise SystemExit(0) @@ -103,23 +122,25 @@ def test_restores_logger_state_on_exit(): sdk_logger.propagate = True handlers_before = (len(cli_logger.handlers), len(sdk_logger.handlers)) - with patch("socketsecurity.core.streaming.register_cli_run", return_value="run-r"), \ - patch("socketsecurity.core.streaming.finalize_cli_run"), \ - patch.object(streaming_mod.BatchedLogUploader, "start"), \ - patch.object(streaming_mod.BatchedLogUploader, "stop"): - with StreamingLogs( + with ( + patch("socketsecurity.core.streaming.register_cli_run", return_value="run-r"), + patch("socketsecurity.core.streaming.finalize_cli_run"), + patch.object(streaming_mod.BatchedLogUploader, "start"), + patch.object(streaming_mod.BatchedLogUploader, "stop"), + StreamingLogs( client=object(), cli_logger=cli_logger, sdk_logger=sdk_logger, client_version="1.0", upload_logs=True, enable_debug=False, - ): - # Inside the with block: levels and propagate are forced. - assert cli_logger.level == logging.DEBUG - assert sdk_logger.level == logging.DEBUG - assert cli_logger.propagate is False - assert sdk_logger.propagate is False + ), + ): + # Inside the with block: levels and propagate are forced. + assert cli_logger.level == logging.DEBUG + assert sdk_logger.level == logging.DEBUG + assert cli_logger.propagate is False + assert sdk_logger.propagate is False assert cli_logger.level == logging.WARNING assert sdk_logger.level == logging.ERROR diff --git a/tests/unit/test_summary_text.py b/tests/unit/test_summary_text.py index d75c1d7e..102d1b32 100644 --- a/tests/unit/test_summary_text.py +++ b/tests/unit/test_summary_text.py @@ -4,6 +4,7 @@ introduced, and blocking is suppressed for exactly that reason. The summary has to say so, or the reported counts read as gating failures that returned 0. """ + from unittest.mock import MagicMock from socketsecurity.config import CliConfig diff --git a/tests/unit/test_tier1_finalize.py b/tests/unit/test_tier1_finalize.py index 25776434..a65cc2ae 100644 --- a/tests/unit/test_tier1_finalize.py +++ b/tests/unit/test_tier1_finalize.py @@ -1,4 +1,5 @@ """Tests for tier1 reachability finalize retry/backoff (G11, Node parity).""" + import json from unittest.mock import MagicMock diff --git a/tests/unit/test_user_agent.py b/tests/unit/test_user_agent.py index ea486cc1..2225bf19 100644 --- a/tests/unit/test_user_agent.py +++ b/tests/unit/test_user_agent.py @@ -21,13 +21,9 @@ def send(session, request, **kwargs): monkeypatch.setattr(requests.sessions.Session, "send", send) if transport == "direct": - CliClient(SocketConfig(api_key="synthetic-test-token")).request( - "organizations" - ) + CliClient(SocketConfig(api_key="synthetic-test-token")).request("organizations") else: - sdk = build_socket_sdk( - CliConfig(api_token="synthetic-test-token", repo="test-repo") - ) + sdk = build_socket_sdk(CliConfig(api_token="synthetic-test-token", repo="test-repo")) sdk.api.do_request("organizations") assert len(captured) == 1 diff --git a/tests/unit/test_version_check.py b/tests/unit/test_version_check.py new file mode 100644 index 00000000..59a3faf6 --- /dev/null +++ b/tests/unit/test_version_check.py @@ -0,0 +1,128 @@ +import importlib.util +import io +import json +import subprocess +from pathlib import Path + +import pytest +from packaging.version import Version + + +@pytest.fixture +def version_check(): + script = Path(__file__).resolve().parents[2] / ".hooks" / "check_version.py" + spec = importlib.util.spec_from_file_location("check_version", script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def version_repo(tmp_path, monkeypatch, version_check): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_ENV", str(tmp_path / "github-env")) + monkeypatch.setattr(version_check, "latest_pypi_version", lambda: Version("2.12.0")) + + def git(*args): + return subprocess.run( + ["git", "-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", *args], + check=True, + capture_output=True, + text=True, + ) + + def prepare( + *, + main_version="2.12.0", + pr_version=None, + project_version=None, + changed_path="socketsecurity/scan.py", + quote='"', + tag=False, + ): + git("init", "-b", "main") + git("config", "user.name", "Version check test") + git("config", "user.email", "test@example.com") + (tmp_path / "socketsecurity").mkdir() + init_file = tmp_path / "socketsecurity" / "__init__.py" + project_file = tmp_path / "pyproject.toml" + init_file.write_text(f'__version__ = "{main_version}"\n') + project_file.write_text(f'[project]\nversion = "{main_version}"\n') + git("add", ".") + git("commit", "-m", "Create release baseline") + git("update-ref", "refs/remotes/origin/main", "HEAD") + + pr_version = pr_version or main_version + init_file.write_text(f"__version__ = {quote}{pr_version}{quote}\n") + project_file.write_text(f'[project]\nversion = "{project_version or pr_version}"\n') + changed = tmp_path / changed_path + changed.parent.mkdir(parents=True, exist_ok=True) + content = changed.read_text() if changed.exists() else "" + changed.write_text(content + "# Pull request content\n") + git("add", ".") + git("commit", "-m", "Update pull request files") + if tag: + git("tag", f"v{pr_version}") + return version_check.main + + return prepare + + +@pytest.mark.parametrize( + ("changed_path", "expected"), + [ + ("README.md", 0), + ("docs/cli-reference.md", 0), + (".github/workflows/lint.yml", 0), + (".hooks/check_version.py", 0), + ("tests/unit/test_output.py", 0), + ("socketsecurity/scan.py", 1), + ("pyproject.toml", 1), + ("uv.lock", 1), + ], +) +def test_published_version_is_rejected_only_for_packaged_changes(version_repo, capsys, changed_path, expected): + check = version_repo(changed_path=changed_path) + assert check() == expected + output = capsys.readouterr().out + assert "already published on PyPI" in output + assert ("Not enforced" in output) == (expected == 0) + + +def test_source_change_can_share_mains_unreleased_version(version_repo, capsys): + check = version_repo(main_version="2.12.1") + assert check() == 0 + assert "Riding main's unreleased 2.12.1" in capsys.readouterr().out + + +@pytest.mark.parametrize("quote", ['"', "'"]) +def test_new_release_accepts_both_version_quote_styles(version_repo, tmp_path, capsys, quote): + check = version_repo(pr_version="2.12.1", quote=quote) + assert check() == 0 + assert "Version properly incremented" in capsys.readouterr().out + assert (tmp_path / "github-env").read_text() == "PR_VERSION=2.12.1\nMAIN_VERSION=2.12.0\n" + + +def test_packaged_version_must_match_cli_version(version_repo, capsys): + check = version_repo(pr_version="2.12.1", project_version="2.12.2") + assert check() == 1 + assert "Version mismatch inside the PR" in capsys.readouterr().out + + +@pytest.mark.parametrize(("changed_path", "expected"), [("socketsecurity/scan.py", 1), ("README.md", 0)]) +def test_existing_release_tag_respects_package_change_exemption(version_repo, capsys, changed_path, expected): + check = version_repo(main_version="2.12.1", changed_path=changed_path, tag=True) + assert check() == expected + assert "Tag v2.12.1 already exists" in capsys.readouterr().out + + +def test_packaged_version_cannot_move_backwards(version_repo, capsys): + check = version_repo(main_version="2.12.2", pr_version="2.12.1") + assert check() == 1 + assert "Version moves backwards" in capsys.readouterr().out + + +def test_latest_pypi_version_ignores_prereleases_and_dev_releases(version_check, monkeypatch): + payload = json.dumps({"releases": {"2.12.0": [], "2.11.9": [], "2.13.0rc1": [], "2.13.0.dev1": []}}).encode() + monkeypatch.setattr(version_check.urllib.request, "urlopen", lambda _url: io.BytesIO(payload)) + assert version_check.latest_pypi_version() == Version("2.12.0") diff --git a/uv.lock b/uv.lock index dc276bb5..6077c132 100644 --- a/uv.lock +++ b/uv.lock @@ -1334,7 +1334,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.12.0" +version = "2.12.1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },