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

## 2.4.9

### Added: opt-in streaming log channel via `--upload-logs`

- New `--upload-logs` flag (default off). When set, each CLI invocation registers a run, reports a per-run status (`in_progress` / `success` / `failure` / `cancelled`), and uploads a transcript of its own log output to the Socket backend for that run, visible in the Socket admin views. The transcript is captured regardless of the local `--enable-debug` state; the existing terminal verbosity is unchanged.
- New `--no-upload-logs` flag (mutually exclusive with `--upload-logs`) explicitly opts the run out of uploading logs, even when an org-level override would otherwise enable it. Use this when you need a guaranteed no-upload guarantee (e.g. legal/consent reasons).
- The Socket backend can also force-enable streaming for specific orgs in the absence of an explicit opt-out. The feature is best-effort — registration or upload failures silently degrade and never block the scan.

## 2.4.8

### Fixed: retry transient full-scan upload failures
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.4.8"
version = "2.4.9"
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.4.8'
__version__ = '2.4.9'
USER_AGENT = f'SocketPythonCLI/{__version__}'
39 changes: 39 additions & 0 deletions socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ class CliConfig:
ignore_commit_files: bool = False
disable_blocking: bool = False
disable_ignore: bool = False
# Tri-state log-upload preference: True = --upload-logs, False = --no-upload-logs,
# None = neither (server-side override decides).
upload_logs: Optional[bool] = None
strict_blocking: bool = False
integration_type: IntegrationType = "api"
integration_org_slug: Optional[str] = None
Expand Down Expand Up @@ -212,6 +215,12 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':

args = parser.parse_args(args_list)

if args.upload_logs and args.decline_logs:
parser.error("--upload-logs and --no-upload-logs are mutually exclusive")
upload_logs: Optional[bool] = (
True if args.upload_logs else False if args.decline_logs else None
)

if args.reach_exclude_paths:
logging.warning(
"--reach-exclude-paths is deprecated; use --exclude-paths instead. "
Expand Down Expand Up @@ -282,6 +291,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
'ignore_commit_files': args.ignore_commit_files,
'disable_blocking': args.disable_blocking,
'disable_ignore': args.disable_ignore,
'upload_logs': upload_logs,
'strict_blocking': args.strict_blocking,
'integration_type': args.integration,
'pending_head': args.pending_head,
Expand Down Expand Up @@ -866,6 +876,35 @@ def create_argument_parser() -> argparse.ArgumentParser:
action="store_true",
help=argparse.SUPPRESS
)
advanced_group.add_argument(
"--upload-logs",
dest="upload_logs",
action="store_true",
help="Upload the CLI's log output to the Socket backend for this run. "
"When set, the CLI registers the run with share_logs=true and streams "
"its log records in 5s batches. Default off. Mutually exclusive with "
"--no-upload-logs."
)
advanced_group.add_argument(
"--upload_logs",
dest="upload_logs",
action="store_true",
help=argparse.SUPPRESS
)
advanced_group.add_argument(
"--no-upload-logs",
dest="decline_logs",
action="store_true",
help="Explicitly opt out of uploading CLI logs to the Socket backend, even "
"when an org-level override would otherwise enable it. Mutually "
"exclusive with --upload-logs."
)
advanced_group.add_argument(
"--no_upload_logs",
dest="decline_logs",
action="store_true",
help=argparse.SUPPRESS
)
Comment on lines +879 to +907

Choose a reason for hiding this comment

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

These can use the same dest (upload_logs) with the store_const action and an appropriate const. They can also be made mutually exclusive at the argparse level with a mutually exclusive group.

