round47: httpx-Integration nach masscan (nur bestaetigte Webinterfaces werden weiterverarbeitet), Sichtbarkeit ob masscan/httpx greifen ueber Admin-Logs

This commit is contained in:
2026-07-25 18:54:23 +02:00
parent eed5089a56
commit 2e15669098
4 changed files with 188 additions and 6 deletions

View File

@@ -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<Map<number, HttpxResult> | 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<number, HttpxResult> | 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<number, HttpxResult>();
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();
});
}

View File

@@ -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<DiscoveredService | null> {
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 } };
}