Skip to content

Commit 8dde4dd

Browse files
committed
Chart each site's risk over time, one line per telemetry source
A site is often watched by more than one scanner: the built-in dynamic scan and, where it is set up, Rapid7 InsightAppSec, with older runs recorded by hand. Those readings measure different things and should not be drawn as one line, so the site page now shows a separate risk trend for each source, built only once there is a finished scan to plot. Each point is one scan, oldest on the left, joined into a line. Its height is a weighted risk worked out from the scan's own finding counts, with the same per severity weights the grade uses, so every kind of scan lands on one scale even when only counts came back. The dot takes the color of the scan's worst finding, and a small bubble above each point carries the scan id so a reading is easy to pick out; the whole point links to that scan, and hovering it names the date, the risk and the counts behind it. The geometry is worked out in the shared chart helper and drawn as inline SVG, so it needs no page script and reads the same in either theme.
1 parent 2948788 commit 8dde4dd

5 files changed

Lines changed: 228 additions & 0 deletions

File tree

‎app/core/charts.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,93 @@ def y_at(value):
171171
}
172172

173173

174+
def risk_line(points: list, width: int = 720, height: int = 240,
175+
pad_left: int = 40, pad_right: int = 16,
176+
pad_top: int = 34, pad_bottom: int = 38) -> dict:
177+
"""-> a single risk line over a run of scans, already positioned.
178+
179+
One point per scan, left to right oldest to newest, joined by a line. Each
180+
point carries the scan's id so the page can print it as a small bubble and
181+
link it, and the point's worst severity so the dot takes that color. The
182+
bubbles are staggered above and below the line in turn, so a run of close
183+
scans does not stack their numbers on top of each other.
184+
185+
A single scan is drawn as one dot in the middle, not an invisible point,
186+
because one reading is a reasonable thing to be looking at.
187+
188+
Each point needs: value (the weighted risk, higher is worse), id, label (a
189+
short x-axis date), date (a full hover date), worst (a severity name or
190+
"none"), counts (a severity->n dict), grade (a letter or None).
191+
"""
192+
if not points:
193+
return {"empty": True, "width": width, "height": height}
194+
195+
plot_w = width - pad_left - pad_right
196+
plot_h = height - pad_top - pad_bottom
197+
198+
values = [max(0, int(p.get("value") or 0)) for p in points]
199+
top = _nice_ceiling(max(values) if values else 0)
200+
count = len(points)
201+
202+
def x_at(index):
203+
if count == 1:
204+
return pad_left + plot_w / 2.0
205+
return pad_left + (plot_w * index / (count - 1.0))
206+
207+
def y_at(value):
208+
return pad_top + plot_h - (plot_h * min(value, top) / float(top))
209+
210+
grid = []
211+
for step in range(GRID_STEPS + 1):
212+
value = top * step // GRID_STEPS
213+
grid.append({"value": value, "y": round(y_at(value), 1)})
214+
215+
line = " ".join("%.1f,%.1f" % (x_at(i), y_at(values[i])) for i in range(count))
216+
217+
marks = []
218+
for i, point in enumerate(points):
219+
x, y = x_at(i), y_at(values[i])
220+
label = str(point.get("id") or "")
221+
bubble_w = round(len(label) * 6.6 + 12, 1)
222+
# Centre the bubble on the point, then keep it inside the frame so the
223+
# first and last ones are not clipped at the edges.
224+
bx = x - bubble_w / 2.0
225+
bx = max(2.0, min(bx, width - bubble_w - 2.0))
226+
above = (i % 2 == 0) # alternate sides to cut overlap
227+
by = (y - 26) if above else (y + 8)
228+
by = max(2.0, min(by, height - 18.0))
229+
marks.append({
230+
"x": round(x, 1), "y": round(y, 1),
231+
"id": point.get("id"), "value": values[i],
232+
"worst": point.get("worst") or "none",
233+
"date": point.get("date", ""), "grade": point.get("grade"),
234+
"counts": point.get("counts", {}), "label": label,
235+
"bubble_x": round(bx, 1), "bubble_y": round(by, 1),
236+
"bubble_w": bubble_w, "text_x": round(bx + bubble_w / 2.0, 1),
237+
"text_y": round(by + 11.5, 1),
238+
})
239+
240+
every = max(1, count // 8)
241+
wanted = [i for i in range(count) if i % every == 0]
242+
if count > 1:
243+
if wanted and (count - 1 - wanted[-1]) <= max(1, every // 2):
244+
wanted.pop()
245+
wanted.append(count - 1)
246+
labels = []
247+
for i in sorted(set(wanted)):
248+
anchor = "start" if i == 0 else "end" if i == count - 1 else "middle"
249+
labels.append({"x": round(x_at(i), 1), "anchor": anchor,
250+
"text": points[i].get("label", "")})
251+
252+
return {
253+
"empty": False, "width": width, "height": height,
254+
"pad_left": pad_left, "pad_top": pad_top,
255+
"plot_w": plot_w, "plot_h": plot_h, "top": top,
256+
"line": line, "grid": grid, "marks": marks, "labels": labels,
257+
"first": values[0], "last": values[-1], "change": values[-1] - values[0],
258+
}
259+
260+
174261
def heat_levels(rows: list, key: str = "total") -> list:
175262
"""Add a 0 to 4 shade to each row, by where it sits against the busiest.
176263

‎app/core/external.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,83 @@ def findings_of(scan: ExternalDastScan) -> list:
17091709
return []
17101710

17111711

1712+
# The telemetry sources a site's scans can come from, grouped for the risk
1713+
# trend. The key decides which scans share a chart; the label titles it.
1714+
_SOURCE_ORDER = {"builtin": 0, "insightappsec": 1, "remote": 2, "recorded": 3}
1715+
1716+
1717+
def _scan_source(scan: ExternalDastScan) -> tuple:
1718+
"""(group key, shown label) for one scan's telemetry source. Rapid7 scans
1719+
come in as source "remote" with the InsightAppSec provider, so they are
1720+
split off from any other remote server by the provider."""
1721+
if (scan.provider or "") == "insightappsec":
1722+
return "insightappsec", "Rapid7 InsightAppSec"
1723+
if scan.source == "builtin":
1724+
return "builtin", "Built-in DAST"
1725+
if scan.source == "remote":
1726+
return "remote", (scan.server_name or "DAST server")
1727+
if scan.source == "recorded":
1728+
return "recorded", "Recorded"
1729+
return (scan.source or "other"), SCAN_SOURCES.get(
1730+
scan.source, (scan.source or "Other").title())
1731+
1732+
1733+
def scan_risk_value(scan: ExternalDastScan) -> int:
1734+
"""One weighted risk number from a scan's severity counts, so every scan -
1735+
a full built-in report, a counts-only InsightAppSec import, a recorded one
1736+
- lands on the same scale. The weights are the report's own per-finding
1737+
demerits, so a point here means what a point in the grade means: higher is
1738+
worse."""
1739+
from app.core.dast_report import DEMERIT
1740+
1741+
return sum(DEMERIT.get(s, 0) * (getattr(scan, s) or 0) for s in SEVERITIES)
1742+
1743+
1744+
def _scan_when_ts(scan: ExternalDastScan) -> float:
1745+
"""A sortable timestamp for a scan, by its own date then when it started,
1746+
tolerant of a naive datetime coming back from SQLite."""
1747+
when = scan.scanned_at or scan.started_at
1748+
if when is None:
1749+
return 0.0
1750+
if when.tzinfo is None:
1751+
when = when.replace(tzinfo=timezone.utc)
1752+
return when.timestamp()
1753+
1754+
1755+
def risk_trends(db, asset_id: int) -> list:
1756+
"""One risk-over-time line per telemetry source for a site. A site scanned
1757+
by both the built-in DAST and Rapid7 gets two charts, one each: the two
1758+
measure different things and must not be drawn as a single line. Only
1759+
finished scans carry final counts, so only those become points."""
1760+
from app.core import charts
1761+
1762+
scans = (db.query(ExternalDastScan)
1763+
.filter(ExternalDastScan.asset_id == asset_id,
1764+
ExternalDastScan.status == "done")
1765+
.all())
1766+
groups: dict = {}
1767+
for s in scans:
1768+
key, label = _scan_source(s)
1769+
groups.setdefault(key, {"label": label, "scans": []})["scans"].append(s)
1770+
out = []
1771+
for key in sorted(groups, key=lambda k: (_SOURCE_ORDER.get(k, 9), k)):
1772+
g = groups[key]
1773+
ordered = sorted(g["scans"], key=lambda s: (_scan_when_ts(s), s.id))
1774+
points = []
1775+
for s in ordered:
1776+
when = s.scanned_at or s.started_at
1777+
points.append({
1778+
"value": scan_risk_value(s), "id": s.id,
1779+
"label": when.strftime("%d %b") if when else "",
1780+
"date": when.strftime("%Y-%m-%d %H:%M") if when else "",
1781+
"worst": s.severity or "none",
1782+
"counts": {sev: getattr(s, sev) or 0 for sev in SEVERITIES},
1783+
})
1784+
out.append({"key": key, "label": g["label"],
1785+
"count": len(points), "chart": charts.risk_line(points)})
1786+
return out
1787+
1788+
17121789
def _iso(value):
17131790
if value is None:
17141791
return None

‎app/web/routes_external.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,7 @@ def detail_page(asset_id: int, request: Request, scans: int = 1,
729729
picks_json=json.dumps(external.picks_of(db, asset)),
730730
environments=external.environments(db), statuses=external.STATUSES,
731731
scan_views=[external.scan_dict(s) for s in page_scans],
732+
risk_trends=external.risk_trends(db, asset.id),
732733
servers=db.query(DastServer).order_by(DastServer.name).all(),
733734
ias=_ias_choices(db, asset),
734735
profiles=dast_engines.PROFILES, active=dast_engines.ACTIVE_PROFILES,

‎app/web/static/app.css‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,28 @@ table.audit-detail td { padding: .2rem 0; font-size: .9rem; }
753753
.chart .boundary { stroke: var(--text-faint); stroke-width: 1;
754754
stroke-dasharray: 3 3; }
755755

756+
/* The risk trend: one line per telemetry source, a dot per scan colored by its
757+
worst finding, and a small bubble carrying the scan id above each point. */
758+
.risk-trend { margin-bottom: .6rem; }
759+
.risk-trend + .risk-trend { border-top: 1px solid var(--border);
760+
padding-top: .5rem; }
761+
.risk-trend-title { font-weight: 600; font-size: .9rem; margin: .1rem 0 0 .15rem; }
762+
.risk-trend-note { margin: .15rem .25rem 0; }
763+
.chart .riskline { fill: none; stroke: var(--info); stroke-width: 2;
764+
stroke-linejoin: round; stroke-linecap: round; }
765+
.chart .dot { stroke: var(--bg); stroke-width: 1.5; }
766+
.chart .dot.band-critical { fill: var(--bad); opacity: 1; }
767+
.chart .dot.band-high { fill: var(--bad); opacity: .72; }
768+
.chart .dot.band-medium { fill: var(--warn); opacity: 1; }
769+
.chart .dot.band-low { fill: var(--info); opacity: 1; }
770+
.chart .dot.band-info { fill: var(--text-faint); opacity: 1; }
771+
.chart .dot.band-clean { fill: var(--ok); opacity: 1; }
772+
.chart a:hover .dot { stroke: var(--text); }
773+
.chart .bubble { fill: var(--bg); stroke: var(--border-strong); stroke-width: 1; }
774+
.chart a:hover .bubble { stroke: var(--info); }
775+
.chart .bubble-text { fill: var(--text-soft); font-size: 10px; font-weight: 600; }
776+
.chart a:hover .bubble-text { fill: var(--info); }
777+
756778
.legend { display: flex; flex-wrap: wrap; gap: .35rem 1rem;
757779
padding: .6rem 1rem 0; font-size: .76rem; color: var(--text-soft); }
758780
.legend .key { display: inline-flex; align-items: center; gap: .35rem; }

‎app/web/templates/external_site.html‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,47 @@
230230
</div>
231231
</div>
232232

233+
<div class="card" data-live="site-risk-trend">
234+
<div class="card-head">Risk trend
235+
<span class="faint small" style="font-weight:400">one line per telemetry source, higher is worse</span></div>
236+
{% if risk_trends %}
237+
<div class="card-body">
238+
{% for t in risk_trends %}
239+
{% set c = t.chart %}
240+
<div class="risk-trend">
241+
<div class="risk-trend-title">{{ t.label }}
242+
<span class="faint small" style="font-weight:400">{{ c.last }} now{% if c.marks|length > 1 %}, {{ c.change if c.change >= 0 else -c.change }} {{ 'higher' if c.change > 0 else ('lower' if c.change < 0 else 'unchanged') }} than the first of {{ t.count }} scans{% else %}, {{ t.count }} scan{% endif %}</span></div>
243+
<div class="chart-wrap">
244+
<svg class="chart risk-chart" viewBox="0 0 {{ c.width }} {{ c.height }}"
245+
preserveAspectRatio="xMidYMid meet" role="img"
246+
aria-label="Risk trend for {{ t.label }}">
247+
{% for line in c.grid %}
248+
<line class="grid" x1="{{ c.pad_left }}" y1="{{ line.y }}" x2="{{ c.width - 12 }}" y2="{{ line.y }}"/>
249+
<text class="axis" x="{{ c.pad_left - 6 }}" y="{{ line.y + 4 }}" text-anchor="end">{{ line.value }}</text>
250+
{% endfor %}
251+
{% if c.marks|length > 1 %}
252+
<polyline class="riskline" points="{{ c.line }}"/>
253+
{% endif %}
254+
{% for m in c.marks %}
255+
<a href="{{ base }}/external/{{ asset.id }}/scans/{{ m.id }}">
256+
<title>Scan {{ m.id }}{% if m.date %} &middot; {{ m.date }}{% endif %} &middot; risk {{ m.value }}{% for sev in ['critical','high','medium','low','info'] %}{% if m.counts[sev] %} &middot; {{ m.counts[sev] }} {{ sev }}{% endif %}{% endfor %}</title>
257+
<circle class="dot band-{{ m.worst if m.worst != 'none' else 'clean' }}" cx="{{ m.x }}" cy="{{ m.y }}" r="4"/>
258+
<rect class="bubble" x="{{ m.bubble_x }}" y="{{ m.bubble_y }}" width="{{ m.bubble_w }}" height="16" rx="8"/>
259+
<text class="bubble-text" x="{{ m.text_x }}" y="{{ m.text_y }}" text-anchor="middle">{{ m.label }}</text>
260+
</a>
261+
{% endfor %}
262+
{% for label in c.labels %}
263+
<text class="axis" x="{{ label.x }}" y="{{ c.height - 8 }}" text-anchor="{{ label.anchor }}">{{ label.text }}</text>
264+
{% endfor %}
265+
</svg>
266+
</div>
267+
</div>
268+
{% endfor %}
269+
<div class="faint small risk-trend-note">Each point is one finished scan, oldest on the left. Risk weighs findings by severity: critical 25, high 12, medium 5, low 2. The number in each bubble is the scan id; click a point to open that scan. A dot takes the color of the scan's worst finding.</div>
270+
</div>
271+
{% else %}<div class="empty">No finished scan yet, so there is nothing to trend.</div>{% endif %}
272+
</div>
273+
233274
<div class="card" data-live="site-scans">
234275
<div class="card-head">Dynamic scans
235276
{% if scan_total %}<span class="faint small" style="font-weight:400">{{ scan_total }} in all, newest first</span>{% endif %}</div>

0 commit comments

Comments
 (0)