From 88a31404e9c3841cfa5d2ea3686630346403170a Mon Sep 17 00:00:00 2001 From: Tobias Wagner Date: Wed, 19 Aug 2026 20:25:05 +0200 Subject: [PATCH 1/2] HTML/CSS --- README.md | 19 ++- scan.py | 3 +- scanner/faq.py | 11 ++ scanner/markup.py | 312 +++++++++++++++++++++++++++++++++++++++++ scanner/report.py | 4 +- templates/index.html | 11 +- tests/test_analysis.py | 70 +++++++++ webapp.py | 5 +- 8 files changed, 423 insertions(+), 12 deletions(-) create mode 100644 scanner/markup.py diff --git a/README.md b/README.md index e3b20d5..44a6810 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ python scan.py "https://localhost:8443/app?q=test" --insecure | `--evidence FILE` | Save an audit-ready **evidence bundle** (findings + verification + controls + remediation + drift) as JSON | | `--history-dir DIR` | Different base folder for history/remediation (default: `%LOCALAPPDATA%/web-vuln-scanner`) | | `--no-tech` | Disable technology detection (tech stack overview) (on by default) | -| `--analyze` | Additional website analysis: performance, SEO, DNS/email, availability & trackers/privacy | +| `--analyze` | Additional website analysis: performance, SEO, HTML markup conformance, DNS/email, availability & trackers/privacy | | `--mxtools` | DNS/registrar checks (records, SPF/DMARC/DKIM/MTA-STS/BIMI/TLS-RPT, RDAP, DNSBL, SMTP) | | `--json FILE` | Additionally save findings as JSON | | `--yes` | Skip the authorization prompt | @@ -638,7 +638,7 @@ python scan.py "https://my-app.com/" --legal > reviewed legally/professionally. The automatic accessibility check covers only a > part of WCAG (contrasts, focus order, screen-reader flow require manual review). -## Website analysis (performance/SEO/DNS/hygiene) +## Website analysis (performance/SEO/markup/DNS/hygiene) Via `--analyze` (CLI) or the **"Website analysis"** checkbox (web UI) – beyond pure security – a separate report (tab **"Analysis"**) is added, grouped by: @@ -648,6 +648,10 @@ security – a separate report (tab **"Analysis"**) is added, grouped by: - **SEO** (deep): title/description quality, H1/headings, `canonical`, indexability, Open Graph/Twitter Cards, **structured data (JSON-LD)**, `hreflang`, alt-text coverage, viewport/lang/charset, `robots.txt`/`sitemap.xml`. +- **Markup/standards**: HTML conformance against the most common violations – doctype/ + quirks mode, unclosed and stray tags, invalid nesting (block elements inside `

`), + obsolete elements/attributes (``, `

