@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
823 lines (788 loc) • 20.6 kB
JavaScript
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { getBranch, gitLogAuthors, gitLogTrailers } from "./envcontext.js";
import { sanitizeBomPropertyValue } from "./propertySanitizer.js";
import { safeExistsSync } from "./utils.js";
/**
* Signatures for matching commit messages and trailers.
*/
const COMMIT_SIGNATURES = [
{
pattern: /Co-authored-by:\s*Claude\b/i,
tool: "claude-code",
confidence: 0.98,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /noreply@anthropic\.com/i,
tool: "claude-code",
confidence: 0.98,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /Generated with \[?Claude Code/i,
tool: "claude-code",
confidence: 0.98,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /Co-authored-by:\s*Copilot\b/i,
tool: "github-copilot",
confidence: 0.85,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /copilot@github\.com/i,
tool: "github-copilot",
confidence: 0.85,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /Generated by Devin/i,
tool: "devin",
confidence: 0.98,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /Generated by Replit/i,
tool: "replit-ai",
confidence: 0.95,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /Replit-Commit-Author:/i,
tool: "replit-ai",
confidence: 0.95,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /cursor\.com/i,
tool: "cursor",
confidence: 0.9,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /WindSurf/i,
tool: "windsurf",
confidence: 0.95,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /Generated by Codex|codex-cli/i,
tool: "openai-codex",
confidence: 0.85,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /\baider\b:/i,
tool: "aider",
confidence: 0.8,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /\(aider\)/i,
tool: "aider",
confidence: 0.8,
phase: "dev",
channel: "commit-trailer",
},
{
pattern: /\b(ai-generated|ai-assisted|llm|gpt|copilot|cursor)\b/i,
tool: "unattributed",
confidence: 0.4,
phase: "dev",
channel: "commit-trailer",
},
];
/**
* Signatures for matching bot/machine author names and emails.
*/
const AUTHOR_SIGNATURES = [
{
pattern: /github-copilot\[bot\]/i,
tool: "github-copilot",
confidence: 1.0,
phase: "dev",
channel: "bot-author",
},
{
pattern: /devin-ai\[bot\]/i,
tool: "devin",
confidence: 1.0,
phase: "dev",
channel: "bot-author",
},
{
pattern: /devin-ai-integration/i,
tool: "devin",
confidence: 1.0,
phase: "dev",
channel: "bot-author",
},
{
pattern: /replit\[bot\]/i,
tool: "replit-ai",
confidence: 0.95,
phase: "dev",
channel: "bot-author",
},
{
pattern: /openhands/i,
tool: "openhands",
confidence: 0.95,
phase: "dev",
channel: "bot-author",
},
{
pattern: /noreply@anthropic\.com/i,
tool: "openhands",
confidence: 0.95,
phase: "dev",
channel: "bot-author",
},
{
pattern: /coderabbitai\[bot\]/i,
tool: "coderabbit",
confidence: 1.0,
phase: "review",
channel: "bot-author",
},
];
/**
* Signatures for matching branch names.
*/
const BRANCH_SIGNATURES = [
{
pattern: /^cursor[-/]/i,
tool: "cursor",
confidence: 0.6,
phase: "dev",
channel: "branch-name",
},
{
pattern: /^codex[-/]/i,
tool: "openai-codex",
confidence: 0.55,
phase: "dev",
channel: "branch-name",
},
{
pattern: /^copilot\//i,
tool: "github-copilot",
confidence: 0.7,
phase: "dev",
channel: "branch-name",
},
{
pattern: /^devin\//i,
tool: "devin",
confidence: 0.7,
phase: "dev",
channel: "branch-name",
},
];
/**
* Signatures for matching workflow yaml file content.
*/
// Workflow signatures are intentionally scoped to vendor/action slugs (org/repo
// style references) rather than bare tool names to avoid false positives from
// unrelated identifiers such as database "cursor" or "sweep" steps.
const WORKFLOW_SIGNATURES = [
{
pattern: /anthropics\/claude-code-action/i,
tool: "claude-code",
confidence: 0.9,
phase: "ci",
},
{
pattern: /github\/copilot/i,
tool: "github-copilot",
confidence: 0.85,
phase: "ci",
},
{
pattern: /coderabbitai\b/i,
tool: "coderabbit",
confidence: 0.9,
phase: "review",
},
{
pattern: /anysphere\/|cursor\/cursor-agent/i,
tool: "cursor",
confidence: 0.85,
phase: "ci",
},
{
pattern: /openai\/codex/i,
tool: "openai-codex",
confidence: 0.85,
phase: "ci",
},
{
pattern: /sweepai\//i,
tool: "sweep",
confidence: 0.9,
phase: "ci",
},
{
pattern: /cognition-ai\/|devin-ai-integration/i,
tool: "devin",
confidence: 0.9,
phase: "ci",
},
];
/**
* Default number of recent commits to inspect for AI provenance signals.
* Recency-proof: the most recent commits are sufficient to establish whether
* AI tooling is actively in use, so a small window keeps the scan fast.
*/
const DEFAULT_GIT_MAX_COUNT = 20;
/**
* "Pseudo-tools" are attribution buckets rather than concrete products. They
* indicate AI involvement that cannot be tied to a specific tool:
* - `unattributed`: generic AI keywords / hooks with no tool-specific marker.
* - `multi-agent`: the cross-tool AGENTS.md standard read by many agents.
*
* Their signals still contribute to detection, confidence, phases, signal
* counts, and the evidence JSON, but they are excluded from the `tools` list and
* from per-tool `cdx:ai:codegen:tool:<tool>` properties so those stay a clean,
* policy-friendly inventory of real products.
*/
const PSEUDO_TOOLS = new Set(["unattributed", "multi-agent"]);
/**
* Helper to compute the aggregate confidence using noisy-OR.
*
* @param {Array<number>} confidences Array of confidence values
* @returns {number} Aggregate confidence capped at 0.99
*/
function computeNoisyOr(confidences) {
if (!confidences || confidences.length === 0) {
return 0.0;
}
let product = 1.0;
for (const c of confidences) {
product *= 1.0 - c;
}
const score = 1.0 - product;
return Math.min(0.99, score);
}
/**
* Safely reads a file's content or returns an empty string on failure.
*
* @param {string} filePath Absolute file path
* @returns {string} File content
*/
function safeReadFile(filePath) {
try {
return readFileSync(filePath, "utf-8");
} catch {
return "";
}
}
/**
* Collects and evaluates signals indicating the presence/use of AI coding agents, assistants, or LLMs.
*
* @param {string} dir Root directory of the project
* @param {Object} [options] Evaluation options
* @param {number} [options.gitMaxCount] Maximum number of git commits to inspect
*
* @returns {Object} The evaluation result containing overall status, tool breakdowns, and properties
*/
export function collectAiProvenance(dir, options = {}) {
const maxCount = options?.gitMaxCount || DEFAULT_GIT_MAX_COUNT;
const signals = [];
// 1. Git branch evaluation
try {
const branchName = getBranch(null, dir);
if (branchName) {
const cleanBranch = branchName.trim();
for (const sig of BRANCH_SIGNATURES) {
if (sig.pattern.test(cleanBranch)) {
signals.push({
channel: sig.channel,
tool: sig.tool,
phase: sig.phase,
evidence: `branch name: ${cleanBranch}`,
confidence: sig.confidence,
});
}
}
}
} catch {
// Degrade gracefully if git command fails or not in git repo
}
// 2. Git authors evaluation
try {
const authors = gitLogAuthors(dir, maxCount);
if (authors && authors.length > 0) {
for (const author of authors) {
const checkStr = `${author.name} <${author.email}>`;
for (const sig of AUTHOR_SIGNATURES) {
if (sig.pattern.test(checkStr)) {
signals.push({
channel: sig.channel,
tool: sig.tool,
phase: sig.phase,
evidence: `bot author: ${checkStr}`,
confidence: sig.confidence,
});
}
}
}
}
} catch {
// Degrade gracefully
}
// 3. Git commit messages / trailers evaluation
try {
const commits = gitLogTrailers(dir, maxCount);
if (commits && commits.length > 0) {
const matchedCommits = {}; // tool -> array of commit hashes
const toolToSig = {};
for (const commit of commits) {
for (const sig of COMMIT_SIGNATURES) {
if (sig.pattern.test(commit.message)) {
if (!matchedCommits[sig.tool]) {
matchedCommits[sig.tool] = [];
toolToSig[sig.tool] = sig;
}
matchedCommits[sig.tool].push(commit.hash);
}
}
}
for (const [tool, hashes] of Object.entries(matchedCommits)) {
const sig = toolToSig[tool];
signals.push({
channel: sig.channel,
tool,
phase: sig.phase,
evidence: `${hashes.length} commit(s)`,
confidence: sig.confidence,
});
}
}
} catch {
// Degrade gracefully
}
// 4. Configuration files evaluation
const configItems = [
{
path: "CLAUDE.md",
tool: "claude-code",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".claude",
type: "dir",
tool: "claude-code",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: "AGENTS.md",
tool: "multi-agent",
phase: "dev",
confidence: 0.65,
channel: "config-file",
},
{
path: "AGENT.md",
tool: "multi-agent",
phase: "dev",
confidence: 0.65,
channel: "config-file",
},
{
path: ".github/copilot-instructions.md",
tool: "github-copilot",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".cursorrules",
tool: "cursor",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".cursor/rules",
type: "dir",
tool: "cursor",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".windsurfrules",
tool: "windsurf",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".aider.conf.yml",
tool: "aider",
phase: "dev",
confidence: 0.6,
channel: "config-file",
},
{
path: ".continue/config.yaml",
tool: "continue",
phase: "dev",
confidence: 0.6,
channel: "config-file",
},
{
path: ".clinerules",
tool: "cline",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".roo",
type: "dir",
tool: "roo",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".roomodes",
tool: "roo",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: "GEMINI.md",
tool: "gemini",
phase: "dev",
confidence: 0.65,
channel: "config-file",
},
{
path: ".sourcegraph/cody.json",
tool: "cody",
phase: "dev",
confidence: 0.65,
channel: "config-file",
},
{
path: ".gito/config.toml",
tool: "gito",
phase: "review",
confidence: 0.7,
channel: "config-file",
},
{
path: ".gitagent",
type: "dir",
tool: "gitagent",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: "agent.yaml",
tool: "gitagent",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
{
path: ".gitagent/audit.jsonl",
tool: "gitagent",
phase: "dev",
confidence: 0.7,
channel: "config-file",
},
];
for (const item of configItems) {
const fullPath = join(dir, item.path);
if (safeExistsSync(fullPath)) {
let matchesType = true;
if (item.type === "dir") {
try {
matchesType = statSync(fullPath).isDirectory();
} catch {
matchesType = false;
}
}
if (matchesType) {
signals.push({
channel: item.channel,
tool: item.tool,
phase: item.phase,
evidence: item.path,
confidence: item.confidence,
});
}
}
}
// 4b. Dynamic config matching for settings files (like .claude/settings*.json)
const claudeDir = join(dir, ".claude");
if (safeExistsSync(claudeDir)) {
try {
if (statSync(claudeDir).isDirectory()) {
const files = readdirSync(claudeDir);
for (const file of files) {
if (file.startsWith("settings") && file.endsWith(".json")) {
signals.push({
channel: "config-file",
tool: "claude-code",
phase: "dev",
evidence: `.claude/${file}`,
confidence: 0.7,
});
}
}
}
} catch {
// ignore
}
}
// 4c. Dynamic config matching for instruction.md
const instructionsDir = join(dir, ".github/instructions");
if (safeExistsSync(instructionsDir)) {
try {
if (statSync(instructionsDir).isDirectory()) {
const readRecursive = (subPath) => {
const currentDir = join(instructionsDir, subPath);
const entries = readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const relPath = join(subPath, entry.name);
if (entry.isDirectory()) {
readRecursive(relPath);
} else if (entry.name.endsWith(".instructions.md")) {
signals.push({
channel: "config-file",
tool: "github-copilot",
phase: "dev",
evidence: `.github/instructions/${relPath}`,
confidence: 0.7,
});
}
}
};
readRecursive("");
}
} catch {
// ignore
}
}
// 4d. Wildcard aider matching (.aider*)
try {
const files = readdirSync(dir);
for (const file of files) {
if (
file.startsWith(".aider") &&
file !== ".aider.conf.yml" &&
file !== ".aider"
) {
signals.push({
channel: "config-file",
tool: "aider",
phase: "dev",
evidence: file,
confidence: 0.6,
});
}
}
} catch {
// ignore
}
// 4e. Content checks for settings/hooks
const preCommitPath = join(dir, ".pre-commit-config.yaml");
if (safeExistsSync(preCommitPath)) {
const content = safeReadFile(preCommitPath);
if (content) {
const aiKeywords = [
"claude",
"copilot",
"coderabbit",
"openai",
"cody",
"sweep",
"devin",
"aider",
];
const hasAiHooks = aiKeywords.some((kw) =>
content.toLowerCase().includes(kw),
);
if (hasAiHooks) {
signals.push({
channel: "config-file",
tool: "unattributed",
phase: "review",
evidence: ".pre-commit-config.yaml w/ AI hooks",
confidence: 0.5,
});
}
}
}
const zedSettingsPath = join(dir, ".zed/settings.json");
if (safeExistsSync(zedSettingsPath)) {
const content = safeReadFile(zedSettingsPath);
if (content) {
const aiKeywords = [
"assistant",
"openai",
"anthropic",
"copilot",
"ollama",
"gemini",
];
const hasAiConfig = aiKeywords.some((kw) =>
content.toLowerCase().includes(kw),
);
if (hasAiConfig) {
signals.push({
channel: "config-file",
tool: "zed",
phase: "dev",
evidence: ".zed/settings.json w/ AI configuration",
confidence: 0.6,
});
}
}
}
// 5. GitHub Workflows / CI evaluation
const workflowsDir = join(dir, ".github/workflows");
if (safeExistsSync(workflowsDir)) {
try {
if (statSync(workflowsDir).isDirectory()) {
const files = readdirSync(workflowsDir);
for (const file of files) {
const filePath = join(workflowsDir, file);
if (statSync(filePath).isFile()) {
const content = safeReadFile(filePath);
if (content) {
for (const sig of WORKFLOW_SIGNATURES) {
if (sig.pattern.test(content)) {
signals.push({
channel: "workflow",
tool: sig.tool,
phase: sig.phase,
evidence: `.github/workflows/${file}`,
confidence: sig.confidence,
});
}
}
}
}
}
}
} catch {
// ignore
}
}
// 6. Compute aggregate statistics
const detected = signals.length > 0;
const overallConfidenceVal = computeNoisyOr(signals.map((s) => s.confidence));
const overallConfidence = overallConfidenceVal.toFixed(2);
let band = "low";
if (overallConfidenceVal >= 0.8) {
band = "high";
} else if (overallConfidenceVal >= 0.5) {
band = "medium";
}
// Deduplicate and group phases (across all signals) and real tools (pseudo
// tools such as `unattributed` / `multi-agent` are excluded from the tool
// inventory but still count toward detection and confidence).
const uniqueTools = Array.from(
new Set(signals.map((s) => s.tool).filter((t) => !PSEUDO_TOOLS.has(t))),
).sort();
const uniquePhases = Array.from(new Set(signals.map((s) => s.phase))).sort();
// Nothing detected: return an empty properties array so BOMs for projects
// without any AI signals stay clean and free of noise.
if (!detected) {
return {
detected,
confidence: overallConfidence,
band,
tools: uniqueTools,
phases: uniquePhases,
signals,
properties: [],
};
}
// Generate properties
const properties = [
{ name: "cdx:ai:codegen:detected", value: String(detected) },
{ name: "cdx:ai:codegen:confidence", value: overallConfidence },
{ name: "cdx:ai:codegen:confidence:band", value: band },
{ name: "cdx:ai:codegen:tools", value: uniqueTools.join(",") },
{ name: "cdx:ai:codegen:phases", value: uniquePhases.join(",") },
{ name: "cdx:ai:codegen:signals:count", value: String(signals.length) },
];
// Group signals by tool to build tool-specific properties, skipping pseudo
// tools so per-tool properties describe only concrete products.
const signalsByTool = {};
for (const sig of signals) {
if (PSEUDO_TOOLS.has(sig.tool)) {
continue;
}
if (!signalsByTool[sig.tool]) {
signalsByTool[sig.tool] = [];
}
signalsByTool[sig.tool].push(sig);
}
for (const [tool, toolSignals] of Object.entries(signalsByTool)) {
const toolConf = computeNoisyOr(toolSignals.map((s) => s.confidence));
const toolPhases = Array.from(
new Set(toolSignals.map((s) => s.phase)),
).sort();
const toolChannels = Array.from(
new Set(toolSignals.map((s) => s.channel)),
).sort();
const toolEvidence = Array.from(
new Set(toolSignals.map((s) => s.evidence)),
).sort();
const toolVal = `confidence=${toolConf.toFixed(
2,
)};phases=${toolPhases.join(",")};channels=${toolChannels.join(
",",
)};evidence=${toolEvidence.join(",")}`;
properties.push({ name: `cdx:ai:codegen:tool:${tool}`, value: toolVal });
}
if (detected) {
properties.push({
name: "cdx:ai:codegen:evidence:json",
value: signals,
});
}
// Sanitize and serialize values so structured properties (for example the
// evidence JSON) are emitted as valid CycloneDX string property values.
const sanitizedProperties = properties.map(({ name, value }) => ({
name,
value: sanitizeBomPropertyValue(name, value),
}));
return {
detected,
confidence: overallConfidence,
band,
tools: uniqueTools,
phases: uniquePhases,
signals,
properties: sanitizedProperties,
};
}