/**
 * CycloneDX SBOM Generator
 *
 * Generates CycloneDX 1.5 SBOMs from package.json and lock files.
 *
 * @module sbom/cyclonedx
 */
import { readFile, access } from "fs/promises";
import { join } from "path";
import { createHash, randomUUID } from "crypto";
/**
 * Check if file exists
 */
async function fileExists(filePath) {
    try {
        await access(filePath);
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Parse package.json
 */
async function parsePackageJson(projectPath) {
    const pkgPath = join(projectPath, "package.json");
    if (!(await fileExists(pkgPath))) {
        return null;
    }
    const content = await readFile(pkgPath, "utf-8");
    return JSON.parse(content);
}
/**
 * Parse package-lock.json
 */
async function parsePackageLock(projectPath) {
    const lockPath = join(projectPath, "package-lock.json");
    if (!(await fileExists(lockPath))) {
        return null;
    }
    const content = await readFile(lockPath, "utf-8");
    return JSON.parse(content);
}
/**
 * Generate purl (package URL) for npm package
 */
function generatePurl(name, version) {
    // Handle scoped packages
    const encodedName = name.startsWith("@")
        ? name.replace("/", "%2F")
        : name;
    return version
        ? `pkg:npm/${encodedName}@${version}`
        : `pkg:npm/${encodedName}`;
}
/**
 * Generate bom-ref from package name and version
 */
function generateBomRef(name, version) {
    return version ? `${name}@${version}` : name;
}
/**
 * Parse integrity hash from package-lock
 */
function parseIntegrityHash(integrity) {
    if (!integrity)
        return [];
    const hashes = [];
    // Format: sha512-base64hash or sha256-base64hash
    if (integrity.startsWith("sha512-")) {
        hashes.push({
            alg: "SHA-512",
            content: integrity.slice(7),
        });
    }
    else if (integrity.startsWith("sha256-")) {
        hashes.push({
            alg: "SHA-256",
            content: integrity.slice(7),
        });
    }
    else if (integrity.startsWith("sha1-")) {
        hashes.push({
            alg: "SHA-1",
            content: integrity.slice(5),
        });
    }
    return hashes;
}
/**
 * Extract author name from package.json author field
 */
function extractAuthorName(author) {
    if (!author)
        return undefined;
    if (typeof author === "string") {
        // Parse "Name <email>" format
        const match = author.match(/^([^<]+)/);
        return match ? match[1].trim() : author;
    }
    return author.name;
}
/**
 * Create component from package info
 */
function createComponent(name, version, options) {
    const component = {
        type: "library",
        "bom-ref": generateBomRef(name, version),
        name,
        version,
        purl: generatePurl(name, version),
    };
    if (options?.integrity) {
        component.hashes = parseIntegrityHash(options.integrity);
    }
    if (options?.description) {
        component.description = options.description;
    }
    if (options?.license) {
        component.licenses = [{ license: { id: options.license } }];
    }
    if (options?.isDev) {
        component.scope = "optional";
        component.properties = [{ name: "cdx:npm:development", value: "true" }];
    }
    if (options?.isOptional) {
        component.scope = "optional";
    }
    if (options?.resolved) {
        component.externalReferences = [
            {
                type: "distribution",
                url: options.resolved,
            },
        ];
    }
    return component;
}
/**
 * Generate SBOM metadata
 */
function generateMetadata(packageJson, options) {
    const projectName = options?.projectName || packageJson.name || "unknown";
    const projectVersion = options?.projectVersion || packageJson.version;
    const metadata = {
        timestamp: new Date().toISOString(),
        tools: [
            {
                vendor: "Vaspera",
                name: "vaspera-hardening",
                version: "1.1.0",
            },
        ],
        component: {
            type: "application",
            "bom-ref": generateBomRef(projectName, projectVersion),
            name: projectName,
            version: projectVersion,
            description: packageJson.description,
            purl: generatePurl(projectName, projectVersion),
        },
    };
    // Add author
    const authorName = options?.author?.name || extractAuthorName(packageJson.author);
    if (authorName) {
        metadata.authors = [
            {
                name: authorName,
                email: options?.author?.email,
            },
        ];
    }
    // Add license
    if (packageJson.license) {
        metadata.licenses = [{ license: { id: packageJson.license } }];
    }
    return metadata;
}
/**
 * Extract components from package-lock.json (v2/v3 format)
 */
function extractComponentsFromLockV2(packageLock, includeDevDependencies) {
    const components = [];
    const packages = packageLock.packages || {};
    for (const [pkgPath, pkgInfo] of Object.entries(packages)) {
        // Skip root package
        if (pkgPath === "")
            continue;
        // Extract name from path (e.g., "node_modules/@types/node" -> "@types/node")
        const name = pkgPath.replace(/^node_modules\//, "").replace(/\/node_modules\//g, "/");
        // Skip dev dependencies if not included
        if (pkgInfo.dev && !includeDevDependencies)
            continue;
        components.push(createComponent(name, pkgInfo.version, {
            resolved: pkgInfo.resolved,
            integrity: pkgInfo.integrity,
            isDev: pkgInfo.dev,
            isOptional: pkgInfo.optional,
        }));
    }
    return components;
}
/**
 * Extract components from package-lock.json (v1 format)
 */
function extractComponentsFromLockV1(packageLock, includeDevDependencies) {
    const components = [];
    const deps = packageLock.dependencies || {};
    function processDeps(dependencies, 
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _prefix = "") {
        for (const [name, info] of Object.entries(dependencies)) {
            // Skip dev dependencies if not included
            if (info.dev && !includeDevDependencies)
                continue;
            components.push(createComponent(name, info.version, {
                resolved: info.resolved,
                integrity: info.integrity,
                isDev: info.dev,
                isOptional: info.optional,
            }));
            // Process nested dependencies
            if (info.dependencies) {
                processDeps(info.dependencies);
            }
        }
    }
    processDeps(deps);
    return components;
}
/**
 * Extract components from package.json only (no lock file)
 */
function extractComponentsFromPackageJson(packageJson, includeDevDependencies) {
    const components = [];
    // Production dependencies
    if (packageJson.dependencies) {
        for (const [name, version] of Object.entries(packageJson.dependencies)) {
            components.push(createComponent(name, version.replace(/^[\^~]/, "")));
        }
    }
    // Dev dependencies
    if (includeDevDependencies && packageJson.devDependencies) {
        for (const [name, version] of Object.entries(packageJson.devDependencies)) {
            components.push(createComponent(name, version.replace(/^[\^~]/, ""), { isDev: true }));
        }
    }
    // Peer dependencies
    if (packageJson.peerDependencies) {
        for (const [name, version] of Object.entries(packageJson.peerDependencies)) {
            components.push(createComponent(name, version.replace(/^[\^~]/, ""), { isOptional: true }));
        }
    }
    return components;
}
/**
 * Generate dependency graph
 */
function generateDependencies(packageJson, components) {
    const dependencies = [];
    const componentRefs = new Set(components.map((c) => c["bom-ref"]));
    // Root application depends on direct dependencies
    const rootRef = generateBomRef(packageJson.name || "unknown", packageJson.version);
    const directDeps = [];
    if (packageJson.dependencies) {
        for (const [name, version] of Object.entries(packageJson.dependencies)) {
            const ref = generateBomRef(name, version.replace(/^[\^~]/, ""));
            if (componentRefs.has(ref)) {
                directDeps.push(ref);
            }
        }
    }
    dependencies.push({
        ref: rootRef,
        dependsOn: directDeps,
    });
    // Add empty dependency entries for each component
    for (const component of components) {
        if (component["bom-ref"] && component["bom-ref"] !== rootRef) {
            dependencies.push({
                ref: component["bom-ref"],
                dependsOn: [],
            });
        }
    }
    return dependencies;
}
/**
 * Convert scanner findings to vulnerabilities
 */
export function findingsToVulnerabilities(findings) {
    const vulnerabilities = [];
    // Filter to dependency vulnerabilities
    const depFindings = findings.filter((f) => f.scanner === "npm-audit" || f.cveIds?.length);
    for (const finding of depFindings) {
        const vuln = {
            "bom-ref": `vuln-${finding.ruleId}`,
            id: finding.cveIds?.[0] || finding.ruleId,
            source: {
                name: finding.scanner === "npm-audit" ? "npm" : "scanner",
                url: finding.scanner === "npm-audit"
                    ? "https://www.npmjs.com/advisories"
                    : undefined,
            },
            ratings: [
                {
                    severity: finding.severity,
                    method: "other",
                },
            ],
            description: finding.message,
        };
        if (finding.cweIds?.length) {
            vuln.cwes = finding.cweIds
                .map((cwe) => parseInt(cwe.replace("CWE-", ""), 10))
                .filter((n) => !isNaN(n));
        }
        vulnerabilities.push(vuln);
    }
    return vulnerabilities;
}
/**
 * Generate CycloneDX SBOM
 */
export async function generateSBOM(projectPath, options, findings) {
    const packageJson = await parsePackageJson(projectPath);
    if (!packageJson) {
        throw new Error(`No package.json found in ${projectPath}`);
    }
    const packageLock = await parsePackageLock(projectPath);
    const includeDevDependencies = options?.includeDevDependencies ?? false;
    // Extract components
    let components;
    if (packageLock) {
        // Use lock file for accurate versions
        if (packageLock.lockfileVersion && packageLock.lockfileVersion >= 2) {
            components = extractComponentsFromLockV2(packageLock, includeDevDependencies);
        }
        else {
            components = extractComponentsFromLockV1(packageLock, includeDevDependencies);
        }
    }
    else {
        // Fall back to package.json
        components = extractComponentsFromPackageJson(packageJson, includeDevDependencies);
    }
    // Generate SBOM
    const sbom = {
        bomFormat: "CycloneDX",
        specVersion: "1.5",
        serialNumber: `urn:uuid:${randomUUID()}`,
        version: 1,
        metadata: generateMetadata(packageJson, options),
        components,
        dependencies: generateDependencies(packageJson, components),
    };
    // Add vulnerabilities if requested
    if (options?.includeVulnerabilities && findings?.length) {
        sbom.vulnerabilities = findingsToVulnerabilities(findings);
    }
    return sbom;
}
/**
 * Generate SBOM as JSON string
 */
export async function generateSBOMJson(projectPath, options, findings) {
    const sbom = await generateSBOM(projectPath, options, findings);
    return JSON.stringify(sbom, null, 2);
}
/**
 * Calculate SHA-256 hash of content
 */
export function calculateSha256(content) {
    return createHash("sha256").update(content).digest("hex");
}
/**
 * Generate compact SBOM summary
 */
export function generateSBOMSummary(sbom) {
    const componentCount = sbom.components?.length || 0;
    const vulnCount = sbom.vulnerabilities?.length || 0;
    const projectName = sbom.metadata?.component?.name || "unknown";
    const projectVersion = sbom.metadata?.component?.version || "?";
    let summary = `SBOM: ${projectName}@${projectVersion} | ${componentCount} components`;
    if (vulnCount > 0) {
        summary += ` | ${vulnCount} vulnerabilities`;
    }
    return summary;
}
//# sourceMappingURL=cyclonedx.js.map