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:
Christian Manivong
2026-09-26 08:24:51 +02:00
co-authored by Claude Opus 5.5
parent 38834100d1
commit 011f816fc9
16 changed files with 203 additions and 374 deletions
+68 -12
View File
@@ -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