From 45a01bd32eafb1723306d868343cf4b06dbfb9b7 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 24 Jul 2026 23:32:48 +0200 Subject: [PATCH] round37: API-Scanner Kontrollprobe entschaerft (weniger Fehlalarme false negatives), Alle-bestaetigen-Buttons, Scan-Buttons bei Geraeten zusammengelegt --- apps/backend/src/scanner/apiDetector.ts | 50 +++++------ .../frontend/src/routes/admin/DevicesPage.tsx | 20 +---- .../frontend/src/routes/admin/ScannerPage.tsx | 86 +++++++++++++++---- 3 files changed, 98 insertions(+), 58 deletions(-) diff --git a/apps/backend/src/scanner/apiDetector.ts b/apps/backend/src/scanner/apiDetector.ts index f684d83..8f38265 100644 --- a/apps/backend/src/scanner/apiDetector.ts +++ b/apps/backend/src/scanner/apiDetector.ts @@ -128,21 +128,14 @@ function isApiLikeResponse(result: RawProbeResult): boolean { * Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine * Login-Weiterleitung ohne API-Signal) zählen nicht. * - * VORAB ein Kontroll-Check gegen einen garantiert nicht existierenden Pfad: - * manche Geräte (z. B. IP-Kameras) beantworten JEDE Anfrage einheitlich mit - * z. B. Basic-Auth-Aufforderung oder demselben JSON-Fehlerobjekt - dann - * würden buchstäblich ALLE Kandidaten-Pfade fälschlich als "API gefunden" - * durchgehen (siehe Bugreport: eine Reolink-Türklingel "hatte" plötzlich - * Home-Assistant-, Proxmox- UND Portainer-APIs). Reagiert das Kontroll-Pfad - * genauso "positiv" wie ein echter Treffer, ist dem Gerät für diese Prüfung - * grundsätzlich nicht zu trauen - dann lieber gar kein Ergebnis als 30 falsche. + * KEIN pauschaler Kontroll-Check gegen einen zufälligen Pfad mehr (frühere + * Version): viele Selfhosted-Programme (Sonarr, Portainer, Home Assistant, …) + * fordern für JEDE unauthentifizierte Anfrage einheitlich eine Anmeldung - + * das sah wie ein Gerät aus, "dem man nicht trauen kann", war aber normales, + * erwartbares Verhalten und hat massenhaft echte Treffer unterdrückt. + * Stattdessen wird JEDER einzelne Treffer für sich geprüft (siehe unten). */ export async function detectApis(baseUrl: string): Promise { - const control = await fetchRaw(`${baseUrl}/__launchpad_api_probe_${Date.now()}__`); - if (control && control.status !== 404 && control.status !== 0 && isApiLikeResponse(control)) { - return []; - } - const checks = await Promise.all( CANDIDATE_PATHS.map(async ({ path, type }) => { const result = await fetchRaw(`${baseUrl}${path}`); @@ -156,19 +149,28 @@ export async function detectApis(baseUrl: string): Promise { let matches = checks.filter((c): c is DetectedApi => c !== null); - // Zweite Stufe gegen Catch-all-Verhalten, das die Kontrollprobe oben nicht - // erwischt (z. B. FritzBox/Hue Bridge: 404 für "echte" Fremdpfade, aber - // jede Anfrage UNTER/NAHE einem bekannten API-Pfad wird gleich generisch - // beantwortet). Für jeden Treffer wird eine offensichtlich erfundene - // Variante DESSELBEN Pfads geprüft (letztes Segment mit Zufallssuffix). - // Antwortet die genauso "positiv", war der ursprüngliche Treffer - // vermutlich nur ein generischer Handler, kein echter Endpunkt. + // Gegen Catch-all-Verhalten (z. B. FritzBox/Hue Bridge: 404 für "echte" + // Fremdpfade, aber jede Anfrage UNTER einem bekannten API-Pfad wird gleich + // generisch beantwortet - ganz unabhängig vom genauen Unterpfad): für + // jeden Treffer werden ZWEI erfundene Varianten geprüft - (a) derselbe + // Pfad mit verändertem letzten Segment, (b) ein komplett zufälliger Pfad + // im selben Verzeichnis. Antwortet EINE der beiden genauso "positiv", war + // der ursprüngliche Treffer nur ein genereller Handler, kein echter, + // spezifischer Endpunkt. const verified = await Promise.all( matches.map(async (m) => { - const mutatedPath = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`); - const sibling = await fetchRaw(`${baseUrl}${mutatedPath}`); - const isCatchAll = - !!sibling && sibling.status !== 404 && sibling.status !== 0 && isApiLikeResponse(sibling); + const mutatedLastSegment = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`); + const directoryPrefix = m.path.slice(0, m.path.lastIndexOf("/") + 1); + const randomSibling = `${directoryPrefix}__lp_probe_${Date.now().toString(36)}__`; + + const [siblingA, siblingB] = await Promise.all([ + fetchRaw(`${baseUrl}${mutatedLastSegment}`), + directoryPrefix.length > 1 ? fetchRaw(`${baseUrl}${randomSibling}`) : Promise.resolve(null), + ]); + + const isCatchAll = [siblingA, siblingB].some( + (s) => !!s && s.status !== 404 && s.status !== 0 && isApiLikeResponse(s) + ); return isCatchAll ? null : m; }) ); diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 66ecb5b..df53ebd 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, type FormEvent } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faMagnifyingGlassPlus } from "@fortawesome/free-solid-svg-icons"; +import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; @@ -413,25 +413,13 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { - {scanMutation.isPending && scanAbort ? ( +
    {nameChanges.map((c) => (
  • @@ -169,9 +177,17 @@ function NewServicesList({ return (
    -

    - {newServices.length} neue(r) Dienst(e) gefunden: -

    +
    +

    + {newServices.length} neue(r) Dienst(e) gefunden: +

    + +
      {newServices.map((s) => (
    • @@ -215,10 +231,18 @@ function StaleServicesReview({ return (
      -

      - {staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port - nicht mehr offen): -

      +
      +

      + {staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port + nicht mehr offen): +

      + +
        {staleServices.map((s) => (
      • @@ -263,9 +287,17 @@ function DeviceChangesReview({ return (
        -

        - {deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab: -

        +
        +

        + {deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab: +

        + +
          {deviceChanges.map((c) => (
        • @@ -308,10 +340,18 @@ function StaleDevicesReview({ return (
          -

          - {staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht - mehr: -

          +
          +

          + {staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht + mehr: +

          + +
            {staleDevices.map((d) => (
          • @@ -416,7 +456,16 @@ function ApiScannerCard() { {status ?

            {status}

            : null} {lastNewFindings.length > 0 ? ( -
              + <> +
              + +
              +
                {lastNewFindings.map(({ serviceId, serviceName, apis: entries }) => (
              • @@ -445,6 +494,7 @@ function ApiScannerCard() {
              • ))}
              + ) : null}
          );