334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { performance } = require("perf_hooks");
|
|
const { db } = require("./db");
|
|
const { dependencyIssues } = require("./dependency-manager");
|
|
const { createLogger, listLogs } = require("./logger");
|
|
const { getPlugins, scanPluginDirectories } = require("./plugins");
|
|
const { readRecoveryMarker } = require("./recovery-mode");
|
|
const { getSetting, setSetting } = require("./settings");
|
|
const {
|
|
isLoopbackAddress,
|
|
isTrustedProxyAddress,
|
|
requestProtocol
|
|
} = require("./proxy-security");
|
|
const { readUpdateState } = require("./update-repository");
|
|
|
|
const repoRoot = path.join(__dirname, "..", "..");
|
|
const packageJson = require(path.join(repoRoot, "package.json"));
|
|
const TOKEN_PREFIX = "lumi_diag_";
|
|
const MAX_REQUESTS_PER_MINUTE = 20;
|
|
const requestWindows = new Map();
|
|
const diagnosticsLog = createLogger("core:diagnostics", { category: "security" });
|
|
const CHECKS = Object.freeze({
|
|
system_health: "Runtime, database, dependency, disk-space, and recovery health.",
|
|
update_state: "Latest local update state, recovery marker, and snapshot summary.",
|
|
plugins: "Installed plugin manifests and registry versions without plugin data.",
|
|
recent_errors: "Recent warning/error records with secrets and local paths redacted.",
|
|
benchmark: "Bounded read-only database, plugin-scan, and serialization timing."
|
|
});
|
|
|
|
function diagnosticsAccessStatus() {
|
|
return {
|
|
enabled: getSetting("production_diagnostics_enabled", false) === true,
|
|
configured: Boolean(getSetting("production_diagnostics_key_hash", "")),
|
|
key_prefix: String(getSetting("production_diagnostics_key_prefix", "") || ""),
|
|
created_at: getSetting("production_diagnostics_key_created_at", null),
|
|
checks: Object.entries(CHECKS).map(([id, description]) => ({ id, description }))
|
|
};
|
|
}
|
|
|
|
function issueDiagnosticsAccessKey() {
|
|
const key = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`;
|
|
setSetting("production_diagnostics_key_hash", hashAccessKey(key));
|
|
setSetting("production_diagnostics_key_prefix", `${key.slice(0, TOKEN_PREFIX.length + 8)}…`);
|
|
setSetting("production_diagnostics_key_created_at", new Date().toISOString());
|
|
setSetting("production_diagnostics_enabled", true);
|
|
diagnosticsLog.warn("Production diagnostics access key rotated", { key_prefix: `${key.slice(0, TOKEN_PREFIX.length + 8)}…` }, { event: "access_rotated" });
|
|
return key;
|
|
}
|
|
|
|
function revokeDiagnosticsAccess() {
|
|
setSetting("production_diagnostics_enabled", false);
|
|
setSetting("production_diagnostics_key_hash", "");
|
|
setSetting("production_diagnostics_key_prefix", "");
|
|
setSetting("production_diagnostics_key_created_at", null);
|
|
requestWindows.clear();
|
|
diagnosticsLog.warn("Production diagnostics access revoked", null, { event: "access_revoked" });
|
|
}
|
|
|
|
function authenticateDiagnosticsRequest(req, now = Date.now()) {
|
|
if (!diagnosticsAccessStatus().enabled) return { allowed: false, status: 404, reason: "disabled" };
|
|
if (!isSecureDiagnosticRequest(req)) return { allowed: false, status: 404, reason: "secure_transport_required" };
|
|
const authorization = String(req.get?.("authorization") || "");
|
|
const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
|
|
const candidate = match?.[1] || "";
|
|
const storedHash = String(getSetting("production_diagnostics_key_hash", "") || "");
|
|
if (!verifyAccessKey(candidate, storedHash)) return { allowed: false, status: 404, reason: "invalid_key" };
|
|
const fingerprint = hashAccessKey(candidate).slice(0, 20);
|
|
const rate = consumeRateLimit(fingerprint, now);
|
|
if (!rate.allowed) return { allowed: false, status: 429, reason: "rate_limited", retry_after_seconds: rate.retry_after_seconds };
|
|
return { allowed: true, fingerprint, remaining: rate.remaining };
|
|
}
|
|
|
|
function runDiagnosticCheck(checkId) {
|
|
const check = String(checkId || "").trim();
|
|
if (!Object.hasOwn(CHECKS, check)) throw new Error("Unknown diagnostic check.");
|
|
const started = performance.now();
|
|
let result;
|
|
if (check === "system_health") result = systemHealth();
|
|
else if (check === "update_state") result = updateState();
|
|
else if (check === "plugins") result = pluginInventory();
|
|
else if (check === "recent_errors") result = recentErrors();
|
|
else result = boundedBenchmark();
|
|
return redactDiagnosticValue({
|
|
schema_version: 1,
|
|
check,
|
|
generated_at: new Date().toISOString(),
|
|
duration_ms: round(performance.now() - started),
|
|
result
|
|
});
|
|
}
|
|
|
|
function auditDiagnosticRequest(values = {}) {
|
|
diagnosticsLog.log(values.ok === false ? "warn" : "info", "Production diagnostics request", {
|
|
request_id: values.request_id,
|
|
check: values.check,
|
|
ok: values.ok !== false,
|
|
key_fingerprint: values.fingerprint || null,
|
|
reason: values.reason || null,
|
|
duration_ms: values.duration_ms || null
|
|
}, { event: "diagnostic_request", requestId: values.request_id });
|
|
}
|
|
|
|
function recentDiagnosticAudit(limit = 30) {
|
|
return listLogs({ limit: 250 })
|
|
.filter((entry) => ["Production diagnostics request", "Production diagnostics access key rotated", "Production diagnostics access revoked"].includes(entry.message))
|
|
.slice(0, Math.max(1, Math.min(100, Number(limit) || 30)))
|
|
.map((entry) => redactDiagnosticValue(entry));
|
|
}
|
|
|
|
function systemHealth() {
|
|
let database = { ok: false };
|
|
try {
|
|
database = { ok: db.prepare("SELECT 1 AS ok").get()?.ok === 1 };
|
|
} catch (error) {
|
|
database = { ok: false, error: error.message };
|
|
}
|
|
let disk = null;
|
|
try {
|
|
if (typeof fs.statfsSync !== "function") throw new Error("Disk statistics are unavailable on this Node.js version.");
|
|
const stats = fs.statfsSync(repoRoot);
|
|
disk = {
|
|
available_bytes: Number(stats.bavail) * Number(stats.bsize),
|
|
total_bytes: Number(stats.blocks) * Number(stats.bsize)
|
|
};
|
|
} catch {
|
|
disk = { available: false };
|
|
}
|
|
const dependencies = dependencyIssues(repoRoot);
|
|
return {
|
|
core_version: packageJson.version,
|
|
node_version: process.version,
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
uptime_seconds: Math.floor(process.uptime()),
|
|
memory: process.memoryUsage(),
|
|
database,
|
|
disk,
|
|
dependencies: {
|
|
ready: dependencies.filter((item) => !item.optional).length === 0,
|
|
issues: dependencies
|
|
},
|
|
recovery: summarizeRecovery(readRecoveryMarker())
|
|
};
|
|
}
|
|
|
|
function updateState() {
|
|
const state = readUpdateState();
|
|
const index = readJson(path.join(repoRoot, "data", "snapshots", "index.json"), []);
|
|
const snapshots = Array.isArray(index) ? index : [];
|
|
return {
|
|
core_version: packageJson.version,
|
|
last_update: {
|
|
status: state.last_update_status || null,
|
|
target_kind: state.last_target_kind || null,
|
|
target_id: state.last_target_id || null,
|
|
target_version: state.last_target_version || null,
|
|
stage: state.last_update_stage || null,
|
|
error: state.last_error || null,
|
|
updated_at: state.last_update_at || state.updated_at || null,
|
|
source_ref: state.branch || null,
|
|
commit: state.commit || null
|
|
},
|
|
recovery: summarizeRecovery(readRecoveryMarker()),
|
|
snapshots: {
|
|
available: snapshots.filter((entry) => entry.status === "available").length,
|
|
latest: snapshots
|
|
.filter((entry) => entry.status === "available")
|
|
.sort((left, right) => Number(right.createdAt) - Number(left.createdAt))
|
|
.slice(0, 10)
|
|
.map((entry) => ({
|
|
type: entry.type,
|
|
plugin_id: entry.pluginId || null,
|
|
from_version: entry.from_version || null,
|
|
to_version: entry.to_version || null,
|
|
created_at: entry.createdAt || null,
|
|
storage_bytes: entry.storage_bytes || null
|
|
}))
|
|
}
|
|
};
|
|
}
|
|
|
|
function pluginInventory() {
|
|
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
|
|
return scanPluginDirectories().map((plugin) => ({
|
|
id: plugin.id,
|
|
name: plugin.name,
|
|
manifest_version: plugin.version,
|
|
registry_version: registry.get(plugin.id)?.version || null,
|
|
enabled: Boolean(registry.get(plugin.id)?.enabled),
|
|
version_matches_registry: !registry.has(plugin.id) || registry.get(plugin.id)?.version === plugin.version
|
|
})).sort((left, right) => left.id.localeCompare(right.id));
|
|
}
|
|
|
|
function recentErrors() {
|
|
return listLogs({ limit: 50, levels: ["warn", "error"] }).map((entry) => ({
|
|
level: entry.level,
|
|
message: entry.message,
|
|
details: entry.details,
|
|
created_at: entry.created_at
|
|
}));
|
|
}
|
|
|
|
function boundedBenchmark() {
|
|
const databaseStarted = performance.now();
|
|
for (let index = 0; index < 100; index += 1) db.prepare("SELECT 1 AS ok").get();
|
|
const databaseMs = performance.now() - databaseStarted;
|
|
const pluginStarted = performance.now();
|
|
let pluginCount = 0;
|
|
for (let index = 0; index < 5; index += 1) pluginCount = scanPluginDirectories().length;
|
|
const pluginMs = performance.now() - pluginStarted;
|
|
const serializationStarted = performance.now();
|
|
const sample = Array.from({ length: 250 }, (_, index) => ({ index, status: "ok", version: packageJson.version }));
|
|
for (let index = 0; index < 20; index += 1) JSON.stringify(sample);
|
|
const serializationMs = performance.now() - serializationStarted;
|
|
return {
|
|
fixed_workload: true,
|
|
database_select_100_ms: round(databaseMs),
|
|
plugin_scan_5_ms: round(pluginMs),
|
|
plugin_count: pluginCount,
|
|
json_serialize_20_ms: round(serializationMs)
|
|
};
|
|
}
|
|
|
|
function redactDiagnosticValue(value, key = "", depth = 0) {
|
|
if (depth > 8) return "[truncated]";
|
|
if (/(?:token|secret|password|passwd|authorization|cookie|session|credential|private.?key|api.?key)/i.test(key)) return "[redacted]";
|
|
if (typeof value === "string") return redactString(value).slice(0, 4000);
|
|
if (typeof value === "bigint") return Number(value);
|
|
if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactDiagnosticValue(item, key, depth + 1));
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).slice(0, 100).map(([childKey, child]) => [childKey, redactDiagnosticValue(child, childKey, depth + 1)]));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function redactString(value) {
|
|
return String(value)
|
|
.replace(/\blumi_diag_[A-Za-z0-9_-]+\b/g, "[diagnostics key]")
|
|
.replace(/(https?:\/\/)[^/@\s:]+:[^/@\s]+@/gi, "$1[credentials]@")
|
|
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[signed token]")
|
|
.replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]")
|
|
.replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]")
|
|
.replace(/([?&](?:token|secret|session|password|api_key|apikey|auth|code)=)[^&#\s]+/gi, "$1[redacted]")
|
|
.replace(/(?:[A-Za-z]:\\|\\\\)[^\s<>\"|?*]+/g, "[local path]")
|
|
.replace(/\/(?:home|Users|mnt|var|tmp|opt|root)\/[^\s)\]}>]+/g, "[local path]")
|
|
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
|
|
.replace(/[A-Za-z0-9+_=-]{40,}/g, (candidate) => /[A-Za-z]/.test(candidate) && /\d/.test(candidate) ? "[high-entropy value]" : candidate);
|
|
}
|
|
|
|
function hashAccessKey(value) {
|
|
return crypto.createHash("sha256").update(String(value || "")).digest("hex");
|
|
}
|
|
|
|
function verifyAccessKey(candidate, expectedHash) {
|
|
if (!String(candidate || "").startsWith(TOKEN_PREFIX) || !/^[a-f0-9]{64}$/i.test(String(expectedHash || ""))) return false;
|
|
const actual = Buffer.from(hashAccessKey(candidate), "hex");
|
|
const expected = Buffer.from(expectedHash, "hex");
|
|
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
}
|
|
|
|
function consumeRateLimit(fingerprint, now = Date.now()) {
|
|
const windowStart = now - (now % 60000);
|
|
const current = requestWindows.get(fingerprint);
|
|
const state = !current || current.window_start !== windowStart ? { window_start: windowStart, count: 0 } : current;
|
|
state.count += 1;
|
|
requestWindows.set(fingerprint, state);
|
|
if (requestWindows.size > 1000) {
|
|
for (const [key, value] of requestWindows.entries()) if (value.window_start < windowStart) requestWindows.delete(key);
|
|
}
|
|
return {
|
|
allowed: state.count <= MAX_REQUESTS_PER_MINUTE,
|
|
remaining: Math.max(0, MAX_REQUESTS_PER_MINUTE - state.count),
|
|
retry_after_seconds: Math.max(1, Math.ceil((windowStart + 60000 - now) / 1000))
|
|
};
|
|
}
|
|
|
|
function isSecureDiagnosticRequest(req) {
|
|
if (req.secure === true) return true;
|
|
const address = String(req.socket?.remoteAddress || req.ip || "");
|
|
if (isLoopbackAddress(address)) return true;
|
|
return diagnosticsRequestProtocol(req) === "https";
|
|
}
|
|
|
|
function diagnosticsRequestProtocol(req) {
|
|
return requestProtocol(req);
|
|
}
|
|
|
|
function isTrustedPrivateProxyAddress(value) {
|
|
return isTrustedProxyAddress(value);
|
|
}
|
|
|
|
function summarizeRecovery(marker) {
|
|
if (!marker) return { active: false };
|
|
return {
|
|
active: ["pending", "applying", "verifying", "failed", "stale"].includes(marker.status),
|
|
status: marker.status || null,
|
|
target_kind: marker.target_kind || null,
|
|
target_id: marker.target_id || null,
|
|
from_version: marker.from_version || null,
|
|
to_version: marker.to_version || null,
|
|
error: marker.error || null,
|
|
updated_at: marker.updated_at || null
|
|
};
|
|
}
|
|
|
|
function readJson(filePath, fallback) {
|
|
try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return fallback; }
|
|
}
|
|
|
|
function round(value) {
|
|
return Math.round(Number(value) * 100) / 100;
|
|
}
|
|
|
|
module.exports = {
|
|
CHECKS,
|
|
MAX_REQUESTS_PER_MINUTE,
|
|
auditDiagnosticRequest,
|
|
authenticateDiagnosticsRequest,
|
|
consumeRateLimit,
|
|
diagnosticsAccessStatus,
|
|
diagnosticsRequestProtocol,
|
|
hashAccessKey,
|
|
issueDiagnosticsAccessKey,
|
|
isSecureDiagnosticRequest,
|
|
isTrustedPrivateProxyAddress,
|
|
recentDiagnosticAudit,
|
|
redactDiagnosticValue,
|
|
revokeDiagnosticsAccess,
|
|
runDiagnosticCheck,
|
|
verifyAccessKey
|
|
};
|