2026-04-28·14 min read·

CRA Article 15: Coordinated Vulnerability Disclosure (CVD) Policy — Manufacturer Obligation Before September 2026

Post #671 in the sota.io EU Cyber Compliance Series

The EU Cyber Resilience Act (CRA, Regulation 2024/2847) introduces a direct legal obligation that most software developers have overlooked: under Article 15, every manufacturer of a product with digital elements must establish, maintain, and publicly register a Coordinated Vulnerability Disclosure (CVD) policy with ENISA before the September 2026 application date.

This is not a best-practice recommendation. It is a mandatory product requirement that sits alongside your CE marking and technical documentation. A missing CVD policy is a conformity failure — and from September 2026, it can trigger market surveillance action, withdrawal orders, and fines up to €15 million or 2.5% of global annual turnover under Art.36.

This guide covers what CRA Art.15 requires, how it differs from NIS2 Art.26, how to build a compliant CVD policy, and how to implement the tracking workflow in Python.


What CRA Article 15 Requires

Article 15 of the CRA places four concrete obligations on manufacturers:

ObligationRequirementDeadline
CVD PolicyEstablish and maintain a policy enabling security researchers to report vulnerabilitiesSeptember 2026
ENISA RegistrationRegister the CVD policy URL in ENISA's European Vulnerability Database (EUVD)September 2026
security.txtPublish contact information at /.well-known/security.txt per RFC 9116September 2026
24h ENISA NotificationNotify ENISA within 24 hours of learning of an actively exploited vulnerabilitySeptember 2026

The 24-hour notification obligation is the sharpest edge. It applies from the moment you become aware of active exploitation — not from the moment you have a patch. You must notify ENISA that you know about it, and ENISA coordinates with CSIRTs across member states.

Application timeline:


CRA Art.15 vs NIS2 Art.26: Two Different CVD Obligations

Developers operating products that are both CRA-covered (software product) and NIS2-covered (essential/important entity) face dual CVD obligations. They are complementary but distinct:

DimensionCRA Art.15NIS2 Art.26
Who it coversProduct manufacturersEssential and important entities as operators
FocusSecurity of the PRODUCT you shipSecurity of the SERVICES you operate
CVD Policy targetResearchers reporting bugs IN your softwareResearchers reporting vulnerabilities IN your infrastructure
ENISA roleRegister CVD policy in EUVDCoordination through CSIRT network
Reporting triggerActively exploited vulnerability in your productSignificant incident affecting your services
Timeline24h notification to ENISA of exploited bug24h early warning, 72h incident notification to NCA/CSIRT
Penalty exposureUp to €15M / 2.5% turnover (Art.36)Up to €10M / 2% turnover for important entities

If you ship a SaaS platform that is also CRA-covered software (e.g., a containerisation platform, monitoring agent, or developer tool), you need both policies. They can be combined into a single Vulnerability Disclosure Policy document, but the ENISA registration and NCA reporting channels are separate.


What a Compliant CVD Policy Must Contain

The CRA does not prescribe exact policy language, but it specifies functional requirements. A compliant CVD policy under Art.15 must cover:

1. Scope Definition

Which products, versions, and components are in scope. Under the CRA, the scope must align with your technical documentation (Art.22) — the same products for which you have a CE marking are in scope for your CVD policy.

2. Reporting Channel

A security contact that researchers can actually reach. RFC 9116 security.txt is the standard mechanism. Your security.txt must include:

3. Acknowledgment and Timeline Commitments

The policy must specify realistic timelines. ENISA's recommended framework:

StageTimeline
Acknowledgment of receiptWithin 5 business days
Initial triage / severity assessmentWithin 14 days
Patch developmentDepends on severity; critical < 30 days
Coordinated public disclosure90 days from acknowledgment (standard)
Notification if delay neededBefore day 90

4. Safe Harbour Language

The policy must explicitly state that good-faith security researchers who comply with the policy will not face legal action. Without clear safe harbour language, researchers will not report vulnerabilities — defeating the purpose of the policy.

5. ENISA EUVD Registration

The final step. Once your policy is published, register its URL at the ENISA European Vulnerability Database. ENISA uses this registry to coordinate disclosure across member state CSIRTs when an actively exploited vulnerability is reported.


Python CRAVDPManager Implementation

Here is a working Python implementation for tracking CVD reports end-to-end under CRA Art.15 obligations:

from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Optional


class CVSSLevel(str, Enum):
    CRITICAL = "critical"     # CVSS 9.0–10.0
    HIGH = "high"             # CVSS 7.0–8.9
    MEDIUM = "medium"         # CVSS 4.0–6.9
    LOW = "low"               # CVSS 0.1–3.9
    INFORMATIONAL = "info"    # No exploitability


class CVDStatus(str, Enum):
    RECEIVED = "received"
    ACKNOWLEDGED = "acknowledged"
    TRIAGING = "triaging"
    PATCH_IN_PROGRESS = "patch_in_progress"
    PATCH_READY = "patch_ready"
    COORDINATING_DISCLOSURE = "coordinating_disclosure"
    ENISA_NOTIFIED = "enisa_notified"          # CRA Art.15 — actively exploited
    PUBLICLY_DISCLOSED = "publicly_disclosed"
    CLOSED = "closed"


@dataclass
class CRAVulnerabilityReport:
    report_id: str
    product: str
    version: str
    summary: str
    reporter_handle: str
    received_at: datetime
    cvss_level: CVSSLevel
    cvss_score: float
    actively_exploited: bool = False
    status: CVDStatus = CVDStatus.RECEIVED
    enisa_notified_at: Optional[datetime] = None
    patch_released_at: Optional[datetime] = None
    disclosed_at: Optional[datetime] = None
    notes: list[str] = field(default_factory=list)

    @property
    def ack_deadline(self) -> datetime:
        return self.received_at + timedelta(days=5)

    @property
    def disclosure_deadline(self) -> datetime:
        return self.received_at + timedelta(days=90)

    @property
    def enisa_deadline(self) -> Optional[datetime]:
        """CRA Art.15: notify ENISA within 24h of knowing about active exploitation."""
        if self.actively_exploited:
            return self.received_at + timedelta(hours=24)
        return None

    def is_enisa_overdue(self) -> bool:
        if not self.actively_exploited:
            return False
        if self.enisa_notified_at:
            return False
        deadline = self.enisa_deadline
        return deadline is not None and datetime.now(timezone.utc) > deadline

    def cra_compliance_gaps(self) -> list[str]:
        gaps = []
        now = datetime.now(timezone.utc)

        if self.status == CVDStatus.RECEIVED and now > self.ack_deadline:
            days_overdue = (now - self.ack_deadline).days
            gaps.append(
                f"OVERDUE: Acknowledgment not sent ({days_overdue}d past 5-day deadline)"
            )

        if self.actively_exploited and not self.enisa_notified_at:
            if self.enisa_deadline and now > self.enisa_deadline:
                hours_overdue = int((now - self.enisa_deadline).total_seconds() / 3600)
                gaps.append(
                    f"CRA BREACH: ENISA not notified of active exploit "
                    f"({hours_overdue}h past 24h deadline)"
                )
            else:
                gaps.append(
                    "WARNING: Active exploitation flagged — ENISA notification required within 24h"
                )

        if now > self.disclosure_deadline and self.status not in {
            CVDStatus.PUBLICLY_DISCLOSED,
            CVDStatus.CLOSED,
            CVDStatus.ENISA_NOTIFIED,
        }:
            days_overdue = (now - self.disclosure_deadline).days
            gaps.append(
                f"OVERDUE: 90-day coordinated disclosure window expired ({days_overdue}d)"
            )

        return gaps


