diff --git a/CHANGELOG.md b/CHANGELOG.md index 3041973..fab4046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 36d7e47..0b5c1ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index a057239..025b61a 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.10.11' +__version__ = '2.10.12' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 1f107cf..cb87432 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -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: @@ -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) # --sarif-file implies --enable-sarif if args.sarif_file: diff --git a/socketsecurity/core/cli_client.py b/socketsecurity/core/cli_client.py index 405a744..6af19d0 100644 --- a/socketsecurity/core/cli_client.py +++ b/socketsecurity/core/cli_client.py @@ -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 @@ -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) diff --git a/socketsecurity/core/socket_config.py b/socketsecurity/core/socket_config.py index 1089a53..57340a3 100644 --- a/socketsecurity/core/socket_config.py +++ b/socketsecurity/core/socket_config.py @@ -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 @@ -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") diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 1dc90b6..9241210 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -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__) @@ -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 @@ -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", "."] diff --git a/socketsecurity/retired_api_token.py b/socketsecurity/retired_api_token.py new file mode 100644 index 0000000..2e2f232 --- /dev/null +++ b/socketsecurity/retired_api_token.py @@ -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() + 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) diff --git a/tests/unit/test_retired_api_token.py b/tests/unit/test_retired_api_token.py new file mode 100644 index 0000000..7c14ac8 --- /dev/null +++ b/tests/unit/test_retired_api_token.py @@ -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" diff --git a/uv.lock b/uv.lock index 6df942c..7aeecc9 100644 --- a/uv.lock +++ b/uv.lock @@ -1292,7 +1292,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.10.11" +version = "2.10.12" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },