Ethical Hacking (methodology, lab-only) · lesson 6 of 6
Beginner
3Intermediate
3Also part of Ethical Hacking (methodology, lab-only)
12- + Port Scanning Your Own Lab with nmap: Open, Closed, Filtered from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + CSRF: Synchronizer Tokens and SameSite Cookies, Fixed and Tested from Web Security (OWASP Top 10, fix-side)
- + SSRF: Allowlists, Resolved-IP Checks and No Redirects from Web Security (OWASP Top 10, fix-side)
- + Security Misconfiguration: Error Pages, Debug Mode and Security Headers from Web Security (OWASP Top 10, fix-side)
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Authentication Failures: Username Enumeration and Login Rate Limiting from Web Security (OWASP Top 10, fix-side)
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + Password Hashing Work Factors, Measured from Cryptography
- + DAST with an OWASP ZAP Baseline Scan Against Your Own App from DevSecOps (AppSec in the pipeline)
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
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
- A file that expires in three weeks passes the validator silently, and then fails the day it
expires. Add a warning when
Expiresis 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.
- RFC 9116 says that if a file has
Canonicalfields 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.
- 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.pytaking 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.
- A program’s policy says in scope is
*.example.com, and out of scope isstatus.example.comand*.staging.example.com. Writebounty_scope.pyto check hostnames against it, with exclusions winning as in the scope lesson. Tryexample.comandWWW.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.
- 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 badExpiresas 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.