Mert Özen Further With Every Line
Security Threat Modeling and a Secure Development Process — #04

Vulnerability Management for Your Own Product: security.txt, Coordinated Disclosure, GitHub Security Advisories and CVEs

Mert Özen Nov 15, 2026 10 min 5 views
Vulnerability Management for Your Own Product: security.txt, Coordinated Disclosure, GitHub Security Advisories and CVEs

A vulnerability report from outside first needs a door: an RFC 9116 security.txt, SECURITY.md and GitHub private vulnerability reporting; then triage, a private fix in the draft advisory, a CVE request and publication on the same day as the fixed release. Serving and testing security.txt from the Bookstore API, sectxt validation, the advisory flow for the Bookstore.Contracts NuGet package, bug bounties and safe harbor.

Version note: The code in this post was built and run with .NET 10; the security.txt file was validated with sectxt 0.9.8. The GitHub Security Advisory and CVE steps are described from GitHub's documentation.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, published a NuGet package called Bookstore.Contracts in the Versioning and Release Management series; the integration teams of institutional customers use it. A security researcher finds a regular expression in the package's ISBN validation that keeps the CPU busy for seconds with a long, specially crafted input: a classic ReDoS.

Guess: whom will the researcher tell? Bookstore's site has no security contact, and the repo has no SECURITY.md. The researcher means well and looks for a way; the only one they find is a public GitHub issue. The moment the issue is opened, the vulnerability is out in the open, without a fix. In this post we talk about how this scenario should not happen: how you receive a report, how you triage it, and how you keep the fix private and announce it with an advisory and a CVE.

security.txt: Contact Details at the Door

The standard place to find a site's security contact is the /.well-known/security.txt file. RFC 9116 defines it; E. Foudil and Y. Shafranovich published it in April 2022. The rules are short: the file is served over HTTPS as text/plain; charset=utf-8. Contact is required and may appear more than once; Expires is required, appears exactly once, and is recommended to be less than a year away. Preferred-Languages, Canonical, Policy, Acknowledgments, Encryption and Hiring are optional. Two more fields were added to the IANA registry later: CSAF in 2023 and Bug-Bounty in March 2026. Signing the file with OpenPGP is recommended too.

Bookstore's file lives in the repo next to the code, and every change to it is reviewed through a PR:

Contact: mailto:security@bookstore.example.com
Contact: https://bookstore.example.com/security/report
Expires: 2027-05-15T00:00:00Z
Preferred-Languages: tr, en
Canonical: https://bookstore.example.com/.well-known/security.txt
Policy: https://bookstore.example.com/security/policy
Acknowledgments: https://bookstore.example.com/security/thanks

The API reads the file at startup and serves it as-is. Generating it dynamically and pushing Expires a year ahead on every request can be tempting; but the field exists to show that a person has confirmed the information is current. A date that renews itself kills that signal.

namespace Bookstore.Security;

// RFC 9116: the file lives in the repo, is reviewed like code and is served as-is.
public static class SecurityTxtEndpoint
{
    public const string Path = "/.well-known/security.txt";

    public static IEndpointRouteBuilder MapSecurityTxt(this IEndpointRouteBuilder app)
    {
        IWebHostEnvironment env = app.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
        string content = File.ReadAllText(System.IO.Path.Combine(env.ContentRootPath, "security.txt"));

        app.MapGet(Path, () => Results.Text(content, "text/plain; charset=utf-8"))
            .ExcludeFromDescription();
        return app;
    }
}

To get the file into the publish output, the line <None Update="security.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> was added to the csproj. Running the API on 127.0.0.1 and sending the request:

curl -si http://127.0.0.1:52864/.well-known/security.txt
HTTP/1.1 200 OK
Content-Length: 343
Content-Type: text/plain; charset=utf-8
Date: <date>
Server: Kestrel

Contact: mailto:security@bookstore.example.com
Contact: https://bookstore.example.com/security/report
Expires: 2027-05-15T00:00:00Z
Preferred-Languages: tr, en
Canonical: https://bookstore.example.com/.well-known/security.txt
Policy: https://bookstore.example.com/security/policy
Acknowledgments: https://bookstore.example.com/security/thanks

Locally we used HTTP; in production TLS terminates at the edge and the file is reachable only over HTTPS. We checked the format with sectxt, an open source validator developed by the Dutch Digital Trust Center; the small script is in the repo as tools/check-security-txt.py:

pip install sectxt==0.9.8
python tools/check-security-txt.py src/Bookstore.Api/security.txt
valid: True
recommendation: no_encryption: 'Encryption' field should be present when 'Contact' field contains an email address.
recommendation: not_signed: security.txt should be digitally signed.

Both recommendations correspond to deliberate decisions. The Bookstore team does not manage an OpenPGP key; a team without a key asking for encrypted e-mail drops reports into a box nobody can open. Instead, the second Contact line is a form that works over HTTPS. The signature is missing for the same reason for now; both will be added together when the team sets up key management. This decision went into threat-model/risk-register.md as a row.

Expires is a field people forget. In Bookstore a test checks that the date is not more than a year away and not closer than 30 days; in the last 30 days the build turns red and reminds the team to renew:

[Fact]
public async Task Security_txt_expires_in_less_than_a_year_and_not_within_30_days()
{
    string body = await factory.CreateClient().GetStringAsync("/.well-known/security.txt", ct);
    string value = body.Split('\n').Single(l => l.StartsWith("Expires: ", StringComparison.Ordinal))["Expires: ".Length..];
    DateTimeOffset expires = DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
    TimeSpan left = expires - DateTimeOffset.UtcNow;

    // RFC 9116 recommends less than a year; 30 days before expiry this test fails and reminds us to renew.
    Assert.True(left < TimeSpan.FromDays(365), $"Expires is too far away: {value}");
    Assert.True(left > TimeSpan.FromDays(30), $"security.txt expires on {value}: renew it and ask the security champion to review the contacts.");
}

SECURITY.md and GitHub Private Vulnerability Reporting

security.txt is for researchers who come to the site; researchers who come to the repo look at SECURITY.md first. GitHub recognises this file in the repo root, in docs/ or in .github/, and shows it on the Security tab. Bookstore's file answers four questions: where to report, what happens next, which versions are supported, and what is promised to a researcher acting in good faith. The "what happens next" part:

| Step | Target |
|---|---|
| We confirm that we received the report | 3 business days |
| First assessment: valid or not, severity, affected versions | 10 business days |
| Fix released: Critical / High | 14 days / 30 days after the assessment |
| Fix released: Medium / Low | Next planned release, at most 90 days |
| Public advisory | When the fix is available, on a date agreed with you |

Writing down target times commits the team; that is a good thing. A researcher who knows when they will hear back is patient; one who does not reads silence as indifference and sets their own date.

On GitHub, the second step is private vulnerability reporting. In public repositories the owner or an admin turns it on in the security section of the repository settings; after that anyone can send a private report with the "Report a vulnerability" button on the Security tab. The report opens as a draft security advisory visible to the maintainers and the reporter; the reporter is automatically added to the advisory as a collaborator and as a credited person. Note: adding SECURITY.md does not turn this feature on; they are separate settings.

Triage, the Draft Advisory and the CVE

When a report arrives, the first reply goes out within three business days and is short: thanks, who is following up, and when the next update will come. The first reply does not get into a "this is not a vulnerability" debate; a rejection given before any assessment pushes the researcher towards the public route. In triage the vulnerability is reproduced locally or in a test environment, the team decides whether it really is a vulnerability, sets the severity and lists the affected versions. CVSS is used to score a vulnerability that has been found; its current version is 4.0, published by FIRST in November 2023, and scoring in detail is covered in the dependency scanning post of the DevSecOps series.

The fix happens inside the draft advisory. According to GitHub's documentation, a draft advisory is visible only to maintainers and the collaborators added to it; in the temporary private fork opened from the advisory, the fix is prepared and reviewed without touching the public repo. The CVE is requested from the same screen: GitHub is a CNA, so you can request an identifier without publishing the advisory. According to the documentation, GitHub usually reviews the request within 72 hours. In the CVE program an identifier is first in the Reserved state and becomes Published once the record is filled in and published; an identifier that should not be used stays on the list as Rejected. According to a cve.org announcement from March 2026, the program has more than 500 CNAs.

The advisory text is written from a template; in Bookstore the template lives in the repo as part of a runbook:

# Security advisory template (GitHub repository security advisory)

Fill this in the draft advisory, never in a public issue or a commit message.

