Skip to content

Commit 4dbf7a7

Browse files
committed
External sites: fast thumbnails, certificate expiry mail, report wording
The External sites list sent each site's full 1280x800 screenshot to fill a 128x80 box, and the security headers turned its cache header into no-store, so every visit fetched every picture again. A screenshot now gets a 256x160 JPEG beside it, made by the same Playwright the screenshot uses. Older screenshots get theirs in the background the first time the list opens. The list asks for the thumbnail, and a route's own private max-age now survives the security headers, so the browser keeps it for a day. The address changes with each new screenshot. Certificate warnings: under Settings, External sites, mail can go out when a site's TLS certificate comes within a set number of days of expiry, 29 by default. The check runs at 08:00, names every site that is not obsolete in one message, and names each certificate once. Before a site is named, its certificate is read again from the site, so one renewed since the last telemetry read is not reported. The warnings go to their own list, or to the Notifications page's To list when that is empty. A send that fails is tried again the next morning. The dynamic scan and security assessment reports no longer say AI. The review is called the enhanced review, an earlier AI validation is an earlier validation, and the model's name and token counts are left out.
1 parent f5a73fb commit 4dbf7a7

15 files changed

Lines changed: 431 additions & 25 deletions

‎app/core/assessment.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def _decided_item(group: dict) -> dict:
129129
if d["by"] == "validation":
130130
item["what"] = tidy(d["what"] or group["description"], 600)
131131
item["evidence"] = tidy(
132-
f"AI validation #{d['id']} rated this {d['rating'].lower()}"
132+
f"Validation #{d['id']} rated this {d['rating'].lower()}"
133133
+ (f" with {d['confidence']} confidence" if d["confidence"] else "")
134134
+ (f" on {d['at']}" if d["at"] else "") + ".", 600)
135135
item["why"] = tidy(d["why"], 600)
@@ -923,8 +923,8 @@ def _write(repository: str, branch: str, connection: str, actor: str) -> None:
923923
asked = [ref for ref, g in zip(refs, groups) if not g["decision"]]
924924

925925
if not send_code:
926-
notes.append("Sending code is switched off on the AI page, so the model saw "
927-
"scanner details and file names but no code.")
926+
notes.append("Sending code is switched off, so the review saw scanner "
927+
"details and file names but no code.")
928928

929929
# ---- one look at the whole repository
930930
overview = {"what_it_is": "", "positives": [], "concerns": []}
@@ -982,8 +982,8 @@ def progress():
982982
if item is None:
983983
item = _clean_item({"verdict": "needs_review", "title": group["title"],
984984
"likelihood": 3, "impact": 3,
985-
"what": "The model could not review this group, so it "
986-
"is reported from the scanner's data.",
985+
"what": "This group could not be reviewed, so it is "
986+
"reported from the scanner's data.",
987987
"evidence": f"{group['count']} alert(s) of rule "
988988
f"{group['rule'] or group['package']}.",
989989
"why": "It needs a person to look at it.",
@@ -1300,7 +1300,7 @@ def record_concerns(items: list, repository: str, branch: str, actor: str) -> li
13001300
description=tidy(" ".join(x for x in (item["what"], item["evidence"],
13011301
item["why"]) if x), 3000),
13021302
confidence="medium",
1303-
triage_reason=("Found by the AI security report, not by a scanner. "
1303+
triage_reason=("Found by the enhanced security report, not by a scanner. "
13041304
"A person should confirm it."),
13051305
url=(f"{web}/blob/{branch}/{item['file']}#L{item['line']}"
13061306
if web and branch else ""),

‎app/core/browser_thumb.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env python3
2+
"""Shrink site screenshots into the small pictures the External sites list shows.
3+
4+
Run as its own process with the DAST environment's Python, where Playwright
5+
lives; nothing from the app is imported. The web app has no image library,
6+
and a screenshot is a 1280x800 PNG of several hundred KB or more. The list
7+
shows it at 128x80, so sending the whole PNG makes every row wait on a full
8+
image. This draws each PNG into a 256x160 page (twice the box, so it stays
9+
sharp on a high density screen) and saves that as a small JPEG.
10+
11+
One browser does every pair it is given. The page loads nothing from the
12+
network: the image goes in as a data URL. The answer is one line of JSON on
13+
standard output with how many were written.
14+
15+
browser_thumb.py SRC.png DST.jpg [SRC.png DST.jpg ...]
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import base64
21+
import json
22+
import os
23+
import sys
24+
25+
WIDTH, HEIGHT = 256, 160
26+
PAGE = ("<!doctype html><html><head><style>html,body{margin:0;background:#fff}"
27+
"img{display:block;width:%dpx;height:%dpx;object-fit:cover;object-position:top}"
28+
"</style></head><body><img src=\"data:image/png;base64,%%s\"></body></html>"
29+
% (WIDTH, HEIGHT))
30+
31+
32+
def main() -> int:
33+
pairs = list(zip(sys.argv[1::2], sys.argv[2::2]))
34+
out = {"written": 0, "error": ""}
35+
try:
36+
from playwright.sync_api import sync_playwright
37+
except ImportError:
38+
out["error"] = "playwright is not installed"
39+
print(json.dumps(out))
40+
return 0
41+
launch = {"args": ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]}
42+
try:
43+
with sync_playwright() as p:
44+
browser = p.chromium.launch(**launch)
45+
page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT})
46+
# Nothing on this page should reach out; the image is inline.
47+
page.route("**/*", lambda route: route.abort())
48+
for src, dst in pairs:
49+
try:
50+
with open(src, "rb") as fh:
51+
data = base64.b64encode(fh.read()).decode()
52+
page.set_content(PAGE % data, wait_until="load", timeout=15000)
53+
tmp = dst + ".tmp"
54+
page.screenshot(path=tmp, type="jpeg", quality=75)
55+
os.replace(tmp, dst)
56+
out["written"] += 1
57+
except Exception as exc: # noqa: BLE001 - one bad file is not the batch
58+
out["error"] = f"{os.path.basename(src)}: {type(exc).__name__}"
59+
browser.close()
60+
except Exception as exc: # noqa: BLE001 - every failure is an answer
61+
out["error"] = f"{type(exc).__name__}: {str(exc).splitlines()[0][:200]}"
62+
print(json.dumps(out))
63+
return 0
64+
65+
66+
if __name__ == "__main__":
67+
sys.exit(main())

‎app/core/dast_report.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
severity, the second and later findings in the same OWASP category cost
1212
less, and no one category can take more than 25 points off the overall
1313
score, so one bad library cannot fail an otherwise clean site. nextgen-dast
14-
counts only findings its probes confirmed. Here the AI triage plays that
14+
counts only findings its probes confirmed. Here the enhanced review plays that
1515
part: when it ran, a finding it called a likely false positive costs nothing
1616
and one it wants reviewed costs half. When it did not run, every finding
1717
counts in full and the report says they are unconfirmed.
@@ -218,11 +218,11 @@ def narrative(host: str, view: dict, overall: dict, by_sev: dict, triaged: bool,
218218
"same kind take less off than the first, and no single kind can take off more "
219219
"than 25 points.")
220220
if triaged:
221-
out.append("An AI review read every finding after the scan. Findings it judged to be "
221+
out.append("An enhanced review read every finding after the scan. Findings it judged to be "
222222
"likely false positives do not count toward the grade, and findings it wants "
223223
"a person to check count half. Its verdict is shown on each finding.")
224224
elif total:
225-
out.append("No AI review was run on this scan, so every finding counts in full and none "
225+
out.append("No enhanced review was run on this scan, so every finding counts in full and none "
226226
"of them has been confirmed. Scanners report some issues that turn out not "
227227
"to be real, so check each one before acting on it.")
228228
return out

‎app/core/external.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,75 @@ def shot_path(asset_id: int) -> Path:
137137
return data_dir() / "shots" / f"{int(asset_id)}.png"
138138

139139

140+
def thumb_path(asset_id: int) -> Path:
141+
"""The small JPEG the list shows in place of the full screenshot."""
142+
return data_dir() / "shots" / f"{int(asset_id)}.thumb.jpg"
143+
144+
145+
def drop_shot(asset_id: int) -> None:
146+
shot_path(asset_id).unlink(missing_ok=True)
147+
thumb_path(asset_id).unlink(missing_ok=True)
148+
149+
150+
def thumb_fresh(asset_id: int) -> bool:
151+
"""A thumbnail made from the screenshot that is there now."""
152+
try:
153+
return thumb_path(asset_id).stat().st_mtime >= shot_path(asset_id).stat().st_mtime
154+
except OSError:
155+
return False
156+
157+
158+
def make_thumbs(asset_ids) -> int:
159+
"""Shrink these sites' screenshots in one browser. -> how many written.
160+
Quietly does nothing where Playwright is not installed; the list then
161+
falls back to the full screenshot."""
162+
import subprocess
163+
164+
from app.core import external_probe
165+
166+
py = external_probe.playwright_python()
167+
script = Path(__file__).with_name("browser_thumb.py")
168+
argv = []
169+
for asset_id in asset_ids:
170+
if shot_path(asset_id).is_file():
171+
argv += [str(shot_path(asset_id)), str(thumb_path(asset_id))]
172+
if not py or not script.is_file() or not argv:
173+
return 0
174+
written = 0
175+
for start in range(0, len(argv), 100): # 50 a browser keeps argv short
176+
try:
177+
proc = subprocess.run([py, str(script), *argv[start:start + 100]],
178+
capture_output=True, text=True, timeout=300)
179+
got = json.loads((proc.stdout or "").strip().splitlines()[-1])
180+
except (subprocess.TimeoutExpired, ValueError, IndexError, OSError) as exc:
181+
log.warning("thumbnails failed: %s", exc)
182+
continue
183+
written += int(got.get("written") or 0)
184+
if got.get("error"):
185+
log.warning("thumbnails: %s", got["error"])
186+
return written
187+
188+
189+
_thumb_lock = threading.Lock()
190+
191+
192+
def backfill_thumbs(asset_ids) -> bool:
193+
"""Make missing or stale thumbnails off the request thread. One pass at
194+
a time; a pass already running is left to finish. -> started or not."""
195+
todo = [a for a in asset_ids if shot_path(a).is_file() and not thumb_fresh(a)]
196+
if not todo or not _thumb_lock.acquire(blocking=False):
197+
return False
198+
199+
def run():
200+
try:
201+
make_thumbs(todo)
202+
finally:
203+
_thumb_lock.release()
204+
205+
threading.Thread(target=run, name="gcr-thumbs", daemon=True).start()
206+
return True
207+
208+
140209
# ------------------------------------------------------------------ hosts
141210

142211
def parent_of(host: str) -> str:
@@ -1327,7 +1396,11 @@ def save_telemetry(asset_id: int, rec: dict) -> None:
13271396
if rec.get("shot"):
13281397
asset.screenshot = rec["shot"][:16]
13291398
if rec["shot"] not in ("ok", "blank", "waf") or not shot_path(asset_id).exists():
1330-
shot_path(asset_id).unlink(missing_ok=True)
1399+
drop_shot(asset_id)
1400+
if shot_path(asset_id).is_file() and not thumb_fresh(asset_id):
1401+
# Made now, while the probe's thread is spent anyway, so the list
1402+
# never waits on the full picture.
1403+
make_thumbs([asset_id])
13311404

13321405

13331406
def refresh_stale() -> int:

‎app/core/external_certs.py‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Mail before an External site's TLS certificate runs out.
2+
3+
Once a day, every site whose certificate ends within the number of days set
4+
under Settings, External sites, is named in one message. Each certificate is
5+
named once: what was told is kept as the site and the certificate's end date,
6+
so a renewed certificate starts over and an unchanged one is not repeated
7+
every morning.
8+
9+
What the list knows about a certificate is as old as the site's telemetry,
10+
which may be a week. A certificate renewed since then would be a false
11+
alarm, so each site about to be named has its certificate read again first,
12+
with the same single handshake telemetry uses. When that read fails the
13+
stored date is used, and the message says where it came from.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import datetime as dt
19+
import logging
20+
21+
from app.core import settings_store as store
22+
23+
log = logging.getLogger("gcr.external_certs")
24+
25+
26+
def _when(value) -> dt.datetime | None:
27+
try:
28+
got = dt.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
29+
except (TypeError, ValueError):
30+
return None
31+
return got if got.tzinfo else got.replace(tzinfo=dt.timezone.utc)
32+
33+
34+
def recipients(session) -> list:
35+
"""The addresses set for these alerts, or the Notifications page's list."""
36+
from app.core import notify
37+
38+
own = notify._split(store.get(session, "external.cert_alert_to"))
39+
return own or notify.recipients(session)
40+
41+
42+
def stored_expiry(asset) -> dt.datetime | None:
43+
from app.core import external
44+
45+
tls = external.telemetry_of(asset).get("tls") or {}
46+
return _when(tls.get("not_after"))
47+
48+
49+
def live_expiry(host: str, allow_private: bool) -> dt.datetime | None:
50+
"""The certificate the site serves now, or None when it cannot be read."""
51+
from app.core import external_probe
52+
53+
ips = external_probe.resolve(host)
54+
if not ips or (not allow_private and not all(external_probe.is_public(i) for i in ips)):
55+
return None
56+
info = external_probe.analyze_tls(host, ips[0], legacy=False)
57+
return _when(info.get("not_after"))
58+
59+
60+
def expiring(session, days: int, now: dt.datetime = None, reread: bool = True) -> list:
61+
"""Sites whose certificate ends within `days`, soonest first. Expired ones
62+
count: a site added after its certificate ran out is still worth saying."""
63+
from app.core import external
64+
from app.core.models import ExternalAsset
65+
66+
now = now or dt.datetime.now(dt.timezone.utc)
67+
horizon = now + dt.timedelta(days=days)
68+
allow_private = bool(store.get(session, "external.allow_private"))
69+
out = []
70+
for asset in session.query(ExternalAsset).all():
71+
if (asset.status or "active") == "obsolete":
72+
continue
73+
ends = stored_expiry(asset)
74+
if ends is None or ends > horizon:
75+
continue
76+
source = "telemetry"
77+
if reread:
78+
try:
79+
fresh = live_expiry(asset.host, allow_private)
80+
except Exception: # noqa: BLE001 - an unreadable site keeps the stored date
81+
fresh = None
82+
if fresh is not None:
83+
ends, source = fresh, "live"
84+
if ends > horizon:
85+
continue # renewed since telemetry was read
86+
tls = external.telemetry_of(asset).get("tls") or {}
87+
out.append({
88+
"id": asset.id, "host": asset.host,
89+
"application": asset.application or "",
90+
"environment": asset.environment or "",
91+
"not_after": ends,
92+
# Whole days on either side: a day and a half ago is one day ago.
93+
"days_left": (ends - now).days if ends >= now else -((now - ends).days),
94+
"issuer": tls.get("issuer") or "",
95+
"source": source,
96+
"read_at": asset.telemetry_at,
97+
})
98+
out.sort(key=lambda r: (r["not_after"], r["host"]))
99+
return out
100+
101+
102+
def _line(site: dict) -> str:
103+
name = site["host"]
104+
extra = ", ".join(x for x in (site["application"], site["environment"]) if x)
105+
if extra:
106+
name += f" ({extra})"
107+
date = site["not_after"].strftime("%Y-%m-%d")
108+
left = site["days_left"]
109+
if left < 0:
110+
when = f"expired {date}, {-left} day{'s' if left != -1 else ''} ago"
111+
elif left == 0:
112+
when = f"expires {date}, today"
113+
else:
114+
when = f"expires {date}, in {left} day{'s' if left != 1 else ''}"
115+
text = f"{name}: certificate {when}"
116+
if site["source"] != "live":
117+
read = (f"telemetry read {site['read_at'].strftime('%Y-%m-%d')}" if site["read_at"]
118+
else "an earlier telemetry read")
119+
text += f". The site did not answer just now, so this date is from {read}"
120+
return text + "."
121+
122+
123+
def message(sites: list, days: int) -> tuple:
124+
"""-> (title, lines)."""
125+
n = len(sites)
126+
title = (f"{n} site certificate{'s' if n != 1 else ''} "
127+
f"expire{'s' if n == 1 else ''} within {days} days")
128+
lines = [_line(s) for s in sites[:60]]
129+
if n > 60:
130+
lines.append(f"and {n - 60} more")
131+
lines.append("Each certificate is named once. A renewed one is named again only "
132+
"if it too comes within the warning window.")
133+
return title, lines
134+
135+
136+
def daily() -> dict:
137+
"""The scheduled check. Quiet unless it is switched on and a certificate
138+
newly comes within the window. -> what happened, for the audit log."""
139+
from app.core import external, notify
140+
from app.core.audit import record
141+
from app.core.db import session_scope
142+
143+
with session_scope() as db:
144+
if not external.enabled(db) or not store.get(db, "external.cert_alert_enabled"):
145+
return {}
146+
days = int(store.get(db, "external.cert_alert_days") or 29)
147+
try:
148+
sites = expiring(db, days)
149+
except Exception as exc: # noqa: BLE001 - a background job must not take the scheduler down
150+
log.warning("certificate check failed: %s", exc)
151+
return {}
152+
told = store.get(db, "external.cert_alert_last") or {}
153+
if not isinstance(told, dict):
154+
told = {}
155+
key = lambda s: s["not_after"].isoformat() # noqa: E731
156+
new = [s for s in sites if told.get(str(s["id"])) != key(s)]
157+
if not new:
158+
# Forget sites that left the window, so the list never grows.
159+
store.set_value(db, "external.cert_alert_last",
160+
{str(s["id"]): key(s) for s in sites}, "scheduler")
161+
return {"new": 0, "expiring": len(sites)}
162+
to = recipients(db)
163+
title, lines = message(new, days)
164+
root = notify.base_url(db)
165+
sent = notify._mail(db, title, lines, f"{root}/external" if root else "",
166+
None, to) if to else {}
167+
ok = bool(sent) and all(sent.values())
168+
if ok:
169+
# Only once it went, so a mail server that was down tries again
170+
# tomorrow instead of the warning being lost.
171+
store.set_value(db, "external.cert_alert_last",
172+
{str(s["id"]): key(s) for s in sites}, "scheduler")
173+
record(db, "notifications.cert_expiry", actor="scheduler",
174+
detail="%d newly expiring, %d in the window; %s" % (
175+
len(new), len(sites),
176+
", ".join(f"{k}: {'ok' if v else 'failed'}" for k, v in sorted(sent.items()))
177+
or "no recipients, nothing sent"),
178+
outcome="ok" if ok else "warn")
179+
return {"new": len(new), "expiring": len(sites), "sent": sent}

0 commit comments

Comments
 (0)