Skip to content

Self-maintaining security research inventory - #1

Merged
p80n-sec merged 38 commits into
mainfrom
research-inventory
Aug 10, 2026
Merged

Self-maintaining security research inventory#1
p80n-sec merged 38 commits into
mainfrom
research-inventory

Conversation

@p80n-sec

Copy link
Copy Markdown
Owner

Turns this repo from four hand-written CVE stubs into an inventory that maintains itself: adding one markdown file per finding updates the README tables and the GitHub Pages site. Nobody hand-edits a table.

What this adds

  • tools/research/ — disclosure-clock arithmetic, frontmatter loading with status-dependent validation, README table rendering, and static site generation
  • tools/generate.py, tools/clock_report.py, tools/scaffold_publish.py — CLI entrypoints
  • .github/workflows/build.yml — regenerate and deploy Pages on push
  • .github/workflows/disclosure-clock.yml — daily: warn 7 days before a deadline via a labeled issue, open a preparation PR at expiry
  • .github/workflows/validate.ymlpull_request: tests plus generation, read-only
  • findings/CVE-2025-6366{2,3,4,5}/ — the four GT Edge AI records, migrated with NVD-verified metadata
  • profile.yml — bio, photo and social links as data rather than markup

A hidden status field (in-progress / published / duplicate) routes each finding to the published table, the pending table, or neither.

Disclosure timing

The clock follows a 90+30 day policy. Nothing self-publishes: at expiry the workflow opens a PR that leaves status: in-progress and adds commented TODO scaffolding, and a human fills in the real identifier, CVSS and title and flips the status before merging.

Preserved paths — please do not "tidy" these

Pending and CVE-2025-6366*/CVE-2025-6366*.md are pointer stubs, not leftovers. NVD's reference lists for all four CVEs cite those exact paths, GitHub does not redirect deleted files, and NVD references cannot be edited after publication. Deleting them permanently breaks live advisory references.

Verification

  • 134 tests passing
  • python -m tools.generate --check exits 0, so the committed README matches what CI generates
  • Site links point at the canonical custom domain www.p80n.com
  • No external requests: fonts are self-hosted with their SIL OFL 1.1 licences

Still manual after merge

  • Confirm the first build deploys and the site loads
  • Confirm the bot's [skip ci] commit starts no second run
  • Consider enabling HTTPS enforcement on Pages — it is currently off

See docs/maintenance.md for two operational traps, including that GitHub disables scheduled workflows after 60 days of repository inactivity, which would silently stop the disclosure clock.

🤖 Generated with Claude Code

Peyton Kennedy and others added 30 commits August 9, 2026 15:09
Frontmatter-driven records generate the README tables and a GitHub Pages
site. Status field routes records between published and pending tables;
a daily workflow opens a PR when the 90+30 disclosure clock expires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CVSS drops the vector field; the numeric score is what the table needs and
NVD is canonical for the vector. The clock workflow gains a first pass that
opens a labeled issue tagging p80n-sec inside a 7-day window before expiry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight TDD tasks: clock arithmetic, record loading, README rendering, site
rendering, CLI entrypoints, migration of the four GT Edge AI records, and
the two workflows.

Spec updated alongside: NVD cites Pending and CVE-2025-63665's path as
references for all four CVEs, so those paths become stubs rather than being
deleted. Migration values verified against NVD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Extract _id_prefix helper to eliminate duplication between id_kind and id_url
- Wrap id_kind call in load_finding to include file path in ValidationError
- Add _parse_cvss helper to guard float conversion and report non-numeric cvss with path
- Add tests verifying both path-inclusive errors
- All errors now consistently include the offending path for CI diagnostics

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… processing

The string replacement form of re.sub() interprets backslash escapes and group
references in the replacement text, causing two failure modes:
1. Block containing \1 (group reference syntax) raises re.error
2. Block containing \n as literal characters is silently converted to newline

Use a lambda callable to bypass backslash processing entirely. Add tests for
both backslash cases and idempotence with backslashes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tasks 1-4 implemented on this branch (59 tests green). Tasks 7 and 8 sit on
separate unmerged agent branches, both needing fix rounds. Captures open
findings, the user's ruling on the expiry-PR design, and the checks that can
only run once this reaches the default branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reproduced defects, all from the plan's reference code: sequential
token substitution corrupts output when a record field contains a token
literal, CVSS 0.0 is dropped from detail pages by a falsy check, and the
finding key is interpolated into an href unescaped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… confine duplicate detection to frontmatter
The user ruled: TODO_FIELDS' id: TODO-assign-CVE-or-GHSA, records.py's
CVE/GHSA prefix check, and a published record requiring a valid id cannot
all hold. Instead of flipping status to published, the expiry pass now
leaves status: in-progress and inserts a commented TODO block (identifier,
title, cvss, disclosed, refs, blog, talk). A human fills it in and flips
status themselves inside the same PR before merging, so CI stays green,
the record stays out of the published table until it is genuinely public,
and nothing self-publishes.

Renamed promote.py/test_promote.py to scaffold_publish.py/
test_scaffold_publish.py to match the new behavior (git mv, history
preserved). scaffold_publish() drops the now-unneeded today parameter,
is idempotent on the TODO marker, and still refuses non in-progress
records via ScaffoldError. Workflow pass 2 now calls scaffold_publish.py,
retitles the PR, and adds an explicit "flip status to published" checklist
item; pass 1 (the 7-day warning) is unchanged.
- Substitute {{TOKEN}} placeholders in a single re.sub pass (_fill) instead
  of chained str.replace, so a record field containing literal token text
  cannot be rescanned and injected as markup.
- Treat cvss=0.0 as present (is not None) rather than falsy, so a zero
  score still renders its meta row on the finding detail page.
- Escape finding.key before interpolating it into the published table's
  href attribute.
Follow-up to 61828eb, which staged only the git-mv rename. This commit
carries the actual content: the new scaffold_publish()/ScaffoldError API,
the rewritten test_scaffold_publish.py covering the new contract (status
unchanged, TODO block present and idempotent, still loads via
records.load_finding as in-progress, ScaffoldError on non in-progress),
and the workflow's pass 2 changes (calls scaffold_publish.py, retitled PR,
"flip status to published" checklist item). Pass 1 untouched.
Moves the four existing CVE stubs into findings/CVE-2025-6366{2,3,4,5}/finding.md
with frontmatter (status, cwe, cvss, disclosed, refs, credits) read verbatim from
NVD on 2026-08-09, so tools/generate.py can render them. The old CVE-2025-6366*/
paths and Pending are kept as pointer stubs because NVD's reference lists cite
them directly and cannot be edited after publication. Seeds links.yml,
talks/.gitkeep, and duplicates/.gitkeep, and adds the README table markers that
generate.py fills in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running a script by path puts that script's own directory on sys.path
instead of the repo root, so `from tools.research import ...` cannot
resolve. tools/clock_report.py imports tools.research.records and
tools.research.clock and fails this way when invoked as
`python tools/clock_report.py`; the disclosure-clock workflow's first
step would fail silently on every scheduled run, producing neither
warnings nor expiry PRs. Switch both invocations to module form:
`python -m tools.clock_report` and `python -m tools.scaffold_publish`.
scaffold_publish.py has no tools.research import today so it wasn't
actually broken, but converting it keeps both call sites consistent
against a future import, per the project's existing tests already
importing these as modules.
The brief's reference code left readme_path.read_text() unguarded, so a
missing README died with a traceback instead of the mandated clean exit 2.
It also defaulted --out to a bare "_site" relative to the process cwd
rather than --root, silently misplacing the site when --root pointed
elsewhere and --out was omitted. Also wrap render_html.render_site's
ValidationError path so a template/token mismatch reports the same way
as the other validation failures instead of escaping as a traceback.
… id case

split_frontmatter split on the '---' substring, so a '---' inside a value
(e.g. component: /api/v1/a---b) silently truncated the frontmatter: every
later field was dropped and leaked into the body. Dropping grace_until
collapses the effective deadline back to the base one, which would open a
publish-preparation PR weeks early on a still-unpatched vulnerability, and
the leaked YAML tail became the public detail-page body. Validation cannot
catch it because truncation happens first and only removes optional fields.

Line-based parsing also tightens the opener check, which accepted '----'
and '---junk'.

load_findings now rejects a key present in both findings/ and duplicates/,
which previously produced two README rows and one twice-written detail page.

id_url uppercases a CVE, since NVD does not resolve a lowercase one. A GHSA
suffix is canonically lowercase, so only its prefix is normalized there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cell values went into markdown rows unescaped. A project name of 'Acme |
Corp' renders six cells in a five-column table, so GitHub shows the CVSS
column as 'Corp' and the Links column as '7.5' — incorrect security data
about a named product, published with no error. An embedded newline ends the
row and injects markdown outside the table, and a ')' in blog or refs.endor
truncates the link URL.

_cell collapses newlines and escapes pipes; _url percent-encodes the
characters that break link syntax. The site path already escaped everything
via _esc, so only the markdown path was exposed.

Talk links now point at the published site anchor. The README has no talks
section, so the previous '#slug' target could never resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scaffold_publish read status with a substring test over the whole file, so a
published record whose body quotes 'status: in-progress' would be scaffolded;
it now reads the parsed frontmatter. Its bare next() over frontmatter lines
raised StopIteration with an empty message on a record with no closing
delimiter, and now raises ScaffoldError with a reason.

generate renders the site into staging before touching the repo, so a
site-render failure can no longer leave a rewritten README beside an unbuilt
site, and --check exercises the site render instead of validating the README
alone. The stale-README hint names the working invocation.

render_site copies talks/<slug>/* (minus talk.md) into the site and rewrites
non-URL slides/paper values to that prefix; a local slides file previously
rendered a site-root href with nothing copied behind it. Talk records now
carry the directory they were loaded from.

sort.js binds only thead th and skips tables without a thead. Selecting every
th also caught the detail page's seven metadata row labels, all at cell index
0, so any handler bound at index >= 2 read an undefined cell and threw; the
CSS pointer cursor advertised those labels as clickable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The expiry loop committed unconditionally. scaffold_publish is idempotent, so
a record already carrying the TODO block stages nothing and git commit exits
1 — under bash -e that kills the step, and the loop never reaches the records
after it, so the same row starves every later one on every subsequent day.
The loop now detects the no-op and skips, and push and PR-creation failures
skip only the current record.

New validate.yml runs the suite and a full generation on pull_request with
read-only permissions. Nothing validated a record before merge, and since
regeneration happens only on main, one invalid record fails build.yml before
the upload step: the public site would keep serving stale content while main
already held new data. Plain generation, not --check, because a PR that adds
a finding legitimately changes the README.

Warning dedupe lists disclosure-clock issues and compares the identifier
field exactly. gh --search tokenizes on hyphens, so EL-2026-001 matched a
title holding EL-2026-0012 and suppressed a warning that was due. The full
title carries a day count, so the comparison is on the identifier field
rather than the whole string, and --limit 500 lifts gh's 30-issue default.

build.yml guards git mv against an existing destination, which was fatal
under bash -e and took regeneration and deploy down with it, and rebuilds on
requirements.txt and pytest.ini changes.

disclosure-clock.yml gets a concurrency group so a manual dispatch cannot
overlap the cron and double-file issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The closing line pointed at `tools/generate.py`, which fails with
ModuleNotFoundError; the generator only runs as `python -m tools.generate`.

Regenerated with --today 2026-08-10. The published table is byte-identical:
cell escaping is a no-op for the current records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Peyton Kennedy and others added 8 commits August 10, 2026 08:56
The line-based frontmatter parse compared lines with .strip(), so an indented
'---' still ended the block. A block scalar holding a horizontal rule is
exactly what a security writeup produces, and treating it as the closer
reproduced the original failure in full: grace_until dropped to None, the
effective deadline collapsed back to the base one, and the remaining YAML
became the published body. The same .strip() also accepted an indented
'   ---' as a valid opening delimiter, which YAML frontmatter is not.
Comparison is now on the line minus its ending only, in split_frontmatter and
in scaffold_publish's insertion scan, which has to agree with it.

_cell now collapses a bare CR too: CommonMark and GFM both end a line on one,
and PyYAML resolves the double-quoted \r escape to a real one, so 'Acme\rCorp'
split the row in two. And backslashes are escaped before pipes: escaping only
the pipe turned an authored 'a\|b' into 'a\\|b', whose leading pair GFM
consumes as one escaped backslash, leaving the pipe live and the CVSS column
shifted. Both are reachable from plain unquoted YAML.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_copy_talk_assets swept every file in talks/<slug>/ into a public Pages path
with no allowlist. talks/<slug>/ is exactly where an embargoed deck sits while
its disclosure is still pending, and this repo exists to control disclosure
timing, so the sweep is now an allowlist: only the files named by a talk's
slides and paper fields are copied. A referenced file that does not exist
raises ValidationError naming the talk and the path, because a broken slides
link is indistinguishable from a withdrawn one. Absolute paths and any value
containing '..' are rejected outright; '../../x' previously rendered an href
of talks/<slug>/../../x.

The expiry loop's git add -A was the one command still bare, so a staging
failure would kill the step and starve every later record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a future reader cannot learn from the code, both owner rulings.

Finding bodies render raw HTML deliberately: one author, and writeups
legitimately carry payload demos and rendered PoC markup. The control is PR
review, which means anyone merging a third-party finding must read the body —
that is the point where this becomes a stored XSS path on a github.io origin.
validate.yml does not inspect body HTML and does not mitigate it.

GitHub disables scheduled workflows after 60 days of repository inactivity,
and the only thing that pushes here is build.yml, which fires only when a
record changes. So the disclosure clock's realistic steady state is silently
off: no warnings, no expiry PRs, and nothing showing red. Documented with
three mitigations and the check that matters.

Also records that 'cannot open a PR for <branch>' is terminal rather than
transient: the branch is already pushed, so the ls-remote guard skips that
record on every later run and it never gets its preparation PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the paused-state handoff with a completion record: what shipped, the
manual steps that can only run from the default branch, the decisions worth
not re-litigating, and the deferred items.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frontmatter-driven records generate the README tables and a GitHub Pages
site. Two workflows: build/deploy on push, and a daily disclosure clock
that warns at 7 days and opens a preparation PR at expiry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instrument Serif and IBM Plex Sans/Mono as local woff2, 68KB total, with
both SIL OFL 1.1 licence files alongside them — the licence is a condition
of use, and the site's no-external-requests rule rules out a font CDN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Editorial black/white/grey: Instrument Serif display against IBM Plex Mono
metadata, numbered section marks, hairline rules instead of cards, and no
colour at all — so CVSS severity rides a proportional bar rather than a
red/amber/green chip that would imply thresholds the score does not have.

Bio, photo and social links come from a new profile.yml rather than being
hardcoded into the template, keeping the repo's edit-data-not-markup shape.
Bio text is escaped and then re-linkified for [text](url) with http, https
and mailto only, so the field cannot smuggle markup. An absent bio renders
nothing and an absent photo falls back to a monogram, so a half-filled
profile never publishes a placeholder.

Headshot stored at 480x480 / 105KB, down from 978x1134 / 1.35MB, EXIF dropped.

Functionality unchanged: same tables, routing, sortable headers, CISA-ADP
footnote and clock. 134 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pages on this repo carries the custom domain www.p80n.com with DNS already
delegated, so the site serves at the domain root rather than under
/Vulnerability-Research. The stubs matter most: they exist to repair NVD
reference lists, which cannot be edited after publication, so a reader
following one should land on the canonical host rather than a redirect.

The historical plan document keeps its original URLs deliberately — it
records what was planned, it is not a set of instructions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@p80n-sec
p80n-sec merged commit a112115 into main Aug 10, 2026
1 check passed
@p80n-sec
p80n-sec deleted the research-inventory branch August 10, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant