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
@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

    # 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
    epss_score: Optional[float] = None
    is_kev: bool = False  # CISA Known Exploited Vulnerability

    # Remediation
    fixed_version: Optional[str] = None
    remediation_guidance: Optional[str] = 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)
    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)

    # Graph / correlation
    related_findings: list[str] = field(default_factory=list)  # IDs of related findings
    evidence: dict = field(default_factory=dict)  # raw evidence payload

    # Risk
    risk_score: float = 0.0  # 0-10 unified risk score

    # 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."""
        if not self.id:
            # Deterministic ID: same CVE on same asset always same ID
            cve_part = self.cve_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)
            self.id = _stable_id(
                self.asset.stable_id,
                cve_part,
                pkg_name,
                pkg_version,
            )

    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
        ):
            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

    def to_dict(self) -> dict:
        """Return a JSON-serializable finding payload."""
        return {
            "id": self.id,
            "finding_type": self.finding_type.value,
            "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,
            },
            "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,
            "cwe_ids": self.cwe_ids,
            "cvss_score": self.cvss_score,
            "epss_score": self.epss_score,
            "is_kev": self.is_kev,
            "fixed_version": self.fixed_version,
            "remediation_guidance": self.remediation_guidance,
            "compliance_tags": self.all_compliance_tags(),
            "applicable_frameworks": list(self.applicable_frameworks),
            "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,
            "related_findings": self.related_findings,
            "evidence": self.evidence,
            "risk_score": self.risk_score,
        }

__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."""
    if not self.id:
        # Deterministic ID: same CVE on same asset always same ID
        cve_part = self.cve_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)
        self.id = _stable_id(
            self.asset.stable_id,
            cve_part,
            pkg_name,
            pkg_version,
        )

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
    ):
        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 {
        "id": self.id,
        "finding_type": self.finding_type.value,
        "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,
        },
        "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,
        "cwe_ids": self.cwe_ids,
        "cvss_score": self.cvss_score,
        "epss_score": self.epss_score,
        "is_kev": self.is_kev,
        "fixed_version": self.fixed_version,
        "remediation_guidance": self.remediation_guidance,
        "compliance_tags": self.all_compliance_tags(),
        "applicable_frameworks": list(self.applicable_frameworks),
        "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,
        "related_findings": self.related_findings,
        "evidence": self.evidence,
        "risk_score": self.risk_score,
    }

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"  # Software vulnerability (from OSV/GHSA/NVIDIA)
    CIS_FAIL = "CIS_FAIL"  # CIS benchmark control failure
    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
    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

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)
    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

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

    @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)

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.