From 35b64f2bc19899b01df46e9c775b08bc74a6bf71 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:09:42 -0400 Subject: [PATCH 1/4] Enforce ruff in CI and pre-commit, and clear the existing violations Ruff already ran in CI, but only as a job inside the Unit Tests workflow, so it inherited that workflow's path filter and never saw .hooks/, benchmarks/, or tests/e2e/. Move it to its own unconditional Lint workflow, which also keeps it usable as a required status check. Add ruff to pre-commit so violations surface before CI. The hook runs ruff out of the project environment rather than the upstream mirror, so the version stays pinned in one place; Dependabot has no pre-commit ecosystem and would never update a mirror's rev. Expand the rule set beyond E/F/I to cover bug classes that matter for a CLI other people run in their pipelines, and fix every resulting violation so the baseline is clean rather than suppressed. Behaviour changes worth calling out: - Package.created_at used str.strip(" (Coordinated Universal Time)"), which treats its argument as a set of characters, not a suffix. It ate a leading "T" from "Tue ..." and a trailing "T" from timestamps that carried no suffix at all. Now uses removesuffix. - Every requests call in the plugins and the GitLab client now passes an explicit timeout. requests blocks forever by default, so a hung notification could wedge the pipeline the CLI reports into. - Two asserts became real checks. assert is stripped under python -O, so neither guard survived an optimised interpreter. - config.py logs through the socketcli logger instead of the root logger, so its messages honour the configured level and format. - A stray debug print in the SBOM artifact loop became a log.debug call; it was writing to stdout, which carries machine-readable output. - Closures defined inside loops in alert_selection and messages were hoisted and now take their inputs explicitly. Complexity is bounded by C901 (max 12) and PLR0913 (max 8). The 20 functions over the limit today carry an explicit noqa; RUF100 fails the build once a suppression goes stale, so the list can only shrink. E501 and W291/W293 are left to ruff format rather than duplicated in the linter: everything the formatter cannot reflow is a string literal, and the PR-comment markup depends on trailing double-spaces as Markdown hard line breaks. Co-Authored-By: Claude Opus 5 (1M context) --- .git-blame-ignore-revs | 4 + .github/workflows/lint.yml | 48 + .github/workflows/python-tests.yml | 20 - .hooks/sync_version.py | 15 +- .pre-commit-config.yaml | 27 +- CONTRIBUTING.md | 63 ++ Makefile | 20 +- benchmarks/manifest_discovery.py | 9 +- pyproject.toml | 133 ++- session.md | 2 +- socketsecurity/__init__.py | 6 +- socketsecurity/config.py | 829 +++++++----------- socketsecurity/core/__init__.py | 600 ++++++------- socketsecurity/core/alert_selection.py | 102 +-- socketsecurity/core/classes.py | 153 ++-- socketsecurity/core/cli_client.py | 32 +- socketsecurity/core/cli_run.py | 19 +- socketsecurity/core/exceptions.py | 14 +- socketsecurity/core/git_interface.py | 364 ++++---- socketsecurity/core/helper/__init__.py | 65 +- .../core/helper/socket_facts_loader.py | 335 ++++--- socketsecurity/core/lazy_file_loader.py | 66 +- socketsecurity/core/log_uploader.py | 21 +- socketsecurity/core/logging.py | 4 +- socketsecurity/core/messages.py | 612 ++++++------- socketsecurity/core/resource_utils.py | 8 +- socketsecurity/core/scm/base.py | 16 +- socketsecurity/core/scm/client.py | 71 +- socketsecurity/core/scm/github.py | 114 +-- socketsecurity/core/scm/gitlab.py | 185 ++-- socketsecurity/core/scm_comments.py | 48 +- socketsecurity/core/socket_config.py | 41 +- socketsecurity/core/streaming.py | 22 +- socketsecurity/core/tools/reachability.py | 154 ++-- socketsecurity/core/utils.py | 140 +-- socketsecurity/fossa_compat.py | 74 +- socketsecurity/output.py | 69 +- socketsecurity/plugins/base.py | 7 +- socketsecurity/plugins/formatters/__init__.py | 2 +- socketsecurity/plugins/formatters/slack.py | 274 +++--- socketsecurity/plugins/jira.py | 57 +- socketsecurity/plugins/manager.py | 3 +- socketsecurity/plugins/slack.py | 437 +++++---- socketsecurity/plugins/teams.py | 4 +- socketsecurity/plugins/webhook.py | 4 +- socketsecurity/socketcli.py | 344 ++++---- tests/core/conftest.py | 89 +- tests/core/test_diff_alerts.py | 28 +- tests/core/test_diff_generation.py | 54 +- tests/core/test_diff_scan_polling.py | 23 +- tests/core/test_facts_compression.py | 1 + tests/core/test_invalid_facts_marker.py | 53 +- tests/core/test_package_and_alerts.py | 165 ++-- tests/core/test_sdk_methods.py | 50 +- tests/core/test_supporting_methods.py | 135 ++- tests/unit/test_alert_selection.py | 85 +- tests/unit/test_cli_config.py | 81 +- tests/unit/test_cli_run.py | 24 +- tests/unit/test_client.py | 71 +- tests/unit/test_config.py | 82 +- tests/unit/test_disable_ignore.py | 61 +- tests/unit/test_exclude_paths.py | 28 +- tests/unit/test_fossa_compat.py | 184 +++- tests/unit/test_fossa_parity.py | 15 +- tests/unit/test_full_scan_retry.py | 44 +- tests/unit/test_git_interface.py | 27 +- tests/unit/test_github_buildkite_config.py | 4 +- tests/unit/test_gitlab_auth.py | 112 +-- tests/unit/test_gitlab_auth_fallback.py | 165 ++-- tests/unit/test_gitlab_commit_status.py | 42 +- tests/unit/test_gitlab_format.py | 117 ++- tests/unit/test_ignore_telemetry_filtering.py | 15 +- tests/unit/test_include_dirs.py | 14 +- tests/unit/test_log_uploader.py | 38 +- tests/unit/test_manifest_discovery.py | 13 +- tests/unit/test_output.py | 404 +++++---- tests/unit/test_pr_comment_rendering.py | 72 +- tests/unit/test_reachability.py | 18 +- tests/unit/test_slack_plugin.py | 99 ++- tests/unit/test_socketcli.py | 69 +- tests/unit/test_streaming.py | 113 ++- tests/unit/test_tier1_finalize.py | 1 + 82 files changed, 4116 insertions(+), 4117 deletions(-) create mode 100644 .git-blame-ignore-revs create mode 100644 .github/workflows/lint.yml mode change 100644 => 100755 .hooks/sync_version.py mode change 100644 => 100755 benchmarks/manifest_discovery.py diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..f2433a87 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# Revisions that only reformat or mechanically re-lint code. +# Configure once per clone: +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# (GitHub applies this file automatically in its blame view.) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..84ac4e2a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,48 @@ +name: Lint + +env: + PYTHON_VERSION: "3.12" + +# Deliberately not path-filtered. Ruff finishes in well under a minute, and its +# trigger surface is every Python file in the repository -- including the ones +# outside the Unit Tests filters (.hooks/, benchmarks/, tests/e2e/). Running +# unconditionally also keeps this usable as a required status check: a +# path-filtered workflow reports as "not run" rather than "passed", which blocks +# any pull request that does not happen to touch the filtered paths. +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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: ๐Ÿ› ๏ธ install deps + run: | + python -m pip install --upgrade pip + pip install uv + uv sync --extra dev + # Same ruff version the pre-commit hook uses (pinned in pyproject.toml, + # locked in uv.lock), so a clean commit locally stays clean here. + - name: ๐Ÿงน ruff check + run: uv run ruff check + - name: ๐ŸŽจ ruff format + run: uv run ruff format --check diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index f05b27c9..34717226 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -67,26 +67,6 @@ jobs: uv export --no-hashes --no-emit-project --format requirements-txt > /tmp/req-audit.txt uvx pip-audit --strict --progress-spinner off --disable-pip --no-deps -r /tmp/req-audit.txt - 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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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/.hooks/sync_version.py b/.hooks/sync_version.py old mode 100644 new mode 100755 index 51835c3f..8ac0d1c4 --- 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,6 +99,7 @@ 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}") @@ -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..f247309e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,4 +6,29 @@ repos: entry: python .hooks/sync_version.py language: python always_run: true - pass_filenames: false \ No newline at end of file + pass_filenames: false + + # Ruff runs out of the project environment rather than the upstream + # astral-sh/ruff-pre-commit mirror so its version is pinned in exactly one + # place: `ruff==0.16.4` under [project.optional-dependencies].dev, locked + # in uv.lock and used verbatim by the Lint workflow. Dependabot has no + # pre-commit ecosystem and will not touch a mirror's `rev:`, so a mirror + # would drift out of step with CI and produce the worst failure mode for a + # hook -- clean locally, red on the pull request. + # + # `--fix` applies only ruff's fixes marked safe. When it changes a file + # pre-commit aborts the commit and leaves the edit in the working tree, so + # nothing lands without being looked at. + - id: ruff-check + name: ruff check + entry: uv run --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 --extra dev ruff format --force-exclude + language: system + types_or: [python, pyi] + require_serial: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed9ee2b0..a71efde3 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,62 @@ 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 is the only linter. It runs in three places, all reading the same +configuration from `pyproject.toml` and the same version pinned in +`[project.optional-dependencies].dev`: + +- `make lint` locally, +- the `ruff-check` pre-commit hook, on the files a commit touches, +- the `Lint` workflow, on every pull request and every push to `main`. + +The pre-commit hook applies ruff's safe fixes and then fails the commit, leaving +the edits unstaged so they get read before they land. CI is the backstop for +commits made with `--no-verify` or without hooks installed. + +`ruff format` is enforced the same way. It owns line length (120) and +whitespace, so the linter does not duplicate those checks: `E501` and `W291`/ +`W293` are deliberately not selected. Everything the formatter cannot reflow is +a string literal -- argparse help text, log messages, the Markdown used to build +pull request comments -- where rewrapping risks silently changing user-visible +text. The PR-comment markup in particular relies on trailing double-spaces as +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, which is also the number of tests needed to cover it. | +| `PLR0913` | 8 | Arguments in a function definition. | + +Functions that already exceed these limits carry an explicit +`# noqa: C901` / `# noqa: PLR0913` on their `def` line. That list is a backlog, +not a precedent: + +- **Do not add a new suppression.** If a function you are writing trips the + limit, split it. This matters most for generated or model-assisted code, where + branches accumulate quickly and nothing pushes back. +- **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. The backlog can only shrink. + +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 diff --git a/Makefile b/Makefile index c0fb1b01..e08edb5c 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 --extra dev pre-commit install + +# Exactly what the Lint workflow runs, so a green `make lint` means a green CI. lint: - uv run ruff check . - uv run ruff format --check . \ No newline at end of file + uv run --extra dev ruff check + uv run --extra dev ruff format --check + +lint-fix: + uv run --extra dev ruff check --fix + +format: + uv run --extra dev ruff format + +format-check: + uv run --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 3dcd5718..264ceda0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,17 @@ show_missing = true skip_empty = true [tool.ruff] -# Exclude a variety of commonly ignored directories. +# Written for a CI/CD CLI: the code is read far more often than it is written, +# ships as a wheel and a Docker image, and is increasingly co-authored by LLMs. +# Rules are chosen to catch bug classes and unreviewable sprawl, not to enforce +# house style -- style is the formatter's job. +# `ruff format` owns line length and whitespace; the linter does not duplicate it. +# E501 is not selected because everything the formatter cannot reflow is a string +# literal -- argparse help text, log messages, Markdown for PR comments -- where +# hand-wrapping risks silently changing user-visible text. W291/W293 are not +# selected for the same reason: the PR-comment markup uses trailing double-spaces +# as Markdown hard line breaks, which the formatter correctly leaves alone. +line-length = 120 exclude = [ ".bzr", ".direnv", @@ -112,25 +122,128 @@ 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 ------------------------------------------ + # The main defense against unreviewable functions. See the thresholds + # in [tool.ruff.lint.mccabe] and [tool.ruff.lint.pylint] below. + "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//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 + # TRY400 (logging.error -> logging.exception in an except block) is + # deliberately absent. This CLI reports expected failures -- a missing + # config file, an APIFailure -- by catching them and logging a readable + # message. Promoting those to .exception() dumps a traceback into the + # user's CI log for conditions that are not crashes, which reads as one. + + # --- Keeping the suppressions honest -------------------------------- + "PGH", # no blanket `# noqa` / `# type: ignore` -- codes required + "RUF100", # unused `# noqa` -- makes the grandfather list self-cleaning +] + +ignore = [ + # Rules whose fix makes this codebase worse. Each was evaluated against the + # actual call sites, not waved off. + "SIM108", # if/else -> ternary: the branches here carry explanatory + # comments that a ternary has nowhere to put. + "PERF401", # loop -> comprehension: the loop bodies build multi-line dict + # literals; inlining them into a generator hurts readability for + # no measurable gain on lists this size. + "S603", # subprocess without shell=True check: fires on every subprocess + # call. This CLI shells out to git and the coana binary by + # design, with non-user-controlled argv. S602 (shell=True) stays + # enabled, which is the case that actually matters. + "S607", # partial executable path: resolving `git` and `npx` via PATH is + # intentional; the Docker image and CI both rely on it. ] # 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] +# Cyclomatic complexity == the number of independent paths through a function, +# which is also the number of tests needed to cover it. Ruff's default is 10; +# 12 leaves room for the argparse/config plumbing a CLI genuinely needs while +# still catching sprawl. +# +# Functions above this limit today carry an explicit `# noqa: C901` so that new +# code is held to the limit immediately. RUF100 flags those suppressions as +# unused once a function is refactored back under it, so the list only shrinks. +# Never add a new `# noqa: C901` -- split the function instead. +max-complexity = 12 + +[tool.ruff.lint.pylint] +# Ruff's default is 5, which fires on ordinary 6-7 argument constructors here. +# 8 is set at the real gap in this codebase: everything is <= 7 arguments +# except one 27-argument function, which is the case worth flagging. +max-args = 8 + +[tool.ruff.lint.per-file-ignores] +# Bandit's S rules describe production code paths. The test suite asserts by +# definition, and its fixtures deliberately contain throwaway credentials and +# fixed temp paths; flagging those is noise, not signal. +"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/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 78220a1f..0096002b 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ -__author__ = 'socket.dev' -__version__ = '2.7.1' -USER_AGENT = f'SocketPythonCLI/{__version__}' +__author__ = "socket.dev" +__version__ = "2.7.1" +USER_AGENT = f"SocketPythonCLI/{__version__}" diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 35904976..caee0c1e 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -2,14 +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__ +log = logging.getLogger("socketcli") + def get_plugin_config_from_env(prefix: str) -> dict: config_str = os.getenv(f"{prefix}_CONFIG_JSON", "{}") @@ -39,25 +41,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 @@ -75,7 +78,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 @@ -88,55 +91,55 @@ 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" - 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 @@ -145,47 +148,47 @@ class CliConfig: disable_ignore: bool = False # 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 # 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 @@ -198,10 +201,10 @@ class CliConfig: enable_commit_status: bool = False legal: bool = False legal_format: str = "socket" - config_file: Optional[str] = None - + config_file: str | None = None + @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) @@ -221,18 +224,18 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': args = parser.parse_args(args_list) 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 @@ -251,126 +254,126 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': # URL encoding can 2-3x raw character count. MAX_COMMIT_MESSAGE_LENGTH = 200 if commit_message and len(commit_message) > MAX_COMMIT_MESSAGE_LENGTH: - logging.debug( + log.debug( f"commit_message truncated from {len(commit_message)} to " f"{MAX_COMMIT_MESSAGE_LENGTH} characters to avoid API request size limits" ) commit_message = commit_message[:MAX_COMMIT_MESSAGE_LENGTH] config_args = { - 'api_token': api_token, - 'repo': args.repo, - 'branch': args.branch, - 'committers': args.committers, - 'pr_number': args.pr_number, - '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, - 'upload_logs': args.upload_logs, - 'strict_blocking': args.strict_blocking, - 'integration_type': args.integration, - '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": args.pr_number, + "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, + "upload_logs": args.upload_logs, + "strict_blocking": args.strict_blocking, + "integration_type": args.integration, + "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, - '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, + "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 @@ -378,58 +381,60 @@ 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) # 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"): @@ -437,140 +442,97 @@ 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 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="", - help="Repository name in owner/repo format", - required=False + "--repo", metavar="", help="Repository name in owner/repo format", 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, metavar="", help="Integration type of api, github, gitlab, azure, or bitbucket. Defaults to api", - default="api" + default="api", ) 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.add_argument( - "--pr-number", - dest="pr_number", - metavar="", - help="Pull request number", - default="0" - ) - pr_group.add_argument( - "--pr_number", - dest="pr_number", - help=argparse.SUPPRESS - ) - pr_group.add_argument( - "--commit-message", - dest="commit_message", - metavar="", - help="Commit message" - ) + pr_group = parser.add_argument_group("Pull Request and Commit") + pr_group.add_argument("--pr-number", dest="pr_number", metavar="", help="Pull request number", default="0") + pr_group.add_argument("--pr_number", dest="pr_number", help=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( - "--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( @@ -579,7 +541,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", @@ -587,80 +549,58 @@ 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 most recent full scan matching this commit (e.g. " - "the merge base from 'git merge-base origin/main HEAD') is used; the CLI " - "errors if no scan exists for it. Mutually exclusive with --base-scan-id." + "scan as the baseline. The most recent full scan matching this commit (e.g. " + "the merge base from 'git merge-base origin/main HEAD') is used; the CLI " + "errors if no scan exists for it. 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.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 = parser.add_argument_group("Path and File") 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( @@ -668,8 +608,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( @@ -677,146 +617,100 @@ 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.add_argument( - "--default-branch", - dest="default_branch", - action="store_true", - help="Make this branch the default branch" - ) + config_group = parser.add_argument_group("Branch and Scan Configuration") 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) config_group.add_argument( "--pending-head", dest="pending_head", action="store_true", - help="If true, the new scan will be set as the branch's head scan" - ) - config_group.add_argument( - "--pending_head", - dest="pending_head", - action="store_true", - help=argparse.SUPPRESS + help="If true, the new scan will be set as the branch's head scan", ) + 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" - ) - output_group.add_argument( - "--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 + "--generate-license", dest="generate_license", action="store_true", help="Generate license information" ) output_group.add_argument( - "--enable-json", - dest="enable_json", - action="store_true", - help="Output in JSON format" + "--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( - "--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", @@ -827,63 +721,51 @@ 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="Ignore commit files" + "--ignore-commit-files", dest="ignore_commit_files", action="store_true", help="Ignore commit files" ) 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", @@ -896,24 +778,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", @@ -921,9 +795,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", @@ -931,33 +805,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)" - ) - advanced_group.add_argument( - "--scm", - metavar="", - default="api", - help="Source control management type" + 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( - "--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", @@ -972,205 +837,183 @@ 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 @coana-tech/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).", ) - 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 a5305bee..93f55247 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -14,10 +14,12 @@ import time from dataclasses import asdict from pathlib import PurePath -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Set, Tuple +from typing import TYPE_CHECKING, ClassVar, NamedTuple, Optional if TYPE_CHECKING: from socketsecurity.config import CliConfig +import contextlib + from socketdev import socketdev from socketdev.exceptions import APIFailure from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact @@ -36,10 +38,10 @@ __all__ = [ + "USER_AGENT", "Core", - "log", "__version__", - "USER_AGENT", + "log", ] version = __version__ @@ -123,7 +125,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 @@ -157,10 +159,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 @@ -172,31 +174,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.""" @@ -209,17 +208,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: @@ -227,9 +226,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 = { @@ -240,7 +237,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()) @@ -298,34 +295,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: @@ -333,15 +331,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: @@ -362,10 +358,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("[^/]") @@ -377,14 +373,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: @@ -394,17 +390,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. @@ -416,7 +412,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): @@ -429,33 +425,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. @@ -473,7 +471,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}") @@ -490,33 +488,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. @@ -528,9 +526,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: @@ -572,7 +570,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, @@ -580,9 +578,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: @@ -614,11 +610,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. @@ -631,7 +627,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. @@ -671,18 +667,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 @@ -711,8 +707,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): @@ -736,8 +732,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: @@ -747,7 +747,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. @@ -761,10 +761,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. @@ -796,10 +796,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: @@ -819,10 +816,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] @@ -838,17 +836,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 """ @@ -868,10 +866,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. @@ -890,11 +888,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}") @@ -924,10 +922,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 @@ -935,12 +933,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( @@ -949,7 +947,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( @@ -1020,14 +1020,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 @@ -1047,8 +1045,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 ( @@ -1067,13 +1065,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. @@ -1103,12 +1101,19 @@ 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 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 @@ -1138,11 +1143,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: @@ -1154,15 +1158,15 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: return full_scan - 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. @@ -1179,11 +1183,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 @@ -1196,15 +1196,15 @@ def create_full_scan_with_report_url( for path in paths: files = self.find_files(path) all_files.extend(files) - + # 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") @@ -1215,7 +1215,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: @@ -1223,14 +1223,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 @@ -1248,13 +1246,8 @@ def create_full_scan_with_report_url( diff.diff_url = diff.report_url diff.id = new_full_scan.id - 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 ) if needs_alerts: @@ -1265,16 +1258,14 @@ def create_full_scan_with_report_url( packages = self._create_packages_dict_without_license_text(sbom_artifacts) diff.packages = packages - 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: @@ -1283,8 +1274,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 = {} @@ -1323,7 +1313,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 @@ -1377,9 +1367,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: """ @@ -1409,25 +1398,23 @@ 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: log.error(f"API failure while creating repository: {e}") - 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 @@ -1442,15 +1429,11 @@ def get_head_scan_for_repo(self, repo_slug: str) -> str: Head scan ID if it exists, None otherwise """ repo_info = self.get_repo_info(repo_slug) - 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 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. @@ -1488,7 +1471,7 @@ def get_full_scan_id_by_commit( return None return results[0].get("id") - 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. @@ -1544,41 +1527,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"] @@ -1590,14 +1573,10 @@ 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 - ) -> DiffArtifacts: + def get_diff_scan_artifacts(self, head_full_scan_id: str, new_full_scan_id: str) -> DiffArtifacts: """Compare two full scans via the diff-scans endpoints, polling for the result. Creates a diff-scan resource from the two full scan IDs, then polls @@ -1656,8 +1635,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 @@ -1726,18 +1704,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. @@ -1772,12 +1748,9 @@ 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 - ) -> 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 + ) -> tuple[dict[str, Package], dict[str, Package], dict[str, Package]]: """ Get packages that were added and removed between scans. @@ -1818,10 +1791,7 @@ def get_added_and_removed_packages( diff_start = time.time() diff_artifacts = None try: - diff_artifacts = self.get_diff_scan_artifacts( - head_full_scan_id, - new_full_scan_id - ) + diff_artifacts = self.get_diff_scan_artifacts(head_full_scan_id, new_full_scan_id) except Exception as error: # SDK error messages can span many lines (path + response headers); the # first line carries the status, which is all the warning needs. @@ -1833,15 +1803,13 @@ def get_added_and_removed_packages( 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}") if self.cli_config and self.cli_config.disable_blocking: @@ -1849,7 +1817,8 @@ def get_added_and_removed_packages( sys.exit(1) 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 @@ -1860,7 +1829,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)] @@ -1875,9 +1844,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)) @@ -1922,18 +1891,18 @@ 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 + 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, ) -> Diff: """Create a new diff using the Socket SDK. @@ -1959,15 +1928,15 @@ def create_new_diff( for path in paths: files = self.find_files(path) all_files.extend(files) - + # 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: @@ -1982,20 +1951,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.debug(f"Created empty baseline scan: {head_full_scan_id}") - + # Clean up the temporary empty file for temp_file in empty_files: try: @@ -2003,20 +1972,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) @@ -2026,23 +1993,20 @@ 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 if self.cli_config and self.cli_config.disable_blocking: sys.exit(0) sys.exit(1) 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 @@ -2062,19 +2026,14 @@ 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( - head_full_scan_id, - new_full_scan.id, - include_license_details=False + (added_packages, removed_packages, packages) = self.get_added_and_removed_packages( + head_full_scan_id, new_full_scan.id, include_license_details=False ) # 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 } @@ -2103,10 +2062,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. @@ -2129,9 +2088,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_new_packages = set() seen_removed_packages = set() @@ -2145,9 +2104,7 @@ def create_diff_report( seen_new_packages.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(): @@ -2159,9 +2116,7 @@ def create_diff_report( seen_removed_packages.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 @@ -2172,25 +2127,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) @@ -2215,7 +2161,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, @@ -2227,10 +2173,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: @@ -2289,10 +2233,7 @@ def add_purl_capabilities(diff: Diff) -> None: new_packages = [] for purl in diff.new_packages: if purl.id in diff.new_capabilities: - new_purl = Purl( - **{**purl.__dict__, - "capabilities": diff.new_capabilities[purl.id]} - ) + new_purl = Purl(**{**purl.__dict__, "capabilities": diff.new_capabilities[purl.id]}) new_packages.append(new_purl) else: new_packages.append(purl) @@ -2311,12 +2252,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) @@ -2335,7 +2273,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, @@ -2351,12 +2289,12 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: next_step_title=props.nextStepTitle, introduced_by=introduced_by, 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: @@ -2381,12 +2319,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. @@ -2396,7 +2334,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: @@ -2412,10 +2350,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. @@ -2427,7 +2365,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: @@ -2437,10 +2375,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] @@ -2453,17 +2390,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. @@ -2476,7 +2414,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: @@ -2492,9 +2430,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. @@ -2506,7 +2442,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 ae5b4772..9a36771a 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, @@ -41,9 +41,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) @@ -58,9 +58,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: @@ -69,7 +67,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(), @@ -77,8 +75,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) @@ -93,7 +91,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 @@ -108,14 +106,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", []) @@ -124,7 +145,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}") @@ -138,39 +159,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( @@ -179,8 +183,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 @@ -204,13 +208,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. @@ -231,7 +235,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 db145221..4b016cf7 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -1,6 +1,6 @@ import json from dataclasses import dataclass, field -from typing import Dict, List, Optional, TypedDict +from typing import TypedDict from socketdev.fullscans import ( FullScanMetadata, @@ -12,22 +12,23 @@ ) __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 @@ -46,20 +47,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 @@ -83,7 +84,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 """ @@ -93,44 +94,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 = "" @@ -138,18 +142,17 @@ class Package(): url: str = "" # Artifact-specific fields - licenseDetails: Optional[list] = None - licenseAttrib: Optional[List] = None - + licenseDetails: list | None = None + licenseAttrib: list | None = None @classmethod 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 """ @@ -179,20 +182,20 @@ def from_socket_artifact(cls, data: dict) -> "Package": artifact=data.get("artifact"), purl=purl, url=url, - namespace=namespace + namespace=namespace, ) @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 """ @@ -246,12 +249,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 @@ -281,7 +285,7 @@ def __init__(self, **kwargs): 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, "manifests"): self.manifests = "" if not hasattr(self, "suggestion"): @@ -290,7 +294,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"): @@ -315,10 +319,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 @@ -342,19 +346,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 @@ -375,10 +377,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] @@ -395,7 +397,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 @@ -415,13 +417,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 @@ -431,7 +434,7 @@ class Purl: size: int transitives: int introduced_by: list - capabilities: List[str] + capabilities: list[str] is_new: bool author_url: str url: str @@ -456,11 +459,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 """ @@ -468,8 +471,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__) @@ -477,7 +479,7 @@ def __str__(self): def to_dict(self) -> dict: """ Convert Purl object to a dictionary representation. - + Returns: Dictionary containing all Purl attributes """ @@ -495,21 +497,21 @@ 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] removed_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] @@ -542,7 +544,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 """ @@ -551,18 +553,23 @@ def to_dict(self) -> dict: "new_capabilities": self.new_capabilities, "removed_packages": [p.to_dict() for p in self.removed_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 2e941e7a..dca11499 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,27 +48,27 @@ 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() return response except requests.exceptions.RequestException as e: - logger.error(f"API request failed: {str(e)}") - raise APIFailure(f"Request failed: {str(e)}") + logger.error(f"API request failed: {e!s}") + raise APIFailure(f"Request failed: {e!s}") from e - 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 03e69b87..f0433d6f 100644 --- a/socketsecurity/core/exceptions.py +++ b/socketsecurity/core/exceptions.py @@ -1,42 +1,36 @@ __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(Exception): """Raised when there is an error using the API""" - 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 \ No newline at end of file diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index b3c53bdc..b1280335 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -12,20 +12,19 @@ class Git: repo: Repo path: str - def __init__(self, path: str): + def __init__(self, path: str): # noqa: C901 initialization_start = time.perf_counter() self.path = path self._fetched_ref_commits = {} 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), @@ -36,7 +35,7 @@ def __init__(self, path: str): ((source, sha) for source, sha in ci_commits if sha), (None, None), ) - + if ci_sha: try: self.commit = self.repo.commit(ci_sha) @@ -44,52 +43,52 @@ def __init__(self, path: str): 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 try: remote_url = self.repo.remotes.origin.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: @@ -108,34 +107,34 @@ def __init__(self, path: str): 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}") @@ -155,15 +154,15 @@ def __init__(self, path: str): 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") if self._is_buildkite_pull_request(buildkite_pr) and buildkite_base_ref: detected = self._detect_pull_request_changes( provider="Buildkite", @@ -172,7 +171,7 @@ def __init__(self, path: str): ) 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, @@ -181,16 +180,16 @@ def __init__(self, path: str): 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}") @@ -200,8 +199,8 @@ def __init__(self, path: str): 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", @@ -212,9 +211,9 @@ def __init__(self, path: str): 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( @@ -256,14 +255,11 @@ def __init__(self, path: str): 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: @@ -282,7 +278,8 @@ def _resolve_ref(self, ref: str | None) -> str | None: for candidate in candidates: try: return self.repo.commit(candidate).hexsha - except Exception: + except Exception as e: + log.debug(f"Could not resolve ref candidate {candidate}: {e}") continue return None @@ -312,10 +309,10 @@ 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, + self, + provider: str, + base_ref: str, + head_ref: str | None, ) -> bool: """Detect a full PR range locally, fetching only refs needed to complete it.""" base_commit = self._resolve_ref(base_ref) @@ -330,19 +327,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, @@ -355,9 +353,7 @@ def _detect_pull_request_changes( f"{base_commit}...{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}") @@ -367,7 +363,7 @@ def _is_commit_and_branch_default(self) -> bool: """ 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 """ @@ -376,93 +372,92 @@ def _is_commit_and_branch_default(self) -> bool: if not self.is_commit_on_default_branch(): log.debug("Commit is not on default branch") return False - + # Check if we're processing the default branch via CI environment variables - 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') - gitlab_default_branch = os.getenv('CI_DEFAULT_BRANCH', '') - bitbucket_branch = os.getenv('BITBUCKET_BRANCH') - buildkite_branch = os.getenv('BUILDKITE_BRANCH') - buildkite_pr = os.getenv('BUILDKITE_PULL_REQUEST') - buildkite_default_branch = os.getenv('BUILDKITE_PIPELINE_DEFAULT_BRANCH') - + 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") + gitlab_default_branch = os.getenv("CI_DEFAULT_BRANCH", "") + bitbucket_branch = os.getenv("BITBUCKET_BRANCH") + buildkite_branch = os.getenv("BUILDKITE_BRANCH") + buildkite_pr = os.getenv("BUILDKITE_PULL_REQUEST") + buildkite_default_branch = os.getenv("BUILDKITE_PIPELINE_DEFAULT_BRANCH") + # Handle Buildkite before GitHub because some Buildkite pipelines # intentionally provide GitHub-compatible environment variables. 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 default_branch_name = buildkite_default_branch or 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}") - + # Handle pull requests - they're not on the default branch - if github_ref.startswith('refs/pull/'): + if github_ref.startswith("refs/pull/"): log.debug("Processing a pull request, not default branch") return False - + # Handle regular branch pushes - if github_ref.startswith('refs/heads/'): - branch_from_ref = github_ref.replace('refs/heads/', '') + if github_ref.startswith("refs/heads/"): + branch_from_ref = github_ref.replace("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 tags or other refs - not default branch log.debug(f"Non-branch ref: {github_ref}, not default branch") return False - + # Handle GitLab CI - elif gitlab_branch or gitlab_mr_branch: + if gitlab_branch or gitlab_mr_branch: # If this is a merge request, use the source branch current_branch = gitlab_mr_branch or gitlab_branch default_branch_name = gitlab_default_branch or self.get_default_branch_name() - + # For merge requests, they're typically not considered "default branch" if gitlab_mr_branch: log.debug(f"Processing GitLab MR from branch: {gitlab_mr_branch}, not default branch") return False - + 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 @@ -471,7 +466,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: @@ -480,63 +475,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 """ @@ -548,14 +543,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 """ @@ -563,15 +558,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 @@ -580,26 +575,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') """ @@ -607,34 +604,35 @@ def get_default_branch_name(self) -> str: # Try to get the default branch from remote HEAD remote_head = self.repo.remotes.origin.refs.HEAD # Extract branch name from refs/remotes/origin/HEAD -> refs/remotes/origin/main - default_branch = str(remote_head.reference).split('/')[-1] + default_branch = str(remote_head.reference).split("/")[-1] log.debug(f"Default branch detected: {default_branch}") return default_branch except Exception as error: log.debug(f"Could not determine default branch from remote: {error}") # Fallback: check common default branch names - for branch_name in ['main', 'master']: + for branch_name in ["main", "master"]: try: - if f'origin/{branch_name}' in [str(ref) for ref in self.repo.remotes.origin.refs]: + if f"origin/{branch_name}" in [str(ref) for ref in self.repo.remotes.origin.refs]: log.debug(f"Using fallback default branch: {branch_name}") return branch_name - except Exception: + except Exception as e: + log.debug(f"Could not check fallback branch {branch_name}: {e}") continue - + # Last fallback: assume 'main' log.debug("Using final fallback default branch: main") - return 'main' - + return "main" + 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 @@ -643,31 +641,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 """ @@ -676,14 +674,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 @@ -691,18 +691,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/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 d968c14b..40c6652c 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -3,7 +3,7 @@ import os import re import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from mdutils import MdUtils @@ -13,8 +13,8 @@ log = logging.getLogger("socketcli") -class Messages: +class Messages: @staticmethod def map_severity_to_sarif(severity: str) -> str: """ @@ -34,10 +34,10 @@ def map_severity_to_sarif(severity: str) -> str: return severity_mapping.get(severity.lower(), "note") @staticmethod - def get_manifest_file_url(diff: Diff, manifest_path: str, config=None) -> str: + def get_manifest_file_url(diff: Diff, manifest_path: str, config=None) -> str: # noqa: C901 """ 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 @@ -45,82 +45,81 @@ 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('/') - + 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'): + if config and hasattr(config, "scm"): scm_type = config.scm.lower() - elif hasattr(diff, 'diff_url') and diff.diff_url: + 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: + if "github.com" in diff_url or "github" in diff_url: scm_type = "github" - elif 'gitlab' in diff_url: + elif "gitlab" in diff_url: scm_type = "gitlab" - elif 'bitbucket' in diff_url: + 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 if scm_type == "github": - if config and hasattr(config, 'repo') and config.repo: + if config and hasattr(config, "repo") and config.repo: # Get branch from config, default to main - branch = getattr(config, 'branch', 'main') if hasattr(config, 'branch') and config.branch else 'main' + branch = getattr(config, "branch", "main") if hasattr(config, "branch") and config.branch else "main" # Construct GitHub URL from repo info (could be github.com or GitHub Enterprise) - github_server = os.getenv('GITHUB_SERVER_URL', 'https://github.com') + github_server = os.getenv("GITHUB_SERVER_URL", "https://github.com") return f"{github_server}/{config.repo}/blob/{branch}/{clean_path}" - + elif scm_type == "gitlab": - if config and hasattr(config, 'repo') and config.repo: + if config and hasattr(config, "repo") and config.repo: # Get branch from config, default to main - branch = getattr(config, 'branch', 'main') if hasattr(config, 'branch') and config.branch else 'main' + branch = getattr(config, "branch", "main") if hasattr(config, "branch") and config.branch else "main" # Construct GitLab URL from repo info (could be gitlab.com or self-hosted GitLab) - gitlab_server = os.getenv('CI_SERVER_URL', 'https://gitlab.com') + gitlab_server = os.getenv("CI_SERVER_URL", "https://gitlab.com") return f"{gitlab_server}/{config.repo}/-/blob/{branch}/{clean_path}" - - elif scm_type == "bitbucket": - if config and hasattr(config, 'repo') and config.repo: - # Get branch from config, default to main - branch = getattr(config, 'branch', 'main') if hasattr(config, 'branch') and config.branch else 'main' - # Construct Bitbucket URL from repo info (could be bitbucket.org or Bitbucket Server) - bitbucket_server = os.getenv('BITBUCKET_SERVER_URL', 'https://bitbucket.org') - return f"{bitbucket_server}/{config.repo}/src/{branch}/{clean_path}" - + + elif scm_type == "bitbucket" and config and hasattr(config, "repo") and config.repo: + # Get branch from config, default to main + branch = getattr(config, "branch", "main") if hasattr(config, "branch") and config.branch else "main" + # Construct Bitbucket URL from repo info (could be bitbucket.org or Bitbucket Server) + bitbucket_server = os.getenv("BITBUCKET_SERVER_URL", "https://bitbucket.org") + return f"{bitbucket_server}/{config.repo}/src/{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). @@ -137,25 +136,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() @@ -165,45 +158,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): @@ -249,7 +241,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. @@ -270,16 +262,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 = {} @@ -291,7 +281,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: @@ -300,7 +295,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) @@ -315,28 +310,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] = { @@ -345,23 +341,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) @@ -395,7 +391,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", @@ -411,16 +407,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 = {} @@ -469,9 +463,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: @@ -481,15 +474,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": @@ -500,15 +491,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], @@ -519,7 +509,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", [])) @@ -540,26 +530,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(): @@ -583,12 +577,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))) @@ -635,7 +624,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 @@ -649,32 +638,37 @@ def extract_identifiers_gitlab(alert: Issue) -> list: identifiers = [] # Primary identifier: Socket alert type - identifiers.append({ - "type": "socket_alert", - "name": f"Socket {alert.type}", - "value": alert.type, - "url": alert.url if hasattr(alert, 'url') and alert.url else None - }) + identifiers.append( + { + "type": "socket_alert", + "name": f"Socket {alert.type}", + "value": alert.type, + "url": alert.url if hasattr(alert, "url") and alert.url else None, + } + ) # Extract CVE identifiers from props - if hasattr(alert, 'props') and alert.props: - if 'cve' in alert.props: - cves = alert.props['cve'] - if isinstance(cves, list): - for cve in cves: - identifiers.append({ + if hasattr(alert, "props") and alert.props and "cve" in alert.props: + cves = alert.props["cve"] + if isinstance(cves, list): + for cve in cves: + identifiers.append( + { "type": "cve", "name": cve, "value": cve, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve}" - }) - elif isinstance(cves, str): - identifiers.append({ + "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve}", + } + ) + elif isinstance(cves, str): + identifiers.append( + { "type": "cve", "name": cves, "value": cves, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cves}" - }) + "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cves}", + } + ) return identifiers @@ -693,35 +687,27 @@ def extract_location_gitlab(alert: Issue) -> dict: dependency_path = [] is_direct = True - if hasattr(alert, 'introduced_by') and alert.introduced_by: + if hasattr(alert, "introduced_by") and alert.introduced_by: if isinstance(alert.introduced_by, list) and len(alert.introduced_by) > 0: first_entry = alert.introduced_by[0] if isinstance(first_entry, (list, tuple)) and len(first_entry) >= 2: dependency_path_str = first_entry[0] - manifest_file = first_entry[1].split(';')[0] if ';' in first_entry[1] else first_entry[1] + manifest_file = first_entry[1].split(";")[0] if ";" in first_entry[1] else first_entry[1] # Parse dependency path - if ' > ' in dependency_path_str: - dependency_path = dependency_path_str.split(' > ') + if " > " in dependency_path_str: + dependency_path = dependency_path_str.split(" > ") # If there's a chain, it's transitive (not direct) is_direct = len(dependency_path) <= 1 - elif hasattr(alert, 'manifests') and alert.manifests: - manifest_file = alert.manifests.split(';')[0] + elif hasattr(alert, "manifests") and alert.manifests: + manifest_file = alert.manifests.split(";")[0] - location = { + return { "file": manifest_file, - "dependency": { - "package": { - "name": alert.pkg_name - }, - "version": alert.pkg_version, - "direct": is_direct - } + "dependency": {"package": {"name": alert.pkg_name}, "version": alert.pkg_version, "direct": is_direct}, } - return location - @staticmethod def create_security_comment_gitlab(diff: Diff) -> dict: """ @@ -745,44 +731,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) @@ -790,18 +772,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()) @@ -942,7 +919,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: @@ -953,11 +930,15 @@ def security_comment_template(diff: Diff, config=None) -> str: manifest_url = Messages.get_manifest_file_url(diff, alert.manifests, config) # 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 {alert.pkg_name}@{alert.pkg_version}
" - 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 {alert.pkg_name}@{alert.pkg_version}
" + f"Or ignore all future alerts with:
" + f"@SocketSecurity ignore-all

