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:
| Obligation | Requirement | Deadline |
|---|---|---|
| CVD Policy | Establish and maintain a policy enabling security researchers to report vulnerabilities | September 2026 |
| ENISA Registration | Register the CVD policy URL in ENISA's European Vulnerability Database (EUVD) | September 2026 |
| security.txt | Publish contact information at /.well-known/security.txt per RFC 9116 | September 2026 |
| 24h ENISA Notification | Notify ENISA within 24 hours of learning of an actively exploited vulnerability | September 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:
- June 11, 2026: Conformity assessment requirements apply (notified bodies for Class II products)
- September 11, 2026: Full application — CVD policy, SBOM, 24h reporting, security updates ALL active
- December 11, 2027: CE marking mandatory on all products with digital elements placed on the EU market
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:
| Dimension | CRA Art.15 | NIS2 Art.26 |
|---|---|---|
| Who it covers | Product manufacturers | Essential and important entities as operators |
| Focus | Security of the PRODUCT you ship | Security of the SERVICES you operate |
| CVD Policy target | Researchers reporting bugs IN your software | Researchers reporting vulnerabilities IN your infrastructure |
| ENISA role | Register CVD policy in EUVD | Coordination through CSIRT network |
| Reporting trigger | Actively exploited vulnerability in your product | Significant incident affecting your services |
| Timeline | 24h notification to ENISA of exploited bug | 24h early warning, 72h incident notification to NCA/CSIRT |
| Penalty exposure | Up 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:
Contact:— email or URL for submitting reportsExpires:— a future date (CRA requires active maintenance)Encryption:— PGP public key URL (recommended)Policy:— URL to the full CVD policy documentPreferred-Languages:— supported languages
3. Acknowledgment and Timeline Commitments
The policy must specify realistic timelines. ENISA's recommended framework:
| Stage | Timeline |
|---|---|
| Acknowledgment of receipt | Within 5 business days |
| Initial triage / severity assessment | Within 14 days |
| Patch development | Depends on severity; critical < 30 days |
| Coordinated public disclosure | 90 days from acknowledgment (standard) |
| Notification if delay needed | Before 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:
- Create ENISA account at
id.enisa.europa.eu - Register your organisation as a CRA manufacturer
- Submit CVD policy URL — ENISA verifies the
security.txtis reachable - Receive EUVD-CVD-XXXX registration ID — add this to your technical documentation (Art.22 Annex V Element 3)
- 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:
- Vulnerability reports containing technical details about your product's weaknesses
- Researcher personal data (names, contact info, payment details for bug bounties)
- Internal product architecture details in your triage notes
- ENISA communication records
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:
- Vulnerability data is not subject to foreign intelligence compulsion
- ENISA coordination happens under a single legal order
- Your CRA Art.15 documentation is not exposed to extraterritorial access
- Researchers have stronger confidence in your safe harbour guarantees
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)
- 1. CVD policy document published at a stable, permanent URL
- 2. Policy covers all products in scope for CRA CE marking
- 3. Scope section names specific products, versions, and exclusions
- 4. Acknowledgment timeline stated (5 business days recommended)
- 5. Triage timeline stated (14 days recommended)
- 6. Disclosure timeline stated (90 days coordinated, with extension process)
- 7. Safe harbour language explicitly protects good-faith researchers
- 8. Policy version controlled with last-updated date
Technical Infrastructure (Items 9–16)
- 9.
security.txtpublished at/.well-known/security.txt(RFC 9116) - 10.
security.txtincludesContact:,Expires:,Policy:fields - 11.
Expires:is set to a future date (not already expired) - 12. PGP encryption key published at
Encryption:URL - 13. Dedicated security inbox (not
info@orsupport@) - 14. Inbox monitored with < 5 business day response SLA
- 15. Bug bounty or acknowledgment reward defined
- 16. Hall of fame / acknowledgments page maintained
ENISA Registration (Items 17–20)
- 17. ENISA EUVD account created and organisation registered
- 18. CVD policy URL submitted and validated in EUVD
- 19. EUVD-CVD-XXXX registration ID recorded in technical documentation (Art.22 Annex V Element 3)
- 20. Annual renewal process documented and assigned to owner
Operational Process (Items 21–27)
- 21. Internal triage process defined with severity × CVSS classification
- 22. Escalation path for Critical / CVSS ≥ 9.0 reports
- 23. 24h ENISA notification procedure for actively exploited vulnerabilities (CRA Art.14)
- 24. CSIRT coordination procedure for cross-border incidents
- 25. Patch development SLAs per severity level
- 26. Reporter communication templates (acknowledgment, triage update, disclosure notice)
- 27. CVE / CWE registration process for confirmed vulnerabilities
Maintenance and Compliance (Items 28–30)
- 28.
security.txtexpiry auto-renewed via CI/CD - 29. CVD policy reviewed and updated at least annually
- 30. CRAVDPManager (or equivalent) tracking all open reports with deadline monitoring
See Also
- EU Cyber Resilience Act Developer Guide: SBOM, CVD, and Security Updates (Art.11–15)
- CRA × NIS2 Overlap: Dual Reporting Obligations for SaaS Developers
- NIS2 Art.26: Coordinated Vulnerability Disclosure (CVD) — Responsible Disclosure Policy
- CRA Article 22: Technical Documentation Requirements and Annex V Compliance
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.