class CRAVDPManager:
    """
    Manages CRA Art.15 CVD obligations for a manufacturer.

    Tracks:
    - Incoming vulnerability reports
    - Acknowledgment and triage timelines
    - ENISA 24h notification for actively exploited vulnerabilities
    - 90-day coordinated disclosure window
    - Compliance gap detection
    """

    def __init__(self, db_path: str = "vdp_reports.json"):
        self.db_path = Path(db_path)
        self.reports: dict[str, CRAVulnerabilityReport] = {}
        if self.db_path.exists():
            self._load()

    def receive_report(
        self,
        product: str,
        version: str,
        summary: str,
        reporter_handle: str,
        cvss_score: float,
        actively_exploited: bool = False,
    ) -> CRAVulnerabilityReport:
        report_id = f"VR-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
        level = self._cvss_to_level(cvss_score)

        report = CRAVulnerabilityReport(
            report_id=report_id,
            product=product,
            version=version,
            summary=summary,
            reporter_handle=reporter_handle,
            received_at=datetime.now(timezone.utc),
            cvss_level=level,
            cvss_score=cvss_score,
            actively_exploited=actively_exploited,
        )
        self.reports[report_id] = report
        self._save()

        if actively_exploited:
            print(
                f"🚨 CRITICAL: Active exploitation flagged for {report_id}. "
                f"ENISA notification required by: {report.enisa_deadline}"
            )
        return report

    def mark_enisa_notified(self, report_id: str) -> None:
        """Record that ENISA has been notified per CRA Art.15."""
        report = self.reports[report_id]
        report.enisa_notified_at = datetime.now(timezone.utc)
        report.status = CVDStatus.ENISA_NOTIFIED
        report.notes.append(
            f"ENISA notified at {report.enisa_notified_at.isoformat()}"
        )
        self._save()

    def compliance_dashboard(self) -> None:
        print("\n=== CRA Art.15 CVD Compliance Dashboard ===\n")
        open_reports = [
            r for r in self.reports.values()
            if r.status not in {CVDStatus.CLOSED, CVDStatus.PUBLICLY_DISCLOSED}
        ]

        if not open_reports:
            print("✅ No open vulnerability reports.")
            return

        for report in sorted(open_reports, key=lambda r: r.cvss_score, reverse=True):
            gaps = report.cra_compliance_gaps()
            status_icon = "🔴" if gaps else "✅"
            print(
                f"{status_icon} [{report.report_id}] {report.product} v{report.version}"
                f" | CVSS {report.cvss_score} ({report.cvss_level.value.upper()})"
                f" | Status: {report.status.value}"
            )
            for gap in gaps:
                print(f"   ⚠️  {gap}")

        all_gaps = [
            g for r in open_reports for g in r.cra_compliance_gaps()
        ]
        breaches = [g for g in all_gaps if "CRA BREACH" in g or "OVERDUE" in g]
        print(f"\nSummary: {len(open_reports)} open reports | {len(breaches)} compliance issues")

    @staticmethod
    def _cvss_to_level(score: float) -> CVSSLevel:
        if score >= 9.0:
            return CVSSLevel.CRITICAL
        if score >= 7.0:
            return CVSSLevel.HIGH
        if score >= 4.0:
            return CVSSLevel.MEDIUM
        if score > 0:
            return CVSSLevel.LOW
        return CVSSLevel.INFORMATIONAL

    def _save(self) -> None:
        data = {}
        for rid, r in self.reports.items():
            data[rid] = {
                "report_id": r.report_id,
                "product": r.product,
                "version": r.version,
                "summary": r.summary,
                "reporter_handle": r.reporter_handle,
                "received_at": r.received_at.isoformat(),
                "cvss_level": r.cvss_level.value,
                "cvss_score": r.cvss_score,
                "actively_exploited": r.actively_exploited,
                "status": r.status.value,
                "enisa_notified_at": r.enisa_notified_at.isoformat() if r.enisa_notified_at else None,
                "patch_released_at": r.patch_released_at.isoformat() if r.patch_released_at else None,
                "disclosed_at": r.disclosed_at.isoformat() if r.disclosed_at else None,
                "notes": r.notes,
            }
        self.db_path.write_text(json.dumps(data, indent=2))

    def _load(self) -> None:
        data = json.loads(self.db_path.read_text())
        for rid, d in data.items():
            self.reports[rid] = CRAVulnerabilityReport(
                report_id=d["report_id"],
                product=d["product"],
                version=d["version"],
                summary=d["summary"],
                reporter_handle=d["reporter_handle"],
                received_at=datetime.fromisoformat(d["received_at"]),
                cvss_level=CVSSLevel(d["cvss_level"]),
                cvss_score=d["cvss_score"],
                actively_exploited=d["actively_exploited"],
                status=CVDStatus(d["status"]),
                enisa_notified_at=datetime.fromisoformat(d["enisa_notified_at"]) if d["enisa_notified_at"] else None,
                patch_released_at=datetime.fromisoformat(d["patch_released_at"]) if d["patch_released_at"] else None,
                disclosed_at=datetime.fromisoformat(d["disclosed_at"]) if d["disclosed_at"] else None,
                notes=d["notes"],
            )

Usage:

mgr = CRAVDPManager()

# Normal vulnerability report
report = mgr.receive_report(
    product="MyProductAgent",
    version="2.1.0",
    summary="SSRF via user-supplied webhook URL allows internal network access",
    reporter_handle="sec-researcher-42",
    cvss_score=7.5,
)
print(f"Report received: {report.report_id}")
print(f"Acknowledgment due by: {report.ack_deadline.date()}")
print(f"Disclosure window closes: {report.disclosure_deadline.date()}")

# Actively exploited — 24h ENISA clock starts
critical = mgr.receive_report(
    product="MyProductAgent",
    version="2.0.x",
    summary="Remote code execution via deserialization of untrusted YAML input",
    reporter_handle="threat-intel-feed",
    cvss_score=9.8,
    actively_exploited=True,
)
# ... after verifying and notifying ENISA:
mgr.mark_enisa_notified(critical.report_id)

mgr.compliance_dashboard()

security.txt Template for CRA Art.15 Compliance

Place this file at /.well-known/security.txt on every domain where your product is accessible:

# CRA Art.15 Compliant security.txt
# Last updated: 2026-04-28

Contact: mailto:security@yourcompany.eu
Contact: https://yourcompany.eu/security/report

Expires: 2027-01-01T00:00:00.000Z

Encryption: https://yourcompany.eu/.well-known/pgp-key.asc

Policy: https://yourcompany.eu/security/cvd-policy

Preferred-Languages: en, de, fr

Acknowledgments: https://yourcompany.eu/security/hall-of-fame

CSIRT: https://csirt.yourcompany.eu/

# CRA Art.15 EUVD Registration:
# CVD Policy registered in ENISA EUVD — ID: EUVD-CVD-XXXX

The CSIRT: field signals to ENISA and national CSIRTs that you have an internal incident response capability. For smaller manufacturers without a dedicated CSIRT, point to your security team's contact page.


ENISA EUVD Registration Process

The European Vulnerability Database (EUVD) at euvd.enisa.europa.eu is the official registry for CRA Art.15 CVD policies. The registration process:

  1. Create ENISA account at id.enisa.europa.eu
  2. Register your organisation as a CRA manufacturer
  3. Submit CVD policy URL — ENISA verifies the security.txt is reachable
  4. Receive EUVD-CVD-XXXX registration ID — add this to your technical documentation (Art.22 Annex V Element 3)
  5. Renew annually — ENISA validates active maintenance

The EUVD also handles incoming vulnerability notifications from researchers who prefer reporting through ENISA rather than directly to manufacturers. ENISA then forwards reports to your registered contact. This is common for researchers who want the formal EU framework as an intermediary.


Common Mistakes to Avoid

Mistake 1: Confusing CRA Art.15 with NIS2 Art.26

NIS2 Art.26 covers how you receive vulnerability reports about your infrastructure (as an essential/important entity). CRA Art.15 covers vulnerability reports about your products (as a manufacturer). If you operate a SaaS product on your own infrastructure, both apply simultaneously.

Mistake 2: Treating CVD as Optional Until December 2027

The CE marking deadline is December 2027, but the reporting and CVD obligations apply from September 2026. You need a functioning CVD policy before you can legally place most products with digital elements on the EU market from September 2026 onward.

Mistake 3: Not Updating the Expires: Field

ENISA validates that your security.txt is currently valid. An expired Expires: field signals inactive maintenance — this is specifically called out in ENISA's EUVD compliance checks. Automate renewal in your CI/CD pipeline.

Mistake 4: Publishing a CVD Policy Without a Working Intake

Manufacturers publish a CVD policy page but route reports to a generic info@ inbox. Researchers stop reporting. ENISA's guidance explicitly requires a dedicated security contact with demonstrably faster response than general support channels.

Mistake 5: Forgetting the 24h ENISA Clock

This is the highest-risk gap. Once your threat intelligence, customer reports, or CISA KEV monitoring indicates active exploitation of a vulnerability in your product, the 24-hour ENISA notification clock starts — regardless of whether you have a patch. Many teams conflate "notify ENISA" with "release a patch" and miss the initial reporting window.


Why EU-Native Infrastructure Matters for CRA Art.15

Under the CLOUD Act, US federal authorities can compel access to data held by US cloud providers without notifying the data controller. For a CRA CVD process, this creates a specific risk:

Your CVD intake system likely processes:

If your CVD management system runs on AWS, Azure, or GCP, the CLOUD Act means US authorities can access this data — including the unpatched vulnerability details that an adversary would find extremely valuable — without your knowledge and without ENISA being notified.

Running your CVD pipeline on EU-native infrastructure (single legal jurisdiction, no US parent corporation) means:

This is the same CLOUD Act risk that affects SBOM storage, technical documentation, and incident response records — a unified EU infrastructure posture addresses all of them simultaneously.


CRA Art.15 CVD Compliance Checklist (30 Items)

Policy Foundation (Items 1–8)

Technical Infrastructure (Items 9–16)

ENISA Registration (Items 17–20)

Operational Process (Items 21–27)

Maintenance and Compliance (Items 28–30)


See Also

EU-Native Hosting

Ready to move to EU-sovereign infrastructure?

sota.io is a German-hosted PaaS — no CLOUD Act exposure, no US jurisdiction, full GDPR compliance by design. Deploy your first app in minutes.