- Title: <component>: <weakness> allows <impact>
- Package / ecosystem: NuGet, `Bookstore.Contracts`
- Affected versions: `>= 2.0.0, < 2.1.1`
- Patched versions: `2.1.1`
- Severity: CVSS 4.0 vector and score, plus our own reasoning in one sentence
- Weakness: CWE id
- Credits: reporter name or handle, as they want to be credited

While preparing this series we did not create a real advisory, repository or CVE request; the steps here are based on GitHub's current documentation.

Release Day: Bookstore.Contracts 2.1.1

A coordinated release means the fix and the announcement come out on the same day, on the date agreed with the researcher. The order matters: the fixed package first, then the advisory. Do it the other way round and users learn about the vulnerability but find no version to upgrade to; an attacker, with the same information, moves before them.

04-disclosure-timeline-en.png

For Bookstore the flow is: 2.1.1 goes out to nuget.org through the pipeline from the NuGet publishing post of the Versioning and Release Management series. Once the package is indexed, the draft advisory is published. For ecosystems it supports, such as NuGet, GitHub takes the published advisory into the GitHub Advisory Database, and that record triggers Dependabot alerts in repositories that use an affected version. For Bookstore's own API there is also a line in the release notes and a short e-mail to the integration teams of institutional customers: which version is affected, what they need to do, and the advisory link.

The most common mistake happens in step four of the timeline. If the fix commit lands on the public main branch with the message "Fix ReDoS in IsbnValidator", the location and type of the vulnerability are disclosed to everyone before the advisory is published; for someone watching the commit history, that is clue enough. That is why Bookstore's runbook keeps the fix in the private fork and asks for neutral commit messages.

When Does a Bug Bounty Make Sense?

A bug bounty is a program that pays for valid findings; a VDP is the policy that describes how you accept reports without payment. OWASP's Vulnerability Disclosure Cheat Sheet describes bug bounties as useful but expensive: filtering low-quality reports takes time, production gets tested continuously, and payouts need a budget. Its advice is therefore that only teams with a mature disclosure process should consider one.

For Bookstore the decision is clear: a VDP first. A team that cannot answer reports within three business days makes the problem worse by offering money and attracting more reports. The time to discuss a bug bounty is after the VDP has met its targets for a year.

The most important part of a VDP is the safe harbor text: a promise not to take legal action against a good-faith researcher who follows the policy. The text in Bookstore's SECURITY.md is short and written in our own words: only your own accounts or the named test environment, do not touch other people's data more than needed, do not slow the service down, give reasonable time before publishing. If you need a more complete text, disclose.io's templates published under CC0 are a good start; still, have a lawyer read the final text.

In Production

The security@ address belongs to a rotation, not a person. A report that goes to the one person who is on holiday silently misses the three-business-day target. In Bookstore the address goes to the security champion and the tech lead together, and a backlog item is opened automatically for every report.

The Canonical field must be the same as the address the file is actually served from; otherwise validators treat the file as copied from somewhere else. If you have more than one domain, each one needs the file, or they all redirect to the same address.

Keep the researcher informed. SECURITY.md promises an update at least every two weeks; if the fix is late, saying so openly is always better than staying silent. Ask the researcher how they want to be credited in the advisory and write it that way.

Common Pitfalls

Ignoring the reporter is the most expensive mistake. A researcher who gets no answer often publishes the finding on their own date, and that date may not match your fix. Worse is answering with a legal threat: it is a lasting loss of reputation in the research community and it makes sure the next finding goes straight to the internet instead of to you.

The second pitfall is releasing the fix with a descriptive commit message before the advisory; we covered it above. The third is publishing the advisory before the fixed version. The fourth is a security.txt whose Expires date has passed: expired contact details tell the researcher "nobody is here".

A Small Experiment

★☆☆ Move the Expires date in security.txt to 20 days from today and run the tests. Does the error message tell the champion what to do?

★★☆ Define a measurement for each target time in SECURITY.md: which date is read from where, and who reports it? What should these measurements show after three months for the process to count as working?

★★★ Write an ISBN validation function for Bookstore.Contracts and add a test that shows how it is protected against ReDoS (hint: RegexOptions.NonBacktracking or a matchTimeout). Then fill in the advisory template for this scenario.

We have talked about receiving a report from outside. Sometimes we invite someone from outside ourselves: an institutional customer asks for a pentest report, or we want an independent pair of eyes before a big release. In the fifth post of the series we talk about pentest scope, rules of engagement, reading findings and retesting.