From 2e15669098dd3741e91a866f226974d2abc4ebff Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 25 Jul 2026 18:54:23 +0200 Subject: [PATCH] round47: httpx-Integration nach masscan (nur bestaetigte Webinterfaces werden weiterverarbeitet), Sichtbarkeit ob masscan/httpx greifen ueber Admin-Logs --- apps/backend/Dockerfile | 23 ++++ apps/backend/src/routes/scan.ts | 4 +- apps/backend/src/scanner/httpx.ts | 127 +++++++++++++++++++++ apps/backend/src/scanner/networkScanner.ts | 40 ++++++- 4 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/scanner/httpx.ts diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 6e901da..2dfb1da 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -41,6 +41,29 @@ ENV HOST=0.0.0.0 # eingebaute (langsamere, aber ohne Zusatzrechte auskommende) Methode zurück. RUN apk add --no-cache iputils-ping masscan +# httpx (ProjectDiscovery) prüft eine Liste offener Ports und liefert NUR +# die, die tatsächlich HTTP/HTTPS sprechen, inkl. Status/Titel/Server-Header +# in einem einzigen, hochparallelen Durchlauf (siehe scanner/httpx.ts) - +# deutlich schneller als eigenes Protokoll-Raten pro Port, und verhindert, +# dass Nicht-Web-Ports (SSH, SMB, NFS, ...) überhaupt erst per HTTP +# angesprochen werden. Kein Alpine-Paket vorhanden, daher als +# Binary-Release von GitHub geladen. Bei Fehlschlag (z. B. kein +# Internetzugang beim Bauen des Images) läuft der Build trotzdem durch - +# der Scanner erkennt ein fehlendes httpx zur Laufzeit automatisch und +# fällt auf die eingebaute Methode zurück. +RUN apk add --no-cache --virtual .httpx-build curl unzip && \ + ( \ + HTTPX_URL=$(curl -fsSL https://api.github.com/repos/projectdiscovery/httpx/releases/latest \ + | grep -o 'https://[^"]*httpx_[0-9][^"]*_linux_amd64\.zip' | head -n1) && \ + test -n "$HTTPX_URL" && \ + curl -fsSL "$HTTPX_URL" -o /tmp/httpx.zip && \ + unzip -o /tmp/httpx.zip -d /usr/local/bin httpx && \ + chmod +x /usr/local/bin/httpx \ + || echo "httpx-Installation fehlgeschlagen - Scanner nutzt automatisch die eingebaute Methode" \ + ) && \ + rm -f /tmp/httpx.zip && \ + apk del .httpx-build + COPY --from=build /app/deploy/ ./ COPY --from=build /app/apps/backend/dist ./dist COPY --from=build /app/packages/shared/dist ./node_modules/@launchpad/shared/dist diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index b6b393e..4bd29c8 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -32,7 +32,7 @@ async function performDeviceScan(device: Device, fullScan = false) { // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); - const { services: discovered, suggestedHostname } = await scanDeviceServices( + const { services: discovered, suggestedHostname, toolsUsed } = await scanDeviceServices( device, fullScan ? fullPortRange() : undefined, servicesBeforeScan.map((s) => s.port) @@ -113,7 +113,7 @@ async function performDeviceScan(device: Device, fullScan = false) { type: "device", targetId: device.id, level: "info", - message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""}`, + message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""} [masscan: ${toolsUsed.masscan ? "✓ genutzt" : "nicht genutzt/verfügbar"}, httpx: ${toolsUsed.httpx ? "✓ genutzt" : "nicht genutzt/verfügbar"}]`, }); return { diff --git a/apps/backend/src/scanner/httpx.ts b/apps/backend/src/scanner/httpx.ts new file mode 100644 index 0000000..c82dc0d --- /dev/null +++ b/apps/backend/src/scanner/httpx.ts @@ -0,0 +1,127 @@ +import { spawn } from "node:child_process"; + +export interface HttpxResult { + port: number; + scheme: "http" | "https"; + statusCode?: number; + title?: string; + webserver?: string; +} + +let httpxConfirmedUnavailable = false; + +export function isHttpxConfirmedUnavailable(): boolean { + return httpxConfirmedUnavailable; +} + +/** + * Nutzt httpx (ProjectDiscovery, falls im Container installiert - siehe + * Dockerfile) um von einer Liste offener Ports (z. B. das Ergebnis von + * masscan) NUR die herauszufiltern, die tatsächlich HTTP oder HTTPS + * sprechen - inklusive Status-Code/Titel/Server-Header in einem einzigen, + * hochparallelen Durchlauf. Deutlich schneller und präziser als unser + * eigenes Protokoll-Raten (erst HTTP versuchen, bei Fehlschlag HTTPS + * versuchen) für JEDEN einzelnen offenen Port einzeln. + * + * Liefert null (nicht einen leeren Array!), wenn httpx fehlt oder + * fehlschlägt - der Aufrufer erkennt daran "kein httpx verfügbar" und fällt + * auf die eingebaute Methode zurück, statt fälschlich "keine Webdienste + * gefunden" anzunehmen. + */ +export function probeWithHttpx(ip: string, ports: number[]): Promise | null> { + if (httpxConfirmedUnavailable || ports.length === 0) { + return Promise.resolve(ports.length === 0 ? new Map() : null); + } + + return new Promise((resolve) => { + const child = spawn( + "httpx", + [ + "-silent", + "-json", + "-timeout", + "5", + "-retries", + "0", + "-no-color", + "-threads", + "50", + ], + { stdio: ["pipe", "pipe", "pipe"] } + ); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const finish = (result: Map | null) => { + if (settled) return; + settled = true; + resolve(result); + }; + + child.on("error", (err) => { + // ENOENT (Programm fehlt) - künftig direkt auf den Fallback gehen, + // statt bei jedem Scan erneut (dann garantiert erfolglos) zu versuchen. + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + httpxConfirmedUnavailable = true; + } + finish(null); + }); + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf-8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf-8"); + }); + + const timeout = setTimeout(() => { + child.kill(); + finish(null); + }, 30_000); + + child.on("close", (code) => { + clearTimeout(timeout); + if (code !== 0 && stdout.trim().length === 0) { + if (/not found|no such file/i.test(stderr)) httpxConfirmedUnavailable = true; + finish(null); + return; + } + + const results = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed) as { + port?: string; + scheme?: string; + status_code?: number; + title?: string; + webserver?: string; + }; + const port = Number(obj.port); + if (!Number.isFinite(port)) continue; + results.set(port, { + port, + scheme: obj.scheme === "https" ? "https" : "http", + statusCode: obj.status_code, + title: obj.title, + webserver: obj.webserver, + }); + } catch { + // einzelne kaputte Zeile ignorieren, Rest trotzdem verwerten + } + } + finish(results); + }); + + // Eingabe: "ip:port" pro Zeile, httpx erkennt Schema (http/https) je + // Eintrag selbst (Standardverhalten: erst https versuchen, sonst http). + for (const port of ports) { + child.stdin.write(`${ip}:${port}\n`); + } + child.stdin.end(); + }); +} diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index c7aea9e..74e9c2e 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -1,6 +1,7 @@ import { resolveHostname, reverseLookup } from "./dns.js"; import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js"; import { scanWithMasscan } from "./masscan.js"; +import { probeWithHttpx } from "./httpx.js"; import { probeHttp } from "./http.js"; import { detectSoftware } from "./softwareDetection.js"; import { findBestIconMatch } from "./iconDb.js"; @@ -78,6 +79,14 @@ export interface DeviceScanResult { * zurückgegeben (siehe routes/scan.ts). */ suggestedHostname: string | null; + /** + * Welche optionalen Beschleuniger-Werkzeuge bei diesem Scan tatsächlich + * genutzt wurden (siehe scanner/masscan.ts, scanner/httpx.ts) - landet im + * Scan-Log (Admin -> Logs), damit sichtbar ist, ob z. B. masscan wirklich + * gegriffen hat oder (mangels Installation/Rechten) auf die eingebaute + * Methode zurückgefallen wurde. + */ + toolsUsed: { masscan: boolean; httpx: boolean }; } /** @@ -112,9 +121,11 @@ export async function scanDeviceServices( // schneller als das eingebaute Batch-Scanning. Bei der kurzen "üblichen // Ports"-Liste (~30 Ports) macht masscan kaum einen Unterschied, das // eingebaute Scanning reicht dafür völlig. + let usedMasscan = false; let openPorts: number[]; if (candidatePorts.length > 1000) { const masscanResult = await scanWithMasscan(device.ip, "1-65535"); + usedMasscan = masscanResult !== null; openPorts = masscanResult ?? (await scanPortsInBatches(device.ip, candidatePorts)); } else { openPorts = await scanPortsInBatches(device.ip, candidatePorts); @@ -157,6 +168,22 @@ export async function scanDeviceServices( (p) => !AVM_MANAGEMENT_PORTS.has(p) || priorityPorts.includes(p) ); + // httpx (falls installiert, siehe Dockerfile) filtert aus den offenen + // Ports NUR die tatsächlich HTTP/HTTPS-sprechenden heraus - deutlich + // schneller UND präziser als unser eigenes Protokoll-Raten (erst HTTP + // versuchen, bei Fehlschlag HTTPS versuchen) für JEDEN einzelnen offenen + // Port einzeln. Ports wie SSH/SMB/NFS, die zwar offen sind aber gar kein + // HTTP sprechen, werden so gar nicht erst an die aufwendigere + // Detail-Pipeline (probeHttp, Softwareerkennung, Icon-Abgleich) + // weitergereicht. httpxResults === null heißt "httpx nicht verfügbar" - + // dann bleibt es beim bisherigen Verhalten (jeder offene Port wird + // geprüft, Protokoll wird pro Port erraten). + const httpxResults = openPorts.length > 0 ? await probeWithHttpx(device.ip, openPorts) : new Map(); + const usingHttpx = httpxResults !== null; + if (usingHttpx) { + openPorts = Array.from(httpxResults!.keys()); + } + const found: DiscoveredService[] = []; // Die HTTP-Abfrage je offenem Port läuft PARALLEL, nicht mehr @@ -165,7 +192,10 @@ export async function scanDeviceServices( // Gerät mit vielen offenen Ports (seit dem vollständigen 1-65535-Portscan // deutlich häufiger) schnell auf mehrere Minuten PRO GERÄT. async function probePort(port: number): Promise { - let isHttps = port === 443 || port === 9443 || port === 8006 || port === 8443; + const knownScheme = usingHttpx ? httpxResults!.get(port)?.scheme : undefined; + let isHttps = knownScheme + ? knownScheme === "https" + : port === 443 || port === 9443 || port === 8006 || port === 8443; let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`; let probe = await probeHttp(baseUrl); @@ -175,8 +205,10 @@ export async function scanDeviceServices( // bloß ein 4xx/5xx. In dem Fall lohnt sich ein zweiter Versuch mit dem // jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst" // übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich - // der zusätzliche Versuch. - if (probe.status === undefined) { + // der zusätzliche Versuch. Übersprungen, wenn httpx das Schema schon + // bestätigt hat - dann wissen wir es bereits sicher, ein Fehlschlag hier + // wäre ein einmaliger Ausrutscher, kein falsches Protokoll. + if (probe.status === undefined && !knownScheme) { isHttps = !isHttps; baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`; const retryProbe = await probeHttp(baseUrl); @@ -307,5 +339,5 @@ export async function scanDeviceServices( } } - return { services: found, suggestedHostname }; + return { services: found, suggestedHostname, toolsUsed: { masscan: usedMasscan, httpx: usingHttpx } }; }