NIS2 Art.26: Coordinated Vulnerability Disclosure (CVD) — Responsible Disclosure Policy and VDP Implementation Guide (2026)
Post #415 in the sota.io EU Cyber Compliance Series
Every SaaS developer who has shipped a product has probably thought about what to do when a security researcher emails to say "I found something." NIS2 Article 26 transforms that informal question into a legal requirement for entities in scope — and gives the broader EU a coordinated framework so that vulnerabilities in critical infrastructure are handled systematically, not ad hoc.
Art.26 is part of NIS2 Chapter IV (security obligations for entities), but its reach extends beyond your own product: it creates a national coordinator role, establishes timelines, and builds a bridge to ENISA's European vulnerability database. For SaaS developers serving EU customers in critical sectors, understanding Art.26 is now as important as understanding Art.21's security measures or Art.23's incident reporting.
This guide covers:
- Art.26 text and structure — what the obligation actually says
- Who must comply and what "coordinated" means in practice
- How to write a NIS2-compliant Vulnerability Disclosure Policy (VDP)
- Python
NIS2VDPManager— full lifecycle tracking from report to patch to disclosure - Integration with GitHub Security Advisories, HackerOne/Intigriti
- CVSS scoring automation and severity triage
- Safe-harbour language and legal considerations
- NIS2 × DORA × GDPR cross-map for vulnerability handling
- 20-item compliance checklist
NIS2 Chapter IV Context: Where Art.26 Fits
Art.26 is the sixth and final article in NIS2 Chapter IV. The chapter builds from governance (Art.20) to measures (Art.21) to incident reporting (Art.23) to registration (Art.24–25) and finally to vulnerability transparency (Art.26):
| Article | Topic | Who it Applies To |
|---|---|---|
| Art.20 | Management body obligations | Essential + Important entities |
| Art.21 | Cybersecurity risk management measures | Essential + Important entities |
| Art.23 | Incident reporting (24h / 72h / 1 month) | Essential + Important entities |
| Art.24 | Registration obligations | Essential + Important entities |
| Art.25 | Domain name database | DNS providers, TLD registries |
| Art.26 | Coordinated vulnerability disclosure | Essential + Important entities + all ICT products/services |
Art.26 is unusual because it imposes obligations at three levels simultaneously: on Member States (national CVD frameworks), on ENISA (European vulnerability database), and on entities (notify relevant CSIRTs of discovered vulnerabilities).
Art.26 Text: What the Directive Actually Requires
NIS2 Art.26 has three main paragraphs:
Art.26(1) — National CVD Framework (Member State obligation): Each Member State shall designate a CSIRT as the "trusted intermediary" for coordinated vulnerability disclosure. The CSIRT must:
- Receive vulnerability notifications
- Coordinate between reporting parties and affected vendors
- Ensure the notifying party is protected (safe harbour)
- Set disclosure timelines
Art.26(2) — ENISA European Vulnerability Database (EU-level obligation): ENISA shall establish and maintain a European vulnerability database (EUVD) as a voluntary registration mechanism for publicly known vulnerabilities in ICT products and services. Unlike the US NVD/CVE system, EUVD is designed to be vendor-neutral and not require US export control compliance.
Art.26(3) — Entity-level CVD obligations: Essential and important entities that discover or are notified of vulnerabilities in their ICT products or services shall notify the relevant CSIRT. The entity must:
- Cooperate with the CSIRT in coordinating the disclosure
- Provide affected party information if known
- Not obstruct responsible disclosure after the coordinated window expires
The key practical implication: If you operate as an essential or important entity under NIS2 and a researcher reports a vulnerability to you, you are now legally required to process that report systematically and cooperate with your national CSIRT rather than ignoring it or threatening legal action.
Who Must Comply with Art.26
Entities in Scope
Art.26(3) applies to essential and important entities as defined in NIS2 Annex I and II:
| Sector | Entity Type | Examples |
|---|---|---|
| Energy | Operators of essential services | Grid operators, gas network operators |
| Transport | Infrastructure operators | Airport operators, rail network managers |
| Banking | Credit institutions | Banks, payment institutions |
| Health | Healthcare providers | Hospitals, pharmaceutical manufacturers |
| Digital infrastructure | DNS, TLD, IXP, cloud, CDN, MSSP | Cloud providers, CDN operators |
| Digital services | Search engines, online marketplaces, social platforms | Applicable at EU level |
| Public administration | Government bodies | National/regional agencies |
SaaS developer relevance: You are directly in scope if your product is classified as:
- A managed security service provider (MSSP)
- A cloud computing service provider (IaaS/PaaS/SaaS at relevant scale)
- A DNS service provider or TLD registry
- Part of the supply chain for essential/important entities (Art.21.2(d) supply chain obligations cascade)
Even if you are not directly in scope, implementing a CVD policy aligned with Art.26 is best practice and increasingly expected by enterprise customers in regulated sectors.
Building a NIS2-Compliant VDP
A Vulnerability Disclosure Policy (VDP) is the public-facing document that tells security researchers how to report vulnerabilities and what to expect. Under Art.26, it must cover four elements:
1. Scope Definition
Clearly define what is in scope (your production systems) and out of scope (third-party services, social engineering, physical access):
IN SCOPE:
- api.example.com and all subdomains
- app.example.com (authenticated and unauthenticated surfaces)
- Our mobile apps (iOS/Android, current release only)
- OAuth and authentication flows
OUT OF SCOPE:
- Third-party services and their infrastructure
- Social engineering attacks
- Denial of service testing
- Automated scanning that causes service degradation
- Vulnerabilities requiring physical access
2. Safe Harbour Language
The most important element for researcher confidence. Under NIS2 Art.26(1)(c), the national CSIRT must ensure the reporting party is protected from legal liability when acting in good faith. Your VDP must reflect this:
SAFE HARBOUR:
We will not pursue legal action against security researchers who:
- Act in good faith and follow this policy
- Do not access, modify, or delete user data beyond what is necessary to demonstrate the vulnerability
- Do not exploit the vulnerability for personal gain or to harm users
- Report the vulnerability to us before public disclosure and give us reasonable time to remediate
- Do not perform actions that would degrade service for other users
We consider good-faith research conducted under this policy to be authorised access
under applicable computer fraud and abuse laws, including the Computer Fraud and
Abuse Act (US) and equivalent EU national legislation. We will not refer such
research to law enforcement.
3. Coordinated Disclosure Timeline
Art.26 requires coordination with the national CSIRT. Your policy should specify timelines that align with the 90-day coordinated disclosure standard while leaving room for CSIRT coordination:
DISCLOSURE TIMELINE:
- Day 0: Report received, acknowledgement sent within 48 hours
- Day 7: Initial triage complete, severity classification shared
- Day 30: Target remediation for Critical/High severity issues
- Day 60: Target remediation for Medium severity issues
- Day 90: Standard coordinated disclosure date (researcher may publish)
- Extension: We may request up to 30 additional days for complex issues
with researcher agreement
- CSIRT coordination: We will notify [national CSIRT] for Critical
vulnerabilities in accordance with NIS2 Art.26
4. Rewards and Recognition
While not required by Art.26, a rewards programme (bug bounty or hall of fame) dramatically increases researcher engagement:
RECOGNITION:
- Hall of Fame: All valid reports receive public acknowledgement (if desired)
- Bug Bounty: Critical = €500–2000, High = €200–500, Medium = €50–200
- CVE Credit: We will credit researchers in CVE publications
Note: Rewards require legal eligibility in your country and compliance
with our terms of service.
Python NIS2VDPManager: Full Lifecycle Tracking
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional
import json
import uuid
class CVSSSeverity(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" # CVSS 0.0
class ReportStatus(Enum):
RECEIVED = "received"
TRIAGING = "triaging"
CONFIRMED = "confirmed"
IN_REMEDIATION = "in_remediation"
PATCHED = "patched"
CSIRT_NOTIFIED = "csirt_notified"
DISCLOSED = "disclosed"
CLOSED_NA = "closed_not_applicable"
CLOSED_DUPE = "closed_duplicate"
@dataclass
class VulnerabilityReport:
id: str = field(default_factory=lambda: f"VR-{str(uuid.uuid4())[:8].upper()}")
received_at: datetime = field(default_factory=datetime.utcnow)
reporter_handle: str = ""
reporter_email: str = ""
title: str = ""
description: str = ""
affected_component: str = ""
cvss_score: float = 0.0
cvss_vector: str = ""
severity: CVSSSeverity = CVSSSeverity.LOW
status: ReportStatus = ReportStatus.RECEIVED
# NIS2 Art.26 specific
csirt_notified: bool = False
csirt_notified_at: Optional[datetime] = None
csirt_reference: str = ""
# Timeline
ack_sent_at: Optional[datetime] = None
triage_completed_at: Optional[datetime] = None
patch_deployed_at: Optional[datetime] = None
disclosed_at: Optional[datetime] = None
disclosure_deadline: Optional[datetime] = None
# CVE
cve_id: Optional[str] = None
cve_reserved_at: Optional[datetime] = None
# Reward
reward_amount: float = 0.0
reward_paid: bool = False
# Internal
internal_notes: list = field(default_factory=list)
external_notes: list = field(default_factory=list)
def __post_init__(self):
self.disclosure_deadline = self.received_at + timedelta(days=90)
self.severity = self._classify_severity()
def _classify_severity(self) -> CVSSSeverity:
if self.cvss_score >= 9.0:
return CVSSSeverity.CRITICAL
elif self.cvss_score >= 7.0:
return CVSSSeverity.HIGH
elif self.cvss_score >= 4.0:
return CVSSSeverity.MEDIUM
elif self.cvss_score > 0.0:
return CVSSSeverity.LOW
return CVSSSeverity.INFORMATIONAL
@property
def days_open(self) -> int:
end = self.disclosed_at or datetime.utcnow()
return (end - self.received_at).days
@property
def days_until_disclosure(self) -> int:
if not self.disclosure_deadline:
return 0
return (self.disclosure_deadline - datetime.utcnow()).days
@property
def is_overdue(self) -> bool:
return (self.days_until_disclosure < 0 and
self.status not in [ReportStatus.DISCLOSED, ReportStatus.CLOSED_NA, ReportStatus.CLOSED_DUPE])
@property
def requires_csirt_notification(self) -> bool:
"""NIS2 Art.26(3): Critical vulnerabilities must be reported to national CSIRT."""
return self.severity == CVSSSeverity.CRITICAL and not self.csirt_notified
class NIS2VDPManager:
"""
Manages the full lifecycle of vulnerability reports in compliance with
NIS2 Article 26 (Coordinated Vulnerability Disclosure).
Tracks: receipt → triage → CSIRT notification → remediation → CVE → disclosure
"""
# Remediation SLAs by severity
REMEDIATION_SLAS = {
CVSSSeverity.CRITICAL: 14, # 14 days
CVSSSeverity.HIGH: 30, # 30 days
CVSSSeverity.MEDIUM: 60, # 60 days
CVSSSeverity.LOW: 90, # 90 days
CVSSSeverity.INFORMATIONAL: 90,
}
# Reward tiers (EUR)
REWARD_TIERS = {
CVSSSeverity.CRITICAL: (500, 2000),
CVSSSeverity.HIGH: (200, 500),
CVSSSeverity.MEDIUM: (50, 200),
CVSSSeverity.LOW: (0, 50),
CVSSSeverity.INFORMATIONAL: (0, 0),
}
def __init__(self,
csirt_email: str,
csirt_name: str = "BSI/CERT-Bund",
cve_cna: str = "mitre"):
self.csirt_email = csirt_email
self.csirt_name = csirt_name
self.cve_cna = cve_cna
self.reports: dict[str, VulnerabilityReport] = {}
def receive_report(self,
title: str,
description: str,
affected_component: str,
reporter_handle: str = "anonymous",
reporter_email: str = "",
cvss_score: float = 0.0,
cvss_vector: str = "") -> VulnerabilityReport:
report = VulnerabilityReport(
title=title,
description=description,
affected_component=affected_component,
reporter_handle=reporter_handle,
reporter_email=reporter_email,
cvss_score=cvss_score,
cvss_vector=cvss_vector,
)
self.reports[report.id] = report
report.status = ReportStatus.TRIAGING
print(f"[VDP] Report received: {report.id} | {report.severity.value.upper()} | {title}")
print(f"[VDP] Disclosure deadline: {report.disclosure_deadline.strftime('%Y-%m-%d')}")
if report.requires_csirt_notification:
print(f"[VDP] CRITICAL: NIS2 Art.26 requires CSIRT notification → {self.csirt_email}")
return report
def send_acknowledgement(self, report_id: str) -> None:
report = self.reports[report_id]
report.ack_sent_at = datetime.utcnow()
report.external_notes.append(
f"{datetime.utcnow().isoformat()} — Acknowledgement sent to {report.reporter_handle}"
)
print(f"[VDP] Ack sent for {report_id} → {report.reporter_handle}")
def complete_triage(self,
report_id: str,
confirmed: bool,
cvss_score: float = None,
internal_note: str = "") -> None:
report = self.reports[report_id]
report.triage_completed_at = datetime.utcnow()
if not confirmed:
report.status = ReportStatus.CLOSED_NA
report.internal_notes.append(f"{datetime.utcnow().isoformat()} — Triage: Not applicable. {internal_note}")
return
if cvss_score is not None:
report.cvss_score = cvss_score
report.severity = report._classify_severity()
report.status = ReportStatus.CONFIRMED
report.internal_notes.append(f"{datetime.utcnow().isoformat()} — Confirmed. CVSS: {report.cvss_score} ({report.severity.value}). {internal_note}")
sla_days = self.REMEDIATION_SLAS[report.severity]
sla_date = datetime.utcnow() + timedelta(days=sla_days)
print(f"[VDP] {report_id} confirmed | {report.severity.value.upper()} | Remediation SLA: {sla_date.strftime('%Y-%m-%d')}")
def notify_csirt(self, report_id: str, csirt_reference: str = "") -> None:
"""NIS2 Art.26(3): Notify national CSIRT for Critical vulnerabilities."""
report = self.reports[report_id]
report.csirt_notified = True
report.csirt_notified_at = datetime.utcnow()
report.csirt_reference = csirt_reference or f"CSIRT-{report.id}"
report.status = ReportStatus.CSIRT_NOTIFIED
report.internal_notes.append(
f"{datetime.utcnow().isoformat()} — CSIRT ({self.csirt_name}) notified. "
f"Reference: {report.csirt_reference}"
)
print(f"[VDP] CSIRT notified for {report_id} | Ref: {report.csirt_reference}")
def mark_patched(self, report_id: str, patch_note: str = "") -> None:
report = self.reports[report_id]
report.patch_deployed_at = datetime.utcnow()
report.status = ReportStatus.PATCHED
report.internal_notes.append(
f"{datetime.utcnow().isoformat()} — Patch deployed. {patch_note}"
)
print(f"[VDP] {report_id} patched | Days open: {report.days_open}")
def reserve_cve(self, report_id: str) -> str:
"""Request CVE reservation via your CNA."""
report = self.reports[report_id]
# In production: call CVE API or MITRE CNA portal
cve_placeholder = f"CVE-{datetime.utcnow().year}-XXXXX"
report.cve_id = cve_placeholder
report.cve_reserved_at = datetime.utcnow()
report.internal_notes.append(
f"{datetime.utcnow().isoformat()} — CVE reserved: {cve_placeholder}"
)
print(f"[VDP] CVE reserved: {cve_placeholder} for {report_id}")
return cve_placeholder
def disclose(self, report_id: str, public_advisory_url: str = "") -> None:
"""Mark as publicly disclosed and credit reporter."""
report = self.reports[report_id]
report.disclosed_at = datetime.utcnow()
report.status = ReportStatus.DISCLOSED
report.external_notes.append(
f"{datetime.utcnow().isoformat()} — Publicly disclosed. "
f"Advisory: {public_advisory_url or 'pending'} | CVE: {report.cve_id or 'N/A'}"
)
print(f"[VDP] {report_id} disclosed | {report.days_open} days from receipt to disclosure")
def calculate_reward(self, report_id: str) -> float:
"""Calculate reward based on CVSS severity tier."""
report = self.reports[report_id]
min_reward, max_reward = self.REWARD_TIERS[report.severity]
# Simple: use midpoint. Production: use full CVSS breakdown
reward = (min_reward + max_reward) / 2
report.reward_amount = reward
print(f"[VDP] Reward for {report_id}: EUR {reward:.0f} ({report.severity.value})")
return reward
def get_overdue_reports(self) -> list[VulnerabilityReport]:
return [r for r in self.reports.values() if r.is_overdue]
def get_pending_csirt_notifications(self) -> list[VulnerabilityReport]:
return [r for r in self.reports.values() if r.requires_csirt_notification]
def dashboard(self) -> dict:
reports = list(self.reports.values())
open_reports = [r for r in reports if r.status not in
[ReportStatus.DISCLOSED, ReportStatus.CLOSED_NA, ReportStatus.CLOSED_DUPE]]
return {
"total": len(reports),
"open": len(open_reports),
"overdue": len(self.get_overdue_reports()),
"pending_csirt": len(self.get_pending_csirt_notifications()),
"by_severity": {s.value: sum(1 for r in reports if r.severity == s) for s in CVSSSeverity},
"by_status": {s.value: sum(1 for r in reports if r.status == s) for s in ReportStatus},
"avg_days_to_patch": self._avg_days_to_patch(reports),
}
def _avg_days_to_patch(self, reports: list) -> float:
patched = [r for r in reports if r.patch_deployed_at]
if not patched:
return 0.0
return sum((r.patch_deployed_at - r.received_at).days for r in patched) / len(patched)
def export_json(self) -> str:
data = []
for r in self.reports.values():
data.append({
"id": r.id,
"title": r.title,
"severity": r.severity.value,
"status": r.status.value,
"cvss": r.cvss_score,
"days_open": r.days_open,
"csirt_notified": r.csirt_notified,
"cve": r.cve_id,
"reporter": r.reporter_handle,
})
return json.dumps(data, indent=2)
# Example: Full lifecycle for a Critical vulnerability
if __name__ == "__main__":
vdp = NIS2VDPManager(
csirt_email="reports@cert-bund.de",
csirt_name="CERT-Bund (BSI)",
)
# Day 0: Researcher reports SQL injection via email
report = vdp.receive_report(
title="SQL Injection in /api/v1/search — unauthenticated",
description="The search endpoint does not sanitise the `q` parameter, allowing "
"UNION-based SQL injection without authentication.",
affected_component="api.example.com/api/v1/search",
reporter_handle="h4x0r_researcher",
reporter_email="researcher@example.com",
cvss_score=9.8,
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
)
# Day 0: Send acknowledgement
vdp.send_acknowledgement(report.id)
# Day 3: Triage confirms vulnerability
vdp.complete_triage(
report.id,
confirmed=True,
internal_note="Reproduced in staging. Full unauthenticated read/write on users table."
)
# Day 3: Critical → notify CSIRT per NIS2 Art.26(3)
if report.requires_csirt_notification:
vdp.notify_csirt(report.id, csirt_reference="CERT-BUND-2026-0142")
# Day 10: Patch deployed
vdp.mark_patched(report.id, patch_note="Parameterised queries across all search endpoints. PR #847 merged.")
# Day 11: Reserve CVE
cve = vdp.reserve_cve(report.id)
# Day 11: Calculate reward
reward = vdp.calculate_reward(report.id)
# Day 14: Coordinate disclosure with researcher
vdp.disclose(report.id, public_advisory_url="https://example.com/security/advisories/2026-001")
print("\n--- Dashboard ---")
import pprint
pprint.pprint(vdp.dashboard())
Integrating with GitHub Security Advisories
GitHub's Security Advisory system is directly compatible with the NIS2 CVD workflow. When you patch a vulnerability, you can:
- Create a private advisory during remediation — keeps the issue confidential while you work
- Request a CVE directly via GitHub (if you are a CNA partner)
- Publish the advisory once patched — automatically feeds into GHSA and NVD
# GitHub Security Advisory API integration
import httpx
GITHUB_TOKEN = "ghp_..."
REPO = "org/repo"
def create_private_advisory(report: VulnerabilityReport) -> str:
"""Create a private security advisory for coordinated handling."""
url = f"https://api.github.com/repos/{REPO}/security-advisories"
payload = {
"summary": report.title,
"description": report.description,
"severity": report.severity.value,
"vulnerabilities": [{
"package": {
"ecosystem": "other",
"name": report.affected_component,
},
}],
}
resp = httpx.post(url, json=payload, headers={
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
resp.raise_for_status()
return resp.json()["html_url"]
def publish_advisory(ghsa_id: str, cve_id: str) -> None:
"""Publish advisory after patch is deployed."""
url = f"https://api.github.com/repos/{REPO}/security-advisories/{ghsa_id}"
httpx.patch(url, json={"state": "published", "cve_id": cve_id}, headers={
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
CVSS 3.1 Quick Scoring Reference
The CVSS score is the most important field in your VDP workflow — it drives SLAs, reward amounts, and CSIRT notification decisions:
| Metric | Options | Notes |
|---|---|---|
| Attack Vector (AV) | N (Network), A (Adjacent), L (Local), P (Physical) | N is worst — remote exploitability |
| Attack Complexity (AC) | L (Low), H (High) | L = no special conditions needed |
| Privileges Required (PR) | N (None), L (Low), H (High) | N is worst — no login required |
| User Interaction (UI) | N (None), R (Required) | N is worst |
| Scope (S) | U (Unchanged), C (Changed) | C if exploit can pivot to other systems |
| CIA Impact | H (High), L (Low), N (None) | All H = maximum impact |
Common SaaS vulnerability scores:
| Vulnerability | Typical CVSS | Severity |
|---|---|---|
| Unauthenticated RCE | 9.8–10.0 | Critical |
| Authenticated SQLi (full DB read) | 8.8 | High |
| SSRF → internal network access | 7.5–8.8 | High |
| Stored XSS (admin panel) | 6.1–7.5 | Medium |
| IDOR (cross-account data access) | 6.5 | Medium |
| Reflected XSS (self) | 4.3 | Medium |
| Information disclosure (stack trace) | 2.7 | Low |
Safe Harbour: National Transposition Considerations
NIS2 is a directive, not a regulation — meaning Member States transpose it into national law with variations. The safe harbour implications of Art.26 differ by country:
| Country | CVD Safe Harbour Status | Notes |
|---|---|---|
| Netherlands | Explicit (since 2016) | NCSC-NL was a pioneer — most mature CVD regime in EU |
| Germany | Partial — BSI Act §5a | CERT-Bund coordination but no blanket researcher immunity |
| France | Explicit since 2021 | ANSSI-coordinated CVD, researchers must notify within 72h |
| Belgium | Explicit since 2023 CCN | Direct outcome of NIS2 transposition |
| Austria | In progress | NIS2 transposition ongoing as of 2026 |
| Ireland | Policy-based | No statutory protection yet — VDP language extra important |
Practical advice: Always include explicit safe harbour language in your VDP regardless of country, and coordinate via your national CSIRT when handling Critical vulnerabilities — that coordination record is your strongest legal protection.
NIS2 × DORA × GDPR Vulnerability Handling Cross-Map
Vulnerability handling is an area where all three major EU regulations overlap:
| Regulation | Relevant Article | Obligation | Timeline |
|---|---|---|---|
| NIS2 Art.26 | CVD coordination | Notify CSIRT for Critical vulns, cooperate on disclosure | Per CSIRT guidance |
| NIS2 Art.23 | Incident reporting | If vulnerability is exploited → ICT incident notification | 24h / 72h / 1 month |
| DORA Art.10 | ICT incident classification | Determine if exploited vulnerability = "major ICT incident" | Per DORA thresholds |
| DORA Art.20 | Threat intelligence | Share vulnerability IOCs with DORA information sharing arrangement | Ongoing |
| GDPR Art.33 | Data breach notification | If vulnerability led to personal data breach → DPA notification | 72 hours |
| GDPR Art.34 | Data subject notification | If high risk to individuals from breach | Without undue delay |
The critical intersection: A Critical vulnerability that is exploited before patching can trigger all three notification streams simultaneously — CSIRT (NIS2 Art.26), competent authority (NIS2 Art.23), DPA (GDPR Art.33), and financial supervisor (DORA Art.19). Your incident response plan must coordinate these notifications to avoid contradictory disclosures.
VDP-as-Code: security.txt and .well-known
RFC 9116 defines security.txt — the standard way researchers find your VDP. It is now referenced in ENISA CVD guidance and many national CSIRTs recommend it:
# https://example.com/.well-known/security.txt
Contact: mailto:security@example.com
Contact: https://example.com/security/vdp
Expires: 2027-04-17T00:00:00.000Z
Encryption: https://example.com/pgp-key.asc
Acknowledgments: https://example.com/security/hall-of-fame
Policy: https://example.com/security/vdp
Preferred-Languages: en, de, fr, nl
Canonical: https://example.com/.well-known/security.txt
# NIS2 Art.26 coordination
CSIRT: https://www.bsi.bund.de/cert-bund (for German-law entities)
Serve this at both /.well-known/security.txt and /security.txt for maximum researcher discoverability.
sota.io: Hosting Your VDP Infrastructure
Running your CVD programme on EU-sovereign infrastructure is itself a compliance signal. When researchers see that your VDP portal and advisory database are hosted in the EU, it demonstrates the seriousness of your security programme:
# Deploy VDP API on sota.io
# sota.yml
service:
name: vdp-api
region: eu-central-1
runtime: python3.12
env:
- CSIRT_EMAIL=reports@cert-bund.de
- CVE_CNA=mitre
- POSTGRES_URL=${DATABASE_URL}
healthcheck:
path: /health
interval: 30s
Key advantages of EU hosting for CVD:
- GDPR compliance: Researcher personal data (email, handle, country) stays in EU jurisdiction
- NIS2 Art.26 alignment: CSIRT coordination happens within EU legal framework
- No US CLOUD Act exposure: Research reports containing vulnerability details cannot be accessed by US law enforcement without EU legal process
20-Item NIS2 Art.26 Compliance Checklist
Policy Foundation
- VDP published — accessible at
/security.txtand/security/vdp - Scope defined — clear in-scope and out-of-scope lists
- Safe harbour language — explicit legal protection for good-faith researchers
- Contact channel — security@yourdomain.com with PGP key published
- Preferred languages — policy available in languages of your main user base
Process and Timelines
- 48-hour acknowledgement — auto-responder or manual SLA defined
- Triage SLA — 7 days from receipt to severity classification
- Remediation SLAs — defined by severity (14/30/60/90 days)
- Disclosure timeline — 90-day coordinated window stated in policy
- Extension process — how to request and agree on timeline extensions
NIS2 Art.26 Specific
- CSIRT identified — national CSIRT email/portal known and documented
- CSIRT notification trigger — Critical (CVSS 9.0+) → mandatory CSIRT notification
- Coordination record — CSIRT reference numbers logged in VDP system
- No obstruction clause — policy explicitly states you will not block disclosure after window
- EUVD readiness — prepare for ENISA European Vulnerability Database submissions
Technical Infrastructure
- Issue tracking — vulnerability reports tracked in dedicated system (not email inbox)
- CVE process — ability to request CVE IDs (via GitHub, MITRE, or own CNA)
- Security advisory template — standardised format for public advisories
- Hall of fame / reward programme — researcher recognition mechanism in place
- Annual review — VDP policy reviewed and
Expiresdate insecurity.txtupdated
Common VDP Mistakes That Create NIS2 Risk
1. Treating the inbox as the system: Forwarding reports to Jira via email leads to missed CSIRT notifications and blown SLAs. Use a dedicated VDP platform or build a lightweight tracker.
2. No safe harbour → researchers go public: Without explicit legal protection, researchers publish immediately rather than waiting 90 days. This means you get zero remediation time and maximum reputational damage.
3. Ignoring CSIRT coordination for Critical issues: Art.26(3) is not optional. A Critical vulnerability disclosed without CSIRT involvement is a compliance failure with Art.26 and potentially Art.23 (if exploited before patching).
4. Out-of-date security.txt: The Expires field is machine-readable. Scanners that find an expired security.txt assume your VDP is abandoned and may disclose immediately rather than wait.
5. Reward programme with no legal review: Bug bounty payments may be taxable in some jurisdictions, and paying researchers in sanctioned countries creates OFAC/EU sanctions risk. Get legal review before launching a paid programme.
Summary: What Art.26 Means for You in Practice
NIS2 Art.26 has three concrete implementation requirements for developers serving EU customers in regulated sectors:
- Have a VDP — a published, researcher-findable policy with safe harbour, scope, and timeline commitments
- Track reports systematically — not in an email inbox; use tooling that enforces SLAs and CSIRT notification triggers
- Coordinate Critical vulnerabilities with your national CSIRT — this is what makes it "coordinated" disclosure under Art.26, not just "responsible" disclosure
The good news: implementing Art.26 properly actually improves your security posture (researchers help you find bugs), reduces breach probability (faster patch cycles), and demonstrates maturity to enterprise customers and regulators alike. A well-run CVD programme is one of the few compliance requirements that pays for itself.
The ENISA European Vulnerability Database (EUVD) is operational as of 2026 and accepts voluntary CVE registrations from EU-based entities — registering your CVEs there, in addition to NVD/Mitre, is a straightforward way to demonstrate Art.26 engagement to your national CSIRT.
Part of the sota.io EU Cyber Compliance Series. Deploy your CVD infrastructure on EU-sovereign hosting — no US CLOUD Act exposure, GDPR-compliant researcher data handling, NIS2 Art.26-ready.