mirror of
https://github.com/usestrix/strix.git
synced 2026-10-17 04:34:03 +00:00
Add explicit CSPT detection skill, categorization, fixtures, and tests
Closes #776
This commit is contained in:
parent
7d5a67d234
commit
47c27aef2e
13 changed files with 948 additions and 0 deletions
127
docs/cspt-evidence-runbook.md
Normal file
127
docs/cspt-evidence-runbook.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# CSPT evidence runbook
|
||||
|
||||
How to reproduce and capture request-path evidence for a Client-Side Path
|
||||
Traversal (CSPT) finding, so a confirmed report carries the proof the maintainer
|
||||
asked for. This is the manual counterpart to the automated tests in
|
||||
`tests/test_cspt_skill.py` — the tests guard the skill/fixtures/categorization in
|
||||
CI; this runbook produces the live browser + proxy evidence a real finding needs
|
||||
(which CI can't run).
|
||||
|
||||
The vulnerability and its confirmation both happen **in the browser**: the win
|
||||
condition is the victim's browser sending a request to an *unintended* same-origin
|
||||
path, carrying the victim's session. So the evidence is the **outbound request
|
||||
path on the wire**, read off the Caido proxy / a HAR capture — not the response
|
||||
body alone.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `agent-browser` CLI (pre-installed in the Strix sandbox), which auto-routes
|
||||
through the Caido proxy via `http_proxy` / `https_proxy`. **Do not pass
|
||||
`--proxy`** — see the `agent_browser` skill.
|
||||
- An authenticated session on the target (so requests carry ambient credentials),
|
||||
or one of the fixtures under `tests/fixtures/cspt/` served locally for a
|
||||
self-contained demo.
|
||||
|
||||
## Procedure
|
||||
|
||||
```bash
|
||||
# 1. Load the page. Authenticate so subsequent requests carry a session.
|
||||
agent-browser open <target>
|
||||
agent-browser auth login <profile> # or: state load ./auth.json
|
||||
|
||||
# 2. Start capturing traffic BEFORE triggering the sink.
|
||||
agent-browser network har start
|
||||
|
||||
# 3a. BENIGN CONTROL — drive the source with a normal value first.
|
||||
# This establishes the intended request path for the side-by-side.
|
||||
agent-browser open "<target>/#/orders/12345"
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser network requests # note the intended path, e.g. /api/orders/12345/detail
|
||||
|
||||
# 3b. TRAVERSAL — drive the same source with a crafted value.
|
||||
agent-browser open "<target>/#/orders/..%2f..%2fadmin%2fkeys"
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser network requests # note the traversed path, e.g. /api/admin/keys
|
||||
|
||||
# 4. Stop the capture and keep the HAR as the primary artifact.
|
||||
agent-browser network har stop /workspace/.agent-browser-screenshots/cspt.har
|
||||
|
||||
# 5. Optional visual proof if the traversed response changes the page.
|
||||
agent-browser screenshot
|
||||
```
|
||||
|
||||
For a `postMessage` source, replace step 3b with an eval that posts the crafted
|
||||
message into the vulnerable listener:
|
||||
|
||||
```bash
|
||||
cat <<'EOF' | agent-browser eval --stdin
|
||||
window.postMessage({ path: "../../admin/keys" }, "*");
|
||||
EOF
|
||||
```
|
||||
|
||||
For a storage source, seed the value first, then reload:
|
||||
|
||||
```bash
|
||||
agent-browser eval "localStorage.setItem('activeTenant', '../../admin')"
|
||||
agent-browser open "<target>"
|
||||
```
|
||||
|
||||
## Confirmation criteria
|
||||
|
||||
A finding is a real CSPT only when **all** of these hold, evidenced from the HAR:
|
||||
|
||||
1. **Traversed path on the wire** — the outbound request path is the traversed
|
||||
target (e.g. `/api/admin/keys`), not the intended one
|
||||
(`/api/orders/12345/detail`).
|
||||
2. **Ambient credentials** — that request carried the victim's session
|
||||
(`Cookie` / `Authorization` header present; read it from the HAR entry).
|
||||
3. **Benign control** — the same flow with a normal value hit the intended path.
|
||||
Show both, side by side.
|
||||
4. **Reach** — the traversed endpoint returned data or performed an action the
|
||||
source value should not have been able to reach.
|
||||
|
||||
Do not exfiltrate real secrets — a status / length / shape delta against a
|
||||
low-sensitivity sibling endpoint is enough to prove reach.
|
||||
|
||||
## Turning it into a report
|
||||
|
||||
File with `create_vulnerability_report`:
|
||||
|
||||
- `finding_class="client_side_path_traversal"` (**required** — separates it from
|
||||
server-side path traversal / LFI / RFI, which share CWE-22).
|
||||
- `cwe="CWE-22"`, `title` prefixed `Client-Side Path Traversal`.
|
||||
- `evidence` — the intended vs. traversed request paths from the HAR, with the
|
||||
credential header shown, plus the benign control, in fenced code blocks.
|
||||
|
||||
### Evidence block template
|
||||
|
||||
```
|
||||
Intended (benign control):
|
||||
GET /api/orders/12345/detail
|
||||
Cookie: session=<redacted>
|
||||
→ 200 OK (own order)
|
||||
|
||||
Traversed (CSPT):
|
||||
GET /api/orders/../../admin/keys/detail
|
||||
→ browser normalizes to: GET /api/admin/keys
|
||||
Cookie: session=<redacted>
|
||||
→ 200 OK (admin key material reachable via a low-priv user's fragment)
|
||||
```
|
||||
|
||||
## Demoing against the bundled fixtures
|
||||
|
||||
The fixtures in `tests/fixtures/cspt/` are self-contained static pages. Serve the
|
||||
directory and drive each one to see the positive/negative behavior without a real
|
||||
target:
|
||||
|
||||
```bash
|
||||
python -m http.server 8000 --directory tests/fixtures/cspt
|
||||
agent-browser network har start
|
||||
agent-browser open "http://localhost:8000/positive_direct.html#/orders/../../admin/keys"
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser network requests # observe the normalized /api/admin/keys request
|
||||
```
|
||||
|
||||
The positive fixtures emit a traversed `/api/...` request; the negative fixtures
|
||||
(`negative_validated.html`, `negative_safe_construction.html`) either reject the
|
||||
input before the sink or keep the value out of the path entirely.
|
||||
|
|
@ -531,6 +531,16 @@ def _result_properties(
|
|||
if value not in (None, ""):
|
||||
strix[key] = value
|
||||
|
||||
# Surface a non-default finding sub-class (e.g. client_side_path_traversal)
|
||||
# so consumers can machine-separate it from other findings that share the
|
||||
# same CWE rule — CSPT and server-side path traversal / LFI / RFI all key
|
||||
# on CWE-22, so ``ruleId`` alone cannot tell them apart. The default
|
||||
# ``dynamic`` carries no signal and is omitted to avoid noise on every
|
||||
# result.
|
||||
finding_class = _string_value(report.get("finding_class"))
|
||||
if finding_class and finding_class != "dynamic":
|
||||
strix["finding_class"] = finding_class
|
||||
|
||||
# SARIF is written for external upload (code-scanning / ASPM), so it must
|
||||
# NOT carry the weaponized exploit payload — that stays a local run
|
||||
# artifact (vulnerabilities.json / the finding MD). We surface the PoC
|
||||
|
|
@ -773,6 +783,7 @@ _VULN_CLASS_KEYWORDS = (
|
|||
"default password",
|
||||
"session fixation",
|
||||
"open redirect",
|
||||
"client-side path traversal",
|
||||
"path traversal",
|
||||
"directory traversal",
|
||||
"command injection",
|
||||
|
|
|
|||
234
strix/skills/vulnerabilities/client_side_path_traversal.md
Normal file
234
strix/skills/vulnerabilities/client_side_path_traversal.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
---
|
||||
name: client-side-path-traversal
|
||||
description: Client-Side Path Traversal (CSPT) testing — attacker-controlled client input steering the victim's own authenticated browser requests to unintended same-origin endpoints
|
||||
---
|
||||
|
||||
# Client-Side Path Traversal (CSPT)
|
||||
|
||||
Client-Side Path Traversal happens **in the victim's browser**, not on the
|
||||
server. Front-end JavaScript takes an attacker-influenceable client-side value
|
||||
and concatenates it into the path of a request the browser then sends with the
|
||||
victim's ambient credentials (cookies, `Authorization` headers set by the app,
|
||||
session). By injecting `../` (or encoded / normalizing variants) into that value,
|
||||
an attacker redirects the request to a **different same-origin endpoint** than the
|
||||
developer intended — turning the victim's own browser into the request forger.
|
||||
|
||||
The bug is a **path join in client code**, and the impact is realized entirely by
|
||||
the browser's URL normalization (`new URL()`, the fetch/XHR URL parser) collapsing
|
||||
`../` before the request leaves the page. Treat every client-readable input that
|
||||
flows into a request URL as untrusted; validate the *resulting path*, not the raw
|
||||
segment.
|
||||
|
||||
CSPT is frequently a **gadget**, not a standalone bug: it lets an attacker point
|
||||
an authenticated request at an endpoint whose JSON/HTML response is then fed into
|
||||
a sink. Chained with a reflected/DOM XSS sink, a permissive CORS reader, or a
|
||||
state-changing endpoint that trusts a weak/absent CSRF defense, a "read-only"
|
||||
CSPT becomes account takeover or a stored-XSS delivery path.
|
||||
|
||||
## Not CSPT (route these elsewhere)
|
||||
|
||||
Keep this class clean. The following are **server-side** file/path issues covered
|
||||
by the `path-traversal-lfi-rfi` skill — report them as `dynamic` findings, never
|
||||
as `client_side_path_traversal`:
|
||||
|
||||
- **Server-side path traversal** — server code joins a request parameter into a
|
||||
filesystem path and reads/returns a file (`/download?file=../../etc/passwd`).
|
||||
The traversal is resolved by the *server's* filesystem, not the browser.
|
||||
- **LFI / RFI** — server includes/executes a file whose name/URL is user-controlled
|
||||
(`php://filter`, remote include). Server-side execution.
|
||||
- **Zip Slip / archive extraction** — server writes files outside a target dir.
|
||||
- **SSRF** — server (not the browser) fetches an attacker-controlled URL.
|
||||
- **Open redirect** — the browser is navigated (Location / `window.location`) to
|
||||
an attacker origin. CSPT stays *same-origin* and targets a request **path**, not
|
||||
a top-level navigation. If the sink is a navigation, it's `open-redirect`.
|
||||
|
||||
The discriminator: **who resolves the `../` and who sends the final request?**
|
||||
If it's the victim's browser building a same-origin `fetch`/XHR path → CSPT. If
|
||||
it's the server touching its filesystem or making an outbound request →
|
||||
server-side traversal / LFI / RFI / SSRF.
|
||||
|
||||
## Sources (attacker-influenceable client input)
|
||||
|
||||
Any of these reaching a request-URL sink is a candidate taint source:
|
||||
|
||||
- **URL query params** — `URLSearchParams`, `location.search`, router query
|
||||
(`?next=`, `?id=`, `?path=`, `?tab=`, `?redirect=`).
|
||||
- **URL path segments** — SPA route params parsed from `location.pathname`
|
||||
(`/orders/:id`, `react-router` / `vue-router` params).
|
||||
- **Fragment / hash** — `location.hash`, hash-router segments. Never sent to the
|
||||
server, so server-side WAFs don't see it — a favorite CSPT source.
|
||||
- **`document.referrer`** — attacker controls it by hosting the referring page.
|
||||
- **`postMessage` data** — `event.data` from another window/iframe without an
|
||||
`event.origin` check.
|
||||
- **Browser storage** — `localStorage`, `sessionStorage`, `IndexedDB`, cookies —
|
||||
when an earlier, attacker-influenceable flow seeded the value.
|
||||
- **`window.name`**, **BroadcastChannel**, **WebSocket message payloads** — cross
|
||||
-context channels that survive navigation.
|
||||
|
||||
## Sinks (client request builders whose path is tainted)
|
||||
|
||||
The tainted value must land in the **path** portion (not just a query value) of:
|
||||
|
||||
- `fetch(url)` / `fetch(new Request(url))`
|
||||
- `XMLHttpRequest.open(method, url)`
|
||||
- `axios.get/post/... (url)`, an axios instance with a `baseURL`
|
||||
- jQuery `$.ajax({url})`, `$.get`, `$.getJSON`, `$.load`
|
||||
- `navigator.sendBeacon(url)`
|
||||
- `new EventSource(url)`, `new WebSocket(url)`
|
||||
- framework data layers that build a request path: Angular `HttpClient`,
|
||||
`HttpClient.get(\`/api/${seg}\`)`, RTK Query / SWR / react-query key→URL builders,
|
||||
GraphQL client `uri`.
|
||||
|
||||
The dangerous pattern is **template/`+` concatenation of a source into a path**:
|
||||
|
||||
```js
|
||||
fetch("/api/users/" + userId + "/avatar") // userId from location.hash
|
||||
fetch(`/api/${tab}/settings`) // tab from ?tab=
|
||||
axios.get("/api/orders/" + params.id) // id from route param
|
||||
xhr.open("GET", baseApi + "/" + segment) // segment from postMessage
|
||||
```
|
||||
|
||||
If `userId` = `../../admin/keys#`, the browser normalizes
|
||||
`/api/users/../../admin/keys` → `/api/admin/keys`, sent with the victim's session.
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
### Surface map
|
||||
|
||||
- Grep client bundles for the sinks above joined with a source. In `agent_browser`:
|
||||
```bash
|
||||
agent-browser open <target>
|
||||
# dump all script URLs, then fetch + grep them
|
||||
agent-browser eval "Array.from(document.scripts).map(s=>s.src).filter(Boolean)"
|
||||
```
|
||||
Then pull each bundle and search for `fetch(`, `.open(`, `axios`, backtick URL
|
||||
templates, and `location.hash` / `URLSearchParams` / `postMessage` / referrer.
|
||||
- Map every endpoint the SPA calls (record with the proxy, see Validation). Note
|
||||
which build their path from a client value vs. a constant.
|
||||
- Identify **high-value sibling endpoints** reachable by traversal from a normal
|
||||
path (admin, other users' resources, token/key endpoints, state-changing POSTs).
|
||||
|
||||
### Capability probes
|
||||
|
||||
Inject into each candidate source and watch the **outbound request path** (via the
|
||||
proxy), not the response body alone — the win condition is the browser sending a
|
||||
path you didn't intend.
|
||||
|
||||
- **Direct**: `../`, `../../`, `..%2f` un-decoded, leading `/` (absolute-path
|
||||
override: `id=/admin/keys` → `/api//admin/keys` or an absolute join).
|
||||
- **Encoded**: `%2e%2e%2f`, `%2e%2e/`, `..%2f`, double-encoded `%252e%252e%252f`
|
||||
when an app decodes before joining. The browser decodes `%2e`/`%2f` in the path
|
||||
during normalization — test both raw and encoded because framework routers may
|
||||
decode a layer first.
|
||||
- **Normalized**: `....//` (folds to `../` after one collapse), `..././`,
|
||||
`.%2e/`, mixed `..\` on apps that rewrite backslashes, redundant `//` and `/./`
|
||||
segments that shift the base.
|
||||
- **Base-relative vs root-relative**: confirm whether the app uses a relative
|
||||
base (`api/x`) or rooted (`/api/x`) — it changes how many `../` are needed and
|
||||
whether a leading `/` fully replaces the path.
|
||||
|
||||
## Validation (browser + Caido proxy)
|
||||
|
||||
Detection is dynamic: drive a real headless browser and read the **final outbound
|
||||
request path** off the proxy. The `agent_browser` CLI auto-routes through the
|
||||
Caido HTTP/HTTPS proxy (do **not** pass `--proxy`; it's wired via
|
||||
`http_proxy`/`https_proxy`). See the `agent_browser` skill for the full CLI.
|
||||
|
||||
Runbook:
|
||||
|
||||
```bash
|
||||
# 1. Load the page and (if needed) authenticate so requests carry a session.
|
||||
agent-browser open <target>
|
||||
agent-browser auth login <profile> # or state load — see agent_browser skill
|
||||
|
||||
# 2. Start capturing traffic BEFORE triggering the sink.
|
||||
agent-browser network har start
|
||||
|
||||
# 3. Drive the tainted source. Examples per source type:
|
||||
# - fragment: navigate with a crafted hash
|
||||
agent-browser open "<target>/#/orders/..%2f..%2fadmin%2fkeys"
|
||||
# - query: open with a crafted param
|
||||
agent-browser open "<target>/dashboard?tab=../../admin/config"
|
||||
# - postMessage: eval a crafted message into the vulnerable listener
|
||||
cat <<'EOF' | agent-browser eval --stdin
|
||||
window.postMessage({ path: "../../admin/keys" }, "*");
|
||||
EOF
|
||||
|
||||
# 4. Let the request fire, then read what actually left the browser.
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser network requests # inspect fired request URLs
|
||||
agent-browser network har stop /workspace/.agent-browser-screenshots/cspt.har
|
||||
|
||||
# 5. Capture visual proof if the traversed response changes the page.
|
||||
agent-browser screenshot
|
||||
```
|
||||
|
||||
**Confirmation criteria** — you have a real CSPT when *all* hold:
|
||||
|
||||
1. The **outbound request path in the proxy/HAR** is the traversed target
|
||||
(`/api/admin/keys`), not the intended one (`/api/users/<id>/avatar`).
|
||||
2. That request carried the **victim's credentials** (session cookie /
|
||||
`Authorization` header present on the request — read it from the HAR).
|
||||
3. There is a **benign same-endpoint control**: the same flow with a normal
|
||||
value hits the intended path. Show both paths side by side.
|
||||
4. The traversed endpoint returned data (or performed an action) the source
|
||||
value should not have been able to reach.
|
||||
|
||||
Record the request/response pair (method, full path, relevant headers, status)
|
||||
from the HAR as evidence. Prefer reading a low-sensitivity sibling endpoint to
|
||||
prove reach; do not exfiltrate real secrets — a status/length/shape delta is
|
||||
enough proof.
|
||||
|
||||
## Reporting
|
||||
|
||||
File confirmed CSPT with `create_vulnerability_report` and:
|
||||
|
||||
- `finding_class="client_side_path_traversal"` — **required** so the finding is
|
||||
separated from server-side path traversal / LFI / RFI (which share the CWE but
|
||||
are a different class). Do not leave it `dynamic`.
|
||||
- `cwe="CWE-22"` (Improper Limitation of a Pathname to a Restricted Directory).
|
||||
- `title` prefixed **`Client-Side Path Traversal`** (e.g.
|
||||
`"Client-Side Path Traversal in order-detail fetch via URL fragment"`), so the
|
||||
class is obvious in the report/SARIF even to a human skimming titles.
|
||||
- `evidence` — the two request paths (intended vs. traversed) from the proxy/HAR,
|
||||
with the credential header present, plus the benign control. Fenced code blocks.
|
||||
- `poc_description` — steps to reproduce (navigate/craft source → observe path).
|
||||
- `poc_script_code` — the crafted URL / `postMessage` / storage-seeding snippet.
|
||||
- `impact` — name the concrete sibling endpoint reached and what it exposes or
|
||||
changes; if chained (XSS/CSRF/CORS), state the chain.
|
||||
- CVSS — usually `AV:N`, `UI:R` (victim opens a link), `PR:N`; scope/impact
|
||||
depends on the reached endpoint.
|
||||
|
||||
## False positives
|
||||
|
||||
Not CSPT — do not report:
|
||||
|
||||
- The source is **validated to the resulting path**: an allowlist of endpoints,
|
||||
a strict `^[a-z0-9-]+$` segment check, or `encodeURIComponent` on a value used
|
||||
as a **single segment** *and* the app rejects/encodes `/` and `.` so traversal
|
||||
can't form. (`encodeURIComponent` alone is only safe when the value is one
|
||||
segment and `%2f`/`%2e` aren't decoded downstream — verify the outbound path.)
|
||||
- **Safe path construction**: the endpoint path is a constant and the client
|
||||
value only ever becomes a **query parameter or request body field**, never part
|
||||
of the path. A tainted query value is not CSPT (it may be another bug).
|
||||
- The browser **does not normalize** the value into a different path (confirm on
|
||||
the wire — no traversal actually occurred in the outbound request).
|
||||
- The traversed request is **not** sent with ambient credentials (no session on
|
||||
that request), so there's no privilege to abuse — note it, don't over-rate it.
|
||||
- Server rejects the traversed path (404/403) with no differential — no reach, no
|
||||
finding.
|
||||
|
||||
## Pro tips
|
||||
|
||||
1. Always judge on the **outbound request path in the proxy**, not the response —
|
||||
a 404 on the traversed path still proves the browser was steered, but a
|
||||
*successful* differential response is what makes it impactful.
|
||||
2. The fragment (`location.hash`) is the strongest source: invisible to server
|
||||
WAFs/logs and fully attacker-controlled via a link.
|
||||
3. Test both raw (`../`) and encoded (`%2e%2e%2f`) — SPA routers often decode one
|
||||
layer, so the effective payload differs by framework.
|
||||
4. `....//` and other self-collapsing sequences bypass naive `replace("../","")`
|
||||
filters — a single non-recursive strip leaves a working `../`.
|
||||
5. CSPT shines when chained. After proving reach, look for a reader sink (CORS,
|
||||
response rendered into DOM) or a state-changing target to escalate read-only
|
||||
traversal into real impact.
|
||||
|
|
@ -154,6 +154,15 @@ _REQUIRED_FIELDS = {
|
|||
|
||||
_VALID_FIX_EFFORT = frozenset({"trivial", "low", "medium", "high"})
|
||||
|
||||
# Dynamically-proven finding sub-classes that a reporting agent may set on
|
||||
# ``create_vulnerability_report``. ``dynamic`` is the default catch-all.
|
||||
# ``client_side_path_traversal`` (CSPT) is called out explicitly so the
|
||||
# finding is machine-separable from server-side path traversal / LFI / RFI,
|
||||
# which share CWE-22 but are a distinct vulnerability class (see the
|
||||
# ``client-side-path-traversal`` skill). ``dependency_cve`` is reserved for
|
||||
# ``create_dependency_report`` and is not selectable here.
|
||||
_VALID_FINDING_CLASS = frozenset({"dynamic", "client_side_path_traversal"})
|
||||
|
||||
|
||||
async def _do_create( # noqa: PLR0912
|
||||
*,
|
||||
|
|
@ -175,6 +184,7 @@ async def _do_create( # noqa: PLR0912
|
|||
cwe: str | None,
|
||||
code_locations: list[dict[str, Any]] | None,
|
||||
fix_pr_body: str | None = None,
|
||||
finding_class: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -201,6 +211,13 @@ async def _do_create( # noqa: PLR0912
|
|||
f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}"
|
||||
)
|
||||
|
||||
finding_class = (finding_class or "dynamic").strip().lower()
|
||||
if finding_class not in _VALID_FINDING_CLASS:
|
||||
errors.append(
|
||||
f"Invalid finding_class: {finding_class!r}. Must be one of: "
|
||||
f"{sorted(_VALID_FINDING_CLASS)}"
|
||||
)
|
||||
|
||||
if not isinstance(cvss_breakdown, dict) or not cvss_breakdown:
|
||||
errors.append("cvss_breakdown: must be an object with the 8 CVSS metrics")
|
||||
cvss_breakdown = {}
|
||||
|
|
@ -295,6 +312,7 @@ async def _do_create( # noqa: PLR0912
|
|||
cwe=cwe,
|
||||
code_locations=parsed_locations,
|
||||
fix_pr_body=fix_pr_body,
|
||||
finding_class=finding_class,
|
||||
agent_id=agent_id if isinstance(agent_id, str) else None,
|
||||
agent_name=agent_name if isinstance(agent_name, str) else None,
|
||||
)
|
||||
|
|
@ -339,6 +357,7 @@ async def create_vulnerability_report(
|
|||
cwe: str | None = None,
|
||||
code_locations: list[dict[str, Any]] | None = None,
|
||||
fix_pr_body: str | None = None,
|
||||
finding_class: str | None = None,
|
||||
) -> str:
|
||||
"""File a vulnerability report — one report per fully-verified finding.
|
||||
|
||||
|
|
@ -543,6 +562,20 @@ async def create_vulnerability_report(
|
|||
fix (summary + rationale). Prose/markdown only — the code
|
||||
change itself belongs in ``code_locations``. Omit for
|
||||
black-box findings.
|
||||
finding_class: Optional finding sub-class for machine-readable
|
||||
categorization. Defaults to ``"dynamic"``. Set to
|
||||
``"client_side_path_traversal"`` for a confirmed CSPT finding
|
||||
— attacker-controlled client-side input (URL params, path
|
||||
segments, fragment, ``document.referrer``, ``postMessage``
|
||||
data, browser storage) reaching a browser request sink
|
||||
(``fetch`` / ``XMLHttpRequest`` / ``axios`` / ``$.ajax`` /
|
||||
``EventSource`` / ``WebSocket``) so the victim's own
|
||||
authenticated browser is steered to an unintended same-origin
|
||||
endpoint. CSPT shares ``CWE-22`` with server-side path
|
||||
traversal / LFI / RFI but is a distinct class; setting this
|
||||
keeps it separated in the report artifacts and SARIF. Do NOT
|
||||
use it for server-side traversal / LFI / RFI (leave those
|
||||
``dynamic``). See the ``client-side-path-traversal`` skill.
|
||||
|
||||
Example (abbreviated — mirror this structure)::
|
||||
|
||||
|
|
@ -610,6 +643,7 @@ async def create_vulnerability_report(
|
|||
cwe=cwe,
|
||||
code_locations=code_locations,
|
||||
fix_pr_body=fix_pr_body,
|
||||
finding_class=finding_class,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
|
|
|||
35
tests/fixtures/cspt/README.md
vendored
Normal file
35
tests/fixtures/cspt/README.md
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# CSPT test fixtures
|
||||
|
||||
Self-contained HTML+JS apps exercising Client-Side Path Traversal (CSPT)
|
||||
detection. Each file is a single static page with no server dependency — a
|
||||
CSPT specialist can drive them with the `agent_browser` CLI (routed through the
|
||||
Caido proxy) exactly as it would a real target, and the automated tests in
|
||||
`tests/test_cspt_skill.py` assert their structural properties so they cannot
|
||||
silently rot.
|
||||
|
||||
Every app builds a same-origin request URL by joining a **client-side source**
|
||||
into a request **path** and sending it via a browser request sink (`fetch` /
|
||||
`XMLHttpRequest` / `axios`-style). The distinction between positive and negative
|
||||
is whether the resulting path is validated before it reaches the sink.
|
||||
|
||||
## Positive fixtures (a real CSPT — source flows unvalidated into the path)
|
||||
|
||||
| File | Source | Sink | Traversal style |
|
||||
|------|--------|------|-----------------|
|
||||
| `positive_direct.html` | URL fragment (`location.hash`) | `fetch` | direct `../` |
|
||||
| `positive_encoded.html` | query param (`?resource=`) | `XMLHttpRequest` | URL-encoded `%2e%2e%2f` |
|
||||
| `positive_normalized.html` | route segment (`location.pathname`) | `fetch` | self-collapsing `....//` |
|
||||
| `positive_postmessage.html` | `postMessage` `event.data` (no origin check) | `fetch` | direct `../` |
|
||||
| `positive_storage.html` | `localStorage` value | `axios`-style wrapper | direct `../` |
|
||||
|
||||
## Negative fixtures (safe — not CSPT)
|
||||
|
||||
| File | Why it's safe |
|
||||
|------|---------------|
|
||||
| `negative_validated.html` | Segment matched against a strict allowlist / `^[a-z0-9-]+$` before use; traversal rejected. |
|
||||
| `negative_safe_construction.html` | Endpoint path is a constant; the client value only becomes a **query parameter**, never part of the path. |
|
||||
|
||||
## How to drive one manually (evidence runbook)
|
||||
|
||||
See `docs/cspt-evidence-runbook.md` for the full browser + Caido proxy procedure
|
||||
that produces the request-path evidence a confirmed finding requires.
|
||||
29
tests/fixtures/cspt/negative_safe_construction.html
vendored
Normal file
29
tests/fixtures/cspt/negative_safe_construction.html
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT NEGATIVE — safe path construction (NOT CSPT)
|
||||
Source: query parameter (?q=)
|
||||
Sink: fetch()
|
||||
The request path is a CONSTANT ("/api/search"). The client value is only ever
|
||||
attached as a query parameter via URLSearchParams, never concatenated into the
|
||||
path, so it cannot introduce path traversal. A tainted query value is not CSPT.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Search (safe)</title></head>
|
||||
<body>
|
||||
<h1>Search</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
<script>
|
||||
// SOURCE: query param, e.g. ?q=../../admin
|
||||
const q = new URLSearchParams(location.search).get("q") || "";
|
||||
|
||||
// SAFE: constant path; the value is a query parameter, not a path segment.
|
||||
const url = new URL("/api/search", location.origin);
|
||||
url.searchParams.set("q", q);
|
||||
|
||||
fetch(url.toString(), { credentials: "include" })
|
||||
.then((r) => r.text())
|
||||
.then((t) => { document.getElementById("out").textContent = t; })
|
||||
.catch((e) => { document.getElementById("out").textContent = String(e); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
32
tests/fixtures/cspt/negative_validated.html
vendored
Normal file
32
tests/fixtures/cspt/negative_validated.html
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT NEGATIVE — validated input (NOT CSPT)
|
||||
Source: URL fragment (location.hash)
|
||||
Sink: fetch()
|
||||
The segment is validated against a strict allowlist pattern before use. Any
|
||||
value containing "/", ".", or "%" fails the check, so no traversal can form
|
||||
and the request is rejected before the sink. Safe.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Order detail (validated)</title></head>
|
||||
<body>
|
||||
<h1>Order detail</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
<script>
|
||||
// SOURCE: fragment, e.g. #/orders/abc123
|
||||
const orderId = location.hash.replace(/^#\/orders\//, "");
|
||||
|
||||
// GUARD: strict allowlist — a single path segment of [a-z0-9-] only.
|
||||
// Traversal characters (/, ., %) cannot pass, so no CSPT.
|
||||
const VALID_ID = /^[a-z0-9-]{1,64}$/;
|
||||
if (!VALID_ID.test(orderId)) {
|
||||
document.getElementById("out").textContent = "invalid order id";
|
||||
} else {
|
||||
fetch("/api/orders/" + orderId + "/detail", { credentials: "include" })
|
||||
.then((r) => r.text())
|
||||
.then((t) => { document.getElementById("out").textContent = t; })
|
||||
.catch((e) => { document.getElementById("out").textContent = String(e); });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
tests/fixtures/cspt/positive_direct.html
vendored
Normal file
26
tests/fixtures/cspt/positive_direct.html
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT POSITIVE — direct traversal
|
||||
Source: URL fragment (location.hash)
|
||||
Sink: fetch()
|
||||
The fragment segment is concatenated straight into the request path with no
|
||||
validation. A hash of `../../admin/keys` normalizes the outbound request to
|
||||
/api/admin/keys, sent with the page's ambient session.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Order detail</title></head>
|
||||
<body>
|
||||
<h1>Order detail</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
<script>
|
||||
// SOURCE: attacker-controlled fragment, e.g. #/orders/../../admin/keys
|
||||
const orderId = location.hash.replace(/^#\/orders\//, "");
|
||||
|
||||
// SINK: order id joined directly into the request path — CSPT.
|
||||
fetch("/api/orders/" + orderId + "/detail", { credentials: "include" })
|
||||
.then((r) => r.text())
|
||||
.then((t) => { document.getElementById("out").textContent = t; })
|
||||
.catch((e) => { document.getElementById("out").textContent = String(e); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
29
tests/fixtures/cspt/positive_encoded.html
vendored
Normal file
29
tests/fixtures/cspt/positive_encoded.html
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT POSITIVE — URL-encoded traversal
|
||||
Source: query parameter (?resource=)
|
||||
Sink: XMLHttpRequest.open()
|
||||
The value is URL-decoded once, then joined into the path. A payload of
|
||||
%2e%2e%2f%2e%2e%2fadmin%2fconfig decodes to ../../admin/config and the browser
|
||||
normalizes the outbound path to /api/admin/config.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Resource viewer</title></head>
|
||||
<body>
|
||||
<h1>Resource viewer</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
<script>
|
||||
// SOURCE: attacker-controlled query param, e.g. ?resource=%2e%2e%2fadmin%2fconfig
|
||||
const raw = new URLSearchParams(location.search).get("resource") || "";
|
||||
const resource = decodeURIComponent(raw);
|
||||
|
||||
// SINK: decoded value joined directly into the request path — CSPT.
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "/api/resources/" + resource);
|
||||
xhr.withCredentials = true;
|
||||
xhr.onload = () => { document.getElementById("out").textContent = xhr.responseText; };
|
||||
xhr.onerror = () => { document.getElementById("out").textContent = "error"; };
|
||||
xhr.send();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
29
tests/fixtures/cspt/positive_normalized.html
vendored
Normal file
29
tests/fixtures/cspt/positive_normalized.html
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT POSITIVE — normalization bypass
|
||||
Source: route segment parsed from location.pathname
|
||||
Sink: fetch()
|
||||
A naive single-pass filter strips one "../" occurrence, but the self-
|
||||
collapsing sequence "....//" folds back to "../" after that strip, so the
|
||||
traversal survives and the browser normalizes the outbound path.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Profile</title></head>
|
||||
<body>
|
||||
<h1>Profile</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
<script>
|
||||
// SOURCE: route segment, e.g. /view/....//....//admin/settings
|
||||
const segment = location.pathname.replace(/^\/view\//, "");
|
||||
|
||||
// Ineffective sanitizer: a single non-recursive strip. "....//" -> "../".
|
||||
const cleaned = segment.replace(/\.\.\//g, "");
|
||||
|
||||
// SINK: still-tainted value joined into the request path — CSPT.
|
||||
fetch("/api/profile/" + cleaned, { credentials: "include" })
|
||||
.then((r) => r.text())
|
||||
.then((t) => { document.getElementById("out").textContent = t; })
|
||||
.catch((e) => { document.getElementById("out").textContent = String(e); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
29
tests/fixtures/cspt/positive_postmessage.html
vendored
Normal file
29
tests/fixtures/cspt/positive_postmessage.html
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT POSITIVE — postMessage source
|
||||
Source: postMessage event.data (NO event.origin check)
|
||||
Sink: fetch()
|
||||
A message handler trusts any window's message and joins the supplied path
|
||||
into the request. An attacker page/iframe posts {path: "../../admin/keys"}.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Widget host</title></head>
|
||||
<body>
|
||||
<h1>Widget host</h1>
|
||||
<pre id="out">waiting for message…</pre>
|
||||
<script>
|
||||
// SOURCE: cross-window message, origin unchecked — attacker-controlled.
|
||||
window.addEventListener("message", (event) => {
|
||||
// MISSING: if (event.origin !== TRUSTED) return;
|
||||
const path = event.data && event.data.path;
|
||||
if (!path) return;
|
||||
|
||||
// SINK: message-supplied value joined directly into the request path — CSPT.
|
||||
fetch("/api/widgets/" + path, { credentials: "include" })
|
||||
.then((r) => r.text())
|
||||
.then((t) => { document.getElementById("out").textContent = t; })
|
||||
.catch((e) => { document.getElementById("out").textContent = String(e); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
33
tests/fixtures/cspt/positive_storage.html
vendored
Normal file
33
tests/fixtures/cspt/positive_storage.html
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT POSITIVE — browser storage source
|
||||
Source: localStorage value (seeded by an earlier attacker-influenceable flow)
|
||||
Sink: axios-style request wrapper
|
||||
The stored "activeTenant" is joined into the API path with no validation, so a
|
||||
poisoned storage value of "../../admin" steers the authenticated request.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Tenant dashboard</title></head>
|
||||
<body>
|
||||
<h1>Tenant dashboard</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
<script>
|
||||
// Minimal axios-like wrapper with a baseURL (stands in for a real axios client).
|
||||
const api = {
|
||||
baseURL: "/api",
|
||||
get(path) {
|
||||
return fetch(this.baseURL + path, { credentials: "include" }).then((r) => r.text());
|
||||
},
|
||||
};
|
||||
|
||||
// SOURCE: attacker-influenceable stored value, e.g. "../../admin".
|
||||
const tenant = localStorage.getItem("activeTenant") || "self";
|
||||
|
||||
// SINK: stored value joined directly into the request path — CSPT.
|
||||
api
|
||||
.get("/tenants/" + tenant + "/summary")
|
||||
.then((t) => { document.getElementById("out").textContent = t; })
|
||||
.catch((e) => { document.getElementById("out").textContent = String(e); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
300
tests/test_cspt_skill.py
Normal file
300
tests/test_cspt_skill.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""Regression tests for Client-Side Path Traversal (CSPT) detection support.
|
||||
|
||||
Covers the three in-repo pillars of issue #776 that can run in CI without a live
|
||||
browser/proxy:
|
||||
|
||||
1. The ``client-side-path-traversal`` skill exists, loads, and documents the
|
||||
source/sink taxonomy plus the "Not CSPT" boundary the agent relies on.
|
||||
2. The HTML fixtures encode a real source->sink flow (positives) or a validation
|
||||
guard / safe construction (negatives), asserted structurally so they can't
|
||||
silently rot.
|
||||
3. A CSPT finding is categorized separately from server-side path traversal /
|
||||
LFI / RFI: the ``finding_class`` param round-trips through the reporting tool
|
||||
and report state, and surfaces distinctly in SARIF (both as a property and as
|
||||
a distinct vulnerability-class fingerprint) even though both share CWE-22.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.report.sarif import write_sarif
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.skills import get_all_skill_names, get_available_skills, load_skills
|
||||
from strix.tools.reporting.tool import _do_create
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "cspt"
|
||||
|
||||
POSITIVE_FIXTURES = [
|
||||
"positive_direct.html",
|
||||
"positive_encoded.html",
|
||||
"positive_normalized.html",
|
||||
"positive_postmessage.html",
|
||||
"positive_storage.html",
|
||||
]
|
||||
NEGATIVE_FIXTURES = [
|
||||
"negative_validated.html",
|
||||
"negative_safe_construction.html",
|
||||
]
|
||||
|
||||
# Browser request sinks whose *path* being tainted constitutes CSPT.
|
||||
_SINK_MARKERS = ("fetch(", "XMLHttpRequest", ".open(", "axios", "sendBeacon", "EventSource")
|
||||
# Client-side taint sources.
|
||||
_SOURCE_MARKERS = (
|
||||
"location.hash",
|
||||
"location.search",
|
||||
"location.pathname",
|
||||
"URLSearchParams",
|
||||
"postMessage",
|
||||
"localStorage",
|
||||
"sessionStorage",
|
||||
"document.referrer",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Skill
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_cspt_skill_is_discoverable() -> None:
|
||||
assert "client_side_path_traversal" in get_all_skill_names()
|
||||
assert "client_side_path_traversal" in get_available_skills().get("vulnerabilities", [])
|
||||
|
||||
|
||||
def test_cspt_skill_loads_with_frontmatter_stripped() -> None:
|
||||
loaded = load_skills(["client_side_path_traversal"])
|
||||
body = loaded.get("client_side_path_traversal")
|
||||
assert body, "CSPT skill failed to load"
|
||||
# Frontmatter must be stripped by the loader.
|
||||
assert not body.lstrip().startswith("---")
|
||||
assert body.lstrip().startswith("# Client-Side Path Traversal")
|
||||
|
||||
|
||||
def test_cspt_skill_documents_sources_sinks_and_boundary() -> None:
|
||||
body = load_skills(["client_side_path_traversal"])["client_side_path_traversal"].lower()
|
||||
|
||||
# It must teach the sources named in the issue.
|
||||
for source in ("location.hash", "postmessage", "referrer", "localstorage"):
|
||||
assert source in body, f"CSPT skill missing source: {source}"
|
||||
# ...and the request sinks.
|
||||
for sink in ("fetch", "xmlhttprequest", "axios"):
|
||||
assert sink in body, f"CSPT skill missing sink: {sink}"
|
||||
# ...and the explicit boundary vs. server-side classes so findings don't get
|
||||
# miscategorized.
|
||||
assert "not cspt" in body
|
||||
for other in ("lfi", "rfi", "server-side"):
|
||||
assert other in body, f"CSPT skill missing boundary reference: {other}"
|
||||
|
||||
|
||||
def test_cspt_skill_prescribes_finding_class_and_cwe() -> None:
|
||||
body = load_skills(["client_side_path_traversal"])["client_side_path_traversal"]
|
||||
# The skill must tell the agent to tag the finding for separation.
|
||||
assert "client_side_path_traversal" in body
|
||||
assert "CWE-22" in body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", POSITIVE_FIXTURES + NEGATIVE_FIXTURES)
|
||||
def test_fixture_exists_and_is_html(name: str) -> None:
|
||||
path = FIXTURES / name
|
||||
assert path.is_file(), f"missing fixture {name}"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert text.lstrip().lower().startswith("<!doctype html>")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", POSITIVE_FIXTURES)
|
||||
def test_positive_fixture_has_source_flowing_into_a_request_sink(name: str) -> None:
|
||||
text = (FIXTURES / name).read_text(encoding="utf-8")
|
||||
assert any(s in text for s in _SOURCE_MARKERS), f"{name}: no client-side source"
|
||||
assert any(s in text for s in _SINK_MARKERS), f"{name}: no request sink"
|
||||
# A positive must join a value into the request *path* (concatenation or a
|
||||
# template literal), not merely attach a query parameter.
|
||||
joins_into_path = '" + ' in text or "+ '" in text or "${" in text or "this.baseURL + " in text
|
||||
assert joins_into_path, f"{name}: no value concatenated into the request path"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", NEGATIVE_FIXTURES)
|
||||
def test_negative_fixture_is_guarded_or_safe(name: str) -> None:
|
||||
text = (FIXTURES / name).read_text(encoding="utf-8")
|
||||
# Still a real client source + sink (so it's a meaningful negative, not a
|
||||
# blank page), but neutralized.
|
||||
assert any(s in text for s in _SOURCE_MARKERS), f"{name}: no client-side source"
|
||||
assert any(s in text for s in _SINK_MARKERS), f"{name}: no request sink"
|
||||
|
||||
if name == "negative_validated.html":
|
||||
# A strict allowlist guard on the segment before it reaches the sink.
|
||||
assert "^[a-z0-9-]" in text
|
||||
assert ".test(" in text
|
||||
elif name == "negative_safe_construction.html":
|
||||
# Value used as a query parameter against a constant path, never joined
|
||||
# into the path.
|
||||
assert "searchParams.set" in text
|
||||
assert '"/api/orders/" +' not in text
|
||||
|
||||
|
||||
def test_positive_and_negative_fixtures_are_distinct() -> None:
|
||||
# Guard against a copy-paste that would make a "negative" actually vulnerable.
|
||||
negatives = {(FIXTURES / n).read_text(encoding="utf-8") for n in NEGATIVE_FIXTURES}
|
||||
positives = {(FIXTURES / n).read_text(encoding="utf-8") for n in POSITIVE_FIXTURES}
|
||||
assert negatives.isdisjoint(positives)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Categorization separation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _cvss() -> dict[str, str]:
|
||||
return {
|
||||
"attack_vector": "N",
|
||||
"attack_complexity": "L",
|
||||
"privileges_required": "N",
|
||||
"user_interaction": "R",
|
||||
"scope": "U",
|
||||
"confidentiality": "H",
|
||||
"integrity": "N",
|
||||
"availability": "N",
|
||||
}
|
||||
|
||||
|
||||
def _create_kwargs(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"title": "Client-Side Path Traversal in order-detail fetch",
|
||||
"description": "Fragment segment reaches a fetch path unvalidated.",
|
||||
"impact": "Authenticated request steered to a sibling admin endpoint.",
|
||||
"target": "https://app.example.com",
|
||||
"technical_analysis": "location.hash is concatenated into the fetch path.",
|
||||
"poc_description": "Open the app with a crafted fragment.",
|
||||
"poc_script_code": "location.hash = '#/orders/../../admin/keys'",
|
||||
"remediation_steps": "Validate the segment against an allowlist.",
|
||||
"evidence": "Outbound request path was /api/admin/keys with the session cookie.",
|
||||
"assumptions": "Assumes the victim opens a crafted link while authenticated.",
|
||||
"fix_effort": "low",
|
||||
"cvss_breakdown": _cvss(),
|
||||
"cwe": "CWE-22",
|
||||
"endpoint": None,
|
||||
"method": None,
|
||||
"cve": None,
|
||||
"code_locations": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_do_create_accepts_cspt_finding_class() -> None:
|
||||
state = ReportState(run_name="cspt-test")
|
||||
set_global_report_state(state)
|
||||
try:
|
||||
result = asyncio.run(
|
||||
_do_create(finding_class="client_side_path_traversal", **_create_kwargs())
|
||||
)
|
||||
finally:
|
||||
set_global_report_state(None) # type: ignore[arg-type]
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert len(state.vulnerability_reports) == 1
|
||||
assert state.vulnerability_reports[0]["finding_class"] == "client_side_path_traversal"
|
||||
|
||||
|
||||
def test_do_create_defaults_finding_class_to_dynamic() -> None:
|
||||
state = ReportState(run_name="cspt-default")
|
||||
set_global_report_state(state)
|
||||
try:
|
||||
result = asyncio.run(_do_create(**_create_kwargs()))
|
||||
finally:
|
||||
set_global_report_state(None) # type: ignore[arg-type]
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert state.vulnerability_reports[0]["finding_class"] == "dynamic"
|
||||
|
||||
|
||||
def test_do_create_rejects_unknown_finding_class() -> None:
|
||||
result = asyncio.run(_do_create(finding_class="not_a_real_class", **_create_kwargs()))
|
||||
assert result["success"] is False
|
||||
assert any("finding_class" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_sarif_surfaces_cspt_finding_class(tmp_path: Path) -> None:
|
||||
write_sarif(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "Client-Side Path Traversal in order-detail fetch",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-22",
|
||||
"finding_class": "client_side_path_traversal",
|
||||
"timestamp": "2026-07-16 10:00:00 UTC",
|
||||
"code_locations": [{"file": "app.js", "start_line": 12}],
|
||||
}
|
||||
],
|
||||
)
|
||||
doc = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8"))
|
||||
strix = doc["runs"][0]["results"][0]["properties"]["strix"]
|
||||
assert strix["finding_class"] == "client_side_path_traversal"
|
||||
|
||||
|
||||
def test_sarif_omits_default_finding_class(tmp_path: Path) -> None:
|
||||
write_sarif(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "Path Traversal in download handler",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-22",
|
||||
"finding_class": "dynamic",
|
||||
"timestamp": "2026-07-16 10:00:00 UTC",
|
||||
"code_locations": [{"file": "app.py", "start_line": 4}],
|
||||
}
|
||||
],
|
||||
)
|
||||
doc = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8"))
|
||||
strix = doc["runs"][0]["results"][0]["properties"]["strix"]
|
||||
assert "finding_class" not in strix
|
||||
|
||||
|
||||
def test_sarif_separates_cspt_from_server_side_traversal_class(tmp_path: Path) -> None:
|
||||
# Both share CWE-22 (same ruleId), but the class fingerprint must differ so
|
||||
# cross-run dismissal tooling doesn't collapse a CSPT alert into a
|
||||
# server-side path traversal alert.
|
||||
write_sarif(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "Client-Side Path Traversal in order-detail fetch",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-22",
|
||||
"finding_class": "client_side_path_traversal",
|
||||
"timestamp": "2026-07-16 10:00:00 UTC",
|
||||
"code_locations": [{"file": "app.js", "start_line": 12}],
|
||||
},
|
||||
{
|
||||
"id": "vuln-0002",
|
||||
"title": "Path Traversal in download file parameter",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-22",
|
||||
"finding_class": "dynamic",
|
||||
"timestamp": "2026-07-16 10:00:00 UTC",
|
||||
"code_locations": [{"file": "server.py", "start_line": 30}],
|
||||
},
|
||||
],
|
||||
)
|
||||
results = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8"))["runs"][0][
|
||||
"results"
|
||||
]
|
||||
hashes = [r["properties"].get("strix_vuln_class_hash") for r in results]
|
||||
assert all(hashes), "expected a class fingerprint on both findings"
|
||||
assert hashes[0] != hashes[1], "CSPT and server-side traversal share a class fingerprint"
|
||||
Loading…
Add table
Reference in a new issue