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:
parent
67476ea59b
commit
a7a1595cbf
38 changed files with 5852 additions and 442 deletions
82
tests/security/route-inventory.ts
Normal file
82
tests/security/route-inventory.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue