Skip to main content
Ethical Hacking (methodology, lab-only) · lesson 6 of 6
Open the Ethical Hacking (methodology, lab-only) page →

Responsible Disclosure and security.txt (RFC 9116)

Publish an RFC 9116 security.txt, validate it with Python, compute a 90-day disclosure timeline, and read a bug-bounty scope like a scope letter.

By SkillByExample · Published

ethical-hacking vulnerability-disclosure security-txt bug-bounty python

The earlier lessons follow a test someone paid for, with a signed scope, a report and a retest. Many vulnerabilities reach their owner a different way: someone finds one and has to work out who to tell. This lesson covers both sides. As a site owner, you publish an RFC 9116 security.txt so reports reach you, and you validate it with Python so it doesn’t go stale. As a researcher, you follow a coordinated-disclosure timeline and read a bug-bounty scope as carefully as a scope letter.

The examples need only Python 3.12 and run on Linux. Every file is local. Nothing fetches a real security.txt or contacts any site; example.com is a reserved name used here as data.

A security.txt for your site

RFC 9116 defines a small text file of Field: value lines, served over HTTPS at /.well-known/security.txt. Here is one for www.example.com:

# Security contact for www.example.com, served at /.well-known/security.txt
Contact: mailto:security@example.com
Contact: https://www.example.com/security/report
Expires: 2027-04-30T23:59:59Z
Encryption: https://www.example.com/pgp-key.txt
Policy: https://www.example.com/security/policy
Preferred-Languages: en
Canonical: https://www.example.com/.well-known/security.txt

Only two fields are required. Contact says where to send a report; it can repeat, in order of preference. Expires says when to stop trusting the file and must appear exactly once. Encryption links to a public key rather than containing one. Policy links to the rules for reporting and, if you run one, your bug-bounty scope. Canonical lists the URLs the file is meant to be served from. Serve it as text/plain; charset=utf-8. Save it as security.txt, and save this neglected one as old-security.txt:

# Written in 2024 and never updated
contact: http://www.example.com/contact-us
CONTACT: security@example.com
Expires: 2025-06-30T00:00:00Z

Reading the fields

Field names are case-insensitive in RFC 9116, so contact, Contact and CONTACT are the same field. The parser lowercases the names and keeps every value, because a field can repeat.

# parse_sectxt.py: read security.txt into {field: [values]}
import sys

fields = {}
for line in open(sys.argv[1], encoding="utf-8"):
    line = line.strip()
    if not line or line.startswith("#"):
        continue
    name, _, value = line.partition(":")
    fields.setdefault(name.strip().lower(), []).append(value.strip())

for name, values in fields.items():
    print(f"{name}: {values}")
$ python parse_sectxt.py security.txt
contact: ['mailto:security@example.com', 'https://www.example.com/security/report']
expires: ['2027-04-30T23:59:59Z']
encryption: ['https://www.example.com/pgp-key.txt']
policy: ['https://www.example.com/security/policy']
preferred-languages: ['en']
canonical: ['https://www.example.com/.well-known/security.txt']
$ python parse_sectxt.py old-security.txt
contact: ['http://www.example.com/contact-us', 'security@example.com']
expires: ['2025-06-30T00:00:00Z']

contact and CONTACT in the old file became one list of two values. A check written as line.startswith("Contact:") would find no contact at all in that file. partition(":") splits at the first colon only, so the colons inside mailto:, https:// and the time stay in the value.

The validator

The checks follow RFC 9116: at least one Contact; each one a mailto:, https:// or tel: URI; exactly one Expires, in the future. The RFC also recommends Expires be less than a year away, so that one is a warning. SECTXT_NOW pins “now” so the output is reproducible.

# sectxt_check.py: check a security.txt against the main rules of RFC 9116
import os
import sys
from datetime import datetime, timedelta, timezone

