Skip to content

Commit c829ea8

Browse files
committed
SAST to DAST sink correlation: read code sinks, mark them reachable or context
The gray box reader already pulled routes from the mapped code. It now also reads dangerous patterns, tagged by type: a query built by string joining, a shell or eval call, an unsafe deserialize, a template rendered from a string, a write into the page, a file opened from user input. Each sink carries the routes its file declares. After the scan, each sink is marked reachable when the scan reached one of those routes, or context when it did not. A reachable sink is a lead a dynamic test can prove; one that is not reachable is a thing to review by hand, not a dynamic bug, which is the right way to treat code the outside cannot touch. The PDF report gains a Code and the scan section: the reached sinks as leads, and an Areas to review list for the rest, not counted toward the grade. All deterministic, no AI. Stored in a new code_sinks column, added on boot.
1 parent e4da488 commit c829ea8

5 files changed

Lines changed: 124 additions & 11 deletions

File tree

‎app/core/dast_report.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,15 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
334334

335335
dast_risk.annotate(findings, getattr(asset, "exposure", "") or "")
336336
risk_matrix = dast_risk.matrix(findings)
337+
# Code sinks from the mapped repositories, split by whether the scan reached
338+
# the route they sit behind. Reachable ones are leads to prove; the rest
339+
# are context to review, not dynamic bugs.
340+
try:
341+
code_sinks = json.loads(scan.code_sinks or "[]") or []
342+
except (ValueError, TypeError):
343+
code_sinks = []
344+
reachable_sinks = [s for s in code_sinks if s.get("reachable")]
345+
context_sinks = [s for s in code_sinks if not s.get("reachable")]
337346
from app.core.external_decisions import counted as _counted
338347

