UNPKG

@cyclonedx/cdxgen

Version:

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

823 lines (788 loc) 20.6 kB
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, }; }