Add test and security baseline

- make quick/check/mutation from cj/security-baseline (Makefile unchanged,
  settings in baseline.mk), pre-commit, gitleaks, Semgrep rules, ESLint
  security rules, Forgejo workflow
- tests/security: generic baseline tests plus app config and role tests
  against createApp() with an in-memory database
- extract createApp() from src/server/index.ts; LB_DATABASE_PATH for tests
- fix gaps found by the tests: reject unknown fields on project create,
  answer malformed JSON with 400 instead of 500, set nosniff on the API
- npm audit fix (next 16.4.1 and others, no high/critical left)
- clear ESLint errors, safeFileName without control-character regex
- per-project ownership stays an open, documented gap (idorGap -> TODO)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-10-15 09:40:33 +02:00
parent 67476ea59b
commit a7a1595cbf
38 changed files with 5852 additions and 442 deletions

View file

@ -0,0 +1,82 @@
/**
* Routen-Inventar für Express 5.
*
* Express 5 (Paket `router` v2) speichert an gemounteten Routern den Mount-Pfad nicht
* mehr. Ein Test, der nur `app.router.stack` liest, findet deshalb die Routen unter
* `app.use("/api", apiRouter)` nicht mit vollem Pfad – und läuft grün, ohne zu prüfen.
* Darum wird `Router.prototype.use` VOR dem Import der App instrumentiert und jeder
* Mount-Pfad mitgeschrieben.
*
* Reihenfolge ist Pflicht: `installRouteRecorder()` aufrufen, DANACH die App importieren.
*/
import { createRequire } from "node:module";
import path from "node:path";
type Layer = { route?: { path: string | string[]; methods: Record<string, boolean> }; handle: unknown };
type RouterLike = { stack: Layer[] };
const mountPaths = new WeakMap<object, string[]>();
let RouterProto: Record<string, unknown> | undefined;
function loadRouterPrototype(): Record<string, unknown> {
const fromApp = createRequire(path.join(process.cwd(), "package.json"));
const expressEntry = fromApp.resolve("express");
// Dasselbe `router`-Modul, das Express selbst lädt (gleiche Datei = gleicher Cache-Eintrag).
const Router = createRequire(expressEntry)("router");
return Router.prototype;
}
export function installRouteRecorder(): void {
if (RouterProto) return;
RouterProto = loadRouterPrototype();
const originalUse = RouterProto.use as (...args: unknown[]) => unknown;
RouterProto.use = function patchedUse(this: RouterLike, ...args: unknown[]) {
const mount = typeof args[0] === "string" ? (args[0] as string) : Array.isArray(args[0]) ? null : "/";
const before = this.stack.length;
const result = originalUse.apply(this, args);
for (const layer of this.stack.slice(before)) {
const existing = mountPaths.get(layer) ?? [];
// Arrays/RegExp als Mount-Pfad werden als "?" markiert und fallen im Test auf.
mountPaths.set(layer, [...existing, mount ?? "?"]);
}
return result;
};
}
function isRouter(handle: unknown): handle is RouterLike {
return typeof handle === "function" && Array.isArray((handle as Partial<RouterLike>).stack);
}
function join(prefix: string, suffix: string): string {
const joined = `${prefix}/${suffix}`.replace(/\/+/g, "/");
return joined.length > 1 ? joined.replace(/\/$/, "") : joined;
}
export type Route = { method: string; path: string };
/** Alle Routen der App mit vollem Pfad, z. B. { method: "GET", path: "/api/projects/:id" }. */
export function collectRoutes(app: { router: RouterLike }): Route[] {
if (!RouterProto) throw new Error("installRouteRecorder() wurde nicht vor dem App-Import aufgerufen");
const routes: Route[] = [];
const walk = (router: RouterLike, prefix: string) => {
for (const layer of router.stack) {
if (layer.route) {
const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
for (const routePath of paths) {
for (const [method, enabled] of Object.entries(layer.route.methods)) {
if (enabled && method !== "_all") routes.push({ method: method.toUpperCase(), path: join(prefix, routePath) });
}
}
} else if (isRouter(layer.handle)) {
for (const mount of mountPaths.get(layer) ?? ["?"]) walk(layer.handle, join(prefix, mount));
}
}
};
walk(app.router, "/");
return routes.sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`));
}
/** Ersetzt :param und *wildcard durch einen Beispielwert. */
export function fillPath(routePath: string, value = "1"): string {
return routePath.replace(/:[A-Za-z0-9_]+/g, value).replace(/\*[A-Za-z0-9_]*/g, value);
}