Harden scans and add LOC comparison endpoint (#1)
ci / test (push) Successful in 9s

Co-authored-by: luna <clawy@reversed.dev>
Co-committed-by: luna <clawy@reversed.dev>
This commit was merged in pull request #1.
This commit is contained in:
2026-07-21 19:33:08 +02:00
committed by Luna
parent ecc1578b92
commit 91c0ed7332
12 changed files with 276 additions and 29 deletions
+39 -1
View File
@@ -4,7 +4,7 @@ import rateLimit from "express-rate-limit";
import type { RuntimeConfig } from "./config.js";
import { HttpError } from "./errors.js";
import { asyncHandler, readOptionalString } from "./lib/http.js";
import type { CountRequest } from "./types.js";
import type { ComparisonResult, CountRequest } from "./types.js";
type AppDependencies = {
getHealth: () => Record<string, number>;
@@ -62,6 +62,17 @@ export function createApp(config: RuntimeConfig, deps: AppDependencies) {
res.json(result);
}));
app.get("/loc/diff", asyncHandler(async (req, res) => {
const base = readRequiredString(req.query.base, "base");
const head = readRequiredString(req.query.head, "head");
const shared = readCountRequest(req);
const [baseResult, headResult] = await Promise.all([
deps.count({ ...shared, ref: base }),
deps.count({ ...shared, ref: head })
]);
res.json(createComparison(baseResult, headResult));
}));
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
const message = error instanceof Error ? error.message : "Unknown error";
const status = error instanceof HttpError ? error.statusCode : message.startsWith("Missing ") ? 400 : 500;
@@ -71,6 +82,25 @@ export function createApp(config: RuntimeConfig, deps: AppDependencies) {
return app;
}
function createComparison(base: import("./types.js").CountResult, head: import("./types.js").CountResult): ComparisonResult {
const languages = new Map(base.languages.map((language) => [language.language, { ...language, files: -language.files, lines: -language.lines }]));
for (const language of head.languages) {
const delta = languages.get(language.language) ?? { language: language.language, files: 0, lines: 0 };
delta.files += language.files;
delta.lines += language.lines;
languages.set(language.language, delta);
}
return {
base,
head,
delta: {
fileCount: head.fileCount - base.fileCount,
lineCount: head.lineCount - base.lineCount,
languages: Array.from(languages.values()).filter((language) => language.files !== 0 || language.lines !== 0).sort((a, b) => b.lines - a.lines || a.language.localeCompare(b.language))
}
};
}
function readCountRequest(req: Request): CountRequest {
const repo = typeof req.query.repo === "string" ? req.query.repo.trim() : "";
if (!repo) {
@@ -83,3 +113,11 @@ function readCountRequest(req: Request): CountRequest {
sshKey: readOptionalString(req.query.ssh_key)
};
}
function readRequiredString(value: unknown, name: string): string {
const result = readOptionalString(value);
if (!result) {
throw new HttpError(400, `Missing ${name} query parameter`);
}
return result;
}
+13
View File
@@ -2,6 +2,7 @@ import path from "node:path";
import { tmpdir } from "node:os";
export type RuntimeConfig = {
allowedGitHosts: string[];
apiKey: string;
cacheSweepIntervalMs: number;
cacheTtlMs: number;
@@ -9,6 +10,9 @@ export type RuntimeConfig = {
defaultSshKeyName: string;
generateSshKeyIfMissing: boolean;
maxConcurrentScans: number;
maxFilesPerScan: number;
maxFileSizeBytes: number;
maxScanBytes: number;
port: number;
rateLimitMax: number;
rateLimitWindowMs: number;
@@ -19,6 +23,7 @@ export type RuntimeConfig = {
export function loadConfig(): RuntimeConfig {
return {
allowedGitHosts: readEnvList("ALLOWED_GIT_HOSTS"),
apiKey: readEnvString("API_KEY", ""),
cacheSweepIntervalMs: readEnvMinutes("CACHE_SWEEP_INTERVAL_MINUTES", 5),
cacheTtlMs: readEnvMinutes("CACHE_TTL_MINUTES", 5),
@@ -26,6 +31,9 @@ export function loadConfig(): RuntimeConfig {
defaultSshKeyName: readEnvString("DEFAULT_SSH_KEY_NAME", "loc_via_git_ed25519"),
generateSshKeyIfMissing: readEnvBoolean("GENERATE_SSH_KEY_IF_MISSING", false),
maxConcurrentScans: readEnvNumber("MAX_CONCURRENT_SCANS", 4),
maxFilesPerScan: readEnvNumber("MAX_FILES_PER_SCAN", 20_000),
maxFileSizeBytes: readEnvNumber("MAX_FILE_SIZE_MB", 5) * 1024 * 1024,
maxScanBytes: readEnvNumber("MAX_SCAN_SIZE_MB", 100) * 1024 * 1024,
port: readEnvNumber("PORT", 3000),
rateLimitMax: readEnvNumber("RATE_LIMIT_MAX", 30),
rateLimitWindowMs: readEnvMinutes("RATE_LIMIT_WINDOW_MINUTES", 5),
@@ -35,6 +43,11 @@ export function loadConfig(): RuntimeConfig {
};
}
function readEnvList(name: string): string[] {
const raw = process.env[name]?.trim() ?? "";
return raw.split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
}
function readEnvMinutes(name: string, fallback: number): number {
return readEnvNumber(name, fallback) * 60_000;
}
+12 -1
View File
@@ -51,7 +51,18 @@ const ignoredFileNames = new Set([
"yarn.lock"
]);
const ignoredDirectoryNames = new Set([
".angular", ".cache", ".next", ".nuxt", ".output", ".parcel-cache", ".svelte-kit",
".terraform", ".venv", "bower_components", "build", "coverage", "dist", "node_modules",
"out", "pods", "target", "vendor"
]);
export function shouldIgnoreCountFile(filePath: string): boolean {
const fileName = path.basename(filePath).toLowerCase();
return ignoredFileNames.has(fileName);
const directories = path.dirname(filePath).toLowerCase().split(path.sep);
return ignoredFileNames.has(fileName)
|| directories.some((directory) => ignoredDirectoryNames.has(directory))
|| /\.(generated|designer|g|pb)\.[^.]+$/.test(fileName)
|| /\.min\.(css|js|mjs|cjs)$/.test(fileName)
|| fileName.endsWith(".snap");
}
+42 -1
View File
@@ -1,6 +1,44 @@
import path from "node:path";
const extensionToLanguage: Record<string, string> = {
".astro": "Astro",
".bash": "Shell",
".bat": "Batchfile",
".cjs": "JavaScript",
".clj": "Clojure",
".cmake": "CMake",
".coffee": "CoffeeScript",
".dart": "Dart",
".dockerfile": "Dockerfile",
".ex": "Elixir",
".exs": "Elixir",
".fish": "Fish",
".fs": "F#",
".fsx": "F#",
".gd": "GDScript",
".graphql": "GraphQL",
".groovy": "Groovy",
".hs": "Haskell",
".ini": "INI",
".ipynb": "Jupyter Notebook",
".jsx": "JavaScript React",
".less": "Less",
".lock": "Lockfile",
".m": "Objective-C",
".nim": "Nim",
".pl": "Perl",
".proto": "Protocol Buffers",
".ps1": "PowerShell",
".r": "R",
".rkt": "Racket",
".scala": "Scala",
".sol": "Solidity",
".styl": "Stylus",
".tf": "Terraform",
".v": "Verilog",
".vala": "Vala",
".wasm": "WebAssembly",
".zig": "Zig",
".c": "C",
".cc": "C++",
".cpp": "C++",
@@ -13,7 +51,6 @@ const extensionToLanguage: Record<string, string> = {
".java": "Java",
".js": "JavaScript",
".json": "JSON",
".jsx": "JavaScript React",
".kt": "Kotlin",
".lua": "Lua",
".md": "Markdown",
@@ -46,6 +83,10 @@ export function detectLanguage(filePath: string): string {
return "Dockerfile";
}
if (["makefile", "gnumakefile"].includes(fileName)) {
return "Makefile";
}
if (fileName.endsWith(".d.ts")) {
return "TypeScript";
}
+94 -23
View File
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { createReadStream } from "node:fs";
import { mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
import { mkdir, mkdtemp, open, readdir, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { RuntimeConfig } from "../config.js";
@@ -51,7 +51,8 @@ export class RepoCounterService {
}
async count(request: CountRequest): Promise<CountResult> {
const cacheKey = JSON.stringify(request);
const validatedRequest = this.validateRequest(request);
const cacheKey = JSON.stringify(validatedRequest);
this.clearExpiredCache();
const cached = this.cache.get(cacheKey);
@@ -66,7 +67,7 @@ export class RepoCounterService {
}
const task = this.semaphore.use(async () => {
const value = await this.cloneAndCount(request);
const value = await this.cloneAndCount(validatedRequest);
this.cache.set(cacheKey, {
value,
expiresAt: Date.now() + this.config.cacheTtlMs
@@ -90,10 +91,14 @@ export class RepoCounterService {
try {
await this.runGitClone(request.repo, repoDir, request.ref, request.sshKey);
const stats = await countDirectory(repoDir);
const [stats, commit] = await Promise.all([
countDirectory(repoDir, this.config),
runCommand("git", ["-C", repoDir, "rev-parse", "HEAD"], { ...process.env }, this.config.cloneTimeoutMs, true)
]);
return {
repo: request.repo,
commit: commit.trim(),
repo: redactRepoUrl(request.repo),
ref: request.ref,
sshKey: request.sshKey,
lineCount: stats.lineCount,
@@ -108,14 +113,16 @@ export class RepoCounterService {
}
private async runGitClone(repo: string, targetDir: string, ref: string | null, sshKey: string | null): Promise<void> {
const env = { ...process.env };
const keyPath = await this.keyManager.resolvePrivateKeyPath(sshKey);
const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
const keyPath = sshKey || isSshRepository(repo)
? await this.keyManager.resolvePrivateKeyPath(sshKey)
: null;
if (keyPath) {
env.GIT_SSH_COMMAND = `ssh -i "${keyPath}" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`;
}
await runCommand("git", ["clone", "--depth", "1", "--single-branch", repo, targetDir], env, this.config.cloneTimeoutMs);
await runCommand("git", ["-c", "protocol.file.allow=never", "clone", "--depth", "1", "--single-branch", repo, targetDir], env, this.config.cloneTimeoutMs);
if (ref) {
await runCommand("git", ["-C", targetDir, "fetch", "--depth", "1", "origin", ref], env, this.config.cloneTimeoutMs);
@@ -123,6 +130,14 @@ export class RepoCounterService {
}
}
private validateRequest(request: CountRequest): CountRequest {
const host = getGitHost(request.repo);
if (this.config.allowedGitHosts.length > 0 && !this.config.allowedGitHosts.includes("*") && !this.config.allowedGitHosts.includes(host)) {
throw new HttpError(403, "Git host is not allowed");
}
return request;
}
private async cleanupStaleTempDirs(): Promise<void> {
const entries = await readdir(this.config.tempRoot, { withFileTypes: true }).catch(() => []);
const staleBefore = Date.now() - this.config.cloneTimeoutMs * 2;
@@ -152,13 +167,14 @@ export class RepoCounterService {
}
}
async function countDirectory(rootDir: string): Promise<{
async function countDirectory(rootDir: string, config: RuntimeConfig): Promise<{
fileCount: number;
languages: LanguageStat[];
lineCount: number;
}> {
let fileCount = 0;
let lineCount = 0;
let scannedBytes = 0;
const languages = new Map<string, LanguageStat>();
const stack = [rootDir];
@@ -180,11 +196,28 @@ async function countDirectory(rootDir: string): Promise<{
continue;
}
if (!entry.isFile() || !(await isTextFile(fullPath))) {
if (!entry.isFile()) {
continue;
}
if (shouldIgnoreCountFile(fullPath)) {
const relativePath = path.relative(rootDir, fullPath);
if (shouldIgnoreCountFile(relativePath)) {
continue;
}
const fileStats = await stat(fullPath);
if (fileStats.size > config.maxFileSizeBytes) {
throw new HttpError(413, `File exceeds ${Math.round(config.maxFileSizeBytes / 1024 / 1024)} MB limit`);
}
if (++fileCount > config.maxFilesPerScan) {
throw new HttpError(413, `Repository exceeds ${config.maxFilesPerScan} file limit`);
}
scannedBytes += fileStats.size;
if (scannedBytes > config.maxScanBytes) {
throw new HttpError(413, `Repository exceeds ${Math.round(config.maxScanBytes / 1024 / 1024)} MB scan limit`);
}
if (!(await isTextFile(fullPath))) {
fileCount -= 1;
continue;
}
@@ -196,7 +229,6 @@ async function countDirectory(rootDir: string): Promise<{
current.lines += lines;
languages.set(language, current);
fileCount += 1;
lineCount += lines;
}
}
@@ -209,8 +241,14 @@ async function countDirectory(rootDir: string): Promise<{
}
async function isTextFile(filePath: string): Promise<boolean> {
const buffer = await readFile(filePath);
return !buffer.subarray(0, 4096).includes(0);
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(4096);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
return !buffer.subarray(0, bytesRead).includes(0);
} finally {
await handle.close();
}
}
async function countFileLines(filePath: string): Promise<number> {
@@ -246,11 +284,12 @@ async function runCommand(
command: string,
args: string[],
env: NodeJS.ProcessEnv,
timeoutMs: number
): Promise<void> {
await new Promise<void>((resolve, reject) => {
timeoutMs: number,
captureStdout = false
): Promise<string> {
return new Promise<string>((resolve, reject) => {
const child = spawn(command, args, { env });
let stderr = "";
let stdout = "";
let timedOut = false;
const timeout = setTimeout(() => {
@@ -258,9 +297,12 @@ async function runCommand(
child.kill("SIGTERM");
}, timeoutMs);
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
if (captureStdout) {
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
}
child.stderr.resume();
child.on("error", (error) => {
clearTimeout(timeout);
@@ -271,7 +313,7 @@ async function runCommand(
clearTimeout(timeout);
if (code === 0) {
resolve();
resolve(stdout);
return;
}
@@ -280,7 +322,36 @@ async function runCommand(
return;
}
reject(new HttpError(400, stderr.trim() || `${command} failed with code ${code}`));
reject(new HttpError(400, `${command} failed`));
});
});
}
function getGitHost(repo: string): string {
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(repo)) {
let url: URL;
try {
url = new URL(repo);
} catch {
throw new HttpError(400, "Invalid repository URL");
}
if (!["https:", "ssh:"].includes(url.protocol) || url.username && url.protocol === "https:") {
throw new HttpError(400, "Only credential-free HTTPS and SSH repository URLs are allowed");
}
return url.hostname.toLowerCase();
}
const scpMatch = repo.match(/^[a-zA-Z0-9._-]+@([a-zA-Z0-9.-]+):[^\s]+$/);
if (!scpMatch) {
throw new HttpError(400, "Invalid repository URL");
}
return scpMatch[1].toLowerCase();
}
function redactRepoUrl(repo: string): string {
return repo.replace(/(https?:\/\/)[^/@\s]+@/i, "$1***@");
}
function isSshRepository(repo: string): boolean {
return repo.startsWith("ssh://") || /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:[^\s]+$/.test(repo);
}
+11
View File
@@ -5,6 +5,7 @@ export type LanguageStat = {
};
export type CountResult = {
commit: string;
repo: string;
ref: string | null;
sshKey: string | null;
@@ -16,6 +17,16 @@ export type CountResult = {
durationMs: number;
};
export type ComparisonResult = {
base: CountResult;
delta: {
fileCount: number;
languages: LanguageStat[];
lineCount: number;
};
head: CountResult;
};
export type CountRequest = {
repo: string;
ref: string | null;