fields = {}
for line in open(sys.argv[1], encoding="utf-8"):
    if line.strip() and not line.lstrip().startswith("#"):
        name, _, value = line.partition(":")
        fields.setdefault(name.strip().lower(), []).append(value.strip())

now = os.environ.get("SECTXT_NOW")
now = datetime.fromisoformat(now) if now else datetime.now(timezone.utc)
problems = [] if fields.get("contact") else ["ERROR missing Contact"]
for contact in fields.get("contact", []):
    if not contact.startswith(("mailto:", "https://", "tel:")):
        problems.append(f"ERROR Contact must be mailto:, https:// or tel: ({contact})")
if len(fields.get("expires", [])) != 1:
    problems.append("ERROR Expires must appear exactly once")
elif (expires := datetime.fromisoformat(fields["expires"][0])) <= now:
    problems.append(f"ERROR expired on {expires:%Y-%m-%d}")
elif expires - now > timedelta(days=365):
    problems.append(f"WARN  Expires {expires:%Y-%m-%d} is more than a year away")

print("\n".join(problems) or f"OK    {sys.argv[1]}: {', '.join(fields)}")
sys.exit(1 if any(p.startswith("ERROR") for p in problems) else 0)
$ export SECTXT_NOW=2026-10-15T09:00:00+00:00
$ python sectxt_check.py security.txt; echo "exit=$?"
OK    security.txt: contact, expires, encryption, policy, preferred-languages, canonical
exit=0
$ python sectxt_check.py old-security.txt; echo "exit=$?"
ERROR Contact must be mailto:, https:// or tel: (http://www.example.com/contact-us)
ERROR Contact must be mailto:, https:// or tel: (security@example.com)
ERROR expired on 2025-06-30
exit=1

The old file fails three ways. RFC 9116 requires a web Contact to start with https://, so a report about a vulnerability doesn’t travel in clear text, and http:// is refused. A bare security@example.com isn’t a URI at all; it needs mailto:. And the file expired over a year ago, which tells a researcher not to trust its contacts. datetime.fromisoformat reads the Z suffix as UTC on Python 3.11 and later, which is why the pinned “now” carries +00:00. Comparing a timezone-aware date with a naive one would raise TypeError.

Now the two ways a good file goes wrong over time:

$ sed 's/2027-04-30/2030-01-01/' security.txt > lazy-security.txt
$ python sectxt_check.py lazy-security.txt; echo "exit=$?"
WARN  Expires 2030-01-01 is more than a year away
exit=0
$ SECTXT_NOW=2027-05-01T09:00:00+00:00 python sectxt_check.py security.txt; echo "exit=$?"
ERROR expired on 2027-04-30
exit=1

Setting Expires years ahead so you never have to touch the file defeats its purpose, so it’s a warning: RFC 9116 calls a value under a year “RECOMMENDED”, not a “MUST”. The second run is the same, unchanged file checked on 1 May 2027, the day after it expired. Run the validator in CI or on a schedule, without SECTXT_NOW, and that error arrives before a researcher notices it.

A disclosure timeline

Coordinated vulnerability disclosure (CVD) means the researcher reports privately, the vendor fixes, and the details are published once users can protect themselves. A fixed deadline stops a report from sitting unanswered forever. Google Project Zero’s policy is “90+30”: 90 days to fix, plus 30 days after a patch ships for users to update. CERT/CC’s default is 45 days. Moving a deadline that lands on a weekend to the next working day is a common convention; Project Zero’s 2015 policy update did it for weekends and US public holidays.

# timeline.py: a coordinated-disclosure timeline from the date a bug was reported
import sys
from datetime import date, timedelta

reported = date.fromisoformat(sys.argv[1])
window = int(sys.argv[2]) if len(sys.argv) > 2 else 90

deadline = reported + timedelta(days=window)
while deadline.weekday() >= 5:  # 5 is Saturday, 6 is Sunday
    deadline += timedelta(days=1)

