"use strict";
/**
 * Canonical security aggregator (G_v4_8 / 2.4.7 Phase C1).
 *
 * One source of truth for "code findings by severity", "dependency
 * advisories by severity", "unique advisory count", and cross-tool
 * dedup. Every consumer (standalone vuln-scan, health-side scorer,
 * BoM, dashboard) reads from `SecurityAggregate` instead of
 * re-counting raw envelope arrays.
 *
 * The disease this closes (D086 / D087 / D091):
 *
 *   - **D086** Health Security section and standalone vuln-scan Code
 *     Findings table both reported "code findings by severity" but
 *     came up with different numbers (`0C 11H 18M 0L` vs
 *     `0C 17H 14M 0L`) on the same repo. Two consumers, two
 *     aggregation paths, slightly-different inclusion rules.
 *
 *   - **D087** Vuln-scan exec summary said "Subtotal: 70" (sum of
 *     dep-vuln severity buckets) and the same page later said
 *     "81 advisories" (findings.length). 70 vs 81 on one page.
 *
 *   - **D091** A single TLS-bypass root finding surfaced twice in the
 *     Code Findings table (registry-grep at `:74` HIGH, semgrep at
 *     `:72` MEDIUM) because code findings carried no fingerprint and
 *     no cross-tool dedup ran.
 *
 * Architectural posture:
 *
 *   - The aggregator sits BETWEEN gather and reports. Gather still
 *     produces raw envelopes (`gatherSecrets`, `gatherFileFindings`,
 *     `gatherCodePatterns`, `gatherTlsBypassFindings`, `gatherDepVulns`);
 *     the aggregator merges + dedups + buckets them into the canonical
 *     shape; consumers read by field name.
 *
 *   - Three separately-named severity buckets (`codeBySeverity`,
 *     `depBySeverity`, `secretsBySeverity`) — the shape forbids any
 *     consumer from accidentally summing cross-axis again.
 *
 *   - Two named dep counts (`dependencyAdvisoryUniqueCount` for the
 *     canonical user-facing total; `dependencyFindingsRawCount` for
 *     diagnostic audit). Renderers cannot pick "the wrong number"
 *     without naming which they want.
 *
 *   - Code findings get a canonical-rule + line-window fingerprint;
 *     cross-tool collisions collapse to ONE CodeFinding with
 *     `keptSeverity = max(severities)` and `producedBy` listing all
 *     contributing tools. The `dedupCollisions` audit trail records
 *     every collapse for `--detailed` visibility.
 *
 *   - `provenance` distinguishes "tool ran, 0 findings" from "tool
 *     didn't run" — drives D080-style "(not run: typescript)" labels.
 *
 * G_v4_8 architectural gate (`scripts/check-architecture.sh`) blocks
 * `countBySeverity` / severity-Record accumulator declarations
 * outside this file, mirroring G_v4_7's walker allowlist.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildSecurityAggregate = buildSecurityAggregate;
const fingerprint_1 = require("../tools/fingerprint");
// ─── Canonical-rule registry ──────────────────────────────────────────────
/**
 * Maps raw `(tool, rule)` pairs to a canonical rule id. Two raw
 * findings with the same canonical rule (and same file + line window)
 * collapse to one `CodeFinding` with `keptSeverity = max` and
 * `producedBy = [tool₁, tool₂, …]`.
 *
 * Unmapped pairs fall through to `raw:${tool}:${rule}` — never
 * accidentally collapses unrelated findings. Adding a new collapse
 * is a one-line addition here; no aggregator code changes.
 *
 * Initial entries close D091's observed TLS-bypass cross-tool
 * double-counting. Future entries land when a new language pack or
 * semgrep ruleset surfaces overlap with an existing finding type.
 */
