feat: replace UI mockups with real netOrk screenshots
The homepage showed seven hand-built JSX imitations of the netOrk UI. They are gone; every image is now a screenshot of netOrk v0.28.0 itself, taken from an anonymized copy of a production database (scripts/demo) with scripts/screenshots/capture.py and published as WebP (~630 KB for all eight). - Hero: the device inventory. Walkthrough: device detail, VLANs, the Security tab, the vulnerability triage queue (replacing the config-diff row), the dashboard and service checks (new row 6). NIS2: the audit log, filtered to what people did. - Copy follows the images: row 3 describes the security assessment, row 4 the triage queue; row 2 no longer claims corrections are always automatic; 18 widgets. Alt texts in both languages. - Also fixed on the homepage: the NIS2 teaser for Art. 21 (2e) and the container list of a deployment (three worker pools, plus Flower, registry, APT cache and the Signal gateway). - Demo tooling hardened on the real dump: secrets inside JSON (Wi-Fi keys), reverse DNS zones, glued identifiers, tens of thousands of CrowdSec addresses, a schema newer than the release (anonymize, then downgrade), MFA-enforcing roles, and click steps for view filters. - DESIGN.md: real screenshots only. PAGES.md: the six rows as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
38834100d1
commit
011f816fc9
+68
-12
@@ -50,6 +50,11 @@ SKIP_TABLES = {
|
||||
}
|
||||
TEXT_TYPES = {"text", "character varying", "jsonb", "json", "inet", "cidr", "macaddr", "ARRAY"}
|
||||
|
||||
# Only these count as internal addresses to move; Python's is_private also
|
||||
# covers 0.0.0.0/8 and friends, which in practice are version numbers.
|
||||
PRIVATE_NETS = [ipaddress.IPv4Network(n) for n in
|
||||
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")]
|
||||
|
||||
# Well-known public resolvers stay as they are; they say nothing about anyone.
|
||||
KEEP_PUBLIC = {"1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9", "149.112.112.112"}
|
||||
|
||||
@@ -57,6 +62,8 @@ IPV4 = re.compile(r"(?<![\d.])((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5
|
||||
MAC = re.compile(r"(?<![0-9A-Fa-f:-])([0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2})(?![0-9A-Fa-f:-])")
|
||||
MAC_DOT = re.compile(r"(?<![0-9A-Fa-f.])([0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4})(?![0-9A-Fa-f.])")
|
||||
IPV6 = re.compile(r"(?<![0-9A-Fa-f:])((?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4})(?![0-9A-Fa-f:])")
|
||||
# Reverse zones and PTR names: 8.22.172.in-addr.arpa is 172.22.8.0/24.
|
||||
REVERSE = re.compile(r"(?<![\d.])((?:\d{1,3}\.){1,4})in-addr\.arpa", re.I)
|
||||
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}")
|
||||
|
||||
|
||||
@@ -69,8 +76,13 @@ class Mapper:
|
||||
# {"home.corp.example.com": "hq.example.demo", "corp.example.com": "example.demo"}
|
||||
self.domains: dict[str, str] = cfg.get("domains", {})
|
||||
self.prefix16 = dict(cfg.get("ipv4_prefix16", {}))
|
||||
self.pool16 = iter(cfg.get("ipv4_pool16", [f"10.{n}" for n in range(20, 250, 10)]))
|
||||
taken = set(self.prefix16.values())
|
||||
pool = cfg.get("ipv4_pool16") or (
|
||||
[f"10.{n}" for n in range(20, 256, 10)] + [f"10.{n}" for n in range(256) if n % 10]
|
||||
+ [f"172.{n}" for n in range(16, 32)])
|
||||
self.pool16 = iter(p for p in pool if p not in taken)
|
||||
self.public: dict[str, str] = {}
|
||||
self.public_used: set[str] = set()
|
||||
# Public-looking dotted quads are only mapped once they were seen as an
|
||||
# address (see collect_public); "kernel 6.8.0.45" is a version, not a host.
|
||||
self.known_public: set[str] = set(cfg.get("public_ips", []))
|
||||
@@ -84,6 +96,8 @@ class Mapper:
|
||||
alt = "|".join(re.escape(k) for k in sorted(names, key=len, reverse=True))
|
||||
# A name is a whole token: not glued to letters, digits, '-' or '_'.
|
||||
self.names_re = re.compile(rf"(?<![\w-])({alt})(?![\w-])")
|
||||
# Plain substrings, for names glued into identifiers (HOME_OFFICE_MGMT_NET).
|
||||
self.substrings: dict[str, str] = cfg.get("substrings", {})
|
||||
self.domain_re = None
|
||||
if self.domains:
|
||||
alt = "|".join(re.escape(d) for d in sorted(self.domains, key=len, reverse=True))
|
||||
@@ -96,16 +110,28 @@ class Mapper:
|
||||
if ip in KEEP_PUBLIC or a.is_loopback or a.is_multicast or a.is_unspecified \
|
||||
or a.is_link_local or ip.startswith("255.") or a.is_reserved:
|
||||
return ip
|
||||
if a.is_private:
|
||||
if any(a in net for net in PRIVATE_NETS):
|
||||
p = ".".join(ip.split(".")[:2])
|
||||
if p not in self.prefix16:
|
||||
self.prefix16[p] = next(self.pool16)
|
||||
return self.prefix16[p] + "." + ".".join(ip.split(".")[2:])
|
||||
if not a.is_global:
|
||||
return ip # 0.x, 192.0.0.x, benchmark ... : versions more often than hosts
|
||||
if ip not in self.known_public:
|
||||
self.unmapped_public[ip] = self.unmapped_public.get(ip, 0) + 1
|
||||
return ip
|
||||
if ip not in self.public:
|
||||
self.public[ip] = next(self.public_pool)
|
||||
fake = next(self.public_pool, None)
|
||||
probe = 0
|
||||
while fake is None or fake in self.public_used:
|
||||
# Documentation ranges exhausted (CrowdSec alone brings tens of
|
||||
# thousands of attacker addresses): hash into the non-routable
|
||||
# benchmark range 198.18.0.0/15, probing on collision.
|
||||
n = int(h(f"{ip}/{probe}", 8), 16) % (2 ** 17)
|
||||
fake = f"198.{18 + (n >> 16)}.{(n >> 8) & 255}.{n & 255}"
|
||||
probe += 1
|
||||
self.public_used.add(fake)
|
||||
self.public[ip] = fake
|
||||
return self.public[ip]
|
||||
|
||||
def mac(self, m: str) -> str:
|
||||
@@ -148,8 +174,19 @@ class Mapper:
|
||||
return e
|
||||
return f"user-{h(e.lower(), 6)}@{DEMO_DOMAIN}"
|
||||
|
||||
def reverse(self, m: re.Match) -> str:
|
||||
octets = m.group(1).rstrip(".").split(".")[::-1] # forward order
|
||||
if len(octets) < 2 or any(int(o) > 255 for o in octets):
|
||||
return m.group(0)
|
||||
padded = octets + ["0"] * (4 - len(octets))
|
||||
mapped = self.ipv4(".".join(padded)).split(".")[:len(octets)]
|
||||
return ".".join(mapped[::-1]) + ".in-addr.arpa"
|
||||
|
||||
# -- whole strings -------------------------------------------------------
|
||||
def text(self, s: str) -> str:
|
||||
s = SECRET_JSON.sub(lambda m: m.group(0) if m.group(1) in SECRET_JSON_KEEP
|
||||
else f'"{m.group(1)}"{m.group(2)}""', s)
|
||||
s = REVERSE.sub(self.reverse, s)
|
||||
s = EMAIL.sub(self.email, s)
|
||||
if self.domain_re:
|
||||
s = self.domain_re.sub(lambda m: m.group(1) + self.domains[m.group(2).lower()], s)
|
||||
@@ -159,20 +196,29 @@ class Mapper:
|
||||
s = IPV4.sub(lambda m: self.ipv4(m.group(1)), s)
|
||||
if self.names_re:
|
||||
s = self.names_re.sub(lambda m: self.names[m.group(1)], s)
|
||||
for old, new in self.substrings.items():
|
||||
s = s.replace(old, new)
|
||||
return s
|
||||
|
||||
|
||||
# Cheap server-side prefilter: only rows that could contain something to map.
|
||||
def prefilter(cfg: dict) -> str:
|
||||
parts = [r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", r"[0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-]",
|
||||
r"[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.", r"[0-9A-Fa-f]{1,4}::?[0-9A-Fa-f]{1,4}:", "@"]
|
||||
for k in [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {})]:
|
||||
r"[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.", r"[0-9A-Fa-f]{1,4}::?[0-9A-Fa-f]{1,4}:", "@",
|
||||
r"in-addr\.arpa", r"(key|psk|passphrase|password|secret|token)\"\s*:"]
|
||||
for k in [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
|
||||
*cfg.get("substrings", {})]:
|
||||
parts.append(re.escape(k))
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
# Columns emptied wherever they occur, found by name so a new table is covered.
|
||||
SECRET_COLUMN = re.compile(r"(password|secret|private_key|api_key|apikey|token)", re.I)
|
||||
SECRET_COLUMN = re.compile(r"(password|secret|private_key|api_key|apikey|token|passphrase|psk|ft_key|wpa_key)", re.I)
|
||||
# The same inside JSON and text: device snapshots carry Wi-Fi keys and the like.
|
||||
SECRET_JSON = re.compile(
|
||||
r'"((?:[A-Za-z0-9_]*_)?(?:key|psk|passphrase|password|passwd|secret|token|private_key|ft_key|sae_password))"'
|
||||
r'(\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||
SECRET_JSON_KEEP = {"public_key", "entry_key", "key_type", "is_secret", "ssh_key_id"}
|
||||
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
||||
|
||||
# Whole tables that only hold secrets or personal delivery data.
|
||||
@@ -313,6 +359,8 @@ async def reset_users(con, cfg: dict, dry: bool) -> None:
|
||||
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
|
||||
"totp_secret = NULL, totp_enabled = false, is_active = false WHERE id = $1",
|
||||
u["id"], f"operator{n}", f"operator{n}@{DEMO_DOMAIN}", hash_password(os.urandom(16).hex()))
|
||||
# TOTP secrets are gone, so a role that demands MFA would lock everyone out.
|
||||
await con.execute("UPDATE roles SET require_mfa = false")
|
||||
role = await con.fetchval("SELECT id FROM roles WHERE lower(name) IN ('administrator', 'admin') LIMIT 1")
|
||||
if role:
|
||||
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
|
||||
@@ -320,18 +368,26 @@ async def reset_users(con, cfg: dict, dry: bool) -> None:
|
||||
|
||||
|
||||
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
|
||||
needles = [n for n in originals if len(n) >= 4]
|
||||
if not needles:
|
||||
# Names are matched as written (FAMILY is a VLAN, "family" a JSON key);
|
||||
# leak_terms and domains in any case.
|
||||
names = [n for n in [*cfg.get("hostnames", {}), *cfg.get("terms", {}), *cfg.get("substrings", {})]
|
||||
if len(n) >= 4]
|
||||
loose = [n for n in [*cfg.get("domains", {}), *cfg.get("leak_terms", [])] if len(n) >= 4]
|
||||
# Postgres has no inline (?i:...), so spell case-insensitivity out: [mM][aA]...
|
||||
def anycase(t: str) -> str:
|
||||
return "".join(f"[{c.lower()}{c.upper()}]" if c.isalpha() else re.escape(c) for c in t)
|
||||
parts = [re.escape(n) for n in names] + [anycase(n) for n in loose]
|
||||
if not parts:
|
||||
return 0
|
||||
pat = "|".join(re.escape(n) for n in needles)
|
||||
pat = "|".join(parts)
|
||||
found = 0
|
||||
for t, c, _ in await columns(con):
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~* $1', pat)
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~ $1', pat)
|
||||
if n:
|
||||
found += n
|
||||
sample = await con.fetchval(
|
||||
f'SELECT substring("{c}"::text from $1) FROM "{t}" WHERE "{c}"::text ~* $1 LIMIT 1',
|
||||
f"(?i)(.{{0,30}}(?:{pat}).{{0,30}})")
|
||||
f'SELECT substring("{c}"::text from $2) FROM "{t}" WHERE "{c}"::text ~ $1 LIMIT 1',
|
||||
pat, f"(.{{0,30}}(?:{pat}).{{0,30}})")
|
||||
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
|
||||
return found
|
||||
|
||||
|
||||
+17
-1
@@ -47,8 +47,24 @@ case "${1:-}" in
|
||||
got=$(docker exec "$DB" psql -U netork -tA -c "SELECT version_num FROM alembic_version")
|
||||
want=$(cd "$SRC" && PATH="$VENV/bin:$PATH" alembic heads 2>/dev/null | awk '{print $1}')
|
||||
echo "dump schema: $got $VERSION head: $want"
|
||||
[ "$got" = "$want" ] || echo "WARNING: schema differs from $VERSION; screens may not match the release."
|
||||
# Anonymize first: it empties every secret, so a downgrade that would
|
||||
# have to decrypt something (with a key we do not have) finds nothing.
|
||||
"$VENV/bin/python" "$HERE/anonymize.py"
|
||||
if [ "$got" != "$want" ]; then
|
||||
# The production instance runs a newer build. Walk the copy back to the
|
||||
# release with the newer code's own downgrade migrations.
|
||||
NEWER="${NETORK_NEWER_REF:-origin/main}"
|
||||
echo "migrating the copy from $got back to $want with $NEWER's migrations"
|
||||
rm -rf "$DEMO/src-newer"; mkdir -p "$DEMO/src-newer"
|
||||
git -C "$NETORK_REPO" archive "$NEWER" | tar -x -C "$DEMO/src-newer"
|
||||
# Rows the older schema cannot hold: CrowdSec blocklist alerts whose scope
|
||||
# is a list name, longer than the column they go back into.
|
||||
docker exec "$DB" psql -U netork -q -c \
|
||||
"DELETE FROM crowdsec_alerts WHERE length(source_scope) > 32" 2>/dev/null || true
|
||||
(cd "$DEMO/src-newer" && PATH="$VENV/bin:$PATH" \
|
||||
DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork" alembic downgrade "$want")
|
||||
"$VENV/bin/python" "$HERE/anonymize.py" --report-only
|
||||
fi
|
||||
;;
|
||||
start)
|
||||
ensure_src; ensure_db
|
||||
|
||||
@@ -17,6 +17,7 @@ taking screenshots cannot change anything on the instance.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -26,6 +27,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from shots import SHOTS
|
||||
@@ -80,7 +82,10 @@ def token() -> str:
|
||||
body = urllib.parse.urlencode({"username": USER, "password": PASSWORD}).encode()
|
||||
try:
|
||||
with urllib.request.urlopen(f"{BASE}/api/v1/auth/token", body) as res:
|
||||
return json.load(res)["access_token"]
|
||||
tok = json.load(res).get("access_token")
|
||||
if not tok:
|
||||
sys.exit(f"Login as {USER} needs MFA; the demo copy should have none (anonymize.py)")
|
||||
return tok
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit(f"Login as {USER} at {BASE} failed: {e} (is scripts/demo/up.sh start running?)")
|
||||
state = json.loads(STATE.read_text())
|
||||
@@ -118,6 +123,15 @@ def settle(page: Page) -> None:
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def publish(png: bytes, path: Path, width: int) -> None:
|
||||
"""Scale the 2x capture down to its published width and store it as WebP."""
|
||||
img = Image.open(io.BytesIO(png)).convert("RGB")
|
||||
if img.width > width:
|
||||
img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS)
|
||||
img.save(path, "WEBP", quality=85, method=6)
|
||||
print(f" -> {path.name} {img.width}x{img.height}, {path.stat().st_size // 1024} KB")
|
||||
|
||||
|
||||
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
terms = mask_terms()
|
||||
@@ -160,6 +174,9 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
if got_it.is_visible():
|
||||
got_it.click()
|
||||
page.wait_for_timeout(300)
|
||||
for sel in shot.clicks:
|
||||
page.locator(sel).first.click()
|
||||
settle(page)
|
||||
masks = [page.locator(s) for s in shot.mask]
|
||||
if mask:
|
||||
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
||||
@@ -167,8 +184,9 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
clip = None
|
||||
if shot.height:
|
||||
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
|
||||
page.screenshot(path=OUT / f"{shot.name}.png", full_page=shot.full_page,
|
||||
clip=clip, mask=masks, mask_color="#334155", animations="disabled")
|
||||
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
|
||||
mask_color="#334155", animations="disabled")
|
||||
publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
browser.close()
|
||||
if blocked:
|
||||
print("Blocked non-GET requests (nothing was sent):")
|
||||
|
||||
@@ -23,15 +23,26 @@ class Shot:
|
||||
full_page: bool = False
|
||||
# Crop height in CSS pixels; None keeps the viewport height.
|
||||
height: int | None = None
|
||||
# Width of the published WebP in pixels (the capture is 3200 wide).
|
||||
width: int = 1600
|
||||
# Selectors clicked in order before the shot, first match each. Only for
|
||||
# controls that change the view (filters, tabs); the API guard in
|
||||
# capture.py aborts anything that would write.
|
||||
clicks: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
SHOTS: list[Shot] = [
|
||||
Shot("devices", "/devices"),
|
||||
Shot("devices", "/devices", width=2400),
|
||||
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
|
||||
Shot("vlans", "/vlans"),
|
||||
Shot("device-security", "/devices/{server}#security/assessment"),
|
||||
Shot("vulnerabilities", "/vulnerabilities"),
|
||||
Shot("dashboard", "/"),
|
||||
Shot("audit-log", "/audit-log"),
|
||||
# Background polls drown out what people did: filter the scheduler out,
|
||||
# the way a reader would (click a source badge, then flip it to exclude).
|
||||
Shot("audit-log", "/audit-log", clicks=[
|
||||
"tbody td >> text=scheduler",
|
||||
"button[title='Click to toggle include/exclude']",
|
||||
]),
|
||||
Shot("service-checks", "/monitoring/checks"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user