" + ) + if show_ignore + else "" + ) comment += f""" @@ -985,18 +966,18 @@ 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 = "๐Ÿ”ถ" - + # Build license findings list license_findings = [] for alert in alerts: license_findings.append(alert.title) - + comment += f""" @@ -1010,17 +991,20 @@ def security_comment_template(diff: Diff, config=None) -> str: """ for finding in license_findings: comment += f"
  • {Messages.inline_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 {first_alert.pkg_name}@{first_alert.pkg_version}. " - 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 {first_alert.pkg_name}@{first_alert.pkg_version}. " + 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?

    @@ -1062,7 +1046,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): """ @@ -1142,48 +1125,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 @@ -1196,7 +1156,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() @@ -1209,7 +1171,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})") @@ -1232,6 +1196,27 @@ def create_remove_line(diff: Diff, md: MdUtils) -> MdUtils: md.new_line(removed_line) return md + @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: """ @@ -1248,7 +1233,7 @@ def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: "Vulnerability", "Quality", "Maintenance", - "License" + "License", ] num_of_overview_columns = len(overview_table) @@ -1259,32 +1244,17 @@ def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: package_url = f"[{added.purl}]({added.url})" diff_badge = f"[![+](https://github-app-statics.socket.dev/diff-added.svg)]({added.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)]({added.url})" - - def get_score_for_badge(score_name: str) -> float: - scores = getattr(added, "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(added, name), added.url) + for name in ("supplyChain", "vulnerability", "quality", "maintenance", "license") + ) # Add the row for this package row = [ @@ -1294,7 +1264,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 # Count total packages @@ -1304,10 +1274,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 @@ -1318,8 +1285,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: @@ -1328,16 +1294,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: :param diff: Diff - Diff report with the detected issues :return: """ - alert_table = PrettyTable( - [ - "Alert", - "Package", - "url", - "Introduced by", - "Manifest File", - "CI Status" - ] - ) + alert_table = PrettyTable(["Alert", "Package", "url", "Introduced by", "Manifest File", "CI Status"]) for alert in diff.new_alerts: alert: Issue manifest_str, source_str = Messages.create_sources(alert, "console") @@ -1349,14 +1306,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: state = "monitor" else: state = "ignore" - row = [ - alert.title, - alert.purl, - alert.url, - source_str, - manifest_str, - state - ] + row = [alert.title, alert.purl, alert.url, source_str, manifest_str, state] alert_table.add_row(row) return alert_table 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 7504a46c..bb1e223e 100644 --- a/socketsecurity/core/scm/github.py +++ b/socketsecurity/core/scm/github.py @@ -16,6 +16,7 @@ @dataclass class GithubConfig: """Configuration from GitHub environment variables""" + sha: str api_url: str ref_type: str @@ -41,9 +42,7 @@ def _repository_from_buildkite() -> tuple[str, str]: repository_url = ( # Comments and statuses belong to the pipeline/base repository, # not a contributor's fork from BUILDKITE_PULL_REQUEST_REPO. - os.getenv("BUILDKITE_REPO") - or os.getenv("BUILDKITE_PULL_REQUEST_REPO") - or "" + os.getenv("BUILDKITE_REPO") or os.getenv("BUILDKITE_PULL_REQUEST_REPO") or "" ).strip() if not repository_url: return "", "" @@ -61,57 +60,51 @@ def _repository_from_buildkite() -> tuple[str, str]: 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. @@ -124,44 +117,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"}, ) @@ -179,8 +157,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) @@ -194,22 +172,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: @@ -220,11 +190,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 = {} @@ -244,7 +210,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") @@ -279,11 +245,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 2c3947de..0801e3e9 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 @@ -12,16 +11,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 @@ -33,100 +37,83 @@ 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: - def __init__(self, client: CliClient, config: Optional[GitlabConfig] = None): + def __init__(self, client: CliClient, config: GitlabConfig | None = None): self.config = config or GitlabConfig.from_env() self.client = client @@ -142,16 +129,16 @@ def _request_with_fallback(self, **kwargs): # Check if this is an authentication error (401) if e.response and e.response.status_code == 401: log.debug("Authentication failed with initial headers, trying fallback method") - + # Determine the fallback headers - 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 fallback_headers and fallback_headers != original_headers: log.debug("Retrying request with fallback authentication method") - kwargs['headers'] = fallback_headers + kwargs["headers"] = fallback_headers return self.client.request(**kwargs) - + # Re-raise the original exception if it's not an auth error or fallback failed raise except Exception: @@ -163,75 +150,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 @@ -242,11 +209,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: @@ -259,12 +222,12 @@ def get_comments_for_pr(self) -> dict: return Comments.check_for_socket_comments(comments) 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") @@ -297,6 +260,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) @@ -305,6 +269,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}") @@ -313,7 +278,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 @@ -334,11 +299,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() @@ -350,7 +315,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( @@ -358,7 +325,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 7c479b72..c5075bb9 100644 --- a/socketsecurity/core/scm_comments.py +++ b/socketsecurity/core/scm_comments.py @@ -36,15 +36,14 @@ def remove_alerts(comments: dict, new_alerts: list) -> list: alert: Issue if ignore_all: break + full_name = f"{alert.pkg_type}/{alert.pkg_name}" + purl = (full_name, alert.pkg_version) + purl_star = (full_name, "*") + if purl in ignore_commands or purl_star in ignore_commands: + log.info(f"Alerts for {alert.pkg_name}@{alert.pkg_version} ignored") else: - full_name = f"{alert.pkg_type}/{alert.pkg_name}" - purl = (full_name, alert.pkg_version) - purl_star = (full_name, "*") - if purl in ignore_commands or purl_star 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 @@ -77,7 +76,7 @@ def get_ignore_options(comments: dict) -> [bool, list]: @staticmethod def is_ignore(pkg_name: str, pkg_version: str, name: str, version: str) -> bool: result = False - if pkg_name == name and (pkg_version == version or version == "*"): + if pkg_name == name and (version in (pkg_version, "*")): result = True return result @@ -114,20 +113,18 @@ def process_security_comment(comment: Comment, comments) -> 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 != '': - title, package, introduced_by, manifest, ci = line.lstrip("|").rstrip("|").split("|") + elif start and "end-socket-alerts-table" not in line and not Comments.is_heading_line(line) and line != "": + _title, package, _introduced_by, _manifest, _ci = line.lstrip("|").rstrip("|").split("|") details, _ = package.split("](") ecosystem, details = details.split("/", 1) ecosystem = ecosystem.lstrip("[") @@ -137,8 +134,7 @@ def process_original_security_comment( # comment produces no ignore_commands, so a loop-internal check # never runs and every row was kept. ignore = ignore_all or any( - Comments.is_ignore(pkg_name, pkg_version, name, version) - for name, version in ignore_commands + Comments.is_ignore(pkg_name, pkg_version, name, version) for name, version in ignore_commands ) if not ignore: kept_alert = True @@ -156,9 +152,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. @@ -176,25 +170,24 @@ 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: + for raw_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") + line = raw_line.rstrip("\r") stripped = line.strip() # Detect the start of an alert section if stripped.startswith("