scaneval 2.0.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scaneval/__init__.py +13 -0
- scaneval/__main__.py +3 -0
- scaneval/adapters/__init__.py +30 -0
- scaneval/adapters/base.py +535 -0
- scaneval/adapters/deepsec.py +3944 -0
- scaneval/adapters/llm_harness.py +3312 -0
- scaneval/adapters/llm_harness_driver.mts +2160 -0
- scaneval/adapters/pi_oci_runner.mts +317 -0
- scaneval/adapters/pi_open_context.mts +19 -0
- scaneval/adapters/pi_open_context_tools.mjs +506 -0
- scaneval/adapters/pi_synthetic_provider.mts +115 -0
- scaneval/adapters/pr.py +252 -0
- scaneval/adapters/runsortie.py +3279 -0
- scaneval/adapters/runsortie_evidence.py +2232 -0
- scaneval/adapters/semgrep.py +1081 -0
- scaneval/aggregate.py +2548 -0
- scaneval/artifacts.py +38 -0
- scaneval/benchmark.py +486 -0
- scaneval/benchmark_cli.py +61 -0
- scaneval/blinding.py +1886 -0
- scaneval/cases.py +1573 -0
- scaneval/cli.py +1799 -0
- scaneval/collectors/__init__.py +545 -0
- scaneval/collectors/claude_code.py +1600 -0
- scaneval/collectors/codex.py +494 -0
- scaneval/collectors/source.py +63 -0
- scaneval/conditions.py +624 -0
- scaneval/conditions_identity.py +93 -0
- scaneval/contracts.py +2695 -0
- scaneval/corpus.py +57 -0
- scaneval/data/model-cutoffs.json +1717 -0
- scaneval/datasets/__init__.py +35 -0
- scaneval/datasets/base.py +139 -0
- scaneval/datasets/builtin.py +224 -0
- scaneval/datasets/cli.py +50 -0
- scaneval/datasets/locks/aacr-bench.json +54 -0
- scaneval/datasets/locks/martian.json +522 -0
- scaneval/datasets/locks/openssf.json +1628 -0
- scaneval/datasets/locks/reviewbench.json +1578 -0
- scaneval/demo.py +64 -0
- scaneval/diagnostics.py +1089 -0
- scaneval/evaluator.py +27 -0
- scaneval/execution.py +1764 -0
- scaneval/experiment.py +507 -0
- scaneval/experiment_execution.py +405 -0
- scaneval/experiment_report.py +457 -0
- scaneval/gate.py +1344 -0
- scaneval/identity_policy.py +1278 -0
- scaneval/investigation.py +467 -0
- scaneval/investigation_html.py +138 -0
- scaneval/isolation/__init__.py +275 -0
- scaneval/isolation/egress_proxy.py +302 -0
- scaneval/isolation/oci.py +1335 -0
- scaneval/isolation/public_retrieval.py +556 -0
- scaneval/kinds.py +86 -0
- scaneval/machine_review.py +299 -0
- scaneval/mappings/kinds.json +43 -0
- scaneval/materialize.py +1243 -0
- scaneval/model_cutoff_projection.py +148 -0
- scaneval/model_cutoffs.py +261 -0
- scaneval/model_cutoffs_cli.py +36 -0
- scaneval/observer/__init__.py +81 -0
- scaneval/observer/emitter.py +1795 -0
- scaneval/open_context.py +341 -0
- scaneval/open_context_evidence.py +285 -0
- scaneval/precision.py +929 -0
- scaneval/report.py +114 -0
- scaneval/resampling.py +93 -0
- scaneval/review.py +475 -0
- scaneval/runner.py +1257 -0
- scaneval/sarif.py +2240 -0
- scaneval/schedule.py +277 -0
- scaneval/schemas/aggregate-report.schema.json +2011 -0
- scaneval/schemas/aggregation-policy.schema.json +131 -0
- scaneval/schemas/blinding-map.schema.json +426 -0
- scaneval/schemas/case-pack-2.1.schema.json +1172 -0
- scaneval/schemas/case-pack.schema.json +257 -0
- scaneval/schemas/comparison-report.schema.json +2078 -0
- scaneval/schemas/condition-record.schema.json +41 -0
- scaneval/schemas/dataset.schema.json +61 -0
- scaneval/schemas/evaluation-plan-2.1.schema.json +329 -0
- scaneval/schemas/evaluation-plan.schema.json +52 -0
- scaneval/schemas/evaluation-schedule.schema.json +647 -0
- scaneval/schemas/execution-record-2.1.schema.json +501 -0
- scaneval/schemas/execution-record.schema.json +79 -0
- scaneval/schemas/experiment-matrix.schema.json +99 -0
- scaneval/schemas/gate-decision.schema.json +387 -0
- scaneval/schemas/gate-policy.schema.json +436 -0
- scaneval/schemas/identity-transformation-manifest.schema.json +136 -0
- scaneval/schemas/import-record.schema.json +680 -0
- scaneval/schemas/machine-judgments.schema.json +72 -0
- scaneval/schemas/precision-estimate.schema.json +807 -0
- scaneval/schemas/precision-reviews.schema.json +96 -0
- scaneval/schemas/precision-sample.schema.json +667 -0
- scaneval/schemas/review-decisions.schema.json +40 -0
- scaneval/schemas/review-record.schema.json +21 -0
- scaneval/schemas/run-config-2.1.schema.json +275 -0
- scaneval/schemas/run-config.schema.json +29 -0
- scaneval/schemas/run-manifest-2.1.schema.json +599 -0
- scaneval/schemas/run-manifest.schema.json +140 -0
- scaneval/schemas/scan-request.schema.json +52 -0
- scaneval/schemas/scan-result-2.1.schema.json +295 -0
- scaneval/schemas/scan-result.schema.json +73 -0
- scaneval/schemas/severity-report.schema.json +1158 -0
- scaneval/schemas/trace-event.schema.json +20 -0
- scaneval/scoring.py +379 -0
- scaneval/severity.py +422 -0
- scaneval/source.py +20 -0
- scaneval/trace.py +80 -0
- scaneval-2.0.0a1.dist-info/METADATA +145 -0
- scaneval-2.0.0a1.dist-info/RECORD +115 -0
- scaneval-2.0.0a1.dist-info/WHEEL +5 -0
- scaneval-2.0.0a1.dist-info/entry_points.txt +2 -0
- scaneval-2.0.0a1.dist-info/licenses/LICENSE +21 -0
- scaneval-2.0.0a1.dist-info/top_level.txt +1 -0
scaneval/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Framework-independent ScanEval evaluation core.
|
|
2
|
+
|
|
3
|
+
Live scanner integrations remain separate from this protocol.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "2.0.0a1"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def evaluate(plan: dict, result: dict, decisions: dict) -> dict:
|
|
10
|
+
"""Evaluate one saved scan with frozen evaluator-side decisions, offline."""
|
|
11
|
+
from .scoring import score
|
|
12
|
+
|
|
13
|
+
return score(plan, result, decisions)
|
scaneval/__main__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Adapter registry. Adapters are looked up by name and constructed without arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
|
|
7
|
+
from .base import Adapter, AdapterError, NativeOutcome, SystemSpec
|
|
8
|
+
|
|
9
|
+
_REGISTRY = {
|
|
10
|
+
"semgrep": ("scaneval.adapters.semgrep", "SemgrepAdapter"),
|
|
11
|
+
"llm-harness": ("scaneval.adapters.llm_harness", "LlmHarnessAdapter"),
|
|
12
|
+
"deepsec": ("scaneval.adapters.deepsec", "DeepsecAdapter"),
|
|
13
|
+
"runsortie": ("scaneval.adapters.runsortie", "RunSortieAdapter"),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def adapter_names() -> list[str]:
|
|
18
|
+
return sorted(_REGISTRY)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_adapter(name: str) -> Adapter:
|
|
22
|
+
try:
|
|
23
|
+
module_name, class_name = _REGISTRY[name]
|
|
24
|
+
except KeyError as exc:
|
|
25
|
+
raise AdapterError(f"unknown adapter {name!r}; expected one of {adapter_names()}") from exc
|
|
26
|
+
module = import_module(module_name)
|
|
27
|
+
return getattr(module, class_name)()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["Adapter", "AdapterError", "NativeOutcome", "SystemSpec", "adapter_names", "get_adapter"]
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Adapter protocol for invoking one scanner on one prepared input.
|
|
2
|
+
|
|
3
|
+
An adapter runs the real product once, preserves its raw output, and translates the
|
|
4
|
+
native findings into normalized claims. It never receives labels, never decides whether
|
|
5
|
+
a claim is true, and never turns an execution failure into an empty successful scan.
|
|
6
|
+
|
|
7
|
+
Every scanner process an adapter starts goes through :func:`run_command`, and that is what lets
|
|
8
|
+
an execution backend hold it: while a backend is active (:func:`routed_through`, which the
|
|
9
|
+
backend's own ``activate()`` enters for the span of one scan), :func:`run_command` hands the
|
|
10
|
+
command to that backend instead of starting it on the host. With no backend active the command
|
|
11
|
+
runs locally exactly as it always has. A process an adapter starts any other way is outside every
|
|
12
|
+
backend, which is why only an adapter declaring :attr:`Adapter.oci_compatible` is run under one.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import signal
|
|
24
|
+
import stat
|
|
25
|
+
import subprocess
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Iterator
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
STATUSES = ("success", "partial", "unsupported", "error", "timeout")
|
|
32
|
+
# Environment variables passed to scanner subprocesses unless an adapter adds more.
|
|
33
|
+
BASE_ENV_PASSTHROUGH = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL")
|
|
34
|
+
|
|
35
|
+
# The execution backend every scanner process in this context is routed through, or None, which
|
|
36
|
+
# runs it on the host. Set only by :func:`routed_through`.
|
|
37
|
+
_ACTIVE_BACKEND: ContextVar[Any] = ContextVar("scaneval_execution_backend", default=None)
|
|
38
|
+
# How many backends are routing commands anywhere in this process. A context variable does not
|
|
39
|
+
# follow a thread started inside the routed block, so a command started on such a thread finds no
|
|
40
|
+
# backend in its context; this count is what makes that a refusal rather than a process started
|
|
41
|
+
# on the host while the scan it belongs to is recorded as contained.
|
|
42
|
+
_ROUTING = 0
|
|
43
|
+
_ROUTING_LOCK = threading.Lock()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AdapterError(RuntimeError):
|
|
47
|
+
"""The adapter could not be prepared or invoked as configured."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class SystemSpec:
|
|
52
|
+
"""One evaluated system configuration. ``config`` is recorded verbatim, so keep secrets out."""
|
|
53
|
+
|
|
54
|
+
system_id: str
|
|
55
|
+
adapter: str
|
|
56
|
+
config: dict = field(default_factory=dict)
|
|
57
|
+
model_id: str | None = None
|
|
58
|
+
model_revision: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class CommandResult:
|
|
63
|
+
argv: list[str]
|
|
64
|
+
exit_code: int | None
|
|
65
|
+
timed_out: bool
|
|
66
|
+
wall_seconds: float
|
|
67
|
+
stdout_path: Path
|
|
68
|
+
stderr_path: Path
|
|
69
|
+
cleanup_succeeded: bool = True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class NativeOutcome:
|
|
74
|
+
"""Everything one invocation produced, before the runner writes the bundle.
|
|
75
|
+
|
|
76
|
+
``omitted_paths`` is what the adapter observed the scanner did not examine of its input: paths the
|
|
77
|
+
scanner's own selection, filter, or limit left out, as the adapter saw them, in no particular
|
|
78
|
+
order. ``None`` is an adapter that reports no omissions, which says nothing about whether there
|
|
79
|
+
were any, and ``[]`` is one that looked and saw none. The runner records a list as the result's
|
|
80
|
+
``omitted_paths``, sorted and each path once, in a 2.1 result, and the scorer withholds quiet credit
|
|
81
|
+
from a control on a listed path. A list is only as true as the adapter's observation, and it is
|
|
82
|
+
meant for an outcome that carries claims the adapter read; it does not change ``status``, which
|
|
83
|
+
stays the adapter's own account of how the run ended.
|
|
84
|
+
|
|
85
|
+
``examined_nothing`` is the adapter's statement about the whole input, beside that list: ``True``
|
|
86
|
+
says it can show no part of the input to have been examined, and in a PR review that is every path
|
|
87
|
+
the change touches being one the scanner is not shown to have read; ``False`` says it saw some
|
|
88
|
+
examined; ``None`` says it does not report. The runner records a ``bool`` as the result's
|
|
89
|
+
``examined_nothing`` in a 2.1 result, and the scorer withholds quiet credit from every control of a
|
|
90
|
+
result that says ``True``, wherever the plan places it, because a scan that read none of what it
|
|
91
|
+
was given has reached no control. It is the adapter's claim like the list, and like the list it
|
|
92
|
+
never changes ``status``.
|
|
93
|
+
|
|
94
|
+
``processing`` is the adapter's statement about the system it ran rather than about what the scan
|
|
95
|
+
saw: ``"curated"`` says the system's configuration chose, before the run, which paths of the input
|
|
96
|
+
are assigned the scanner's analysis, ``"full"`` says it chose none, and ``None`` says the adapter
|
|
97
|
+
does not report it. The runner records a string as the result's ``processing`` in a 2.1 result,
|
|
98
|
+
whose contract admits only those two values. It is the selection marker that aggregation, comparison
|
|
99
|
+
and the benchmark score show beside the adapter version, and they refuse one that the system's
|
|
100
|
+
configuration and recorded adapter version do not give. It changes no status, claim, or quiet
|
|
101
|
+
credit.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
status: str
|
|
105
|
+
exit_code: int | None
|
|
106
|
+
command: list[str]
|
|
107
|
+
claims: list[dict] = field(default_factory=list)
|
|
108
|
+
ranking: str = "unranked"
|
|
109
|
+
bundles_resolved: bool = True
|
|
110
|
+
artifacts: list[dict] = field(default_factory=list) # {"id": ..., "path": Path}
|
|
111
|
+
tool_versions: dict[str, str] = field(default_factory=dict)
|
|
112
|
+
model_identity: dict | None = None
|
|
113
|
+
usage: dict = field(default_factory=dict)
|
|
114
|
+
error: dict | None = None
|
|
115
|
+
capture: dict = field(default_factory=dict)
|
|
116
|
+
notes: list[str] = field(default_factory=list)
|
|
117
|
+
trace_path: Path | None = None
|
|
118
|
+
capture_state: dict | None = None
|
|
119
|
+
timed_out: bool = False
|
|
120
|
+
omitted_paths: list[str] | None = None
|
|
121
|
+
examined_nothing: bool | None = None
|
|
122
|
+
processing: str | None = None
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
if self.status not in STATUSES:
|
|
126
|
+
raise AdapterError(f"invalid outcome status {self.status!r}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_env(extra_names: tuple[str, ...] = (), overrides: dict[str, str] | None = None) -> dict[str, str]:
|
|
130
|
+
env = {name: os.environ[name] for name in (*BASE_ENV_PASSTHROUGH, *extra_names) if name in os.environ}
|
|
131
|
+
env.update(overrides or {})
|
|
132
|
+
return env
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@contextmanager
|
|
136
|
+
def routed_through(backend: Any) -> Iterator[None]:
|
|
137
|
+
"""Hand every :func:`run_command` in this context to *backend* until the block exits.
|
|
138
|
+
|
|
139
|
+
*backend* provides ``run_command`` with this module's signature and returns a
|
|
140
|
+
:class:`CommandResult`. The routing follows the context, not the process: a thread started
|
|
141
|
+
inside the block does not carry it. While any such block is open, a :func:`run_command` whose
|
|
142
|
+
context carries no backend is therefore refused instead of run on the host, because the only
|
|
143
|
+
way to reach that state during a routed scan is a command the backend would never see. The
|
|
144
|
+
block does not start, stop, or clean up anything itself; that is the backend's job.
|
|
145
|
+
"""
|
|
146
|
+
global _ROUTING
|
|
147
|
+
token = _ACTIVE_BACKEND.set(backend)
|
|
148
|
+
with _ROUTING_LOCK:
|
|
149
|
+
_ROUTING += 1
|
|
150
|
+
try:
|
|
151
|
+
yield
|
|
152
|
+
finally:
|
|
153
|
+
with _ROUTING_LOCK:
|
|
154
|
+
_ROUTING -= 1
|
|
155
|
+
_ACTIVE_BACKEND.reset(token)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def active_backend() -> Any:
|
|
159
|
+
"""The backend :func:`run_command` hands commands to in this context, or ``None`` (the host)."""
|
|
160
|
+
return _ACTIVE_BACKEND.get()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _wait_for_unreaped_exit(pid: int, timeout_seconds: float) -> bool:
|
|
164
|
+
"""Observe *pid* exit without releasing its PID/process-group identity.
|
|
165
|
+
|
|
166
|
+
``waitpid`` (and :meth:`subprocess.Popen.wait`) reaps the leader. A later
|
|
167
|
+
``killpg(pid, ...)`` can then target an unrelated process group if the kernel
|
|
168
|
+
recycles that numeric ID between the probe and signal. ``WNOWAIT`` leaves the
|
|
169
|
+
exited leader waitable, so its PID and process-group ID cannot be recycled while
|
|
170
|
+
the caller drains descendants.
|
|
171
|
+
"""
|
|
172
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
173
|
+
flags = os.WEXITED | os.WNOHANG | os.WNOWAIT
|
|
174
|
+
while True:
|
|
175
|
+
try:
|
|
176
|
+
event = os.waitid(os.P_PID, pid, flags)
|
|
177
|
+
except InterruptedError:
|
|
178
|
+
continue
|
|
179
|
+
if event is not None:
|
|
180
|
+
return True
|
|
181
|
+
remaining = deadline - time.monotonic()
|
|
182
|
+
if remaining <= 0:
|
|
183
|
+
return False
|
|
184
|
+
time.sleep(min(0.01, remaining))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _signal_reserved_process_group(pid: int, signal_to_send: int) -> bool:
|
|
188
|
+
"""Signal a group whose unreaped leader still reserves *pid*.
|
|
189
|
+
|
|
190
|
+
Some supported kernels report ``EPERM`` for a group containing only an exited
|
|
191
|
+
zombie leader. The definitive check is therefore the observation after the
|
|
192
|
+
leader is reaped; this return value only controls the TERM grace period.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
os.killpg(pid, signal_to_send)
|
|
196
|
+
except OSError:
|
|
197
|
+
return False
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _process_group_absent_after_reap(pid: int, timeout_seconds: float) -> bool:
|
|
202
|
+
"""Boundedly observe group disappearance after reaping, without signalling it."""
|
|
203
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
os.killpg(pid, 0)
|
|
207
|
+
except ProcessLookupError:
|
|
208
|
+
return True
|
|
209
|
+
except OSError:
|
|
210
|
+
# Permission denial or any other result is not proof of absence. It may
|
|
211
|
+
# still clear as a killed descendant is reaped, so keep observing briefly.
|
|
212
|
+
pass
|
|
213
|
+
remaining = deadline - time.monotonic()
|
|
214
|
+
if remaining <= 0:
|
|
215
|
+
return False
|
|
216
|
+
time.sleep(min(0.01, remaining))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _feed_stdin_while_leader_is_unreaped(process: subprocess.Popen, data: bytes, deadline: float) -> bool:
|
|
220
|
+
"""Boundedly feed stdin without polling or reaping the process-group leader."""
|
|
221
|
+
if process.stdin is None:
|
|
222
|
+
return True
|
|
223
|
+
descriptor = process.stdin.fileno()
|
|
224
|
+
offset = 0
|
|
225
|
+
try:
|
|
226
|
+
os.set_blocking(descriptor, False)
|
|
227
|
+
while offset < len(data):
|
|
228
|
+
try:
|
|
229
|
+
written = os.write(descriptor, data[offset:])
|
|
230
|
+
except BlockingIOError:
|
|
231
|
+
written = 0
|
|
232
|
+
except BrokenPipeError:
|
|
233
|
+
return True
|
|
234
|
+
if written:
|
|
235
|
+
offset += written
|
|
236
|
+
continue
|
|
237
|
+
try:
|
|
238
|
+
event = os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
|
|
239
|
+
except InterruptedError:
|
|
240
|
+
continue
|
|
241
|
+
if event is not None:
|
|
242
|
+
return True
|
|
243
|
+
remaining = deadline - time.monotonic()
|
|
244
|
+
if remaining <= 0:
|
|
245
|
+
return False
|
|
246
|
+
time.sleep(min(0.01, remaining))
|
|
247
|
+
return True
|
|
248
|
+
finally:
|
|
249
|
+
process.stdin.close()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_command(
|
|
253
|
+
argv: list[str],
|
|
254
|
+
*,
|
|
255
|
+
cwd: Path,
|
|
256
|
+
timeout_seconds: float,
|
|
257
|
+
env: dict[str, str],
|
|
258
|
+
stdout_path: Path,
|
|
259
|
+
stderr_path: Path,
|
|
260
|
+
stdin_text: str | None = None,
|
|
261
|
+
ensure_group_empty: bool = False,
|
|
262
|
+
) -> CommandResult:
|
|
263
|
+
"""Run one process with a hard timeout, streaming stdout/stderr to files.
|
|
264
|
+
|
|
265
|
+
On timeout the whole process group is killed and ``timed_out`` is reported; the
|
|
266
|
+
partial stdout/stderr files are kept as raw artifacts.
|
|
267
|
+
|
|
268
|
+
Inside :func:`routed_through` the command goes to the active backend instead, with the same
|
|
269
|
+
arguments, and what that backend returns is returned; the backend decides where the process
|
|
270
|
+
runs and states its own guarantees. A command started outside every routed context while one
|
|
271
|
+
is open elsewhere in the process, which is a thread the routed scan started, is refused with
|
|
272
|
+
:class:`AdapterError` and never started. Everything below that applies only when no backend
|
|
273
|
+
is involved, and it is the same code path this function has always had.
|
|
274
|
+
"""
|
|
275
|
+
backend = _ACTIVE_BACKEND.get()
|
|
276
|
+
if backend is not None:
|
|
277
|
+
if ensure_group_empty:
|
|
278
|
+
raise AdapterError("process-group emptiness checks are available only on the local execution seam")
|
|
279
|
+
return backend.run_command(argv, cwd=cwd, timeout_seconds=timeout_seconds, env=env,
|
|
280
|
+
stdout_path=stdout_path, stderr_path=stderr_path, stdin_text=stdin_text)
|
|
281
|
+
if _ROUTING:
|
|
282
|
+
raise AdapterError(
|
|
283
|
+
f"could not start {argv[0] if argv else 'a command'}: an execution backend is holding a "
|
|
284
|
+
"scan in this process and this command was started outside it, from a thread that does "
|
|
285
|
+
"not carry the backend; it was not run on the host")
|
|
286
|
+
if ensure_group_empty:
|
|
287
|
+
waitid_names = ("waitid", "P_PID", "WEXITED", "WNOHANG", "WNOWAIT", "killpg")
|
|
288
|
+
if any(not hasattr(os, name) for name in waitid_names):
|
|
289
|
+
raise AdapterError("process-group emptiness checks require POSIX waitid(WNOWAIT) support")
|
|
290
|
+
started = time.monotonic()
|
|
291
|
+
with stdout_path.open("wb") as out, stderr_path.open("wb") as err:
|
|
292
|
+
try:
|
|
293
|
+
process = subprocess.Popen(
|
|
294
|
+
argv, cwd=str(cwd), env=env, stdout=out, stderr=err,
|
|
295
|
+
stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
|
|
296
|
+
start_new_session=True,
|
|
297
|
+
)
|
|
298
|
+
except OSError as exc:
|
|
299
|
+
raise AdapterError(f"could not start {argv[0]}: {exc}") from exc
|
|
300
|
+
timed_out = False
|
|
301
|
+
cleanup_succeeded = True
|
|
302
|
+
if ensure_group_empty:
|
|
303
|
+
# Do not call poll(), communicate(), or wait() until the process group has been
|
|
304
|
+
# drained: all three can reap the leader and free its numeric PGID for reuse.
|
|
305
|
+
try:
|
|
306
|
+
command_deadline = started + max(0.0, timeout_seconds)
|
|
307
|
+
input_delivered = _feed_stdin_while_leader_is_unreaped(
|
|
308
|
+
process, stdin_text.encode("utf-8") if stdin_text is not None else b"", command_deadline)
|
|
309
|
+
leader_exited = input_delivered and _wait_for_unreaped_exit(
|
|
310
|
+
process.pid, max(0.0, command_deadline - time.monotonic()))
|
|
311
|
+
if not leader_exited:
|
|
312
|
+
timed_out = True
|
|
313
|
+
_signal_reserved_process_group(process.pid, signal.SIGKILL)
|
|
314
|
+
leader_exited = _wait_for_unreaped_exit(process.pid, 5)
|
|
315
|
+
if not leader_exited:
|
|
316
|
+
raise AdapterError("scanner leader did not exit within bounded process-group cleanup")
|
|
317
|
+
|
|
318
|
+
# The exited leader is deliberately still waitable here. Its PID/PGID therefore
|
|
319
|
+
# cannot be recycled between either signal and the eventual reap.
|
|
320
|
+
if _signal_reserved_process_group(process.pid, signal.SIGTERM):
|
|
321
|
+
time.sleep(0.25)
|
|
322
|
+
_signal_reserved_process_group(process.pid, signal.SIGKILL)
|
|
323
|
+
except ChildProcessError as exc:
|
|
324
|
+
# An unexpected external waiter has already freed the group identity. Never
|
|
325
|
+
# signal that numeric PGID after this point.
|
|
326
|
+
raise AdapterError("scanner leader was reaped before process-group cleanup") from exc
|
|
327
|
+
except BaseException as original:
|
|
328
|
+
# The code above has not reaped the leader, so these group signals cannot hit a
|
|
329
|
+
# recycled PGID. Preserve the triggering exception after bounded best-effort
|
|
330
|
+
# termination and reaping.
|
|
331
|
+
group_stopped = False
|
|
332
|
+
for _ in range(3):
|
|
333
|
+
try:
|
|
334
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
335
|
+
group_stopped = True
|
|
336
|
+
break
|
|
337
|
+
except ProcessLookupError:
|
|
338
|
+
group_stopped = True
|
|
339
|
+
break
|
|
340
|
+
except BaseException:
|
|
341
|
+
pass
|
|
342
|
+
if not group_stopped:
|
|
343
|
+
try:
|
|
344
|
+
original.add_note("scanner process-group cleanup failed after three attempts")
|
|
345
|
+
except BaseException:
|
|
346
|
+
pass
|
|
347
|
+
try:
|
|
348
|
+
process.kill()
|
|
349
|
+
except BaseException:
|
|
350
|
+
pass
|
|
351
|
+
try:
|
|
352
|
+
exited_for_reap = _wait_for_unreaped_exit(process.pid, 5)
|
|
353
|
+
except BaseException:
|
|
354
|
+
exited_for_reap = False
|
|
355
|
+
if exited_for_reap:
|
|
356
|
+
deadline = time.monotonic() + 5
|
|
357
|
+
for _ in range(3):
|
|
358
|
+
try:
|
|
359
|
+
process.wait(timeout=max(0, deadline - time.monotonic()))
|
|
360
|
+
break
|
|
361
|
+
except BaseException:
|
|
362
|
+
pass
|
|
363
|
+
if process.returncode is None:
|
|
364
|
+
try:
|
|
365
|
+
original.add_note("scanner could not be reaped within bounded cleanup attempts")
|
|
366
|
+
except BaseException:
|
|
367
|
+
pass
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
# From this point onward the implementation may reap and observe, but must never
|
|
371
|
+
# signal the numeric PGID again. A surviving group makes the command non-success.
|
|
372
|
+
process.wait(timeout=5)
|
|
373
|
+
cleanup_succeeded = _process_group_absent_after_reap(process.pid, 1.0)
|
|
374
|
+
else:
|
|
375
|
+
try:
|
|
376
|
+
process.communicate(input=stdin_text.encode("utf-8") if stdin_text is not None else None,
|
|
377
|
+
timeout=timeout_seconds)
|
|
378
|
+
except subprocess.TimeoutExpired:
|
|
379
|
+
timed_out = True
|
|
380
|
+
try:
|
|
381
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
382
|
+
except ProcessLookupError:
|
|
383
|
+
pass
|
|
384
|
+
process.wait(timeout=5)
|
|
385
|
+
except BaseException as original:
|
|
386
|
+
# Retry interrupted cleanup without replacing the triggering exception. The
|
|
387
|
+
# attempts and total wait are bounded even if another signal handler raises.
|
|
388
|
+
group_stopped = False
|
|
389
|
+
for _ in range(3):
|
|
390
|
+
try:
|
|
391
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
392
|
+
group_stopped = True
|
|
393
|
+
break
|
|
394
|
+
except ProcessLookupError:
|
|
395
|
+
group_stopped = True
|
|
396
|
+
break
|
|
397
|
+
except BaseException:
|
|
398
|
+
pass
|
|
399
|
+
if not group_stopped:
|
|
400
|
+
try:
|
|
401
|
+
original.add_note("scanner process-group cleanup failed after three attempts")
|
|
402
|
+
except BaseException:
|
|
403
|
+
pass
|
|
404
|
+
# A group operation can fail independently of direct-child termination.
|
|
405
|
+
try:
|
|
406
|
+
process.kill()
|
|
407
|
+
except BaseException:
|
|
408
|
+
pass
|
|
409
|
+
deadline = time.monotonic() + 5
|
|
410
|
+
reaped = False
|
|
411
|
+
for _ in range(3):
|
|
412
|
+
try:
|
|
413
|
+
process.wait(timeout=max(0, deadline - time.monotonic()))
|
|
414
|
+
reaped = True
|
|
415
|
+
break
|
|
416
|
+
except BaseException:
|
|
417
|
+
pass
|
|
418
|
+
if not reaped:
|
|
419
|
+
try:
|
|
420
|
+
original.add_note("scanner could not be reaped within bounded cleanup attempts")
|
|
421
|
+
except BaseException:
|
|
422
|
+
pass
|
|
423
|
+
if process.stdin is not None:
|
|
424
|
+
try:
|
|
425
|
+
process.stdin.close()
|
|
426
|
+
except BaseException:
|
|
427
|
+
pass
|
|
428
|
+
raise
|
|
429
|
+
wall = time.monotonic() - started
|
|
430
|
+
return CommandResult(list(argv), None if timed_out else process.returncode, timed_out, wall,
|
|
431
|
+
stdout_path, stderr_path, cleanup_succeeded)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def tail_text(path: Path, limit: int = 2000) -> str:
|
|
435
|
+
"""The last *limit* bytes of *path* as text, or ``""`` when it cannot be read. Never raises.
|
|
436
|
+
|
|
437
|
+
The file is one a scanner wrote, and the scanner can replace it after its own descriptor
|
|
438
|
+
closed, so it is read the way :func:`~scaneval.execution.read_regular_file` reads:
|
|
439
|
+
``O_NOFOLLOW`` refuses a symbolic link, ``O_NONBLOCK`` makes a named pipe fail at once instead
|
|
440
|
+
of waiting for a writer that never comes, and :func:`os.fstat` on the descriptor refuses
|
|
441
|
+
anything that is not a regular file. The read seeks to the last *limit* bytes, so the size of
|
|
442
|
+
the file does not decide how much memory this takes. A regular file gives exactly the text it
|
|
443
|
+
always gave. What changed is that a link planted where stderr belongs is no longer followed to
|
|
444
|
+
a file of the scanner's choosing, whose tail every failure message would then have quoted into
|
|
445
|
+
the record; under an isolating backend that was a host file carried across the boundary.
|
|
446
|
+
"""
|
|
447
|
+
try:
|
|
448
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0))
|
|
449
|
+
except OSError:
|
|
450
|
+
return ""
|
|
451
|
+
try:
|
|
452
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
453
|
+
status = os.fstat(stream.fileno())
|
|
454
|
+
if not stat.S_ISREG(status.st_mode):
|
|
455
|
+
return ""
|
|
456
|
+
if status.st_size > limit:
|
|
457
|
+
stream.seek(status.st_size - limit)
|
|
458
|
+
return stream.read(limit).decode("utf-8", errors="replace")
|
|
459
|
+
except (OSError, ValueError):
|
|
460
|
+
return ""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class Adapter(ABC):
|
|
464
|
+
"""Invoke one system once on one prepared input and normalize its native output."""
|
|
465
|
+
|
|
466
|
+
name: str = "abstract"
|
|
467
|
+
adapter_version: str = "0.0.0"
|
|
468
|
+
requires_git: bool = False
|
|
469
|
+
supported_languages: frozenset[str] = frozenset()
|
|
470
|
+
# The scan modes this adapter carries out: ``full`` scans the whole exported tree, ``pr`` reviews
|
|
471
|
+
# the change between the base and head commits its request names. The runner never calls scan()
|
|
472
|
+
# for an input whose mode is not listed: it records the invocation as unsupported, which stays in
|
|
473
|
+
# every denominator, and a full scan of the head is never run in place of a PR review. An adapter
|
|
474
|
+
# declaring ``pr`` promises to read ``request["input"]["pr"]`` and to review that change, and to
|
|
475
|
+
# refuse a request it cannot honour rather than scan something else.
|
|
476
|
+
scan_modes: frozenset[str] = frozenset({"full"})
|
|
477
|
+
env_passthrough: tuple[str, ...] = ()
|
|
478
|
+
# Top-level scanner-owned paths excluded from the immutable input comparison. By default the
|
|
479
|
+
# runner also copies each one under raw/harness-state. An adapter that independently performs a
|
|
480
|
+
# bounded, no-follow evidence import may set ``state_capture_dirs`` to a subset (including the
|
|
481
|
+
# empty tuple) so the generic unbounded tree copy cannot replace its already-preserved outcome.
|
|
482
|
+
state_dirs: tuple[str, ...] = ()
|
|
483
|
+
state_capture_dirs: tuple[str, ...] | None = None
|
|
484
|
+
# Whether this adapter may run under the ``oci`` execution backend. True is three promises:
|
|
485
|
+
# every scanner process it starts goes through :func:`run_command` from the thread that called
|
|
486
|
+
# ``scan()``; its scanner exists in a Linux image and it takes the in-image tool when a backend
|
|
487
|
+
# is active; and every host-side read of what a command wrote goes through
|
|
488
|
+
# :func:`~scaneval.execution.read_regular_file` or :func:`tail_text`, because a container can
|
|
489
|
+
# replace any path under its writable mounts with a link to a host file, and a read that
|
|
490
|
+
# followed it would carry that file across the boundary. An adapter that shells out any other
|
|
491
|
+
# way, whose tools exist only as host installs, or whose reads follow links would run partly
|
|
492
|
+
# outside the boundary, so the default is False and the runner refuses such a system under
|
|
493
|
+
# ``oci`` with a recorded reason instead of running it.
|
|
494
|
+
oci_compatible: bool = False
|
|
495
|
+
|
|
496
|
+
def condition_controls(self, spec: SystemSpec, preparation: dict) -> dict[str, Any]:
|
|
497
|
+
"""Machine-readable retrieval controls applied by this adapter, recorded beside the run.
|
|
498
|
+
|
|
499
|
+
The default supplies no evidence. An implementation describes actual adapter controls,
|
|
500
|
+
not the operator's policy or a scanner's self-report. ``inference`` is ``offline`` or
|
|
501
|
+
``remote``; ``tools`` maps browser, web_search, remote_fetch, package_install, and
|
|
502
|
+
scanner_update to disabled/enabled/unknown. Provider capability, the requested control,
|
|
503
|
+
and an applicable provider attestation remain separate in ``provider``. Artifact paths
|
|
504
|
+
in an attestation are bundle-relative and must name saved bytes.
|
|
505
|
+
"""
|
|
506
|
+
return {}
|
|
507
|
+
|
|
508
|
+
def prepare(self, spec: SystemSpec, cache_root: Path) -> dict[str, Any]:
|
|
509
|
+
"""Separately recorded preparation phase (rulesets, dependencies). Default: nothing."""
|
|
510
|
+
return {}
|
|
511
|
+
|
|
512
|
+
def runtime_mounts(self, spec: SystemSpec, preparation: dict) -> tuple[str, ...]:
|
|
513
|
+
"""Host paths the scanner reads at run time besides its workspace. Default: none.
|
|
514
|
+
|
|
515
|
+
An isolating backend mounts each of them read-only at its own path, so a path the adapter
|
|
516
|
+
hands its scanner means the same file inside the boundary as outside it. Only what the
|
|
517
|
+
scan needs belongs here, and never a directory holding evaluator material: whatever is
|
|
518
|
+
listed becomes readable to the scanner.
|
|
519
|
+
"""
|
|
520
|
+
return ()
|
|
521
|
+
|
|
522
|
+
@abstractmethod
|
|
523
|
+
def scan(
|
|
524
|
+
self,
|
|
525
|
+
*,
|
|
526
|
+
request: dict,
|
|
527
|
+
source_dir: Path,
|
|
528
|
+
raw_dir: Path,
|
|
529
|
+
spec: SystemSpec,
|
|
530
|
+
preparation: dict,
|
|
531
|
+
timeout_seconds: float,
|
|
532
|
+
trace_mode: str,
|
|
533
|
+
trace_dir: Path | None,
|
|
534
|
+
) -> NativeOutcome:
|
|
535
|
+
"""Run the system once. Must preserve raw output and report an explicit status."""
|