Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ jobs:
echo "sha=${{ github.event.pull_request.head.sha }}"
} > sfw-artifacts/context.txt

- uses: ./.github/actions/setup-sfw
- uses: $/.github/actions/setup-sfw
with:
uv: "true"
mode: firewall-free
Expand Down Expand Up @@ -241,7 +241,7 @@ jobs:
echo "sha=${{ github.event.pull_request.head.sha }}"
} > sfw-artifacts/context.txt

- uses: ./.github/actions/setup-sfw
- uses: $/.github/actions/setup-sfw
with:
uv: "true"
mode: firewall-enterprise
Expand Down Expand Up @@ -332,7 +332,7 @@ jobs:
echo "sha=${{ github.event.pull_request.head.sha }}"
} > sfw-artifacts/context.txt

- uses: ./.github/actions/setup-sfw
- uses: $/.github/actions/setup-sfw
with:
node: "true"
mode: firewall-free
Expand Down Expand Up @@ -393,7 +393,7 @@ jobs:
echo "sha=${{ github.event.pull_request.head.sha }}"
} > sfw-artifacts/context.txt

- uses: ./.github/actions/setup-sfw
- uses: $/.github/actions/setup-sfw
with:
node: "true"
mode: firewall-enterprise
Expand Down Expand Up @@ -454,7 +454,7 @@ jobs:
echo "sha=${{ github.event.pull_request.head.sha }}"
} > sfw-artifacts/context.txt

- uses: ./.github/actions/setup-sfw
- uses: $/.github/actions/setup-sfw
with:
python: "true"
mode: firewall-free
Expand Down Expand Up @@ -518,7 +518,7 @@ jobs:
echo "sha=${{ github.event.pull_request.head.sha }}"
} > sfw-artifacts/context.txt

- uses: ./.github/actions/setup-sfw
- uses: $/.github/actions/setup-sfw
with:
python: "true"
mode: firewall-enterprise
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker-stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
echo "Version ${INPUT_VERSION} found on PyPI - proceeding with release"

- name: Set up Docker publishing
uses: ./.github/actions/setup-docker
uses: $/.github/actions/setup-docker
with:
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/package-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
python-version: "3.12"

- name: Install build tooling
uses: ./.github/actions/setup-hatch
uses: $/.github/actions/setup-hatch

- name: Install distribution validator
run: python -m pip install "twine>=4.0.0"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ jobs:
python-version: "3.12"

- name: Install build tooling
uses: ./.github/actions/setup-hatch
uses: $/.github/actions/setup-hatch

- name: Install distribution validator
run: python -m pip install "twine>=4.0.0"
Expand Down Expand Up @@ -252,7 +252,7 @@ jobs:
path: dist

- name: Set up Docker publishing
uses: ./.github/actions/setup-docker
uses: $/.github/actions/setup-docker
with:
# QEMU is required to build the arm64 layer on an amd64 runner.
enable-qemu: "true"
Expand Down
46 changes: 44 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ jobs:
permissions:
id-token: write
contents: read
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -19,7 +20,7 @@ jobs:
python-version: '3.13'

- name: Install build tooling
uses: ./.github/actions/setup-hatch
uses: $/.github/actions/setup-hatch

- name: Get Version
id: version
Expand Down Expand Up @@ -69,7 +70,7 @@ jobs:
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3

- name: Set up Docker publishing
uses: ./.github/actions/setup-docker
uses: $/.github/actions/setup-docker
with:
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
Expand Down Expand Up @@ -123,3 +124,44 @@ jobs:
socketdev/cli:${{ env.VERSION }}
build-args: |
CLI_VERSION=${{ env.VERSION }}

# Releases are immutable, so a failed run leaves a published GitHub release whose
# tag can never be reused, with nothing on PyPI or Docker Hub behind it. A failed
# run only notifies whoever published the release, so raise it where the team
# will see it.
- name: Open an issue for the failed release
if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
VERSION: ${{ env.VERSION }}
with:
script: |
const release = context.payload.release;
const runUrl =
`${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` +
`/actions/runs/${context.runId}`;
const body = [
`@SocketDev/customer-engineering the release workflow for [${release.tag_name}](${release.html_url}) ` +
`(published by @${release.author.login}) failed: ${runUrl}`,
'',
`- Tag: \`${release.tag_name}\``,
`- Package version at that commit: \`${process.env.VERSION || 'unknown'}\``,
'',
'Check the run for which step failed. If it failed before "Publish to PyPI", nothing reached PyPI or ' +
'Docker Hub, but the GitHub release is immutable and its tag can never be reused.',
'',
'Before publishing another release:',
'',
'1. Edit this release\'s notes to say it was not published and name the release that replaces it.',
'2. If the tag and the package version disagree, merge a PR that bumps the version to one with no ' +
'existing tag (the Version Check workflow enforces this), then publish a release whose tag matches ' +
'that version exactly. Do not pick the next tag number from the releases list.',
].join('\n');

const created = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Release ${release.tag_name} failed`,
body,
});
core.error(`Release failed; opened #${created.data.number}`);
16 changes: 16 additions & 0 deletions .github/workflows/version-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
python3 <<'PY'
import json
import os
import subprocess
import tomllib
import urllib.request
from packaging import version
Expand Down Expand Up @@ -96,6 +97,21 @@ jobs:
)
raise SystemExit(1)

# Releases are immutable: once a v<version> release is published, its tag can't
# be moved or reused, even when that release failed and nothing reached PyPI.
# PyPI alone misses that, so a burned tag would only surface when the release
# run fails after the merge.
tag = f"v{os.environ['PR_VERSION']}"
if subprocess.run(
["git", "rev-parse", "--quiet", "--verify", f"refs/tags/{tag}"],
capture_output=True,
).returncode == 0:
print(
f"❌ Tag {tag} already exists, so {pr_ver} can never be released "
f"(latest PyPI release: {pypi_ver}). Bump to a version with no tag."
)
raise SystemExit(1)

if pr_ver < main_ver:
print(
f"❌ Version moves backwards: main is {main_ver}, PR is {pr_ver}."
Expand Down
22 changes: 13 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
# Changelog

## 2.11.8
## 2.11.10

### Changed: bump pinned Coana CLI to 15.14.0
Versions 2.11.7 through 2.11.9 were never published to PyPI or Docker Hub. The
GitHub releases tagged `v2.11.8` and `v2.11.9` have no packages behind them;
their changes ship here.

- Bumped the pinned reachability engine (the Coana CLI) from `15.13.3` to
`15.14.0`. See the
[reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog)
for engine changes.
### Fixed: report why a new repository couldn't be created

## 2.11.7
- When a repository isn't in Socket yet and the API token can't create it, the
CLI now logs the failure, the HTTP status, and on a 403 a hint about the
`repo:create` scope before exiting `2`. Previously it exited `2` right after
file discovery with nothing logged.
- After the CLI creates a missing repository, its logging is no longer silenced
for the rest of the run.

### Changed: bump pinned Coana CLI to 15.13.3
### Changed: bump pinned Coana CLI to 15.14.0

- Bumped the pinned reachability engine (the Coana CLI) from `15.13.2` to
`15.13.3`. See the
`15.14.0`. See the
[reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog)
for engine changes.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"

[project]
name = "socketsecurity"
version = "2.11.8"
version = "2.11.10"
requires-python = ">= 3.11"
license = {"file" = "LICENSE"}
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion socketsecurity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__author__ = 'socket.dev'
__version__ = '2.11.8'
__version__ = '2.11.10'
USER_AGENT = f'SocketPythonCLI/{__version__}'
21 changes: 17 additions & 4 deletions socketsecurity/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
from socketsecurity.config import CliConfig
from git import Repo
from socketdev import socketdev
from socketdev.exceptions import APIFailure
from socketdev.exceptions import (
APIFailure,
APIInsufficientQuota,
APIOrganizationNotAllowed,
)
from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact
from socketdev.org import Organization
from socketdev.repos import RepositoryInfo
Expand Down Expand Up @@ -1505,8 +1509,11 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br
sdk_logger = logging_std.getLogger("socketdev")
original_level = sdk_logger.level
sdk_logger.setLevel(logging_std.CRITICAL)
response = self.sdk.repos.repo(self.config.org_slug, repo_slug, use_types=True)
sdk_logger.setLevel(original_level)
try:
response = self.sdk.repos.repo(self.config.org_slug, repo_slug, use_types=True)
finally:
# This module logs through "socketdev" too, so a failed lookup must not leave it muted.
sdk_logger.setLevel(original_level)
if not response.success:
log.error(f"Failed to get repository: {response.status}")
# log.error(response.message)
Expand All @@ -1530,7 +1537,13 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br
return response.data

except APIFailure as e:
log.error(f"API failure while creating repository: {e}")
status = f" (HTTP {e.status_code})" if e.status_code else ""
# The SDK appends the request path and response headers after the first line,
# and some subclasses carry no message at all.
reason = str(e).split("\n", 1)[0] or type(e).__name__
log.error(f"API failure while creating repository {repo_slug}{status}: {reason}")
if e.status_code == 403 and not isinstance(e, (APIInsufficientQuota, APIOrganizationNotAllowed)):
log.error("A 403 here usually means the API token is missing the repo:create scope.")
sys.exit(2) # Exit here with code 2. Code 1 indicates a successfully-detected security issue.

return response.data
Expand Down
69 changes: 68 additions & 1 deletion tests/core/test_sdk_methods.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import logging

import pytest
from socketdev.exceptions import APIFailure
from socketdev.exceptions import (
APIAccessDenied,
APIFailure,
APIInsufficientPermissions,
APIInsufficientQuota,
APIResourceNotFound,
)
from socketdev.fullscans import FullScanParams, FullScanStreamResponse, ScanType

from socketsecurity.config import CliConfig
Expand Down Expand Up @@ -44,6 +52,65 @@ def test_get_repo_info(core, mock_sdk_with_responses):
assert repo_info.id == "f639d6c9-acc3-4d8a-9fb5-2090ad651c7e"
assert repo_info.head_full_scan_id == "head"


def test_get_repo_info_logs_why_repo_creation_failed(core, mock_sdk_with_responses, caplog):
"""A repo that isn't in Socket yet and can't be created exits 2 with the reason logged"""
mock_sdk_with_responses.repos.repo.side_effect = APIResourceNotFound(status_code=404)
# Mirrors the SDK, which appends the request path and response headers to the message
mock_sdk_with_responses.repos.post.side_effect = APIAccessDenied(
"Insufficient permissions\nPath: https://api.socket.dev/v0/orgs/test/repos\n\nHeaders:\nx-req: 1",
status_code=403,
)

with caplog.at_level(logging.INFO, logger="socketdev"), pytest.raises(SystemExit) as exit_info:
core.get_repo_info("new-repo")

assert exit_info.value.code == 2
messages = [record.getMessage() for record in caplog.records]
assert "Failed to get repository new-repo, attempting to create it" in messages
assert "API failure while creating repository new-repo (HTTP 403): Insufficient permissions" in messages
assert any("repo:create" in message for message in messages)


@pytest.mark.parametrize(
("error", "expected_reason", "expects_scope_hint"),
[
(APIInsufficientPermissions(status_code=403), "APIInsufficientPermissions", True),
(APIInsufficientQuota(status_code=403), "APIInsufficientQuota", False),
],
)
def test_get_repo_info_repo_creation_failure_reason(
core, mock_sdk_with_responses, caplog, error, expected_reason, expects_scope_hint
):
"""Message-less SDK errors still name a reason, and only permission 403s get the scope hint"""
mock_sdk_with_responses.repos.repo.side_effect = APIResourceNotFound(status_code=404)
mock_sdk_with_responses.repos.post.side_effect = error

with caplog.at_level(logging.INFO, logger="socketdev"), pytest.raises(SystemExit):
core.get_repo_info("new-repo")

messages = [record.getMessage() for record in caplog.records]
assert f"API failure while creating repository new-repo (HTTP 403): {expected_reason}" in messages
assert any("repo:create" in message for message in messages) is expects_scope_hint


def test_get_repo_info_restores_log_level_after_creating_repo(
core, mock_sdk_with_responses, repo_info_response, caplog
):
"""Creating a missing repo must not leave the socketdev logger muted for the rest of the run"""
mock_sdk_with_responses.repos.repo.side_effect = [
APIResourceNotFound(status_code=404),
repo_info_response,
]
mock_sdk_with_responses.repos.post.return_value = {"id": "created"}

with caplog.at_level(logging.INFO, logger="socketdev"):
repo_info = core.get_repo_info("new-repo")
assert logging.getLogger("socketdev").level == logging.INFO

assert repo_info.id == "f639d6c9-acc3-4d8a-9fb5-2090ad651c7e"


def test_get_head_scan_for_repo(core, mock_sdk_with_responses):
"""Test getting head scan ID for a repository"""
head_scan_id = core.get_head_scan_for_repo("test")
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading