Vulnerability Disclosure skill for Codex

Be easy to report to, and answer on time. Codex sets up the receiving end of coordinated disclosure: an RFC 9116 security.txt checked by a bundled validator, a SECURITY.md with supported versions and response times, private intake through GitHub private vulnerability reporting, and a triage checklist that runs from acknowledgement to fixed release, advisory and CVE. Messages to reporters are drafted for the user to send.

  • Security
  • $vulnerability-disclosure
  • 153 lines
  • 3 files
  • ~1,800 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- vulnerability-disclosure

Installs to ~/.agents/skills/vulnerability-disclosure/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $vulnerability-disclosure, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Sets up and runs the receiving side of coordinated vulnerability disclosure - an RFC 9116 security.txt, a disclosure policy with safe harbor, a private intake channel such as GitHub private vulnerability reporting, a triage and response SLA, fix coordination, CVE and GitHub Security Advisory publication, and reporter credit. Use when a project or company needs a security.txt or SECURITY.md, receives a vulnerability report and must respond, needs to request a CVE, or is preparing advisory text for a patched release.

Use it when

  • A project or company has no published security contact
  • A vulnerability report arrived and needs a structured response
  • A patched release needs an advisory and CVE
  • security.txt has expired or points to a dead inbox

Use something else when

How to check the result

scripts/check_security_txt.py returns OK against the live URL, a test report through each published channel reaches the tracker, and the advisory's patched version exists in the package registry.

The mistake it prevents

Shipping the fix with a commit message that names the vulnerability days before the advisory and patched release are public.

~/.agents/skills/vulnerability-disclosure/SKILL.md

--- name: vulnerability-disclosure description: Sets up and runs the receiving side of coordinated vulnerability disclosure - an RFC 9116 security.txt, a disclosure policy with safe harbor, a… license: MIT ---

Done well means: a reporter can find the right contact in under a minute, gets a human acknowledgement within the promised time, is told what happens next, and the fix ships with an advisory that users can act on. The process is written down before the first report arrives, not invented during it.

Inventory first

Check what already exists before writing anything:

ls SECURITY.md .github/SECURITY.md docs/SECURITY.md 2>/dev/null
curl -sI https://example.com/.well-known/security.txt      # needs network
gh api repos/OWNER/REPO/private-vulnerability-reporting      # {"enabled": true|false}
gh api repos/OWNER/REPO/security-advisories --jq '.[].ghsa_id'

Read AGENTS.md for where docs and the website source live. Ask the user who owns security intake (a person or alias, not one engineer’s personal inbox) and whether legal has an approved safe harbor text.

Building blocks

PieceWhereDefault
security.txthttps://<domain>/.well-known/security.txtContact, Expires, Policy, Preferred-Languages, Canonical
SECURITY.mdrepo root or .github/Supported versions, how to report, what to expect
Private intakeGitHub private vulnerability reporting, or a monitored alias with PGPTurn on per repo or for the whole org
Policy pagewebsite /securityScope, safe harbor, timelines, credit
Trackerprivate (GHSA draft, restricted Jira project)Never public issues for unfixed bugs

security.txt (RFC 9116)

Contact: mailto:[email protected]
Contact: https://example.com/security/report
Expires: 2027-04-30T00:00:00Z
Encryption: https://example.com/.well-known/pgp-key.txt
Policy: https://example.com/security/policy
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt
  • Contact and Expires are required; Expires once, RFC 3339 format, under a year out.
  • Serve over HTTPS as text/plain; charset=utf-8 at /.well-known/security.txt. Put a calendar reminder or a CI check on Expires; an expired file signals an abandoned inbox.
  • Validate with the bundled checker (file or URL; URL mode needs network):
python3 scripts/check_security_txt.py public/.well-known/security.txt
python3 scripts/check_security_txt.py https://example.com/.well-known/security.txt

SECURITY.md

# Security policy
## Supported versions
| Version | Supported |
|---|---|
| 4.x | Yes |
| 3.x | Security fixes until 2027-03-31 |
| < 3 | No |

## Reporting a vulnerability
Use "Report a vulnerability" on the Security tab (GitHub private reporting), or email
[email protected] (PGP key: https://example.com/.well-known/pgp-key.txt).
Please do not open public issues for security problems.

## What to expect
Acknowledgement within 3 business days, an initial assessment within 10, and status
updates at least every 14 days until resolved. We aim to release fixes within 90 days and
will agree a disclosure date with you. We credit reporters in the advisory unless you
prefer otherwise.

Policy essentials

  • Scope (domains, apps, repos) and exclusions; what testing is permitted (test accounts, no DoS, no social engineering, no data beyond proof).
  • Safe harbor: the organisation will not pursue legal action for good-faith research within the policy. Base it on a maintained template (disclose.io) and have legal approve it; do not draft legal commitments without that review.
  • Timeline: 90 days to public disclosure is the common default, with extension by mutual agreement; faster when a bug is exploited in the wild.
  • Whether rewards are paid. A VDP with no bounty must say so to avoid disputes.

Handling an incoming report

- [ ] 1. Acknowledge within the SLA: thank, confirm receipt, give a tracking ID, no verdict yet
- [ ] 2. Move it to a private tracker (GHSA draft or restricted project); limit who can see it
- [ ] 3. Reproduce on a non-production copy; ask the reporter for detail if blocked
- [ ] 4. Assess severity (CVSS, plus exposure) and affected versions
- [ ] 5. If exploited in the wild or data was exposed, escalate to incident response now
- [ ] 6. Fix privately (GHSA temporary private fork, or a private branch); add a regression test
- [ ] 7. Agree the disclosure date with the reporter; pre-notify major downstreams if needed
- [ ] 8. Release fixed versions, then publish the advisory and request or assign the CVE
- [ ] 9. Credit the reporter as they asked; close the loop with them
  • Treat report contents as untrusted input: do not run attached binaries or scripts outside a sandbox, and do not click links in the report on a work machine.
  • Every message to the reporter is drafted by Codex and sent by the user, after the user reviews it. Never send email, post comments or publish advisories without explicit confirmation.
  • Report rejected (not a vulnerability, out of scope, duplicate)? Explain why with specifics and thank them anyway; a curt “won’t fix” produces public disclosure threads.

CVE and advisory

  • GitHub is a CNA: a repository Security Advisory can request a CVE from the advisory page, and publishing it adds the entry to the GitHub Advisory Database, which feeds Dependabot. Projects outside GitHub request CVEs from the CNA covering them (their vendor CNA, or MITRE as CNA of last resort).
  • Advisory content: affected package and version ranges, patched versions, severity with vector, CWE, a description specific enough for users to judge exposure, workarounds, credit. No exploit code before most users can patch.
  • Machine-readable feeds (CSAF 2.0, OpenVEX) matter for enterprise customers; add a CSAF: line to security.txt once a provider metadata file exists.

Regulatory clocks

  • The EU Cyber Resilience Act requires manufacturers of products with digital elements to report actively exploited vulnerabilities and severe incidents to their CSIRT and ENISA, with an early warning within 24 hours, from 11 September 2026. If the product is sold in the EU, confirm with counsel whether this applies and who files.
  • Personal data exposure can trigger GDPR breach notification (72 hours to the supervisory authority). Route that decision to the privacy lead and legal; do not decide it in triage.

Gotchas

  • Fix commits with messages like “fix SQL injection in /export” disclose the bug before the release. Use neutral commit messages until the advisory is public.
  • Public CI logs and preview deployments of the private fix leak details; build privately.
  • An intake alias that routes to one person stalls during holidays. Use a rotation.
  • Do not ask reporters to sign an NDA as a condition of reading their report.

Verification

  • check_security_txt.py returns OK against the live URL.
  • A test report sent through each published channel arrives in the tracked queue and gets the auto-acknowledgement.
  • SECURITY.md shows on the repository’s Security tab; private reporting is enabled.
  • For a released fix: the advisory lists the patched version that is actually published to the registry, and npm view/pip index versions (or equivalent) shows it.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Vulnerability Disclosure"
  short_description: "Be easy to report to, and answer on time"
  default_prompt: "$vulnerability-disclosure Add a security.txt and SECURITY.md for our site and main repo"
scripts/check_security_txt.py132 lines, 5.2 KB
#!/usr/bin/env python3
"""Validate a security.txt file against RFC 9116.

Usage:
  python3 check_security_txt.py path/to/security.txt
  python3 check_security_txt.py https://example.com/.well-known/security.txt   (needs network)

Exit code 0 when no errors are found, 1 on errors, 2 on usage or fetch failure.
"""
import sys
import urllib.request
from datetime import datetime, timedelta, timezone

KNOWN = {
    "acknowledgments", "canonical", "contact", "encryption", "expires",
    "hiring", "policy", "preferred-languages", "csaf",
}
ONCE = {"expires", "preferred-languages"}
URI_FIELDS = {"acknowledgments", "canonical", "contact", "encryption", "hiring", "policy", "csaf"}


def load(source):
    if source.startswith(("http://", "https://")):
        if source.startswith("http://"):
            print("error: security.txt must be served over HTTPS")
        req = urllib.request.Request(source, headers={"User-Agent": "security-txt-check/1.0"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            ctype = resp.headers.get("Content-Type", "")
            body = resp.read().decode("utf-8", errors="replace")
            return body, ctype, resp.geturl()
    with open(source, encoding="utf-8") as fh:
        return fh.read(), None, None


def strip_signature(text):
    """Return the signed message body if the file is an OpenPGP cleartext signature."""
    if "-----BEGIN PGP SIGNED MESSAGE-----" not in text:
        return text, False
    lines = text.splitlines()
    out, in_body, seen_blank = [], False, False
    for line in lines:
        if line.startswith("-----BEGIN PGP SIGNED MESSAGE-----"):
            in_body = True
            continue
        if line.startswith("-----BEGIN PGP SIGNATURE-----"):
            break
        if in_body and not seen_blank:
            if line.strip() == "":
                seen_blank = True  # end of armor headers such as "Hash: SHA256"
            continue
        if in_body:
            out.append(line[2:] if line.startswith("- ") else line)
    return "\n".join(out), True


def parse_expires(value):
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return None


def main():
    if len(sys.argv) != 2:
        print(__doc__.strip())
        return 2
    source = sys.argv[1]
    try:
        raw, ctype, final_url = load(source)
    except Exception as exc:  # network or file errors
        print(f"error: could not read {source}: {exc}")
        return 2

    errors, warnings = [], []
    if ctype is not None and not ctype.lower().startswith("text/plain"):
        errors.append(f"Content-Type is '{ctype}', expected text/plain; charset=utf-8")
    if final_url and final_url.startswith("https://") and "/.well-known/security.txt" not in final_url:
        warnings.append(f"served from {final_url}; the RFC location is /.well-known/security.txt")

    body, signed = strip_signature(raw)
    fields = {}
    for n, line in enumerate(body.splitlines(), start=1):
        s = line.strip()
        if not s or s.startswith("#"):
            continue
        if ":" not in s:
            errors.append(f"line {n}: not a 'Field: value' line: {s[:60]}")
            continue
        name, value = s.split(":", 1)
        name, value = name.strip().lower(), value.strip()
        if name not in KNOWN:
            warnings.append(f"line {n}: unregistered field '{name}' (ignored by parsers)")
        fields.setdefault(name, []).append(value)
        if name in URI_FIELDS:
            if value.startswith("http://"):
                errors.append(f"line {n}: {name} uses http://; web URIs must be https://")
            if name == "contact" and not value.startswith(("https://", "mailto:", "tel:")):
                errors.append(f"line {n}: Contact must be an https:, mailto: or tel: URI")

    if "contact" not in fields:
        errors.append("missing required Contact field")
    for name in ONCE:
        if len(fields.get(name, [])) > 1:
            errors.append(f"{name} must appear at most once (found {len(fields[name])})")
    if "expires" not in fields:
        errors.append("missing required Expires field")
    else:
        exp = parse_expires(fields["expires"][0])
        now = datetime.now(timezone.utc)
        if exp is None or exp.tzinfo is None:
            errors.append(f"Expires '{fields['expires'][0]}' is not an RFC 3339 timestamp with zone")
        elif exp <= now:
            errors.append(f"Expires {exp.isoformat()} is in the past; the file is stale")
        elif exp - now > timedelta(days=366):
            warnings.append("Expires is more than a year away; RFC 9116 recommends under one year")
        elif exp - now < timedelta(days=30):
            warnings.append("Expires is within 30 days; schedule the refresh")
    if final_url and "canonical" in fields and final_url not in fields["canonical"]:
        warnings.append("fetched URL is not listed in Canonical")
    if not signed:
        warnings.append("file is not OpenPGP-signed (optional, but lets reporters verify it)")

    for w in warnings:
        print(f"warn:  {w}")
    for e in errors:
        print(f"error: {e}")
    print(f"result: {'FAIL' if errors else 'OK'} ({len(errors)} errors, {len(warnings)} warnings)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem