Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 2.10.12

### Fixed: reject retired API tokens before use

- The CLI rejects the retired API credential before HTTP or reachability
requests are created.

## 2.10.11

### Changed: bump pinned @coana-tech/cli to 15.12.7
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.10.11"
version = "2.10.12"
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.10.11'
__version__ = '2.10.12'
USER_AGENT = f'SocketPythonCLI/{__version__}'
30 changes: 23 additions & 7 deletions socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@
from socketdev import INTEGRATION_TYPES, IntegrationType

from socketsecurity import __version__
from socketsecurity.retired_api_token import reject_retired_api_token

API_TOKEN_ENVIRONMENT_VARIABLES = (
"SOCKET_SECURITY_API_KEY",
"SOCKET_SECURITY_API_TOKEN",
"SOCKET_API_KEY",
"SOCKET_API_TOKEN",
)


def select_api_token(
environment: dict[str, str], argument_token: Optional[str]
) -> Optional[str]:
return next(
(
environment[name]
for name in API_TOKEN_ENVIRONMENT_VARIABLES
if environment.get(name)
),
argument_token,
)


def get_plugin_config_from_env(prefix: str) -> dict:
Expand Down Expand Up @@ -272,13 +293,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
)

# 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
)
api_token = select_api_token(os.environ, args.api_token)
reject_retired_api_token(api_token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retired-token error escapes CLI handler

Medium Severity

reject_retired_api_token raises ValueError from CliConfig.from_args. cli catches that and calls from_args again to read --disable-blocking and --exit-code-on-api-error. The second call raises the same error, so _emit_infrastructure_error never runs and those flags are ignored. Operators get an uncaught traceback instead of the CLI's formatted infrastructure error path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cfada31. Configure here.


# --sarif-file implies --enable-sarif
if args.sarif_file:
Expand Down
2 changes: 2 additions & 0 deletions socketsecurity/core/cli_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import requests

from socketsecurity import USER_AGENT
from socketsecurity.retired_api_token import reject_retired_api_token

from .exceptions import APIFailure
from .socket_config import SocketConfig
Expand All @@ -14,6 +15,7 @@

class CliClient:
def __init__(self, config: SocketConfig):
reject_retired_api_token(config.api_key)
self.config = config
self._encoded_key = self._encode_key(config.api_key)

Expand Down
3 changes: 3 additions & 0 deletions socketsecurity/core/socket_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from socketdev.core.issues import AllIssues

from socketsecurity import __version__
from socketsecurity.retired_api_token import reject_retired_api_token

default_exclude_dirs = {
"node_modules", "bower_components", "jspm_packages", # JS/TS
Expand Down Expand Up @@ -39,6 +40,8 @@ def __post_init__(self):
if not self.api_key:
raise ValueError("API key is required")

reject_retired_api_token(self.api_key)

if self.timeout <= 0:
raise ValueError("Timeout must be a positive integer")

Expand Down
3 changes: 3 additions & 0 deletions socketsecurity/core/tools/reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from socketdev import socketdev

from socketsecurity import __version__
from socketsecurity.retired_api_token import reject_retired_api_token

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -51,6 +52,7 @@ def _build_caller_user_agent() -> str:

class ReachabilityAnalyzer:
def __init__(self, sdk: socketdev, api_token: str):
reject_retired_api_token(api_token)
self.sdk = sdk
self.api_token = api_token

Expand Down Expand Up @@ -137,6 +139,7 @@ def run_reachability_analysis(
Returns:
Dict containing scan_id and report_path
"""
reject_retired_api_token(self.api_token)
# Build the coana CLI arguments (everything after the package spec). The launcher
# (npx, or the npm-install + node fallback) is chosen in _spawn_coana() below.
coana_args = ["run", "."]
Expand Down
28 changes: 28 additions & 0 deletions socketsecurity/retired_api_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import hashlib
import hmac
from typing import Optional

RETIRED_API_TOKEN_SHA256 = (
"9cab58b24140ee12c08e265729d246f9200f8ef9b1d884f90c06c4e6b69adf18"
)
RETIRED_API_TOKEN_ERROR = (
"This API token has been retired. Create a new API token in Socket "
"and update your configuration."
)


# Fingerprint identifies a retired credential; it is not a password verifier.
def _matches_retired_digest(token: Optional[str], digest: str) -> bool:
if token is None:
return False
token_digest = hashlib.sha256(token.strip().encode("utf-8")).hexdigest()
Comment thread
jdalton marked this conversation as resolved.
Dismissed
return hmac.compare_digest(token_digest, digest)


def _reject_retired_api_token(token: Optional[str], digest: str) -> None:
if _matches_retired_digest(token, digest):
raise ValueError(RETIRED_API_TOKEN_ERROR)


def reject_retired_api_token(token: Optional[str]) -> None:
_reject_retired_api_token(token, RETIRED_API_TOKEN_SHA256)
73 changes: 73 additions & 0 deletions tests/unit/test_retired_api_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import hashlib

import pytest
from socketdev import socketdev

from socketsecurity.config import (
API_TOKEN_ENVIRONMENT_VARIABLES,
CliConfig,
select_api_token,
)
from socketsecurity.core.cli_client import CliClient
from socketsecurity.core.socket_config import SocketConfig
from socketsecurity.core.tools.reachability import ReachabilityAnalyzer
from socketsecurity.retired_api_token import (
RETIRED_API_TOKEN_ERROR,
_matches_retired_digest,
_reject_retired_api_token,
)


def test_retired_api_token_rejection_redacts_token_and_digest():
token = "synthetic-retired-token"
digest = hashlib.sha256(token.encode()).hexdigest()

with pytest.raises(ValueError) as exc_info:
_reject_retired_api_token(token, digest)

assert str(exc_info.value) == RETIRED_API_TOKEN_ERROR
assert token not in str(exc_info.value)
assert digest not in str(exc_info.value)
assert _matches_retired_digest(f" {token} ", digest)


def test_cli_config_preserves_token_environment_precedence(monkeypatch):
for name in API_TOKEN_ENVIRONMENT_VARIABLES:
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv(API_TOKEN_ENVIRONMENT_VARIABLES[0], "first-env-token")
monkeypatch.setenv(API_TOKEN_ENVIRONMENT_VARIABLES[1], "second-env-token")

config = CliConfig.from_args(["--api-token", "argument-token"])

assert config.api_token == "first-env-token"


def test_cli_config_uses_argument_when_token_environment_is_empty(monkeypatch):
for name in API_TOKEN_ENVIRONMENT_VARIABLES:
monkeypatch.delenv(name, raising=False)

config = CliConfig.from_args(["--api-token", "argument-token"])

assert config.api_token == "argument-token"


def test_cli_http_and_reachability_construction_accept_safe_token_without_network():
token = "synthetic-safe-token"
config = SocketConfig(api_key=token)
client = CliClient(config)
sdk = socketdev(token=token)
analyzer = ReachabilityAnalyzer(sdk, token)

assert client.config.api_key == token
assert analyzer.api_token == token


@pytest.mark.parametrize("token", [None, "different-token"])
def test_nonretired_api_token_is_accepted(token):
digest = hashlib.sha256(b"synthetic-retired-token").hexdigest()
_reject_retired_api_token(token, digest)


@pytest.mark.parametrize("name", API_TOKEN_ENVIRONMENT_VARIABLES)
def test_each_cli_environment_source_is_supported(name):
assert select_api_token({name: "selected-token"}, "argument-token") == "selected-token"
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