From 781a0544eb6dd92e25e23d48d2f82ba2319fa79f Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:47:07 -0400 Subject: [PATCH 1/4] fix(slack): normalize the API's "middle" severity to "medium" Every severity lookup in the Slack reachability formatter is keyed on "medium", but "middle" is what the API sends. A mid-severity finding missed all of them at once: uncounted in the summary, excluded from total_findings so the "and N more" count can go negative, and sorted at the default order of 4 -- below "low" -- so it was truncated out of the message first. Normalized at the point the alert is read rather than by adding a parallel key to each dict, so one canonical spelling flows downstream. The GitLab severity map and the PR comment path already accept both forms; this formatter did not. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 ++++ socketsecurity/plugins/formatters/slack.py | 6 ++ .../unit/test_slack_severity_normalization.py | 76 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/unit/test_slack_severity_normalization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dda5bc..343542ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,19 @@ - Full-scan and streamed-diff API failures now use the configured infrastructure error exit code instead of the security-finding exit code. +### Fixed: mid-severity findings were dropped from the Slack summary + +- The Slack reachability formatter keyed every severity lookup on `medium`, + but the API sends `middle`. A mid-severity finding therefore missed all of + them at once: it was not counted, so the summary always read `Medium: 0`; it + was excluded from `total_findings`, which can drive the "and N more" count + negative; and it sorted at the default order of 4, below `low`, so it was the + first thing truncated when the Slack block limit was reached. +- Severity is now normalized to one spelling when an alert is read, matching + how the GitLab and PR-comment paths already handle both forms. The findings + themselves were always listed; only the counts, ordering and truncation were + wrong. + ## 2.7.2 ### Changed: bump pinned @coana-tech/cli to 15.10.39 diff --git a/socketsecurity/plugins/formatters/slack.py b/socketsecurity/plugins/formatters/slack.py index 1a3ab874..955b330a 100644 --- a/socketsecurity/plugins/formatters/slack.py +++ b/socketsecurity/plugins/formatters/slack.py @@ -108,6 +108,12 @@ def _extract_alert_info(component: Dict[str, Any], alert: Dict[str, Any]) -> Dic """ props = alert.get('props', {}) or {} severity = str(alert.get('severity') or props.get('severity') or '').lower() + # The API's mid-level severity is "middle"; every lookup in this module is + # keyed on "medium". Normalizing here rather than adding a parallel key to + # each dict keeps one canonical spelling downstream, matching what + # Messages.map_socket_severity_to_gitlab already does. + if severity == 'middle': + severity = 'medium' return { 'cve_id': str(props.get('ghsaId') or props.get('cveId') or alert.get('title') or 'Unknown'), diff --git a/tests/unit/test_slack_severity_normalization.py b/tests/unit/test_slack_severity_normalization.py new file mode 100644 index 00000000..861fe34c --- /dev/null +++ b/tests/unit/test_slack_severity_normalization.py @@ -0,0 +1,76 @@ +"""The Slack formatter keys on "medium"; the API sends "middle". + +Every severity lookup in ``socketsecurity/plugins/formatters/slack.py`` is keyed +on ``medium``, but ``middle`` is what the API actually emits -- it is the value +in the OpenAPI spec's ``SocketIssueSeverity`` and in the SDK enum. Unnormalized, +a mid-severity finding fell through every one of them at once: + +* it was not counted, so the summary always read ``Medium: 0`` +* it was excluded from ``total_findings``, which can drive ``omitted_count`` + negative when mid-severity findings are the ones being displayed +* it sorted at the default order of 4, below ``low``, so it was truncated out of + the message first when the block limit was reached + +Two other call sites already handle both spellings (``Messages.map_socket_ +severity_to_gitlab`` and the GitLab severity map); this formatter did not. +""" + +import unittest + +from socketsecurity.plugins.formatters.slack import ( + SEVERITY_EMOJI, + SEVERITY_ORDER, + _extract_alert_info, + format_socket_facts_for_slack, +) + + +def _component(severity: str) -> dict: + return { + "name": "example-package", + "version": "1.0.0", + "alerts": [{"title": "Example alert", "severity": severity, "props": {}}], + } + + +class TestSeverityNormalization(unittest.TestCase): + def test_middle_normalizes_to_medium(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity"], "medium") + + def test_middle_gets_the_medium_order_not_the_default(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity_order"], SEVERITY_ORDER["medium"]) + # Regression: the default of 4 sorted mid-severity below "low". + self.assertLess(info["severity_order"], SEVERITY_ORDER["low"]) + + def test_middle_gets_the_medium_emoji_not_the_fallback(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity_emoji"], SEVERITY_EMOJI["medium"]) + self.assertNotEqual(info["severity_emoji"], SEVERITY_EMOJI["low"]) + + def test_medium_still_works(self): + info = _extract_alert_info(_component("medium"), {"severity": "medium"}) + self.assertEqual(info["severity"], "medium") + self.assertEqual(info["severity_order"], SEVERITY_ORDER["medium"]) + + def test_middle_findings_are_counted_in_the_summary(self): + result = format_socket_facts_for_slack([_component("middle")]) + self.assertEqual(len(result), 1) + self.assertIn("🟡 Medium: 1", result[0]["summary"]) + + def test_middle_findings_reach_total_findings(self): + # Regression: excluded from the total, omitted_count could go negative. + result = format_socket_facts_for_slack([_component("middle")]) + self.assertEqual(result[0]["total_findings"], 1) + + def test_unrecognized_severity_still_falls_back(self): + info = _extract_alert_info( + _component("brand-new-level"), {"severity": "brand-new-level"} + ) + self.assertEqual(info["severity_order"], 4) + self.assertEqual(info["severity_emoji"], "⚪") + + +if __name__ == "__main__": + unittest.main() From 0f094ab6f42b640e9fe940247ddb74404834b8f7 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:56:50 -0400 Subject: [PATCH 2/4] chore(deps): bump socketdev to 3.6.0 Unblocks the pin now that 3.6.0 is on PyPI. SocketPURL_Type gained ten members -- alpm, chrome, clawhub, edge-extension, firefox-extension, qpkg, socket, swid, vscode and vscode-extension -- and removed none, so artifacts of those types stop falling back to "unknown". No other CLI change is needed: none of the SDK's enum types are imported here, and every severity and type lookup already has a default, so the new members cannot reach an unguarded branch. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 343542ea..ba943fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ - Clarified monorepo scan scoping, workspace flags, CI path filters, and timeout behavior, with a changed-workspace GitHub Actions example. +### Changed: bump socketdev to 3.6.0 + +- Bumped the pinned SDK (`socketdev`) from `3.5.0` to `3.6.0`. Its package-type + enum gained ten members — `alpm`, `chrome`, `clawhub`, `edge-extension`, + `firefox-extension`, `qpkg`, `socket`, `swid`, `vscode` and + `vscode-extension` — so artifacts of those types are now reported under their + own type instead of falling back to `unknown`. + ### Fixed: apply configured exit codes to API failures - Full-scan and streamed-diff API failures now use the configured infrastructure diff --git a/pyproject.toml b/pyproject.toml index 29a9e412..a9035118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "GitPython==3.1.59", "packaging==26.3", "python-dotenv==1.2.3", - "socketdev==3.5.0", + "socketdev==3.6.0", "beautifulsoup4==4.15.0", "markdown==3.10.3", "brotli==1.2.0; platform_python_implementation == 'CPython'", diff --git a/uv.lock b/uv.lock index 2eab7d99..28dea3ad 100644 --- a/uv.lock +++ b/uv.lock @@ -1280,15 +1280,15 @@ wheels = [ [[package]] name = "socketdev" -version = "3.5.0" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/24/0c11290dc7d59e24b7075035c7e1a3ab87fa17a445cebc88cfa6ee98b22c/socketdev-3.5.0.tar.gz", hash = "sha256:a2b20f9b98f73c25f3d2e97a1ae730504509c91219c0b393f28a9230266b3531", size = 195138, upload-time = "2026-08-06T03:47:14.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/64/7c44c1b1f739db9c40ea0a3d429d27b1810f43153b0680ec4bfc3aada644/socketdev-3.6.0.tar.gz", hash = "sha256:8453da37520db79735479e0892b74cef8ce5bcba52c99f15ffacc6a70f9ea355", size = 201023, upload-time = "2026-09-09T22:44:01.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/75/5a8506a473716740e94f2f01b697909333f966c143d8a0a566d278e6118d/socketdev-3.5.0-py3-none-any.whl", hash = "sha256:780f5841770397035ff87de6181d954b6318cd0a07f6fdd304d1376667f33f68", size = 72027, upload-time = "2026-08-06T03:47:12.773Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/e4ac2746b803109eab1fa7b44b02697f365fb418340411df5091e50d4a07/socketdev-3.6.0-py3-none-any.whl", hash = "sha256:fe017aefa72638375bb0fb1697bf5704f64143a5fab634534dfc3e77c851b374", size = 73352, upload-time = "2026-09-09T22:44:00.167Z" }, ] [[package]] @@ -1350,7 +1350,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.5" }, - { name = "socketdev", specifier = "==3.5.0" }, + { name = "socketdev", specifier = "==3.6.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.8" }, ] From b80cb012df74dab16ec5bea9483de64458251c6b Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:02:26 -0400 Subject: [PATCH 3/4] ci: only floor the version check at the latest published release The check required the PR version to exceed both main and PyPI. Comparing against main forbids the legitimate case where several PRs ship under one unreleased version: the first bumps main, and the rest ride it without bumping again so they stay under a single changelog header. Every such PR failed, and the only way to green it was a throwaway bump that would strand a changelog header on a version that never ships. PyPI is now the floor, since the real invariant is that a release cannot reuse a published version. Main is still a floor in the one direction that matters: a PR may leave the version alone or move it forwards, never back. Every genuine failure the old check caught -- forgetting to bump, reusing a published version, branching from a stale base -- still fails. Also added this workflow to its own paths filter so a change to the check is exercised by the PR that makes it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/version-check.yml | 38 ++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index d2ff77ad..3d3c2ba9 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -6,6 +6,8 @@ on: - 'socketsecurity/**' - 'pyproject.toml' - 'uv.lock' + # Included so a change to the check itself is exercised by its own PR. + - '.github/workflows/version-check.yml' permissions: contents: read @@ -42,7 +44,7 @@ jobs: export PR_VERSION export MAIN_VERSION - # Compare against both main and latest published PyPI release. + # Compare against the latest published PyPI release. python3 <<'PY' import json import os @@ -62,19 +64,37 @@ jobs: published_versions.append(parsed) pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0") - required_floor = max(main_ver, pypi_ver) - if pr_ver <= required_floor: + # The only hard requirement is that the version is ahead of what is + # actually released. Treating main's version as a second floor breaks + # the legitimate case where several PRs share one unreleased release: + # the first bumps main to the new version and the rest ride it without + # bumping again, which is what keeps them under a single changelog + # header. Main is therefore only a floor when this PR moves the + # version -- a change to it must go forwards, never backwards. + if pr_ver <= pypi_ver: print( - f"❌ Version must be greater than main and PyPI! " - f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + f"❌ Version {pr_ver} is already published on PyPI " + f"(latest release: {pypi_ver}). Bump it." + ) + raise SystemExit(1) + + if pr_ver < main_ver: + print( + f"❌ Version moves backwards: main is {main_ver}, PR is {pr_ver}." ) raise SystemExit(1) - print( - f"✅ Version properly incremented. " - f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" - ) + if pr_ver == main_ver: + print( + f"✅ Riding main's unreleased {pr_ver} " + f"(latest PyPI release: {pypi_ver})." + ) + else: + print( + f"✅ Version properly incremented. " + f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + ) PY - name: Require uv.lock update when pyproject changes From ee265b569aa5e96f8a48b1eb73d938a838d8c486 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:35:28 -0400 Subject: [PATCH 4/4] ci: require pyproject.toml and __init__.py versions to agree The version lives as two hand-maintained literals with nothing deriving one from the other: pyproject.toml is what gets published, and __init__.py is what the CLI reports as its User-Agent. Every comparison in this job read only __init__.py, so bumping that alone passed the check and then published under the old number -- surfacing late, as twine rejecting an existing file, after the merge. Both are now required to match before any other comparison runs. uv.lock carries a third copy, but uv derives it and `uv lock --locked` in python-tests already fails when it drifts, so it needs no check here. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/version-check.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 3d3c2ba9..292e2980 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -48,12 +48,33 @@ jobs: python3 <<'PY' import json import os + import tomllib import urllib.request from packaging import version pr_ver = version.parse(os.environ["PR_VERSION"]) main_ver = version.parse(os.environ["MAIN_VERSION"]) + with open("pyproject.toml", "rb") as fh: + pyproject_ver = version.parse(tomllib.load(fh)["project"]["version"]) + + # The version is two hand-maintained literals with nothing deriving one + # from the other: pyproject.toml is what actually gets published, and + # socketsecurity/__init__.py is what the CLI reports as its User-Agent. + # Every comparison below reads only __init__.py, so bumping that alone + # would pass this job and then publish under the old number -- caught + # late, by twine rejecting an existing file, after the merge. Require + # the two to agree before comparing anything. (uv.lock carries a third + # copy, but uv derives it and `uv lock --locked` in python-tests + # already fails when it drifts.) + if pr_ver != pyproject_ver: + print( + f"❌ Version mismatch inside the PR: pyproject.toml is " + f"{pyproject_ver}, socketsecurity/__init__.py is {pr_ver}. " + f"Bump both." + ) + raise SystemExit(1) + with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response: pypi_data = json.load(response)