339348
by_sev = {s: sum(1 for f in findings if f.get("severity") == s and _counted(f))
@@ -416,6 +425,7 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
416425
"asset": asset, "scan": scan, "view": view, "findings": findings,
417426
"overall": overall, "by_sev": by_sev, "categories": categories,
418427
"risk_matrix": risk_matrix,
428+
"reachable_sinks": reachable_sinks, "context_sinks": context_sinks,
419429
"heat_max": heat_max, "triaged": triaged, "ai_summary": ai_summary,
420430
"narrative": narrative(asset.host, view, overall, by_sev, triaged, profile_words),
421431
"top": [f for f in findings if f.get("severity") in ("critical", "high")

‎app/core/dast_whitebox.py‎

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,32 @@
6363

6464
_PARAM = re.compile(r"\{[^}/]*\}|<[^>/]*>|:[A-Za-z_]\w*|\*\*?|\(\?[^)]*\)")
6565

66+
MAX_SINKS = 200
67+
# High signal dangerous patterns, tagged by type. These are not a SAST engine:
68+
# they mark where code does something a dynamic test wants aimed at it. The
69+
# reachability of the route in the same file decides whether a sink matters or
70+
# is only context, so a loose match that is not reachable costs nothing.
71+
SINK_PATTERNS = [
72+
("sql-injection", re.compile(
73+
r"\b(?:execute|executeQuery|query|rawQuery|prepare|mysqli_query|pg_query)\s*\("
74+
r"[^)\n]{0,160}?(?:\+|%[sd]?|\$\{|\bf[\"']|\.format\(|\|\|)", re.I)),
75+
("command-injection", re.compile(
76+
r"\b(?:os\.system|subprocess\.(?:call|run|Popen)|child_process\.exec(?:Sync)?|"
77+
r"Runtime\.getRuntime\(\)\.exec|ProcessBuilder|shell_exec|passthru|\bpopen|"
78+
r"\bsystem|\beval|\bexec)\s*\(", re.I)),
79+
("deserialization", re.compile(
80+
r"\b(?:pickle\.loads|yaml\.load\s*\((?![^)\n]*Safe)|unserialize|"
81+
r"ObjectInputStream|readObject|Marshal\.load|cPickle\.loads)\b", re.I)),
82+
("template-injection", re.compile(
83+
r"\b(?:render_template_string|Template\s*\([^)\n]*\)\.render|Handlebars\.compile)\b", re.I)),
84+
("xss-sink", re.compile(
85+
r"\b(?:innerHTML\s*=|outerHTML\s*=|document\.write|insertAdjacentHTML|"
86+
r"dangerouslySetInnerHTML|v-html)\b", re.I)),
87+
("path-traversal", re.compile(
88+
r"\b(?:open|readFile(?:Sync)?|sendFile|include(?:_once)?|require(?:_once)?|"
89+
r"file_get_contents|fopen)\s*\([^)\n]{0,120}?(?:\+|\$\{|\breq\.|request\.|params|\binput|\.\.\/)", re.I)),
90+
]
91+
6692

6793
def _clean_route(raw: str) -> str:
6894
"""A route as a path that can be requested. Parameters become 1."""
@@ -106,9 +132,21 @@ def _openapi_paths(text: str, name: str) -> list:
106132
return out
107133

108134

135+
def _sinks_in(text: str, rel: str, file_routes: list) -> list:
136+
"""Dangerous patterns in one file, each tagged with the routes that file
137+
declares (its reachability decides later whether the sink matters)."""
138+
out = []
139+
for kind, pattern in SINK_PATTERNS:
140+
for m in pattern.finditer(text):
141+
line = text.count("\n", 0, m.start()) + 1
142+
out.append({"file": rel, "line": line, "type": kind,
143+
"routes": file_routes[:8]})
144+
return out
145+
146+
109147
def read(root: Path, host: str = "") -> dict:
110-
"""-> {routes: [path], host_files: [file], files: read, frameworks: [...]}"""
111-
routes, host_files, read_count = [], [], 0
148+
"""-> {routes: [path], sinks: [...], host_files: [file], files: read}"""
149+
routes, sinks, host_files, read_count = [], [], [], 0
112150
host_re = re.compile(r"(?<![A-Za-z0-9.-])" + re.escape(host.lower()) + r"(?![A-Za-z0-9-])",
113151
re.I) if host else None
114152
for dirpath, dirnames, filenames in os.walk(root):
@@ -133,19 +171,27 @@ def read(root: Path, host: str = "") -> dict:
133171
rel = str(path.relative_to(root)).replace(os.sep, "/")
134172
if host_re is not None and len(host_files) < 10 and host_re.search(text):
135173
host_files.append(rel)
174+
file_routes = []
136175
if path.suffix.lower() in (".json", ".yaml", ".yml"):
137176
for p in _openapi_paths(text, filename):
138177
cleaned = _clean_route(p)
139-
if cleaned and cleaned not in routes:
140-
routes.append(cleaned)
178+
if cleaned:
179+
file_routes.append(cleaned)
180+
if cleaned not in routes:
181+
routes.append(cleaned)
141182
if path.suffix.lower() == ".json":
142183
continue
143184
for pattern in ROUTE_PATTERNS:
144185
for m in pattern.finditer(text):
145186
cleaned = _clean_route(m.group("p"))
146-
if cleaned and cleaned != "/" and cleaned not in routes:
147-
routes.append(cleaned)
148-
if len(routes) >= MAX_ROUTES * 2:
187+
if cleaned and cleaned != "/":
188+
if cleaned not in file_routes:
189+
file_routes.append(cleaned)
190+
if cleaned not in routes:
191+
routes.append(cleaned)
192+
if len(sinks) < MAX_SINKS:
193+
sinks += _sinks_in(text, rel, file_routes)
194+
if len(routes) >= MAX_ROUTES * 2 and len(sinks) >= MAX_SINKS:
149195
break
150-
return {"routes": routes[:MAX_ROUTES], "host_files": host_files,
151-
"files": read_count}
196+
return {"routes": routes[:MAX_ROUTES], "sinks": sinks[:MAX_SINKS],
197+
"host_files": host_files, "files": read_count}

‎app/core/external.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def default_environment(db) -> str:
110110
# it counts without anybody saying so. Below this it is a suggestion.
111111
LINK_THRESHOLD = 60
112112
MAX_WHITEBOX_REPOS = 3
113+
MAX_WHITEBOX_SINKS = 300
113114
MAX_FINDINGS = 3000
114115

115116

@@ -1964,7 +1965,7 @@ def _whitebox(scan_id: int, asset_id: int, host: str) -> dict:
19641965
for branch in (l.get("branches") or [l["repo"].default_branch or ""]):
19651966
plan.append((l["repo"].id, l["full_name"], l["repo"].connection or "", branch))
19661967
plan = plan[:MAX_WHITEBOX_REPOS]
1967-
out = {"repos": [], "routes": [], "by_repo": {}}
1968+
out = {"repos": [], "routes": [], "by_repo": {}, "sinks": []}
19681969
for repo_id, name, connection, branch in plan:
19691970
if scan_id in _stopping:
19701971
break
@@ -1984,6 +1985,9 @@ def _whitebox(scan_id: int, asset_id: int, host: str) -> dict:
19841985
for route in got["routes"]:
19851986
if route not in out["routes"]:
19861987
out["routes"].append(route)
1988+
for sink in got.get("sinks") or []:
1989+
if len(out["sinks"]) < MAX_WHITEBOX_SINKS:
1990+
out["sinks"].append(dict(sink, repository=name))
19871991
except Exception as exc: # noqa: BLE001 - the scan carries on without it
19881992
entry["error"] = f"{type(exc).__name__}: {exc}"[:200]
19891993
out["repos"].append(entry)
@@ -2685,11 +2689,30 @@ def fetch(path):
26852689
dast_paths.verify(records, controls, fetch, first)
26862690
paths = {"control": {p: c["status"] for p, c in controls.items()},
26872691
"paths": records, "counts": dast_paths.counts(records)}
2692+
sinks = _correlate_sinks(wb.get("sinks") or [], records)
26882693
_update(scan_id, paths=json.dumps(paths), paths_count=paths["counts"]["answered"],
2689-
paths_real=paths["counts"]["reachable"], code_matches=json.dumps(
2694+
paths_real=paths["counts"]["reachable"],
2695+
code_sinks=json.dumps(sinks), code_matches=json.dumps(
26902696
_code_matches(asset_id, records, index, names)))
26912697

26922698

2699+
def _correlate_sinks(sinks: list, records: list) -> list:
2700+
"""Mark each code sink reachable when the dynamic scan reached one of the
2701+
routes its file declares. A reachable sink is a lead worth proving; one
2702+
that is not reachable is context, a thing to look at, not a dynamic bug."""
2703+
reached = {r["p"] for r in records if r.get("r") is True}
2704+
out = []
2705+
for s in sinks:
2706+
hit = sorted(set(s.get("routes") or []) & reached)
2707+
out.append({"file": s.get("file"), "line": s.get("line"),
2708+
"type": s.get("type"), "repository": s.get("repository", ""),
2709+
"routes": (s.get("routes") or [])[:4],
2710+
"reachable": bool(hit), "reached_route": hit[0] if hit else ""})
2711+
# Reachable ones first, then by type, so the report leads with what matters.
2712+
out.sort(key=lambda s: (not s["reachable"], s["type"], s["file"]))
2713+
return out
2714+
2715+
26932716
def code_index(db, extra: dict = None):
26942717
"""Every repository's declared routes, with the routes the scan just read
26952718
from the ones linked to the site. -> (dast_paths.Index, {id: name})"""

‎app/core/models.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2608,6 +2608,9 @@ class ExternalDastScan(Base):
26082608
# Both JSON, and only for a scan run here.
26092609
paths = Column(LONG_JSON, default="")
26102610
code_matches = Column(LONG_JSON, default="")
2611+
# Dangerous code sinks from the mapped repositories, each marked reachable
2612+
# by the dynamic scan or not, for the risk read and the areas to review.
2613+
code_sinks = Column(LONG_JSON, default="")
26112614
# The two counts out of paths, so a list of sites does not read every
26122615
# scan's paths to say how big each one is: every path with an answer,
26132616
# and the ones that were reachable (a 200 that was a real page).

‎app/web/templates/dast_report_pdf.html‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,37 @@ <h3>{{ f.ref }} {{ f.title }}</h3>
262262
</div>
263263
{% endfor %}
264264

265+
{% if reachable_sinks or context_sinks %}
266+
<h2 class="new-page">5. Code and the scan</h2>
267+
<p class="faint">{{ "Dangerous patterns read from the mapped code, split by whether the scan reached the route they sit behind. Reachable means a dynamic test can get to that code, so it is a lead worth proving. The rest is context: a thing to look at by hand, not a dynamic finding." | sentence_spaced }}</p>
268+
{% if reachable_sinks %}
269+
<h3>5.1 Reached by the scan (leads to prove)</h3>
270+
<table>
271+
<thead><tr><th>Type</th><th>Where</th><th>Reached route</th></tr></thead>
272+
<tbody>
273+
{% for s in reachable_sinks %}
274+
<tr><td><span class="badge r-high">{{ s.type }}</span></td>
275+
<td class="mono">{{ s.repository }} {{ s.file }}:{{ s.line }}</td>
276+
<td class="mono">{{ s.reached_route }}</td></tr>
277+
{% endfor %}
278+
</tbody>
279+
</table>
280+
{% endif %}
281+
{% if context_sinks %}
282+
<h3>5.2 Areas to review (not reached by the scan)</h3>
283+
<p class="faint">{{ "Not confirmed, and not counted toward the grade. These are leads for a code review, not proven bugs, because the scan could not reach them from the outside." | sentence_spaced }}</p>
284+
<table>
285+
<thead><tr><th>Type</th><th>Where</th></tr></thead>
286+
<tbody>
287+
{% for s in context_sinks[:60] %}
288+
<tr><td>{{ s.type }}</td><td class="mono">{{ s.repository }} {{ s.file }}:{{ s.line }}</td></tr>
289+
{% endfor %}
290+
</tbody>
291+
</table>
292+
{% if context_sinks | length > 60 %}<p class="faint">{{ context_sinks | length - 60 }} more not shown.</p>{% endif %}
293+
{% endif %}
294+
{% endif %}
295+
265296
<h2 class="new-page">Appendix A. The scan underneath</h2>
266297
<table>
267298
<tr><td class="k">Scan number</td><td>{{ scan.id }}</td></tr>

0 commit comments

Comments
 (0)