mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 05:19:39 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
130c91bb1f | ||
|
|
87f2d783d6 | ||
|
|
956a26715b | ||
|
|
0f8db1fca8 | ||
|
|
4bf7541599 | ||
|
|
d8c00a8f4e | ||
|
|
c5854e7be2 | ||
|
|
442b3c3df5 | ||
|
|
710fe7b3b6 | ||
|
|
b68cdb8edc | ||
|
|
429f6218f5 | ||
|
|
f7a32ae62d |
No files matched your search
@@ -94,6 +94,18 @@ strix --target ./app-directory
|
||||
> [!NOTE]
|
||||
> First run automatically pulls the sandbox Docker image. Results are saved to `strix_runs/<run-name>`
|
||||
|
||||
### Updating
|
||||
|
||||
```bash
|
||||
# Self-update the standalone binary to the latest release
|
||||
strix --update
|
||||
|
||||
# ...or re-run the installer
|
||||
curl -sSL https://strix.ai/install | bash
|
||||
```
|
||||
|
||||
Installed with pipx, uv, or pip? `strix --update` prints the matching upgrade command instead (`pipx upgrade strix-agent`, `uv tool upgrade strix-agent`, `pip install --upgrade strix-agent`). Strix also shows a notice with the right command when a new version is available.
|
||||
|
||||
---
|
||||
|
||||
## Ways to Run Strix
|
||||
|
||||
@@ -8,7 +8,7 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -203,7 +203,7 @@ async def run_strix_scan(
|
||||
scan_id: str | None = None,
|
||||
image: str,
|
||||
local_sources: list[dict[str, Any]] | None = None,
|
||||
extra_files: list[dict[str, Any]] | None = None,
|
||||
extra_files: list[dict[str, Any]] | Awaitable[list[dict[str, Any]] | None] | None = None,
|
||||
coordinator: AgentCoordinator | None = None,
|
||||
interactive: bool = False,
|
||||
max_turns: int = DEFAULT_MAX_TURNS,
|
||||
|
||||
@@ -66,7 +66,7 @@ func panelFocus(panel sidebarPanel) (focusMode, bool) {
|
||||
|
||||
func (m Model) sidebarPanels() []panelRect {
|
||||
statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights()
|
||||
top := m.viewerHeight()
|
||||
top := m.sidebarTopHeight()
|
||||
rects := []panelRect{{panelAgents, top, agentHeight}}
|
||||
top += agentHeight
|
||||
if vulnHeight > 0 {
|
||||
@@ -83,7 +83,7 @@ func (m Model) sidebarPanels() []panelRect {
|
||||
|
||||
func (m Model) sidebarGap() int {
|
||||
statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights()
|
||||
return max(0, m.height-m.viewerHeight()-agentHeight-vulnHeight-mcpHeight-statsHeight)
|
||||
return max(0, m.height-m.sidebarTopHeight()-agentHeight-vulnHeight-mcpHeight-statsHeight)
|
||||
}
|
||||
|
||||
func (m Model) panelAt(y int) (panelRect, bool) {
|
||||
|
||||
@@ -355,3 +355,77 @@ func TestLonePanelHasNoControls(t *testing.T) {
|
||||
t.Fatalf("controls should return once a second panel exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetsBoxSitsUnderViewer(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
m.snapshot.Targets = []string{"https://example.com", "./app"}
|
||||
m.snapshot.TargetCount = 2
|
||||
m.resizeViewport()
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n")
|
||||
if len(rows) != m.height {
|
||||
t.Fatalf("sidebar renders %d rows, want %d", len(rows), m.height)
|
||||
}
|
||||
top := m.viewerHeight()
|
||||
for i, want := range []string{"Targets 2", "▸ https://example.com", "▸ ./app"} {
|
||||
if !strings.Contains(rows[top+1+i], want) {
|
||||
t.Fatalf("row %d missing %q:\n%s", top+1+i, want, strings.Join(rows, "\n"))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rows[panelRectOf(t, m, panelAgents).top+1], "Agents") {
|
||||
t.Fatalf("agents panel not where its rect says:\n%s", strings.Join(rows, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetsBoxDropsOnShortTerminal(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
m.height = 19
|
||||
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
|
||||
m.snapshot.TargetCount = 5
|
||||
m.resizeViewport()
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got > m.height {
|
||||
t.Fatalf("sidebar renders %d rows on a %d-row terminal", got, m.height)
|
||||
}
|
||||
}
|
||||
|
||||
// The targets box only takes rows the agent tree would otherwise grow into, so
|
||||
// the stats panel keeps the Caido URL and the other panels keep their heights.
|
||||
func TestTargetsBoxNeverSqueezesPanels(t *testing.T) {
|
||||
for height := 19; height <= 40; height++ {
|
||||
m := panelsModel(t)
|
||||
m.height = height
|
||||
m.snapshot.Usage = map[string]any{"total_tokens": 1234, "cost": 0.5}
|
||||
m.snapshot.CaidoURL = "http://127.0.0.1:8080"
|
||||
m.resizeViewport()
|
||||
stats, vuln, mcp, _ := m.sidebarHeights()
|
||||
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
|
||||
m.snapshot.TargetCount = 5
|
||||
m.resizeViewport()
|
||||
gotStats, gotVuln, gotMcp, _ := m.sidebarHeights()
|
||||
if gotStats != stats || gotVuln != vuln || gotMcp != mcp {
|
||||
t.Fatalf("height %d: targets squeezed panels: stats %d->%d findings %d->%d mcp %d->%d",
|
||||
height, stats, gotStats, vuln, gotVuln, mcp, gotMcp)
|
||||
}
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got != m.height {
|
||||
t.Fatalf("height %d: sidebar renders %d rows", height, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetsViewCountsTheTargetsItCannotShow(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
|
||||
m.snapshot.TargetCount = 5
|
||||
for rows, want := range map[int]string{
|
||||
2: "Targets 5\n+5 more",
|
||||
3: "Targets 5\n▸ a\n+4 more",
|
||||
6: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more",
|
||||
9: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more",
|
||||
} {
|
||||
if got := ansi.Strip(m.targetsView(20, rows)); got != want {
|
||||
t.Fatalf("%d rows:\n%s\nwant:\n%s", rows, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,7 +421,7 @@ func (m Model) setupComposer(width int) string {
|
||||
inner := max(1, width-4)
|
||||
body := m.highlightInputSelection(m.input.View())
|
||||
body += "\n\n" + m.setupSummaryView(inner)
|
||||
if targets := m.setupTargetsView(inner); targets != "" {
|
||||
if targets := m.targetsView(inner, maxTargetRows); targets != "" {
|
||||
body += "\n" + targets
|
||||
}
|
||||
return lipgloss.NewStyle().Width(max(1, width-2)).Padding(0, 1).
|
||||
@@ -454,15 +454,22 @@ func (m Model) setupSummaryView(width int) string {
|
||||
return truncate(strings.Join(chips, render.Dim().Render(" · ")), max(1, width))
|
||||
}
|
||||
|
||||
// setupTargetsView lists what the scan is pointed at, once anything is queued.
|
||||
func (m Model) setupTargetsView(width int) string {
|
||||
if len(m.snapshot.Targets) == 0 {
|
||||
// maxTargetRows fits the header, four targets and the count of the rest.
|
||||
const maxTargetRows = 6
|
||||
|
||||
// targetsView lists what the scan is pointed at, once anything is queued, in at
|
||||
// most maxRows rows. Targets that do not fit are counted on the last row.
|
||||
func (m Model) targetsView(width, maxRows int) string {
|
||||
if len(m.snapshot.Targets) == 0 || maxRows < 2 {
|
||||
return ""
|
||||
}
|
||||
const visible = 4
|
||||
total := max(m.snapshot.TargetCount, len(m.snapshot.Targets))
|
||||
visible := min(len(m.snapshot.Targets), maxTargetRows-2, maxRows-1)
|
||||
if visible < total {
|
||||
visible = min(visible, maxRows-2)
|
||||
}
|
||||
rows := []string{render.Bold(green).Render("Targets") + render.Dim().Render(fmt.Sprintf(" %d", total))}
|
||||
for _, target := range m.snapshot.Targets[:min(visible, len(m.snapshot.Targets))] {
|
||||
for _, target := range m.snapshot.Targets[:visible] {
|
||||
rows = append(rows, render.Col(dim).Render("▸ ")+render.Col(white).Render(truncate(target, max(1, width-2))))
|
||||
}
|
||||
if hidden := total - visible; hidden > 0 {
|
||||
|
||||
@@ -542,6 +542,15 @@ func (m Model) sidebarView(width, height int) string {
|
||||
BorderForeground(dark).
|
||||
Padding(0, 1).
|
||||
Render(m.viewerBox(width - 4))}
|
||||
if targetsHeight := m.targetsBoxHeight(); targetsHeight > 0 {
|
||||
parts = append(parts, lipgloss.NewStyle().
|
||||
Width(width-2).
|
||||
Height(targetsHeight-2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(dark).
|
||||
Padding(0, 1).
|
||||
Render(m.targetsView(width-4, targetsHeight-2)))
|
||||
}
|
||||
agents := ""
|
||||
if agentHeight > 1 {
|
||||
agentRows := max(1, agentHeight-4)
|
||||
@@ -592,6 +601,13 @@ func (m Model) sidebarView(width, height int) string {
|
||||
}
|
||||
|
||||
func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight int) {
|
||||
_, statsHeight, vulnHeight, mcpHeight, agentHeight = m.sidebarLayout()
|
||||
return
|
||||
}
|
||||
|
||||
// sidebarLayout sizes the targets box and the panels. The targets box only
|
||||
// takes rows no panel needs, so it never squeezes the panels below it.
|
||||
func (m Model) sidebarLayout() (targetsHeight, statsHeight, vulnHeight, mcpHeight, agentHeight int) {
|
||||
// Measure the stats panel the way its box will render it: a long model name
|
||||
// wraps inside the sidebar, and counting only its newlines would size the
|
||||
// box short and push the whole frame past the bottom of the terminal.
|
||||
@@ -628,6 +644,14 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight
|
||||
}
|
||||
}
|
||||
spare := max(0, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight)
|
||||
// The box needs its border and two rows: the header and a target or the
|
||||
// count of hidden ones. A zoomed panel keeps the whole sidebar.
|
||||
if targets := m.targetsView(m.sidebarInnerWidth(), maxTargetRows); targets != "" && m.zoomedPanel == panelNone {
|
||||
if fit := min(lipgloss.Height(targets)+2, spare); fit >= 4 {
|
||||
targetsHeight = fit
|
||||
spare -= fit
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case m.zoomedPanel == panelFindings && vulnHeight > 1:
|
||||
vulnHeight += spare
|
||||
@@ -643,6 +667,17 @@ func (m Model) viewerHeight() int {
|
||||
return strings.Count(m.viewerView(m.viewerContentWidth()), "\n") + 3
|
||||
}
|
||||
|
||||
// sidebarTopHeight is the rows above the panels: the viewer box and, when the
|
||||
// run has targets, the targets box under it.
|
||||
func (m Model) sidebarTopHeight() int {
|
||||
return m.viewerHeight() + m.targetsBoxHeight()
|
||||
}
|
||||
|
||||
func (m Model) targetsBoxHeight() int {
|
||||
targetsHeight, _, _, _, _ := m.sidebarLayout()
|
||||
return targetsHeight
|
||||
}
|
||||
|
||||
func (m Model) sidebarInnerWidth() int {
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
if sidebarWidth == 0 {
|
||||
|
||||
@@ -124,6 +124,7 @@ _SKILL_PHRASINGS: dict[str, tuple[str, ...]] = {
|
||||
"prototype_pollution": ("prototype pollution",),
|
||||
"race_conditions": ("race condition", "toctou"),
|
||||
"rce": ("rce", "remote code execution", "code execution", "command injection"),
|
||||
"redos": ("redos", "regex denial", "regular expression denial", "catastrophic backtracking"),
|
||||
"semantic_confusion": (
|
||||
"semantic confusion",
|
||||
"parser differential",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.sandbox.session.manager import Instrumentation
|
||||
from agents.sandbox.session.sandbox_session import SandboxSession
|
||||
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import io
|
||||
from collections.abc import Sequence
|
||||
|
||||
from agents.sandbox.entries import BaseEntry
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.materialization import MaterializedFile
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.types import ExecResult, ExposedPortEndpoint, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PendingSessionState(SandboxSessionState):
|
||||
type: str = "pending"
|
||||
|
||||
|
||||
class DeferredSandboxSession(SandboxSession):
|
||||
def __init__(
|
||||
self,
|
||||
bring_up: asyncio.Task[tuple[Any, BaseSandboxSession]],
|
||||
*,
|
||||
manifest: Manifest,
|
||||
exposed_ports: tuple[int, ...] = (),
|
||||
) -> None:
|
||||
self._bring_up = bring_up
|
||||
self._pending_state: SandboxSessionState = _PendingSessionState(
|
||||
snapshot=NoopSnapshot(id="pending"),
|
||||
manifest=manifest,
|
||||
exposed_ports=exposed_ports,
|
||||
)
|
||||
self._pending_limits: Any = None
|
||||
self._pending_archive_limits: Any = None
|
||||
self._resolved: BaseSandboxSession | None = None
|
||||
self._client: Any = None
|
||||
self._instrumentation: Instrumentation = Instrumentation()
|
||||
self._seq = 0
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self._resolved is None
|
||||
|
||||
@property
|
||||
def bring_up(self) -> asyncio.Task[tuple[Any, BaseSandboxSession]]:
|
||||
return self._bring_up
|
||||
|
||||
async def resolve(self) -> BaseSandboxSession:
|
||||
if self._resolved is not None:
|
||||
return self._resolved
|
||||
client, session = await asyncio.shield(self._bring_up)
|
||||
self._adopt(client, session)
|
||||
return session
|
||||
|
||||
def _adopt(self, client: Any, session: BaseSandboxSession) -> None:
|
||||
if self._resolved is not None:
|
||||
return
|
||||
self._client = client
|
||||
self._resolved = session
|
||||
self._inner = session
|
||||
if self._pending_limits is not None:
|
||||
session._set_concurrency_limits(self._pending_limits)
|
||||
if self._pending_archive_limits is not None:
|
||||
session._set_archive_limits(self._pending_archive_limits)
|
||||
if self._pending_state.manifest != session.state.manifest:
|
||||
session.state = session.state.model_copy(
|
||||
update={"manifest": self._pending_state.manifest}
|
||||
)
|
||||
|
||||
@property
|
||||
def client(self) -> Any:
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def state(self) -> SandboxSessionState:
|
||||
if self._resolved is None:
|
||||
return self._pending_state
|
||||
return self._resolved.state
|
||||
|
||||
@state.setter
|
||||
def state(self, value: SandboxSessionState) -> None:
|
||||
if self._resolved is None:
|
||||
self._pending_state = value
|
||||
else:
|
||||
self._resolved.state = value
|
||||
|
||||
def _set_concurrency_limits(self, limits: Any) -> None:
|
||||
if self._resolved is None:
|
||||
self._pending_limits = limits
|
||||
else:
|
||||
self._resolved._set_concurrency_limits(limits)
|
||||
|
||||
def _set_archive_limits(self, limits: Any) -> None:
|
||||
if self._resolved is None:
|
||||
self._pending_archive_limits = limits
|
||||
else:
|
||||
self._resolved._set_archive_limits(limits)
|
||||
|
||||
def _manifest_base_dir(self) -> Path:
|
||||
if self._resolved is None:
|
||||
return Path.cwd()
|
||||
return self._resolved._manifest_base_dir()
|
||||
|
||||
def normalize_path(self, path: Path | str, *, for_write: bool = False) -> Path:
|
||||
if self._resolved is None:
|
||||
return Path(path)
|
||||
return self._resolved.normalize_path(path, for_write=for_write)
|
||||
|
||||
def register_persist_workspace_skip_path(self, path: Path | str) -> Path:
|
||||
if self._resolved is None:
|
||||
return Path(path)
|
||||
return self._resolved.register_persist_workspace_skip_path(path)
|
||||
|
||||
def supports_pty(self) -> bool:
|
||||
return self._resolved.supports_pty() if self._resolved is not None else False
|
||||
|
||||
async def running(self) -> bool:
|
||||
if self._resolved is None:
|
||||
if not self._bring_up.done():
|
||||
return True
|
||||
return not self._bring_up.cancelled() and self._bring_up.exception() is None
|
||||
return await self._resolved.running()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._resolved is None:
|
||||
return
|
||||
await self._resolved.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._resolved is None:
|
||||
return
|
||||
await self._resolved.stop()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._resolved is None:
|
||||
return
|
||||
await self._resolved.shutdown()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._resolved is None:
|
||||
return
|
||||
await self._resolved.aclose()
|
||||
|
||||
async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None:
|
||||
session = await self.resolve()
|
||||
await session._validate_manifest_application(only_ephemeral=only_ephemeral)
|
||||
|
||||
async def apply_manifest(self, *, only_ephemeral: bool = False) -> Any:
|
||||
session = await self.resolve()
|
||||
return await session.apply_manifest(only_ephemeral=only_ephemeral)
|
||||
|
||||
async def _apply_entry_batch(
|
||||
self,
|
||||
entries: Sequence[tuple[Path, BaseEntry]],
|
||||
*,
|
||||
base_dir: Path,
|
||||
) -> list[MaterializedFile]:
|
||||
del base_dir
|
||||
session = await self.resolve()
|
||||
return await session._apply_entry_batch(entries, base_dir=session._manifest_base_dir())
|
||||
|
||||
async def exec(self, *command: str | Path, **kwargs: Any) -> ExecResult:
|
||||
await self.resolve()
|
||||
return await super().exec(*command, **kwargs)
|
||||
|
||||
async def ls(self, *args: Any, **kwargs: Any) -> Any:
|
||||
await self.resolve()
|
||||
return await super().ls(*args, **kwargs)
|
||||
|
||||
async def rm(self, *args: Any, **kwargs: Any) -> Any:
|
||||
await self.resolve()
|
||||
return await super().rm(*args, **kwargs)
|
||||
|
||||
async def mkdir(self, *args: Any, **kwargs: Any) -> Any:
|
||||
await self.resolve()
|
||||
return await super().mkdir(*args, **kwargs)
|
||||
|
||||
async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
|
||||
await self.resolve()
|
||||
return await super().read(path, user=user)
|
||||
|
||||
async def write(self, *args: Any, **kwargs: Any) -> Any:
|
||||
await self.resolve()
|
||||
return await super().write(*args, **kwargs)
|
||||
|
||||
async def resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
|
||||
await self.resolve()
|
||||
return await super().resolve_exposed_port(port)
|
||||
|
||||
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
|
||||
session = await self.resolve()
|
||||
return await session._resolve_exposed_port(port)
|
||||
|
||||
async def pty_exec_start(self, *args: Any, **kwargs: Any) -> Any:
|
||||
await self.resolve()
|
||||
return await super().pty_exec_start(*args, **kwargs)
|
||||
|
||||
async def pty_write_stdin(self, *args: Any, **kwargs: Any) -> Any:
|
||||
await self.resolve()
|
||||
return await super().pty_write_stdin(*args, **kwargs)
|
||||
|
||||
async def pty_terminate_all(self) -> None:
|
||||
if self._resolved is None:
|
||||
return
|
||||
await super().pty_terminate_all()
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
await self.resolve()
|
||||
return await super().persist_workspace()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
await self.resolve()
|
||||
await super().hydrate_workspace(data)
|
||||
|
||||
async def teardown(self) -> None:
|
||||
if self._resolved is None:
|
||||
self._bring_up.cancel()
|
||||
with contextlib.suppress(BaseException):
|
||||
client, session = await self._bring_up
|
||||
self._adopt(client, session)
|
||||
if self._resolved is None:
|
||||
return
|
||||
await self._client.delete(self._resolved)
|
||||
|
||||
|
||||
class DeferredSandboxClient:
|
||||
def __init__(self, session: DeferredSandboxSession) -> None:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
client = self._session.client
|
||||
if client is None:
|
||||
raise AttributeError(f"sandbox client unavailable before bring-up finishes ({name})")
|
||||
return getattr(client, name)
|
||||
|
||||
async def delete(self, session: Any) -> None:
|
||||
if isinstance(session, DeferredSandboxSession):
|
||||
await session.teardown()
|
||||
return
|
||||
real = await self._session.resolve()
|
||||
await self._session.client.delete(real if session is self._session else session)
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -19,9 +21,12 @@ from strix.config import load_settings
|
||||
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
|
||||
from strix.runtime.caido_bootstrap import bootstrap_caido
|
||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
||||
from strix.runtime.deferred_session import DeferredSandboxClient, DeferredSandboxSession
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable
|
||||
|
||||
from agents.sandbox.session import BaseSandboxSession
|
||||
|
||||
from strix.runtime.status import StatusSink
|
||||
@@ -255,13 +260,22 @@ def _gitdir_from_pointer(git_file: Path) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_extra_file_archive(
|
||||
extra_files: list[dict[str, Any]] | Awaitable[list[dict[str, Any]] | None] | None,
|
||||
local_sources: list[dict[str, Any]],
|
||||
) -> bytes | None:
|
||||
files = await extra_files if inspect.isawaitable(extra_files) else extra_files
|
||||
return build_extra_file_archive(files, local_sources) if files else None
|
||||
|
||||
|
||||
async def create_or_reuse(
|
||||
scan_id: str,
|
||||
*,
|
||||
image: str,
|
||||
local_sources: list[dict[str, Any]],
|
||||
extra_files: list[dict[str, Any]] | None = None,
|
||||
extra_files: list[dict[str, Any]] | Awaitable[list[dict[str, Any]] | None] | None = None,
|
||||
status_sink: StatusSink | None = None,
|
||||
defer: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the existing session bundle for ``scan_id`` or create a new one.
|
||||
|
||||
@@ -271,7 +285,9 @@ async def create_or_reuse(
|
||||
Each ``extra_files`` entry (``{"workspace_path": "/workspace/<rel>",
|
||||
"content": bytes | str}``) lands as a regular, agent-writable file at its
|
||||
``workspace_path`` on every backend: the files are uploaded as one archive
|
||||
and unpacked inside the sandbox right after bring-up.
|
||||
and unpacked inside the sandbox right after bring-up. ``extra_files`` may
|
||||
also be an awaitable resolving to that list: provisioning starts at once and
|
||||
the files are awaited only when the sandbox is ready to receive them.
|
||||
"""
|
||||
|
||||
def report(phase: str) -> None:
|
||||
@@ -292,9 +308,6 @@ async def create_or_reuse(
|
||||
else:
|
||||
bind_mounts = []
|
||||
entries = build_manifest_entries(local_sources)
|
||||
extra_file_archive = (
|
||||
build_extra_file_archive(extra_files, local_sources) if extra_files else None
|
||||
)
|
||||
|
||||
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
|
||||
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
|
||||
@@ -323,41 +336,69 @@ async def create_or_reuse(
|
||||
backend_name,
|
||||
image,
|
||||
)
|
||||
report("Starting sandbox container")
|
||||
client, session = await backend(
|
||||
image=image,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
bind_mounts=bind_mounts,
|
||||
)
|
||||
|
||||
if extra_file_archive is not None:
|
||||
report("Placing workspace files")
|
||||
async def _bring_up() -> tuple[Any, Any]:
|
||||
report("Starting sandbox container")
|
||||
client, session = await _create_or_discard(
|
||||
backend(
|
||||
image=image,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
bind_mounts=bind_mounts,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await stage_extra_files(session, extra_file_archive)
|
||||
extra_file_archive = await _resolve_extra_file_archive(extra_files, local_sources)
|
||||
if extra_file_archive is not None:
|
||||
report("Placing workspace files")
|
||||
await stage_extra_files(session, extra_file_archive)
|
||||
except BaseException:
|
||||
await _discard_session(client, session)
|
||||
raise
|
||||
return client, session
|
||||
|
||||
report("Setting up the proxy")
|
||||
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
||||
scheme = "https" if caido_endpoint.tls else "http"
|
||||
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
|
||||
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
|
||||
async def _caido_url(session: Any) -> str:
|
||||
report("Setting up the proxy")
|
||||
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
||||
scheme = "https" if caido_endpoint.tls else "http"
|
||||
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
|
||||
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
|
||||
return host_caido_url
|
||||
|
||||
# The Caido login + project setup polls the guest for a couple of seconds
|
||||
# and nothing needs the client before the first proxy tool call, so it
|
||||
# runs concurrently with the rest of scan start; consumers resolve the
|
||||
# handle at first use (see CaidoBootstrapHandle).
|
||||
caido_client = CaidoBootstrapHandle(
|
||||
asyncio.create_task(
|
||||
bootstrap_caido(
|
||||
session,
|
||||
host_url=host_caido_url,
|
||||
container_url=container_caido_url,
|
||||
),
|
||||
name=f"caido-bootstrap-{scan_id}",
|
||||
def _caido(session: Any, host_caido_url: str) -> Any:
|
||||
return bootstrap_caido(
|
||||
session,
|
||||
host_url=host_caido_url,
|
||||
container_url=container_caido_url,
|
||||
)
|
||||
|
||||
if defer:
|
||||
bring_up = asyncio.create_task(_bring_up(), name=f"sandbox-bring-up-{scan_id}")
|
||||
deferred = DeferredSandboxSession(
|
||||
bring_up,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
)
|
||||
|
||||
async def _caido_when_up() -> Any:
|
||||
session = await deferred.resolve()
|
||||
return await _caido(session, await _caido_url(session))
|
||||
|
||||
bundle = {
|
||||
"client": DeferredSandboxClient(deferred),
|
||||
"session": deferred,
|
||||
"caido_client": CaidoBootstrapHandle(
|
||||
asyncio.create_task(_caido_when_up(), name=f"caido-bootstrap-{scan_id}")
|
||||
),
|
||||
}
|
||||
_SESSION_CACHE[scan_id] = bundle
|
||||
logger.info("Sandbox session for scan %s deferred and cached", scan_id)
|
||||
return bundle
|
||||
|
||||
client, session = await _bring_up()
|
||||
host_caido_url = await _caido_url(session)
|
||||
caido_client = CaidoBootstrapHandle(
|
||||
asyncio.create_task(_caido(session, host_caido_url), name=f"caido-bootstrap-{scan_id}")
|
||||
)
|
||||
|
||||
bundle = {
|
||||
@@ -370,6 +411,26 @@ async def create_or_reuse(
|
||||
return bundle
|
||||
|
||||
|
||||
_DISCARD_TASKS: set[asyncio.Task[None]] = set()
|
||||
|
||||
|
||||
async def _create_or_discard(create: Awaitable[tuple[Any, Any]]) -> tuple[Any, Any]:
|
||||
pending = asyncio.ensure_future(create)
|
||||
try:
|
||||
return await asyncio.shield(pending)
|
||||
except asyncio.CancelledError:
|
||||
discard = asyncio.create_task(_discard_when_created(pending))
|
||||
_DISCARD_TASKS.add(discard)
|
||||
discard.add_done_callback(_DISCARD_TASKS.discard)
|
||||
raise
|
||||
|
||||
|
||||
async def _discard_when_created(create: asyncio.Future[tuple[Any, Any]]) -> None:
|
||||
with contextlib.suppress(BaseException):
|
||||
client, session = await create
|
||||
await _discard_session(client, session)
|
||||
|
||||
|
||||
async def _discard_session(client: Any, session: Any) -> None:
|
||||
"""Best-effort teardown of a session that never made it into the cache."""
|
||||
try:
|
||||
|
||||
@@ -115,7 +115,8 @@ not just "upgrade <vulnerable pkg> to <fixed>".
|
||||
### Usage / reachability analysis (required for every dependency CVE)
|
||||
|
||||
For every CVE you are about to report, run a static usage analysis and record
|
||||
the result in the structured `reachability` + `reachability_evidence` fields.
|
||||
the result in the structured `reachability` + `reachability_evidence` fields,
|
||||
and report every usage site you find as a `code_locations` entry (see Reporting).
|
||||
The level is an **evidence ladder, never an exploitability verdict** — claim
|
||||
only what you proved, and cite the proof. It never changes severity (that is
|
||||
`advisory_cvss` alone); it exists so the reader can prioritize.
|
||||
@@ -220,8 +221,18 @@ findings and rejects empty PoC fields):
|
||||
GHSA, look up the mapped CVE; if there is genuinely no CVE, do not report it
|
||||
with this tool.
|
||||
- There are no PoC fields — `create_dependency_report` does not take
|
||||
`poc_description` / `poc_script_code` / `code_locations`. The proof lives in
|
||||
`description` and `technical_analysis` (scanner output + advisory).
|
||||
`poc_description` / `poc_script_code`. The proof lives in `description` and
|
||||
`technical_analysis` (scanner output + advisory).
|
||||
- **You MUST report usage sites in `code_locations`.** Add one entry for each
|
||||
import of the package and each call of an affected symbol that your usage
|
||||
analysis found. Every `file:line` you cite in `reachability_evidence` must
|
||||
also be a `code_locations` entry. Also add the line in `manifest_path` that
|
||||
pins the vulnerable version. Each entry needs the repo-relative `file`, the
|
||||
exact `start_line` / `end_line`, the verbatim `snippet` at those lines, and a
|
||||
short `label` (e.g. "imports js-yaml", "calls yaml.load on CLI input"). Read
|
||||
the real file to confirm the lines; never guess. When the package is
|
||||
`not_imported`, the manifest line is the only entry. If an entry carries
|
||||
`fix_before` / `fix_after`, also fill `fix_verification`.
|
||||
- **Always fill the structured dependency fields** (they power the dedicated
|
||||
dependency-report card; do not leave them only in free-text):
|
||||
- `package_name` — `PkgName` (required).
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: redos
|
||||
description: Regular expression denial of service (ReDoS, CWE-1333) in backtracking regex engines, covering exponential and polynomial patterns, recheck-assisted detection, growth-curve confirmation, and severity calibration
|
||||
---
|
||||
|
||||
# ReDoS (Regular Expression Denial of Service)
|
||||
|
||||
A backtracking regex engine can take exponential or polynomial time on a crafted input that almost matches. When attacker-controlled text reaches such a pattern, one request or one document can pin a CPU core for seconds to hours. Tracked as CWE-1333 (Inefficient Regular Expression Complexity).
|
||||
|
||||
The textbook shape, nested quantifiers like `(a+)+`, is the rarest one in real code. Most real ReDoS is polynomial: two adjacent pieces that can match the same characters, or an unanchored pattern that fails slowly at every start position. Do not rule a pattern out because it has no nested quantifier.
|
||||
|
||||
## Engines
|
||||
|
||||
Backtracking (vulnerable): Python `re` and `regex`, JavaScript (V8, JavaScriptCore, SpiderMonkey), Java `java.util.regex`, .NET (default mode), PCRE/PHP, Perl, Ruby before 3.2.
|
||||
|
||||
Linear-time (not vulnerable to ReDoS): Go `regexp`, Rust `regex`, RE2 and its bindings (`google-re2`, `re2j`), .NET `RegexOptions.NonBacktracking`. Ruby 3.2+ memoizes most patterns and has `Regexp.timeout`; treat it as lower risk, not immune.
|
||||
|
||||
PHP is special: when `pcre.backtrack_limit` is hit, `preg_match` returns `false` instead of hanging. That turns ReDoS into a logic bypass when the caller treats `false` as "no match", e.g. a validator or WAF rule that silently passes.
|
||||
|
||||
## Vulnerable Shapes
|
||||
|
||||
**Nested quantifiers (exponential)**: `^(a+)+$`, `^(a*)*$`, `^(\w+\s?)+$`. About 30 matching characters plus one that fails the anchor can hang.
|
||||
|
||||
**Overlapping alternation inside a repeat (exponential)**: `^(a|a)*$`, `^(\w|\d)+$`, `^(.*,)*$`.
|
||||
|
||||
**Adjacent overlapping quantifiers (polynomial)**: two consecutive pieces that can both match the same character, followed by something that fails. The engine tries every way to split the run between them.
|
||||
|
||||
~~~text
|
||||
\d+\d+x a run of digits split between two \d+, with no x after it (cubic: also retried at every start)
|
||||
^`{3,}[^\n]*\n a run of backticks split between the fence and [^\n]*, with no newline after it
|
||||
~~~
|
||||
|
||||
**Unanchored pattern that fails slowly at every start (polynomial)**: `<[^>]+>` on `<<<<…` with no `>`, or `\s+$` on a long run of spaces followed by one non-space character. Each start position scans to the end, and `sub`/`findall`/`search` retry at every position.
|
||||
|
||||
**Lazy scan with an absent terminator inside a loop**: `.*?END` or `[\s\S]*?` applied repeatedly to text that never contains `END`.
|
||||
|
||||
**Backreferences**: `^(a+)\1$` is quadratic: the engine tries every split of the run between the group and its repeat.
|
||||
|
||||
## Where to Look
|
||||
|
||||
- Validators: email, URL, hostname, semver, UUID, phone, password rules
|
||||
- Parsers and sanitizers: markdown, MDX, HTML stripping, template engines, log and CSV parsers, user-agent parsing
|
||||
- Routing and matching: route patterns, CORS origin checks, WAF and content-filter rules, glob-to-regex conversions
|
||||
- Patterns built from input: `re.compile(user_value)`, search filters, MongoDB `$regex`, Elasticsearch `regexp` queries (these are regex injection, the attacker supplies the pattern itself)
|
||||
- Document and RAG loaders that clean uploaded or fetched files: one large file per request is an easy delivery path
|
||||
|
||||
Find candidate patterns in source:
|
||||
|
||||
```bash
|
||||
rg -n --type py "re\.(compile|match|search|fullmatch|sub|subn|findall|finditer|split)\(" .
|
||||
rg -n --type js --type ts "new RegExp\(|/[^/\n]+/[dgimsuyv]*\.(test|exec)|\.(match|matchAll|replace|replaceAll|search|split)\(/" .
|
||||
```
|
||||
|
||||
## Detect with recheck
|
||||
|
||||
[recheck](https://github.com/makenowjust-labs/recheck) combines static analysis and fuzzing, reports the complexity class (linear, polynomial with degree, exponential), and produces an attack string. It catches polynomial cases that nested-quantifier heuristics and some tools miss. Install a pinned version at scan time:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/recheck && cd /tmp/recheck && npm install --silent [email protected]
|
||||
```
|
||||
|
||||
Put the patterns in a JSON file, one `[source, flags]` pair per entry, so no shell escaping is involved:
|
||||
|
||||
```js
|
||||
// /tmp/recheck/run.mjs usage: node run.mjs patterns.json
|
||||
import { check } from "recheck";
|
||||
import { readFileSync } from "node:fs";
|
||||
for (const [source, flags] of JSON.parse(readFileSync(process.argv[2], "utf8"))) {
|
||||
const r = await check(source, flags, { timeout: 30000 });
|
||||
console.log(JSON.stringify({ source, status: r.status, complexity: r.complexity?.summary, attack: r.attack?.pattern }));
|
||||
}
|
||||
```
|
||||
|
||||
recheck understands JavaScript syntax only. Translate other dialects first:
|
||||
|
||||
| Python | JavaScript |
|
||||
|--------|-----------|
|
||||
| `(?P<name>…)` | `(?<name>…)` |
|
||||
| `(?P=name)` | `\k<name>` |
|
||||
| `\Z` | `$(?![\s\S])` |
|
||||
| `\A` | `^` without the `m` flag |
|
||||
| inline `(?m)`, `(?s)`, `(?i)` | `m`, `s`, `i` in the flags string |
|
||||
| `(?>…)`, `a*+` (3.11+) | no equivalent; recheck will over-report, so confirm in Python |
|
||||
|
||||
Treat recheck's verdict as a lead and its attack string as a hint. The attack string can rely on JavaScript-only behavior and run fast in the target language: with the `m` flag, JavaScript `^` also matches after `\r`, `
`, and `
`, while Python `^` only matches after `\n`. Rebuild the attack for the real engine. If `npm install` fails (no network), reason from the shapes above and go straight to timing.
|
||||
|
||||
## Confirm in the Target Language
|
||||
|
||||
One timing proves nothing. Measure growth: double or 10x the input and compare times. Linear grows about 10x per 10x, quadratic about 100x, cubic about 1000x, exponential explodes after a few dozen characters.
|
||||
|
||||
```python
|
||||
import re, time
|
||||
pattern = re.compile(r"(?ms)^(?P<fence>`{3,}|~{3,})[^\n]*\n.*?(?:^(?P=fence)[ \t]*$|\Z)")
|
||||
for n in (1_000, 10_000, 100_000):
|
||||
payload = "`" * n # the repeated character, with the required terminator missing
|
||||
t = time.perf_counter(); pattern.sub("", payload); print(n, f"{time.perf_counter() - t:.3f}s")
|
||||
```
|
||||
|
||||
Build the payload as: a prefix that reaches the vulnerable piece, then many copies of the character both quantifiers accept, then a suffix that makes the overall match fail (or no suffix, when the failing piece needs a character that never arrives). Run the confirmation against the function the attacker reaches, not just the bare regex, so input caps and preprocessing are included.
|
||||
|
||||
## Severity Calibration
|
||||
|
||||
Report the time at a realistic input size, not the asymptotic class alone. Ask:
|
||||
|
||||
- Can the attacker supply the text, and how large can it be? Body size limits, field length validation, and upload caps decide whether 100 KB (seconds for quadratic) is reachable.
|
||||
- What does one slow match block? A Node.js event loop or a single-threaded worker stalls every user (higher). A per-request thread with a timeout affects one request (lower).
|
||||
- Is the work repeatable without authentication or rate limits?
|
||||
- Diff or PR review: compare against the base code. A new slow pattern next to an existing pattern with the same complexity on the same input does not add an attack; say so and calibrate down instead of reporting a new HIGH.
|
||||
|
||||
Exponential on short unauthenticated input in a shared event loop is high. Quadratic needing megabytes behind authentication is usually low or informational.
|
||||
|
||||
## False Positives
|
||||
|
||||
- Linear-time engine (Go, Rust, RE2, .NET NonBacktracking)
|
||||
- Input length capped well below where the curve hurts
|
||||
- recheck flagged a construct the target dialect handles atomically (possessive quantifiers, atomic groups)
|
||||
- Only the developer controls the input (build scripts, static config)
|
||||
|
||||
## Remediation
|
||||
|
||||
- Make overlapping pieces unambiguous: atomic groups (`` (?>`{3,}) ``; Python 3.11+, Java, PCRE, .NET) or possessive quantifiers (`a++`; Python 3.11+, Java, PCRE), or rewrite so adjacent pieces cannot match the same character
|
||||
- Anchor patterns and bound repeats (`{1,256}`) where the format allows
|
||||
- Cap input length before matching
|
||||
- Switch to a linear-time engine (`google-re2`, Rust/Go regex) for untrusted input
|
||||
- Use engine timeouts where available: .NET `matchTimeout`, Ruby `Regexp.timeout`, Python `regex` module `timeout=`
|
||||
|
||||
## Summary
|
||||
|
||||
Look for adjacent pieces that can match the same character, not just nested quantifiers. Use recheck to flag candidates and get a starting attack string, then rebuild and time the attack in the target language at 1k, 10k, and 100k characters. Calibrate severity by reachable input size, what a stalled match blocks, and, in a PR, whether the base code was already as slow.
|
||||
@@ -13,6 +13,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
from caido_sdk_client import BaseError
|
||||
|
||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
||||
from strix.tools.nullish import clean_optional
|
||||
@@ -124,7 +125,11 @@ def _no_client() -> str:
|
||||
|
||||
|
||||
def _err(name: str, exc: Exception) -> str:
|
||||
logger.exception("%s failed", name)
|
||||
# Caido-side failures (timeouts, bad filters, ...) are returned to the model, not our bugs.
|
||||
if isinstance(exc, BaseError):
|
||||
logger.warning("%s failed", name, exc_info=True)
|
||||
else:
|
||||
logger.exception("%s failed", name)
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"{name} failed: {exc}"},
|
||||
ensure_ascii=False,
|
||||
|
||||
@@ -1876,14 +1876,10 @@ def _build_dependency_evidence(
|
||||
evidence += f"\n\n**Usage analysis:** {label}."
|
||||
if reachability_evidence and reachability_evidence.strip():
|
||||
evidence += f" {reachability_evidence.strip()}"
|
||||
evidence += (
|
||||
" This is a prioritization signal from static analysis, not a"
|
||||
" proof of exploitability or of safety."
|
||||
)
|
||||
return evidence
|
||||
|
||||
|
||||
async def _do_create_dependency( # noqa: PLR0912
|
||||
async def _do_create_dependency( # noqa: PLR0912, PLR0915
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
@@ -1905,6 +1901,8 @@ async def _do_create_dependency( # noqa: PLR0912
|
||||
manifest_path: str | None = None,
|
||||
reachability: str = "unknown",
|
||||
reachability_evidence: str | None = None,
|
||||
code_locations: list[dict[str, Any]] | None = None,
|
||||
fix_verification: str | None = None,
|
||||
contextual_cvss_breakdown: dict[str, str] | None = None,
|
||||
contextual_cvss_reasoning: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
@@ -1947,6 +1945,16 @@ async def _do_create_dependency( # noqa: PLR0912
|
||||
if manifest_err:
|
||||
errors.append(manifest_err)
|
||||
|
||||
parsed_locations = _normalize_code_locations(code_locations)
|
||||
if parsed_locations:
|
||||
errors.extend(_validate_code_locations(parsed_locations))
|
||||
elif code_locations:
|
||||
errors.append(
|
||||
"code_locations were dropped as unusable - every location needs a relative "
|
||||
"'file' and an integer 'start_line'"
|
||||
)
|
||||
errors.extend(_validate_fix_verification(parsed_locations, fix_verification))
|
||||
|
||||
reachability = (reachability or "unknown").strip().lower()
|
||||
if reachability not in _VALID_REACHABILITY:
|
||||
errors.append(
|
||||
@@ -2053,6 +2061,8 @@ async def _do_create_dependency( # noqa: PLR0912
|
||||
cvss=cvss_score if advisory_cvss is not None else None,
|
||||
cve=parsed_cve,
|
||||
cwe=cwe,
|
||||
code_locations=parsed_locations,
|
||||
fix_verification=fix_verification,
|
||||
finding_class="dependency_cve",
|
||||
dependency_metadata=dependency_metadata,
|
||||
agent_id=agent_id if isinstance(agent_id, str) else None,
|
||||
@@ -2107,6 +2117,8 @@ async def create_dependency_report(
|
||||
dependency_path: str | None = None,
|
||||
reachability: str = "unknown",
|
||||
reachability_evidence: str | None = None,
|
||||
code_locations: list[dict[str, Any]] | None = None,
|
||||
fix_verification: str | None = None,
|
||||
contextual_cvss_breakdown: dict[str, str] | None = None,
|
||||
contextual_cvss_reasoning: str | None = None,
|
||||
) -> str:
|
||||
@@ -2217,6 +2229,23 @@ async def create_dependency_report(
|
||||
is off in production), and say who controls the input. State
|
||||
it plainly when no entry point reaches the sink — that is the
|
||||
most useful result a reader can get.
|
||||
code_locations: Where application code imports or calls the package —
|
||||
the evidence behind your ``reachability`` claim. List of dicts,
|
||||
same shape as ``create_vulnerability_report``::
|
||||
|
||||
{"file": "src/api/client.ts", "start_line": 14, "end_line": 14,
|
||||
"snippet": "const x = require('pkg')", "label": "imports it"}
|
||||
|
||||
Cite the repo-relative ``file`` and the exact 1-based
|
||||
``start_line`` where ``snippet`` actually sits — verify against
|
||||
the real file, never guess. A location may also propose
|
||||
the fix itself: ``fix_before`` (verbatim current code, e.g. the
|
||||
manifest pin line) + ``fix_after`` (same lines with the fixed
|
||||
version) — that combination requires ``fix_verification``.
|
||||
fix_verification: Required whenever any ``code_locations`` entry
|
||||
carries ``fix_after``. Same bar as a normal finding: security
|
||||
closure, bypass review, preserved behavior, and how each was
|
||||
checked.
|
||||
contextual_cvss_breakdown: **Required.** Full CVSS v3.1 rating of this
|
||||
CVE **in this codebase** — the same 8-metric object as
|
||||
``create_vulnerability_report``'s ``cvss_breakdown``:
|
||||
@@ -2277,6 +2306,8 @@ async def create_dependency_report(
|
||||
manifest_path=manifest_path,
|
||||
reachability=reachability,
|
||||
reachability_evidence=reachability_evidence,
|
||||
code_locations=code_locations,
|
||||
fix_verification=fix_verification,
|
||||
contextual_cvss_breakdown=contextual_cvss_breakdown,
|
||||
contextual_cvss_reasoning=contextual_cvss_reasoning,
|
||||
agent_id=agent_id,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from agents.run_config import SandboxRunConfig
|
||||
from agents.sandbox import SandboxAgent
|
||||
from agents.sandbox.capabilities import Filesystem, Shell
|
||||
from agents.sandbox.runtime_session_manager import SandboxRuntimeSessionManager
|
||||
|
||||
from strix.runtime import session_manager
|
||||
from strix.runtime.backends import _BACKENDS, _BIND_MOUNT_BACKENDS, register_backend
|
||||
from strix.runtime.deferred_session import DeferredSandboxClient, DeferredSandboxSession
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RuntimeSettings:
|
||||
backend: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Settings:
|
||||
runtime: _RuntimeSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Endpoint:
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8080
|
||||
tls: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ExecResult:
|
||||
exit_code: int = 0
|
||||
stdout: bytes = b""
|
||||
stderr: bytes = b""
|
||||
|
||||
def ok(self) -> bool:
|
||||
return self.exit_code == 0
|
||||
|
||||
|
||||
class _State:
|
||||
def __init__(self) -> None:
|
||||
self.manifest: Any = None
|
||||
self.session_id = "00000000-0000-4000-8000-000000000001"
|
||||
|
||||
def model_copy(self, *, update: dict[str, Any]) -> _State:
|
||||
copy = _State()
|
||||
copy.manifest = update.get("manifest", self.manifest)
|
||||
return copy
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self) -> None:
|
||||
self.state = _State()
|
||||
self.execs: list[tuple[str, ...]] = []
|
||||
self.writes: list[tuple[Path, bytes]] = []
|
||||
self.limits: list[Any] = []
|
||||
|
||||
def _set_concurrency_limits(self, limits: Any) -> None:
|
||||
self.limits.append(limits)
|
||||
|
||||
def _set_archive_limits(self, limits: Any) -> None:
|
||||
self.limits.append(limits)
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def resolve_exposed_port(self, _port: int) -> _Endpoint:
|
||||
return _Endpoint()
|
||||
|
||||
async def exec(self, *argv: str, timeout: float | None = None, **_kw: Any) -> _ExecResult:
|
||||
del timeout
|
||||
self.execs.append(argv)
|
||||
return _ExecResult()
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, **_kw: Any) -> None:
|
||||
self.writes.append((path, data.read()))
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.deleted: list[Any] = []
|
||||
|
||||
async def delete(self, session: Any) -> None:
|
||||
self.deleted.append(session)
|
||||
|
||||
|
||||
async def _no_caido(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Backend:
|
||||
def __init__(self, *, fail: bool = False) -> None:
|
||||
self.gate = asyncio.Event()
|
||||
self.calls = 0
|
||||
self.fail = fail
|
||||
self.client = _Client()
|
||||
self.session = _Session()
|
||||
|
||||
async def __call__(self, **_kwargs: Any) -> tuple[Any, Any]:
|
||||
self.calls += 1
|
||||
await self.gate.wait()
|
||||
if self.fail:
|
||||
raise RuntimeError("bring-up exploded")
|
||||
return self.client, self.session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Iterator[_Backend]:
|
||||
name = f"deferred-{request.node.name}"
|
||||
fake = _Backend(fail="fail" in request.node.name)
|
||||
register_backend(name, fake, supports_bind_mounts=True)
|
||||
monkeypatch.setattr(session_manager, "bootstrap_caido", _no_caido)
|
||||
monkeypatch.setattr(
|
||||
session_manager, "load_settings", lambda: _Settings(runtime=_RuntimeSettings(backend=name))
|
||||
)
|
||||
yield fake
|
||||
_BACKENDS.pop(name, None)
|
||||
_BIND_MOUNT_BACKENDS.discard(name)
|
||||
|
||||
|
||||
async def _settle() -> None:
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
async def _create(scan_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
return await session_manager.create_or_reuse(
|
||||
scan_id, image="img", local_sources=[], defer=True, **kwargs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deferred_bundle_returns_before_the_backend_finishes(backend: _Backend) -> None:
|
||||
scan_id = "deferred-returns"
|
||||
try:
|
||||
bundle = await asyncio.wait_for(_create(scan_id), timeout=1)
|
||||
session = bundle["session"]
|
||||
assert isinstance(session, DeferredSandboxSession)
|
||||
assert isinstance(bundle["client"], DeferredSandboxClient)
|
||||
assert session.pending
|
||||
await _settle()
|
||||
assert backend.calls == 1
|
||||
assert await session.running()
|
||||
assert session.state.manifest.environment.value["PYTHONUNBUFFERED"] == "1"
|
||||
again = await session_manager.create_or_reuse(scan_id, image="img", local_sources=[])
|
||||
assert again is bundle
|
||||
assert backend.calls == 1
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_io_resolves_the_sandbox_then_delegates(backend: _Backend) -> None:
|
||||
scan_id = "deferred-io"
|
||||
try:
|
||||
bundle = await _create(scan_id)
|
||||
session = bundle["session"]
|
||||
session._set_concurrency_limits("limits")
|
||||
exec_task = asyncio.create_task(session.exec("echo", "hi"))
|
||||
await asyncio.sleep(0.05)
|
||||
assert not exec_task.done()
|
||||
backend.gate.set()
|
||||
result = await asyncio.wait_for(exec_task, timeout=1)
|
||||
assert result.ok()
|
||||
assert backend.session.execs == [("echo", "hi")]
|
||||
assert not session.pending
|
||||
assert backend.session.limits == ["limits"]
|
||||
assert backend.session.state.manifest is session.state.manifest
|
||||
await session.write(Path("/workspace/x"), io.BytesIO(b"data"))
|
||||
assert backend.session.writes == [(Path("/workspace/x"), b"data")]
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
assert backend.client.deleted == [backend.session]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_bring_up_surfaces_on_first_io(backend: _Backend) -> None:
|
||||
scan_id = "deferred-fail"
|
||||
try:
|
||||
bundle = await _create(scan_id)
|
||||
session = bundle["session"]
|
||||
backend.gate.set()
|
||||
with pytest.raises(RuntimeError, match="bring-up exploded"):
|
||||
await asyncio.wait_for(session.exec("true"), timeout=1)
|
||||
assert not await session.running()
|
||||
with pytest.raises(RuntimeError, match="bring-up exploded"):
|
||||
await bundle["caido_client"].get()
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
assert backend.client.deleted == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_while_pending_cancels_the_bring_up(backend: _Backend) -> None:
|
||||
scan_id = "deferred-cancel"
|
||||
bundle = await _create(scan_id)
|
||||
session = bundle["session"]
|
||||
await asyncio.wait_for(session_manager.cleanup(scan_id), timeout=1)
|
||||
assert session.bring_up.cancelled()
|
||||
assert backend.client.deleted == []
|
||||
assert session_manager._SESSION_CACHE.get(scan_id) is None
|
||||
backend.gate.set()
|
||||
await _settle()
|
||||
assert backend.client.deleted == [backend.session]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_while_pending_ignores_a_failed_late_bring_up(backend: _Backend) -> None:
|
||||
scan_id = "deferred-cancel-fail"
|
||||
await _create(scan_id)
|
||||
await asyncio.wait_for(session_manager.cleanup(scan_id), timeout=1)
|
||||
backend.gate.set()
|
||||
await _settle()
|
||||
assert backend.client.deleted == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extra_files_are_staged_inside_the_deferred_bring_up(backend: _Backend) -> None:
|
||||
scan_id = "deferred-extra"
|
||||
try:
|
||||
bundle = await _create(
|
||||
scan_id,
|
||||
extra_files=[{"workspace_path": "/workspace/.strix/notes.md", "content": "hi"}],
|
||||
)
|
||||
assert backend.session.writes == []
|
||||
backend.gate.set()
|
||||
await asyncio.wait_for(bundle["session"].resolve(), timeout=1)
|
||||
assert len(backend.session.writes) == 1
|
||||
assert backend.session.execs
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awaitable_extra_files_do_not_delay_the_backend_call(backend: _Backend) -> None:
|
||||
scan_id = "deferred-extra-awaitable"
|
||||
files: asyncio.Future[list[dict[str, Any]] | None] = asyncio.get_running_loop().create_future()
|
||||
try:
|
||||
bundle = await _create(scan_id, extra_files=files)
|
||||
backend.gate.set()
|
||||
await _settle()
|
||||
assert backend.calls == 1
|
||||
assert backend.session.writes == []
|
||||
files.set_result([{"workspace_path": "/workspace/.strix/notes.md", "content": "hi"}])
|
||||
await asyncio.wait_for(bundle["session"].resolve(), timeout=1)
|
||||
assert len(backend.session.writes) == 1
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awaitable_extra_files_resolving_to_none_stage_nothing(backend: _Backend) -> None:
|
||||
scan_id = "deferred-extra-none"
|
||||
|
||||
async def _nothing() -> list[dict[str, Any]] | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
bundle = await _create(scan_id, extra_files=_nothing())
|
||||
backend.gate.set()
|
||||
await asyncio.wait_for(bundle["session"].resolve(), timeout=1)
|
||||
assert backend.session.writes == []
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_path_still_blocks_until_the_sandbox_is_up(backend: _Backend) -> None:
|
||||
scan_id = "deferred-off"
|
||||
backend.gate.set()
|
||||
try:
|
||||
bundle = await session_manager.create_or_reuse(scan_id, image="img", local_sources=[])
|
||||
assert bundle["session"] is backend.session
|
||||
assert bundle["client"] is backend.client
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
assert backend.client.deleted == [backend.session]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_session_prep_does_not_wait_for_the_bring_up(backend: _Backend) -> None:
|
||||
scan_id = "deferred-sdk-prep"
|
||||
try:
|
||||
bundle = await _create(scan_id)
|
||||
agent = SandboxAgent(
|
||||
name="root", instructions="x", tools=[], capabilities=[Filesystem(), Shell()]
|
||||
)
|
||||
manager = SandboxRuntimeSessionManager(
|
||||
starting_agent=agent,
|
||||
sandbox_config=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
||||
run_state=None,
|
||||
)
|
||||
session = await asyncio.wait_for(
|
||||
manager.ensure_session(
|
||||
agent=agent, capabilities=list(agent.capabilities), is_resumed_state=False
|
||||
),
|
||||
timeout=1,
|
||||
)
|
||||
assert session is bundle["session"]
|
||||
assert session.pending
|
||||
backend.gate.set()
|
||||
await asyncio.wait_for(session.resolve(), timeout=1)
|
||||
assert not session.pending
|
||||
finally:
|
||||
await session_manager.cleanup(scan_id)
|
||||
@@ -13,6 +13,7 @@ from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from caido_sdk_client.errors import OperationUserError
|
||||
|
||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
||||
from strix.tools.proxy import caido_api, tools
|
||||
@@ -252,3 +253,11 @@ async def test_existing_request_ids_queries_current_project(
|
||||
|
||||
assert existing == {"1042"}
|
||||
assert looked_up == ["1042", "1088"]
|
||||
|
||||
|
||||
def test_err_logs_caido_errors_as_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with caplog.at_level("WARNING", logger="strix.tools.proxy.tools"):
|
||||
tools._err("list_requests", OperationUserError(""))
|
||||
|
||||
[record] = caplog.records
|
||||
assert record.levelname == "WARNING"
|
||||
@@ -408,6 +408,165 @@ async def test_dependency_report_records_transitive_chain(report_state: ReportSt
|
||||
)
|
||||
|
||||
|
||||
async def test_dependency_report_persists_code_locations(report_state: ReportState) -> None:
|
||||
"""Usage-site locations file onto a dependency finding like any other."""
|
||||
result = await _do_create_dependency(
|
||||
title="CVE-2021-23337 in lodash 4.17.20",
|
||||
description="Command injection via template.",
|
||||
target="repo/package.json",
|
||||
cve="CVE-2021-23337",
|
||||
package_name="lodash",
|
||||
installed_version="4.17.20",
|
||||
impact="Arbitrary command execution.",
|
||||
remediation_steps="Upgrade to 4.17.21.",
|
||||
assumptions="Assumes the template sink is reachable.",
|
||||
package_ecosystem="npm",
|
||||
manifest_path="package-lock.json",
|
||||
fixed_version="4.17.21",
|
||||
cwe="CWE-94",
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="vulnerable_symbol_used",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[
|
||||
{
|
||||
"file": "src/render.ts",
|
||||
"start_line": 14,
|
||||
"end_line": 14,
|
||||
"snippet": "const merge = require('lodash').merge",
|
||||
"label": "imports the vulnerable package",
|
||||
}
|
||||
],
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
)
|
||||
assert result["success"] is True
|
||||
locs = report_state.vulnerability_reports[0]["code_locations"]
|
||||
assert locs[0]["file"] == "src/render.ts"
|
||||
assert locs[0]["label"] == "imports the vulnerable package"
|
||||
|
||||
|
||||
async def test_dependency_report_fix_locations_require_verification(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""A location proposing a fix still requires fix_verification."""
|
||||
result = await _do_create_dependency(
|
||||
title="CVE-2021-23337 in lodash 4.17.20",
|
||||
description="Command injection via template.",
|
||||
target="repo/package.json",
|
||||
cve="CVE-2021-23337",
|
||||
package_name="lodash",
|
||||
installed_version="4.17.20",
|
||||
impact="Arbitrary command execution.",
|
||||
remediation_steps="Upgrade to 4.17.21.",
|
||||
assumptions="Assumes the template sink is reachable.",
|
||||
package_ecosystem="npm",
|
||||
manifest_path="package-lock.json",
|
||||
fixed_version="4.17.21",
|
||||
cwe="CWE-94",
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[
|
||||
{
|
||||
"file": "package-lock.json",
|
||||
"start_line": 10,
|
||||
"end_line": 10,
|
||||
"fix_before": '"lodash": "4.17.20"',
|
||||
"fix_after": '"lodash": "4.17.21"',
|
||||
}
|
||||
],
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert any("fix_verification" in e for e in result["errors"])
|
||||
assert not report_state.vulnerability_reports
|
||||
|
||||
|
||||
async def test_dependency_report_fix_locations_persist_with_verification(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""A fix-pair location files when fix_verification is supplied, and both
|
||||
fields persist on the stored report."""
|
||||
result = await _do_create_dependency(
|
||||
title="CVE-2021-23337 in lodash 4.17.20",
|
||||
description="Command injection via template.",
|
||||
target="repo/package.json",
|
||||
cve="CVE-2021-23337",
|
||||
package_name="lodash",
|
||||
installed_version="4.17.20",
|
||||
impact="Arbitrary command execution.",
|
||||
remediation_steps="Upgrade to 4.17.21.",
|
||||
assumptions="Assumes the template sink is reachable.",
|
||||
package_ecosystem="npm",
|
||||
manifest_path="package-lock.json",
|
||||
fixed_version="4.17.21",
|
||||
cwe="CWE-94",
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[
|
||||
{
|
||||
"file": "package-lock.json",
|
||||
"start_line": 10,
|
||||
"end_line": 10,
|
||||
"fix_before": '"lodash": "4.17.20"',
|
||||
"fix_after": '"lodash": "4.17.21"',
|
||||
}
|
||||
],
|
||||
fix_verification="Bump verified: lockfile parses and the sink is unreachable.",
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
)
|
||||
assert result["success"] is True
|
||||
report = report_state.vulnerability_reports[0]
|
||||
loc = report["code_locations"][0]
|
||||
assert loc["fix_before"] == '"lodash": "4.17.20"'
|
||||
assert loc["fix_after"] == '"lodash": "4.17.21"'
|
||||
assert report["fix_verification"] == (
|
||||
"Bump verified: lockfile parses and the sink is unreachable."
|
||||
)
|
||||
|
||||
|
||||
async def test_dependency_report_rejects_unusable_locations(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""A supplied list that normalizes to nothing errors instead of filing
|
||||
silently without the requested usage evidence."""
|
||||
result = await _do_create_dependency(
|
||||
title="CVE-2021-23337 in lodash 4.17.20",
|
||||
description="Command injection via template.",
|
||||
target="repo/package.json",
|
||||
cve="CVE-2021-23337",
|
||||
package_name="lodash",
|
||||
installed_version="4.17.20",
|
||||
impact="Arbitrary command execution.",
|
||||
remediation_steps="Upgrade to 4.17.21.",
|
||||
assumptions="Assumes the template sink is reachable.",
|
||||
package_ecosystem="npm",
|
||||
manifest_path="package-lock.json",
|
||||
fixed_version="4.17.21",
|
||||
cwe="CWE-94",
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[{"file": "/abs/path.ts", "snippet": "require('lodash')"}],
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert any("dropped as unusable" in e for e in result["errors"])
|
||||
assert not report_state.vulnerability_reports
|
||||
|
||||
|
||||
async def test_dependency_report_omits_blank_chain_fields(report_state: ReportState) -> None:
|
||||
result = await _do_create_dependency(
|
||||
title="CVE-2024-0001 in sample 1.0.0",
|
||||
@@ -504,7 +663,6 @@ async def test_dependency_report_records_reachability(report_state: ReportState)
|
||||
== "src/render.ts:14 calls `_.template()`."
|
||||
)
|
||||
assert "**Usage analysis:**" in report["evidence"]
|
||||
assert "not a proof of exploitability or of safety" in report["evidence"]
|
||||
# The level must never influence the rating — that comes from the contextual
|
||||
# breakdown, or from advisory_cvss when no breakdown applies.
|
||||
assert report["severity"] == "high"
|
||||
|
||||
Reference in new issue
Block a user