// ─── Severity helpers ─────────────────────────────────────────────────────
const SEVERITY_RANK = {
    critical: 3,
    high: 2,
    medium: 1,
    low: 0,
};
function maxSeverity(a, b) {
    return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
}
function emptyCounts() {
    return { critical: 0, high: 0, medium: 0, low: 0 };
}
function bumpCounts(counts, severity) {
    counts[severity]++;
}
/**
 * Build the canonical aggregate from per-gatherer envelopes. Pure
 * function — same input always produces the same output.
 *
 * Dedup pipeline (code-side):
 *   1. Concat raw findings from secrets/fileFindings/codePatterns/tlsBypass.
 *   2. Group by `(canonicalRule, file, lineWindow)` key.
 *   3. For each group:
 *      - Emit ONE `CodeFinding` with `keptSeverity = max(severities)`,
 *        `producedBy` = unique sources.
 *      - If the group had >1 raw finding, record a `DedupCollision`
 *        audit entry.
 *
 * Dedup pipeline (dep-side):
 *   - Group `depVulns.findings` by `fingerprint`.
 *   - For each group: pick the highest-severity entry as the
 *     representative; severity counts are derived from the unique
 *     set so they match `dependencyAdvisoryUniqueCount`.
 *   - Findings without a fingerprint pass through unchanged (defensive;
 *     `stampFingerprints` in `gatherDepVulns` runs before this).
 */
function buildSecurityAggregate(input) {
    // ─── Code-side dedup ────────────────────────────────────────────────
    const rawCodeFindings = [
        ...input.secrets.findings,
        ...input.fileFindings,
        ...input.codePatterns.findings,
        ...input.tlsBypass,
    ];
    const groups = new Map();
    for (const f of rawCodeFindings) {
        const canonicalRule = (0, fingerprint_1.canonicalRuleFor)(f.tool, f.rule);
        const naturalFingerprint = (0, fingerprint_1.computeCodeFingerprint)(canonicalRule, f.file, f.line);
        // C1.10: neighbor-bucket lookup. The 3-line fixed bucket misses
        // adjacent findings that straddle a multiple-of-3 line (the JS-heavy
        // customer frontend surfaced SetupConfigForm.js:43 + :45 → buckets 42
        // + 45 → different keys → no collapse pre-C1.10). Look up the natural
        // bucket first, then
        // the adjacent buckets (naturalBucket ± 3 via `line ± 3`). Same
        // canonical-rule + file + neighbor-bucket counts as a match; merges
        // into that group. Effective dedup window grows from "0-2 lines
        // within one bucket" to "3-5 lines across one bucket boundary."
        let fingerprint = naturalFingerprint;
        let existing = groups.get(fingerprint);
        if (!existing) {
            for (const offset of [-3, 3]) {
                const neighborFingerprint = (0, fingerprint_1.computeCodeFingerprint)(canonicalRule, f.file, f.line + offset);
                const candidate = groups.get(neighborFingerprint);
                if (candidate) {
                    existing = candidate;
                    fingerprint = neighborFingerprint;
                    break;
                }
            }
        }
        if (existing) {
            existing.severity = maxSeverity(existing.severity, f.severity);
            existing.producedBy.add(f.tool);
            existing.raws.push({
                tool: f.tool,
                rule: f.rule,
                line: f.line,
                severity: f.severity,
            });
            // Prefer the lower line number as the canonical line — semgrep
            // typically reports the declaration (earlier line) while
            // registry-grep reports the assignment; the declaration is the
            // more useful navigation target. Tie-break by category preserve.
            if (f.line < existing.line) {
                existing.line = f.line;
                existing.title = f.title;
                existing.rule = f.rule;
                existing.tool = f.tool;
                existing.cwe = f.cwe || existing.cwe;
            }
        }
        else {
            groups.set(fingerprint, {
                fingerprint,
                canonicalRule,
                file: f.file,
                line: f.line,
                severity: f.severity,
                category: f.category,
                cwe: f.cwe,
                rule: f.rule,
                title: f.title,
                tool: f.tool,
                producedBy: new Set([f.tool]),
                raws: [
                    {
                        tool: f.tool,
                        rule: f.rule,
                        line: f.line,
                        severity: f.severity,
                    },
                ],
            });
        }
    }
    const codeFindingsByCategory = {
        secret: [],
        code: [],
        config: [],
    };
    const codeBySeverity = emptyCounts();
    const secretsBySeverity = emptyCounts();
    const dedupCollisions = [];
    for (const g of groups.values()) {
        const finding = {
            severity: g.severity,
            category: g.category,
            cwe: g.cwe,
            rule: g.rule,
            title: g.title,
            file: g.file,
            line: g.line,
            tool: g.tool,
            fingerprint: g.fingerprint,
            canonicalRule: g.canonicalRule,
            producedBy: [...g.producedBy].sort(),
        };
        if (g.category === 'secret') {
            codeFindingsByCategory.secret.push(finding);
            bumpCounts(secretsBySeverity, g.severity);
        }
        else if (g.category === 'config') {
            codeFindingsByCategory.config.push(finding);
            // Config findings (`.env in git`) are secret-adjacent — they
            // share the secrets axis so the Security dimension treats them
            // as the same risk class. Pre-aggregator code paths agreed on
            // this; we preserve it.
            bumpCounts(secretsBySeverity, g.severity);
        }
        else {
            codeFindingsByCategory.code.push(finding);
            bumpCounts(codeBySeverity, g.severity);
        }
        if (g.raws.length > 1) {
            dedupCollisions.push({
                canonicalRule: g.canonicalRule,
                file: g.file,
                line: g.line,
                keptSeverity: g.severity,
                collapsedFrom: g.raws,
            });
        }
    }
    // ─── Dep-side dedup ─────────────────────────────────────────────────
    // Group by fingerprint. Findings without a fingerprint (defensive
    // path — shouldn't happen post-`stampFingerprints`) get a synthetic
    // unique key so they pass through individually.
    const depGroups = new Map();
    let syntheticFingerprintCounter = 0;
    for (const f of input.depVulns.findings) {
        const key = f.fingerprint ?? `__unstamped__${syntheticFingerprintCounter++}`;
        const existing = depGroups.get(key);
        if (!existing) {
            depGroups.set(key, f);
        }
        else {
            // Keep the higher-severity representative. Same advisory may
            // appear multiple times if cross-pack joins didn't already
            // collapse it (defensive).
            if (SEVERITY_RANK[f.severity] > SEVERITY_RANK[existing.severity]) {
                depGroups.set(key, f);
            }
        }
    }
    const uniqueDepFindings = [...depGroups.values()];
    const depBySeverity = emptyCounts();
    for (const f of uniqueDepFindings) {
        bumpCounts(depBySeverity, f.severity);
    }
    // ─── Provenance ─────────────────────────────────────────────────────
    const provenance = {
        secrets: {
            tool: input.secrets.toolUsed,
            ran: input.secrets.toolUsed !== null,
        },
        codePatterns: {
            tool: input.codePatterns.toolUsed,
            ran: input.codePatterns.toolUsed !== null,
        },
        tlsBypass: {
            // ran=true means the registry walk happened (patterns existed).
            // ran=false means no pack registered TLS-bypass patterns — a
            // legitimate "nothing to scan" state on a repo with no active
            // packs declaring patterns.
            ran: input.tlsBypassPatternCount > 0,
            patternCount: input.tlsBypassPatternCount,
        },
        fileFindings: { ran: true },
        depVulns: {
            tool: input.depVulns.tool,
            available: input.depVulns.available,
            unavailableReason: input.depVulns.unavailableReason,
        },
    };
    return {
        codeBySeverity,
        depBySeverity,
        secretsBySeverity,
        findingsByCategory: {
            secret: codeFindingsByCategory.secret,
            code: codeFindingsByCategory.code,
            config: codeFindingsByCategory.config,
            dependency: uniqueDepFindings,
        },
        dependencyAdvisoryUniqueCount: uniqueDepFindings.length,
        dependencyFindingsRawCount: input.depVulns.findings.length,
        dedupCollisions,
        provenance,
    };
}
//# sourceMappingURL=aggregator.js.map