@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
662 lines (621 loc) • 21 kB
JavaScript
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,
};
}