mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 18:08:59 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b8ee1bd4 | ||
|
|
aae7c97461 |
No files matched your search
@@ -32,7 +32,6 @@ jobs:
|
||||
wheel-platform: win_amd64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
@@ -111,7 +110,6 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
name: Binary
|
||||
name: Package
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- strix.spec
|
||||
- scripts/**
|
||||
- strix/interface/tui/**
|
||||
- .github/actions/**
|
||||
- .github/workflows/binary.yml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- strix.spec
|
||||
- scripts/**
|
||||
- strix/interface/tui/**
|
||||
- .github/actions/**
|
||||
- .github/workflows/binary.yml
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
wheel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv build --wheel
|
||||
- name: Wheel ships the TUI sidecar and the viewer bundle
|
||||
run: |
|
||||
uv run --frozen python - <<'PY'
|
||||
import glob, zipfile
|
||||
(wheel,) = glob.glob("dist/*.whl")
|
||||
names = zipfile.ZipFile(wheel).namelist()
|
||||
assert "strix/bin/strix-tui" in names, names
|
||||
assert "strix/interface/viewer/static/index.html" in names, names
|
||||
assert not any(name.startswith("strix/interface/viewer/frontend/") for name in names)
|
||||
assert not any(name.startswith("strix/interface/tui/cmd/") for name in names)
|
||||
PY
|
||||
- name: Install into a clean environment and run the CLI
|
||||
env:
|
||||
UV_EXCLUDE_NEWER: 5 days
|
||||
run: |
|
||||
uv venv --python 3.12 /tmp/strix-smoke
|
||||
uv pip install --python /tmp/strix-smoke/bin/python dist/*.whl
|
||||
/tmp/strix-smoke/bin/strix --version
|
||||
/tmp/strix-smoke/bin/strix --help >/dev/null
|
||||
- name: Vertex AI and Bedrock dependencies install with the wheel
|
||||
run: /tmp/strix-smoke/bin/python -c "import google.auth.transport.requests, boto3"
|
||||
|
||||
binary:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Python
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- run: uv lock --check
|
||||
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv run --frozen ruff check .
|
||||
- run: uv run --frozen ruff format --check .
|
||||
|
||||
mypy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv run --frozen mypy strix/
|
||||
|
||||
bandit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv run --frozen bandit -r strix/ -c pyproject.toml
|
||||
|
||||
pytest:
|
||||
name: pytest (${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13', '3.14']
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: uv run --frozen pytest -q
|
||||
@@ -0,0 +1,26 @@
|
||||
name: TUI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
go:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/tui
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: CGO_ENABLED=0 go build -trimpath -o /dev/null ./cmd/strix-tui
|
||||
- run: go test -race ./...
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Viewer
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/viewer/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: strix/interface/viewer/frontend/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- name: Committed viewer bundle matches the build
|
||||
run: git -C "$GITHUB_WORKSPACE" diff --exit-code --stat -- strix/interface/viewer/static && test -z "$(git -C "$GITHUB_WORKSPACE" status --porcelain -- strix/interface/viewer/static)"
|
||||
@@ -1,27 +1,14 @@
|
||||
name: Workflows
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/**
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/**
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
ACTIONLINT_SCRIPT_REF: 914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
||||
@@ -40,7 +27,6 @@ jobs:
|
||||
|
||||
zizmor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
+26
-95
@@ -15,107 +15,38 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv lock --check
|
||||
- run: uv run --frozen ruff check .
|
||||
- run: uv run --frozen ruff format --check .
|
||||
- run: uv run --frozen mypy strix/
|
||||
- run: uv run --frozen bandit -r strix/ -c pyproject.toml
|
||||
|
||||
pytest:
|
||||
name: pytest (${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.12', '3.14']
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: uv run --frozen pytest -q
|
||||
python:
|
||||
uses: ./.github/workflows/ci-python.yml
|
||||
|
||||
tui:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/tui
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: CGO_ENABLED=0 go build -trimpath -o /dev/null ./cmd/strix-tui
|
||||
- run: go test -race ./...
|
||||
uses: ./.github/workflows/ci-tui.yml
|
||||
|
||||
viewer:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/viewer/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: strix/interface/viewer/frontend/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- name: Committed viewer bundle matches the build
|
||||
run: git -C "$GITHUB_WORKSPACE" diff --exit-code --stat -- strix/interface/viewer/static && test -z "$(git -C "$GITHUB_WORKSPACE" status --porcelain -- strix/interface/viewer/static)"
|
||||
uses: ./.github/workflows/ci-viewer.yml
|
||||
|
||||
wheel:
|
||||
package:
|
||||
uses: ./.github/workflows/ci-package.yml
|
||||
|
||||
workflows:
|
||||
uses: ./.github/workflows/ci-workflows.yml
|
||||
|
||||
ci-passed:
|
||||
name: ci-passed
|
||||
if: always()
|
||||
needs:
|
||||
- python
|
||||
- tui
|
||||
- viewer
|
||||
- package
|
||||
- workflows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv build --wheel
|
||||
- name: Wheel ships the TUI sidecar and the viewer bundle
|
||||
run: |
|
||||
uv run --frozen python - <<'PY'
|
||||
import glob, zipfile
|
||||
(wheel,) = glob.glob("dist/*.whl")
|
||||
names = zipfile.ZipFile(wheel).namelist()
|
||||
assert "strix/bin/strix-tui" in names, names
|
||||
assert "strix/interface/viewer/static/index.html" in names, names
|
||||
assert not any(name.startswith("strix/interface/viewer/frontend/") for name in names)
|
||||
assert not any(name.startswith("strix/interface/tui/cmd/") for name in names)
|
||||
PY
|
||||
- name: Install into a clean environment and run the CLI
|
||||
- name: Every job succeeded
|
||||
env:
|
||||
UV_EXCLUDE_NEWER: 5 days
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
uv venv --python 3.12 /tmp/strix-smoke
|
||||
uv pip install --python /tmp/strix-smoke/bin/python dist/*.whl
|
||||
/tmp/strix-smoke/bin/strix --version
|
||||
/tmp/strix-smoke/bin/strix --help >/dev/null
|
||||
- name: Vertex AI and Bedrock dependencies install with the wheel
|
||||
run: /tmp/strix-smoke/bin/python -c "import google.auth.transport.requests, boto3"
|
||||
failing=$(jq -r 'to_entries[] | select(.value.result != "success") | "\(.key): \(.value.result)"' <<< "$NEEDS")
|
||||
if [ -n "$failing" ]; then
|
||||
echo "$failing"
|
||||
exit 1
|
||||
fi
|
||||
@@ -45,3 +45,23 @@ repos:
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: [-c, pyproject.toml]
|
||||
|
||||
# Additional Python code quality checks
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
|
||||
ci:
|
||||
autofix_commit_msg: |
|
||||
[pre-commit.ci] auto fixes from pre-commit.com hooks
|
||||
|
||||
for more information, see https://pre-commit.ci
|
||||
autofix_prs: true
|
||||
autoupdate_branch: ""
|
||||
autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate"
|
||||
autoupdate_schedule: weekly
|
||||
# pre-commit.ci cannot run `language: system` hooks; mypy runs via `make check-all`.
|
||||
skip: [mypy]
|
||||
submodules: false
|
||||
@@ -58,6 +58,13 @@ Structure agents by function:
|
||||
- Business logic flaws
|
||||
- Infrastructure vulnerabilities
|
||||
|
||||
**Sensitive Browser Confirmations**
|
||||
- For every browser workflow containing a security-sensitive or consequential user action—whether authenticated or unauthenticated—assign `browser_security` to the agent testing that workflow or create a dedicated UI-redressing agent. This includes actions that change protected state, grant authority, submit credentials, expose sensitive data, or cross a trust boundary.
|
||||
- Its task must cover both iframe and cross-window/DoubleClickjacking paths; framing protections close only iframe coverage.
|
||||
- A workflow crossing origins or ports remains eligible when every involved origin is an explicit scan target. Same-origin policy blocks DOM reads, not top-level navigation, popups, opener relationships, focus changes, or window closing; cross-origin separation alone does not close UI-redressing coverage.
|
||||
- Never expand authorization from a link alone. If a required origin is not an explicit target, leave the path `needs_follow_up` and do not claim clickjacking or the related workflow is cleared; scope expansion must come from scan configuration.
|
||||
- Before accepting `no_issue_found` or `ruled_out` for cross-window UI redressing, reject closure evidence that cites popup-as-tab behavior, ignored geometry, unavailable OS-level input, or only a manual control click; keep the exact two-click path `needs_follow_up`. A successfully executed protected consent, approval, or grant must be impact-calibrated, not dismissed because it is idempotent, pre-seeded, or issues no immediate token or data.
|
||||
|
||||
**Exploitation and Validation**
|
||||
- Proof-of-concept development
|
||||
- Impact demonstration
|
||||
|
||||
@@ -114,103 +114,99 @@ not just "upgrade <vulnerable pkg> to <fixed>".
|
||||
|
||||
### Usage / reachability analysis (required for every dependency CVE)
|
||||
|
||||
Every CVE you report carries a verdict in `reachability`, the proof in
|
||||
`reachability_evidence`, and every usage site in `code_locations` (see
|
||||
Reporting). The verdict answers one question for THIS advisory in THIS
|
||||
codebase: does the vulnerable code run here? There are three answers and no
|
||||
"unknown". The tool rejects a report without a verdict or without evidence,
|
||||
so finish the analysis before you file.
|
||||
For every CVE you are about to report, run a static usage analysis and record
|
||||
the result in the structured `reachability` + `reachability_evidence` fields,
|
||||
and report every usage site you find as a `code_locations` entry (see Reporting).
|
||||
The level is an **evidence ladder, never an exploitability verdict** — claim
|
||||
only what you proved, and cite the proof. It never changes severity (that is
|
||||
`advisory_cvss` alone); it exists so the reader can prioritize.
|
||||
|
||||
- **`reachable`** — the advisory's vulnerable code executes in this codebase:
|
||||
application code, or a package it calls on the application's behalf, calls
|
||||
the affected function, mode or feature. Evidence: the call site as
|
||||
`file:line` and the source-to-sink trace (step 3).
|
||||
- **`not_reachable`** — the package is loaded by the shipped code, but this
|
||||
CVE's vulnerable path does not execute here. Evidence: what you checked and
|
||||
why the path is dead. Typical reasons: no caller of the affected API after
|
||||
a complete search; the only call site is behind a condition that is false
|
||||
here (a polyfill for an older runtime, a flag that is off, a dev-only
|
||||
command); the parent package of a transitive dependency never references
|
||||
the affected API; the flaw sits in a mode or option this code never uses;
|
||||
the argument that triggers the flaw is a constant here.
|
||||
- **`not_applicable`** — the advisory does not apply to this codebase at all:
|
||||
nothing the shipped code loads imports the package; it is build, test or
|
||||
dev tooling the artifact never executes; the installed copy is a patched or
|
||||
unaffected build; the flaw is specific to a platform this code does not run
|
||||
on; or the only code that could use it cannot be inspected (a compiled
|
||||
blob, a private bundle with no source) and nothing you can inspect reaches
|
||||
it. Evidence: the search scope and the negative result.
|
||||
**Go — use govulncheck (real call-graph analysis):**
|
||||
|
||||
The verdict is per CVE, not per package. Sibling CVEs on one package usually
|
||||
affect different symbols or modes (one hits a parser, another a header
|
||||
check): run the check against each advisory's own affected functions and
|
||||
never copy one CVE's verdict or evidence onto another. The import check
|
||||
(step 1) is the only step shared across a package's CVEs.
|
||||
```bash
|
||||
# Symbol-level: reports only vulnerabilities whose vulnerable functions are
|
||||
# actually reachable from application code. Needs the Go toolchain + module
|
||||
# deps; if either is missing, fall back to the checks below rather than
|
||||
# claiming a level.
|
||||
if command -v govulncheck >/dev/null && go version >/dev/null 2>&1; then
|
||||
govulncheck -format json ./... > "$ART/govulncheck.json" || true
|
||||
fi
|
||||
```
|
||||
|
||||
`reachable` is not an exploit, and `not_reachable` / `not_applicable` are
|
||||
not proof of safety (dynamic `import()`, reflection and framework wiring
|
||||
evade static search). Say what you checked; never write "not exploitable".
|
||||
- A finding with a call stack ⇒ `reachability=reachable_call_path`, put the
|
||||
call-path excerpt (entrypoint → vulnerable function) in
|
||||
`reachability_evidence`.
|
||||
- Listed as affecting a required module but with no reachable symbol ⇒ fall
|
||||
back to the import/symbol checks below (`imported` / `not_imported`).
|
||||
|
||||
**Procedure**
|
||||
**All other ecosystems — import check, then symbol match:**
|
||||
|
||||
1. **Import check.** Search application code (exclude lockfiles, vendored
|
||||
deps, `node_modules`, build output) for imports of the vulnerable package
|
||||
under its ecosystem import name (`PyYAML` → `yaml`): `ast-grep`/`rg` for
|
||||
`import`/`require`/`from X import`. For a **transitive** dependency nothing
|
||||
in application code imports it, so walk every introducing chain from the
|
||||
lockfile (e.g. `express > body-parser > qs`) and inspect the code of the
|
||||
package one hop above the vulnerable one on each chain (`body-parser`, not
|
||||
just `express`; its installed sources, or the class references of its
|
||||
jar). Nothing imports it and no chain loads it ⇒ `not_applicable`, citing
|
||||
the scope you searched. Loaded ⇒ continue.
|
||||
2. **Affected-symbol check, per CVE.** Read the advisory (GHSA/NVD/OSV
|
||||
`affected[].ecosystem_specific.imports`, the advisory text, the fix commit
|
||||
when the text is vague) for the functions, classes, options or modes that
|
||||
trigger the flaw. Search application code, and the parent package of a
|
||||
transitive dependency, for them (`ast-grep` pattern or `rg -n`; sibling
|
||||
CVEs can share one multi-pattern search per package). No caller after a
|
||||
complete search ⇒ `not_reachable`, citing the symbols and the scope. A
|
||||
caller ⇒ step 3. When the flaw is in code every use of the package runs
|
||||
(the parser the package exists for), the import itself is the call site ⇒
|
||||
step 3.
|
||||
3. **Source-to-sink trace.** Start at the sink (the exact line that calls the
|
||||
affected function) and walk backwards hop by hop to the source: the entry
|
||||
point that carries the input (HTTP route, CLI argument, queue or webhook
|
||||
payload, uploaded file, config value). Read each intermediate function;
|
||||
when a hop is a thin wrapper, go one step deeper — never stop at the first
|
||||
caller. Read what reaches the function: a constant argument that cannot
|
||||
contain the triggering pattern, a flag that is off, a runtime-version
|
||||
gate, a path only a dev tool runs means the vulnerable path does not run
|
||||
⇒ `not_reachable`, citing the condition. Otherwise ⇒ `reachable`. Write
|
||||
the chain into `reachability_evidence` as
|
||||
`entry point -> intermediate call -> package call` with a repo-relative
|
||||
`file:line` for every hop, what each hop enforces (authentication, a role
|
||||
check, validation, a size or type limit), and who controls the input. A
|
||||
trusted or operator-only source does not soften the verdict: the code
|
||||
runs, so it is `reachable`, and the trace is what tells the reader who can
|
||||
reach it; the contextual CVSS carries that.
|
||||
4. **Go — govulncheck first.** When the Go toolchain and module deps are
|
||||
present, `govulncheck -format json ./... > "$ART/govulncheck.json"` gives a
|
||||
real call graph: a finding with a call stack ⇒ `reachable`, with the call
|
||||
path (entrypoint → vulnerable function) as the trace; an affected module
|
||||
with no reachable symbol ⇒ `not_reachable` when application code imports
|
||||
it (cite the govulncheck result and the import), `not_applicable` when
|
||||
nothing imports it. When govulncheck cannot analyze the module (build
|
||||
errors, missing deps), run steps 1–3 instead of guessing.
|
||||
deps, `node_modules`, build output) for imports of the vulnerable package:
|
||||
`ast-grep`/`rg` for `import`/`require`/`from X import` of the package (and
|
||||
its ecosystem import name, which may differ from the registry name, e.g.
|
||||
`PyYAML` → `yaml`). No hits ⇒ `not_imported`, with the search scope stated
|
||||
in `reachability_evidence`. For a **transitive** dependency, the check is
|
||||
whether application code imports it directly; if not, it is reachable only
|
||||
through the direct dependency — check whether the direct dep's usage can
|
||||
hit it (if unclear, use `imported` when the direct dep is used at all).
|
||||
2. **Symbol match — per CVE, not per package.** Read each CVE's own advisory
|
||||
(GHSA/NVD/OSV `affected[].ecosystem_specific.imports` or the advisory
|
||||
text) for the affected functions/classes/APIs. Search application code for
|
||||
those symbols (`ast-grep` pattern or `rg -n`). Hits ⇒
|
||||
`vulnerable_symbol_used`, with repo-relative `file:line` of each hit (up
|
||||
to a handful) in `reachability_evidence`. Imported but no affected-symbol
|
||||
usage found (or the advisory names no symbols) ⇒ `imported`.
|
||||
Different CVEs on the same package usually affect **different** symbols
|
||||
(one hits a parser, another a header check) — never copy one CVE's
|
||||
verdict/evidence onto its siblings; run the symbol search against each
|
||||
CVE's own affected-symbol list. The import check (step 1) is the only
|
||||
part shared across a package's CVEs.
|
||||
3. **Source-to-sink trace — do this whenever step 2 found a symbol hit.** A
|
||||
symbol hit alone says the code calls the vulnerable API; it does not say
|
||||
who can reach it. Start at the sink (the exact line that calls the
|
||||
vulnerable function) and walk backwards hop by hop to the source: the
|
||||
entry point that carries untrusted input (HTTP route, CLI argument, queue
|
||||
or webhook payload, uploaded file, config value). Read each intermediate
|
||||
function; when a hop is a thin wrapper, go one step deeper — never stop at
|
||||
the first caller. Record what each hop enforces: authentication, a role
|
||||
check, validation, a feature flag, a size or type limit, a default that is
|
||||
off in production.
|
||||
Write the chain into `reachability_evidence` as
|
||||
`entry point -> intermediate call -> package call` with a
|
||||
repository-relative `file:line` for every hop, and say who controls the
|
||||
input. If no source reaches the sink, say that too — the level stays
|
||||
`vulnerable_symbol_used` (the call is real), and the trace is what tells
|
||||
the reader it is only reachable from, say, an operator CLI.
|
||||
4. If the analysis was not performed or is inconclusive (obfuscated code,
|
||||
dynamic loading, unparsable sources) ⇒ `unknown` and say why in
|
||||
`assumptions`.
|
||||
|
||||
Work in severity order (critical, high and KEV first) so that if time runs
|
||||
out the low tail is what is missing, and name that tail in the scan summary.
|
||||
A CVE whose analysis you did not finish is neither filed with a guessed
|
||||
verdict nor filed as `not_applicable`: finish it, or report the gap. Never let
|
||||
the usage analysis silently drop a CVE: every advisory the scanner matched is
|
||||
either filed with a verdict or named in the summary as not analyzed.
|
||||
Cheap-first budgeting: the import check is one search per package — always do
|
||||
it. Do the per-CVE symbol match for every CVE whose advisory names affected
|
||||
symbols (they can be batched into one multi-pattern search per package);
|
||||
prioritize `critical`/`high`/KEV when the budget is tight; a CVE whose symbol
|
||||
search was skipped may still be reported as `imported` (the import check is
|
||||
real evidence), but its `reachability_evidence` must state that the
|
||||
affected-symbol check was not performed, so a skipped search is never
|
||||
mistaken for a completed one with no hits. Never let this analysis stall
|
||||
reporting — `unknown` with a reason beats an unverified claim.
|
||||
|
||||
### Reachability is a verdict on the code path, not on severity
|
||||
Anti-overclaim rules:
|
||||
|
||||
Do NOT suppress or downgrade a known CVE because its vulnerable code path is
|
||||
not reached. Report it with its verdict, set `advisory_cvss` from the
|
||||
advisory, put what the analysis proved in `reachability_evidence`, and use
|
||||
`assumptions` for anything softer. If you *can* actually trigger the
|
||||
- `not_imported` still does NOT mean safe (dynamic `import()`/reflection/
|
||||
framework wiring evade static search) — never phrase it as "not exploitable".
|
||||
- `reachable_call_path` is reserved for call-graph tools (govulncheck); a
|
||||
symbol grep hit is `vulnerable_symbol_used`, no matter how convinced you are.
|
||||
- The tool rejects any level other than `unknown` without
|
||||
`reachability_evidence`.
|
||||
|
||||
### Reachability is a confidence modifier, not a gate
|
||||
|
||||
Do NOT suppress or downgrade a known CVE just because you could not prove the
|
||||
vulnerable code path is reachable. Report it, set `advisory_cvss` from the
|
||||
advisory, record the usage analysis in `reachability`/`reachability_evidence`,
|
||||
and use `assumptions` for anything softer. If you *can* actually trigger the
|
||||
vulnerable path or chain it into a dynamic exploit, additionally report that
|
||||
as a normal dynamic finding with `create_vulnerability_report` (the standalone
|
||||
CVE stays in its own `create_dependency_report`).
|
||||
@@ -234,8 +230,8 @@ findings and rejects empty PoC fields):
|
||||
pins the vulnerable version. Each entry needs the repo-relative `file`, the
|
||||
exact `start_line` / `end_line`, the verbatim `snippet` at those lines, and a
|
||||
short `label` (e.g. "imports js-yaml", "calls yaml.load on CLI input"). Read
|
||||
the real file to confirm the lines; never guess. When the verdict is
|
||||
`not_applicable`, the manifest line is the only entry. If an entry carries
|
||||
the real file to confirm the lines; never guess. When the package is
|
||||
`not_imported`, the manifest line is the only entry. If an entry carries
|
||||
`fix_before` / `fix_after`, also fill `fix_verification`.
|
||||
- **Always fill the structured dependency fields** (they power the dedicated
|
||||
dependency-report card; do not leave them only in free-text):
|
||||
@@ -264,8 +260,9 @@ findings and rejects empty PoC fields):
|
||||
- Do NOT cap severity at LOW just because there is no dynamic reproduction — use
|
||||
the advisory score.
|
||||
- Set `reachability` + `reachability_evidence` from the usage analysis above —
|
||||
the tool rejects a report without a verdict or without evidence; use
|
||||
`assumptions` for anything softer (confidence, caveats, analysis limits).
|
||||
the tool rejects a report with no evidence, so for `unknown` write what you
|
||||
searched and why the result is inconclusive;
|
||||
use `assumptions` for anything softer (confidence, caveats, analysis limits).
|
||||
- **Always set `contextual_cvss_breakdown` + `contextual_cvss_reasoning`.** Every
|
||||
dependency finding carries a contextual rating of the CVE in this codebase
|
||||
(see below). Start from the published metrics and change only what your
|
||||
@@ -299,8 +296,8 @@ evidence proves is different in this codebase:
|
||||
deployment requires before the path is reachable.
|
||||
- `scope` `U`/`C` — whether exploitation here escapes the component boundary.
|
||||
- `confidentiality`/`integrity`/`availability` `N`/`L`/`H` — the impact in this
|
||||
codebase. A `not_applicable` package the build still ships is usually `N`
|
||||
across all three.
|
||||
codebase. `not_imported` code the build still ships is usually `N` across all
|
||||
three.
|
||||
|
||||
Ground every metric in the **source-to-sink trace** from the usage analysis
|
||||
(step 3 above), not in a general impression of the package. Derive the metrics
|
||||
@@ -311,7 +308,7 @@ preconditions the hops enforce; `confidentiality`, `integrity`, and
|
||||
|
||||
When you have no source-to-sink trace, still rate the finding: copy the
|
||||
published metrics, change only the metrics the usage level itself proves, and
|
||||
say so in the reasoning. For example, for a `not_applicable` package that the
|
||||
say so in the reasoning. For example, for a `not_imported` package that the
|
||||
build still ships, keep the published metrics and lower `confidentiality`,
|
||||
`integrity`, and `availability` to `N`, because no code path reaches the
|
||||
vulnerable symbol. Never invent a hop you did not read.
|
||||
@@ -347,8 +344,8 @@ hallucinate a CVE id.
|
||||
- Do not silently drop a known CVE because it lacks a dynamic PoC — that is the
|
||||
exact failure this skill prevents.
|
||||
- Do not downgrade advisory severity for lack of dynamic reproduction.
|
||||
- Do not claim a `reachability` verdict the evidence does not prove, and do
|
||||
not file a CVE whose analysis you did not finish — report the gap instead.
|
||||
- Do not claim a `reachability` level the evidence does not prove — `unknown`
|
||||
with a reason is always acceptable; an overclaimed level never is.
|
||||
- Do not send a report without `contextual_cvss_breakdown` and
|
||||
`contextual_cvss_reasoning` — the reader rates and ranks the finding with them.
|
||||
- Do not use the contextual breakdown to quietly de-rate a CVE you could not
|
||||
|
||||
@@ -207,7 +207,7 @@ agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Credentials in shell history are a leak. For anything sensitive, use the
|
||||
auth vault (see `references/authentication.md`):
|
||||
auth vault (see [references/authentication.md](references/authentication.md)):
|
||||
|
||||
```bash
|
||||
agent-browser auth save my-app --url https://app.example.com/login \
|
||||
@@ -367,7 +367,7 @@ agent-browser click @e3
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
See `references/video-recording.md` for
|
||||
See [references/video-recording.md](references/video-recording.md) for
|
||||
codec options, GIF export, and more.
|
||||
|
||||
### Iframes
|
||||
@@ -491,8 +491,8 @@ snapshot — fall back to `eval` in the iframe's origin or use the
|
||||
|
||||
**Authentication expires mid-workflow**
|
||||
Use `--session-name <name>` or `state save`/`state load` so your session
|
||||
survives browser restarts. See `references/session-management.md`
|
||||
and `references/authentication.md`.
|
||||
survives browser restarts. See [references/session-management.md](references/session-management.md)
|
||||
and [references/authentication.md](references/authentication.md).
|
||||
|
||||
## Global flags worth knowing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
description: Browser-internals security testing for clickjacking, UI redressing, DoubleClickjacking, 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
|
||||
@@ -43,6 +43,15 @@ Draw the context graph. Security checks on `event.origin`, `event.source`, or a
|
||||
- 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.
|
||||
|
||||
### Clickjacking and UI Redressing
|
||||
|
||||
- **UI-redressing closure gate:** Never mark clickjacking as ruled out from `X-Frame-Options` or CSP `frame-ancestors` alone. Before closure, test both frame embedding and cross-window/popup retargeting.
|
||||
- Do not treat a different origin or port as protection against UI redressing. Same-origin policy blocks DOM access, not top-level navigation, `window.open()`, opener relationships, focus changes, or window closing. Test only explicitly authorized targets; if a required origin is outside scope, keep the path `needs_follow_up` and do not claim the vulnerability class is cleared.
|
||||
- For framed cases, prove the overlay and sensitive click. For DoubleClickjacking, use two real browser clicks and trace click 1 → window/focus/navigation/positioning change → click 2 → sensitive control. Never substitute scripted `.click()` calls.
|
||||
- Reproduce in the victim state required by the workflow—authenticated when necessary—with realistic user activation and stable geometry; capture window bounds and timing.
|
||||
- **Evidence-limited closure:** Verify a repeatable sensitive server-side state change with a recording or deterministic event trace and a negative control. If the exact sequence cannot be tested, record `needs_follow_up`, not `ruled_out` or `no_issue_found`. Popup-as-tab behavior, ignored geometry, unavailable OS-level input, or a manual control click are not counterevidence. Close the path only when a named mitigation blocks it, or when geometry is honored and both physical clicks are delivered without a sensitive action.
|
||||
- **Impact floor:** Successful execution of a protected consent, approval, or grant is an integrity impact even when idempotent, pre-seeded, or followed by no immediate token or data access. Use downstream capability to calibrate severity, not erase the demonstrated protected action.
|
||||
|
||||
### Client-Side Path Traversal
|
||||
|
||||
Trace the complete source-to-request pipeline:
|
||||
@@ -146,7 +155,7 @@ Also inspect DevTools network initiators, service workers, storage, CSP violatio
|
||||
|
||||
### Source Review
|
||||
|
||||
- Search for `postMessage`, message listeners, `window.open`, named targets, opener/parent access, frame creation, and sandbox attributes.
|
||||
- Search for `postMessage`, message listeners, `window.open`, named targets, opener/parent access, frame creation, `dblclick`, `mousedown`/`mouseup`, focus/blur, move/resize, navigation handlers, 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.
|
||||
@@ -161,7 +170,7 @@ Also inspect DevTools network initiators, service workers, storage, CSP violatio
|
||||
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.
|
||||
8. **State interaction requirements** - Record each physical click or drag, popup permission state, active context, focus, geometry, navigation, timing window, login state, and visual deception.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -171,7 +180,7 @@ Also inspect DevTools network initiators, service workers, storage, CSP violatio
|
||||
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.
|
||||
7. For interaction-dependent chains, show both click targets, intermediate context changes, and the resulting sensitive state change in a recording or deterministic event trace.
|
||||
|
||||
## False Positives
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: semantic-confusion
|
||||
description: Cross-component semantic confusion testing for parser differentials, normalization mismatches, email and IDNA identity drift, overloaded fields, lifecycle state drift, internal redirects, protocol translation, and validator-to-sink inconsistencies
|
||||
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
|
||||
@@ -59,27 +59,6 @@ The highest-signal condition is `security_check(value_A)` followed by `sink(tran
|
||||
- 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.
|
||||
|
||||
### Email and Account Identity Drift
|
||||
|
||||
Treat an email address as both a principal selector and a delivery destination. Trace the complete boundary before testing:
|
||||
|
||||
```text
|
||||
submitted or identity-provider address
|
||||
-> application normalization / IDNA
|
||||
-> database comparison and collation
|
||||
-> selected local principal
|
||||
-> token, session, invitation, or link binding
|
||||
-> recipient or linked identity consumed by the final service
|
||||
```
|
||||
|
||||
- Cover recovery, magic links, invitations, registration uniqueness, email changes, and OAuth/OIDC account linking. Test case/accent-insensitive collations, Unicode normalization, IDNA U-label/A-label conversion, trailing dots, and provider canonicalization only when the observed stack supports them.
|
||||
- In black-box testing, report the observed equivalence and routing behavior. Do not name a specific collation, normalizer, or provider algorithm without source, configuration, or version evidence.
|
||||
- Determine whether two non-identical addresses can select the same protected principal while SMTP or an identity provider routes them to different owners. The address that receives authority must identify the same principal selected by the security check.
|
||||
- In recovery flows, compare the submitted address with the selected account's stored canonical address. A fuzzy lookup followed by delivery to the submitted value is high risk; an exact lookup plus delivery to the stored value is the primary negative control.
|
||||
- Use only an explicitly authorized attacker-controlled mailbox or test domain. Derive a bounded variant set from the deployed collation and canonicalizers rather than spraying external domains or the Unicode range.
|
||||
|
||||
For a recovery, magic-link, or invitation finding, a generic success response is not proof. Demonstrate that the variant selects the protected principal, the issued authority reaches the controlled mailbox or identity, and redemption yields the protected principal's access. If delivery or redemption cannot be observed, keep the path `needs_follow_up`.
|
||||
|
||||
### 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.
|
||||
@@ -191,8 +170,6 @@ A valid confusion finding should include:
|
||||
- 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
|
||||
- A Unicode or Punycode lookalike that neither selects another principal nor receives authority intended for that principal
|
||||
- A permissive database comparison where recovery delivery still uses the selected account's stored canonical address
|
||||
- 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
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""In-memory bearer refresh for HTTP MCP connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RefreshingBearerAuth(httpx.Auth):
|
||||
def __init__(
|
||||
self, token: str | None, refresh_token: Callable[[], Awaitable[str | None]]
|
||||
) -> None:
|
||||
self._token = token
|
||||
self._refresh_token = refresh_token
|
||||
self._lock = asyncio.Lock()
|
||||
self._refresh_generation = 0
|
||||
|
||||
def _authorize(self, request: httpx.Request, token: str | None) -> None:
|
||||
if token:
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
else:
|
||||
request.headers.pop("Authorization", None)
|
||||
|
||||
async def async_auth_flow(
|
||||
self, request: httpx.Request
|
||||
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
sent_token = self._token
|
||||
sent_generation = self._refresh_generation
|
||||
self._authorize(request, sent_token)
|
||||
response = yield request
|
||||
if response.status_code != 401:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._token == sent_token and self._refresh_generation == sent_generation:
|
||||
try:
|
||||
token = await self._refresh_token()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("MCP bearer refresh failed (%s)", type(exc).__name__)
|
||||
token = None
|
||||
self._refresh_generation += 1
|
||||
if token and token != sent_token:
|
||||
self._token = token
|
||||
retry_token = self._token
|
||||
if retry_token and retry_token != sent_token:
|
||||
self._authorize(request, retry_token)
|
||||
yield request
|
||||
@@ -39,11 +39,10 @@ from strix.tools.mcp.session import McpConnectionUnavailableError, SupervisedMcp
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from strix.tools.mcp.auth import RefreshingBearerAuth
|
||||
from strix.tools.mcp.config import McpConnectionConfig
|
||||
from strix.tools.mcp.registry import McpConnectionRequest, McpRegistry
|
||||
|
||||
@@ -122,9 +121,7 @@ class _QuietMCPServerStdio(MCPServerStdio):
|
||||
return _quiet_stdio_streams(self.params)
|
||||
|
||||
|
||||
def _build_server(
|
||||
config: McpConnectionConfig, bearer_auth: RefreshingBearerAuth | None = None
|
||||
) -> BuiltMcpServer:
|
||||
def _build_server(config: McpConnectionConfig) -> BuiltMcpServer:
|
||||
"""Construct (but do not connect) the SDK server for one connection.
|
||||
|
||||
The returned tuple carries the server and, for HTTP connections, a recorder
|
||||
@@ -164,13 +161,13 @@ def _build_server(
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=bearer_auth or auth)
|
||||
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
|
||||
client.event_hooks.setdefault("response", []).append(recorder)
|
||||
return client
|
||||
|
||||
http_params: MCPServerStreamableHttpParams = {
|
||||
"url": cast("str", config.url),
|
||||
"headers": {} if bearer_auth is not None else _auth_headers(config),
|
||||
"headers": _auth_headers(config),
|
||||
"timeout": config.http_timeout_seconds,
|
||||
"sse_read_timeout": config.sse_read_timeout_seconds,
|
||||
"httpx_client_factory": httpx_client_factory,
|
||||
@@ -303,12 +300,9 @@ async def connect_mcp_servers(
|
||||
configs: list[McpConnectionConfig],
|
||||
*,
|
||||
max_concurrency: int = 6,
|
||||
token_refreshers: Mapping[str, Callable[[], Awaitable[str | None]]] | None = None,
|
||||
) -> list[ConnectedMcpServer]:
|
||||
"""Connect MCP configs concurrently under a fixed bound.
|
||||
|
||||
``token_refreshers`` optionally supplies a bearer refresher by connection name.
|
||||
|
||||
Each connection becomes a :class:`~strix.tools.mcp.session.SupervisedMcpSession`
|
||||
that owns ``connect()``, the held-open session, and ``cleanup()`` on one
|
||||
dedicated task, so a later background failure in one session is contained to
|
||||
@@ -331,10 +325,7 @@ async def connect_mcp_servers(
|
||||
|
||||
async def connect_one(config: McpConnectionConfig) -> ConnectedMcpServer | None:
|
||||
async with semaphore:
|
||||
session = SupervisedMcpSession(
|
||||
config,
|
||||
refresh_token=token_refreshers.get(config.name) if token_refreshers else None,
|
||||
)
|
||||
session = SupervisedMcpSession(config)
|
||||
sessions.append(session)
|
||||
try:
|
||||
if not await session.start():
|
||||
@@ -390,14 +381,7 @@ async def attach_mcp_requests(
|
||||
ends).
|
||||
"""
|
||||
request_by_name = {request.config.name: request for request in requests}
|
||||
connections = await connect_mcp_servers(
|
||||
[request.config for request in requests],
|
||||
token_refreshers={
|
||||
request.config.name: request.refresh_token
|
||||
for request in requests
|
||||
if request.refresh_token is not None
|
||||
},
|
||||
)
|
||||
connections = await connect_mcp_servers([request.config for request in requests])
|
||||
for connection in connections:
|
||||
request = request_by_name[connection.name]
|
||||
registry.add(
|
||||
@@ -407,6 +391,5 @@ async def attach_mcp_requests(
|
||||
purpose=request.purpose or connection.notes,
|
||||
provider=request.provider,
|
||||
result_transform=request.result_transform,
|
||||
refresh_token=request.refresh_token,
|
||||
)
|
||||
return connections
|
||||
@@ -129,20 +129,12 @@ def classify(exc: BaseException) -> FailureInfo:
|
||||
|
||||
|
||||
class HttpStatusRecorder:
|
||||
"""Capture the last non-success response from one HTTP connection.
|
||||
|
||||
A later success clears a recorded 401, so a bearer refreshed and retried
|
||||
in the auth flow does not leave a stale auth failure behind.
|
||||
"""
|
||||
"""Capture the last non-success response from one HTTP connection."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._failure: FailureInfo | None = None
|
||||
|
||||
async def __call__(self, response: httpx.Response) -> None:
|
||||
if 200 <= response.status_code < 300:
|
||||
if self._failure is not None and self._failure.kind == "auth":
|
||||
self._failure = None
|
||||
return
|
||||
if not 200 <= response.status_code < 300:
|
||||
request = response.request
|
||||
self._failure = _from_status(
|
||||
|
||||
@@ -32,7 +32,7 @@ from strix.tools.mcp.session import McpConnectionUnavailableError, SupervisedMcp
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
from agents.mcp import MCPServer
|
||||
|
||||
@@ -96,9 +96,6 @@ class McpConnectionEntry:
|
||||
tool_count: int = 0
|
||||
result_transform: ResultTransform | None = None
|
||||
provider: str | None = None
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = dataclasses.field(
|
||||
default=None, repr=False
|
||||
)
|
||||
state: McpConnectionState = "configured"
|
||||
_catalog: list[Any] | None = dataclasses.field(default=None, repr=False)
|
||||
_connect_task: asyncio.Task[SupervisedMcpSession] | None = dataclasses.field(
|
||||
@@ -183,7 +180,7 @@ class McpConnectionEntry:
|
||||
f"MCP connection {self.name!r} has no connection configuration."
|
||||
)
|
||||
self._set_state("connecting")
|
||||
session = SupervisedMcpSession(config, refresh_token=self.refresh_token)
|
||||
session = SupervisedMcpSession(config)
|
||||
try:
|
||||
started = await session.start()
|
||||
except BaseException:
|
||||
@@ -301,17 +298,12 @@ class McpConnectionRequest:
|
||||
at the single dispatch point (strix-pro's sanitizer; empty for the
|
||||
command-line path). ``purpose`` is the human label ``list_mcps`` reports as the
|
||||
connection's description; when unset it falls back to ``config.notes``.
|
||||
``refresh_token`` optionally fetches a new bearer asynchronously after an HTTP
|
||||
401; returning no token or the current token leaves the failure unchanged.
|
||||
"""
|
||||
|
||||
config: McpConnectionConfig
|
||||
provider: str | None = None
|
||||
result_transform: ResultTransform | None = None
|
||||
purpose: str | None = None
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = dataclasses.field(
|
||||
default=None, repr=False
|
||||
)
|
||||
|
||||
|
||||
class McpCallInfo(NamedTuple):
|
||||
@@ -345,7 +337,6 @@ class McpRegistry:
|
||||
purpose=request.purpose or request.config.notes,
|
||||
result_transform=request.result_transform,
|
||||
provider=request.provider,
|
||||
refresh_token=request.refresh_token,
|
||||
)
|
||||
entry.set_status_sink(self._status_sink)
|
||||
self._entries[entry.name] = entry
|
||||
@@ -362,7 +353,6 @@ class McpRegistry:
|
||||
tool_count: int = 0,
|
||||
result_transform: ResultTransform | None = None,
|
||||
provider: str | None = None,
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = None,
|
||||
) -> McpConnectionEntry:
|
||||
"""Register one connection under ``name`` (last write wins).
|
||||
|
||||
@@ -384,7 +374,6 @@ class McpRegistry:
|
||||
tool_count=tool_count,
|
||||
result_transform=result_transform,
|
||||
provider=provider,
|
||||
refresh_token=refresh_token,
|
||||
state="connected",
|
||||
)
|
||||
entry.set_status_sink(self._status_sink)
|
||||
|
||||
@@ -54,7 +54,6 @@ import time
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from strix.tools.mcp.auth import RefreshingBearerAuth
|
||||
from strix.tools.mcp.config import DEFAULT_MAX_CONCURRENT_CALLS
|
||||
from strix.tools.mcp.failures import FailureInfo, HttpStatusRecorder, classify
|
||||
|
||||
@@ -158,19 +157,9 @@ class SupervisedMcpSession:
|
||||
:attr:`name`, :attr:`server`, :attr:`config`, :attr:`is_dead`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: McpConnectionConfig,
|
||||
*,
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = None,
|
||||
) -> None:
|
||||
def __init__(self, config: McpConnectionConfig) -> None:
|
||||
self._name = config.name
|
||||
self._config: McpConnectionConfig | None = config
|
||||
self._bearer_auth = (
|
||||
RefreshingBearerAuth(config.auth.token if config.auth else None, refresh_token)
|
||||
if refresh_token is not None and config.transport == "http"
|
||||
else None
|
||||
)
|
||||
self._server: MCPServer | None = None
|
||||
self._supervised = True
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
@@ -204,7 +193,6 @@ class SupervisedMcpSession:
|
||||
self = cls.__new__(cls)
|
||||
self._name = name
|
||||
self._config = config
|
||||
self._bearer_auth = None
|
||||
self._server = server
|
||||
self._supervised = False
|
||||
self._task = None
|
||||
@@ -713,11 +701,7 @@ class SupervisedMcpSession:
|
||||
|
||||
if self._config is None:
|
||||
raise RuntimeError(f"MCP connection {self._name!r} has no config to connect")
|
||||
built = (
|
||||
_build_server(self._config, self._bearer_auth)
|
||||
if self._bearer_auth is not None
|
||||
else _build_server(self._config)
|
||||
)
|
||||
built = _build_server(self._config)
|
||||
server = built.server
|
||||
self._recorder = built.recorder
|
||||
try:
|
||||
|
||||
@@ -1689,7 +1689,15 @@ def _dependency_severity(advisory_cvss: float | None) -> tuple[float, str]:
|
||||
return score, "none"
|
||||
|
||||
|
||||
_VALID_REACHABILITY = frozenset({"reachable", "not_reachable", "not_applicable"})
|
||||
_VALID_REACHABILITY = frozenset(
|
||||
{
|
||||
"not_imported",
|
||||
"imported",
|
||||
"vulnerable_symbol_used",
|
||||
"reachable_call_path",
|
||||
"unknown",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_path(manifest_path: str | None) -> str | None:
|
||||
@@ -1830,13 +1838,11 @@ def _build_dependency_metadata(
|
||||
|
||||
|
||||
_REACHABILITY_EVIDENCE_LABELS = {
|
||||
"reachable": "the advisory's vulnerable code runs in this codebase",
|
||||
"not_reachable": (
|
||||
"the package is loaded, but this advisory's vulnerable code does not run here"
|
||||
),
|
||||
"not_applicable": (
|
||||
"the advisory does not apply here: the vulnerable package is not loaded, "
|
||||
"or the installed copy is not affected"
|
||||
"not_imported": "not imported by application code",
|
||||
"imported": "imported by application code; affected API usage unconfirmed",
|
||||
"vulnerable_symbol_used": "the advisory's affected API is used in application code",
|
||||
"reachable_call_path": (
|
||||
"a call path from application code to the vulnerable function was proven"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1893,7 +1899,7 @@ async def _do_create_dependency( # noqa: PLR0912, PLR0915
|
||||
introduced_by: str | None = None,
|
||||
dependency_path: str | None = None,
|
||||
manifest_path: str | None = None,
|
||||
reachability: str | None = None,
|
||||
reachability: str = "unknown",
|
||||
reachability_evidence: str | None = None,
|
||||
code_locations: list[dict[str, Any]] | None = None,
|
||||
fix_verification: str | None = None,
|
||||
@@ -1949,22 +1955,17 @@ async def _do_create_dependency( # noqa: PLR0912, PLR0915
|
||||
)
|
||||
errors.extend(_validate_fix_verification(parsed_locations, fix_verification))
|
||||
|
||||
reachability = (reachability or "").strip().lower()
|
||||
if not reachability:
|
||||
errors.append(
|
||||
"reachability is required: 'reachable', 'not_reachable' or 'not_applicable'. "
|
||||
"There is no 'unknown': finish the usage analysis for this CVE before filing it."
|
||||
)
|
||||
elif reachability not in _VALID_REACHABILITY:
|
||||
reachability = (reachability or "unknown").strip().lower()
|
||||
if reachability not in _VALID_REACHABILITY:
|
||||
errors.append(
|
||||
f"Invalid reachability: {reachability!r}. Must be one of: {sorted(_VALID_REACHABILITY)}"
|
||||
)
|
||||
elif not (reachability_evidence or "").strip():
|
||||
errors.append(
|
||||
"reachability_evidence is required: cite the concrete proof (the call site "
|
||||
"and its source-to-sink trace for 'reachable'; the complete search or the "
|
||||
"condition that keeps the vulnerable path from running for 'not_reachable'; "
|
||||
"the search scope for 'not_applicable'). Never claim a verdict without evidence."
|
||||
"reachability_evidence is required: cite the concrete proof (import "
|
||||
"file:line, matched symbol usage, or govulncheck call path), or, for "
|
||||
"'unknown', say what you searched and why the result is inconclusive. "
|
||||
"Never claim a reachability level without evidence."
|
||||
)
|
||||
|
||||
errors.extend(_validate_contextual_cvss(contextual_cvss_breakdown, contextual_cvss_reasoning))
|
||||
@@ -2114,7 +2115,7 @@ async def create_dependency_report(
|
||||
fix_effort: str = "low",
|
||||
introduced_by: str | None = None,
|
||||
dependency_path: str | None = None,
|
||||
reachability: str | None = None,
|
||||
reachability: str = "unknown",
|
||||
reachability_evidence: str | None = None,
|
||||
code_locations: list[dict[str, Any]] | None = None,
|
||||
fix_verification: str | None = None,
|
||||
@@ -2144,26 +2145,23 @@ async def create_dependency_report(
|
||||
- Re-reporting the same CVE/package already filed.
|
||||
|
||||
**Reachability**: do NOT silently downgrade or suppress a finding
|
||||
because the vulnerable code path is not reached — report it, and record
|
||||
the verdict of the usage analysis in the structured ``reachability`` +
|
||||
``reachability_evidence`` fields (see the dependency-cve-scanning skill
|
||||
for the procedure). The verdict answers one question for this advisory
|
||||
in this codebase, "does the vulnerable code run here?", and the tool
|
||||
rejects a report without it or without evidence:
|
||||
because the vulnerable code path may be unreachable — report it, and
|
||||
record what the usage analysis showed via the structured
|
||||
``reachability`` + ``reachability_evidence`` fields (see the
|
||||
dependency-cve-scanning skill for the analysis procedure). The level
|
||||
is an evidence ladder, never an exploitability verdict:
|
||||
|
||||
- ``reachable`` — the advisory's vulnerable code executes here:
|
||||
application code, or a package it calls on the application's behalf,
|
||||
calls the affected function, mode or feature.
|
||||
- ``not_reachable`` — the package is loaded, but this advisory's
|
||||
vulnerable path does not execute here (no caller of the affected API
|
||||
after a complete search, a call behind a condition that is false here,
|
||||
a parent package that never references the affected API, a mode this
|
||||
code never uses, a triggering argument that is a constant here).
|
||||
- ``not_applicable`` — the advisory does not apply to this codebase:
|
||||
nothing the shipped code loads imports the package, it is build or
|
||||
test tooling the artifact never runs, the installed copy is a patched
|
||||
or unaffected build, or the flaw is specific to a platform this code
|
||||
does not run on.
|
||||
- ``not_imported`` — the package is never imported/required by
|
||||
application code (strongest de-prioritization signal; still not
|
||||
proof of safety — dynamic loading, reflection, or framework wiring
|
||||
can evade static search).
|
||||
- ``imported`` — application code imports the package, but usage of
|
||||
the advisory's affected API was not confirmed.
|
||||
- ``vulnerable_symbol_used`` — the advisory's affected
|
||||
function/class/API appears in application code.
|
||||
- ``reachable_call_path`` — a call-graph tool (e.g. ``govulncheck``)
|
||||
proved a path from application code to the vulnerable function.
|
||||
- ``unknown`` — usage analysis was not performed or was inconclusive.
|
||||
|
||||
Severity comes from ``contextual_cvss_breakdown`` when you provide one
|
||||
(computed exactly like a normal finding's ``cvss_breakdown``), otherwise
|
||||
@@ -2211,24 +2209,26 @@ async def create_dependency_report(
|
||||
checkout directory prefix so the path is relative to the
|
||||
repository root. This binds the finding to its exact file so
|
||||
remediation can target the right repository.
|
||||
reachability: **Required.** The verdict of the usage analysis for
|
||||
this CVE — ``reachable`` / ``not_reachable`` / ``not_applicable``.
|
||||
Claim only what the evidence proves; there is no ``unknown``,
|
||||
finish the analysis before filing.
|
||||
reachability: Usage-evidence level from static analysis — one of
|
||||
``not_imported`` / ``imported`` / ``vulnerable_symbol_used`` /
|
||||
``reachable_call_path`` / ``unknown``. Claim only what the
|
||||
evidence proves; when in doubt use ``unknown``.
|
||||
reachability_evidence: **Required.** The concrete proof for the
|
||||
verdict: repo-relative ``file:line`` of the import and of the
|
||||
call that reaches the affected symbol, the search scope and
|
||||
negative result behind ``not_applicable``, or the condition that
|
||||
keeps the vulnerable path from running behind ``not_reachable``.
|
||||
Whenever the affected symbol is called, also give the
|
||||
claimed level, or, for ``unknown``, what you searched and why
|
||||
the result is inconclusive: repo-relative
|
||||
``file:line`` of the import or symbol usage, the matched
|
||||
advisory symbols, or the govulncheck call-path excerpt.
|
||||
Whenever you found the vulnerable symbol in use, also give the
|
||||
**source-to-sink trace** here: start at the vulnerable package
|
||||
call site and walk backwards hop by hop to the entry point
|
||||
that carries the input (HTTP route, CLI argument, queue
|
||||
that carries untrusted input (HTTP route, CLI argument, queue
|
||||
message, webhook, config file), going one step deeper whenever
|
||||
a hop is a wrapper. Write it as ``entry point -> intermediate
|
||||
call -> package call`` with a ``file:line`` per hop, name what
|
||||
each hop enforces (auth, role check, validation, a flag that
|
||||
is off in production), and say who controls the input.
|
||||
is off in production), and say who controls the input. State
|
||||
it plainly when no entry point reaches the sink — that is the
|
||||
most useful result a reader can get.
|
||||
code_locations: Where application code imports or calls the package —
|
||||
the evidence behind your ``reachability`` claim. List of dicts,
|
||||
same shape as ``create_vulnerability_report``::
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
"""HTTP bearer refresh regressions without network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp.types import CallToolResult, TextContent, Tool
|
||||
|
||||
from strix.tools.mcp import BearerAuth, McpConnectionConfig, McpConnectionRequest, McpRegistry
|
||||
from strix.tools.mcp import client as mcp_client
|
||||
from strix.tools.mcp import session as mcp_session
|
||||
from strix.tools.mcp.auth import RefreshingBearerAuth
|
||||
|
||||
|
||||
def _config() -> McpConnectionConfig:
|
||||
return McpConnectionConfig(
|
||||
name="refresh",
|
||||
url="https://example.test/mcp",
|
||||
auth=BearerAuth(token="old"), # noqa: S106 # nosec B106
|
||||
)
|
||||
|
||||
|
||||
def _client(auth: RefreshingBearerAuth | None, handler: Any) -> httpx.AsyncClient:
|
||||
built = mcp_client._build_server(_config(), auth)
|
||||
params = cast("Any", built.server).params
|
||||
assert params["headers"] == ({} if auth else {"Authorization": "Bearer old"})
|
||||
client = cast("httpx.AsyncClient", params["httpx_client_factory"](headers=params["headers"]))
|
||||
client._transport = httpx.MockTransport(handler)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_retries_once_and_persists_across_server_builds() -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
session = mcp_session.SupervisedMcpSession(_config(), refresh_token=refresh)
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.headers["Authorization"])
|
||||
return httpx.Response(200 if seen[-1] == "Bearer new" else 401)
|
||||
|
||||
for _ in range(2):
|
||||
async with _client(session._bearer_auth, handler) as client:
|
||||
response = await client.post("https://example.test/mcp", content=b"payload")
|
||||
assert response.status_code == 200
|
||||
assert seen == ["Bearer old", "Bearer new", "Bearer new"]
|
||||
refresh.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("replacement", ["new", None, "old", "raises"])
|
||||
async def test_concurrent_unauthorized_requests_share_refresh(replacement: str | None) -> None:
|
||||
refresh = AsyncMock(return_value=replacement)
|
||||
if replacement == "raises":
|
||||
refresh.side_effect = ValueError("refresh unavailable")
|
||||
auth = RefreshingBearerAuth("old", refresh)
|
||||
both_sent = asyncio.Event()
|
||||
old_requests = 0
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal old_requests
|
||||
if request.headers["Authorization"] == "Bearer old":
|
||||
old_requests += 1
|
||||
if old_requests == 5:
|
||||
both_sent.set()
|
||||
await both_sent.wait()
|
||||
return httpx.Response(401)
|
||||
return httpx.Response(200)
|
||||
|
||||
async with _client(auth, handler) as client:
|
||||
responses = await asyncio.wait_for(
|
||||
asyncio.gather(*(client.get("https://example.test/mcp") for _ in range(5))),
|
||||
timeout=2,
|
||||
)
|
||||
assert [response.status_code for response in responses] == [
|
||||
200 if replacement == "new" else 401
|
||||
] * 5
|
||||
assert old_requests == 5
|
||||
refresh.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("replacement", [None, "", "old", "new", "raises", "disabled"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecovered_401_still_retires_connection(
|
||||
replacement: str | None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
refresh = AsyncMock(return_value=replacement)
|
||||
if replacement == "raises":
|
||||
refresh.side_effect = ValueError("private-token-value")
|
||||
auth = None if replacement == "disabled" else RefreshingBearerAuth("old", refresh)
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.headers["Authorization"])
|
||||
return httpx.Response(401)
|
||||
|
||||
async with _client(auth, handler) as client:
|
||||
server = AsyncMock()
|
||||
|
||||
async def call_tool(*_args: Any, **_kwargs: Any) -> None:
|
||||
response = await client.get("https://example.test/mcp")
|
||||
response.raise_for_status()
|
||||
|
||||
server.call_tool = call_tool
|
||||
session = mcp_session.SupervisedMcpSession.adopt(server, name="refresh")
|
||||
result = await session.dispatch("read", {}, label="refresh_read")
|
||||
assert result["success"] is False
|
||||
assert session.is_dead
|
||||
await session.aclose()
|
||||
assert seen == (["Bearer old", "Bearer new"] if replacement == "new" else ["Bearer old"])
|
||||
assert refresh.await_count == (0 if replacement == "disabled" else 1)
|
||||
assert "private-token-value" not in caplog.text
|
||||
if replacement == "raises":
|
||||
assert "ValueError" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_401_does_not_refresh() -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
auth = RefreshingBearerAuth("old", refresh)
|
||||
async with _client(auth, lambda _: httpx.Response(403)) as client:
|
||||
assert (await client.get("https://example.test/mcp")).status_code == 403
|
||||
refresh.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_passes_refresher_to_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
registry = McpRegistry()
|
||||
entry = registry.register(McpConnectionRequest(_config(), refresh_token=refresh))
|
||||
server = AsyncMock()
|
||||
captured: list[RefreshingBearerAuth] = []
|
||||
|
||||
def build(_config: McpConnectionConfig, auth: RefreshingBearerAuth) -> Any:
|
||||
captured.append(auth)
|
||||
return mcp_client.BuiltMcpServer(server, None)
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", build)
|
||||
session = await entry.ensure_connected()
|
||||
assert entry.refresh_token is refresh
|
||||
assert captured == [session._bearer_auth]
|
||||
await session._open()
|
||||
assert captured == [session._bearer_auth, session._bearer_auth]
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refreshed_token_can_refresh_again_after_expiry() -> None:
|
||||
refresh = AsyncMock(side_effect=["new", "newer"])
|
||||
auth = RefreshingBearerAuth("old", refresh)
|
||||
valid = "new"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200 if request.headers["Authorization"] == f"Bearer {valid}" else 401)
|
||||
|
||||
async with _client(auth, handler) as client:
|
||||
assert (await client.get("https://example.test/mcp")).status_code == 200
|
||||
valid = "newer"
|
||||
assert (await client.get("https://example.test/mcp")).status_code == 200
|
||||
assert refresh.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_refresher", [True, False])
|
||||
@pytest.mark.asyncio
|
||||
async def test_eager_attachment_preserves_bearer_refresh(
|
||||
monkeypatch: pytest.MonkeyPatch, with_refresher: bool
|
||||
) -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
original_build = mcp_client._build_server
|
||||
valid = "old"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200 if request.headers["Authorization"] == f"Bearer {valid}" else 401)
|
||||
|
||||
def build(config: McpConnectionConfig, auth: RefreshingBearerAuth | None = None) -> Any:
|
||||
built = original_build(config, auth)
|
||||
params = cast("Any", built.server).params
|
||||
client = params["httpx_client_factory"](headers=params["headers"])
|
||||
client._transport = httpx.MockTransport(handler)
|
||||
server = AsyncMock()
|
||||
server.use_structured_content = False
|
||||
server.list_tools.return_value = [Tool(name="read", inputSchema={"type": "object"})]
|
||||
server.cleanup.side_effect = client.aclose
|
||||
|
||||
async def call_tool(*_args: Any, **_kwargs: Any) -> CallToolResult:
|
||||
response = await client.post("https://example.test/mcp")
|
||||
response.raise_for_status()
|
||||
return CallToolResult(content=[TextContent(type="text", text="ok")])
|
||||
|
||||
server.call_tool = call_tool
|
||||
return mcp_client.BuiltMcpServer(server, built.recorder)
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", build)
|
||||
registry = McpRegistry()
|
||||
try:
|
||||
connections = await mcp_client.attach_mcp_requests(
|
||||
[McpConnectionRequest(_config(), refresh_token=refresh if with_refresher else None)],
|
||||
registry,
|
||||
)
|
||||
assert len(connections) == 1
|
||||
entry = registry.get("refresh")
|
||||
assert entry is not None
|
||||
assert entry.refresh_token is (refresh if with_refresher else None)
|
||||
session = await entry.ensure_connected()
|
||||
assert await session.dispatch("read", {}, label="refresh_read") == {
|
||||
"type": "text",
|
||||
"text": "ok",
|
||||
}
|
||||
valid = "new"
|
||||
result = await session.dispatch("read", {}, label="refresh_read")
|
||||
if with_refresher:
|
||||
assert result == {"type": "text", "text": "ok"}
|
||||
assert not session.is_dead
|
||||
refresh.assert_awaited_once()
|
||||
else:
|
||||
assert result["success"] is False
|
||||
assert session.is_dead
|
||||
finally:
|
||||
await registry.close()
|
||||
@@ -111,15 +111,6 @@ async def test_retry_after_parses_seconds_and_http_date() -> None:
|
||||
assert 0 <= retry_after <= 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_response_clears_previous_auth_failure() -> None:
|
||||
recorder = HttpStatusRecorder()
|
||||
await recorder(_http_error(401).response)
|
||||
request = httpx.Request("POST", "https://provider.example/tools")
|
||||
await recorder(httpx.Response(200, request=request))
|
||||
assert recorder.take() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recorder_only_keeps_non_sensitive_request_metadata() -> None:
|
||||
recorder = HttpStatusRecorder()
|
||||
|
||||
@@ -336,7 +336,7 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
@@ -357,7 +357,7 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta
|
||||
"package_ecosystem": "npm",
|
||||
"manifest_path": "package-lock.json",
|
||||
"fixed_version": "4.17.21",
|
||||
"reachability": "not_reachable",
|
||||
"reachability": "imported",
|
||||
"reachability_evidence": _DEP_EVIDENCE,
|
||||
"contextual_cvss_breakdown": _DEP_CONTEXT,
|
||||
"contextual_cvss_score": pytest.approx(7.5, abs=0.05),
|
||||
@@ -386,7 +386,7 @@ async def test_dependency_report_records_transitive_chain(report_state: ReportSt
|
||||
fix_effort="trivial",
|
||||
introduced_by="[email protected]",
|
||||
dependency_path="[email protected] > [email protected] > [email protected]",
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
@@ -427,7 +427,7 @@ async def test_dependency_report_persists_code_locations(report_state: ReportSta
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="reachable",
|
||||
reachability="vulnerable_symbol_used",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[
|
||||
{
|
||||
@@ -468,7 +468,7 @@ async def test_dependency_report_fix_locations_require_verification(
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[
|
||||
{
|
||||
@@ -509,7 +509,7 @@ async def test_dependency_report_fix_locations_persist_with_verification(
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[
|
||||
{
|
||||
@@ -556,7 +556,7 @@ async def test_dependency_report_rejects_unusable_locations(
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
code_locations=[{"file": "/abs/path.ts", "snippet": "require('lodash')"}],
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
@@ -587,7 +587,7 @@ async def test_dependency_report_omits_blank_chain_fields(report_state: ReportSt
|
||||
fix_effort="trivial",
|
||||
introduced_by=" ",
|
||||
dependency_path=None,
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
@@ -618,7 +618,7 @@ async def test_dependency_report_with_no_contextual_impact_is_info(
|
||||
advisory_cvss=0.0,
|
||||
technical_analysis=None,
|
||||
fix_effort="low",
|
||||
reachability="not_applicable",
|
||||
reachability="not_imported",
|
||||
reachability_evidence="No file imports the package.",
|
||||
contextual_cvss_breakdown={**_DEP_CONTEXT, "availability": "N"},
|
||||
contextual_cvss_reasoning="No application code imports the package.",
|
||||
@@ -649,7 +649,7 @@ async def test_dependency_report_records_reachability(report_state: ReportState)
|
||||
advisory_cvss=7.2,
|
||||
technical_analysis=None,
|
||||
fix_effort="low",
|
||||
reachability="reachable",
|
||||
reachability="vulnerable_symbol_used",
|
||||
reachability_evidence="src/render.ts:14 calls `_.template()`.",
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
@@ -657,7 +657,7 @@ async def test_dependency_report_records_reachability(report_state: ReportState)
|
||||
|
||||
assert result["success"] is True
|
||||
report = report_state.vulnerability_reports[0]
|
||||
assert report["dependency_metadata"]["reachability"] == "reachable"
|
||||
assert report["dependency_metadata"]["reachability"] == "vulnerable_symbol_used"
|
||||
assert (
|
||||
report["dependency_metadata"]["reachability_evidence"]
|
||||
== "src/render.ts:14 calls `_.template()`."
|
||||
@@ -688,7 +688,7 @@ async def test_dependency_report_rejects_reachability_without_evidence(
|
||||
advisory_cvss=5.0,
|
||||
technical_analysis=None,
|
||||
fix_effort="low",
|
||||
reachability="not_applicable",
|
||||
reachability="not_imported",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
@@ -725,7 +725,7 @@ async def test_dependency_report_rejects_unknown_reachability_level(
|
||||
assert not report_state.vulnerability_reports
|
||||
|
||||
|
||||
async def test_dependency_report_requires_a_reachability_verdict(report_state: ReportState) -> None:
|
||||
async def test_dependency_report_records_unknown_reachability(report_state: ReportState) -> None:
|
||||
result = await _do_create_dependency(
|
||||
title="CVE-2024-0001 in sample 1.0.0",
|
||||
description="Published advisory affects the pinned version.",
|
||||
@@ -748,9 +748,10 @@ async def test_dependency_report_requires_a_reachability_verdict(report_state: R
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
)
|
||||
|
||||
assert result["success"] is False, result
|
||||
assert any("reachability is required" in e for e in result["errors"])
|
||||
assert not report_state.vulnerability_reports
|
||||
assert result["success"] is True, result
|
||||
metadata = report_state.vulnerability_reports[0]["dependency_metadata"]
|
||||
assert metadata["reachability"] == "unknown"
|
||||
assert metadata["reachability_evidence"] == "Grep for the package found no import."
|
||||
|
||||
|
||||
async def test_dependency_report_requires_advisory_cvss(report_state: ReportState) -> None:
|
||||
@@ -827,7 +828,7 @@ async def test_dependency_report_dedupe_candidate_includes_dependency_metadata(
|
||||
advisory_cvss=0.0,
|
||||
technical_analysis=None,
|
||||
fix_effort="low",
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
contextual_cvss_breakdown=_DEP_CONTEXT,
|
||||
contextual_cvss_reasoning=_DEP_REASONING,
|
||||
@@ -846,7 +847,7 @@ async def test_dependency_report_dedupe_candidate_includes_dependency_metadata(
|
||||
"package_ecosystem": "npm",
|
||||
"manifest_path": "package-lock.json",
|
||||
"fixed_version": "1.0.1",
|
||||
"reachability": "not_reachable",
|
||||
"reachability": "imported",
|
||||
"reachability_evidence": _DEP_EVIDENCE,
|
||||
"contextual_cvss_breakdown": _DEP_CONTEXT,
|
||||
"contextual_cvss_score": pytest.approx(7.5, abs=0.05),
|
||||
@@ -1380,7 +1381,7 @@ async def test_dependency_report_computes_contextual_cvss(
|
||||
cwe="CWE-94",
|
||||
fix_effort="trivial",
|
||||
manifest_path="package-lock.json",
|
||||
reachability="reachable",
|
||||
reachability="vulnerable_symbol_used",
|
||||
reachability_evidence="scripts/import.py:88 calls `_.template()`.",
|
||||
contextual_cvss_breakdown=_CONTEXTUAL_BREAKDOWN,
|
||||
contextual_cvss_reasoning="Only scripts/import.py reaches the sink.",
|
||||
@@ -1420,7 +1421,7 @@ async def test_dependency_report_requires_contextual_breakdown(
|
||||
cwe="CWE-94",
|
||||
fix_effort="trivial",
|
||||
manifest_path="package-lock.json",
|
||||
reachability="not_reachable",
|
||||
reachability="imported",
|
||||
reachability_evidence=_DEP_EVIDENCE,
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
Reference in new issue
Block a user