`, `bgcolor` …), duplicate `id` + attributes, position of the charset declaration and unescaped `&` in text. - **DNS/email** (deep): **DNSSEC**, **CAA**, MX, **SPF/DMARC policy strength**, **MTA-STS** (needs `dnspython`). - **Availability & privacy**: redirect chains, **HTTP→HTTPS**, **www canonicalization**, 404 @@ -657,6 +661,16 @@ security – a separate report (tab **"Analysis"**) is added, grouped by: Rated as **PASS/WARN/FAIL/INFO**, heuristic (without a headless browser), included in the HTML and JSON export. +> **On the markup check:** it runs **entirely locally** – neither the target URL nor the +> markup is sent to an external validator (same stance as the CVE lookup, which sends only +> product and version). It is therefore not a full replacement for +> [validator.w3.org](https://validator.w3.org/), and it deliberately never rates worse +> than **WARN**: markup violations are code quality, not a defect – the vast majority of +> sites do not validate cleanly, and browsers and screen readers cope. WCAG 2.2 removed +> success criterion 4.1.1 "Parsing" for exactly that reason, which is why this lives in +> the analysis report and **not** in the legal/accessibility report. CSS is not checked: +> a real CSS validator is its own project, and a heuristic finds nothing that matters. + **Real Core Web Vitals (optional, rendered):** in addition to `--analyze`, `--browser` (CLI) or the **"Headless browser"** checkbox enables a performance audit in Chromium (Playwright) and measures **LCP, CLS, FCP, TTFB, Load** as well as @@ -890,6 +904,7 @@ scanner/ techstack.py # Technology detection (Wappalyzer style): server/language/framework/CMS/JS/CDN perf.py # Analysis: performance (response time, compression, caching, page weight, render-blocking) seo.py # Analysis: SEO (title/description, headings, canonical, OG, JSON-LD, hreflang, alt, sitemap) + markup.py # Analysis: HTML markup conformance (doctype, tag structure, obsolete elements, duplicate ids) dnsinfo.py # Analysis: DNS/email (DNSSEC, CAA, MX, SPF/DMARC policy, MTA-STS) hygiene.py # Analysis: availability (redirects, HTTP->HTTPS, www, 404, well-known) + trackers/privacy browserperf.py # Optional: rendered performance audit (Core Web Vitals via Playwright; --browser) diff --git a/scan.py b/scan.py index 86396c6..d415b96 100644 --- a/scan.py +++ b/scan.py @@ -23,7 +23,7 @@ jwt, pollution, pwmgr, traversal, verify, wordpress, xss, xxe) from scanner import cms, dotnet, java, python as pychk from scanner import techstack -from scanner import perf, seo, dnsinfo, hygiene, browserperf, dnsreg +from scanner import perf, seo, dnsinfo, hygiene, browserperf, dnsreg, markup from scanner import history, controls, remediation from scanner.utils import ScannerSession, SEVERITY_ORDER @@ -624,6 +624,7 @@ def main() -> int: print("[*] Website-Analyse (Performance/SEO/DNS/Erreichbarkeit) ...") analysis_results = (perf.analyze(session, target, base_url, resp) + seo.analyze(session, target, base_url, resp) + + markup.analyze(session, target, base_url, resp) + dnsinfo.analyze(session, target, base_url, resp) + hygiene.analyze(session, target, base_url, resp)) if args.browser: diff --git a/scanner/faq.py b/scanner/faq.py index 51efa34..58fc30a 100644 --- a/scanner/faq.py +++ b/scanner/faq.py @@ -309,6 +309,17 @@ "Auth) ueber ALLE entdeckten Seiten ausgewertet und je Anforderung App-weit " "aggregiert (schlimmster Status gewinnt, betroffene Seiten werden gelistet); " "host-weite Kategorien (Architektur, Zugriffskontrolle, Krypto) laufen einmal."}, + {"name": "Website-Analyse", + "expects": "Haekchen (CLI: --analyze).", + "means": "Erstellt ausserhalb der Sicherheit einen eigenen Analyse-Tab: Performance " + "(Antwortzeit, Kompression, Caching, Seitengewicht), SEO (vertieft), " + "MARKUP/STANDARDS (HTML-Konformitaet: Doctype/Quirks-Modus, nicht geschlossene " + "und ueberzaehlige Tags, Block-Elemente in

, obsolete Elemente/Attribute, " + "doppelte ids, Charset-Position, unmaskierte &-Zeichen), DNS/E-Mail sowie " + "Erreichbarkeit & Tracker/Privacy. Die Markup-Pruefung laeuft rein lokal - " + "es geht nichts an einen externen Validator; sie ist bewusst nie FAIL, weil " + "Markup-Verstoesse Code-Qualitaet sind und kein Defekt (WCAG 2.2 hat das " + "Kriterium 4.1.1 'Parsing' ersatzlos gestrichen)."}, {"name": "SSRF-Callback", "expects": "Optionaler eigener OOB-Host, z.B. abc.oast.pro (interactsh/Burp-Collaborator). " "Leer lassen, wenn keiner vorhanden.", diff --git a/scanner/markup.py b/scanner/markup.py new file mode 100644 index 0000000..d4166d7 --- /dev/null +++ b/scanner/markup.py @@ -0,0 +1,312 @@ +"""Analyse: Markup-Konformitaet (HTML-Standard, nicht-destruktiv). + +Prueft die ausgelieferte Startseite rein lokal gegen haeufige Verstoesse gegen den +HTML-Standard - ohne externen Validator, damit weder Ziel-URL noch Markup das Haus +verlassen (gleiche Opsec-Linie wie beim CVE-Abgleich, der nur Produkt+Version sendet). + +Geprueft werden: Doctype/Quirks-Modus, nicht geschlossene und ueberzaehlige Tags, +ungueltige Verschachtelung (Block-Element in

), obsolete Elemente/Attribute, +doppelte id-Attribute, doppelte Attribute im selben Tag, Position der Charset- +Deklaration und unmaskierte &-Zeichen im Text. + +Bewusst KEIN CSS: ein echter CSS-Validator ist ein eigenes Projekt, und eine +Heuristik (unbalancierte Klammern, unbekannte At-Rules) findet nichts, was zaehlt. + +Bewusst maximal WARN, nie FAIL: Markup-Verstoesse sind Code-Qualitaet, kein Defekt - +die grosse Mehrheit aller Seiten validiert nicht sauber, und Browser wie Screenreader +kommen damit zurecht. WCAG 2.2 hat das Kriterium 4.1.1 "Parsing" aus genau diesem +Grund ersatzlos gestrichen; die Pruefung gehoert deshalb in die Website-Analyse und +nicht in den Legal-/Barrierefreiheits-Report.""" +from __future__ import annotations + +import re +from html.parser import HTMLParser + +from .utils import AnalysisResult + +CAT = "Markup/Standards" + +# Elemente ohne Endtag ("void elements"). +_VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", + "meta", "param", "source", "track", "wbr"} +# Elemente, deren Endtag der Standard optional macht -> fehlt er, ist das kein Verstoss. +_OPTIONAL_END = {"p", "li", "dt", "dd", "option", "optgroup", "thead", "tbody", "tfoot", + "tr", "td", "th", "colgroup", "rt", "rp", "html", "head", "body"} +# Ein neuer Starttag schliesst offene Geschwister-Elemente implizit. +_IMPLICIT_CLOSE = { + "li": {"li"}, "dt": {"dt", "dd"}, "dd": {"dt", "dd"}, "p": {"p"}, + "option": {"option"}, "optgroup": {"optgroup", "option"}, + "tr": {"tr", "td", "th"}, "td": {"td", "th"}, "th": {"td", "th"}, + "thead": {"tr", "td", "th"}, "tbody": {"tr", "td", "th"}, "tfoot": {"tr", "td", "th"}, +} +# Block-Elemente, die in einem

nicht vorkommen duerfen (der Browser schliesst das +#

still - der entstehende DOM weicht dann vom Quelltext ab). +_BLOCK_IN_P = {"div", "p", "ul", "ol", "dl", "table", "section", "article", "aside", + "header", "footer", "nav", "main", "form", "fieldset", "blockquote", + "figure", "hr", "pre", "h1", "h2", "h3", "h4", "h5", "h6"} +# In HTML5 entfernte Elemente (Praesentation/Frames) mit moderner Entsprechung. +_OBSOLETE_TAGS = { + "center": "CSS text-align/margin", "font": "CSS font/color", "big": "CSS font-size", + "strike": "/", "tt": "/CSS font-family", "acronym": "", + "marquee": "CSS-Animation", "blink": "-", "applet": "/", + "frame": "-", "frameset": "-", "noframes": "-", "dir": "
    ", "isindex": "
    ", + "basefont": "CSS", "menuitem": "-", "keygen": "-", +} +# In HTML5 entfernte Praesentations-Attribute. +_OBSOLETE_ATTRS = {"bgcolor", "cellpadding", "cellspacing", "valign", "hspace", "vspace", + "bordercolor", "background", "nowrap", "frameborder", "scrolling", + "marginwidth", "marginheight", "clear", "compact", "noshade"} + +_SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?", re.I | re.S) +_COMMENT_RE = re.compile(r"", re.S) +_TAG_RE = re.compile(r"<[^>]*>", re.S) +# Ein & ist im Text nur als Teil einer Entity zulaessig (benannt oder numerisch). +_RAW_AMP_RE = re.compile(r"&(?![a-zA-Z][a-zA-Z0-9]{1,31};|#[0-9]{1,7};|#[xX][0-9a-fA-F]{1,6};)") +_DOCTYPE_RE = re.compile(r"]*)>", re.I) +_CHARSET_RE = re.compile( + r"]*?(?:charset\s*=\s*[\"']?[\w-]+|http-equiv\s*=\s*[\"']?content-type)", re.I) + +_MAX_EXAMPLES = 5 # so viele Beispiele je Befund als Nachweis +_MAX_HTML = 3_000_000 # Groessenkappe: sehr grosse Dokumente nicht komplett parsen + + +class _MarkupParser(HTMLParser): + """Sammelt Struktur-Verstoesse in einem einzigen Durchlauf. + + Der Stack fuehrt die offenen Elemente; Elemente mit optionalem Endtag werden + beim naechsten Geschwister-Starttag implizit geschlossen, damit korrektes + Markup wie ``
  • a
  • b`` nicht faelschlich als Fehler gemeldet wird.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.stack: list[tuple[str, int]] = [] + self.unclosed: list[str] = [] + self.stray_end: list[str] = [] + self.bad_nesting: list[str] = [] + self.dup_attrs: list[str] = [] + self.obsolete_tags: dict[str, int] = {} + self.obsolete_attrs: dict[str, int] = {} + self.ids: dict[str, int] = {} + + def handle_starttag(self, tag, attrs): + line = self.getpos()[0] + if tag in _OBSOLETE_TAGS: + self.obsolete_tags[tag] = self.obsolete_tags.get(tag, 0) + 1 + + seen: set[str] = set() + for name, value in attrs: + if name in seen and len(self.dup_attrs) < _MAX_EXAMPLES: + self.dup_attrs.append(f"<{tag}> Attribut '{name}' (Zeile {line})") + seen.add(name) + if name in _OBSOLETE_ATTRS: + self.obsolete_attrs[name] = self.obsolete_attrs.get(name, 0) + 1 + if name == "id" and value: + self.ids[value] = self.ids.get(value, 0) + 1 + + # Block-Element in einem offenen

    : der Browser schliesst das

    still. + if tag in _BLOCK_IN_P and any(t == "p" for t, _ in self.stack): + if len(self.bad_nesting) < _MAX_EXAMPLES: + self.bad_nesting.append(f"<{tag}> in

    (Zeile {line})") + + closes = _IMPLICIT_CLOSE.get(tag) + if closes: + while self.stack and self.stack[-1][0] in closes: + self.stack.pop() + + if tag not in _VOID: + self.stack.append((tag, line)) + + def handle_startendtag(self, tag, attrs): + #

    ist in HTML nicht selbstschliessend; die Attribut-Pruefung soll + # trotzdem laufen, auf dem Stack bleibt das Element aber nicht liegen. + self.handle_starttag(tag, attrs) + if tag not in _VOID and self.stack and self.stack[-1][0] == tag: + self.stack.pop() + + def handle_endtag(self, tag): + line = self.getpos()[0] + if tag in _VOID: + if len(self.stray_end) < _MAX_EXAMPLES: + self.stray_end.append(f" (Zeile {line}, Element ohne Endtag)") + return + for i in range(len(self.stack) - 1, -1, -1): + if self.stack[i][0] == tag: + # Alles darueber blieb offen (ausser Elementen mit optionalem Endtag). + for open_tag, open_line in self.stack[i + 1:]: + if open_tag not in _OPTIONAL_END and len(self.unclosed) < _MAX_EXAMPLES: + self.unclosed.append(f"<{open_tag}> (Zeile {open_line})") + del self.stack[i:] + return + if len(self.stray_end) < _MAX_EXAMPLES: + self.stray_end.append(f" (Zeile {line}, ohne offenes Element)") + + def finish(self): + """Am Dokumentende noch offene Elemente einsammeln.""" + for open_tag, open_line in self.stack: + if open_tag not in _OPTIONAL_END and len(self.unclosed) < _MAX_EXAMPLES: + self.unclosed.append(f"<{open_tag}> (Zeile {open_line})") + self.stack = [] + + +def analyze(session, target: str, base_url: str, resp=None) -> list[AnalysisResult]: + """Prueft die Startseite auf haeufige Verstoesse gegen den HTML-Standard.""" + out: list[AnalysisResult] = [] + try: + resp = resp if resp is not None else session.get(target) + except Exception: + return out + + ctype = (resp.headers.get("Content-Type", "") or "").lower() + html = resp.text or "" + if "html" not in ctype and " _MAX_HTML: + return [AnalysisResult(CAT, "Dokument zu gross fuer die Markup-Pruefung", "INFO", + f"{len(html)} Zeichen (Kappe {_MAX_HTML}).")] + + parser = _MarkupParser() + try: + parser.feed(html) + parser.finish() + except Exception: + return [AnalysisResult(CAT, "Markup nicht parsebar", "WARN", + "Das Dokument liess sich nicht vollstaendig parsen.", + "HTML auf schwere Syntaxfehler pruefen.")] + + out += _doctype(html) + out += _structure(parser) + out += _obsolete(parser) + out += _duplicate_ids(parser) + out += _charset_position(html, resp) + out += _raw_ampersands(html) + + problems = [r for r in out if r.status == "WARN"] + out.insert(0, AnalysisResult( + CAT, f"Markup-Konformitaet: {len(problems)} Auffaelligkeit(en)", + "WARN" if problems else "PASS", + "Lokale Pruefung gegen haeufige HTML-Standard-Verstoesse (kein vollstaendiger " + "Validator, kein CSS)." if problems + else "Keiner der geprueften Standard-Verstoesse gefunden.", + "Fuer die vollstaendige Pruefung validator.w3.org nutzen - dabei geht die URL " + "bzw. das Markup an einen Dritten." if problems else "")) + return out + + +def _doctype(html: str) -> list[AnalysisResult]: + m = _DOCTYPE_RE.search(html[:1000]) + if not m: + return [AnalysisResult(CAT, "Kein Doctype (Quirks-Modus)", "WARN", + "Das Dokument beginnt ohne .", + " als erste Zeile setzen - ohne ihn rendert der " + "Browser im Quirks-Modus mit abweichendem Box-Modell.")] + decl = " ".join(m.group(1).split()).strip() + if decl.lower() != "html": + return [AnalysisResult(CAT, "Veralteter Doctype", "WARN", f"", + "Auf den HTML5-Doctype umstellen.")] + if html[:m.start()].strip(): + return [AnalysisResult(CAT, "Doctype nicht am Dokumentanfang", "WARN", + "Vor dem Doctype steht Inhalt (haeufig eine BOM oder eine " + "vorzeitige Ausgabe aus dem Backend).", + "Der Doctype muss das erste im Dokument sein.")] + return [AnalysisResult(CAT, "HTML5-Doctype vorhanden", "PASS", "")] + + +def _structure(p: _MarkupParser) -> list[AnalysisResult]: + out = [] + if p.unclosed: + out.append(AnalysisResult(CAT, f"Nicht geschlossene Tags ({len(p.unclosed)}+)", "WARN", + "Offen geblieben: " + ", ".join(p.unclosed), + "Fehlende Endtags ergaenzen - der Browser repariert das nach " + "eigenen Regeln, was Layout und JS-Selektoren veraendern kann.")) + if p.stray_end: + out.append(AnalysisResult(CAT, f"Ueberzaehlige Endtags ({len(p.stray_end)}+)", "WARN", + ", ".join(p.stray_end), + "Endtags ohne passendes offenes Element entfernen.")) + if p.bad_nesting: + out.append(AnalysisResult(CAT, f"Ungueltige Verschachtelung ({len(p.bad_nesting)}+)", "WARN", + ", ".join(p.bad_nesting), + "Block-Elemente gehoeren nicht in

    - der Browser schliesst " + "das

    still und erzeugt einen anderen DOM als erwartet.")) + if p.dup_attrs: + out.append(AnalysisResult(CAT, f"Doppelte Attribute ({len(p.dup_attrs)}+)", "WARN", + ", ".join(p.dup_attrs), + "Doppelte Attribute im selben Tag entfernen - nur das erste wirkt.")) + if not (p.unclosed or p.stray_end or p.bad_nesting or p.dup_attrs): + out.append(AnalysisResult(CAT, "Tag-Struktur sauber", "PASS", + "Keine offenen/ueberzaehligen Tags, keine Block-Elemente in

    .")) + return out + + +def _obsolete(p: _MarkupParser) -> list[AnalysisResult]: + out = [] + if p.obsolete_tags: + items = sorted(p.obsolete_tags.items(), key=lambda kv: -kv[1])[:_MAX_EXAMPLES] + detail = ", ".join(f"<{t}> ({n}x, heute: {_OBSOLETE_TAGS[t]})" for t, n in items) + out.append(AnalysisResult(CAT, f"Obsolete Elemente ({len(p.obsolete_tags)})", "WARN", detail, + "In HTML5 entfernte Elemente durch CSS bzw. die moderne " + "Entsprechung ersetzen.")) + if p.obsolete_attrs: + items = sorted(p.obsolete_attrs.items(), key=lambda kv: -kv[1])[:_MAX_EXAMPLES] + out.append(AnalysisResult(CAT, f"Obsolete Attribute ({len(p.obsolete_attrs)})", "WARN", + ", ".join(f"{a} ({n}x)" for a, n in items), + "Praesentations-Attribute durch CSS ersetzen.")) + if not (p.obsolete_tags or p.obsolete_attrs): + out.append(AnalysisResult(CAT, "Keine obsoleten Elemente/Attribute", "PASS", + "Kein /

    /bgcolor & Co. gefunden.")) + return out + + +def _duplicate_ids(p: _MarkupParser) -> list[AnalysisResult]: + dups = {i: n for i, n in p.ids.items() if n > 1} + if not dups: + return [AnalysisResult(CAT, "id-Attribute eindeutig", "PASS", + f"{len(p.ids)} id(s) im Dokument, keine doppelt.")] + items = sorted(dups.items(), key=lambda kv: -kv[1])[:_MAX_EXAMPLES] + return [AnalysisResult(CAT, f"Doppelte id-Attribute ({len(dups)})", "WARN", + ", ".join(f"id={i!r} ({n}x)" for i, n in items), + "ids muessen dokumentweit eindeutig sein - Duplikate brechen " + "Label-Bezuege, ARIA-Referenzen, Ankerlinks und querySelector.")] + + +def _charset_position(html: str, resp) -> list[AnalysisResult]: + m = _CHARSET_RE.search(html) + header_charset = "charset=" in (resp.headers.get("Content-Type", "") or "").lower() + if not m: + if header_charset: + return [AnalysisResult(CAT, "Charset nur im HTTP-Header", "INFO", + "Kein im Dokument, nur der Content-Type-Header.", + " ergaenzen, damit die Datei auch ohne " + "Header (lokal/Proxy) korrekt dekodiert wird.")] + return [AnalysisResult(CAT, "Keine Charset-Deklaration", "WARN", + "Weder noch charset im Content-Type-Header.", + " als erstes Element im setzen.")] + # Der Standard verlangt die Deklaration innerhalb der ersten 1024 Bytes. + offset = len(html[:m.start()].encode("utf-8", "ignore")) + if offset > 1024: + return [AnalysisResult(CAT, "Charset-Deklaration zu spaet", "WARN", + f" erst nach {offset} Bytes (Standard: 1024).", + "Die Charset-Deklaration an den Anfang des ziehen.")] + return [AnalysisResult(CAT, "Charset-Deklaration korrekt platziert", "PASS", + f"Innerhalb der ersten {offset} Bytes deklariert.")] + + +def _raw_ampersands(html: str) -> list[AnalysisResult]: + text = _SCRIPT_STYLE_RE.sub(" ", html) + text = _COMMENT_RE.sub(" ", text) + text = _TAG_RE.sub(" ", text) # nur Textinhalt, keine Attributwerte + hits = _RAW_AMP_RE.findall(text) + if not hits: + return [] + return [AnalysisResult(CAT, f"Unmaskierte &-Zeichen im Text ({len(hits)})", "WARN", + f"{len(hits)} '&' ohne gueltige Entity, z.B.: {_amp_context(text)}", + "Im Text '&' als & schreiben - haeufigster Validator-Fehler; " + "sonst kann der Parser Zeichen verschlucken (z.B. '©' in einer URL).")] + + +def _amp_context(text: str) -> str: + m = _RAW_AMP_RE.search(text) + if not m: + return "" + snippet = text[max(0, m.start() - 20):m.start() + 20] + return " ".join(snippet.split())[:60] diff --git a/scanner/report.py b/scanner/report.py index 2238710..8278c6c 100644 --- a/scanner/report.py +++ b/scanner/report.py @@ -820,8 +820,8 @@ def to_html(findings, target: str, asvs=None, legal=None, pages=None, tech=None, abadges = _badges(acounts, _ASVS_ORDER_HTML, "a-") if abadges: parts.append(f'
    {abadges}
    ') - parts.append('
    Performance, SEO, DNS/E-Mail und Erreichbarkeit/Privacy ' - '(heuristisch, ohne Headless-Browser).
    ') + parts.append('
    Performance, SEO, Markup/Standards, DNS/E-Mail und ' + 'Erreichbarkeit/Privacy (heuristisch, ohne Headless-Browser).
    ') by_cat_a: dict[str, list[dict]] = {} for r in adicts: by_cat_a.setdefault(r.get("category", ""), []).append(r) diff --git a/templates/index.html b/templates/index.html index f6c20b3..f5713f8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -264,7 +264,7 @@

    web-vuln-scanner v{{ version }}

    Verlauf / Drift verfolgen -