advanced_group.add_argument(
"--strict-blocking",
dest="strict_blocking",
Expand Down
78 changes: 78 additions & 0 deletions socketsecurity/core/cli_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Lifecycle helpers for a CLI run on the Socket backend.
A "run" represents a single CLI invocation. `register_cli_run` opens it and
returns a server-issued `run_id` when streaming is enabled; `finalize_cli_run`
closes it on exit. The run_id keys the rows that `BatchedLogUploader` POSTs to
`/python-cli-runs/<run_id>/logs` during the run so the dashboard can show
what the user saw in their terminal.
Streaming is opt-in via the `share_logs` field on register. The server may
also force-enable streaming for an org regardless of the client's request,
so the CLI always calls register and gates on the response's
`log_streaming_enabled` flag rather than the client's intent.
Both calls are best-effort: failures fall back to no-streaming and never
prevent the scan from running.
"""

import json
import logging
from typing import Optional

from .cli_client import CliClient
from .exceptions import APIFailure

log = logging.getLogger("socketcli")


def register_cli_run(
client: CliClient,
client_version: str,
share_logs: bool,
decline_logs: bool,
) -> Optional[str]:
try:
resp = client.request(
path="python-cli-runs",
method="POST",
payload=json.dumps({
"client_version": client_version,
"share_logs": share_logs,
"decline_logs": decline_logs,
}),
)
except APIFailure as e:
log.debug(f"cli-run register failed (streaming disabled): {e}")
return None

try:
body = resp.json()
except (ValueError, json.JSONDecodeError) as e:
log.debug(f"cli-run register: bad JSON body: {e}")
return None

if not body.get("log_streaming_enabled"):
log.debug("cli-run register: log streaming not enabled by server")
return None

run_id = body.get("run_id")
if not isinstance(run_id, str) or not run_id:
log.debug(f"cli-run register: enabled but missing run_id in response: {body!r}")
return None
return run_id


def finalize_cli_run(
client: CliClient,
run_id: str,
status: str = "success",
report_run_id: Optional[str] = None,
) -> None:
try:
client.request(
path=f"python-cli-runs/{run_id}/finalize",
method="POST",
payload=json.dumps({"status": status, "report_run_id": report_run_id}),
)
except Exception as e:
log.debug(f"cli-run finalize failed (swallowed): {e}")
112 changes: 112 additions & 0 deletions socketsecurity/core/log_uploader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Buffer the CLI's local log records and POST them in batches to
/python-cli-runs/<run_id>/logs so the dashboard's view of a CLI run
mirrors what the user sees in their terminal.

Behavior:
- daemon thread, 5s flush
- swallow all network errors (debug log only)
- skip empty buffers
- drain on shutdown
- at-most-once semantics (failed batches dropped, not retried)

A thread-local recursion guard prevents the uploader's own request-error
log lines (emitted by `cli_client.py`'s `socketdev` logger) from being
re-enqueued during a flush.
"""

import json
import logging
import threading
from datetime import datetime, timezone
from typing import Optional

from .cli_client import CliClient

log = logging.getLogger(__name__)

_FLUSH_GUARD = threading.local()


def _now_str() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]


class BatchedLogUploader:
def __init__(
self,
client: CliClient,
run_id: str,
flush_interval: float = 5.0,
):
self._client = client
self._run_id = run_id
self._flush_interval = flush_interval
self._buf: list = []
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None

def add(self, entry: dict) -> None:
with self._lock:
self._buf.append(entry)

def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(
target=self._run,
name=f"socket-log-uploader-{self._run_id[:8]}",
daemon=True,
)
self._thread.start()

def stop(self, timeout: float = 2.0) -> None:
if self._thread is not None:
self._stop.set()
self._thread.join(timeout=timeout)
self._thread = None
self._flush()

def _run(self) -> None:
while not self._stop.is_set():
self._flush()
self._stop.wait(self._flush_interval)

def _flush(self) -> None:
with self._lock:
if not self._buf:
return
batch = self._buf
self._buf = []

_FLUSH_GUARD.active = True
try:
self._client.request(
path=f"python-cli-runs/{self._run_id}/logs",
method="POST",
payload=json.dumps({"logs": batch}),
)
except Exception as e:
log.debug(f"log upload failed (swallowed, {len(batch)} entries dropped): {e}")
finally:
_FLUSH_GUARD.active = False


class UploadingLogHandler(logging.Handler):
def __init__(self, uploader: BatchedLogUploader, context: str = "socket-python-cli"):
super().__init__()
self._uploader = uploader
self._context = context

def emit(self, record: logging.LogRecord) -> None:
if getattr(_FLUSH_GUARD, "active", False):
return
Comment thread
BarrensZeppelin marked this conversation as resolved.
try:
self._uploader.add({
"timestamp": _now_str(),
"level": logging.getLevelName(record.levelno),
"message": self.format(record),
"context": self._context,
})
except Exception:
self.handleError(record)
Loading
Loading