UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

662 lines (621 loc) 21 kB
import { readFileSync } from "node:fs"; import { join } from "node:path"; import { parseGitAiNote } from "../parsers/gitAiNotes.js"; import { gitAiNotes, gitCommitDiffAnalysis, gitLogCommitsDetailed, gitRevertsAndHotfixes, } from "./envcontext.js"; import { enrichFromForge } from "./forgeEnricher.js"; import { sanitizeBomPropertyValue } from "./propertySanitizer.js"; import { safeExistsSync } from "./utils.js"; const UNAVAILABLE = "unavailable"; /** * High-confidence AI-authorship signatures for classifying an individual commit * as AI-authored. Deliberately narrower than the broad keyword bucket used by * the provenance collector: for per-commit oversight we only trust strong, * tool-specific markers (bot authors, co-authored-by trailers, git-ai notes) so * we do not misclassify ordinary commits that merely mention "ai" or "gpt". */ const BOT_SIGNATURES = [ /github-copilot\[bot\]/i, /devin-ai\[bot\]/i, /devin-ai-integration/i, /replit\[bot\]/i, /openhands/i, /noreply@anthropic\.com/i, ]; const COMMIT_SIGNATURES = [ /Co-authored-by:\s*Claude\b/i, /noreply@anthropic\.com/i, /Generated with \[?Claude Code/i, /Co-authored-by:\s*Copilot\b/i, /copilot@github\.com/i, /Generated by Devin/i, /Generated by Replit/i, /Replit-Commit-Author:/i, /Generated by Codex|codex-cli/i, /Co-authored-by:\s*(?:aider|cursor|windsurf)\b/i, ]; /** * Determines whether a parsed git-ai note actually attributes lines to an AI * agent. Note presence alone is NOT sufficient: tools such as git-ai annotate * every commit (human and AI) with per-line authorship, so we only treat a note * as an AI signal when it names an agent/model or carries AI line attribution. * * @param {Object} parsedNote Result of parseGitAiNote * @returns {boolean} True if the note attributes AI authorship */ function noteAttributesAi(parsedNote) { if (!parsedNote) { return false; } return Boolean( parsedNote.agent || parsedNote.model || (parsedNote.lines && parsedNote.lines.length > 0) || (parsedNote.ranges && parsedNote.ranges.length > 0), ); } /** * Validates whether a given commit matches known AI signatures. * * @param {Object} commit Commit object with author, committer, and message * @param {boolean} noteIsAi Whether the git-ai note attributes AI authorship * @returns {boolean} True if the commit is AI-authored */ function isCommitAiAuthored(commit, noteIsAi) { if (noteIsAi) { return true; } if ( BOT_SIGNATURES.some( (regex) => regex.test(commit.authorName || "") || regex.test(commit.authorEmail || ""), ) ) { return true; } return COMMIT_SIGNATURES.some((regex) => regex.test(commit.message || "")); } /** * Converts a CODEOWNERS glob pattern into a regex and tests a file path. * * @param {string} pattern The glob pattern * @param {string} filePath The file path to test * @returns {boolean} True if the pattern matches the path */ function matchCodeowners(pattern, filePath) { // CODEOWNERS files live in the (potentially untrusted) scanned repo, so the // pattern is attacker-controlled. Bound complexity and build the regex in a // single left-to-right pass that only emits NON-backtracking constructs. A // naive `/.+/`-based globstar is exponential (ReDoS) in the number of `**` // segments; `(?:[^/]+/)*` is linear because each iteration is anchored by `/`. if ( typeof pattern !== "string" || pattern.length > 512 || (pattern.match(/\*/g) || []).length > 24 ) { return false; } // Collapse redundant slashes and runs of consecutive globstars. const normalized = pattern .replace(/\/{2,}/g, "/") .replace(/\*\*(?:\/\*\*)+/g, "**"); const anchoredStart = normalized.startsWith("/"); const src = anchoredStart ? normalized.slice(1) : normalized; let regexStr = anchoredStart ? "^" : "(?:^|/)"; for (let i = 0; i < src.length; i++) { const c = src[i]; if (c === "*") { if (src[i + 1] === "*") { if (src[i + 2] === "/") { // `/**/` or leading `**/` -> zero or more whole path segments. regexStr += "(?:[^/]+/)*"; i += 2; } else { // trailing or embedded `**` -> match across segments regexStr += ".*"; i += 1; } } else { // single `*` -> stay within one path segment regexStr += "[^/]*"; } } else if (c === "?") { regexStr += "[^/]"; } else if (/[.+^${}()|[\]\\]/.test(c)) { regexStr += `\\${c}`; } else { regexStr += c; } } regexStr += normalized.endsWith("/") ? ".*" : "(?:$|/.*)"; try { return new RegExp(regexStr).test(filePath); } catch { return false; } } /** * Loads and parses the CODEOWNERS file if present in the repository. * * @param {string} dir Root directory of the repository * @returns {Array<Object>|null} List of parsed rules or null if not found */ function loadCodeowners(dir) { const candidatePaths = [ join(dir, ".github", "CODEOWNERS"), join(dir, "CODEOWNERS"), join(dir, "docs", "CODEOWNERS"), ]; for (const p of candidatePaths) { if (!safeExistsSync(p)) { continue; } try { const content = readFileSync(p, "utf-8"); const rules = []; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) { continue; } const parts = trimmed.split(/\s+/); const pattern = parts[0]; const owners = parts.slice(1); if (pattern && owners.length > 0) { rules.push({ pattern, owners }); } } return rules; } catch { // ignore and try next candidate } } return null; } /** * Checks if a file path matches any loaded CODEOWNERS rules. * * @param {string} filePath The path of the file * @param {Array<Object>} rules The parsed rules * @returns {boolean} True if matched by CODEOWNERS */ function isFileCovered(filePath, rules) { return rules.some((rule) => matchCodeowners(rule.pattern, filePath)); } /** * Calculates the median of an array of numbers. * * @param {Array<number>} values Array of numeric values * @returns {number} The median value */ function median(values) { if (!values || values.length === 0) { return 0; } const sorted = [...values].sort((a, b) => a - b); const half = Math.floor(sorted.length / 2); if (sorted.length % 2 !== 0) { return sorted[half]; } return (sorted[half - 1] + sorted[half]) / 2; } const isNum = (v) => typeof v === "number" && Number.isFinite(v); /** * Formats a metric value for a CycloneDX property: numbers are rendered with * fixed precision, the sentinel `unavailable` is passed through verbatim. * * @param {number|string} value Metric value * @param {number} [digits] Decimal precision for numbers * @returns {string} Property value */ function fmt(value, digits = 4) { return isNum(value) ? value.toFixed(digits) : String(value); } /** * Core collector for AI oversight. Orchestrates the collection of local git * signals, optionally enriches with forge (GitHub/GitLab) pull/merge request * review data, computes a transparent weighted rigor score, and emits * `cdx:ai:oversight:*` CycloneDX properties. * * When git-ai notes carry AI attribution data, the result also includes * `cdx:ai:codegen:models`, `cdx:ai:codegen:agents`, and related properties: * model names, agent tool names, note/session counts, and total AI-attributed * entry counts. * * Honesty first: local git cannot observe pull-request reviews/approvals, so * review-dependent metrics (reviewCoverage, reviewLatencyVsSize, selfMergeRate, * verificationDebtRatio) are reported as `unavailable` unless a forge enricher * supplies authoritative data. This prevents a git-only scan from falsely * reporting "zero oversight". * * @param {string} dir Root directory of the repository * @param {Object} [options] Oversight evaluation options * @param {number} [options.gitMaxCount] Max commits to scan (defaults to 20) * @param {string} [options.gitAiNotesRef] Git notes ref (defaults to refs/notes/ai) * @returns {Promise<Object>} Oversight metrics, score, band, and properties */ export async function collectAiOversight(dir, options = {}) { const maxCount = options.gitMaxCount || 20; const notesRef = options.gitAiNotesRef || "refs/notes/ai"; const emptyResult = { score: 1.0, band: "strong", properties: [], evidence: {}, }; const commits = gitLogCommitsDetailed(dir, maxCount); if (commits.length === 0) { return emptyResult; } // git-ai notes (line-level attribution), parsed once and keyed by commit hash. // Note: tools like git-ai annotate every commit; a note only counts as an AI // signal when it actually attributes lines to an agent (see noteAttributesAi). const notes = gitAiNotes(dir, { ref: notesRef, maxCount }); const parsedNotesMap = new Map( notes.map((n) => [n.hash, parseGitAiNote(n.note)]), ); const aiNoteCount = [...parsedNotesMap.values()].filter( noteAttributesAi, ).length; // Identify AI-authored commits and cache their diff analysis const aiCommits = []; const aiCommitsDiffs = []; const allCommitsDiffs = []; for (const commit of commits) { const diffAnalysis = gitCommitDiffAnalysis(dir, commit.hash); allCommitsDiffs.push({ commit, diffAnalysis }); const noteIsAi = noteAttributesAi(parsedNotesMap.get(commit.hash)); if (isCommitAiAuthored(commit, noteIsAi)) { aiCommits.push(commit); aiCommitsDiffs.push({ commit, diffAnalysis }); } } // No AI authorship => stay silent (no properties, no noise) if (aiCommits.length === 0) { return emptyResult; } const dataSources = ["git"]; if (aiNoteCount > 0) { dataSources.push("git-ai-notes"); } // Optional authoritative forge enrichment (GITHUB_TOKEN / GITLAB_TOKEN) const forgeResult = await enrichFromForge(dir, aiCommits, options); for (const ds of forgeResult.dataSources) { if (!dataSources.includes(ds)) { dataSources.push(ds); } } const forgeReviewsMap = new Map( forgeResult.prReviews.map((r) => [r.commitHash, r]), ); const forgeCovered = aiCommits.filter((c) => forgeReviewsMap.has(c.hash)); const hasForgeData = forgeCovered.length > 0; // --- Review-dependent metrics (authoritative only via forge) --- let reviewCoverage = UNAVAILABLE; let reviewLatencyVsSize = UNAVAILABLE; let selfMergeRate = UNAVAILABLE; let verificationDebtRatio = UNAVAILABLE; if (hasForgeData) { let reviewedCount = 0; let selfMergeCount = 0; let unreviewedCount = 0; let totalLatency = 0; let latencyCount = 0; for (const commit of forgeCovered) { const r = forgeReviewsMap.get(commit.hash); if (r.hasIndependentApproval) { reviewedCount++; } else { unreviewedCount++; } if (r.selfApproved) { selfMergeCount++; } if ( r.reviewLatencySeconds !== null && r.reviewLatencySeconds !== undefined ) { totalLatency += r.reviewLatencySeconds; latencyCount++; } } reviewCoverage = reviewedCount / forgeCovered.length; selfMergeRate = selfMergeCount / forgeCovered.length; verificationDebtRatio = unreviewedCount / forgeCovered.length; const avgLatency = latencyCount > 0 ? totalLatency / latencyCount : 0; const sizes = aiCommitsDiffs.map( (d) => d.diffAnalysis.addedLinesCount + d.diffAnalysis.deletedLinesCount, ); const medianSize = median(sizes) || 1; reviewLatencyVsSize = avgLatency / medianSize; } // --- Batch size (context) --- const aiSizes = aiCommitsDiffs.map( (d) => d.diffAnalysis.addedLinesCount + d.diffAnalysis.deletedLinesCount, ); const batchSizeMedian = median(aiSizes); // --- Unique AI-touched files --- const aiTouchedFiles = new Set(); for (const d of aiCommitsDiffs) { for (const f of d.diffAnalysis.touchedFiles) { aiTouchedFiles.add(f); } } // --- Revert / hotfix rate touching AI work --- const aiHashes = new Set(aiCommits.map((c) => c.hash)); const reverts = gitRevertsAndHotfixes(dir, maxCount).filter( (r) => !aiHashes.has(r.hash), ); let revertCount = 0; for (const rev of reverts) { let matchesAi = [...aiHashes].some((hash) => rev.message.includes(hash)); if (!matchesAi && aiTouchedFiles.size > 0) { const revDiff = gitCommitDiffAnalysis(dir, rev.hash); matchesAi = revDiff.touchedFiles.some((f) => aiTouchedFiles.has(f)); } if (matchesAi) { revertCount++; } } const revertHotfixRate = revertCount / aiCommits.length; // --- CI weakening events (test deletions + weakening tokens) --- let ciWeakeningEvents = 0; for (const d of aiCommitsDiffs) { ciWeakeningEvents += d.diffAnalysis.testFilesDeleted.length; ciWeakeningEvents += d.diffAnalysis.weakeningTokens.length; } // --- Sign-off coverage (always local) --- let signedCount = 0; for (const commit of aiCommits) { if (["G", "U"].includes(commit.signatureStatus) || commit.hasSignedOff) { signedCount++; } } const signoffCoverage = signedCount / aiCommits.length; // --- CODEOWNERS coverage (unavailable when no CODEOWNERS file) --- const codeownersRules = loadCodeowners(dir); let codeownersCoverage = UNAVAILABLE; if ( codeownersRules && codeownersRules.length > 0 && aiTouchedFiles.size > 0 ) { let covered = 0; for (const f of aiTouchedFiles) { if (isFileCovered(f, codeownersRules)) { covered++; } } codeownersCoverage = covered / aiTouchedFiles.size; } // --- AI line ratio (precise from git-ai notes when parseable; else coarse // heuristic). Only note-attributed line counts feed the precise ratio; when // the note schema is not understood we fall back to the commit-level // heuristic rather than over-claiming precision. --- let aiLinesCount = 0; let hasParsedAiLines = false; let totalLinesInAiCommits = 0; for (const d of aiCommitsDiffs) { const linesChanged = d.diffAnalysis.addedLinesCount + d.diffAnalysis.deletedLinesCount; totalLinesInAiCommits += linesChanged; const parsedNote = parsedNotesMap.get(d.commit.hash); if (!parsedNote) { continue; } if (parsedNote.lines && parsedNote.lines.length > 0) { aiLinesCount += parsedNote.lines.length; hasParsedAiLines = true; } else if (parsedNote.ranges && parsedNote.ranges.length > 0) { for (const range of parsedNote.ranges) { if (typeof range === "string" && range.includes("-")) { const [a, b] = range.split("-").map(Number); if (!Number.isNaN(a) && !Number.isNaN(b)) { aiLinesCount += Math.abs(b - a) + 1; hasParsedAiLines = true; } } else { aiLinesCount++; hasParsedAiLines = true; } } } } let totalLinesInAllCommits = 0; for (const d of allCommitsDiffs) { totalLinesInAllCommits += d.diffAnalysis.addedLinesCount + d.diffAnalysis.deletedLinesCount; } let aiLineRatio; if (hasParsedAiLines) { aiLineRatio = totalLinesInAiCommits > 0 ? Math.min(1.0, aiLinesCount / totalLinesInAiCommits) : 0.0; } else { aiLineRatio = totalLinesInAllCommits > 0 ? totalLinesInAiCommits / totalLinesInAllCommits : 0.0; } // --- Git-ai note detail aggregation (model names, agents, attribution) --- const allModels = new Set(); const allAgents = new Set(); let totalAiNotes = 0; let totalSessions = 0; let totalPrompts = 0; let totalAttribution = 0; for (const { hash } of aiCommits) { const parsedNote = parsedNotesMap.get(hash); if (!parsedNote || !noteAttributesAi(parsedNote)) { continue; } totalAiNotes++; totalAttribution += parsedNote.aiAttributionCount || 0; if (parsedNote.models && parsedNote.models.length > 0) { for (const m of parsedNote.models) { if (m) allModels.add(m); } } else if (parsedNote.model) { allModels.add(parsedNote.model); } if (parsedNote.agents && parsedNote.agents.length > 0) { for (const a of parsedNote.agents) { if (a) allAgents.add(a); } } else if (parsedNote.agent) { allAgents.add(parsedNote.agent); } totalSessions += Object.keys(parsedNote.sessions || {}).length; totalPrompts += Object.keys(parsedNote.prompts || {}).length; } // --- Transparent weighted scoring over available signals --- const ciWeakeningScore = Math.max(0.0, 1.0 - ciWeakeningEvents * 0.25); const revertScore = Math.max(0.0, 1.0 - Math.min(1.0, revertHotfixRate)); const contributions = {}; const addContribution = (key, value, weight) => { if (isNum(value)) { contributions[key] = { value, weight }; } }; addContribution( "verificationDebt", isNum(verificationDebtRatio) ? 1.0 - verificationDebtRatio : undefined, 0.3, ); addContribution( "reviewCoverage", isNum(reviewCoverage) ? reviewCoverage : undefined, 0.25, ); addContribution( "selfMerge", isNum(selfMergeRate) ? 1.0 - selfMergeRate : undefined, 0.15, ); addContribution("ciWeakening", ciWeakeningScore, 0.15); addContribution("revert", revertScore, 0.05); addContribution("signoff", signoffCoverage, 0.05); addContribution( "codeowners", isNum(codeownersCoverage) ? codeownersCoverage : undefined, 0.05, ); let weightedSum = 0.0; let weightTotal = 0.0; for (const c of Object.values(contributions)) { weightedSum += c.value * c.weight; weightTotal += c.weight; } let finalScore = weightTotal > 0 ? weightedSum / weightTotal : 1.0; // Hard penalty for quality-gate weakening on AI commits if (ciWeakeningEvents > 0) { finalScore = Math.max( 0.0, finalScore - Math.min(0.5, ciWeakeningEvents * 0.1), ); } finalScore = Number(finalScore.toFixed(4)); let band = "weak"; if (finalScore >= 0.75) { band = "strong"; } else if (finalScore >= 0.5) { band = "moderate"; } const evidence = { aiCommitCount: aiCommits.length, selfMergeRate, reviewCoverage, reviewLatencyVsSize, batchSizeMedian, revertHotfixRate, ciWeakeningEvents, signoffCoverage, codeownersCoverage, aiLineRatio, verificationDebtRatio, score: finalScore, band, dataSources, contributions, aiNotesCount: totalAiNotes, giModels: [...allModels], giAgents: [...allAgents], giSessionCount: totalSessions, giPromptCount: totalPrompts, giAttributionCount: totalAttribution, }; const properties = [ { name: "cdx:ai:oversight:reviewCoverage", value: fmt(reviewCoverage) }, { name: "cdx:ai:oversight:selfMergeRate", value: fmt(selfMergeRate) }, { name: "cdx:ai:oversight:reviewLatencyVsSize", value: fmt(reviewLatencyVsSize, 2), }, { name: "cdx:ai:oversight:batchSizeMedian", value: fmt(batchSizeMedian, 1), }, { name: "cdx:ai:oversight:revertHotfixRate", value: fmt(revertHotfixRate) }, { name: "cdx:ai:oversight:ciWeakeningEvents", value: String(ciWeakeningEvents), }, { name: "cdx:ai:oversight:signoffCoverage", value: fmt(signoffCoverage) }, { name: "cdx:ai:oversight:codeownersCoverage", value: fmt(codeownersCoverage), }, { name: "cdx:ai:oversight:aiLineRatio", value: fmt(aiLineRatio) }, { name: "cdx:ai:oversight:verificationDebtRatio", value: fmt(verificationDebtRatio), }, { name: "cdx:ai:oversight:score", value: finalScore.toFixed(4) }, { name: "cdx:ai:oversight:band", value: band }, { name: "cdx:ai:oversight:dataSources", value: dataSources.join(",") }, { name: "cdx:ai:oversight:evidence:json", value: JSON.stringify(evidence) }, ]; // Append git-ai derived detail properties when git-ai attribution data exists. if (totalAiNotes > 0) { if (allModels.size > 0) { properties.push({ name: "cdx:ai:codegen:models", value: [...allModels].sort().join(","), }); } if (allAgents.size > 0) { properties.push({ name: "cdx:ai:codegen:agents", value: [...allAgents].sort().join(","), }); } properties.push({ name: "cdx:ai:codegen:noteCount", value: String(totalAiNotes), }); properties.push({ name: "cdx:ai:codegen:sessionCount", value: String(totalSessions), }); properties.push({ name: "cdx:ai:codegen:attributionCount", value: String(totalAttribution), }); } const sanitizedProperties = properties.map(({ name, value }) => ({ name, value: sanitizeBomPropertyValue(name, value), })); return { score: finalScore, band, properties: sanitizedProperties, evidence, }; }