generated from Dicken/dickendock
round26: Ping vor Portscan, Liste neuer Dienste, Suchmaschinen-Integration (OpenSearch), API-Scanner
This commit is contained in:
97
apps/backend/src/scanner/apiDetector.ts
Normal file
97
apps/backend/src/scanner/apiDetector.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
|
||||
export interface DetectedApi {
|
||||
path: string;
|
||||
type: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface RawProbeResult {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
bodySnippet: string;
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 8_192;
|
||||
|
||||
function fetchRaw(url: string, timeoutMs = 2500): Promise<RawProbeResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
const isHttps = url.startsWith("https://");
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.get(
|
||||
url,
|
||||
{ timeout: timeoutMs, rejectUnauthorized: false, headers: { Accept: "application/json, */*" } },
|
||||
(res) => {
|
||||
let body = "";
|
||||
let received = 0;
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (received <= MAX_BODY_BYTES) body += chunk.toString("utf-8");
|
||||
});
|
||||
res.on("end", () => {
|
||||
const contentType = res.headers["content-type"] ?? null;
|
||||
resolve({ status: res.statusCode ?? 0, contentType, bodySnippet: body });
|
||||
});
|
||||
res.on("error", () => resolve(null));
|
||||
}
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise
|
||||
* ihre API bzw. deren Dokumentation/Schema anbietet. Bewusst eine
|
||||
* kuratierte, kurze Liste statt eines vollständigen Wortlisten-Bruteforce -
|
||||
* das hier ist ein Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
||||
*/
|
||||
const CANDIDATE_PATHS: { path: string; type: string }[] = [
|
||||
{ path: "/openapi.json", type: "OpenAPI" },
|
||||
{ path: "/swagger.json", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/api-docs", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/swagger/index.html", type: "Swagger-UI" },
|
||||
{ path: "/docs", type: "API-Dokumentation" },
|
||||
{ path: "/graphql", type: "GraphQL" },
|
||||
{ path: "/api/v1", type: "REST-API" },
|
||||
{ path: "/api", type: "REST-API" },
|
||||
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
||||
];
|
||||
|
||||
function looksLikeJson(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die kuratierten Kandidaten-Pfade unter einer Basis-URL parallel und
|
||||
* liefert alle, die auf eine tatsächlich vorhandene API hindeuten: eine
|
||||
* JSON-Antwort (egal ob 200 oder z. B. 401 "unauthorized" - eine
|
||||
* JSON-Fehlermeldung zeigt trotzdem "hier läuft eine API"), oder ein
|
||||
* Content-Type, der explizit auf JSON/GraphQL hindeutet. Reine HTML-Seiten
|
||||
* (z. B. eine 404-Fehlerseite des Frontends) zählen nicht.
|
||||
*/
|
||||
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
||||
const checks = await Promise.all(
|
||||
CANDIDATE_PATHS.map(async ({ path, type }) => {
|
||||
const result = await fetchRaw(`${baseUrl}${path}`);
|
||||
if (!result || result.status === 0 || result.status === 404) return null;
|
||||
|
||||
const contentTypeIsApi =
|
||||
result.contentType?.includes("json") || result.contentType?.includes("graphql");
|
||||
const bodyIsJson = looksLikeJson(result.bodySnippet);
|
||||
|
||||
if (!contentTypeIsApi && !bodyIsJson) return null;
|
||||
|
||||
const detected: DetectedApi = { path, type, status: result.status };
|
||||
return detected;
|
||||
})
|
||||
);
|
||||
|
||||
return checks.filter((c): c is DetectedApi => c !== null);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
|
||||
import { probeHttp } from "./http.js";
|
||||
import { detectSoftware } from "./softwareDetection.js";
|
||||
import { findBestIconMatch } from "./iconDb.js";
|
||||
import { pingHost, isPingBinaryConfirmedMissing } from "./ping.js";
|
||||
|
||||
export interface ScanTarget {
|
||||
hostname: string;
|
||||
@@ -90,6 +91,18 @@ export async function scanDeviceServices(
|
||||
// gemeldeten Namen ein.
|
||||
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
||||
|
||||
// Vor dem eigentlichen Portscan erst ein einzelner Ping (ICMP) - ist das
|
||||
// Gerät gar nicht erreichbar (aus, im Standby, vom Netz getrennt), spart
|
||||
// das den kompletten Portscan (auch parallel noch ~800ms) UND alle
|
||||
// nachfolgenden DNS/HTTP-Versuche. Nur wenn KEIN Ping ankommt wird
|
||||
// übersprungen - manche Geräte blocken ICMP, antworten aber auf TCP, dafür
|
||||
// bleibt genau deswegen bewusst KEIN weiterer früher Abbruch bestehen,
|
||||
// sondern nur dieser eine zusätzliche, sehr schnelle Vorab-Check.
|
||||
const reachable = await pingHost(device.ip);
|
||||
if (!reachable && !isPingBinaryConfirmedMissing()) {
|
||||
return { services: [], suggestedHostname };
|
||||
}
|
||||
|
||||
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
||||
|
||||
// Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
|
||||
let pingBinaryConfirmedMissing = false;
|
||||
|
||||
/**
|
||||
* Prüft per System-Ping (ICMP), ob eine IP erreichbar ist. Bewusst NICHT über
|
||||
* einen Port-Connect (wie isPortOpen in ports.ts) - ein Gerät kann online
|
||||
@@ -16,8 +18,28 @@ export function pingHost(ip: string): Promise<boolean> {
|
||||
const command = isWindows ? `ping -n 1 -w 1000 ${ip}` : `ping -c 1 -W 1 ${ip}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(command, { timeout: 2000 }, (error) => {
|
||||
exec(command, { timeout: 2000 }, (error, _stdout, stderr) => {
|
||||
// Fehlt der ping-Befehl im Container, meldet die Shell das über exec
|
||||
// NICHT als Node-ENOENT, sondern als regulären Fehlschlag mit
|
||||
// Exit-Code 127 und "not found" in stderr (z. B. "/bin/sh: 1: ping:
|
||||
// not found") - das wird hier separat erkannt (siehe
|
||||
// isPingBinaryConfirmedMissing), damit ein fehlendes ping-Programm
|
||||
// nicht stillschweigend JEDEN Scan leerlaufen lässt.
|
||||
if (error && (error.code === 127 || /not found/i.test(stderr))) {
|
||||
pingBinaryConfirmedMissing = true;
|
||||
}
|
||||
resolve(!error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* true, wenn ein vorheriger pingHost()-Aufruf festgestellt hat, dass der
|
||||
* ping-Befehl im Container gar nicht existiert (z. B. iputils-ping fehlt im
|
||||
* Image). Wird von scanDeviceServices genutzt, um den Ping-Vorab-Check in
|
||||
* dem Fall zu überspringen, statt fälschlich jedes Gerät als nicht
|
||||
* erreichbar zu melden.
|
||||
*/
|
||||
export function isPingBinaryConfirmedMissing(): boolean {
|
||||
return pingBinaryConfirmedMissing;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user