"use strict"; const fs = require("node:fs/promises"); const path = require("node:path"); const { run } = require("./process-runner.cjs"); const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs"); const RECOMMENDED_GITIGNORE = `# Local configuration and secrets .env .env.* !.env.example !.env.sample # Dependencies and generated output node_modules/ dist/ build/ coverage/ # Editors and operating systems .idea/ .vscode/ .DS_Store Thumbs.db `; const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.zip binary `; const RECOMMENDED_EDITORCONFIG = `root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space indent_size = 2 [*.{bat,cmd,ps1}] end_of_line = crlf `; function sameRemote(left, right) { const a = normalizeRemoteUrl(left); const b = normalizeRemoteUrl(right); return Boolean(a && b && a.host === b.host && a.path === b.path); } function result(id, category, title, status, detail, options = {}) { return { id, category, title, status, detail, weight: options.weight || 5, fixAction: options.fixAction || null, safe: options.safe === true, confirmation: options.confirmation || null, evidence: options.evidence || null, }; } function isSensitiveTrackedPath(filePath) { const value = String(filePath || "") .replace(/\\/g, "/") .toLowerCase(); if (/\.env\.(example|sample|template)$/.test(value)) return false; return ( /(^|\/)\.env($|\.)/.test(value) || /(^|\/)(id_rsa|id_ed25519)$/.test(value) || /\.(pem|p12|pfx|key)$/.test(value) || /(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value) ); } class GitValidatorService { constructor({ git, gitea, diagnostics, store }) { this.git = git; this.gitea = gitea; this.diagnostics = diagnostics; this.store = store; } async config(root, key, { local = true } = {}) { const response = await run( "git", ["config", ...(local ? ["--local"] : []), "--get", key], { cwd: root, timeout: 10_000, allowExitCodes: [1], }, ); return response.stdout.trim(); } async trackedFiles(root) { const response = await run("git", ["ls-files", "-z"], { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024, }); return response.stdout.split("\0").filter(Boolean); } async scan(repository) { const checks = []; const defaultBranch = repository.defaultBranch || "main"; const owner = repository.owner?.login; try { const protection = await this.gitea.getBranchProtection( owner, repository.name, defaultBranch, ); checks.push( result( "default-branch-protection", "Gitea governance", "Default branch protection", protection.protected ? "pass" : "warning", protection.protected ? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.` : `${defaultBranch} accepts unprotected direct changes.`, { weight: 18, fixAction: protection.protected ? null : "protect-default-branch", safe: false, confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`, }, ), ); if (protection.protected) checks.push( result( "force-push", "Gitea governance", "Force-push protection", protection.enableForcePush ? "warning" : "pass", protection.enableForcePush ? "Force pushes remain enabled on the protected branch." : "Force pushes are blocked on the protected branch.", { weight: 8 }, ), ); } catch (error) { checks.push( result( "branch-protection-unavailable", "Gitea governance", "Branch protection could not be verified", "warning", error.message, { weight: 18 }, ), ); } if (!repository.localPath) { checks.push( result( "local-link", "Local repository", "Local working tree", "warning", "Link or clone this repository to validate files and local Git configuration.", { weight: 35 }, ), ); return this.finalize(repository, checks, null); } const root = await this.git.ensureRepository(repository.localPath); const status = await this.git.status(root); const tracked = await this.trackedFiles(root); const lowerFiles = tracked.map((file) => file.toLowerCase()); const desiredRemote = repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl; checks.push( result( "origin", "Repository identity", "Origin matches Gitea", sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error", sameRemote(status.remoteUrl, desiredRemote) ? status.remoteUrl : `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`, { weight: 15, fixAction: sameRemote(status.remoteUrl, desiredRemote) ? null : "align-origin", safe: true, }, ), ); checks.push( result( "upstream", "Branch hygiene", "Current branch has an upstream", status.branch?.upstream ? "pass" : "warning", status.branch?.upstream ? `${status.branch.head} tracks ${status.branch.upstream}.` : `${status.branch?.head || "The current branch"} is not published or tracked.`, { weight: 8 }, ), ); checks.push( result( "working-tree", "Branch hygiene", "Working tree is intentional", status.clean ? "pass" : "warning", status.clean ? "No uncommitted changes." : `${status.counts.changed} changed file(s) require review, commit or stash.`, { weight: 5 }, ), ); const [userName, userEmail, fetchPrune, pullFf, autoStash] = await Promise.all([ this.config(root, "user.name", { local: false }), this.config(root, "user.email", { local: false }), this.config(root, "fetch.prune"), this.config(root, "pull.ff"), this.config(root, "rebase.autoStash"), ]); checks.push( result( "identity", "Commit integrity", "Repository author identity", userName && userEmail ? "pass" : "warning", userName && userEmail ? `${userName} <${userEmail}>` : "The effective Git user.name or user.email is missing.", { weight: 7 }, ), ); const safetyReady = fetchPrune === "true" && pullFf === "only" && autoStash === "true"; checks.push( result( "local-safety", "Local configuration", "Safe synchronization defaults", safetyReady ? "pass" : "warning", safetyReady ? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled." : "Recommended repository-local fetch, pull and autostash safeguards are incomplete.", { weight: 10, fixAction: safetyReady ? null : "configure-local-safety", safe: true, }, ), ); const hasReadme = lowerFiles.some((file) => /(^|\/)readme(\.[^/]+)?$/.test(file), ); checks.push( result( "readme", "Repository documentation", "README is versioned", hasReadme ? "pass" : "warning", hasReadme ? "Repository purpose and usage can be documented at the source." : "No tracked README was found.", { weight: 7 }, ), ); const hasGitignore = lowerFiles.includes(".gitignore"); checks.push( result( "gitignore", "Repository hygiene", ".gitignore is versioned", hasGitignore ? "pass" : "warning", hasGitignore ? "Generated and local-only files can be excluded centrally." : "No tracked .gitignore was found.", { weight: 8, fixAction: hasGitignore ? null : "add-gitignore", safe: false, confirmation: "Create a recommended .gitignore in the working tree? It will remain uncommitted for review.", }, ), ); for (const [id, title, filename, action] of [ ["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"], ["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"], ]) { const present = lowerFiles.includes(filename); checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning", present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, { weight: 5, fixAction: present ? null : action, safe: false, confirmation: `Create a recommended ${filename} in the working tree for review?`, })); } const packageManagers = [ { manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] }, { manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] }, { manifests: ["composer.json"], locks: ["composer.lock"] }, { manifests: ["gemfile"], locks: ["gemfile.lock"] }, ]; const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name))); if (lockCheck) { const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name)); checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning", lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 })); } const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file)); checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning", hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 })); const sensitive = tracked.filter(isSensitiveTrackedPath); checks.push( result( "tracked-secrets", "Security", "No secret-shaped files are tracked", sensitive.length ? "error" : "pass", sensitive.length ? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.` : "No tracked environment, private-key or credential filenames were detected.", { weight: 22 }, ), ); const large = []; const candidates = tracked.slice(0, 5000); for ( let index = 0; index < candidates.length && large.length < 12; index += 64 ) { const batch = candidates.slice(index, index + 64); const stats = await Promise.all( batch.map(async (file) => ({ file, stat: await fs.stat(path.join(root, file)).catch(() => null), })), ); for (const item of stats) { if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024) large.push({ file: item.file, size: item.stat.size }); if (large.length >= 12) break; } } checks.push( result( "large-files", "Repository performance", "No oversized tracked files", large.length ? "warning" : "pass", large.length ? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.` : "No tracked files above 10 MB were found.", { weight: 7 }, ), ); await this.addAssuranceChecks(root, tracked, lowerFiles, checks); return this.finalize(repository, checks, status); } async addAssuranceChecks(root, tracked, lowerFiles, checks) { const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file))); const fileCheck = (id, category, title, patterns, detail, weight = 5) => { const present = has(...patterns); checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight })); }; fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8); fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6); fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5); fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7); fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4); fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3); fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4); fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7); fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6); const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file)); checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) })); const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file)); checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) })); const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file)); const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n"); const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref)); checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) })); const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText); checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 })); const [commitSignature, tagSignature, recentSubjects] = await Promise.all([ run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"), run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""), run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []), ]); checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 })); checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 })); const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject)); checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 })); const releaseFiles = { "release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/, "release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/, "release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/, }; for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4); checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 })); } async finalize(repository, checks, status) { const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] }; const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions); const report = this.summarize(repository, governedChecks); report.policy = policy; report.commitSha = status?.head || status?.branch?.oid || null; report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => { const categoryChecks = governedChecks.filter((check) => check.category === category); return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)]; })); report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking); report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id); report.trend = buildTrend(repositoryState.trends.at(-1), report); if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend); return report; } async setPolicy(repository, policyInput) { const policy = normalizePolicy(policyInput); if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable."); await this.store.setGitValidatorPolicy(repository.fullName, policy); return policy; } async suppress(repository, input) { const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } }; const suppression = validateSuppression(input, normalizePolicy(state.policy)); await this.store.addGitValidatorSuppression(repository.fullName, suppression); return suppression; } export(report, format) { return exportReport(report, format); } async previewRepair(repository, check) { if (!check?.fixAction) throw new Error("This validator check has no repair action."); const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null; const fileDefinitions = { "add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE], "add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES], "add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG], }; if (fileDefinitions[check.fixAction]) { const [name, content] = fileDefinitions[check.fixAction]; if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`); return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false }; } if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false }; if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false }; if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true }; throw new Error("Unsupported Git Validator repair action."); } async resolveRepairCheck(repository, candidate) { const checkId = String(candidate?.id || candidate?.checkId || "").trim(); if (!checkId) throw new Error("A current Git Validator check ID is required."); const report = await this.scan(repository); const current = report.checks.find((check) => check.id === checkId); if (!current?.fixAction) throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing."); if (candidate?.fixAction && candidate.fixAction !== current.fixAction) throw new Error("The Git Validator repair request is stale. Scan again before repairing."); return current; } summarize(repository, checks) { const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0); const earned = checks.reduce( (sum, check) => sum + (check.status === "pass" || check.suppressed ? check.weight : check.status === "warning" ? check.weight * 0.45 : 0), 0, ); const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0; return { repository: repository.fullName, checkedAt: new Date().toISOString(), score, grade: score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 55 ? "Needs attention" : "High risk", checks, summary: { passed: checks.filter((check) => check.status === "pass").length, warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length, errors: checks.filter((check) => check.status === "error" && !check.suppressed).length, suppressed: checks.filter((check) => check.suppressed).length, repairable: checks.filter((check) => check.fixAction).length, }, }; } async repair(repository, check) { if (!check?.fixAction) throw new Error("This validator check has no repair action."); const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null; if (check.fixAction === "align-origin") { return this.git.setRemoteUrl( root, repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl, ); } if (check.fixAction === "configure-local-safety") { for (const [key, value] of [ ["fetch.prune", "true"], ["pull.ff", "only"], ["rebase.autoStash", "true"], ]) await run("git", ["config", "--local", key, value], { cwd: root, timeout: 10_000, }); return { configured: true }; } if (check.fixAction === "add-gitignore") { const target = path.join(root, ".gitignore"); const exists = await fs.stat(target).catch(() => null); if (exists) throw new Error(".gitignore already exists; rescan before repairing."); await fs.writeFile(target, RECOMMENDED_GITIGNORE, { encoding: "utf8", flag: "wx", }); return { created: ".gitignore" }; } if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) { const definition = check.fixAction === "add-gitattributes" ? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES } : { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG }; const target = path.join(root, definition.name); if (await fs.stat(target).catch(() => null)) throw new Error(`${definition.name} already exists; rescan before repairing.`); await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" }); return { created: definition.name }; } if (check.fixAction === "protect-default-branch") { return this.gitea.createBranchProtection( repository.owner.login, repository.name, repository.defaultBranch || "main", ); } throw new Error("Unsupported Git Validator repair action."); } } module.exports = { GitValidatorService, RECOMMENDED_GITIGNORE, RECOMMENDED_GITATTRIBUTES, RECOMMENDED_EDITORCONFIG, sameRemote, isSensitiveTrackedPath, };