Skip to content

Finding

Unified finding model for all security findings (vulnerabilities, SAST, compliance, enforcement).

agent_bom.finding

Unified Finding model — single stream for all finding types.

Phase 1 (issue #566): core dataclasses + BlastRadius migration shim. Later phases will add cloud CIS, proxy alerts, SAST, and skill findings.

Finding dataclass

Unified finding — one model for all issue types across all sources.

Phase 1 covers CVE findings (migrated from BlastRadius). Phase 2 will add cloud CIS, proxy, SAST, skill findings.

Source code in src/agent_bom/finding.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
@dataclass
class Finding:
    """Unified finding — one model for all issue types across all sources.

    Phase 1 covers CVE findings (migrated from BlastRadius).
    Phase 2 will add cloud CIS, proxy, SAST, skill findings.
    """

    # Core identity
    finding_type: FindingType
    source: FindingSource
    asset: Asset
    severity: str  # mirrors Severity enum value; str for forward-compat

    # Explicit scope (issue #3946) — carried on the finding for query/filter
    # convenience and mirrored onto the asset at ingest. All optional/nullable
    # so existing findings serialize unchanged.
    provider: Optional[str] = None  # aws | azure | gcp | snowflake | ...
    account_ref: Optional[str] = None  # normalized ``<provider>:<account>``
    region: Optional[str] = None
    environment: Optional[str] = None  # prod | staging | dev | ...

    # Vendor severity (from source scanner) vs normalised CVSS severity
    vendor_severity: Optional[str] = None  # severity as reported by vendor/scanner
    cvss_severity: Optional[str] = None  # normalised from CVSS base score

    # Finding content
    title: str = ""
    description: str = ""
    cve_id: Optional[str] = None  # e.g. "CVE-2024-1234"
    cwe_ids: list[str] = field(default_factory=list)  # e.g. ["CWE-79"]
    cvss_score: Optional[float] = None
    cvss_vector: Optional[str] = None
    attack_vector: Optional[str] = None
    attack_complexity: Optional[str] = None
    privileges_required: Optional[str] = None
    user_interaction: Optional[str] = None
    network_exploitable: bool = False
    epss_score: Optional[float] = None
    is_kev: bool = False  # CISA Known Exploited Vulnerability
    is_malicious: bool = False  # Known-malicious package (MAL- IDs, typosquat, etc.)
    malicious_reason: Optional[str] = None

    # Remediation
    fixed_version: Optional[str] = None
    remediation_guidance: Optional[str] = None
    # Structured advisory remediation (fix + least-privilege-to-apply + optional
    # artifact). Additive + optional: findings without it serialize unchanged.
    # Read-only forever — agent-bom recommends, the user applies.
    remediation: Optional["Remediation"] = None

    # Compliance mappings (same tags as BlastRadius for parity)
    compliance_tags: list[str] = field(default_factory=list)  # all framework tags combined
    # Framework slugs that govern this finding (set by compliance_hub.apply_hub_classification).
    # Distinct from the per-framework `*_tags` fields below, which hold control codes.
    applicable_frameworks: list[str] = field(default_factory=list)
    controls: list[ControlTag] = field(default_factory=list)
    owasp_tags: list[str] = field(default_factory=list)
    atlas_tags: list[str] = field(default_factory=list)
    attack_tags: list[str] = field(default_factory=list)
    nist_ai_rmf_tags: list[str] = field(default_factory=list)
    owasp_mcp_tags: list[str] = field(default_factory=list)
    owasp_agentic_tags: list[str] = field(default_factory=list)
    eu_ai_act_tags: list[str] = field(default_factory=list)
    nist_csf_tags: list[str] = field(default_factory=list)
    iso_27001_tags: list[str] = field(default_factory=list)
    soc2_tags: list[str] = field(default_factory=list)
    cis_tags: list[str] = field(default_factory=list)
    cmmc_tags: list[str] = field(default_factory=list)
    nist_800_53_tags: list[str] = field(default_factory=list)
    fedramp_tags: list[str] = field(default_factory=list)
    pci_dss_tags: list[str] = field(default_factory=list)

    # Graph / correlation
    related_findings: list[str] = field(default_factory=list)  # IDs of related findings
    evidence: dict = field(default_factory=dict)  # raw evidence payload
    # First-class graph FKs (optional, additive). ``node_id`` is the estate /
    # asset UnifiedNode this finding attaches to; ``finding_node_id`` is the
    # vulnerability/misconfiguration node (e.g. ``vuln:CVE-…``) when materialised.
    # ``entity_type`` mirrors EntityType.value for the asset node when known.
    node_id: Optional[str] = None
    finding_node_id: Optional[str] = None
    entity_type: Optional[str] = None

    # Risk
    risk_score: float = 0.0  # 0-10 unified risk score
    reachability: Optional[str] = None
    is_actionable: Optional[bool] = None
    impact_category: Optional[str] = None

    # Suppression state (mirrors BlastRadius; preserved through the unified stream
    # so a suppressed finding never appears unsuppressed downstream)
    suppressed: bool = False
    suppression_id: Optional[str] = None
    suppression_state: Optional[str] = None
    suppression_reason: Optional[str] = None
    unsuppressed_risk_score: Optional[float] = None

    # AI-native risk context (mirrors BlastRadius)
    ai_risk_context: Optional[str] = None
    ai_summary: Optional[str] = None
    attack_vector_summary: Optional[str] = None

    # Reach / blast-radius lists — kept structured rather than collapsed to counts
    affected_servers: list[str] = field(default_factory=list)  # MCP server names on the impacted path
    affected_agents: list[str] = field(default_factory=list)  # agent names reachable along the path
    exposed_credentials: list[str] = field(default_factory=list)  # credential env-var names at risk
    exposed_tools: list[str] = field(default_factory=list)  # tool names accessible through the path

    # CWPP runtime/EDR workload evidence (optional, additive). Never implies the
    # workload is clean — summaries carry clean_workload_assertion=False.
    workload_runtime_evidence: Optional[dict] = None

    # Unique ID — deterministic UUID v5 based on content (computed in __post_init__)
    # Pass an explicit id= to override (e.g. when ingesting from external scanner)
    id: str = field(default="")

    def __post_init__(self) -> None:
        """Compute stable ID from finding content if not explicitly set."""
        from agent_bom.graph.severity import normalize_severity

        self.severity = normalize_severity(self.severity)
        # Keep finding scope and asset scope consistent: mirror finding-level
        # scope down to the asset when the asset does not already carry it (and
        # lift asset scope up when only the asset was populated). Non-cloud
        # findings leave every field None, so this is a no-op for them.
        for _scope_field in ("provider", "account_ref", "region", "environment"):
            finding_val = getattr(self, _scope_field)
            asset_val = getattr(self.asset, _scope_field, None)
            if finding_val is not None and asset_val is None:
                setattr(self.asset, _scope_field, finding_val)
            elif finding_val is None and asset_val is not None:
                setattr(self, _scope_field, asset_val)
        if self.vendor_severity is not None:
            self.vendor_severity = normalize_severity(self.vendor_severity)
        if self.cvss_severity is not None:
            self.cvss_severity = normalize_severity(self.cvss_severity)
        self.controls = _dedupe_control_tags(
            [
                *(tag if isinstance(tag, ControlTag) else ControlTag.from_dict(tag) for tag in self.controls),
                *self._legacy_control_tags(),
            ]
        )
        # Derive entity_type from asset_type aliases without mutating asset_type
        # (asset_type feeds stable_id / finding id — must stay byte-stable).
        if not self.entity_type:
            try:
                from agent_bom.graph.asset_entity import entity_type_for_asset_type

                mapped = entity_type_for_asset_type(self.asset.asset_type)
                if mapped is not None:
                    self.entity_type = mapped.value
            except Exception:  # noqa: BLE001 — finding construction must stay resilient
                pass
        if not self.id:
            # Deterministic ID: same CVE on same asset always same ID
            cve_part = self.vulnerability_id or self.title
            pkg_name = ""
            pkg_version = ""
            if self.asset.asset_type == "package" and self.asset.identifier:
                # purl like "pkg:pypi/torch@2.3.0" — extract name/version
                purl = self.asset.identifier
                pkg_part = purl.split("/")[-1] if "/" in purl else purl
                if "@" in pkg_part:
                    pkg_name, pkg_version = pkg_part.rsplit("@", 1)
            elif isinstance(self.evidence, dict):
                # Asset is a server/container/etc — the affected package lives in
                # evidence. Fold it into the discriminator so two CVEs on distinct
                # packages under one asset don't collide on the same id.
                pkg_name = str(self.evidence.get("package_name") or "")
                pkg_version = str(self.evidence.get("package_version") or "")
            self.id = canonical_finding_id(
                self.asset.stable_id,
                cve_part,
                pkg_name,
                pkg_version,
            )

    @property
    def canonical_id(self) -> str:
        """Canonical alias for id used by report and graph consumers."""
        return self.id

    @property
    def vulnerability_id(self) -> Optional[str]:
        """Canonical advisory identity, regardless of CVE/GHSA/OSV namespace.

        ``cve_id`` remains the wire-compatible legacy field. New producers may
        populate ``evidence.vulnerability_id`` and consumers should prefer this
        namespace-neutral alias when joining findings to advisories.
        """
        if self.cve_id:
            return self.cve_id
        raw = self.evidence.get("vulnerability_id") if isinstance(self.evidence, dict) else None
        return str(raw).strip() or None if raw is not None else None

    @property
    def advisory_ids(self) -> list[str]:
        """Return deterministic, de-duplicated CVE/GHSA/OSV advisory aliases."""
        raw: list[object] = [self.vulnerability_id]
        if isinstance(self.evidence, dict):
            raw.extend(self.evidence.get("cve_ids") or [])
            raw.extend(self.evidence.get("advisory_aliases") or [])
            raw.extend(self.evidence.get("advisory_ids") or [])
        seen: set[str] = set()
        result: list[str] = []
        for value in raw:
            item = str(value or "").strip()
            if item and item not in seen:
                seen.add(item)
                result.append(item)
        return result

    @property
    def finding_category(self) -> str:
        """Stable category for consumers while legacy finding types remain intact."""
        if self.finding_type is FindingType.CVE:
            return "vulnerability"
        if self.finding_type in {FindingType.CIS_FAIL, FindingType.CIS_ERROR}:
            return "compliance"
        return self.finding_type.value.lower()

    def _legacy_control_tags(self) -> list[ControlTag]:
        """Return normalized controls derived from legacy tag arrays."""
        tags: list[ControlTag] = []
        for field_name, framework in LEGACY_CONTROL_FIELDS:
            values = getattr(self, field_name)
            for value in values:
                if value:
                    tags.append(
                        ControlTag(
                            framework=framework,
                            control=str(value),
                            version=_LEGACY_CONTROL_VERSION_BY_FRAMEWORK.get(framework, "legacy"),
                            confidence=0.75,
                            source=f"legacy:{field_name}",
                            via=field_name,
                        )
                    )
        return tags

    def normalized_controls(self) -> list[ControlTag]:
        """Return deduplicated structured controls for this finding."""
        return _dedupe_control_tags([*self.controls, *self._legacy_control_tags()])

    def all_compliance_tags(self) -> list[str]:
        """Return deduplicated union of all compliance tag lists."""
        seen: set[str] = set()
        result: list[str] = []
        for tag in (
            self.compliance_tags
            + self.owasp_tags
            + self.atlas_tags
            + self.attack_tags
            + self.nist_ai_rmf_tags
            + self.owasp_mcp_tags
            + self.owasp_agentic_tags
            + self.eu_ai_act_tags
            + self.nist_csf_tags
            + self.iso_27001_tags
            + self.soc2_tags
            + self.cis_tags
            + self.cmmc_tags
            + self.nist_800_53_tags
            + self.fedramp_tags
            + self.pci_dss_tags
            + [tag.control for tag in self.normalized_controls()]
        ):
            if tag not in seen:
                seen.add(tag)
                result.append(tag)
        return result

    def effective_severity(self) -> str:
        """Return the best severity value: vendor > cvss > base severity."""
        return self.vendor_severity or self.cvss_severity or self.severity

    @property
    def security_domain(self) -> str:
        """Derived posture lane: one of cspm/vuln/aspm/dspm/aispm.

        A pure function of source + finding type (+ evidence for the cloud
        data-vs-config split), so the overview and findings surfaces route each
        finding to exactly one coverage lane without double counting.
        """
        from agent_bom.finding_scope import security_domain_for

        return security_domain_for(self.source, self.finding_type, self.evidence)

    def to_dict(self) -> dict:
        """Return a JSON-serializable finding payload."""
        return {
            "schema_version": FINDING_SCHEMA_VERSION,
            "id": self.id,
            "canonical_id": self.canonical_id,
            "finding_type": self.finding_type.value,
            "finding_category": self.finding_category,
            "source": self.source.value,
            "asset": {
                "name": self.asset.name,
                "asset_type": self.asset.asset_type,
                "identifier": self.asset.identifier,
                "location": self.asset.location,
                "stable_id": self.asset.stable_id,
                "canonical_id": self.asset.canonical_id,
                "source_ids": self.asset.source_ids,
                "provider": self.asset.provider,
                "account_ref": self.asset.account_ref,
                "region": self.asset.region,
                "environment": self.asset.environment,
            },
            # First-class scope + taxonomy (issue #3946)
            "provider": self.provider,
            "account_ref": self.account_ref,
            "region": self.region,
            "environment": self.environment,
            "security_domain": self.security_domain,
            "severity": self.severity,
            "effective_severity": self.effective_severity(),
            "vendor_severity": self.vendor_severity,
            "cvss_severity": self.cvss_severity,
            "title": self.title,
            "description": self.description,
            "cve_id": self.cve_id,
            "vulnerability_id": self.vulnerability_id,
            "advisory_ids": self.advisory_ids,
            "cve_ids": self.evidence.get("cve_ids") or ([self.cve_id] if self.cve_id else []),
            "match_confidence_tier": self.evidence.get("match_confidence_tier"),
            "advisory_aliases": self.evidence.get("advisory_aliases") or [],
            "cwe_ids": self.cwe_ids,
            "cvss_score": self.cvss_score,
            "cvss_vector": self.cvss_vector,
            "attack_vector": self.attack_vector,
            "attack_complexity": self.attack_complexity,
            "privileges_required": self.privileges_required,
            "user_interaction": self.user_interaction,
            "network_exploitable": self.network_exploitable,
            "epss_score": self.epss_score,
            "is_kev": self.is_kev,
            "is_malicious": self.is_malicious,
            "malicious_reason": self.malicious_reason,
            "fixed_version": self.fixed_version,
            "remediation_guidance": self.remediation_guidance,
            # Structured advisory remediation — emitted only when populated so
            # findings without it keep their existing serialization shape.
            **({"remediation": self.remediation.to_dict()} if self.remediation is not None else {}),
            "compliance_tags": self.all_compliance_tags(),
            "applicable_frameworks": list(self.applicable_frameworks),
            "controls": [tag.to_dict() for tag in self.normalized_controls()],
            "owasp_tags": self.owasp_tags,
            "atlas_tags": self.atlas_tags,
            "attack_tags": self.attack_tags,
            "nist_ai_rmf_tags": self.nist_ai_rmf_tags,
            "owasp_mcp_tags": self.owasp_mcp_tags,
            "owasp_agentic_tags": self.owasp_agentic_tags,
            "eu_ai_act_tags": self.eu_ai_act_tags,
            "nist_csf_tags": self.nist_csf_tags,
            "iso_27001_tags": self.iso_27001_tags,
            "soc2_tags": self.soc2_tags,
            "cis_tags": self.cis_tags,
            "cmmc_tags": self.cmmc_tags,
            "nist_800_53_tags": self.nist_800_53_tags,
            "fedramp_tags": self.fedramp_tags,
            "pci_dss_tags": self.pci_dss_tags,
            "related_findings": self.related_findings,
            "evidence": self.evidence,
            "node_id": self.node_id,
            "finding_node_id": self.finding_node_id,
            "entity_type": self.entity_type,
            "risk_score": self.risk_score,
            "reachability": self.reachability,
            "is_actionable": self.is_actionable,
            "impact_category": self.impact_category,
            # Suppression state — a suppressed finding must never surface as
            # unsuppressed downstream (mirrors BlastRadius / SARIF suppressions[]).
            "suppressed": self.suppressed,
            "suppression_id": self.suppression_id,
            "suppression_state": self.suppression_state,
            "suppression_reason": self.suppression_reason,
            "unsuppressed_risk_score": self.unsuppressed_risk_score,
            # AI-native risk context
            "ai_risk_context": self.ai_risk_context,
            "ai_summary": self.ai_summary,
            "attack_vector_summary": self.attack_vector_summary,
            # Structured reach / blast-radius lists (not collapsed to counts)
            "affected_servers": list(self.affected_servers),
            "affected_agents": list(self.affected_agents),
            "exposed_credentials": list(self.exposed_credentials),
            "exposed_tools": list(self.exposed_tools),
            # CWPP runtime/EDR — omit when unset so plain findings stay unchanged
            **(
                {"workload_runtime_evidence": dict(self.workload_runtime_evidence)}
                if isinstance(self.workload_runtime_evidence, dict)
                else {}
            ),
        }

canonical_id property

canonical_id: str

Canonical alias for id used by report and graph consumers.

vulnerability_id property

vulnerability_id: Optional[str]

Canonical advisory identity, regardless of CVE/GHSA/OSV namespace.

cve_id remains the wire-compatible legacy field. New producers may populate evidence.vulnerability_id and consumers should prefer this namespace-neutral alias when joining findings to advisories.

advisory_ids property

advisory_ids: list[str]

Return deterministic, de-duplicated CVE/GHSA/OSV advisory aliases.

finding_category property

finding_category: str

Stable category for consumers while legacy finding types remain intact.

security_domain property

security_domain: str

Derived posture lane: one of cspm/vuln/aspm/dspm/aispm.

A pure function of source + finding type (+ evidence for the cloud data-vs-config split), so the overview and findings surfaces route each finding to exactly one coverage lane without double counting.

__post_init__

__post_init__() -> None

Compute stable ID from finding content if not explicitly set.

Source code in src/agent_bom/finding.py
def __post_init__(self) -> None:
    """Compute stable ID from finding content if not explicitly set."""
    from agent_bom.graph.severity import normalize_severity

    self.severity = normalize_severity(self.severity)
    # Keep finding scope and asset scope consistent: mirror finding-level
    # scope down to the asset when the asset does not already carry it (and
    # lift asset scope up when only the asset was populated). Non-cloud
    # findings leave every field None, so this is a no-op for them.
    for _scope_field in ("provider", "account_ref", "region", "environment"):
        finding_val = getattr(self, _scope_field)
        asset_val = getattr(self.asset, _scope_field, None)
        if finding_val is not None and asset_val is None:
            setattr(self.asset, _scope_field, finding_val)
        elif finding_val is None and asset_val is not None:
            setattr(self, _scope_field, asset_val)
    if self.vendor_severity is not None:
        self.vendor_severity = normalize_severity(self.vendor_severity)
    if self.cvss_severity is not None:
        self.cvss_severity = normalize_severity(self.cvss_severity)
    self.controls = _dedupe_control_tags(
        [
            *(tag if isinstance(tag, ControlTag) else ControlTag.from_dict(tag) for tag in self.controls),
            *self._legacy_control_tags(),
        ]
    )
    # Derive entity_type from asset_type aliases without mutating asset_type
    # (asset_type feeds stable_id / finding id — must stay byte-stable).
    if not self.entity_type:
        try:
            from agent_bom.graph.asset_entity import entity_type_for_asset_type

            mapped = entity_type_for_asset_type(self.asset.asset_type)
            if mapped is not None:
                self.entity_type = mapped.value
        except Exception:  # noqa: BLE001 — finding construction must stay resilient
            pass
    if not self.id:
        # Deterministic ID: same CVE on same asset always same ID
        cve_part = self.vulnerability_id or self.title
        pkg_name = ""
        pkg_version = ""
        if self.asset.asset_type == "package" and self.asset.identifier:
            # purl like "pkg:pypi/torch@2.3.0" — extract name/version
            purl = self.asset.identifier
            pkg_part = purl.split("/")[-1] if "/" in purl else purl
            if "@" in pkg_part:
                pkg_name, pkg_version = pkg_part.rsplit("@", 1)
        elif isinstance(self.evidence, dict):
            # Asset is a server/container/etc — the affected package lives in
            # evidence. Fold it into the discriminator so two CVEs on distinct
            # packages under one asset don't collide on the same id.
            pkg_name = str(self.evidence.get("package_name") or "")
            pkg_version = str(self.evidence.get("package_version") or "")
        self.id = canonical_finding_id(
            self.asset.stable_id,
            cve_part,
            pkg_name,
            pkg_version,
        )

normalized_controls

normalized_controls() -> list[ControlTag]

Return deduplicated structured controls for this finding.

Source code in src/agent_bom/finding.py
def normalized_controls(self) -> list[ControlTag]:
    """Return deduplicated structured controls for this finding."""
    return _dedupe_control_tags([*self.controls, *self._legacy_control_tags()])

all_compliance_tags

all_compliance_tags() -> list[str]

Return deduplicated union of all compliance tag lists.

Source code in src/agent_bom/finding.py
def all_compliance_tags(self) -> list[str]:
    """Return deduplicated union of all compliance tag lists."""
    seen: set[str] = set()
    result: list[str] = []
    for tag in (
        self.compliance_tags
        + self.owasp_tags
        + self.atlas_tags
        + self.attack_tags
        + self.nist_ai_rmf_tags
        + self.owasp_mcp_tags
        + self.owasp_agentic_tags
        + self.eu_ai_act_tags
        + self.nist_csf_tags
        + self.iso_27001_tags
        + self.soc2_tags
        + self.cis_tags
        + self.cmmc_tags
        + self.nist_800_53_tags
        + self.fedramp_tags
        + self.pci_dss_tags
        + [tag.control for tag in self.normalized_controls()]
    ):
        if tag not in seen:
            seen.add(tag)
            result.append(tag)
    return result

effective_severity

effective_severity() -> str

Return the best severity value: vendor > cvss > base severity.

Source code in src/agent_bom/finding.py
def effective_severity(self) -> str:
    """Return the best severity value: vendor > cvss > base severity."""
    return self.vendor_severity or self.cvss_severity or self.severity

to_dict

to_dict() -> dict

Return a JSON-serializable finding payload.

Source code in src/agent_bom/finding.py
def to_dict(self) -> dict:
    """Return a JSON-serializable finding payload."""
    return {
        "schema_version": FINDING_SCHEMA_VERSION,
        "id": self.id,
        "canonical_id": self.canonical_id,
        "finding_type": self.finding_type.value,
        "finding_category": self.finding_category,
        "source": self.source.value,
        "asset": {
            "name": self.asset.name,
            "asset_type": self.asset.asset_type,
            "identifier": self.asset.identifier,
            "location": self.asset.location,
            "stable_id": self.asset.stable_id,
            "canonical_id": self.asset.canonical_id,
            "source_ids": self.asset.source_ids,
            "provider": self.asset.provider,
            "account_ref": self.asset.account_ref,
            "region": self.asset.region,
            "environment": self.asset.environment,
        },
        # First-class scope + taxonomy (issue #3946)
        "provider": self.provider,
        "account_ref": self.account_ref,
        "region": self.region,
        "environment": self.environment,
        "security_domain": self.security_domain,
        "severity": self.severity,
        "effective_severity": self.effective_severity(),
        "vendor_severity": self.vendor_severity,
        "cvss_severity": self.cvss_severity,
        "title": self.title,
        "description": self.description,
        "cve_id": self.cve_id,
        "vulnerability_id": self.vulnerability_id,
        "advisory_ids": self.advisory_ids,
        "cve_ids": self.evidence.get("cve_ids") or ([self.cve_id] if self.cve_id else []),
        "match_confidence_tier": self.evidence.get("match_confidence_tier"),
        "advisory_aliases": self.evidence.get("advisory_aliases") or [],
        "cwe_ids": self.cwe_ids,
        "cvss_score": self.cvss_score,
        "cvss_vector": self.cvss_vector,
        "attack_vector": self.attack_vector,
        "attack_complexity": self.attack_complexity,
        "privileges_required": self.privileges_required,
        "user_interaction": self.user_interaction,
        "network_exploitable": self.network_exploitable,
        "epss_score": self.epss_score,
        "is_kev": self.is_kev,
        "is_malicious": self.is_malicious,
        "malicious_reason": self.malicious_reason,
        "fixed_version": self.fixed_version,
        "remediation_guidance": self.remediation_guidance,
        # Structured advisory remediation — emitted only when populated so
        # findings without it keep their existing serialization shape.
        **({"remediation": self.remediation.to_dict()} if self.remediation is not None else {}),
        "compliance_tags": self.all_compliance_tags(),
        "applicable_frameworks": list(self.applicable_frameworks),
        "controls": [tag.to_dict() for tag in self.normalized_controls()],
        "owasp_tags": self.owasp_tags,
        "atlas_tags": self.atlas_tags,
        "attack_tags": self.attack_tags,
        "nist_ai_rmf_tags": self.nist_ai_rmf_tags,
        "owasp_mcp_tags": self.owasp_mcp_tags,
        "owasp_agentic_tags": self.owasp_agentic_tags,
        "eu_ai_act_tags": self.eu_ai_act_tags,
        "nist_csf_tags": self.nist_csf_tags,
        "iso_27001_tags": self.iso_27001_tags,
        "soc2_tags": self.soc2_tags,
        "cis_tags": self.cis_tags,
        "cmmc_tags": self.cmmc_tags,
        "nist_800_53_tags": self.nist_800_53_tags,
        "fedramp_tags": self.fedramp_tags,
        "pci_dss_tags": self.pci_dss_tags,
        "related_findings": self.related_findings,
        "evidence": self.evidence,
        "node_id": self.node_id,
        "finding_node_id": self.finding_node_id,
        "entity_type": self.entity_type,
        "risk_score": self.risk_score,
        "reachability": self.reachability,
        "is_actionable": self.is_actionable,
        "impact_category": self.impact_category,
        # Suppression state — a suppressed finding must never surface as
        # unsuppressed downstream (mirrors BlastRadius / SARIF suppressions[]).
        "suppressed": self.suppressed,
        "suppression_id": self.suppression_id,
        "suppression_state": self.suppression_state,
        "suppression_reason": self.suppression_reason,
        "unsuppressed_risk_score": self.unsuppressed_risk_score,
        # AI-native risk context
        "ai_risk_context": self.ai_risk_context,
        "ai_summary": self.ai_summary,
        "attack_vector_summary": self.attack_vector_summary,
        # Structured reach / blast-radius lists (not collapsed to counts)
        "affected_servers": list(self.affected_servers),
        "affected_agents": list(self.affected_agents),
        "exposed_credentials": list(self.exposed_credentials),
        "exposed_tools": list(self.exposed_tools),
        # CWPP runtime/EDR — omit when unset so plain findings stay unchanged
        **(
            {"workload_runtime_evidence": dict(self.workload_runtime_evidence)}
            if isinstance(self.workload_runtime_evidence, dict)
            else {}
        ),
    }

FindingType

Bases: str, Enum

What category of issue this finding represents.

Source code in src/agent_bom/finding.py
class FindingType(str, Enum):
    """What category of issue this finding represents."""

    CVE = "CVE"  # Legacy software-vulnerability value (from OSV/GHSA/NVIDIA)
    CIS_FAIL = "CIS_FAIL"  # CIS benchmark control failure
    CIS_ERROR = "CIS_ERROR"  # CIS control could not be evaluated reliably
    CLOUD_BEST_PRACTICE_FAIL = "CLOUD_BEST_PRACTICE_FAIL"
    CLOUD_BEST_PRACTICE_ERROR = "CLOUD_BEST_PRACTICE_ERROR"
    CREDENTIAL_EXPOSURE = "CREDENTIAL_EXPOSURE"  # Credential found in environment/config
    TOOL_DRIFT = "TOOL_DRIFT"  # MCP tool description changed (rug pull)
    INJECTION = "INJECTION"  # Prompt/argument injection in MCP tool
    PROMPT_SECURITY = "PROMPT_SECURITY"  # Prompt template or prompt content security finding
    EXFILTRATION = "EXFILTRATION"  # Data exfiltration pattern detected by proxy
    CLOAKING = "CLOAKING"  # Invisible chars / SVG cloaking in response
    SAST = "SAST"  # Static analysis finding (CWE-mapped)
    SKILL_RISK = "SKILL_RISK"  # Behavioral risk in AI skill file
    BROWSER_EXT = "BROWSER_EXT"  # Suspicious browser extension
    LICENSE = "LICENSE"  # License compliance violation
    RATE_LIMIT = "RATE_LIMIT"  # Rate limit abuse by MCP tool
    MCP_BLOCKLIST = "MCP_BLOCKLIST"  # Curated malicious/suspicious MCP server match
    COMBINATION = "COMBINATION"  # Toxic combination — multiple signals chained into one exploitable path
    MALICIOUS_PACKAGE = "MALICIOUS_PACKAGE"  # Known-malicious / typosquat package with no CVE row
    MALICIOUS_MODEL = "MALICIOUS_MODEL"  # Content-confirmed executable payload in a model artifact
    MODEL_INTEGRITY = "MODEL_INTEGRITY"  # Model artifact provenance/integrity gap (tampered, unsigned, unscanned)
    CIEM_OVER_PRIVILEGE = "CIEM_OVER_PRIVILEGE"  # Cloud identity granted permissions it never uses (right-sizing)
    SENSITIVE_DATA = "SENSITIVE_DATA"  # Content-confirmed sensitive data at rest (DSPM object/database sampling)

FindingSource

Bases: str, Enum

Which scanner or subsystem produced this finding.

Source code in src/agent_bom/finding.py
class FindingSource(str, Enum):
    """Which scanner or subsystem produced this finding."""

    MCP_SCAN = "MCP_SCAN"  # agent discovery + CVE scanner
    CONTAINER = "CONTAINER"  # container image scan (Syft/Grype/Trivy ingestion)
    SBOM = "SBOM"  # SBOM ingest (CycloneDX / SPDX)
    CLOUD_CIS = "CLOUD_CIS"  # cloud CIS benchmark (AWS/Azure/GCP/Snowflake)
    CLOUD_SECURITY = "CLOUD_SECURITY"  # vendor-authored cloud security best practices
    PROXY = "PROXY"  # runtime proxy detector
    SAST = "SAST"  # static analysis (Semgrep)
    SKILL = "SKILL"  # skill file auditor
    BROWSER_EXT = "BROWSER_EXT"  # browser extension scanner
    EXTERNAL = "EXTERNAL"  # ingested from external scanner (Trivy/Grype/Syft JSON)
    FILESYSTEM = "FILESYSTEM"  # filesystem mount scan
    PROMPT_SCAN = "PROMPT_SCAN"  # prompt template/content scanner
    SECRET_SCAN = "SECRET_SCAN"  # hardcoded secret / PII scanner
    GRAPH_ANALYSIS = "GRAPH_ANALYSIS"  # graph-level correlation (toxic combinations, attack-path fusion)
    DSPM = "DSPM"  # data security posture content classifier (S3/GCS/Azure Blob/database sampling)
    MODEL_SCAN = "MODEL_SCAN"  # static model-artifact safety scanner

Asset dataclass

What is affected by this finding.

Source code in src/agent_bom/finding.py
@dataclass
class Asset:
    """What is affected by this finding."""

    name: str  # human-readable name (server name, package name, cloud resource ID)
    asset_type: str  # "mcp_server" | "package" | "container" | "cloud_resource" | "agent"
    identifier: Optional[str] = None  # purl, ARN, image digest, etc.
    location: Optional[str] = None  # file path, URL, cloud region

    # Explicit scope — where this asset lives. Optional/nullable so non-cloud
    # assets (packages, files) serialize unchanged. ``account_ref`` is a single
    # normalized string (e.g. ``aws:123456789012``) built by finding_scope.
    provider: Optional[str] = None  # aws | azure | gcp | snowflake | ...
    account_ref: Optional[str] = None  # normalized ``<provider>:<account>``
    region: Optional[str] = None
    environment: Optional[str] = None  # prod | staging | dev | ...

    @property
    def stable_id(self) -> str:
        """Deterministic UUID derived from asset content.

        Same asset type + identifier always produces the same ID across scans.
        This enables tracking: first seen, last seen, resolved, re-emerged.
        """
        identifier = self.identifier or f"{self.name}:{self.location or ''}"
        return _stable_id(self.asset_type, identifier)

    @property
    def canonical_id(self) -> str:
        """Canonical alias for stable_id used by reports and graph joins."""
        return self.stable_id

    @property
    def source_ids(self) -> dict[str, str]:
        """Original source identifiers retained as provenance."""
        return source_ids(identifier=self.identifier, location=self.location)

stable_id property

stable_id: str

Deterministic UUID derived from asset content.

Same asset type + identifier always produces the same ID across scans. This enables tracking: first seen, last seen, resolved, re-emerged.

canonical_id property

canonical_id: str

Canonical alias for stable_id used by reports and graph joins.

source_ids property

source_ids: dict[str, str]

Original source identifiers retained as provenance.