2026-04-17·16 min read·

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:


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

ArticleTopicWho it Applies To
Art.20Management body obligationsEssential + Important entities
Art.21Cybersecurity risk management measuresEssential + Important entities
Art.23Incident reporting (24h / 72h / 1 month)Essential + Important entities
Art.24Registration obligationsEssential + Important entities
Art.25Domain name databaseDNS providers, TLD registries
Art.26Coordinated vulnerability disclosureEssential + 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:

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:

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:

SectorEntity TypeExamples
EnergyOperators of essential servicesGrid operators, gas network operators
TransportInfrastructure operatorsAirport operators, rail network managers
BankingCredit institutionsBanks, payment institutions
HealthHealthcare providersHospitals, pharmaceutical manufacturers
Digital infrastructureDNS, TLD, IXP, cloud, CDN, MSSPCloud providers, CDN operators
Digital servicesSearch engines, online marketplaces, social platformsApplicable at EU level
Public administrationGovernment bodiesNational/regional agencies

SaaS developer relevance: You are directly in scope if your product is classified as:

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:

  1. Create a private advisory during remediation — keeps the issue confidential while you work
  2. Request a CVE directly via GitHub (if you are a CNA partner)
  3. 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:

MetricOptionsNotes
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 ImpactH (High), L (Low), N (None)All H = maximum impact

Common SaaS vulnerability scores:

VulnerabilityTypical CVSSSeverity
Unauthenticated RCE9.8–10.0Critical
Authenticated SQLi (full DB read)8.8High
SSRF → internal network access7.5–8.8High
Stored XSS (admin panel)6.1–7.5Medium
IDOR (cross-account data access)6.5Medium
Reflected XSS (self)4.3Medium
Information disclosure (stack trace)2.7Low

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:

CountryCVD Safe Harbour StatusNotes
NetherlandsExplicit (since 2016)NCSC-NL was a pioneer — most mature CVD regime in EU
GermanyPartial — BSI Act §5aCERT-Bund coordination but no blanket researcher immunity
FranceExplicit since 2021ANSSI-coordinated CVD, researchers must notify within 72h
BelgiumExplicit since 2023 CCNDirect outcome of NIS2 transposition
AustriaIn progressNIS2 transposition ongoing as of 2026
IrelandPolicy-basedNo 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:

RegulationRelevant ArticleObligationTimeline
NIS2 Art.26CVD coordinationNotify CSIRT for Critical vulns, cooperate on disclosurePer CSIRT guidance
NIS2 Art.23Incident reportingIf vulnerability is exploited → ICT incident notification24h / 72h / 1 month
DORA Art.10ICT incident classificationDetermine if exploited vulnerability = "major ICT incident"Per DORA thresholds
DORA Art.20Threat intelligenceShare vulnerability IOCs with DORA information sharing arrangementOngoing
GDPR Art.33Data breach notificationIf vulnerability led to personal data breach → DPA notification72 hours
GDPR Art.34Data subject notificationIf high risk to individuals from breachWithout 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:


20-Item NIS2 Art.26 Compliance Checklist

Policy Foundation

Process and Timelines

NIS2 Art.26 Specific

Technical Infrastructure


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:

  1. Have a VDP — a published, researcher-findable policy with safe harbour, scope, and timeline commitments
  2. Track reports systematically — not in an email inbox; use tooling that enforces SLAs and CSIRT notification triggers
  3. 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.