From 0d6d99d46687374f4a16d85a151a2acae9a230f9 Mon Sep 17 00:00:00 2001 From: karthickmk2001 Date: Thu, 12 Nov 2026 03:49:04 +0000 Subject: [PATCH] feat(sensor): Python runtime probe and secfoo instrument (#55) In-process probe that records which watched vulnerable functions run, as #53 events: sys.monitoring PY_START on watched code objects (3.12+) or a setprofile fallback (3.10/3.11), import-time symbol resolution, app-code coverage, load inventory, per-process sessions for forked and spawned workers, and file/unix/tcp/npipe/https transports. Inert unless SECFOO_SENSOR=1, never reads locals or arguments, and fails open. secfoo instrument wraps any command via a temporary sitecustomize. Includes an overhead benchmark. Closes #55 Co-Authored-By: Claude Opus 5.5 --- benchmarks/sensor_overhead.py | 87 +++++ docs/REFERENCE.md | 67 ++++ src/secfoo/cli.py | 62 ++++ src/secfoo/sensor/__init__.py | 49 +++ src/secfoo/sensor/__main__.py | 29 ++ src/secfoo/sensor/probe.py | 436 +++++++++++++++++++++++++ src/secfoo/sensor/transport.py | 214 ++++++++++++ tests/test_sensor/test_python_probe.py | 276 ++++++++++++++++ 8 files changed, 1220 insertions(+) create mode 100644 benchmarks/sensor_overhead.py create mode 100644 src/secfoo/sensor/__init__.py create mode 100644 src/secfoo/sensor/__main__.py create mode 100644 src/secfoo/sensor/probe.py create mode 100644 src/secfoo/sensor/transport.py create mode 100644 tests/test_sensor/test_python_probe.py diff --git a/benchmarks/sensor_overhead.py b/benchmarks/sensor_overhead.py new file mode 100644 index 0000000..36b381b --- /dev/null +++ b/benchmarks/sensor_overhead.py @@ -0,0 +1,87 @@ +"""Overhead of the Python runtime probe (#55): requests/sec with and without it. + + python benchmarks/sensor_overhead.py # all modes, ~10 s + python benchmarks/sensor_overhead.py --requests 200000 + +Each mode runs in a fresh interpreter. A "request" is a small handler that +parses a query string, builds a response dict through a few app functions, +and serializes it -- the shape of a typical web handler, without network +noise. "watched" calls the watched vulnerable function on every request +(the worst case); "unwatched" never calls it. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +APP = textwrap.dedent("""\ + import json, sys, time, urllib.parse + import vulnlib + + def parse(qs): + return dict(urllib.parse.parse_qsl(qs)) + + def build(params, call_vuln): + body = {"user": params.get("user"), "items": [i * 2 for i in range(10)]} + if call_vuln: + body["parsed"] = vulnlib.load(params.get("doc", "")) + return body + + def handle(qs, call_vuln): + return json.dumps(build(parse(qs), call_vuln)) + + n, call_vuln = int(sys.argv[1]), sys.argv[2] == "watched" + for _ in range(2000): + handle("user=a&doc=x", call_vuln) + start = time.perf_counter() + for _ in range(n): + handle("user=alice&doc=hello", call_vuln) + print(n / (time.perf_counter() - start)) + """) +VULNLIB = "def load(text):\n return text[::-1]\n" + + +def run(workdir: Path, requests: int, traffic: str, env_extra: dict[str, str] | None) -> float: + env = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} + for key in ("SECFOO_SENSOR", "SECFOO_SENSOR_ADDR", "SECFOO_EVENTS_OUT"): + env.pop(key, None) + env.update(env_extra or {}) + cmd = [sys.executable, "-m", "secfoo.sensor", "app.py", str(requests), traffic] + out = subprocess.run(cmd, cwd=workdir, env=env, capture_output=True, text=True, check=True) # nosec B603 + return float(out.stdout.strip().splitlines()[-1]) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--requests", type=int, default=100_000) + args = parser.parse_args() + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + (workdir / "app.py").write_text(APP, encoding="utf-8") + (workdir / "vulnlib.py").write_text(VULNLIB, encoding="utf-8") + (workdir / "watchlist.json").write_text(json.dumps({"version": 1, "generated_at": "x", "entries": [{ + "finding_fingerprint": "fp", "advisory": None, "ecosystem": "PyPI", "package": "vulnlib", + "version": "1", "granularity": "symbol", "symbols": ["vulnlib.load"], "source": "osv"}]}), encoding="utf-8") + probe_env = {"SECFOO_SENSOR": "1", "SECFOO_WATCHLIST": "watchlist.json", + "SECFOO_EVENTS_OUT": str(workdir / "events.ndjson"), "SECFOO_FLUSH_INTERVAL": "5"} + backends = ["profile"] + (["monitoring"] if hasattr(sys, "monitoring") else []) + print(f"Python {sys.version.split()[0]}, {args.requests:,} requests per mode\n") + print(f"{'mode':<34}{'req/s':>12}{'overhead':>10}") + for traffic in ("unwatched", "watched"): + base = run(workdir, args.requests, traffic, None) + print(f"{'no probe, ' + traffic:<34}{base:>12,.0f}{'':>10}") + for backend in backends: + rate = run(workdir, args.requests, traffic, {**probe_env, "SECFOO_SENSOR_BACKEND": backend}) + label = f"probe ({backend}), {traffic}" + print(f"{label:<34}{rate:>12,.0f}{(base / rate - 1) * 100:>9.1f}%") + + +if __name__ == "__main__": + main() diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index c4f68e4..c028715 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -473,6 +473,73 @@ runtime-confirmed findings, but only once there is runtime data. ![SCA vulnerabilities list with the Runtime column and a highlighted conflict](assets/sca-runtime.jpg) +### Python probe + +The probe runs inside a Python app and records which watched vulnerable +functions actually run. It writes the event format above. + +```bash +secfoo runtime watchlist --project payments -o watchlist.json +secfoo instrument --watchlist watchlist.json -o events.ndjson -- python app.py # or gunicorn ..., uvicorn ... +secfoo runtime ingest events.ndjson --project payments +``` + +`secfoo instrument` starts the probe in every Python process the command +launches, workers included, without changing the app's start command. It +does this through a temporary `sitecustomize` on `PYTHONPATH`. When you can't +wrap the command, set the variables yourself: + +```bash +SECFOO_SENSOR=1 SECFOO_WATCHLIST=watchlist.json SECFOO_EVENTS_OUT=events-{pid}.ndjson python -m secfoo.sensor app.py +``` + +| Variable | Meaning | +|---|---| +| `SECFOO_SENSOR=1` | Required. Without it the probe installs nothing: no hooks, threads or connections. | +| `SECFOO_WATCHLIST` | The watchlist file. It is re-read when its mtime changes (checked at most every 30 s). | +| `SECFOO_EVENTS_OUT` | The events file. `{pid}` gives each process its own file; otherwise processes append to one file safely. | +| `SECFOO_SENSOR_ADDR` | Send to a sensor instead: `unix://…`, `tcp://127.0.0.1:port` (loopback only), `npipe://…`, `https://…` (direct mode, using the runtime ingest API with `SECFOO_API_KEY_FILE` / `SECFOO_PROJECT`), or `file://…`. | +| `SECFOO_FLUSH_INTERVAL` | Seconds between flushes (default 10). It also flushes at exit. | +| `SECFOO_APP_ROOT` | The app's own code, for coverage and stack frames (default: the working directory). | + +**How it works.** + +- **Backends.** On Python 3.12+ it uses `sys.monitoring` (PEP 669), with + `PY_START` events only on the watched functions' code objects, so unwatched + code costs nothing. On 3.10/3.11 it falls back to `sys.setprofile` and + `threading.setprofile`. +- **Symbols** are resolved as their modules are imported. +- **Coverage.** Each app function is counted the first time it runs, for + `app_functions_executed`. On 3.12+ that event is then disabled for the + function. +- **Inventory.** It emits `load` lines for installed distributions whose + top-level module was imported. +- **Workers.** Each process, including forked or spawned workers (gunicorn, + uvicorn, multiprocessing), is its own session. With gunicorn `--preload`, + the probe starts in the master and each forked worker starts a new session + automatically. + +**Guarantees.** + +- It never reads `frame.f_locals`, arguments or return values. It records + only function identity, counts, timestamps and `file:line` app frames. +- An internal error disables the probe, with one stderr line, and never + reaches the app. +- Transports run on a background thread and drop data rather than block + when the destination is gone. +- It opens no connection other than `SECFOO_SENSOR_ADDR`. + +**Overhead** (`python benchmarks/sensor_overhead.py`, CPython 3.14, a ~7 µs +request-like handler): + +| Mode | Unwatched code | Watched function called on every request | +|---|---|---| +| `sys.monitoring` (3.12+) | ≈ 0% | ~20% (≈ 1.4 µs per watched call) | +| `setprofile` fallback (3.10/3.11) | ~10× slower | ~10× slower | + +The fallback fires on every Python call, so prefer 3.12+ in production or +keep the fallback to staging. + ### Runtime ingest API Live sensors send evidence to the same server your CLI results go to. They diff --git a/src/secfoo/cli.py b/src/secfoo/cli.py index e3b39a9..0d2cb88 100644 --- a/src/secfoo/cli.py +++ b/src/secfoo/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import shutil import sys from datetime import date, datetime, timezone @@ -1815,6 +1816,67 @@ def skills_show( sys.stdout.write(render_prompt(skill, context, depth=depth.value) + "\n") +_SITECUSTOMIZE = """\ +# Written by `secfoo instrument` (#55): starts the secfoo runtime probe in +# every Python process of the wrapped command. Inert unless SECFOO_SENSOR=1. +try: + import secfoo.sensor as _secfoo_sensor + _secfoo_sensor.start_from_env() +except Exception: # never break the app's interpreter start-up + pass +""" + + +@app.command( + name="instrument", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def instrument( + ctx: typer.Context, + watchlist: Annotated[Path | None, typer.Option( + "--watchlist", exists=True, dir_okay=False, help="Watchlist from `secfoo runtime watchlist`.", + )] = None, + output: Annotated[Path | None, typer.Option( + "--output", "-o", + help="Write events here ({pid} is replaced per process). Ignored when SECFOO_SENSOR_ADDR is set.", + )] = None, + flush_interval: Annotated[float, typer.Option("--flush-interval", min=0.05)] = 10.0, +) -> None: + """Run a Python command with the secfoo runtime probe: secfoo instrument -o events.ndjson -- python app.py + + The probe starts in every Python process the command launches (workers + included), records which watched vulnerable functions run, and writes + #53 events. Then: `secfoo runtime ingest events.ndjson --project

`. + """ + import subprocess # nosec B404 -- runs the user's own command, as given + import tempfile + + import secfoo + + command = list(ctx.args) + if command[:1] == ["--"]: + command = command[1:] + if not command: + err_console.print("[red]Error:[/] give the command to run after --, e.g. -- python app.py") + raise typer.Exit(code=2) + env = dict(os.environ) + if output is None and not env.get("SECFOO_SENSOR_ADDR") and not env.get("SECFOO_EVENTS_OUT"): + err_console.print("[red]Error:[/] pass -o events.ndjson (or set SECFOO_SENSOR_ADDR) so events go somewhere.") + raise typer.Exit(code=2) + with tempfile.TemporaryDirectory(prefix="secfoo-instrument-") as hook_dir: + Path(hook_dir, "sitecustomize.py").write_text(_SITECUSTOMIZE, encoding="utf-8") + package_root = str(Path(secfoo.__file__).resolve().parent.parent) + env["PYTHONPATH"] = os.pathsep.join(p for p in (hook_dir, package_root, env.get("PYTHONPATH", "")) if p) + env["SECFOO_SENSOR"] = "1" + env["SECFOO_FLUSH_INTERVAL"] = str(flush_interval) + if watchlist is not None: + env["SECFOO_WATCHLIST"] = str(watchlist.resolve()) + if output is not None: + env["SECFOO_EVENTS_OUT"] = str(output.resolve()) + result = subprocess.run(command, env=env, check=False) # nosec B603 -- the user's command + raise typer.Exit(code=result.returncode) + + MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" diff --git a/src/secfoo/sensor/__init__.py b/src/secfoo/sensor/__init__.py new file mode 100644 index 0000000..8d17d00 --- /dev/null +++ b/src/secfoo/sensor/__init__.py @@ -0,0 +1,49 @@ +"""secfoo runtime sensor: the in-process Python probe (#55). + +Opt-in only (#51 rule 0). `start_from_env()` does nothing unless +`SECFOO_SENSOR=1` is set, so importing this package -- or having it on +PYTHONPATH -- never changes an app's behavior. + + SECFOO_SENSOR=1 SECFOO_WATCHLIST=watchlist.json SECFOO_EVENTS_OUT=events.ndjson python -m secfoo.sensor app.py + secfoo instrument --watchlist watchlist.json -o events.ndjson -- python app.py +""" + +from __future__ import annotations + +import os +import sys + +_probe = None + + +def enabled(environ=None) -> bool: + return (os.environ if environ is None else environ).get("SECFOO_SENSOR", "").strip() == "1" + + +def start_from_env(): + """Start the probe from SECFOO_* variables. Returns it, or None when the + sensor isn't enabled. Never raises into the app.""" + global _probe + if _probe is not None or not enabled(): + return _probe + try: + from secfoo.sensor.probe import DEFAULT_FLUSH_SECONDS, Probe + from secfoo.sensor.transport import from_env + + flush = float(os.environ.get("SECFOO_FLUSH_INTERVAL", DEFAULT_FLUSH_SECONDS)) + _probe = Probe( + watchlist_path=os.environ.get("SECFOO_WATCHLIST") or None, + transport=from_env(), + app_root=os.environ.get("SECFOO_APP_ROOT") or None, + flush_seconds=max(flush, 0.05), + backend=os.environ.get("SECFOO_SENSOR_BACKEND") or None, + ) + _probe.start() + except Exception as exc: # noqa: BLE001 -- fail-open: a bad config never stops the app + print(f"secfoo sensor: not started ({exc})", file=sys.stderr) + _probe = None + return _probe + + +def current_probe(): + return _probe diff --git a/src/secfoo/sensor/__main__.py b/src/secfoo/sensor/__main__.py new file mode 100644 index 0000000..769edaa --- /dev/null +++ b/src/secfoo/sensor/__main__.py @@ -0,0 +1,29 @@ +"""`python -m secfoo.sensor app.py [args]` or `python -m secfoo.sensor -m module [args]`. + +Starts the probe (only if SECFOO_SENSOR=1), then runs the app exactly as +`python app.py` would. +""" + +from __future__ import annotations + +import runpy +import sys + +from secfoo.sensor import start_from_env + + +def main(argv: list[str]) -> None: + if not argv: + print("usage: python -m secfoo.sensor app.py [args] | -m module [args]", file=sys.stderr) + raise SystemExit(2) + start_from_env() + if argv[0] == "-m" and len(argv) > 1: + sys.argv = [argv[1], *argv[2:]] + runpy.run_module(argv[1], run_name="__main__", alter_sys=True) + else: + sys.argv = list(argv) + runpy.run_path(argv[0], run_name="__main__") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/src/secfoo/sensor/probe.py b/src/secfoo/sensor/probe.py new file mode 100644 index 0000000..587d8c7 --- /dev/null +++ b/src/secfoo/sensor/probe.py @@ -0,0 +1,436 @@ +"""In-process Python probe (#55): records which watched vulnerable functions +run, which packages are loaded, and how much app code ran, as #53 events. + +Design rules (#51), all enforced here: + +- **Inert unless `SECFOO_SENSOR=1`.** `start()` returns without installing a + hook, thread or connection otherwise. +- **Observe-only, fail-open.** Callbacks only count. Any internal error turns + the probe off (one stderr line) and the app carries on. +- **No argument values.** Only code identity, counts, timestamps and + `file:line` frames are recorded. `frame.f_locals`, arguments and return + values are never read. +- **Never blocks the app.** Aggregation is in memory (bounded by the size of + the watchlist and the app); a daemon thread flushes running totals, so a + slow or missing transport only ever drops data. + +Backends: `sys.monitoring` (PEP 669, Python 3.12+) with PY_START on the +watched code objects only, or `sys.setprofile` filtered to them on 3.10/3.11 +(`SECFOO_SENSOR_BACKEND=profile` forces it, for tests and comparisons). +""" + +from __future__ import annotations + +import atexit +import importlib.abc +import json +import os +import sys +import sysconfig +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +PROBE_VERSION = "0.1.0" +DEFAULT_FLUSH_SECONDS = 10.0 +WATCHLIST_RECHECK_SECONDS = 30.0 +MAX_STACK_FRAMES = 10 +_TOOL_NAME = "secfoo-sensor" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _iso(epoch: float) -> str: + return datetime.fromtimestamp(epoch, timezone.utc).isoformat() + + +def _stdlib_and_site_dirs() -> tuple[str, ...]: + paths = sysconfig.get_paths() + dirs = {paths.get(k) for k in ("stdlib", "platstdlib", "purelib", "platlib")} + dirs |= {p for p in sys.path if p and ("site-packages" in p or "dist-packages" in p)} + return tuple(sorted(os.path.normcase(os.path.abspath(d)) for d in dirs if d)) + + +class Probe: + def __init__(self, *, watchlist_path: str | None, transport, app_root: str | None = None, + flush_seconds: float = DEFAULT_FLUSH_SECONDS, backend: str | None = None) -> None: + self.watchlist_path = watchlist_path + self.transport = transport + self.flush_seconds = flush_seconds + self.app_root = os.path.normcase(os.path.abspath(app_root or os.getcwd())) + self.backend = backend or ("monitoring" if hasattr(sys, "monitoring") else "profile") + self._excluded_dirs = _stdlib_and_site_dirs() + self._lock = threading.Lock() + self._active = False + self._broken = False + self._reset_session() + self._watch_entries: list[dict] = [] + self._watchlist_mtime: float | None = None + self._watchlist_checked = 0.0 + self._watched: dict = {} # code object -> (symbol, [fingerprints]) + self._pending_symbols: dict = {} # symbol -> [fingerprints], not resolved yet + self._seen_app_code: set = set() + self._tool_id: int | None = None + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + # ---------- lifecycle ---------- + + def _reset_session(self) -> None: + self.session_id = str(uuid.uuid4()) + self.started_at = _now() + self._hits: dict[tuple, dict] = {} # (fingerprint, symbol) -> aggregate + self._seen_app_code = set() + self.dropped = 0 + + def start(self) -> None: + try: + self._load_watchlist(force=True) + if self.backend == "monitoring": + self._start_monitoring() + else: + self._start_profile() + sys.meta_path.insert(0, _ImportWatcher(self)) + self._active = True + self._resolve_pending() + self._start_flusher() + atexit.register(self.stop) + # multiprocessing workers leave through os._exit(), which skips + # atexit; its own exit hook still runs, so flush there too. + import multiprocessing.util + + multiprocessing.util.Finalize(None, self.stop, exitpriority=100) + if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=self._after_fork) + except Exception as exc: # noqa: BLE001 -- fail-open + self._disable(exc) + + def stop(self) -> None: + if not self._active: + return + self._stop.set() + self.flush() + self._active = False + try: + if self._tool_id is not None: + sys.monitoring.register_callback(self._tool_id, sys.monitoring.events.PY_START, None) + sys.monitoring.set_events(self._tool_id, 0) + sys.monitoring.free_tool_id(self._tool_id) + self._tool_id = None + elif self.backend == "profile": + sys.setprofile(None) + threading.setprofile(None) # type: ignore[arg-type] + self.transport.close() + except Exception: # noqa: BLE001, S110 -- shutting down; never raise into the app + pass + + def _after_fork(self) -> None: + """A forked worker (gunicorn, multiprocessing) is its own session.""" + try: + self._lock = threading.Lock() + self._reset_session() + self._stop = threading.Event() + self.transport = self.transport.reopen() + self._start_flusher() + except Exception as exc: # noqa: BLE001 -- fail-open + self._disable(exc) + + def _disable(self, exc: BaseException) -> None: + if self._broken: + return + self._broken = True + self._active = False + try: + if self._tool_id is not None: + sys.monitoring.set_events(self._tool_id, 0) + for code in list(self._watched): + sys.monitoring.set_local_events(self._tool_id, code, 0) + elif self.backend == "profile": + sys.setprofile(None) + print(f"secfoo sensor: disabled after an internal error ({exc.__class__.__name__}); " + "the app is unaffected", file=sys.stderr) + except Exception: # noqa: BLE001, S110 -- never raise into the app + pass + + # ---------- watchlist ---------- + + def _load_watchlist(self, *, force: bool = False) -> None: + if not self.watchlist_path: + return + now = time.monotonic() + if not force and now - self._watchlist_checked < WATCHLIST_RECHECK_SECONDS: + return + self._watchlist_checked = now + try: + mtime = os.path.getmtime(self.watchlist_path) + except OSError: + return + if mtime == self._watchlist_mtime: + return + self._watchlist_mtime = mtime + with open(self.watchlist_path, encoding="utf-8") as handle: + document = json.load(handle) + entries = [e for e in document.get("entries", []) if e.get("ecosystem") == "PyPI"] + self._watch_entries = entries + for entry in entries: + for symbol in entry.get("symbols") or []: + self._pending_symbols.setdefault(symbol, []).append(entry["finding_fingerprint"]) + + def _resolve_pending(self) -> None: + for symbol in list(self._pending_symbols): + code = _resolve(symbol) + if code is None: + continue + fingerprints = self._pending_symbols.pop(symbol) + known = self._watched.get(code) + self._watched[code] = (symbol, sorted(set(fingerprints + (known[1] if known else [])))) + if self._tool_id is not None: + sys.monitoring.set_local_events(self._tool_id, code, sys.monitoring.events.PY_START) + + # ---------- backends ---------- + + def _start_monitoring(self) -> None: + mon = sys.monitoring + for tool_id in range(mon.PROFILER_ID, 6): # first free tool id; 0-1 are debugger/coverage + if mon.get_tool(tool_id) is None: + mon.use_tool_id(tool_id, _TOOL_NAME) + self._tool_id = tool_id + break + else: + raise RuntimeError("no free sys.monitoring tool id") + mon.register_callback(self._tool_id, mon.events.PY_START, self._on_py_start) + # Global PY_START for coverage of app code; each location is disabled + # after its first event, so steady-state cost is ~zero. + mon.set_events(self._tool_id, mon.events.PY_START) + + def _on_py_start(self, code, _offset): + try: + watched = self._watched.get(code) + if watched is not None: + self._record_hit(code, watched) + if code not in self._seen_app_code and self._is_app_code(code.co_filename): + self._seen_app_code.add(code) + return None # keep counting watched functions + if code.co_name != "" and self._is_app_code(code.co_filename): + self._seen_app_code.add(code) + return sys.monitoring.DISABLE + except Exception as exc: # noqa: BLE001 -- never raise into the app + self._disable(exc) + return sys.monitoring.DISABLE + + def _start_profile(self) -> None: + def profiler(frame, event, _arg): + if event != "call": + return + try: + code = frame.f_code + watched = self._watched.get(code) + if watched is not None: + self._record_hit(code, watched) + if (code not in self._seen_app_code and code.co_name != "" + and self._is_app_code(code.co_filename)): + self._seen_app_code.add(code) + except Exception as exc: # noqa: BLE001 + self._disable(exc) + + sys.setprofile(profiler) + threading.setprofile(profiler) + + # ---------- recording ---------- + + def _is_app_code(self, filename: str) -> bool: + if not filename or filename.startswith("<"): + return False + path = os.path.normcase(os.path.abspath(filename)) + return path.startswith(self.app_root) and not path.startswith(self._excluded_dirs) + + def _record_hit(self, code, watched) -> None: + """The hot path: one dict lookup and two stores per watched call. + Timestamps stay raw floats until flush. No lock: under the GIL a + lost increment between threads is possible but rare, and a running + total that's off by one is an acceptable price for not serialising + the app's threads on every call.""" + symbol, fingerprints = watched + now = time.time() + hits = self._hits + for fingerprint in fingerprints: + agg = hits.get((fingerprint, symbol)) + if agg is not None: + agg[0] += 1 + agg[2] = now + continue + stack = self._stack_sample() + # stack[0] is the watched function itself; the entry frame is the + # app code that called it. [count, first_seen, last_seen, stack, entry] + with self._lock: + hits.setdefault((fingerprint, symbol), [1, now, now, stack, stack[1] if len(stack) > 1 else None]) + + def _stack_sample(self) -> list[str]: + """file:line of app frames only (plus nothing else): no locals, no args.""" + frames: list[str] = [] + frame = sys._getframe(3) + while frame is not None and len(frames) < MAX_STACK_FRAMES: + filename = frame.f_code.co_filename + if self._is_app_code(filename): + rel = os.path.relpath(filename, self.app_root).replace(os.sep, "/") + frames.append(f"{rel}:{frame.f_lineno}") + frame = frame.f_back + return frames + + # ---------- output ---------- + + def snapshot(self) -> list[dict]: + """The session's running totals as #53 events.""" + with self._lock: + hits = {k: list(v) for k, v in self._hits.items()} + executed = len(self._seen_app_code) + session = { + "type": "session", "id": self.session_id, "language": "python", + "runtime": ".".join(map(str, sys.version_info[:3])), "source": "sensor", + "started_at": self.started_at, "ended_at": _now(), "sensor_version": PROBE_VERSION, + "instance": os.environ.get("HOSTNAME") or os.environ.get("COMPUTERNAME") or None, + } + if self.backend == "monitoring": + total = _count_app_functions(self) + session["app_functions_total"] = max(total, executed) + session["app_functions_executed"] = executed + session = {k: v for k, v in session.items() if v is not None} + events = [session, *self._loads()] + for (fingerprint, symbol), (count, first, last, stack, entry) in sorted(hits.items()): + hit = {"type": "hit", "session": self.session_id, "finding_fingerprint": fingerprint, + "symbol": symbol, "route": "unknown", "count": count, "first_seen": _iso(first), + "last_seen": _iso(last), "stack_sample": stack, "entry_frame": entry} + events.append({k: v for k, v in hit.items() if v not in (None, [])}) + return events + + def _loads(self) -> list[dict]: + """`load` lines for installed distributions with an imported top-level module.""" + try: + from importlib.metadata import packages_distributions, version + except ImportError: + return [] + imported = {name.split(".")[0] for name in list(sys.modules)} + seen: set[str] = set() + loads = [] + for top, dists in packages_distributions().items(): + if top not in imported: + continue + for dist in dists: + if dist.lower() in seen: + continue + seen.add(dist.lower()) + try: + loads.append({"type": "load", "session": self.session_id, "ecosystem": "PyPI", + "package": dist, "version": version(dist), "first_seen": self.started_at}) + except Exception: # noqa: BLE001, S112 -- broken package metadata: skip it + continue + return loads + + def flush(self) -> None: + if self._broken: + return + try: + self._load_watchlist() + self._resolve_pending() + if not self.transport.send(self.snapshot()): + self.dropped += 1 + except Exception as exc: # noqa: BLE001 + self._disable(exc) + + def _start_flusher(self) -> None: + def loop(): + while not self._stop.wait(self.flush_seconds): + self.flush() + + self._thread = threading.Thread(target=loop, name="secfoo-sensor-flush", daemon=True) + self._thread.start() + + +def _resolve(symbol: str): + """The code object for `module.qualname`, once the module is imported.""" + parts = symbol.split(".") + for split in range(len(parts) - 1, 0, -1): + module = sys.modules.get(".".join(parts[:split])) + if module is None: + continue + obj = module + try: + for attr in parts[split:]: + obj = getattr(obj, attr) + except AttributeError: + return None + obj = getattr(obj, "__func__", obj) + while hasattr(obj, "__wrapped__"): + obj = obj.__wrapped__ + return getattr(obj, "__code__", None) + return None + + +def _count_app_functions(probe: Probe) -> int: + """Function code objects defined in loaded app modules (the coverage + denominator). Walks co_consts, never module or frame state.""" + total = 0 + files = {getattr(module, "__file__", None) for module in list(sys.modules.values())} + # __main__ may already be gone from sys.modules at exit; the files of app + # code that ran, and the main script, still name it. + files |= {code.co_filename for code in list(probe._seen_app_code)} + if sys.argv and sys.argv[0]: + files.add(os.path.abspath(sys.argv[0])) + for filename in {os.path.normcase(os.path.abspath(f)) for f in files if f}: + if not os.path.isfile(filename) or not probe._is_app_code(filename): + continue + try: + source = Path(filename).read_text(encoding="utf-8") + stack = [compile(source, filename, "exec")] + except (OSError, SyntaxError, ValueError, UnicodeDecodeError): + continue + while stack: + code = stack.pop() + for const in code.co_consts: + if hasattr(const, "co_code"): + total += 1 + stack.append(const) + return total + + +class _ImportWatcher(importlib.abc.MetaPathFinder): + """Resolves watched symbols right after their module is imported. Never + finds modules itself (find_spec returns None), so imports are unchanged.""" + + def __init__(self, probe: Probe) -> None: + self.probe = probe + self._busy = threading.local() + + def find_spec(self, fullname, path=None, target=None): + if getattr(self._busy, "on", False) or not self.probe._pending_symbols: + return None + if not any(s == fullname or s.startswith(fullname + ".") for s in self.probe._pending_symbols): + return None + self._busy.on = True + try: + spec = importlib.util.find_spec(fullname) if path is None else None + except (ImportError, ValueError): + spec = None + finally: + self._busy.on = False + if spec is None or spec.loader is None or not hasattr(spec.loader, "exec_module"): + return None + original = spec.loader.exec_module + probe = self.probe + + def exec_module(module): + original(module) + try: + probe._resolve_pending() + except Exception as exc: # noqa: BLE001 + probe._disable(exc) + + spec.loader.exec_module = exec_module # type: ignore[method-assign] + return spec + + +import importlib.util diff --git a/src/secfoo/sensor/transport.py b/src/secfoo/sensor/transport.py new file mode 100644 index 0000000..6cba198 --- /dev/null +++ b/src/secfoo/sensor/transport.py @@ -0,0 +1,214 @@ +"""Where a probe's events go (#73 transports), chosen by one env var. + + SECFOO_SENSOR_ADDR=unix:///var/run/secfoo/sensor.sock host daemon / sidecar sharing a volume + SECFOO_SENSOR_ADDR=tcp://127.0.0.1:7461 sidecar sharing a network namespace (loopback only) + SECFOO_SENSOR_ADDR=npipe:////./pipe/secfoo-sensor Windows + SECFOO_SENSOR_ADDR=https://secfoo.internal direct mode (serverless): POST to the #66 API + SECFOO_SENSOR_ADDR=file:///path/events.ndjson offline; read later with `secfoo runtime ingest` + +With no SECFOO_SENSOR_ADDR, SECFOO_EVENTS_OUT (a file path) is used. Every +`send()` runs on the probe's flush thread, never on a request path, and +returns False instead of raising or retrying when the destination is gone. +The probe opens no connection other than the one named here. +""" + +from __future__ import annotations + +import gzip +import ipaddress +import json +import os +import socket +import sys +import urllib.parse +import urllib.request + +PROTOCOL_VERSION = 1 +DIRECT_TIMEOUT_SECONDS = 0.2 # #73: a cold start or shutdown never waits on the network +SOCKET_TIMEOUT_SECONDS = 1.0 + + +class TransportError(ValueError): + """The address is malformed or not allowed (e.g. a routable TCP host).""" + + +def _lines(events: list[dict]) -> bytes: + return "".join(json.dumps(e, separators=(",", ":")) + "\n" for e in events).encode("utf-8") + + +def hello(runtime: str = "python") -> dict: + return { + "type": "hello", "protocol": PROTOCOL_VERSION, "runtime": runtime, + "runtime_version": ".".join(map(str, sys.version_info[:3])), + "probe_version": "0.1.0", "pid": os.getpid(), + "host": os.environ.get("HOSTNAME") or socket.gethostname(), + } + + +class FileTransport: + def __init__(self, path: str) -> None: + self.path = path.replace("{pid}", str(os.getpid())) + self.template = path + + def send(self, events: list[dict]) -> bool: + try: + fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + try: + os.write(fd, _lines(events)) # one write per flush: lines from forked workers never interleave + finally: + os.close(fd) + return True + except OSError: + return False + + def reopen(self) -> FileTransport: + return FileTransport(self.template) + + def close(self) -> None: + pass + + +class _StreamTransport: + """unix / tcp / npipe: newline-framed #53 lines after a `hello`.""" + + def __init__(self, address: str) -> None: + self.address = address + self._conn = None + self.slow = False + + def _connect(self): + raise NotImplementedError + + def _write(self, data: bytes) -> None: + raise NotImplementedError + + def send(self, events: list[dict]) -> bool: + try: + if self._conn is None: + self._connect() + self._write(_lines([hello()])) + self._write(_lines(events)) + return True + except OSError: + self.close() # sensor gone: drop this batch, reconnect next flush + return False + + def reopen(self): + return type(self)(self.address) + + def close(self) -> None: + conn, self._conn = self._conn, None + if conn is not None: + try: + conn.close() + except OSError: + pass + + +class SocketTransport(_StreamTransport): + def __init__(self, address: str) -> None: + super().__init__(address) + parsed = urllib.parse.urlsplit(address) + self.scheme = parsed.scheme + if parsed.scheme == "tcp": + host = parsed.hostname or "" + try: + loopback = host == "localhost" or ipaddress.ip_address(host).is_loopback + except ValueError: + loopback = False + if not loopback or not parsed.port: + raise TransportError("tcp:// sensor addresses must be loopback with a port, e.g. tcp://127.0.0.1:7461") + self.target = (host, parsed.port) + else: + self.target = parsed.path + + def _connect(self): + family = socket.AF_INET6 if self.scheme == "tcp" and ":" in self.target[0] else socket.AF_INET + if self.scheme == "unix": + family = socket.AF_UNIX + conn = socket.socket(family, socket.SOCK_STREAM) + conn.settimeout(SOCKET_TIMEOUT_SECONDS) + try: + conn.connect(self.target) + except OSError: + conn.close() + raise + self._conn = conn + key = os.environ.get("SECFOO_SENSOR_KEY") + if key and self.scheme == "tcp": + self._write(_lines([{"type": "auth", "key": key}])) + + def _write(self, data: bytes) -> None: + self._conn.sendall(data) + + +class PipeTransport(_StreamTransport): + def __init__(self, address: str) -> None: + super().__init__(address) + # npipe:////./pipe/secfoo-sensor -> \\.\pipe\secfoo-sensor + self.path = "\\\\" + address[len("npipe:////"):].replace("/", "\\") + + def _connect(self): + self._conn = open(self.path, "wb", buffering=0) # noqa: SIM115 -- kept open between flushes + + def _write(self, data: bytes) -> None: + self._conn.write(data) + + +class DirectTransport: + """Direct mode: POST gzip NDJSON to the #66 API with the sensor's key, + capped at DIRECT_TIMEOUT_SECONDS so the app never waits on the network.""" + + def __init__(self, address: str) -> None: + from secfoo.runtime.client import load_sensor_config + + self.config = load_sensor_config({**os.environ, "SECFOO_SERVER": address}) + + def send(self, events: list[dict]) -> bool: + query = urllib.parse.urlencode({k: v for k, v in { + "project": self.config.project, "app": self.config.app, "env": self.config.env}.items() if v}) + request = urllib.request.Request( # nosec B310 -- https enforced by load_sensor_config + self.config.url("events") + "?" + query, data=gzip.compress(_lines(events)), method="POST", + headers={"Authorization": f"Bearer {self.config.api_key}", "Content-Encoding": "gzip", + "Content-Type": "application/x-ndjson"}, + ) + try: + with urllib.request.urlopen(request, timeout=DIRECT_TIMEOUT_SECONDS) as response: # nosec B310 + return 200 <= response.status < 300 + except OSError: + return False + + def reopen(self): + return self + + def close(self) -> None: + pass + + +class NullTransport: + def send(self, events: list[dict]) -> bool: + return False + + def reopen(self): + return self + + def close(self) -> None: + pass + + +def from_env(environ: dict[str, str] | None = None): + environ = os.environ if environ is None else environ + address = environ.get("SECFOO_SENSOR_ADDR", "").strip() + if not address: + out = environ.get("SECFOO_EVENTS_OUT", "").strip() + return FileTransport(out) if out else NullTransport() + scheme = urllib.parse.urlsplit(address).scheme + if scheme == "file": + return FileTransport(urllib.request.url2pathname(urllib.parse.urlsplit(address).path)) + if scheme in ("unix", "tcp"): + return SocketTransport(address) + if scheme == "npipe": + return PipeTransport(address) + if scheme == "https": + return DirectTransport(address) + raise TransportError(f"unsupported SECFOO_SENSOR_ADDR {address!r}; use unix://, tcp://, npipe://, https:// or file://") diff --git a/tests/test_sensor/test_python_probe.py b/tests/test_sensor/test_python_probe.py new file mode 100644 index 0000000..17bd9c3 --- /dev/null +++ b/tests/test_sensor/test_python_probe.py @@ -0,0 +1,276 @@ +"""#55: the in-process Python probe.""" +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import textwrap +import threading +from pathlib import Path + +import pytest + +import secfoo +from secfoo.runtime.evidence import ingest_events +from secfoo.sensor import transport as transport_mod +from secfoo.sensor.probe import Probe +from secfoo.storage.repository import RunRepository + +SRC = str(Path(secfoo.__file__).resolve().parent.parent) +BACKENDS = ["profile"] + (["monitoring"] if hasattr(sys, "monitoring") else []) + +VULN = "def parse(text):\n return text.upper()\n\ndef safe(text):\n return text\n" +APP = textwrap.dedent("""\ + import sys + import sample_vuln + + def handle(secret): + return sample_vuln.parse(secret) if "--call" in sys.argv else sample_vuln.safe(secret) + + if __name__ == "__main__": + for _ in range(5): + handle("password=hunter2") + print("served") + """) +WORKERS_APP = textwrap.dedent("""\ + import multiprocessing as mp + import sample_vuln + + def work(n): + return sample_vuln.parse("x" * n) + + if __name__ == "__main__": + pool = mp.get_context("spawn").Pool(2) + print(sum(len(r) for r in pool.map(work, range(1, 9), chunksize=4))) + pool.close() # workers exit normally, as gunicorn/uvicorn workers do + pool.join() + """) + + +def _watchlist(path: Path, symbols=("sample_vuln.parse",)) -> Path: + path.write_text(json.dumps({"version": 1, "generated_at": "2026-11-12T00:00:00Z", "entries": [ + {"finding_fingerprint": "fp-1", "advisory": None, "ecosystem": "PyPI", "package": "sample-vuln", + "version": "1.0", "granularity": "symbol", "symbols": list(symbols), "source": "osv"}]}), encoding="utf-8") + return path + + +@pytest.fixture +def app_dir(tmp_path): + (tmp_path / "sample_vuln.py").write_text(VULN, encoding="utf-8") + (tmp_path / "app.py").write_text(APP, encoding="utf-8") + (tmp_path / "workers.py").write_text(WORKERS_APP, encoding="utf-8") + _watchlist(tmp_path / "watchlist.json") + return tmp_path + + +def _run_instrumented(app_dir: Path, *args: str, backend: str, out: str = "events.ndjson", extra_env=None): + env = {**os.environ, "PYTHONPATH": SRC, "SECFOO_SENSOR_BACKEND": backend, **(extra_env or {})} + env.pop("SECFOO_SENSOR_ADDR", None) + cmd = [sys.executable, "-m", "secfoo.cli", "instrument", "--watchlist", "watchlist.json", "-o", out, + "--flush-interval", "0.1", "--", sys.executable, *args] + return subprocess.run(cmd, cwd=app_dir, env=env, capture_output=True, text=True, timeout=120, check=False) + + +def _events(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def _latest_hits(events: list[dict]) -> dict: + hits = {} + for event in events: + if event["type"] == "hit": + hits[(event["session"], event["symbol"])] = event + return hits + + +# ---------- calling / not calling the watched function ---------- + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_calling_the_watched_function_is_recorded(app_dir, backend): + result = _run_instrumented(app_dir, "app.py", "--call", backend=backend) + assert result.returncode == 0 and "served" in result.stdout, result.stderr + events = _events(app_dir / "events.ndjson") + [hit] = _latest_hits(events).values() + assert hit["finding_fingerprint"] == "fp-1" and hit["symbol"] == "sample_vuln.parse" and hit["count"] == 5 + assert hit["entry_frame"] == "app.py:5" # the app line that called it + assert hit["stack_sample"][0].startswith("sample_vuln.py:") + session = [e for e in events if e["type"] == "session"][-1] # the final flush's running totals + assert session["language"] == "python" and session["source"] == "sensor" + if backend == "monitoring": + # handle() and parse() ran; safe() didn't. + assert session["app_functions_executed"] == 2 and session["app_functions_total"] >= 3 + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_not_calling_it_records_a_session_without_hits(app_dir, backend): + result = _run_instrumented(app_dir, "app.py", backend=backend) + assert result.returncode == 0, result.stderr + events = _events(app_dir / "events.ndjson") + assert any(e["type"] == "session" for e in events) + assert not any(e["type"] == "hit" for e in events) + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_argument_values_never_reach_the_events(app_dir, backend): + _run_instrumented(app_dir, "app.py", "--call", backend=backend) + assert "hunter2" not in (app_dir / "events.ndjson").read_text(encoding="utf-8") + + +def test_output_ingests_cleanly_with_the_53_format(app_dir, tmp_path): + _run_instrumented(app_dir, "app.py", "--call", backend=BACKENDS[-1]) + with RunRepository(db_path=tmp_path / "db.sqlite") as repo: + pid = repo.upsert_project("/app", "app", "local") + run_id = repo.get_run(repo.create_run(project_id=pid, skill_id="sca-reachability", skill_name="SCA", + agent_id="claude", confluence_urls=[])).id + repo.upsert_sca_finding(project_id=pid, fingerprint="fp-1", run_id=run_id, current_ref="D1", + package="sample-vuln", severity="High") + lines = (app_dir / "events.ndjson").read_text(encoding="utf-8").splitlines() + report = ingest_events(repo.connection, pid, lines) + assert report.skipped == [] and report.hits >= 1 + assert repo.connection.execute("SELECT count FROM runtime_evidence").fetchone()[0] == 5 + + +# ---------- opt-in only ---------- + + +def test_inert_without_secfoo_sensor(app_dir): + env = {**os.environ, "PYTHONPATH": SRC, "SECFOO_WATCHLIST": "watchlist.json", "SECFOO_EVENTS_OUT": "events.ndjson"} + env.pop("SECFOO_SENSOR", None) + result = subprocess.run([sys.executable, "-m", "secfoo.sensor", "app.py", "--call"], cwd=app_dir, env=env, + capture_output=True, text=True, timeout=60, check=False) + assert result.returncode == 0 and "served" in result.stdout + assert not (app_dir / "events.ndjson").exists() + + +def test_importing_the_package_does_nothing(monkeypatch): + monkeypatch.delenv("SECFOO_SENSOR", raising=False) + from secfoo import sensor + + assert sensor.start_from_env() is None and sensor.current_probe() is None + + +# ---------- fail-open ---------- + + +def test_a_broken_watchlist_never_stops_the_app(app_dir): + (app_dir / "watchlist.json").write_text("{not json", encoding="utf-8") + result = _run_instrumented(app_dir, "app.py", "--call", backend=BACKENDS[-1]) + assert result.returncode == 0 and "served" in result.stdout + assert "secfoo sensor" in result.stderr + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_an_error_inside_the_probe_disables_it_and_the_app_keeps_running(tmp_path, backend, monkeypatch, capsys): + module = tmp_path / "probe_target_mod.py" + module.write_text(VULN, encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + import probe_target_mod + + probe = Probe(watchlist_path=str(_watchlist(tmp_path / "w.json", ("probe_target_mod.parse",))), + transport=transport_mod.NullTransport(), app_root=str(tmp_path), flush_seconds=60, backend=backend) + probe.start() + try: + def boom(*_a, **_k): + raise RuntimeError("probe bug") + + monkeypatch.setattr(probe, "_record_hit", boom) + assert probe_target_mod.parse("abc") == "ABC" # app code still works + assert probe_target_mod.parse("def") == "DEF" + assert probe._broken + assert "disabled after an internal error" in capsys.readouterr().err + finally: + probe.stop() + if backend == "profile": + sys.setprofile(None) + + +def test_sensor_dying_mid_run_drops_events_without_blocking(tmp_path): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen() + port = server.getsockname()[1] + received = [] + + def accept_once(): + conn, _ = server.accept() + data = b"" + while b"\n" not in data or data.count(b"\n") < 2: + chunk = conn.recv(65536) + if not chunk: + break + data += chunk + received.append(data) + conn.close() + server.close() # the sensor goes away + + thread = threading.Thread(target=accept_once, daemon=True) + thread.start() + tcp = transport_mod.SocketTransport(f"tcp://127.0.0.1:{port}") + assert tcp.send([{"type": "session", "id": "s"}]) + thread.join(5) + hello = json.loads(received[0].splitlines()[0]) + assert hello["type"] == "hello" and hello["protocol"] == 1 and hello["runtime"] == "python" + # Sensor gone: sends now fail fast and return False instead of raising or retrying. + results = [tcp.send([{"type": "session", "id": "s"}]) for _ in range(3)] + assert results[-1] is False + + +# ---------- several processes ---------- + + +def test_each_worker_process_is_its_own_session(app_dir): + result = _run_instrumented(app_dir, "workers.py", backend=BACKENDS[-1]) + assert result.returncode == 0 and result.stdout.strip() == "36", result.stderr + events = _events(app_dir / "events.ndjson") + hits = _latest_hits(events) + sessions = {e["id"] for e in events if e["type"] == "session"} + assert len(sessions) >= 2 # the parent and its spawned workers are separate sessions + assert {session for session, _ in hits} < sessions # hits come from workers, not the parent + assert sum(h["count"] for h in hits.values()) == 8 # every call counted exactly once + + +def test_pid_placeholder_gives_each_process_its_own_file(app_dir): + result = _run_instrumented(app_dir, "workers.py", backend=BACKENDS[-1], out="events-{pid}.ndjson") + assert result.returncode == 0, result.stderr + assert len(list(app_dir.glob("events-*.ndjson"))) >= 2 + + +# ---------- transports ---------- + + +@pytest.mark.parametrize("address", ["tcp://10.0.0.5:7461", "tcp://example.com:7461", "tcp://127.0.0.1"]) +def test_tcp_sensor_address_must_be_loopback_with_a_port(address): + with pytest.raises(transport_mod.TransportError): + transport_mod.SocketTransport(address) + + +def test_transport_selection(tmp_path): + assert isinstance(transport_mod.from_env({}), transport_mod.NullTransport) + assert isinstance(transport_mod.from_env({"SECFOO_EVENTS_OUT": str(tmp_path / "e")}), transport_mod.FileTransport) + assert isinstance(transport_mod.from_env({"SECFOO_SENSOR_ADDR": "tcp://127.0.0.1:7461"}), + transport_mod.SocketTransport) + assert isinstance(transport_mod.from_env({"SECFOO_SENSOR_ADDR": "npipe:////./pipe/secfoo-sensor"}), + transport_mod.PipeTransport) + with pytest.raises(transport_mod.TransportError): + transport_mod.from_env({"SECFOO_SENSOR_ADDR": "ftp://x"}) + + +def test_direct_mode_needs_explicit_key_and_project(monkeypatch): + monkeypatch.delenv("SECFOO_API_KEY", raising=False) + monkeypatch.delenv("SECFOO_API_KEY_FILE", raising=False) + from secfoo.runtime.client import SensorConfigError + + with pytest.raises(SensorConfigError): + transport_mod.from_env({"SECFOO_SENSOR_ADDR": "https://secfoo.internal"}) + + +def test_instrument_requires_a_destination_and_a_command(app_dir): + env = {**os.environ, "PYTHONPATH": SRC} + env.pop("SECFOO_SENSOR_ADDR", None) + env.pop("SECFOO_EVENTS_OUT", None) + no_out = subprocess.run([sys.executable, "-m", "secfoo.cli", "instrument", "--", sys.executable, "app.py"], + cwd=app_dir, env=env, capture_output=True, text=True, timeout=60, check=False) + assert no_out.returncode == 2 and "-o events.ndjson" in no_out.stderr