print(f"reported:        {reported:%a %Y-%m-%d}")
print(f"acknowledge by:  {reported + timedelta(days=7):%a %Y-%m-%d}")
print(f"disclose on:     {deadline:%a %Y-%m-%d} (day {(deadline - reported).days})")
$ python timeline.py 2026-10-15
reported:        Thu 2026-10-15
acknowledge by:  Thu 2026-10-22
disclose on:     Wed 2027-01-13 (day 90)
$ python timeline.py 2026-10-18
reported:        Sun 2026-10-18
acknowledge by:  Sun 2026-10-25
disclose on:     Mon 2027-01-18 (day 92)
$ python timeline.py 2026-10-18 45
reported:        Sun 2026-10-18
acknowledge by:  Sun 2026-10-25
disclose on:     Wed 2026-12-02 (day 45)

A report on Sunday 18 October reaches day 90 on a Saturday, so the deadline moves to Monday, day 92. timedelta handles month and year boundaries, so nobody has to count 90 days through December by hand. The seven-day acknowledgement is a typical policy promise, not a standard; use whatever your own policy publishes. Write the dates down in the first email to the vendor, so both sides know them from the start. The deadline holds unless both sides agree to move it. CERT/CC publishes “regardless of the existence or availability of patches or workarounds”, so a vendor that needs longer negotiates an extension before the date, not after it.

Bug-bounty scope is a scope letter you didn’t negotiate

A bug-bounty program or a published vulnerability disclosure policy is how an organization authorizes strangers to test. It plays the same role as the signed scope in the first lesson, except that you accept it as written instead of negotiating it. Read it the same way:

  • In scope lists the assets you may test, often with wildcards such as *.example.com.
  • Out of scope lists assets that are excluded even if a wildcard covers them, such as a third-party status page. Exclusions win, exactly as in scope_check.py.
  • Rules ban techniques: usually denial of service, social engineering, physical access, and reading or changing other users’ data beyond the minimum needed to prove a bug.
  • Safe harbour is the program’s promise not to pursue legal action against good-faith research that stays within those rules. disclose.io publishes standard wording for it. It covers only what the policy allows.

A security.txt with no Policy link gives you a contact address, not permission. If there’s no program, report what you came across in normal use and don’t probe further. Practice task 4 turns a program’s scope into a checker.

Practice

  1. A file that expires in three weeks passes the validator silently, and then fails the day it expires. Add a warning when Expires is less than 30 days away.
Solution

Add one more branch after the “more than a year away” one:

elif expires - now < timedelta(days=30):
    problems.append(f"WARN  Expires in {(expires - now).days} days; publish a renewed file")
$ SECTXT_NOW=2027-04-10T09:00:00+00:00 python sectxt_check.py security.txt; echo "exit=$?"
WARN  Expires in 20 days; publish a renewed file
exit=0

It’s still exit 0, because nothing is wrong yet. A scheduled job that emails on any WARN line gives the owner three weeks’ notice. .days drops the partial day, so 20 days and almost 15 hours prints as 20.

  1. RFC 9116 says that if a file has Canonical fields and was fetched from a URL none of them list, its contents shouldn’t be trusted. Take the URL the file was fetched from as an optional second argument and check it.
Solution

Add before the final print:

url = sys.argv[2] if len(sys.argv) > 2 else None
if url and fields.get("canonical") and url not in fields["canonical"]:
    problems.append(f"ERROR fetched from {url}, which no Canonical field lists")
$ python sectxt_check.py security.txt https://www.example.com/.well-known/security.txt; echo "exit=$?"
OK    security.txt: contact, expires, encryption, policy, preferred-languages, canonical
exit=0
$ python sectxt_check.py security.txt https://example.com/.well-known/security.txt; echo "exit=$?"
ERROR fetched from https://example.com/.well-known/security.txt, which no Canonical field lists
exit=1

