Skip to content

Commit 06bdea2

Browse files
committed
External sites CSV: HTTP 200 paths and unique endpoints for scoping
url_paths only counts a 200 that is a real page, so it read low and said nothing about why. The CSV now also carries, from the same newest scan that recorded paths: http_200_paths every path that answered HTTP 200 unique_endpoints the application's own pages: real 200s without CDN or firewall files (Cloudflare, Imperva, Akamai) and static files, with numbers and ids collapsed so /orders/1001 and /orders/1002 are one. The root counts when it answered 200. scan_blocked yes when most answers were 401 or 403, with the share and the firewall seen, so a small count reads as blocked rather than as a small site paths_answered every path that answered at all All four come from the stored paths, so scans already run fill them in. The scan page shows the same count with the list of endpoints and the blocked warning. url_paths is unchanged.
1 parent 58588a0 commit 06bdea2

4 files changed

Lines changed: 123 additions & 0 deletions

File tree

‎app/core/dast_paths.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,79 @@ def counts(records: list) -> dict:
235235
return out
236236

237237

238+
# ------------------------------------------------------------------ scoping
239+
240+
# What a penetration test is scoped on is the application's own pages and
241+
# endpoints. Three kinds of 200 are not that: files a CDN or firewall serves
242+
# on every site behind it, static files the pages load, and a site that turned
243+
# the scanner away, where the few 200s left are the firewall's own.
244+
STATIC = re.compile(r"\.(js|mjs|css|map|png|jpe?g|gif|svg|ico|webp|avif|bmp|woff2?|ttf|otf|eot"
245+
r"|mp4|webm|mp3|wav|pdf|txt|json|xml|webmanifest)$", re.I)
246+
EDGE = [("Cloudflare", re.compile(r"^/(cdn-cgi|__cf)(/|$)", re.I)),
247+
("Imperva", re.compile(r"^/(_Incapsula_Resource|_incapsula)", re.I)),
248+
("Akamai", re.compile(r"^/(akam|_sec)/", re.I))]
249+
# A segment that is a value rather than a name: a number, a UUID, a long hex
250+
# or mixed token, or a date. /orders/1001 and /orders/1002 are one endpoint.
251+
_VALUE = re.compile(r"^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
252+
r"|[0-9a-f]{12,}|(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{20,}"
253+
r"|\d{4}-\d{2}-\d{2})$", re.I)
254+
# Most of what was asked was refused: the scan saw the firewall, not the site.
255+
BLOCKED_SHARE = 0.8
256+
BLOCKED_MIN = 10
257+
258+
259+
def edge_of(path: str) -> str:
260+
"""The CDN or firewall a path belongs to, or ""."""
261+
return next((name for name, rule in EDGE if rule.match(path or "")), "")
262+
263+
264+
def endpoint(path: str) -> str:
265+
"""The path with its values written as {id}."""
266+
segs = [s for s in (path or "/").split("/") if s]
267+
return "/" + "/".join("{id}" if _VALUE.match(s) else s for s in segs)
268+
269+
270+
def scoping(records: list) -> dict:
271+
"""The numbers a penetration test is scoped on, from one scan's paths.
272+
273+
http_200 every path that answered HTTP 200
274+
endpoints the application's own pages and endpoints: a 200 that is a
275+
real page (see classify), less CDN and firewall files and
276+
static files, with values collapsed. The site's root counts
277+
when it answered 200, since a site that answers is real.
278+
blocked most answers were 401 or 403, so the count is the firewall's
279+
"""
280+
records = records or []
281+
answered = len(records)
282+
http_200 = [r for r in records if r.get("s") == 200]
283+
edges, refused, found = set(), 0, set()
284+
for r in records:
285+
name = edge_of(r.get("p", ""))
286+
if name:
287+
edges.add(name)
288+
if r.get("s") in (401, 403):
289+
refused += 1
290+
for r in http_200:
291+
path = r.get("p", "")
292+
if edge_of(path) or STATIC.search(path):
293+
continue
294+
if r.get("r") is True or path == "/":
295+
found.add(endpoint(path))
296+
share = refused / answered if answered else 0.0
297+
return {"answered": answered, "http_200": len(http_200),
298+
"endpoints": sorted(found), "refused_share": round(share, 3),
299+
"edge": sorted(edges),
300+
"blocked": answered >= BLOCKED_MIN and share >= BLOCKED_SHARE}
301+
302+
303+
def blocked_text(scope: dict) -> str:
304+
"""For the CSV: empty, or yes with why."""
305+
if not scope.get("blocked"):
306+
return ""
307+
by = ", ".join(scope.get("edge") or [])
308+
return f"yes, {round(scope['refused_share'] * 100)}% refused" + (f" by {by}" if by else "")
309+
310+
238311
# ------------------------------------------------------------------ matching
239312

240313
def _segs(path: str) -> list:

‎app/core/external.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2058,6 +2058,29 @@ def touch_asset(db, scan: ExternalDastScan) -> None:
20582058
scan.recorded_by)
20592059

20602060

2061+
def scoping_by_asset(db, asset_ids=None) -> dict:
2062+
"""The pen test scoping numbers of each site, from its newest finished
2063+
scan that recorded paths, the same scan url_paths comes from.
2064+
-> {asset id: {"scan": scan, "scope": dast_paths.scoping(...)}}"""
2065+
from app.core import dast_paths
2066+
2067+
query = (db.query(ExternalDastScan.id, ExternalDastScan.asset_id)
2068+
.filter(ExternalDastScan.status == "done",
2069+
ExternalDastScan.paths_count.isnot(None)))
2070+
if asset_ids is not None:
2071+
query = query.filter(ExternalDastScan.asset_id.in_(list(asset_ids) or [0]))
2072+
newest = {}
2073+
for scan_id, asset_id in query.order_by(ExternalDastScan.scanned_at.desc(),
2074+
ExternalDastScan.id.desc()):
2075+
newest.setdefault(asset_id, scan_id)
2076+
out = {}
2077+
for asset_id, scan_id in newest.items():
2078+
scan = db.get(ExternalDastScan, scan_id)
2079+
out[asset_id] = {"scan": scan, "scope": dast_paths.scoping(
2080+
dast_paths.load(scan.paths).get("paths") or [])}
2081+
return out
2082+
2083+
20612084
def copy_newest(db, asset: ExternalAsset) -> None:
20622085
"""The newest finished scan onto the site, or nothing when there is none."""
20632086
newest = (db.query(ExternalDastScan)

‎app/web/routes_external.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,10 +299,16 @@ def export_csv(request: Request, user: User = Depends(current_user)):
299299
"telemetry_at", "last_dast_at", "last_dast_severity",
300300
"last_dast_server", "last_dast_url",
301301
"url_paths", "url_paths_scan",
302+
"http_200_paths", "unique_endpoints", "scan_blocked",
303+
"paths_answered",
302304
"repositories", "comments"]
303305
+ external.FIELD_KEYS)
306+
# One read of each site's newest counted scan, not one per column.
307+
scoped = external.scoping_by_asset(db)
308+
from app.core import dast_paths as _dast_paths
304309
for a in db.query(ExternalAsset).order_by(ExternalAsset.host):
305310
values = external.field_values(a)
311+
scope = (scoped.get(a.id) or {}).get("scope")
306312
repos = []
307313
for l in mapper.links(a):
308314
if l["linked"]:
@@ -324,6 +330,10 @@ def export_csv(request: Request, user: User = Depends(current_user)):
324330
"" if a.last_paths is None else a.last_paths,
325331
(a.last_paths_at.strftime("%Y-%m-%d") + " " + (a.last_paths_profile or "")).strip()
326332
if a.last_paths_at else "",
333+
"" if scope is None else scope["http_200"],
334+
"" if scope is None else len(scope["endpoints"]),
335+
"" if scope is None else (_dast_paths.blocked_text(scope) or "no"),
336+
"" if scope is None else scope["answered"],
327337
" ".join(repos),
328338
(a.notes or "").replace("\n", " ")] + [values.get(k, "") for k in external.FIELD_KEYS]])
329339
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M")
@@ -1063,7 +1073,12 @@ def scan_page(asset_id: int, scan_id: int, request: Request, sev: str = "",
10631073
context = _shared(db, user)
10641074
if verdict == "decided":
10651075
findings = [f for f in findings if f.get("decision")]
1076+
from app.core import dast_paths as _dast_paths
1077+
1078+
scope = (_dast_paths.scoping(_dast_paths.load(scan.paths).get("paths") or [])
1079+
if scan.paths_count is not None else None)
10661080
context.update(active_page="external", asset=asset, scan=scan, view=view,
1081+
scope=scope, blocked_text=_dast_paths.blocked_text(scope or {}),
10671082
code=external.code_behind(db, user, scan, asset),
10681083
findings=findings, sev=sev, verdict=verdict, decided=decided,
10691084
decision_kinds=external_decisions.KINDS,

‎app/web/templates/external_scan.html‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@
8181
<div class="faint">{{ cc.answered | thousands }} addresses answered. {% for k, n in cc.by_status.items() if n %}{{ k }}: {{ n | thousands }}{{ ', ' if not loop.last }}{% endfor %}
8282
{% if cc.by_engine %}&middot; by engine: {% for k, n in cc.by_engine.items() %}{{ k }} {{ n | thousands }}{{ ', ' if not loop.last }}{% endfor %}{% endif %}</div>
8383
</div>
84+
{% if scope %}
85+
<div class="small" style="margin-bottom:.5rem">For penetration test scoping:
86+
<strong>{{ scope.endpoints | length | thousands }}</strong> unique endpoint{{ '' if scope.endpoints | length == 1 else 's' }}
87+
<span class="faint" title="Real pages that answered 200, without CDN or firewall files and static files, with numbers and ids collapsed so /orders/1001 and /orders/1002 are one">(the application's own pages, ids collapsed)</span>
88+
&middot; {{ scope.http_200 | thousands }} path{{ '' if scope.http_200 == 1 else 's' }} answered HTTP 200
89+
{% if blocked_text %}<div><span class="badge high">blocked</span> {{ blocked_text | capitalize }}. The scan mostly saw the
90+
firewall, so these counts are not the site's. Let the scanner's address through, then scan again.</div>{% endif %}
91+
{% if scope.endpoints %}<details style="margin-top:.3rem"><summary class="faint">The endpoints</summary>
92+
<div class="mono small">{% for e in scope.endpoints[:500] %}<div>{{ e }}</div>{% endfor %}
93+
{% if scope.endpoints | length > 500 %}<div class="faint">and {{ scope.endpoints | length - 500 }} more</div>{% endif %}</div></details>{% endif %}
94+
</div>
95+
{% endif %}
8496
{% endif %}
8597
{% if code.linked %}
8698
<div class="small" style="margin-bottom:.5rem">Linked code reached:

0 commit comments

Comments
 (0)