Skip to content

Commit 8660622

Browse files
committed
Protect notification output and test release version checks
1 parent 261efff commit 8660622

8 files changed

Lines changed: 295 additions & 127 deletions

File tree

‎.github/workflows/version-check.yml‎

Lines changed: 1 addition & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -29,123 +29,7 @@ jobs:
2929
python -m pip install --upgrade pip
3030
pip install packaging
3131
32-
# Get version from current PR
33-
PR_VERSION=$(grep -E '^__version__[[:space:]]*=' socketsecurity/__init__.py | awk '{print $3}' | tr -d "\"'")
34-
echo "PR_VERSION=$PR_VERSION" >> "$GITHUB_ENV"
35-
36-
# Get version from main branch
37-
MAIN_VERSION=$(git show origin/main:socketsecurity/__init__.py | grep -E '^__version__[[:space:]]*=' | awk '{print $3}' | tr -d "\"'")
38-
echo "MAIN_VERSION=$MAIN_VERSION" >> "$GITHUB_ENV"
39-
40-
export PR_VERSION
41-
export MAIN_VERSION
42-
43-
# CI and documentation changes do not require a package release.
44-
if git diff --name-only origin/main...HEAD \
45-
| grep -qE '^(socketsecurity/|pyproject\.toml$|uv\.lock$)'; then
46-
PACKAGE_CHANGED=true
47-
else
48-
PACKAGE_CHANGED=false
49-
fi
50-
export PACKAGE_CHANGED
51-
52-
# Compare against the latest published PyPI release.
53-
python3 <<'PY'
54-
import json
55-
import os
56-
import subprocess
57-
import tomllib
58-
import urllib.request
59-
from packaging import version
60-
61-
pr_ver = version.parse(os.environ["PR_VERSION"])
62-
main_ver = version.parse(os.environ["MAIN_VERSION"])
63-
64-
enforced = os.environ["PACKAGE_CHANGED"] == "true"
65-
66-
def reject(message):
67-
print(message)
68-
if enforced:
69-
raise SystemExit(1)
70-
print(
71-
"ℹ️ Not enforced: this PR changes no packaged files, "
72-
"so it ships nothing that needs a new version."
73-
)
74-
raise SystemExit(0)
75-
76-
with open("pyproject.toml", "rb") as fh:
77-
pyproject_ver = version.parse(tomllib.load(fh)["project"]["version"])
78-
79-
# The version is two hand-maintained literals with nothing deriving one
80-
# from the other: pyproject.toml is what actually gets published, and
81-
# socketsecurity/__init__.py is what the CLI reports as its User-Agent.
82-
# Every comparison below reads only __init__.py, so bumping that alone
83-
# would pass this job and then publish under the old number -- caught
84-
# late, by twine rejecting an existing file, after the merge. Require
85-
# the two to agree before comparing anything. (uv.lock carries a third
86-
# copy, but uv derives it and `uv lock --locked` in python-tests
87-
# already fails when it drifts.)
88-
if pr_ver != pyproject_ver:
89-
reject(
90-
f"❌ Version mismatch inside the PR: pyproject.toml is "
91-
f"{pyproject_ver}, socketsecurity/__init__.py is {pr_ver}. "
92-
f"Bump both."
93-
)
94-
95-
with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response:
96-
pypi_data = json.load(response)
97-
98-
published_versions = []
99-
for raw in pypi_data.get("releases", {}).keys():
100-
parsed = version.parse(raw)
101-
if not parsed.is_prerelease and not parsed.is_devrelease:
102-
published_versions.append(parsed)
103-
104-
pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0")
105-
106-
# The only hard requirement is that the version is ahead of what is
107-
# actually released. Treating main's version as a second floor breaks
108-
# the legitimate case where several PRs share one unreleased release:
109-
# the first bumps main to the new version and the rest ride it without
110-
# bumping again, which is what keeps them under a single changelog
111-
# header. Main is therefore only a floor when this PR moves the
112-
# version -- a change to it must go forwards, never backwards.
113-
if pr_ver <= pypi_ver:
114-
reject(
115-
f"❌ Version {pr_ver} is already published on PyPI "
116-
f"(latest release: {pypi_ver}). Bump it."
117-
)
118-
119-
# Releases are immutable: once a v<version> release is published, its tag can't
120-
# be moved or reused, even when that release failed and nothing reached PyPI.
121-
# PyPI alone misses that, so a burned tag would only surface when the release
122-
# run fails after the merge.
123-
tag = f"v{os.environ['PR_VERSION']}"
124-
if subprocess.run(
125-
["git", "rev-parse", "--quiet", "--verify", f"refs/tags/{tag}"],
126-
capture_output=True,
127-
).returncode == 0:
128-
reject(
129-
f"❌ Tag {tag} already exists, so {pr_ver} can never be released "
130-
f"(latest PyPI release: {pypi_ver}). Bump to a version with no tag."
131-
)
132-
133-
if pr_ver < main_ver:
134-
reject(
135-
f"❌ Version moves backwards: main is {main_ver}, PR is {pr_ver}."
136-
)
137-
138-
if pr_ver == main_ver:
139-
print(
140-
f"✅ Riding main's unreleased {pr_ver} "
141-
f"(latest PyPI release: {pypi_ver})."
142-
)
143-
else:
144-
print(
145-
f"✅ Version properly incremented. "
146-
f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}"
147-
)
148-
PY
32+
python3 .hooks/check_version.py
14933
15034
- name: Require uv.lock update when pyproject changes
15135
run: |

‎.hooks/check_version.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Validate release versions for pull requests that change packaged files."""
2+
3+
import json
4+
import os
5+
import re
6+
import subprocess
7+
import tomllib
8+
import urllib.request
9+
from pathlib import Path
10+
11+
from packaging.version import Version
12+
13+
14+
def read_version(source: str) -> Version:
15+
match = re.search(r"^__version__\s*=\s*['\"]([^'\"]+)['\"]", source, re.MULTILINE)
16+
if not match:
17+
raise ValueError("No __version__ declaration found")
18+
return Version(match.group(1))
19+
20+
21+
def latest_pypi_version() -> Version:
22+
with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response:
23+
releases = json.load(response).get("releases", {})
24+
versions = (Version(raw) for raw in releases)
25+
return max((v for v in versions if not v.is_prerelease and not v.is_devrelease), default=Version("0.0.0"))
26+
27+
28+
def main() -> int:
29+
pr_ver = read_version(Path("socketsecurity/__init__.py").read_text())
30+
main_ver = read_version(
31+
subprocess.check_output(["git", "show", "origin/main:socketsecurity/__init__.py"], text=True)
32+
)
33+
changed_paths = subprocess.check_output(
34+
["git", "diff", "--name-only", "origin/main...HEAD"], text=True
35+
).splitlines()
36+
enforced = any(
37+
name.startswith("socketsecurity/") or name in {"pyproject.toml", "uv.lock"} for name in changed_paths
38+
)
39+
40+
if github_env := os.environ.get("GITHUB_ENV"):
41+
with open(github_env, "a", encoding="utf-8") as env_file:
42+
env_file.write(f"PR_VERSION={pr_ver}\nMAIN_VERSION={main_ver}\n")
43+
44+
def reject(message: str) -> int:
45+
print(message)
46+
if enforced:
47+
return 1
48+
print("ℹ️ Not enforced: this PR changes no packaged files, so it ships nothing that needs a new version.")
49+
return 0
50+
51+
with open("pyproject.toml", "rb") as project_file:
52+
pyproject_ver = Version(tomllib.load(project_file)["project"]["version"])
53+
if pr_ver != pyproject_ver:
54+
return reject(
55+
f"❌ Version mismatch inside the PR: pyproject.toml is {pyproject_ver}, "
56+
f"socketsecurity/__init__.py is {pr_ver}. Bump both."
57+
)
58+
59+
pypi_ver = latest_pypi_version()
60+
if pr_ver <= pypi_ver:
61+
return reject(f"❌ Version {pr_ver} is already published on PyPI (latest release: {pypi_ver}). Bump it.")
62+
63+
# Published release tags are immutable, even if publication to PyPI fails.
64+
tag = f"v{pr_ver}"
65+
if (
66+
subprocess.run(
67+
["git", "rev-parse", "--quiet", "--verify", f"refs/tags/{tag}"], capture_output=True, check=False
68+
).returncode
69+
== 0
70+
):
71+
return reject(
72+
f"❌ Tag {tag} already exists, so {pr_ver} can never be released "
73+
f"(latest PyPI release: {pypi_ver}). Bump to a version with no tag."
74+
)
75+
76+
if pr_ver < main_ver:
77+
return reject(f"❌ Version moves backwards: main is {main_ver}, PR is {pr_ver}.")
78+
79+
# Multiple pull requests may share main's unreleased version.
80+
if pr_ver == main_ver:
81+
print(f"✅ Riding main's unreleased {pr_ver} (latest PyPI release: {pypi_ver}).")
82+
else:
83+
print(f"✅ Version properly incremented. Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}")
84+
return 0
85+
86+
87+
if __name__ == "__main__":
88+
raise SystemExit(main())

‎CHANGELOG.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44

55
### Fixed
66

7-
- Configuration debug output masks credential fields, including nested plugin
8-
settings, and integration URL paths. Slack configuration environment contents
9-
are reported only as set or unset.
7+
- Configuration debug output masks credential fields and integration URL paths,
8+
including nested plugin settings and Slack webhook lists. Slack configuration
9+
environment contents are reported only as set or unset.
1010
- The ignore-command authorization policy remains visible in configuration
1111
debug output so comment-handling decisions can be diagnosed.
1212
- Alert timestamps retain their leading and trailing characters when the
1313
optional timezone suffix is removed.
1414
- Slack, Teams, Jira, webhook, and GitLab commit-status requests use a 30-second
1515
timeout so an unresponsive endpoint cannot hold a scan open indefinitely.
16+
- Jira request failures are logged without interrupting report file creation or
17+
changing the scan's exit code.
1618
- Manifest upload validation also runs under `python -O`.
1719
- Duplicate SBOM package diagnostics use debug logging, keeping stdout available
1820
for machine-readable reports.

‎socketsecurity/output.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .core.classes import Diff, Issue
1818
from .core.messages import Messages
1919
from .fossa_compat import build_fossa_report_payload
20-
from .redaction import redact_url
20+
from .redaction import redact_mapping
2121

2222

2323
class OutputHandler:
@@ -69,7 +69,7 @@ def handle_output(self, diff_report: Diff) -> None: # noqa: C901
6969
slack_config_env = "Set" if os.getenv("SOCKET_SLACK_CONFIG_JSON") else "Not set"
7070
slack_url = "Not configured"
7171
if self.config.slack_plugin.config and self.config.slack_plugin.config.get("url"):
72-
slack_url = self.config.slack_plugin.config.get("url")
72+
slack_url = redact_mapping(self.config.slack_plugin.config)["url"]
7373
slack_mode = (self.config.slack_plugin.config or {}).get("mode", "webhook")
7474
bot_token = os.getenv("SOCKET_SLACK_BOT_TOKEN")
7575
bot_token_status = "Set" if bot_token else "Not set"
@@ -78,7 +78,7 @@ def handle_output(self, diff_report: Diff) -> None: # noqa: C901
7878
self.logger.debug(f"Slack Mode: {slack_mode}")
7979
self.logger.debug(f"SOCKET_SLACK_ENABLED environment variable: {slack_enabled_env}")
8080
self.logger.debug(f"SOCKET_SLACK_CONFIG_JSON environment variable: {slack_config_env}")
81-
self.logger.debug(f"Slack Webhook URL: {redact_url(slack_url)}")
81+
self.logger.debug("Slack Webhook URL: %s", slack_url)
8282
self.logger.debug(f"SOCKET_SLACK_BOT_TOKEN: {bot_token_status}")
8383
self.logger.debug(f"Slack Alert Levels: {self.config.slack_plugin.levels}")
8484
if self.config.reach:

‎socketsecurity/plugins/jira.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ def send(self, diff: Diff, config: CliConfig):
7171
headers = {"Authorization": f"Basic {auth}", "Content-Type": "application/json"}
7272
jira_url = f"{url}/rest/api/3/issue"
7373
log.debug(f"Jira URL: {redact_url(jira_url)}")
74-
response = requests.post(jira_url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT_SECONDS)
74+
try:
75+
response = requests.post(jira_url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT_SECONDS)
76+
except requests.RequestException as error:
77+
log.error("Jira request failed (%s) for %s", type(error).__name__, redact_url(jira_url))
78+
return
7579
if response.status_code >= 300:
7680
log.error(f"Jira error {response.status_code}: {response.text}")
7781
else:

‎tests/unit/test_jira_plugin.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import json
2+
import logging
3+
4+
import pytest
5+
import requests
6+
7+
from socketsecurity.config import CliConfig, PluginConfig
8+
from socketsecurity.core.classes import Diff
9+
from socketsecurity.output import OutputHandler
10+
from socketsecurity.plugins.base import REQUEST_TIMEOUT_SECONDS
11+
12+
13+
@pytest.mark.parametrize("error_type", [requests.Timeout, requests.ConnectionError])
14+
def test_jira_request_failure_preserves_scan_artifacts(tmp_path, mocker, caplog, error_type):
15+
config = CliConfig(api_token="test-token", repo="widgets")
16+
config.jira_plugin = PluginConfig(
17+
enabled=True,
18+
config={
19+
"url": "https://jira.example.com/private-endpoint",
20+
"project": "SEC",
21+
"email": "test@example.com",
22+
"api_token": "test-token",
23+
},
24+
)
25+
config.json_file = str(tmp_path / "report.json")
26+
config.summary_file = str(tmp_path / "summary.txt")
27+
config.report_link_file = str(tmp_path / "report-link.txt")
28+
config.sbom_file = str(tmp_path / "sbom.json")
29+
post = mocker.patch(
30+
"socketsecurity.plugins.jira.requests.post",
31+
side_effect=error_type("Request failed at https://jira.example.com/private-endpoint?token=secret"),
32+
)
33+
diff = Diff(id="scan", diff_url="https://socket.dev/report", sbom={"packages": []})
34+
handler = OutputHandler(config, sdk=mocker.Mock())
35+
36+
with caplog.at_level(logging.ERROR, logger="socketcli"):
37+
handler.handle_output(diff)
38+
39+
assert json.loads((tmp_path / "report.json").read_text())["full_scan_id"] == "scan"
40+
assert (tmp_path / "summary.txt").read_text().strip() == "No issues found"
41+
assert (tmp_path / "report-link.txt").read_text().strip() == diff.diff_url
42+
assert json.loads((tmp_path / "sbom.json").read_text()) == diff.sbom
43+
assert handler.return_exit_code(diff) == 0
44+
assert post.call_args.kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS
45+
assert f"Jira request failed ({error_type.__name__})" in caplog.text
46+
assert "https://jira.example.com/***redacted***" in caplog.text
47+
assert "private-endpoint" not in caplog.text
48+
assert "token=secret" not in caplog.text

‎tests/unit/test_redaction.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import dataclasses
8+
import json
89
import logging
910

1011
import pytest
@@ -165,14 +166,27 @@ def test_the_config_debug_line_does_not_emit_the_token(caplog):
165166
assert REDACTED in caplog.text
166167

167168

168-
def test_slack_debug_output_masks_environment_config_and_webhook(monkeypatch, mocker, caplog):
169-
monkeypatch.setenv("SOCKET_SLACK_CONFIG_JSON", f'{{"url": "{WEBHOOK}", "token": "{TOKEN}"}}')
169+
@pytest.mark.parametrize(
170+
("urls", "expected_url"),
171+
[
172+
(WEBHOOK, f"https://hooks.slack.com/{REDACTED}"),
173+
([WEBHOOK, "https://hooks.slack.com/services/another-secret"], REDACTED),
174+
([{"url": WEBHOOK, "name": "alerts"}], REDACTED),
175+
([], "Not configured"),
176+
(None, "Not configured"),
177+
("", "Not configured"),
178+
],
179+
)
180+
def test_slack_debug_output_masks_environment_config_and_webhook(monkeypatch, mocker, caplog, urls, expected_url):
181+
monkeypatch.setenv("SOCKET_SLACK_CONFIG_JSON", json.dumps({"url": urls, "token": TOKEN}))
170182
config = CliConfig.from_args(["--api-token", TOKEN, "--repo", "widgets", "--enable-debug"])
171183
handler = OutputHandler(config, sdk=mocker.Mock())
172184
mocker.patch.object(handler, "output_console_comments")
173185
with caplog.at_level(logging.DEBUG, logger="socketcli"):
174186
handler.handle_output(Diff(id="scan", diff_url="https://socket.dev/report"))
175187
assert TOKEN not in caplog.text
176188
assert "XXXXXXXXsecretXXXXXXXX" not in caplog.text
189+
assert "another-secret" not in caplog.text
177190
assert "SOCKET_SLACK_CONFIG_JSON environment variable: Set" in caplog.text
178-
assert "Slack Webhook URL: https://hooks.slack.com/***redacted***" in caplog.text
191+
assert f"Slack Webhook URL: {expected_url}" in caplog.text
192+
assert config.slack_plugin.config["url"] == urls

0 commit comments

Comments
 (0)