If you serve the same file on example.com and www.example.com, list both in Canonical fields. The check protects researchers from a copied file: someone who copies your security.txt onto their own site, with your contacts or theirs, fails it.

  1. Under a “90+30” policy like Project Zero’s, a fix that ships within 90 days moves disclosure to 30 days after the fix, even past day 90, so users have time to update. With no fix by day 90, disclosure is on day 90. Write timeline_fix.py taking the report date and the fix date.
Solution
# timeline_fix.py: 90+30, disclose 30 days after a fix that ships by day 90, else on day 90
import sys
from datetime import date, timedelta

reported, fixed = (date.fromisoformat(d) for d in sys.argv[1:3])
deadline = reported + timedelta(days=90)
if fixed <= deadline:
    deadline = fixed + timedelta(days=30)
while deadline.weekday() >= 5:  # 5 is Saturday, 6 is Sunday
    deadline += timedelta(days=1)

print(f"fix shipped on day {(fixed - reported).days}")
print(f"disclose on {deadline:%a %Y-%m-%d} (day {(deadline - reported).days})")
$ python timeline_fix.py 2026-10-15 2026-11-24
fix shipped on day 40
disclose on Thu 2026-12-24 (day 70)
$ python timeline_fix.py 2026-10-15 2027-01-05
fix shipped on day 82
disclose on Thu 2027-02-04 (day 112)
$ python timeline_fix.py 2026-10-15 2027-01-20
fix shipped on day 97
disclose on Wed 2027-01-13 (day 90)

The fix on day 82 is inside the 90 days, so the details wait until 30 days after it, day 112, well past the original deadline. That’s the point of the extra 30 days: a patch only protects users once they’ve installed it. The fix on day 97 missed the deadline, so disclosure stays on day 90 and the fix date doesn’t matter. The first date is Christmas Eve, which the weekend rule doesn’t catch. Real policies often skip public holidays too, so agree the final date with the vendor rather than trusting the arithmetic alone.

  1. A program’s policy says in scope is *.example.com, and out of scope is status.example.com and *.staging.example.com. Write bounty_scope.py to check hostnames against it, with exclusions winning as in the scope lesson. Try example.com and WWW.EXAMPLE.COM.
Solution
# bounty_scope.py: is a hostname inside a bug-bounty program's published scope?
import sys
from fnmatch import fnmatchcase

IN_SCOPE = ["*.example.com"]
OUT_OF_SCOPE = ["status.example.com", "*.staging.example.com"]

for host in sys.argv[1:]:
    name = host.lower().rstrip(".")
    if any(fnmatchcase(name, p) for p in OUT_OF_SCOPE):
        print(f"OUT  {host}: listed as out of scope")
    elif any(fnmatchcase(name, p) for p in IN_SCOPE):
        print(f"IN   {host}")
    else:
        print(f"OUT  {host}: not listed, so not in scope")
$ python bounty_scope.py www.example.com api.example.com status.example.com example.com qa.staging.example.com WWW.EXAMPLE.COM example.com.attacker.test
IN   www.example.com
IN   api.example.com
OUT  status.example.com: listed as out of scope
OUT  example.com: not listed, so not in scope
OUT  qa.staging.example.com: listed as out of scope
IN   WWW.EXAMPLE.COM
OUT  example.com.attacker.test: not listed, so not in scope

*.example.com doesn’t cover example.com itself, because the pattern needs something before the dot. If the program means the bare domain too, it lists it separately; don’t assume. Hostnames are case-insensitive, so the script lowercases them and uses fnmatchcase, which behaves the same on every OS (fnmatch is case-insensitive only on Windows). Note that * in fnmatch also matches dots, so a.b.example.com counts as in scope. Most programs mean that, but check the policy’s wording. Unlike the scope lesson, this checks names, not IPs, because that’s how programs publish their scope. A name in scope can still point at a third party’s service, such as a hosted help desk or a CDN, and most policies exclude those. When in doubt, ask the program before testing.

  1. RFC 9116’s own example writes the time with a lowercase z, and some owners write only a date. Make one file of each and run the validator. Then make it report a bad Expires as an error instead of crashing.
Solution
$ sed 's/T23:59:59Z/T23:59:59z/' security.txt > lower-z.txt
$ sed 's/T23:59:59Z//' security.txt > date-only.txt
$ grep Expires lower-z.txt date-only.txt
lower-z.txt:Expires: 2027-04-30T23:59:59z
date-only.txt:Expires: 2027-04-30
$ python sectxt_check.py lower-z.txt 2>&1 | tail -n 1
ValueError: Invalid isoformat string: '2027-04-30T23:59:59z'
$ python sectxt_check.py date-only.txt 2>&1 | tail -n 1
TypeError: can't compare offset-naive and offset-aware datetimes

Both crash with a traceback instead of a report; tail -n 1 keeps just the exception. Python 3.12’s fromisoformat accepts Z but not z, although RFC 3339 allows either. The date-only value parses, but as a naive datetime, and comparing it with the timezone-aware “now” raises TypeError.

Add a parser above the now = ... line:

def parse_expires(value):
    try:
        expires = datetime.fromisoformat(value.upper())
    except ValueError:
        return None
    return expires if expires.tzinfo else None

Then replace the elif (expires := datetime.fromisoformat(...)) <= now: line with two checks:

elif (expires := parse_expires(fields["expires"][0])) is None:
    problems.append("ERROR Expires is not an RFC 3339 date-time")
elif expires <= now:
$ python sectxt_check.py lower-z.txt; echo "exit=$?"
OK    lower-z.txt: contact, expires, encryption, policy, preferred-languages, canonical
exit=0
$ python sectxt_check.py date-only.txt; echo "exit=$?"
ERROR Expires is not an RFC 3339 date-time
exit=1

value.upper() turns the lowercase z (and a lowercase t separator, which RFC 3339 also allows) into the form Python parses, without touching the digits. A value with no timezone is refused rather than guessed. RFC 3339 requires an offset, and guessing would move the expiry by however many hours separate the server from the reader. A validator meant to run unattended should never crash on its input: an error line and exit 1 tell the owner what to fix.

Frequently Asked Questions

What is security.txt?
security.txt is a plain-text file, defined by RFC 9116, that tells security researchers how to report a vulnerability to a website's owner. It is served over HTTPS at /.well-known/security.txt. It must contain at least one Contact field and exactly one Expires field, and can also point to a disclosure policy, an encryption key and preferred languages.
Why does security.txt need an Expires field?
So researchers can tell whether the contact details are still current. A file written years ago may point to an inbox nobody reads, and a report sent there is lost. RFC 9116 requires the Expires field and recommends that it be less than a year in the future, which forces the owner to review the file regularly.
What is coordinated vulnerability disclosure?
The researcher reports a vulnerability privately to the vendor, the vendor fixes it, and the details are published once users can protect themselves. A deadline keeps the process moving: Google Project Zero gives 90 days to fix, plus 30 days after a patch for users to update, and CERT/CC's default is 45 days. The deadline holds whether or not a fix exists, unless both sides agree to move it, so users learn about the risk either way.
What is safe harbour in a bug bounty program?
Safe harbour is the program's promise not to take legal action against researchers who test in good faith and stay within its published rules. It only covers what the policy allows: the listed in-scope assets and permitted techniques. Testing an out-of-scope host or using a banned technique, such as denial of service, falls outside it.
Can I test a website that has a security.txt file?
No, not on that basis alone. A security.txt file says where to send a report; it doesn't grant permission to test. Permission comes from a published disclosure policy or bug-bounty program that names what's in scope, or from a signed agreement with the owner. Read the Policy link, and if there isn't one, report only what you came across in normal use.