mirror of
https://github.com/usestrix/strix.git
synced 2026-10-15 04:18:10 +00:00
Merge 6a7f86b06b into 87f2d783d6
This commit is contained in:
commit
976c4ee409
28 changed files with 1058 additions and 108 deletions
|
|
@ -48,6 +48,7 @@ Core vulnerability classes with deep exploitation techniques.
|
|||
| `business_logic` | Logic flaws, state manipulation, race conditions |
|
||||
| `race_conditions` | TOCTOU, parallel request attacks |
|
||||
| `path_traversal_lfi_rfi` | File inclusion, path traversal |
|
||||
| `client_side_path_traversal` | Exploitable CSPT: route/query/hash into fetch/router |
|
||||
| `open_redirect` | Redirect bypasses, URL parsing tricks |
|
||||
| `mass_assignment` | Attribute injection, hidden parameter pollution |
|
||||
| `insecure_file_uploads` | Upload bypasses, extension tricks |
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -65,80 +66,162 @@ _INCOMPLETE_RUN_STATUSES = frozenset({"failed", "interrupted", "stopped", "runni
|
|||
#: so holding one implies no coverage obligation.
|
||||
_RISK_SKILL_CATEGORY = "vulnerabilities"
|
||||
|
||||
#: How each vulnerability skill can legitimately appear in a ledger row.
|
||||
#:
|
||||
#: Matching a skill to a row is textual, and a skill's filename is not how a
|
||||
#: pentester writes the class down: an agent carrying ``path_traversal_lfi_rfi``
|
||||
#: records "Path Traversal", and one carrying ``weak_password_detection``
|
||||
#: records "weak password policy". A row matches when it contains every word
|
||||
#: of *any one* phrasing here. Skills absent from this map fall back to their
|
||||
#: own words, so a new skill is merely matched strictly, never crashed on —
|
||||
#: but add an entry, because a false gap asserts something untrue in a report.
|
||||
_SKILL_PHRASINGS: dict[str, tuple[str, ...]] = {
|
||||
"agentic_system_security": (
|
||||
"agentic",
|
||||
"agent tool",
|
||||
"mcp",
|
||||
"confused deputy",
|
||||
"tool invocation",
|
||||
@dataclass(frozen=True)
|
||||
class SubTopic:
|
||||
"""One independently-accountable surface of a bundled skill.
|
||||
|
||||
``label`` is how the surface is named back to a reader; ``phrasings`` are
|
||||
the ledger-row wordings that count as covering it (a row matches when it
|
||||
contains every word of any one phrasing).
|
||||
"""
|
||||
|
||||
label: str
|
||||
phrasings: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VulnClass:
|
||||
"""Canonical description of one vulnerability class.
|
||||
|
||||
``aliases`` are the phrasings a ledger row may use to name the class — a
|
||||
skill's filename is not how a pentester writes the class down (an agent
|
||||
carrying ``path_traversal_lfi_rfi`` records "Path Traversal"), so the
|
||||
aliases bridge the two. ``sub_topics`` are the independently-accountable
|
||||
surfaces of a *bundled* skill: testing one surface must not imply the rest,
|
||||
so each is checked on its own. ``cwe`` is the canonical id(s) for the class
|
||||
— advisory metadata today (SARIF keeps its own CWE map), but the registry
|
||||
is the place to unify it.
|
||||
"""
|
||||
|
||||
aliases: tuple[str, ...]
|
||||
sub_topics: tuple[SubTopic, ...] = ()
|
||||
cwe: tuple[str, ...] = ()
|
||||
#: True when a reporting agent may set this class as a report's
|
||||
#: machine-readable ``finding_class`` to separate it from other classes
|
||||
#: that share a CWE (e.g. client-side vs server-side path traversal, both
|
||||
#: CWE-22). Most classes rely on ``ruleId`` alone and leave this False.
|
||||
selectable_finding_class: bool = False
|
||||
|
||||
|
||||
#: The canonical registry of vulnerability classes, keyed by the bare skill
|
||||
#: name (the ``strix/skills/vulnerabilities/<name>.md`` stem). This is the one
|
||||
#: place these facts live, so the same class cannot be described by two
|
||||
#: unrelated string lists — the drift that let client-side path traversal hide
|
||||
#: behind the server-side "path traversal" name. A new skill absent here is
|
||||
#: matched strictly by its own name, never crashed on — but add an entry,
|
||||
#: because a false gap asserts something untrue in a report.
|
||||
VULN_CLASSES: dict[str, VulnClass] = {
|
||||
"agentic_system_security": VulnClass(
|
||||
("agentic", "agent tool", "mcp", "confused deputy", "tool invocation")
|
||||
),
|
||||
"argument_injection": ("argument injection", "option injection", "argv"),
|
||||
"authentication_jwt": ("authentication", "jwt", "session"),
|
||||
"broken_function_level_authorization": (
|
||||
"function level authorization",
|
||||
"authorization",
|
||||
"access control",
|
||||
"privilege escalation",
|
||||
"argument_injection": VulnClass(
|
||||
("argument injection", "option injection", "argv"), cwe=("CWE-88",)
|
||||
),
|
||||
"browser_security": (
|
||||
"browser",
|
||||
"postmessage",
|
||||
"xs leak",
|
||||
"service worker",
|
||||
"cross origin state",
|
||||
"authentication_jwt": VulnClass(("authentication", "jwt", "session"), cwe=("CWE-287",)),
|
||||
"broken_function_level_authorization": VulnClass(
|
||||
(
|
||||
"function level authorization",
|
||||
"authorization",
|
||||
"access control",
|
||||
"privilege escalation",
|
||||
),
|
||||
cwe=("CWE-862",),
|
||||
),
|
||||
"business_logic": ("business logic", "logic flaw"),
|
||||
"csrf": ("csrf", "cross site request forgery"),
|
||||
"header_injection": ("header injection", "host header", "crlf"),
|
||||
"http_request_smuggling": ("request smuggling", "desync"),
|
||||
"idor": ("idor", "object level authorization", "bola", "direct object reference"),
|
||||
"information_disclosure": (
|
||||
"information disclosure",
|
||||
"information leak",
|
||||
"sensitive data",
|
||||
"data exposure",
|
||||
"browser_security": VulnClass(
|
||||
("browser", "postmessage", "xs leak", "service worker", "cross origin state"),
|
||||
sub_topics=(
|
||||
SubTopic("postMessage", ("postmessage", "message listener", "window messaging")),
|
||||
SubTopic(
|
||||
"client-side path traversal",
|
||||
("client side path traversal", "cspt", "client side path"),
|
||||
),
|
||||
SubTopic("XS-Leaks", ("xs leak", "xs-leaks", "cross origin oracle", "cross site leak")),
|
||||
SubTopic(
|
||||
"service workers and caches",
|
||||
("service worker", "cache api", "cache poisoning"),
|
||||
),
|
||||
SubTopic("web workers", ("web worker", "worker script")),
|
||||
SubTopic(
|
||||
"navigation and redirect control",
|
||||
("client side redirect", "spa navigation", "history manipulation", "meta refresh"),
|
||||
),
|
||||
SubTopic(
|
||||
"CSP and browser parsing",
|
||||
("content security policy", "csp bypass", "trusted types"),
|
||||
),
|
||||
SubTopic("local network access", ("local network", "loopback", "dns rebinding")),
|
||||
SubTopic("JavaScript gadgets", ("javascript gadget", "gadget chain", "dom gadget")),
|
||||
),
|
||||
),
|
||||
"insecure_deserialization": ("deserialization",),
|
||||
"insecure_file_uploads": ("file upload",),
|
||||
"llm_prompt_injection": ("prompt injection",),
|
||||
"mass_assignment": ("mass assignment", "parameter binding"),
|
||||
"nosql_injection": ("nosql",),
|
||||
"open_redirect": ("redirect",),
|
||||
"path_traversal_lfi_rfi": (
|
||||
"path traversal",
|
||||
"directory traversal",
|
||||
"file inclusion",
|
||||
"lfi",
|
||||
"rfi",
|
||||
"business_logic": VulnClass(("business logic", "logic flaw"), cwe=("CWE-840",)),
|
||||
"client_side_path_traversal": VulnClass(
|
||||
("client side path traversal", "cspt", "client side path"),
|
||||
cwe=("CWE-22",),
|
||||
selectable_finding_class=True,
|
||||
),
|
||||
"prototype_pollution": ("prototype pollution",),
|
||||
"race_conditions": ("race condition", "toctou"),
|
||||
"rce": ("rce", "remote code execution", "code execution", "command injection"),
|
||||
"semantic_confusion": (
|
||||
"semantic confusion",
|
||||
"parser differential",
|
||||
"normalization",
|
||||
"validator sink mismatch",
|
||||
"csrf": VulnClass(("csrf", "cross site request forgery"), cwe=("CWE-352",)),
|
||||
"header_injection": VulnClass(("header injection", "host header", "crlf"), cwe=("CWE-113",)),
|
||||
"http_request_smuggling": VulnClass(("request smuggling", "desync"), cwe=("CWE-444",)),
|
||||
"idor": VulnClass(
|
||||
("idor", "object level authorization", "bola", "direct object reference"),
|
||||
cwe=("CWE-639",),
|
||||
),
|
||||
"sql_injection": ("sql injection", "sqli"),
|
||||
"ssrf": ("ssrf", "server side request forgery"),
|
||||
"ssti": ("ssti", "template injection"),
|
||||
"subdomain_takeover": ("subdomain takeover",),
|
||||
"weak_password_detection": ("password", "credential", "brute force"),
|
||||
"xss": ("xss", "cross site scripting", "script injection"),
|
||||
"xxe": ("xxe", "xml external entity", "xml entity"),
|
||||
"information_disclosure": VulnClass(
|
||||
("information disclosure", "information leak", "sensitive data", "data exposure"),
|
||||
cwe=("CWE-200",),
|
||||
),
|
||||
"insecure_deserialization": VulnClass(("deserialization",), cwe=("CWE-502",)),
|
||||
"insecure_file_uploads": VulnClass(("file upload",), cwe=("CWE-434",)),
|
||||
"llm_prompt_injection": VulnClass(("prompt injection",), cwe=("CWE-1427",)),
|
||||
"mass_assignment": VulnClass(("mass assignment", "parameter binding"), cwe=("CWE-915",)),
|
||||
"nosql_injection": VulnClass(("nosql",), cwe=("CWE-943",)),
|
||||
"open_redirect": VulnClass(("redirect",), cwe=("CWE-601",)),
|
||||
"path_traversal_lfi_rfi": VulnClass(
|
||||
("path traversal", "directory traversal", "file inclusion", "lfi", "rfi"),
|
||||
cwe=("CWE-22",),
|
||||
),
|
||||
"prototype_pollution": VulnClass(("prototype pollution",), cwe=("CWE-1321",)),
|
||||
"race_conditions": VulnClass(("race condition", "toctou"), cwe=("CWE-362",)),
|
||||
"rce": VulnClass(
|
||||
("rce", "remote code execution", "code execution", "command injection"), cwe=("CWE-94",)
|
||||
),
|
||||
"semantic_confusion": VulnClass(
|
||||
("semantic confusion", "parser differential", "normalization", "validator sink mismatch")
|
||||
),
|
||||
"sql_injection": VulnClass(("sql injection", "sqli"), cwe=("CWE-89",)),
|
||||
"ssrf": VulnClass(("ssrf", "server side request forgery"), cwe=("CWE-918",)),
|
||||
"ssti": VulnClass(("ssti", "template injection"), cwe=("CWE-1336",)),
|
||||
"subdomain_takeover": VulnClass(("subdomain takeover",)),
|
||||
"weak_password_detection": VulnClass(
|
||||
("password", "credential", "brute force"), cwe=("CWE-521",)
|
||||
),
|
||||
"xss": VulnClass(("xss", "cross site scripting", "script injection"), cwe=("CWE-79",)),
|
||||
"xxe": VulnClass(("xxe", "xml external entity", "xml entity"), cwe=("CWE-611",)),
|
||||
}
|
||||
|
||||
#: Backward-compatible view: skill name -> alias phrasings, derived from the
|
||||
#: registry so the two can never drift. Used wherever a class's top-level
|
||||
#: phrasings are needed (and imported by the coverage tests).
|
||||
_SKILL_PHRASINGS: dict[str, tuple[str, ...]] = {
|
||||
name: vclass.aliases for name, vclass in VULN_CLASSES.items()
|
||||
}
|
||||
|
||||
|
||||
def selectable_finding_classes() -> frozenset[str]:
|
||||
"""Vulnerability classes a reporting agent may set as a report's
|
||||
``finding_class``.
|
||||
|
||||
``finding_class`` is a machine-readable sub-classification that separates
|
||||
findings sharing a CWE rule (``ruleId``) in SARIF and dedup — client-side
|
||||
path traversal and server-side path traversal / LFI / RFI all key on
|
||||
CWE-22, so the rule alone cannot tell them apart. Sourcing the selectable
|
||||
set from the registry keeps it from being hand-maintained in the reporting
|
||||
tool, where it would silently drift from the class definitions here.
|
||||
"""
|
||||
return frozenset(
|
||||
name for name, vclass in VULN_CLASSES.items() if vclass.selectable_finding_class
|
||||
)
|
||||
|
||||
|
||||
def read_agent_graph(state_dir: Path) -> dict[str, Any]:
|
||||
"""Load the coordinator's snapshot, or ``{}`` when it isn't readable.
|
||||
|
|
@ -215,55 +298,182 @@ def agents_from_graph(graph: dict[str, Any]) -> list[dict[str, Any]]:
|
|||
return agents
|
||||
|
||||
|
||||
def _skill_phrasings(skill: str) -> list[list[str]]:
|
||||
"""Word lists that would each count as a ledger row naming *skill*."""
|
||||
phrasings = _SKILL_PHRASINGS.get(skill) or (skill,)
|
||||
return [terms for phrase in phrasings if (terms := _normalized(phrase).split())]
|
||||
def _phrasing_wordlists(phrases: tuple[str, ...]) -> list[list[str]]:
|
||||
"""Normalize each phrasing into a word list for :func:`_entry_is_about`."""
|
||||
return [terms for phrase in phrases if (terms := _normalized(phrase).split())]
|
||||
|
||||
|
||||
def cwe_for_skill(skill: str) -> tuple[str, ...]:
|
||||
"""Canonical CWE id(s) for a vulnerability skill, or empty if unmapped."""
|
||||
vclass = VULN_CLASSES.get(_skill_leaf(skill))
|
||||
return vclass.cwe if vclass else ()
|
||||
|
||||
|
||||
def _term_in_words(term: str, words: set[str]) -> bool:
|
||||
"""A phrasing word matches a row word exactly, or as its simple plural.
|
||||
|
||||
Matching whole words, not substrings, is what stops a short class token
|
||||
like ``rce`` matching inside ``brute force`` / ``enforcement`` or ``idor``
|
||||
inside ``corridor``. The plural allowance (``worker`` matches a ``workers``
|
||||
row, ``password`` a ``passwords`` row) keeps a trivial inflection from
|
||||
opening a false gap.
|
||||
|
||||
Only the forward direction (phrasing term + ``s``) is allowed, never the
|
||||
reverse (row word + ``s`` == term): the reverse would let the row word
|
||||
``xs`` satisfy the ``xss`` class alias (``"xs" + "s" == "xss"``), so an
|
||||
unrelated XS-Leaks row would falsely mark XSS covered. A phrasing that
|
||||
needs to match a singular row carries the singular spelling as its own
|
||||
alias instead.
|
||||
"""
|
||||
return term in words or f"{term}s" in words
|
||||
|
||||
|
||||
def _entry_is_about(entry: dict[str, Any], phrasings: list[list[str]]) -> bool:
|
||||
"""True when a ledger row plausibly concerns any phrasing of a risk class."""
|
||||
haystack = _normalized(f"{entry.get('risk_area', '')} {entry.get('surface', '')}")
|
||||
return any(all(term in haystack for term in terms) for terms in phrasings)
|
||||
"""True when a ledger row names any phrasing of a risk class.
|
||||
|
||||
A phrasing matches when every one of its words appears as a **word** in the
|
||||
row (``risk_area`` + ``surface``) — not as a substring, so "brute force"
|
||||
never counts as ``rce`` coverage and "corridor" never as ``idor``. As a
|
||||
fallback it also matches the phrasing's words joined into one token, so a
|
||||
row that recorded a joined / CamelCase name the normalizer kept whole
|
||||
("DirectoryTraversal" -> "directorytraversal", "XSLeaks" -> "xsleaks")
|
||||
still counts — still a whole-token check, never a substring.
|
||||
"""
|
||||
words = set(_normalized(f"{entry.get('risk_area', '')} {entry.get('surface', '')}").split())
|
||||
for terms in phrasings:
|
||||
if all(_term_in_words(term, words) for term in terms):
|
||||
return True
|
||||
if len(terms) > 1 and _term_in_words("".join(terms), words):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _entry_authored_by(entry: dict[str, Any], owner_ids: set[str]) -> bool:
|
||||
"""True when this row — now, or in a superseded revision — was written by
|
||||
one of *owner_ids*.
|
||||
|
||||
``update_coverage`` overwrites ``agent_id`` with the updater's, so a surface
|
||||
a carrier genuinely assessed would look unexamined after an unrelated agent
|
||||
edits the shared row. History preserves the earlier author, so an earlier
|
||||
assessment by a carrier still counts.
|
||||
"""
|
||||
if str(entry.get("agent_id") or "") in owner_ids:
|
||||
return True
|
||||
history = entry.get("history")
|
||||
if isinstance(history, list):
|
||||
return any(
|
||||
isinstance(item, dict) and str(item.get("agent_id") or "") in owner_ids
|
||||
for item in history
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _topics_for(skill: str) -> list[tuple[str | None, list[list[str]]]]:
|
||||
"""The independently-accountable topics a carried skill must cover.
|
||||
|
||||
A *bundled* skill (one with declared ``sub_topics``) is accountable per
|
||||
surface: proving one does not imply the rest, so each sub-topic is a topic
|
||||
of its own. Every other skill has a single implicit topic — the class
|
||||
itself, matched by its aliases — which reproduces the original skill-level
|
||||
behaviour exactly. Each topic is ``(label, wordlists)``; the label is
|
||||
``None`` for the single-topic case.
|
||||
"""
|
||||
vclass = VULN_CLASSES.get(skill)
|
||||
if vclass is None:
|
||||
return [(None, _phrasing_wordlists((skill,)))]
|
||||
if vclass.sub_topics:
|
||||
return [(st.label, _phrasing_wordlists(st.phrasings)) for st in vclass.sub_topics]
|
||||
return [(None, _phrasing_wordlists(vclass.aliases))]
|
||||
|
||||
|
||||
def skill_coverage_gaps(
|
||||
entries: list[dict[str, Any]], agents: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Vulnerability classes an agent was equipped for but never recorded.
|
||||
"""Vulnerability classes (and bundled-skill surfaces) an agent was equipped
|
||||
for but never recorded.
|
||||
|
||||
A skill assigned to an agent is a declaration of intent that the runtime
|
||||
observed independently of anything the agent later said. When no ledger
|
||||
row mentions that class, the class is unaccounted for — which is a very
|
||||
different report line from "tested, nothing found".
|
||||
|
||||
A bundled skill is held to a finer standard: each of its surfaces is
|
||||
checked on its own, so testing the easy one (a ``postMessage`` origin
|
||||
check) cannot mark the dangerous one (client-side path traversal) as
|
||||
covered. A skill nobody touched still collapses to a single class-level
|
||||
gap; a partially tested one yields one gap per untested surface.
|
||||
"""
|
||||
risk_skills = _risk_skill_names()
|
||||
if not risk_skills:
|
||||
return []
|
||||
|
||||
carriers: dict[str, list[str]] = {}
|
||||
carrier_ids: dict[str, set[str]] = {}
|
||||
for agent in agents:
|
||||
agent_id = str(agent.get("agent_id") or "")
|
||||
for skill in agent["skills"]:
|
||||
leaf = _skill_leaf(skill)
|
||||
if leaf in risk_skills:
|
||||
carriers.setdefault(leaf, []).append(str(agent["agent_name"]))
|
||||
if agent_id:
|
||||
carrier_ids.setdefault(leaf, set()).add(agent_id)
|
||||
|
||||
gaps: list[dict[str, Any]] = []
|
||||
for skill, agent_names in sorted(carriers.items()):
|
||||
phrasings = _skill_phrasings(skill)
|
||||
if any(_entry_is_about(entry, phrasings) for entry in entries):
|
||||
names = ", ".join(sorted(set(agent_names)))
|
||||
topics = _topics_for(skill)
|
||||
# A bundled skill's surfaces are only covered by rows from an agent that
|
||||
# actually carried that skill. Otherwise an unrelated row that merely
|
||||
# shares a word — an open_redirect row mentioning "meta refresh" — would
|
||||
# mask an untested browser surface. Single-topic skills keep the shared
|
||||
# ledger semantics: any row naming the class counts, whoever wrote it.
|
||||
vclass = VULN_CLASSES.get(skill)
|
||||
if vclass is not None and vclass.sub_topics:
|
||||
owner_ids = carrier_ids.get(skill, set())
|
||||
relevant = [e for e in entries if _entry_authored_by(e, owner_ids)]
|
||||
else:
|
||||
relevant = entries
|
||||
uncovered = [
|
||||
(label, wordlists)
|
||||
for label, wordlists in topics
|
||||
if not any(_entry_is_about(entry, wordlists) for entry in relevant)
|
||||
]
|
||||
if not uncovered:
|
||||
continue
|
||||
gaps.append(
|
||||
{
|
||||
"kind": "unrecorded_risk_class",
|
||||
"risk_area": skill.replace("_", " "),
|
||||
"detail": (
|
||||
f"Agent(s) {', '.join(sorted(set(agent_names)))} were assigned the "
|
||||
f"'{skill}' skill, but no coverage entry records this class being "
|
||||
"assessed. Treat it as unexamined, not as clean."
|
||||
),
|
||||
}
|
||||
)
|
||||
if len(uncovered) == len(topics):
|
||||
# Nothing about this class was recorded at all — one class-level gap.
|
||||
detail = (
|
||||
f"Agent(s) {names} were assigned the '{skill}' skill, but no coverage "
|
||||
"entry records this class being assessed. Treat it as unexamined, not "
|
||||
"as clean."
|
||||
)
|
||||
if len(topics) > 1:
|
||||
surfaces = ", ".join(str(label) for label, _ in topics)
|
||||
detail += f" Its surfaces ({surfaces}) are each separately accountable."
|
||||
gaps.append(
|
||||
{
|
||||
"kind": "unrecorded_risk_class",
|
||||
"risk_area": skill.replace("_", " "),
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
continue
|
||||
# Some surfaces were tested but not all: name each untested surface, so a
|
||||
# bundled skill cannot be marked covered on the strength of its easiest one.
|
||||
for label, _wordlists in uncovered:
|
||||
gaps.append(
|
||||
{
|
||||
"kind": "unrecorded_sub_topic",
|
||||
"skill": skill,
|
||||
"risk_area": str(label),
|
||||
"detail": (
|
||||
f"Agent(s) {names} carried '{skill}' and recorded coverage for some "
|
||||
f"of its surfaces, but nothing records '{label}'. Testing one surface "
|
||||
"of a multi-surface skill does not cover the others — treat "
|
||||
f"'{label}' as unexamined."
|
||||
),
|
||||
}
|
||||
)
|
||||
return gaps
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ CRITICAL DEDUPLICATION RULES:
|
|||
- Same CVE but different package/ecosystem is NOT a duplicate
|
||||
- Same package/ecosystem but different CVE is NOT a duplicate
|
||||
|
||||
5. finding_class separates classes only when both are specific:
|
||||
- two reports with different SPECIFIC (non-default) finding_class values are
|
||||
distinct classes even under one shared CWE - fixed by different code, NOT duplicates
|
||||
- but `dynamic` is the default catch-all, not a class: a `dynamic` report and a
|
||||
`client_side_path_traversal` report can still be the SAME bug (e.g. an older CSPT
|
||||
filed before the class existed). Do not treat dynamic-vs-specific as proof of
|
||||
distinctness - compare the underlying root cause
|
||||
|
||||
COMPARISON GUIDELINES:
|
||||
- Focus on the technical root cause, not surface-level similarities
|
||||
- Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters
|
||||
|
|
@ -103,6 +111,9 @@ COMPARISON GUIDELINES:
|
|||
FIELDS TO ANALYZE:
|
||||
- title, description: General vulnerability info
|
||||
- target, endpoint, method: Exact location of vulnerability
|
||||
- finding_class: Machine-readable class. Two different SPECIFIC classes under one
|
||||
CWE are different bugs (not duplicates); but `dynamic` is the default, not a
|
||||
class, so a `dynamic` report may still duplicate a specific one - compare the bug
|
||||
- technical_analysis: Root cause details
|
||||
- poc_description: How it's exploited
|
||||
- impact: What damage it can cause
|
||||
|
|
@ -145,6 +156,7 @@ def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:
|
|||
"endpoint",
|
||||
"method",
|
||||
"cve",
|
||||
"finding_class",
|
||||
"dependency_metadata",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -556,6 +556,15 @@ 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 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.
|
||||
finding_class = _string_value(report.get("finding_class"))
|
||||
if finding_class and finding_class != "dynamic":
|
||||
strix["finding_class"] = finding_class
|
||||
|
||||
dependency_metadata = report.get("dependency_metadata")
|
||||
if isinstance(dependency_metadata, dict) and dependency_metadata:
|
||||
strix["dependency_metadata"] = dependency_metadata
|
||||
|
|
@ -1017,12 +1026,18 @@ def _primary_fingerprint(
|
|||
parts.append(f"route:{route}")
|
||||
|
||||
if is_synthetic:
|
||||
# Augment with class keyword so locationless findings of
|
||||
# Augment with the class token so locationless findings of
|
||||
# different vuln classes don't collide. rule_id is already in
|
||||
# the composite so we don't also need to stamp the CWE.
|
||||
title = _string_value(report.get("title")) or ""
|
||||
if title:
|
||||
parts.append(f"synth_class:{_class_keyword(title)}")
|
||||
# Prefer the structured finding_class over a title keyword: a CSPT
|
||||
# finding and a server-side traversal finding both key on CWE-22
|
||||
# and can share the generic "path traversal" title keyword, so
|
||||
# keying on the title alone would collapse them onto one primary
|
||||
# fingerprint. _class_token resolves to the discriminating
|
||||
# structured field when present.
|
||||
token = _class_token(report)
|
||||
if token:
|
||||
parts.append(f"synth_class:{token}")
|
||||
|
||||
composite = "|".join(parts)
|
||||
return hashlib.sha256(composite.encode("utf-8")).hexdigest()
|
||||
|
|
@ -1045,23 +1060,41 @@ def _class_fingerprint(rule_id: str, report: dict[str, Any]) -> str | None:
|
|||
primary fingerprint (typical case: file rename, or a fix that moves
|
||||
the vulnerable code to a new module).
|
||||
|
||||
Composite of (rule_id, vuln-class keyword extracted from title).
|
||||
Title is LLM-authored so it's stochastic at the prose level, but
|
||||
the class keyword extraction picks up the discrete vulnerability
|
||||
category, which is much more stable than the full title.
|
||||
|
||||
Falls back to the first 5 lowercased words of the title when no
|
||||
curated keyword matches. Acceptable as a fallback because the
|
||||
class fingerprint is a tiebreaker, not a primary reconciliation key.
|
||||
Composite of (rule_id, vuln-class token). The token comes from
|
||||
``_class_token``, which prefers the structured ``finding_class`` and
|
||||
falls back to a title keyword — see that helper for why the structured
|
||||
field must win (CSPT vs. server-side traversal both key on CWE-22).
|
||||
"""
|
||||
title = _string_value(report.get("title")) or ""
|
||||
keyword = _class_keyword(title) if title else ""
|
||||
if not keyword:
|
||||
token = _class_token(report)
|
||||
if not token:
|
||||
return None
|
||||
composite = f"rule:{rule_id}|class:{keyword}"
|
||||
composite = f"rule:{rule_id}|class:{token}"
|
||||
return hashlib.sha256(composite.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _class_token(report: dict[str, Any]) -> str:
|
||||
"""Resolve a finding's vulnerability-class token for fingerprinting.
|
||||
|
||||
Prefers the structured ``finding_class`` when it carries signal (present
|
||||
and not the default ``dynamic``); otherwise falls back to a keyword from
|
||||
the (LLM-authored) title. Returns an empty string when neither yields a
|
||||
token.
|
||||
|
||||
Shared by ``_class_fingerprint`` (file-rename dismissal carryover) and the
|
||||
synthetic branch of ``_primary_fingerprint`` (locationless distinguisher)
|
||||
so both fingerprints agree on what counts as "the same class". This is what
|
||||
stops a CSPT finding whose title contains the generic ``path traversal``
|
||||
keyword from collapsing onto a server-side path-traversal finding's primary
|
||||
fingerprint: the structured field distinguishes them and both hashes
|
||||
consume it.
|
||||
"""
|
||||
finding_class = _string_value(report.get("finding_class"))
|
||||
if finding_class and finding_class != "dynamic":
|
||||
return finding_class
|
||||
title = _string_value(report.get("title")) or ""
|
||||
return _class_keyword(title) if title else ""
|
||||
|
||||
|
||||
def _class_keyword(title: str) -> str:
|
||||
"""Pick the first matching curated keyword in ``title``, or fall
|
||||
back to the first 5 lowercased alpha-numeric words.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ Notable source-aware skills:
|
|||
- `semantic_confusion` (vulnerabilities): cross-boundary parser, normalization, and representation mismatch analysis
|
||||
- `agentic_system_security` (vulnerabilities): effective-authority and MCP/tool ecosystem security testing
|
||||
- `browser_security` (vulnerabilities): browsing-context, postMessage, XS-Leaks, service-worker, and cross-origin state-machine testing
|
||||
- `client_side_path_traversal` (vulnerabilities): exploitable client-side path traversal — route/query/hash into fetch/router/BFF request, escalated by sink
|
||||
- `azure` (cloud): Azure and Microsoft Entra privilege, PIM, workload identity, and cross-plane escalation analysis
|
||||
- `infrastructure_lifecycle` (reconnaissance): abandoned or mutable external dependencies such as update endpoints, MX, storage, and control domains
|
||||
- `argument_injection` (vulnerabilities): shell-free CLI option smuggling, secondary argument-file parsing, and platform-specific argv transformation boundaries
|
||||
|
|
|
|||
|
|
@ -117,6 +117,22 @@ row because the fetch is an intended feature, because the filter is
|
|||
operator-configured or empty by default, or because it only runs
|
||||
pre-request.
|
||||
|
||||
**Client-side request construction.** Enumerate every place a route
|
||||
segment, query value, or URL hash flows into a client request URL —
|
||||
`fetch`/XHR/axios or any other HTTP client — into a client-router
|
||||
navigation, or into a server-side/BFF fetch whose path the client
|
||||
shapes. Keep both lines candidate-visible: the decode/normalize step
|
||||
(where `../`, `%2f`, `%2e`, a backslash, or double-encoded forms survive)
|
||||
and the request-construction sink that consumes the tainted path.
|
||||
Promote only when the traversed request has a security-relevant effect —
|
||||
a state change (CSRF-like), a response rendered or executed in an unsafe
|
||||
sink (XSS), a server-side fetch reaching an internal/privileged target
|
||||
(SSRF), an authorization or permission bypass, or disclosure of another
|
||||
user's or tenant's data. A router or framework that merely decodes `../`
|
||||
with no request sink, or a sink whose redirected request has no
|
||||
security-relevant effect, is a false positive — do not report it. Point
|
||||
to `client_side_path_traversal`.
|
||||
|
||||
**Command and action runners.** Enumerate every attacker-controllable
|
||||
argument type and execution mode before you call command injection
|
||||
covered. Type-safety maps, unsafe-type denylists, template substitution,
|
||||
|
|
|
|||
|
|
@ -176,6 +176,35 @@ lighter syntax/anti-pattern check when ESLint is over-eager. The
|
|||
`JS-Snooper` / `jsniper.sh` tools (in `katana.md`) are the right next
|
||||
step to mine those bundles for endpoint candidates.
|
||||
|
||||
### Route-Param-Into-Request (Client-Side Path Traversal)
|
||||
|
||||
`eslint --rule no-eval` and `retire` will NOT surface this — neither
|
||||
flags tainted-path flow or framework routing. Grep the router inputs and
|
||||
the request sinks, then pair them by hand:
|
||||
|
||||
```bash
|
||||
# Router / query / hash inputs
|
||||
grep -rnE 'useParams|useSearchParams|useLocation|useRouter|usePathname' src/ # React/Next
|
||||
grep -rnE 'route\.params|route\.query|\$route|ActivatedRoute|paramMap' src/ # Vue, Angular
|
||||
|
||||
# Request sinks that may consume a tainted path
|
||||
grep -rnE 'fetch\(|axios\.|\.get\(|\.post\(|new URL\(' src/
|
||||
```
|
||||
|
||||
Pair the source with the sink structurally with ast-grep:
|
||||
|
||||
```bash
|
||||
sg run --pattern 'fetch($URL)'
|
||||
sg run --pattern 'axios.get($URL)'
|
||||
```
|
||||
|
||||
A route/query/hash value that reaches the URL argument (directly or via a
|
||||
string-interpolated URL) without re-encoding is a candidate. Include
|
||||
Next.js route handlers (`app/api/**/route.ts`) that forward a
|
||||
client-supplied path segment into a server-side `fetch` — the traversal
|
||||
then runs server-side (SSRF / internal reach). Point to
|
||||
`client_side_path_traversal`.
|
||||
|
||||
## Converting Static Signals Into Exploits
|
||||
|
||||
When source contains model-provider SDKs, prompt templates, retrieval/vector stores, tool/function calling, model loading, training/feedback pipelines, or token/agent-loop accounting, load `llm_applications`. Use its OWASP 2026 LLM01-LLM10 map to trace data provenance, model output, retrieval authorization, tool authority, and resource multipliers rather than treating the provider call as the sink.
|
||||
|
|
|
|||
|
|
@ -200,6 +200,15 @@ Look for `_metadata`, `_internal`, `__typename` (GraphQL), nested sensitive obje
|
|||
|
||||
Server vs client render differences can enable gadget-based XSS.
|
||||
|
||||
**Client-Side Path Traversal**
|
||||
|
||||
`params`/`searchParams` (or a client-supplied path forwarded by a Route
|
||||
Handler / BFF) used to build a `fetch` or `router.push`/navigation URL is
|
||||
a client-side path traversal surface — decoded `../`/`%2f` can repoint
|
||||
the request to another endpoint. Treat it as a finding only when the
|
||||
repointed request has a security-relevant effect (CSRF-like / XSS / SSRF
|
||||
/ authz / cross-tenant); load `client_side_path_traversal`.
|
||||
|
||||
### Draft/Preview Mode
|
||||
|
||||
- Secret URLs/cookies enabling preview
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ Test every input vector with every applicable technique.
|
|||
- Cache poisoning and cache deception
|
||||
- Subdomain takeover
|
||||
- Prototype pollution (JavaScript applications)
|
||||
- Client-side path traversal: client-rendered front end (SPA in React/Vue/Angular/Svelte/Next.js, or any page building a fetch/XHR/axios URL or client router navigation from the URL path, query, or hash) → load `client_side_path_traversal` and test for EXPLOITABLE CSPT (a decoded `../` reaching no request sink with real impact is a false positive)
|
||||
- CORS misconfiguration exploitation
|
||||
- WebSocket security testing
|
||||
- GraphQL-specific attacks (introspection, batching, nested queries)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ Also in scope, and routinely missed:
|
|||
- A behavioral change that invalidates an assumption elsewhere: a type
|
||||
loosened, a default flipped, a validator made optional, an error path
|
||||
changed from reject to log-and-continue.
|
||||
- A change that adds or alters client-side URL or route construction
|
||||
from user-controlled URL input (path, query, or hash) feeding a
|
||||
fetch/XHR/axios call or client router navigation — a
|
||||
CSPT-introduction candidate. Load `client_side_path_traversal` and
|
||||
confirm an EXPLOITABLE sink; a decoded `../` reaching no request sink
|
||||
with real impact is a false positive.
|
||||
|
||||
**Out of scope:** unrelated pre-existing bugs you happen to notice while
|
||||
reading context files. Note them, do not file them against this PR. The
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ Test in priority order:
|
|||
4. **SQL injection** - authentication endpoints, search, filters
|
||||
5. **SSRF** - URL parameters, webhooks, integrations
|
||||
6. **Exposed secrets** - hardcoded credentials, API keys, config files
|
||||
7. **Client-side path traversal** - client-rendered front end (SPA in React/Vue/Angular/Svelte/Next.js, or any page building a fetch/XHR/axios URL or client router navigation from the URL path, query, or hash) → load `client_side_path_traversal` and test for EXPLOITABLE CSPT; a decoded `../` reaching no request sink with real impact is a false positive
|
||||
|
||||
Skip for quick scans:
|
||||
- Exhaustive subdomain enumeration
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ Test each attack surface methodically. Spawn focused subagents for different are
|
|||
- File upload bypass attempts
|
||||
- Search and filter parameter manipulation
|
||||
- Redirect and URL parameter handling
|
||||
- Client-side path traversal: client-rendered front end (SPA in React/Vue/Angular/Svelte/Next.js, or any page building a fetch/XHR/axios URL or client router navigation from the URL path, query, or hash) → load `client_side_path_traversal` and test for EXPLOITABLE CSPT (a decoded `../` reaching no request sink with real impact is a false positive)
|
||||
|
||||
**Authentication & Session**
|
||||
- Brute force protection
|
||||
|
|
|
|||
|
|
@ -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 browsing-context relationships, postMessage, client-side path traversal (SPA fetch-or-router URL built from a URL path, query, or hash), XS-Leaks, service workers, Web Workers, navigation behavior, CSP interactions, caches, and cross-origin state machines
|
||||
---
|
||||
|
||||
# Browser Security
|
||||
|
|
@ -45,6 +45,8 @@ Draw the context graph. Security checks on `event.origin`, `event.source`, or a
|
|||
|
||||
### Client-Side Path Traversal
|
||||
|
||||
For the full exploitable-CSPT discovery/escalation/validation workflow, load `client_side_path_traversal`.
|
||||
|
||||
Trace the complete source-to-request pipeline:
|
||||
|
||||
```text
|
||||
|
|
|
|||
128
strix/skills/vulnerabilities/client_side_path_traversal.md
Normal file
128
strix/skills/vulnerabilities/client_side_path_traversal.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
name: client-side-path-traversal
|
||||
description: Client-side path traversal (CSPT) testing in SPAs — React Router, Vue Router, Angular router, Next.js client navigation — where a route/query/hash parameter flows into a fetch/XHR/axios URL, a client router navigation, or a BFF proxy path
|
||||
---
|
||||
|
||||
# Client-Side Path Traversal
|
||||
|
||||
Client-Side Path Traversal (CSPT) is a traversal where the attacker-controlled source is a browser-visible part of the URL (a path segment, query value, or hash) and the sink is a request the page builds or shapes — a `fetch`/XHR/axios call, a client-router navigation, or a server-side/BFF fetch whose path the client supplies. There is no filesystem and no local file read: the `../`, `%2f`, `%2e`, or backslash survives decoding/normalization and rewrites the *request path* the browser (or the BFF on its behalf) ultimately issues. Treat it as a request-redirection primitive inside the app, not as server-side path traversal; the impact comes from where the redirected request lands and what it does.
|
||||
|
||||
## Exploitability Bar
|
||||
|
||||
CSPT is a finding only when ALL THREE hold:
|
||||
|
||||
1. **Controlled source survives.** A browser-controlled source — a URL path segment, query value, or hash — is decoded/normalized so that path syntax (`../`, `%2f`, `%2e`, backslash, double-encoded forms) still carries into the next stage instead of being stripped or re-encoded.
|
||||
2. **Reaches a request sink.** That value reaches a request-construction sink in the page: `fetch`/XHR/axios/another HTTP client, OR a client-router navigation, OR a server-side/BFF fetch whose path the client controls.
|
||||
3. **Produces real impact.** The resulting request has a security-relevant effect: a state change (CSRF-like), a response rendered/executed in an unsafe sink (XSS), a server-side fetch reaching an internal/privileged target (SSRF), an authorization/permission bypass, or disclosure of another user's or tenant's data.
|
||||
|
||||
**False-positive rule:** a router or framework that merely decodes `../` with NO request sink, or a sink whose redirected request has NO security-relevant effect, is a FALSE POSITIVE — do not report it. This mirrors the repo-wide closure discipline (`confirmed` / `ruled_out` / `open_proof_gap`) and the `counterevidence` pass: no proven sink and impact means `open_proof_gap`, not a finding.
|
||||
|
||||
## Source -> Sink -> Impact
|
||||
|
||||
Trace the whole pipeline before claiming anything:
|
||||
|
||||
```text
|
||||
browser URL -> router parser -> route/query/hash accessor -> app URL construction -> fetch/XHR/router -> final normalized request
|
||||
```
|
||||
|
||||
Each decode or re-encode along that chain is a decision point: confirm the traversal is still present in the request that actually leaves the page.
|
||||
|
||||
Sink taxonomy and the impact it maps to:
|
||||
|
||||
- **State-changing API call** (`POST`/`PUT`/`PATCH`/`DELETE` built from the source) -> CSRF-like unintended action on a different resource.
|
||||
- **Response rendered/executed in the DOM** (traversed response body into `innerHTML`, template, `eval`, script/JSON-driven render) -> XSS.
|
||||
- **Server-side / BFF fetch** keyed on a client-supplied path segment -> SSRF toward internal/metadata/privileged targets.
|
||||
- **Authorization/permission check keyed on the path** (resource id, tenant slug, role segment) -> authz bypass or cross-tenant disclosure of another user's/tenant's data.
|
||||
|
||||
One source can reach several sinks; classify by the strongest proven impact, not the easiest to trigger.
|
||||
|
||||
Minimal shape of the primitive — a profile viewer whose id comes from the route:
|
||||
|
||||
```text
|
||||
app route: /users/<id>
|
||||
app builds: fetch(`/api/users/${id}`)
|
||||
attacker id: ../../admin/settings (or ..%2f..%2fadmin%2fsettings)
|
||||
final req: GET /api/admin/settings <- traversal survived into the sink
|
||||
```
|
||||
|
||||
Whether this is a finding depends entirely on what `/api/admin/settings` does for the current principal — map that before reporting.
|
||||
|
||||
## Discovery -- black-box (runtime)
|
||||
|
||||
Instrument in a controlled browser session to capture the FINAL request shape (method + origin + path only — never bodies, credentials, or query strings):
|
||||
|
||||
```javascript
|
||||
const realFetch = window.fetch;
|
||||
window.fetch = (...args) => {
|
||||
const input = args[0];
|
||||
const raw = typeof input === 'string' ? input : input.url;
|
||||
const u = new URL(raw, location.href);
|
||||
const method = args[1]?.method || input?.method || 'GET';
|
||||
console.log('fetch', {method, origin: u.origin, path: u.pathname});
|
||||
return realFetch(...args);
|
||||
};
|
||||
const realOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||
const u = new URL(url, location.href);
|
||||
console.log('xhr', {method, origin: u.origin, path: u.pathname});
|
||||
return realOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
```
|
||||
|
||||
Restore `window.fetch = realFetch` and the XHR prototype after validation. This adapts the wrapper in `browser_security`; load that skill for the full browser-state model and router-decoding notes. The wrapper captures `fetch` and XHR only — a pure client-router navigation sink (`history.pushState`, `router.push`) issues no HTTP request from this hook, so watch those in Caido's outbound capture or wrap the history/router API separately.
|
||||
|
||||
Then vary the route/query/hash independently with `%2f`, `%2e`, `%5c`, raw dot-segments (`../`, `..%2f`), and double-encoded forms (`%252f`, `%252e`). For each source, read the logged final path and determine exactly where the sequence decodes, is removed, or re-encodes. Path parameters, query values, and fragments are commonly transformed differently — test each on its own. agent-browser (see `tooling/agent_browser`) runs in the sandbox with Caido capturing the final request, so compare the console log against the captured outbound request.
|
||||
|
||||
## Discovery -- white-box (source)
|
||||
|
||||
Find a route/query/hash value flowing into a request URL. Grep or `ast-grep` per framework:
|
||||
|
||||
- **React:** `useParams`, `useSearchParams`, `useLocation`, `useRouter` whose result is interpolated into `fetch(`, `axios`, `.get(`/`.post(`.
|
||||
```bash
|
||||
rg -n "useParams|useSearchParams|useLocation|useRouter" --glob '*.{ts,tsx,js,jsx}' -A4 | rg "fetch\(|axios|\.(get|post|put|patch|delete)\("
|
||||
```
|
||||
- **Vue:** `useRoute`, `route.params`, `route.query`, `$route` feeding an HTTP client (`fetch`, `axios`, `$http`).
|
||||
- **Angular:** `ActivatedRoute`, `paramMap`, `snapshot.params`/`snapshot.queryParams` feeding `HttpClient.get/post`.
|
||||
- **Svelte/SvelteKit:** `$page.params`, `$page.url.searchParams`, and `load({ params, url })` whose value is interpolated into `fetch(`/`axios`, or routed through a `+server.ts`/`+page.server.ts` handler that forwards the client-supplied segment.
|
||||
- **Generic:** string-interpolated URL construction — `` `${base}/${param}` ``, `url + param`, `new URL(param, base)`, path `.join`/concat — where `param` is any router-derived value.
|
||||
|
||||
Call out BFF and Next.js route handlers (`app/**/route.ts`, `pages/api/*`) that forward a client-supplied path into a server `fetch`; those turn CSPT into SSRF. See `nextjs` for RSC/BFF path forwarding. The same framework API can decode differently in client components, server components, and route handlers — do not assume one behavior.
|
||||
|
||||
## Escalation by sink
|
||||
|
||||
Prove the impact-class-specific fact before calling it exploitable:
|
||||
|
||||
- **CSRF-like:** show the redirected request hit a *different*, state-changing endpoint and the state change occurred (not merely a 404/redirect). Include a benign-path control.
|
||||
- **XSS:** show the traversed response body reached an unsafe render sink AND executed (script ran / handler fired) — a reflected string that never executes is not XSS.
|
||||
- **SSRF:** show the server-side/BFF fetch reached an internal or metadata target (e.g. a controlled internal canary or `169.254.169.254`) and returned or acted on that response; a same-origin public redirect is not SSRF.
|
||||
- **Authz bypass / cross-tenant:** show the request returned or mutated a resource the current principal must not access (another user/tenant id), with a paired same-principal control that is denied or scoped correctly.
|
||||
|
||||
## Validation
|
||||
|
||||
1. Capture the final network request (method + origin + path) AND the security-relevant response or action it caused.
|
||||
2. Run a paired control: a benign path next to the traversed path, same session and state, so the difference is attributable to the traversal and not to unrelated behavior.
|
||||
3. Provide a minimal PoC — the smallest source value that redirects the request and the single observed effect. Do not paste large payload catalogs.
|
||||
4. Close under the repo discipline: a proven source+sink+impact is `confirmed`; a decode with no reachable sink, or a sink with no security-relevant effect, is `ruled_out` (name the control) or `open_proof_gap` (name the missing proof). Run the `counterevidence` pass before reporting.
|
||||
|
||||
## False Positives
|
||||
|
||||
- Decode/normalization of `../` with no request sink downstream — the value never shapes a request.
|
||||
- A sink whose redirected request has no security-relevant effect (static asset, idempotent read of already-authorized data, dead/404 path).
|
||||
- Same-origin-only redirection to an equally-authorized endpoint — the principal could already call it directly.
|
||||
- A framework that re-encodes or segment-normalizes before the sink, so the traversal does not survive into the final request.
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. The attacker-supplied string is not evidence; the browser-parsed final request path is. Always read the logged `origin` + `path`, never the raw input.
|
||||
2. Query and hash values are usually decoded automatically; path segments vary by router and by execution context. Test each source separately.
|
||||
3. Double-encoding (`%252f`) often survives one decode layer that strips single-encoded forms — probe it whenever a single `%2f` is removed.
|
||||
4. A BFF/route-handler hop is the difference between a same-origin dead end and SSRF; trace whether the server fetch inherits the client-supplied segment.
|
||||
5. No sink and no impact is `open_proof_gap`, never a finding. Name the missing proof instead of reporting the decode.
|
||||
6. Prefer a one-line minimal PoC and a paired control over a payload catalog; the reusable unit is the source-to-sink pipeline, not the string.
|
||||
|
||||
## Cross-links
|
||||
|
||||
- `browser_security` — browser-state model, router-decoding notes, runtime instrumentation this skill adapts.
|
||||
- `semantic_confusion` — decode/normalization differentials across router vs server when the two disagree on the same bytes.
|
||||
- `open_redirect`, `csrf`, `xss`, `ssrf` — the escalation sinks; use the matching skill once the impact class is known.
|
||||
- `nextjs` — RSC/BFF route handlers that forward a client-supplied path into a server fetch.
|
||||
|
|
@ -152,6 +152,7 @@ Cross-site request forgery abuses ambient authority (cookies, HTTP auth) across
|
|||
- CSRF + IDOR: force actions on other users' resources once references are known
|
||||
- CSRF + Clickjacking: guide user interactions to bypass UI confirmations
|
||||
- CSRF + OAuth mix-up: bind victim sessions to unintended clients
|
||||
- CSRF + client-side path traversal: a browser-controlled path can retarget a same-origin, credentialed request to a state-changing endpoint, defeating assumptions that only the intended path is called — see `client_side_path_traversal`
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||
- Read files outside intended roots via `../`, encoding, normalization gaps
|
||||
- Write or create files outside intended roots, then evaluate framework-controlled resolution paths separately from direct web access
|
||||
|
||||
**Client-Side Variant**
|
||||
- A browser-controlled path builds a client-issued request (fetch/XHR/router navigation) rather than a filesystem path — see `client_side_path_traversal`
|
||||
|
||||
**Local File Inclusion (LFI)**
|
||||
- Include server-side files into interpreters/templates
|
||||
|
||||
|
|
@ -194,7 +197,7 @@ Test search order with candidate marker files or filesystem traces. Trigger the
|
|||
|
||||
## False Positives
|
||||
|
||||
- In-app virtual paths that do not map to filesystem; content comes from safe stores (DB/object storage)
|
||||
- In-app virtual paths that do not map to filesystem; content comes from safe stores (DB/object storage). When a browser-controlled path segment builds a client-side fetch/XHR/router URL (or a server-side/BFF fetch the client shapes), that is client-side path traversal and may be exploitable — see `client_side_path_traversal` — not an automatic false positive
|
||||
- Canonicalized paths constrained to an allowlist/root after normalization
|
||||
- Wrappers disabled and includes using constant templates only
|
||||
- Archive extractors that sanitize paths and enforce destination directories
|
||||
|
|
|
|||
|
|
@ -157,11 +157,11 @@ Server-Side Request Forgery enables the server to reach networks and services th
|
|||
|
||||
## False Positives
|
||||
|
||||
- Client-side fetches only (no server request)
|
||||
- Client-side fetches only (no server request) — but when a server-side/BFF proxy forwards the browser-controlled path that built that client fetch to an internal target, it is CSPT-to-SSRF; see `client_side_path_traversal`
|
||||
- Strict allowlists with DNS pinning and no redirect following
|
||||
- SSRF simulators/mocks returning canned responses without real egress
|
||||
- Blocked egress confirmed by uniform errors across all targets and protocols
|
||||
- OAST callbacks where the source IP matches the tester's machine, not the server — the browser or a client-side fetch made the request, not the backend
|
||||
- OAST callbacks where the source IP matches the tester's machine, not the server — the browser or a client-side fetch made the request, not the backend (unless a server-side/BFF proxy then forwards the client-shaped path to an internal target: CSPT-to-SSRF; see `client_side_path_traversal`)
|
||||
|
||||
## Impact
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ Distinguish HTML-string input from live DOM objects sanitized with `IN_PLACE`. R
|
|||
|
||||
**Sources**
|
||||
- `location.*` (hash/search), `document.referrer`, postMessage, storage, service worker messages
|
||||
- A URL path/query/hash segment used to build a fetch/navigation URL whose response is rendered in an unsafe sink (client-side path traversal → XSS) — see `client_side_path_traversal`
|
||||
|
||||
**Sinks**
|
||||
- `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `document.write`
|
||||
|
|
|
|||
|
|
@ -292,6 +292,12 @@ def _update_impl(
|
|||
superseded["evidence"] = existing["evidence"]
|
||||
if existing.get("agent_name"):
|
||||
superseded["agent_name"] = existing["agent_name"]
|
||||
# Preserve the superseded author's id, not just their name: a later
|
||||
# update by a different agent overwrites ``agent_id``, and coverage
|
||||
# accounting attributes a bundled skill's surface to the agent that
|
||||
# actually assessed it, so the earlier authorship has to survive.
|
||||
if existing.get("agent_id"):
|
||||
superseded["agent_id"] = existing["agent_id"]
|
||||
history = existing.get("history")
|
||||
existing["history"] = [*history, superseded] if isinstance(history, list) else [superseded]
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from typing import TYPE_CHECKING, Any
|
|||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.report.coverage import selectable_finding_classes
|
||||
from strix.report.sarif import _build_physical_locations
|
||||
from strix.tools.nullish import clean_optional
|
||||
from strix.tools.proxy.tools import existing_request_ids
|
||||
|
||||
|
|
@ -803,6 +805,7 @@ async def _do_create(
|
|||
confidence_rationale: str | None = None,
|
||||
fix_verification: str | None = 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]:
|
||||
|
|
@ -850,6 +853,31 @@ async def _do_create(
|
|||
)
|
||||
errors.extend(http_exchange_errors)
|
||||
|
||||
finding_class = (finding_class or "dynamic").strip().lower()
|
||||
if finding_class not in _VALID_FINDING_CLASSES:
|
||||
errors.append(
|
||||
f"Invalid finding_class: {finding_class!r}. "
|
||||
f"Must be one of: {sorted(_VALID_FINDING_CLASSES)}"
|
||||
)
|
||||
elif finding_class == "client_side_path_traversal":
|
||||
# CSPT findings are typically locationless (the bug is in a JS bundle,
|
||||
# not a server route), so without a discriminator multiple distinct CSPT
|
||||
# findings collapse onto one synthetic SARIF fingerprint. The anchor has
|
||||
# to be one SARIF can actually use: an endpoint (→ route), or a
|
||||
# code_location that survives _build_physical_locations (a repo-relative
|
||||
# file with a real start line — an absolute or drive path is dropped and
|
||||
# would leave the finding synthetic and collapsed).
|
||||
has_endpoint = bool(str(endpoint or "").strip())
|
||||
physical_locations, _dropped = _build_physical_locations(parsed_locations)
|
||||
if not (has_endpoint or physical_locations):
|
||||
errors.append(
|
||||
"finding_class 'client_side_path_traversal' needs a discriminator SARIF "
|
||||
"can anchor, or distinct CSPT findings collapse onto one fingerprint: set "
|
||||
"endpoint (the traversed target path, e.g. '/admin/keys') and method, or a "
|
||||
"code_location with a repo-relative file and start line (not an absolute or "
|
||||
"drive path, which SARIF drops)."
|
||||
)
|
||||
|
||||
if errors:
|
||||
return {"success": False, "error": "Validation failed", "errors": errors}
|
||||
|
||||
|
|
@ -883,6 +911,7 @@ async def _do_create(
|
|||
"poc_script_code": poc_script_code,
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"finding_class": finding_class,
|
||||
}
|
||||
report_fields: dict[str, Any] = {
|
||||
"title": title,
|
||||
|
|
@ -911,6 +940,7 @@ async def _do_create(
|
|||
"fix_verification": fix_verification,
|
||||
"fix_pr_body": fix_pr_body,
|
||||
"http_exchange_ids": normalized_http_exchange_ids,
|
||||
"finding_class": finding_class,
|
||||
}
|
||||
|
||||
dedupe = await check_duplicate(candidate, existing)
|
||||
|
|
@ -1005,6 +1035,7 @@ async def create_vulnerability_report(
|
|||
confidence_rationale: str | None = None,
|
||||
fix_verification: str | None = None,
|
||||
fix_pr_body: str | None = None,
|
||||
finding_class: str | None = None,
|
||||
) -> str:
|
||||
"""File a vulnerability report — one report per fully-verified finding.
|
||||
|
||||
|
|
@ -1410,6 +1441,22 @@ 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 machine-readable sub-class, default
|
||||
``"dynamic"``. Set ``"client_side_path_traversal"`` for a
|
||||
confirmed CSPT finding — attacker-controlled client input
|
||||
(URL path/query/fragment, ``document.referrer``,
|
||||
``postMessage`` data, browser storage) reaching a browser
|
||||
request sink (``fetch`` / ``XMLHttpRequest`` / ``axios`` /
|
||||
``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 — leave those ``dynamic``). A CSPT report must
|
||||
also carry a discriminator: ``endpoint`` (the traversed
|
||||
target path) and ``method``, or a ``code_location`` for the
|
||||
client sink. See the ``client_side_path_traversal`` skill.
|
||||
"""
|
||||
(
|
||||
http_exchange_ids,
|
||||
|
|
@ -1454,6 +1501,7 @@ async def create_vulnerability_report(
|
|||
http_exchange_ids=http_exchange_ids,
|
||||
fix_verification=fix_verification,
|
||||
fix_pr_body=fix_pr_body,
|
||||
finding_class=finding_class,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
|
@ -2325,7 +2373,13 @@ _SEVERITY_ORDER = {
|
|||
"none": 5,
|
||||
}
|
||||
_VALID_SEVERITIES = frozenset(_SEVERITY_ORDER)
|
||||
_VALID_FINDING_CLASSES = frozenset({"dynamic", "dependency_cve"})
|
||||
# ``dynamic`` (the PoC-backed default) and ``dependency_cve`` (reserved for
|
||||
# create_dependency_report) are report-type markers; the remaining selectable
|
||||
# classes come from the canonical vulnerability-class registry so this set
|
||||
# never drifts from the class definitions. ``client_side_path_traversal`` is
|
||||
# the first registry-sourced member — machine-separable from server-side
|
||||
# path traversal / LFI / RFI, which share CWE-22.
|
||||
_VALID_FINDING_CLASSES = frozenset({"dynamic", "dependency_cve"}) | selectable_finding_classes()
|
||||
_REPORT_DESCRIPTION_PREVIEW_CHARS = 280
|
||||
|
||||
# Compact, listing-safe fields — no full bodies / PoC code / evidence.
|
||||
|
|
|
|||
19
tests/fixtures/cspt/README.md
vendored
Normal file
19
tests/fixtures/cspt/README.md
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# CSPT fixtures
|
||||
|
||||
Minimal HTML pages exercising client-side path traversal (CSPT) source to sink
|
||||
flows, used by `tests/test_cspt_fixtures.py` as a deterministic, browser-free
|
||||
regression for what CSPT looks like in client code. Unlike a Playwright
|
||||
capture test, these run in ordinary CI with no Chromium.
|
||||
|
||||
Naming: `positive_*` has the CSPT **primitive** — an attacker-controlled client
|
||||
source (URL query, fragment, referrer, postMessage, storage) reaches a request
|
||||
sink's path unguarded. That is the source-to-sink reachability only; whether the
|
||||
redirected request is *exploitable* (a state change, a rendered response, an
|
||||
internal fetch, an authz bypass) is a separate step, per the exploitability bar
|
||||
in the `client_side_path_traversal` skill. `negative_*` is a control — the flow
|
||||
is validated, or the user value never reaches the request path.
|
||||
|
||||
Convention the test relies on: the attacker-controlled value that reaches a
|
||||
sink is named `userPath`, so a sink-call line containing `userPath` marks a
|
||||
tainted flow. The sources, sinks, and mitigation markers the matcher keys on
|
||||
are listed at the top of `tests/test_cspt_fixtures.py`.
|
||||
20
tests/fixtures/cspt/negative_hardcoded.html
vendored
Normal file
20
tests/fixtures/cspt/negative_hardcoded.html
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<!--
|
||||
CSPT fixture - Expected classification: negative.
|
||||
The request path is hardcoded; the user-controlled query value is only shown
|
||||
in the page text and never reaches the fetch path.
|
||||
-->
|
||||
<html>
|
||||
<head><meta charset="utf-8" /><title>Profile</title></head>
|
||||
<body>
|
||||
<h1 id="greeting"></h1>
|
||||
<pre id="profile"></pre>
|
||||
<script>
|
||||
const name = new URLSearchParams(location.search).get('name') || 'guest';
|
||||
document.getElementById('greeting').textContent = 'Hello ' + name;
|
||||
fetch('/api/profile')
|
||||
.then((r) => r.json())
|
||||
.then((data) => { document.getElementById('profile').textContent = JSON.stringify(data); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
tests/fixtures/cspt/negative_validated.html
vendored
Normal file
20
tests/fixtures/cspt/negative_validated.html
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<!--
|
||||
CSPT fixture - Expected classification: negative (validated).
|
||||
The query value is checked against a numeric allowlist regex before it is
|
||||
used, so traversal sequences never reach the fetch path. This is a true
|
||||
negative control: the source does reach the sink, but guarded.
|
||||
-->
|
||||
<html>
|
||||
<head><meta charset="utf-8" /><title>Item</title></head>
|
||||
<body>
|
||||
<pre id="item"></pre>
|
||||
<script>
|
||||
const raw = new URLSearchParams(location.search).get('id') || '';
|
||||
const userPath = /^[0-9]+$/.test(raw) ? raw : '0';
|
||||
fetch('/api/items/' + userPath)
|
||||
.then((r) => r.json())
|
||||
.then((data) => { document.getElementById('item').textContent = JSON.stringify(data); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
tests/fixtures/cspt/positive_hash_route.html
vendored
Normal file
20
tests/fixtures/cspt/positive_hash_route.html
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<!--
|
||||
CSPT fixture - positive: the unguarded source-to-sink primitive is present
|
||||
(impact is proven separately, per the client_side_path_traversal skill).
|
||||
Source: URL fragment (location.hash, a client hash router). Sink: fetch() path.
|
||||
The fragment is never sent to the server by the browser, so only a
|
||||
client-side trace shows the traversed request leaving the page.
|
||||
-->
|
||||
<html>
|
||||
<head><meta charset="utf-8" /><title>Item</title></head>
|
||||
<body>
|
||||
<pre id="item"></pre>
|
||||
<script>
|
||||
const userPath = location.hash.slice(1);
|
||||
fetch('/api/items/' + userPath)
|
||||
.then((r) => r.json())
|
||||
.then((data) => { document.getElementById('item').textContent = JSON.stringify(data); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
tests/fixtures/cspt/positive_query_param.html
vendored
Normal file
20
tests/fixtures/cspt/positive_query_param.html
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<!--
|
||||
CSPT fixture - positive: the unguarded source-to-sink primitive is present
|
||||
(impact is proven separately, per the client_side_path_traversal skill).
|
||||
Source: URL query parameter (location.search). Sink: fetch() request path.
|
||||
The attacker-controlled value flows unmodified into the fetch path, so a
|
||||
value like ../../admin/keys redirects the victim's authenticated request.
|
||||
-->
|
||||
<html>
|
||||
<head><meta charset="utf-8" /><title>Document viewer</title></head>
|
||||
<body>
|
||||
<main id="doc"></main>
|
||||
<script>
|
||||
const userPath = new URLSearchParams(location.search).get('doc') || '';
|
||||
fetch('/api/documents/' + userPath)
|
||||
.then((r) => r.text())
|
||||
.then((html) => { document.getElementById('doc').textContent = html; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
79
tests/test_cspt_finding_class.py
Normal file
79
tests/test_cspt_finding_class.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""CSPT ``finding_class`` separation: registry sourcing, SARIF, dedup.
|
||||
|
||||
Client-side path traversal shares CWE-22 with server-side path traversal /
|
||||
LFI / RFI, so ``ruleId`` alone cannot tell them apart. These tests pin the
|
||||
machine-readable separation that keeps them distinct in the report artifacts —
|
||||
pure-Python, no browser or network, so they run in ordinary CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from strix.report.coverage import selectable_finding_classes
|
||||
from strix.report.dedupe import _prepare_report_for_comparison
|
||||
from strix.report.sarif import _class_token, _primary_fingerprint
|
||||
|
||||
|
||||
_SECURITY_MD = [{"physicalLocation": {"artifactLocation": {"uri": "SECURITY.md"}}}]
|
||||
|
||||
|
||||
def test_class_token_prefers_structured_finding_class() -> None:
|
||||
"""The structured field wins over the (LLM-authored) title keyword."""
|
||||
assert (
|
||||
_class_token(
|
||||
{"finding_class": "client_side_path_traversal", "title": "Path traversal in fetch"}
|
||||
)
|
||||
== "client_side_path_traversal"
|
||||
)
|
||||
# default 'dynamic' carries no signal, so fall back to the title keyword
|
||||
assert _class_token({"finding_class": "dynamic", "title": "Path traversal in download"}) == (
|
||||
"path traversal"
|
||||
)
|
||||
assert _class_token({"title": "Reflected XSS in q"}) == "xss"
|
||||
|
||||
|
||||
def test_cspt_and_server_side_cwe22_get_distinct_primary_fingerprints() -> None:
|
||||
"""The reviewer's repro: a locationless CSPT and a server-side traversal,
|
||||
both CWE-22 and both anchored to SECURITY.md, must not collapse onto one
|
||||
SARIF primary fingerprint."""
|
||||
cspt = {
|
||||
"title": "Path traversal via client fetch",
|
||||
"finding_class": "client_side_path_traversal",
|
||||
}
|
||||
server = {"title": "Path traversal in file download", "finding_class": "dynamic"}
|
||||
|
||||
fp_cspt = _primary_fingerprint("CWE-22", cspt, _SECURITY_MD, is_synthetic=True)
|
||||
fp_server = _primary_fingerprint("CWE-22", server, _SECURITY_MD, is_synthetic=True)
|
||||
|
||||
assert fp_cspt and fp_server
|
||||
assert fp_cspt != fp_server
|
||||
|
||||
|
||||
def test_same_class_still_reconciles_to_one_fingerprint() -> None:
|
||||
"""Two server-side CWE-22 findings of the same class still share a
|
||||
fingerprint — the separation is by class, not by prose."""
|
||||
server_a = {"title": "Path traversal in file download", "finding_class": "dynamic"}
|
||||
server_b = {"title": "Path traversal reading logs", "finding_class": "dynamic"}
|
||||
|
||||
assert _primary_fingerprint(
|
||||
"CWE-22", server_a, _SECURITY_MD, is_synthetic=True
|
||||
) == _primary_fingerprint("CWE-22", server_b, _SECURITY_MD, is_synthetic=True)
|
||||
|
||||
|
||||
def test_valid_finding_classes_are_sourced_from_the_registry() -> None:
|
||||
"""The selectable CSPT class comes from VULN_CLASSES, not a hand-maintained
|
||||
list in the reporting tool."""
|
||||
from strix.tools.reporting.tool import _VALID_FINDING_CLASSES
|
||||
|
||||
assert "client_side_path_traversal" in selectable_finding_classes()
|
||||
assert "client_side_path_traversal" in _VALID_FINDING_CLASSES
|
||||
assert {"dynamic", "dependency_cve"} <= _VALID_FINDING_CLASSES
|
||||
|
||||
|
||||
def test_dedupe_comparison_includes_finding_class() -> None:
|
||||
"""Dedup sees finding_class, so two CWE-22 findings of different classes are
|
||||
not judged duplicates."""
|
||||
cleaned = _prepare_report_for_comparison(
|
||||
{"id": "1", "title": "x", "finding_class": "client_side_path_traversal"}
|
||||
)
|
||||
|
||||
assert cleaned.get("finding_class") == "client_side_path_traversal"
|
||||
73
tests/test_cspt_fixtures.py
Normal file
73
tests/test_cspt_fixtures.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Deterministic, browser-free regression over the CSPT fixtures.
|
||||
|
||||
This checks one thing only: whether an attacker-controlled client source reaches
|
||||
a request sink's path **unguarded** — the CSPT *primitive*. It deliberately does
|
||||
NOT decide exploitability: a reached sink is not yet a finding. Whether the
|
||||
redirected request has security impact (a state change, a rendered response, an
|
||||
internal fetch, an authz bypass) is proven separately, per the exploitability bar
|
||||
in the `client_side_path_traversal` skill. So a passing test here means "the
|
||||
primitive is present/absent as labelled", not "exploitable".
|
||||
|
||||
It is a fixture/pattern regression, not a static taint tracker, kept honest by a
|
||||
convention: the attacker value that reaches a sink is named ``userPath``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_FIXTURES = Path(__file__).parent / "fixtures" / "cspt"
|
||||
|
||||
#: Browser-controlled input sources an attacker influences.
|
||||
_SOURCES = (
|
||||
"location.search",
|
||||
"URLSearchParams",
|
||||
"location.hash",
|
||||
"document.referrer",
|
||||
"postMessage",
|
||||
"localStorage",
|
||||
)
|
||||
#: Request sinks whose path, if attacker-shaped, carries the traversal.
|
||||
_SINKS = ("fetch(", "XMLHttpRequest", "axios", "EventSource", "WebSocket")
|
||||
#: Markers of a guard that neutralises the flow before the sink.
|
||||
_MITIGATIONS = ("/^", ".test(", "allowlist", "hardcoded")
|
||||
|
||||
|
||||
def _reaches_sink_unguarded(text: str) -> bool:
|
||||
"""True when the attacker value (``userPath``) reaches a sink-call line."""
|
||||
return any(
|
||||
any(sink in line for sink in _SINKS) and "userPath" in line
|
||||
for line in text.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _detect_primitive(text: str) -> str:
|
||||
""""reaches_sink" when an unguarded attacker source reaches a sink path.
|
||||
|
||||
This is the CSPT primitive, not an exploitability verdict — impact is a
|
||||
separate, out-of-band step the skill describes.
|
||||
"""
|
||||
has_source = any(token in text for token in _SOURCES)
|
||||
has_sink = any(token in text for token in _SINKS)
|
||||
guarded = any(token in text for token in _MITIGATIONS)
|
||||
if has_source and has_sink and _reaches_sink_unguarded(text) and not guarded:
|
||||
return "reaches_sink"
|
||||
return "no_unguarded_sink"
|
||||
|
||||
|
||||
def test_cspt_fixtures_match_their_labelled_primitive() -> None:
|
||||
fixtures = sorted(_FIXTURES.glob("*.html"))
|
||||
assert fixtures, "no CSPT fixtures found"
|
||||
|
||||
positives = [f for f in fixtures if f.name.startswith("positive_")]
|
||||
negatives = [f for f in fixtures if f.name.startswith("negative_")]
|
||||
assert len(positives) >= 2, "need at least two primitive-present fixtures"
|
||||
assert len(negatives) >= 2, "need at least two guarded / no-flow controls"
|
||||
|
||||
for fixture in fixtures:
|
||||
# positive_* = the unguarded source-to-sink primitive is present
|
||||
# (exploitability is proven separately); negative_* = guarded or no flow.
|
||||
expected = "reaches_sink" if fixture.name.startswith("positive_") else "no_unguarded_sink"
|
||||
actual = _detect_primitive(fixture.read_text(encoding="utf-8"))
|
||||
assert actual == expected, f"{fixture.name}: expected {expected}, matched {actual}"
|
||||
|
|
@ -7,7 +7,9 @@ from typing import TYPE_CHECKING, Any
|
|||
|
||||
from strix.report.coverage import (
|
||||
_SKILL_PHRASINGS,
|
||||
VULN_CLASSES,
|
||||
build_coverage_document,
|
||||
cwe_for_skill,
|
||||
read_agent_graph,
|
||||
write_coverage,
|
||||
)
|
||||
|
|
@ -84,6 +86,46 @@ def test_assigned_risk_skill_without_coverage_becomes_a_gap() -> None:
|
|||
assert [gap["risk_area"] for gap in gaps] == ["sql injection"]
|
||||
|
||||
|
||||
def test_substring_match_does_not_hide_a_gap() -> None:
|
||||
"""A short class token matches whole words, not substrings: 'brute force' is
|
||||
not RCE coverage, 'enforcement' is not 'rce', 'corridor' is not 'idor'."""
|
||||
cases = [
|
||||
("rce", "POST /login", "brute force protection"),
|
||||
("rce", "GET /api/resources/{id}", "object-level authorization"),
|
||||
("rce", "Access control enforcement", "authorization"),
|
||||
("idor", "GET /building/corridor/{id}", "information disclosure"),
|
||||
]
|
||||
for skill, surface, risk in cases:
|
||||
doc = _document(
|
||||
entries=[_entry(surface=surface, risk_area=risk)],
|
||||
agent_graph=_graph(metadata={"agent-1": {"skills": [skill]}}),
|
||||
)
|
||||
classes = [g["risk_area"] for g in doc["gaps"] if g["kind"] == "unrecorded_risk_class"]
|
||||
assert skill.replace("_", " ") in classes, (skill, surface, risk, classes)
|
||||
|
||||
|
||||
def test_joined_camelcase_name_counts_as_coverage() -> None:
|
||||
"""A row recorded as a joined/CamelCase class name ('DirectoryTraversal')
|
||||
still counts, via the joined-token fallback — no substring matching."""
|
||||
doc = _document(
|
||||
entries=[_entry(risk_area="DirectoryTraversal reviewed", surface="/download")],
|
||||
agent_graph=_graph(metadata={"agent-1": {"skills": ["path_traversal_lfi_rfi"]}}),
|
||||
)
|
||||
|
||||
assert not [g for g in doc["gaps"] if g["kind"] == "unrecorded_risk_class"]
|
||||
|
||||
|
||||
def test_simple_plural_still_counts_as_coverage() -> None:
|
||||
"""Whole-word matching tolerates a trivial plural, so 'passwords' /
|
||||
'credentials' still cover weak_password_detection."""
|
||||
doc = _document(
|
||||
entries=[_entry(risk_area="weak passwords and credentials reviewed", surface="/login")],
|
||||
agent_graph=_graph(metadata={"agent-1": {"skills": ["weak_password_detection"]}}),
|
||||
)
|
||||
|
||||
assert not [g for g in doc["gaps"] if g["kind"] == "unrecorded_risk_class"]
|
||||
|
||||
|
||||
def test_recorded_risk_class_is_not_reported_as_a_gap() -> None:
|
||||
doc = _document(
|
||||
entries=[_entry(risk_area="SQL injection", surface="GET /search?q=")],
|
||||
|
|
@ -100,6 +142,148 @@ def test_synonym_phrasing_counts_as_recorded_coverage() -> None:
|
|||
assert not [gap for gap in doc["gaps"] if gap["kind"] == "unrecorded_risk_class"]
|
||||
|
||||
|
||||
def test_cspt_acronym_phrasing_counts_as_recorded_coverage() -> None:
|
||||
"""The ledger spells out "Client-Side Path Traversal (CSPT)"; the skill is
|
||||
called client_side_path_traversal."""
|
||||
doc = _document(
|
||||
entries=[
|
||||
_entry(risk_area="Client-Side Path Traversal (CSPT)", surface="GET /app#/profile")
|
||||
],
|
||||
agent_graph=_graph(metadata={"agent-1": {"skills": ["client_side_path_traversal"]}}),
|
||||
)
|
||||
|
||||
assert not [gap for gap in doc["gaps"] if gap["kind"] == "unrecorded_risk_class"]
|
||||
|
||||
|
||||
def test_bundled_skill_partially_tested_flags_untested_subtopics() -> None:
|
||||
"""browser_security bundles many surfaces; proving postMessage must not
|
||||
mark client-side path traversal covered."""
|
||||
doc = _document(
|
||||
entries=[_entry(risk_area="postMessage origin validation", surface="window listener")],
|
||||
agent_graph=_graph(metadata={"agent-1": {"skills": ["browser_security"]}}),
|
||||
)
|
||||
|
||||
flagged = {g["risk_area"] for g in doc["gaps"] if g["kind"] == "unrecorded_sub_topic"}
|
||||
assert "client-side path traversal" in flagged
|
||||
assert "postMessage" not in flagged # the surface that was tested is not re-flagged
|
||||
# a partially-tested bundled skill is not also reported as a wholesale gap
|
||||
assert not [
|
||||
g
|
||||
for g in doc["gaps"]
|
||||
if g["kind"] == "unrecorded_risk_class" and g["risk_area"] == "browser security"
|
||||
]
|
||||
|
||||
|
||||
def test_bundled_skill_wholly_untested_is_a_single_gap() -> None:
|
||||
"""A bundled skill nobody touched collapses to one class-level gap, not one
|
||||
per surface — the honest short statement is "not examined"."""
|
||||
doc = _document(
|
||||
entries=[_entry(risk_area="SQL injection", surface="GET /search")],
|
||||
agent_graph=_graph(metadata={"agent-1": {"skills": ["browser_security"]}}),
|
||||
)
|
||||
|
||||
class_gaps = [
|
||||
g
|
||||
for g in doc["gaps"]
|
||||
if g["kind"] == "unrecorded_risk_class" and g["risk_area"] == "browser security"
|
||||
]
|
||||
assert len(class_gaps) == 1
|
||||
assert "surfaces" in class_gaps[0]["detail"]
|
||||
assert not [g for g in doc["gaps"] if g["kind"] == "unrecorded_sub_topic"]
|
||||
|
||||
|
||||
def test_bundled_skill_with_every_surface_covered_is_clean() -> None:
|
||||
"""Covering every surface of a bundled skill leaves no coverage gap."""
|
||||
entries = [
|
||||
_entry(risk_area=sub.phrasings[0], surface=sub.label)
|
||||
for sub in VULN_CLASSES["browser_security"].sub_topics
|
||||
]
|
||||
doc = _document(
|
||||
entries=entries,
|
||||
agent_graph=_graph(metadata={"agent-1": {"skills": ["browser_security"]}}),
|
||||
)
|
||||
|
||||
assert not [
|
||||
g
|
||||
for g in doc["gaps"]
|
||||
if g["kind"] in {"unrecorded_risk_class", "unrecorded_sub_topic"}
|
||||
]
|
||||
|
||||
|
||||
def test_unrelated_agents_row_does_not_cover_a_browser_subtopic() -> None:
|
||||
"""An open_redirect agent's 'meta refresh' row must not mark browser_security's
|
||||
navigation surface covered — only a browser_security carrier's row counts."""
|
||||
doc = _document(
|
||||
entries=[
|
||||
_entry(
|
||||
risk_area="postMessage origin validation",
|
||||
surface="listener",
|
||||
agent_id="agent-1",
|
||||
agent_name="browser-tester",
|
||||
),
|
||||
_entry(
|
||||
risk_area="open redirect via meta refresh",
|
||||
surface="/login?next=",
|
||||
agent_id="agent-2",
|
||||
agent_name="redirect-tester",
|
||||
),
|
||||
],
|
||||
agent_graph=_graph(
|
||||
statuses={"agent-1": "completed", "agent-2": "completed"},
|
||||
names={"agent-1": "browser-tester", "agent-2": "redirect-tester"},
|
||||
metadata={
|
||||
"agent-1": {"skills": ["browser_security"]},
|
||||
"agent-2": {"skills": ["open_redirect"]},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
sub = {g["risk_area"] for g in doc["gaps"] if g["kind"] == "unrecorded_sub_topic"}
|
||||
assert "navigation and redirect control" in sub # not masked by the open_redirect row
|
||||
assert "postMessage" not in sub # the browser agent's own row still counts
|
||||
|
||||
|
||||
def test_updated_browser_row_keeps_its_carrier_authorship() -> None:
|
||||
"""update_coverage overwrites agent_id with the updater's; a browser surface a
|
||||
carrier assessed must still count via the author preserved in history."""
|
||||
doc = _document(
|
||||
entries=[
|
||||
_entry(
|
||||
risk_area="client-side path traversal",
|
||||
surface="fetch from route param",
|
||||
agent_id="agent-2", # current author after an unrelated update
|
||||
agent_name="redirect-tester",
|
||||
history=[
|
||||
{
|
||||
"outcome": "no_issue_found",
|
||||
"agent_id": "agent-1", # the carrier who first assessed it
|
||||
"agent_name": "browser-tester",
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
agent_graph=_graph(
|
||||
statuses={"agent-1": "completed", "agent-2": "completed"},
|
||||
names={"agent-1": "browser-tester", "agent-2": "redirect-tester"},
|
||||
metadata={
|
||||
"agent-1": {"skills": ["browser_security"]},
|
||||
"agent-2": {"skills": ["open_redirect"]},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
sub = {g["risk_area"] for g in doc["gaps"] if g["kind"] == "unrecorded_sub_topic"}
|
||||
assert "client-side path traversal" not in sub # preserved carrier authorship counts
|
||||
|
||||
|
||||
def test_registry_backs_skill_phrasings_and_cwe() -> None:
|
||||
"""_SKILL_PHRASINGS is derived from the registry, so they cannot drift."""
|
||||
assert _SKILL_PHRASINGS == {name: v.aliases for name, v in VULN_CLASSES.items()}
|
||||
assert cwe_for_skill("sql_injection") == ("CWE-89",)
|
||||
assert cwe_for_skill("vulnerabilities/ssrf") == ("CWE-918",)
|
||||
assert cwe_for_skill("subdomain_takeover") == ()
|
||||
|
||||
|
||||
def test_non_risk_skills_carry_no_coverage_obligation() -> None:
|
||||
"""Tooling skills describe how an agent works, not what it hunts."""
|
||||
doc = _document(agent_graph=_graph(metadata={"agent-1": {"skills": ["idor", "caido"]}}))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue