Skip to content

Add security-advisory skill for writing OSSA-based advisories - #1077

Open
berendt wants to merge 3 commits into
mainfrom
security-advisory-skill
Open

berendt wants to merge 3 commits into
mainfrom
security-advisory-skill

Conversation

@berendt

@berendt berendt commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

Adds a project-level Claude Code skill (.claude/skills/security-advisory/) that drafts an OSISM
security advisory from a single OSSA reference — a review.opendev.org change URL or number, an
OSSA id, or a security.openstack.org URL — for example
/security-advisory https://review.opendev.org/c/openstack/ossa/+/1002324.

  • scripts/collect.py (stdlib only, PyYAML optional) resolves the OSSA change on Gerrit and
    collects into a research dossier: the OSSA YAML, the Launchpad bugs (including whether they are
    public), every upstream fix review per branch with status, upstream series status and released
    versions from openstack/releases (the series of a version is derived from the deliverables,
    never guessed), CVE records with CVSS, the OSISM patches in container-images-kolla mapped to
    their pull requests and upstream reviews via the patch Change-Id, CHANGELOG and PR search hits,
    kolla image names, kolla-ansible tag variables, the OSISM image parameters from osism/defaults
    with the tags available in the rolling registry, and the local advisory conventions and release
    support status. It ends with a coverage matrix per OpenStack release and a list of open points.
  • SKILL.md is the workflow: collect → read the conventions and the two newest advisories → read
    the upstream diff → decide coverage (all releases OSISM builds images for; a release without a
    fix is never guessed but clarified with the author) → write page and index row → verify (lint,
    table format, links) → report with a proposed commit message. Nothing is committed without an
    explicit yes.
  • reference/style.md distils the conventions of the existing advisories (property table,
    status phrases, table variants, check commands, remediation with *_tag and *_image
    overrides in the rolling kolla namespace, reference order, commit message);
    reference/template.md is the skeleton; reference/sources.md lists the verified API commands.

Tested against OSSA-2026-037 (new page, see #1075) and OSSA-2026-005 (existing page, update
mode). The files pass markdownlint, markdown-table-formatter and codespell; the script passes
ruff.

Assisted-by: Claude:claude-fable-5

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

✅⚠️MegaLinter analysis: Success with warnings

Descriptor Linter Files Fixed Errors Max errors Warnings Elapsed time
✅ ACTION actionlint 5 0 0 0.06s
✅ JSON jsonlint 4 0 0 0.12s
✅ JSON prettier 4 0 0 0.45s
✅ JSON v8r 4 0 0 8.26s
✅ MARKDOWN markdownlint 174 0 0 3.33s
✅ MARKDOWN markdown-table-formatter 174 0 0 0.44s
✅ REPOSITORY betterleaks yes no no 0.98s
✅ REPOSITORY checkov yes no no 18.82s
✅ REPOSITORY git_diff yes no no 0.08s
✅ REPOSITORY secretlint yes no no 1.45s
✅ REPOSITORY trufflehog yes no no 4.3s
✅ SPELL codespell 184 0 0 0.6s
⚠️ SPELL lychee 184 3 0 40.62s
✅ YAML prettier 6 0 0 0.43s
✅ YAML v8r 6 0 0 7.85s
✅ YAML yamllint 6 0 0 0.55s

Detailed Issues

⚠️ SPELL / lychee - 3 errors
📝 Summary
---------------------
🔍 Total.........1142
🔗 Unique.........866
✅ Successful....1043
⏳ Timeouts.........3
🔀 Redirected.......9
👻 Excluded........93
❓ Unknown..........0
🚫 Errors...........3
⛔ Unsupported......3

Errors in .claude/skills/security-advisory/SKILL.md
[400] https://cveawg.mitre.org/api/cve/ (at 16:18) | Rejected status code: 400 Bad Request
[404] https://raw.githubusercontent.com/osism/ (at 17:18) | Rejected status code: 404 Not Found
[400] https://registry.osism.tech/service/token (at 18:19) | Rejected status code: 400 Bad Request

Errors in docs/concepts/cluster-network.md
[TIMEOUT] https://stordis.com/basic-configuration-and-management-of-sonic-devices/ (at 196:3) | Request timed out
[TIMEOUT] https://stordis.com/bgp-unnumbered-in-enterprise-sonic/ (at 66:1) | Request timed out
[TIMEOUT] https://stordis.com/deploying-bgp-underlay-in-enterprise-sonic/ (at 197:3) | Request timed out

Hint: Followed 9 redirects. You might want to consider replacing redirecting URLs with the resolved URLs. Use verbose mode (`-v`/`-vv`) to see redirection details.

See detailed reports in MegaLinter artifacts

Your project could benefit from a custom flavor, which would allow you to run only the linters you need, and thus improve runtime performances. (Skip this info by defining FLAVOR_SUGGESTIONS: false)

  • Documentation: Custom Flavors
  • Command: npx mega-linter-runner@10.1.0 --custom-flavor-setup --custom-flavor-linters ACTION_ACTIONLINT,JSON_JSONLINT,JSON_V8R,JSON_PRETTIER,MARKDOWN_MARKDOWNLINT,MARKDOWN_MARKDOWN_TABLE_FORMATTER,REPOSITORY_CHECKOV,REPOSITORY_GIT_DIFF,REPOSITORY_BETTERLEAKS,REPOSITORY_SECRETLINT,REPOSITORY_TRUFFLEHOG,SPELL_LYCHEE,SPELL_CODESPELL,YAML_PRETTIER,YAML_YAMLLINT,YAML_V8R

MegaLinter is provided by OX Security
Show us your support by starring ⭐ the repository

@berendt berendt self-assigned this Aug 26, 2026
if not ossa.get("id"):
m = re.search(r"(OSSA-\d{4}-\d{3})", path) or re.search(r"(OSSA-\d{4}-\d{3})", text)
ossa["id"] = m.group(1) if m else None
ossa["products"] = [p["product"] for p in ossa.get("affected_products", []) if p.get("product")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Product names are not normalised

ossa["products"] is taken verbatim from affected-products[].product, and every consumer
assumes it is the lowercase deliverable name. About 10% of the OSSA corpus disagrees, and it is
not a historical artifact:

OSSA-2026-035  2026-08-13  Octavia      ← the second-newest advisory
OSSA-2026-004  2026-03-19  Glance
OSSA-2025-002  2025-11-04  Keystone
OSSA-2024-005  2024-12-03  Neutron
… 21 of 208 documents; OSSA-2023-002 even has "Cinder, Glance, Nova" in one field

The upstream paths are case-sensitive:

deliverables/epoxy/octavia.yaml  → 200     deliverables/epoxy/Octavia.yaml  → 404
kolla docker/octavia             → 200     kolla docker/Octavia             → 404

With Octavia the following all miss, every one of them silently because the fetches use
allow_404:

  • :605 deliverables/<series>/Octavia.yaml → {"error": "no deliverable file"} for every series
  • :749 project_dir.lower() not in product_variants → every patch file is dropped
  • :893 docker/Octavia → 404, plus the misleading "probably a library" note at :897
  • :901 role match r["name"] == product → no roles → no tag_variables
  • :910, :913 enable_Octavia[a-z0-9_]* → case-sensitive regex, no match
  • :919-921 prod_var → ^(Octavia[a-z0-9_]*_(image|tag)): → no osism_image_parameters, no registry lookups
  • :1031 r["project"].split("/")[-1] in products → rel_reviews empty

Only github_tag_date survives, because GitHub repo names are case-insensitive.

Net effect on a real run against OSSA-2026-035: the dossier is not empty, which is what makes it
dangerous — it is populated with confident negatives.

  • Section 6 renders a per-release table whose "Latest released version" column reads
    no deliverable file on every row (:1181). This is the one visible tell, and nothing warns
    or connects it to the cause.
  • Section 8 prints "No patch files for the affected products in any built release. Either the
    fix is not yet shipped by OSISM, or OSISM relies on the upstream stable branch
    " (:1257) —
    a plausible, wrong conclusion.
  • Section 9 lists the product with empty images/roles/tag variables and the note "no
    docker/Octavia directory in openstack/kolla — probably a library that is installed into
    several images" (:897). That is an actively false statement about a service project, and
    section 9 is what SKILL.md:71-76 and style.md:153-172 mandate for the override snippet.
  • Section 11 has a full set of rows, all with - for versions and fix reviews and
    Covered = **no**.

So it does fail safe on coverage — every ASK-THE-AUTHOR box fires — but every other input the
author needs is silently wrong, and SKILL.md:88-89 says to map versions to series only through
section 6.

Normalise once, at parse time, and keep the original for display:

ossa["products"] = [p["product"].strip().lower()
                    for p in ossa.get("affected_products", []) if p.get("product")]

and lowercase p["product"] in affected_products too (both in parse_ossa and in
parse_ossa_fallback), or carry a product_key alongside the display name. While there:
product_variants at :730-732 should be built from the lowercased name so :749 compares like
with like.

Lowercasing alone is not sufficient for every value in the corpus. OSSA-2023-002 has
- product: Cinder, Glance, Nova in a single field, which lowercases to one bogus deliverable
name. Either split on , into separate products, or — simpler and honest — self.warn() when a
product value does not look like a single deliverable (re.fullmatch(r"[a-z0-9._-]+", key) fails)
and add a section-12 checkbox, so the author sees why the lookups came back empty.

related = [e for e in patches if e.get("related_to_ossa")]
prs = sorted({f"#{p['number']}" for e in related for p in e.get("pull_requests", [])})
fix_merged_upstream = any(r["status"] == "MERGED" for r in rel_reviews)
covered = bool(related) or fix_merged_upstream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] covered over-claims from untrusted evidence

covered is the only gate in front of the ASK THE AUTHOR checkbox at :1355 and the Covered
column that style.md:94-100 drives the Affected Versions table from. Both disjuncts leak.

  • fix_merged_upstream comes from rel_reviews (:1031), which filters only on branch +
    project-basename + MERGED. But collect_reviews deliberately adds every change found via
    message:<bugid> / topic:bug/<bugid> (:517-522) and marks them listed_in_ossa: false
    because they are unclassified. Any merged Related-Bug: follow-up or prerequisite on
    stable/2024.1 now reads as "the fix is merged there".
  • bool(related) inherits the same pollution: related_to_ossa (:780) is also set by the
    subject-slug prefix heuristic at :770-771 (guarded only by len(slug) >= 20) and the bare
    bug-set intersection at :772-773.

Failure case: fix merged only on master, plus one merged Related-Bug cleanup on
stable/2024.1 → the dossier says Covered = yes for 2024.1 and no author question is raised.
The dossier is an authoring input rather than published output, so a human is still in the loop —
but the loop is exactly what this suppresses: SKILL.md:90-95 and style.md:96-97 tell the author
to take the coverage decision from this column and to pick the "Fix in OSISM Images" wording from
it, and the ASK-THE-AUTHOR checkbox is the only prompt to do otherwise. The realistic outcome is a
published advisory saying "Upstream fix via the stable/2024.1 branch" for a release that has none.
Because related also feeds the osism_prs column, a fuzzy filename match can likewise put a
wrong PR number into Remediation.

Suggest making this tri-state rather than boolean:

if strong_match:                  # listed_in_ossa + MERGED, or Change-Id / cherry-pick lineage
    covered = "yes"
elif rel_reviews or related:      # slug match, bug intersection, or unlisted reviews only
    covered = "unverified"
else:
    covered = "no"

Treat unverified exactly like no at :1355 (emit the checkbox) and render it as
**unverified** in the section-11 table. listed_in_ossa and closes_bugs are already
collected; they just are not used here. Please also downgrade the two fuzzy paths at :770-773
to an unverified marker on the patch entry instead of related_to_ossa: True — fixing only
fix_merged_upstream leaves the gate open on a filename match.

Erring toward unverified costs one extra author question. Erring the current way deletes the
question.

points.append("Reviews referencing the bugs but not listed in the OSSA: " + ", ".join(str(r["number"]) for r in extra)
+ " — decide whether they belong to this advisory (follow-ups, OSSN material).")
for b in d.get("bugs", []):
if b.get("information_type") and "Private" in b["information_type"]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The embargo gate cannot fire for the case it exists for

This gate cannot fire for the case it is meant to catch. A genuinely private bug is unreadable:
collect_bugs gets a 404, stores {"error": "not readable"} with no information_type
(:471-475), and b.get("information_type") is falsy here — so the only trace is a warn() that
lands in section 13 among transport noise, while section 12 (which SKILL.md:55-56 drives the
stop decision from) stays silent.

To be precise: a 404 does not prove the bug is private — it may be private, deleted, renumbered,
or Launchpad may simply be unavailable. That is the argument for a positive test rather than a
negative one. The rule in sources.md:41 is already stated positively ("information_type must be
Public Security before anything is published"); the code implements a negative test, and a
narrower one than the rule, since Launchpad's Embargoed and Proprietary types contain no
"Private".

The banner at :1109-1111 does not cover this — it only fires when the OSSA change is unmerged,
it is prose rather than a checklist item, and it says nothing when the change is merged while a
referenced bug is still unreadable.

Invert to a positive check that also covers the unreadable case:

for b in d.get("bugs", []):
    if b.get("information_type") != "Public Security":
        points.append(f"Bug #{b['id']} is not confirmed *Public Security* "
                      f"(information type: {b.get('information_type') or b.get('error') or 'unknown'}) "
                      "— STOP: do not publish until public status is positively established.")

The blocker has to land in points, not just in self.warnings.

if not files:
self.warn("the change does not touch an ossa/OSSA-*.{yaml,rst} file")
else:
path = sorted(files)[-1]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] A batched OSSA change resolves to the wrong advisory

sorted(files)[-1] ignores self.data["input"]["ossa_id"], even when the user asked by id and
resolve() used that id to find the change — and resolve()'s candidate filter
(any(ossa_id in f for f in files), :294) happily matches a multi-file change.

Batched changes are routine in openstack/ossa, three times in 2026 alone:

7fefe7f 2026-07-28  Add OSSA-2026-030 and OSSA-2026-031 (CVE-2026-pending)
b646815 2026-07-23  Add OSSA-2026-027 (CVE-2026-pending) and OSSA-2026-028 (CVE-2026-54422)
688a44d 2026-06-03  OSSA-2026-017, OSSA-2026-018, OSSA-2026-019

Ask for OSSA-2026-030 and you get a dossier for OSSA-2026-031 — a different vulnerability,
different CVE, different bugs — written to <output-dir>/OSSA-2026-031/, because parse_ossa
takes the id from the document body. The only trace is that dossier section 1 still shows the
requested reference while section 2 shows the substituted id; nothing flags the mismatch.

wanted = self.data["input"]["ossa_id"]
path = next((f for f in sorted(files) if wanted and wanted in f), sorted(files)[-1])
if len(files) > 1:
    self.warn(f"the change touches {len(files)} OSSA files ({', '.join(sorted(files))}); using {path}")

Two rules rather than one, though, because the no-id case has no right answer. When the reference
was a bare change number or Gerrit URL, ossa_id is None (:316), the fallback still picks
sorted(files)[-1], and :358-360 then derives the id from whichever file won — so the run
silently commits to one of several advisories with nothing to check it against. Please:

  • an OSSA id was supplied → select the matching file; hard error if none matches;
  • no id supplied and the change touches several ossa/OSSA-* files → hard error naming them and
    asking for a specific id.

Guessing is defensible when there is a stated target to check against. Here there isn't one.


def github_json(self, path: str, allow_404: bool = False):
"""GitHub REST API via `gh api` when available, urllib otherwise."""
if not self.use_github:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] --no-github fails silently

--no-github is the fallback SKILL.md:57 actively recommends, but it fails silently: this early
return emits no warning, and nothing records the flag in self.data or self.warnings, so
neither dossier.md nor raw.json shows that GitHub data is unknown rather than absent.

Downstream:

  • the tree listing is empty → built_releases == [] (:738-740)
  • → osism_builds is false for every row (:1062) → every per-release ASK THE AUTHOR
    checkbox at :1355 is suppressed
  • section 8 renders "No patch files for the affected products in any built release…" (:1257),
    indistinguishable from a genuine negative
  • github_tag_date returns nothing, so release dates vanish
  • style.md:86-89 derives the whole Affected Versions release list from that now-empty
    "Built OpenStack releases" line

The blanket checkbox at :1351 still fires, which mitigates but does not replace the per-release
questions.

Suggest: warn once in __init__ when use_github is false, set self.data["github_skipped"] = True,
render every GitHub-derived field in sections 8/11 as unknown (GitHub not queried) rather than
-/no, and drop c["osism_builds"] and from the guard at :1355 in that mode so the coverage
questions stay unconditional.

| Date | The `date` of the OSSA document (not the day the page is written). With errata: `2026-01-15 (Errata: 2026-01-16)`. |
| CVE | Linked ids `[CVE-…](https://www.cve.org/CVERecord?id=CVE-…)`, comma separated. Four or more ids: plain ids in the cell, links only in References. Not assigned yet: `CVE pending (requested from MITRE)`. |
| Severity | CVSS base severity from the CVE record when it contains one (`High`, `Medium`, …). Otherwise OSISM's assessment; then the Summary must contain the sentence below. A qualifier is allowed: `High (multi-pool deployments only)`. |
| Affected Project | Project name without "OpenStack": `Keystone`, `Nova`, `Neutron`, `Designate`, `keystonemiddleware`. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] No rule for multi-product advisories

The property table row is Affected Project with a singular value (Keystone, Nova), the
template mirrors that (template.md:20), and the index component is OpenStack <Project>
(style.md:206-207). But OSSAs routinely cover several deliverables — 14 of the 208 documents in
openstack/ossa, and not only historical ones:

OSSA-2026-036  2026-08   aodh, watcher
OSSA-2024-003  2024-09   Ironic, Ironic-Python-Agent
OSSA-2024-001  2024-07   Cinder, Glance, Nova
OSSA-2023-003  2023      cinder, glance_store, nova, os-brick

collect.py handles N products throughout — products is a list, section 9 renders one block per
product, build_coverage matches any of them. The style and template files are the only place
where the arity drops to one, so the model is left to improvise the property table, the Affected
Versions table (one table per product? one combined?), the check commands and the index row. All
seven existing advisories are single-product, so there is no exemplar to imitate either.

Doesn't need much: a sentence on the property-table row (comma-separated, order of the OSSA), one
on whether Affected Versions gets a table per product or a Project column, and one on the index
component. Worth settling before the first multi-product advisory rather than during it.

@@ -0,0 +1,232 @@
# Style and content rules for OSISM security advisories

Distilled from the advisories in `docs/appendix/security/`. When in doubt, imitate the two newest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Exemplar selection has three authorities that can diverge

  • SKILL.md:62-64 — "the two exemplar advisories named in dossier section 10"
  • style.md:3-4 — "imitate the two newest files there; they are the canonical examples"
  • sources.md:139 — "the two newest are the style reference"

These are not inherently incompatible — once collect.py:947 sorts by filename (finding 9),
section 10 does name the two newest advisories and all three agree. They are redundant
authorities that can diverge: two of them define the exemplars independently of the dossier and
neither says what "newest" means, so before finding 9 is fixed they can already point at different
files, and afterwards they are three statements to keep in sync. One authority is enough — point
style.md:3-4 and sources.md:139 at dossier section 10.


| Property | Rule |
|:-----------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Date | The `date` of the OSSA document (not the day the page is written). With errata: `2026-01-15 (Errata: 2026-01-16)`. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] The errata Date form has no derivation rule

The mandated form is 2026-01-15 (Errata: 2026-01-16), but nothing says where the errata date
comes from. errata_history is free text, newest first — OSSA-2026-035 has:

errata_history:
  - 2026-08-17 - Errata 1
  - 2026-08-13 - Original Version

and the dossier prints it as a joined string (:1120). So the model has to infer that the OSSA
date field is the original and that the errata date is the leading token of the first entry.
That happens to be right here, but it is an inference, not a rule. One sentence in style.md
("the date field is the original publication date; the errata date is the newest entry in
errata_history, dossier section 2") removes it.

with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return resp.read()
except urllib.error.HTTPError as err:
if err.code == 404 and allow_404:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] A real 404 and a failed request are indistinguishable to every caller

fetch returns None for an allowed 404 and for a 4xx, a 5xx that survives the retry, a
timeout, a transport error, and (via fetch_json) unparseable JSON. Fourteen call sites pass
allow_404=True, and several of them turn that None into a positive statement. Two matter:

CVE state (:694-697). A failed lookup becomes {"state": "not published"}. That drives
section 7, the section-12 point "<CVE> is not published on cve.org yet", and — because metrics
stays empty — the point "No CVSS score available — the Severity is OSISM's own assessment and must
say so". style.md:24 and style.md:28-31 then make the author write OSISM's own severity plus
the sentence "Upstream has not published a CVSS score for this issue." So a transient
cveawg.mitre.org failure is published as a claim about upstream, and the severity in the property
table changes as a result.

Deliverable lookup (:604-609). A failed fetch becomes {"error": "no deliverable file"},
which is also the legitimate answer for a project that did not exist in that series. It feeds the
section-6 table that SKILL.md:88-89 designates the sole authority for mapping versions to
series, plus released state and the section-11 matrix. An opendev outage renders as "upstream has
no such deliverable" for every release at once.

A warning is emitted in the failure cases (:197, :201) and not for a real 404, so the
information exists — but only in section 13, and the callers can't see it. Please make the
distinction reachable, e.g. return a sentinel for an allowed 404 and let everything else raise or
return a typed error, so collect_cves can render state: "lookup failed" and collect_releases
can render error: "lookup failed" distinctly from "no deliverable file". The other twelve sites
are mostly benign (or "" / or {} defaults), but the same sentinel makes them auditable.

entry["series"] = rid
break
if entry["series"] is None:
major = ver.split(".")[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] An unreleased fixed version is assigned to a series by major-number guess

When the exact fixed version isn't in any series' release list, the fallback takes the first series
containing any version with the same major number, iterating releases[product] in ascending
release-id order (:600) — so it picks the oldest matching series, not an authoritative one.

For the independently-versioned deliverables that actually appear in OSSAs, a major spans many
series:

keystonemiddleware   10.x → caracal, dalmatian, epoxy, flamingo   (11.x/12.x only in gazpacho)
os-brick              6.x → caracal … gazpacho                    (five series)
python-keystoneclient 5.x → caracal … gazpacho                    (five series)

So an unreleased keystonemiddleware 10.13.0 is attributed to 2024.1 (Caracal) when the real
answer is Flamingo or later. entry["series"] lands in the section-6 "Fixed versions named by the
OSSA" table and gates fixed_here in build_coverage (:1040), so the Affected Versions table
shows the fixed version against the wrong OpenStack release. The == pinned path just below
(:651-665) is worse: it uses the same guessed series to pick fixed_in from "later versions with
this major" and sets released: True on it.

The irony is worth naming: SKILL.md:88-89 tells the model "the major version of a project does
not equal its OpenStack release — never map from memory", and this is the collector doing exactly
that inference and presenting the result as fact.

Collect all candidate series rather than the first, and when the set isn't a singleton report the
mapping as unverified with a section-12 checkbox — the same tri-state shape as finding 2. The
authoritative signal is the deliverable's branches list (already parsed at :616), which maps a
series to its stable/* branch directly.

@github-project-automation github-project-automation Bot moved this from New to In review in Human Board Aug 28, 2026
Add a Claude Code skill that drafts an OSISM security advisory from a
single OSSA reference. A collector script gathers the OSSA document,
Launchpad bugs, upstream fix reviews and releases, CVE records, the
OSISM container-images-kolla patches with their pull requests, kolla
image tag variables and the local advisory conventions into a research
dossier; the skill then writes the advisory page and index row in the
established format and verifies it.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
Address the review of the security-advisory skill. The common defect
was that a lookup which failed, or evidence that was only a hint, ended
up in the dossier as a definite statement.

Collector:

- Tell an allowed 404 (confirmed negative) and a failed request apart.
  CVE records, deliverable files, Launchpad bugs, kolla image listings
  and the registry now report "lookup failed" / "unknown" and add an
  open point instead of "not published", "no deliverable file" or "do
  not list it in the override".
- Normalise product names to the deliverable name (Octavia -> octavia),
  split fields naming several products and flag names that are no
  deliverable.
- Select the OSSA file of a batched change by the requested id; a
  missing id or a missing match is a hard error instead of a guess.
- Make the coverage tri-state. Only a fix review listed in the OSSA (or
  a backport with its Change-Id) and a patch file carrying that
  Change-Id count as covered; file name and bug number matches and
  merged reviews that merely mention the bug are "unverified" and are
  asked like "no".
- Check the need-to-know rule positively: every bug that is not
  confirmed Public Security, including an unreadable one, is a STOP
  point in section 12.
- Record --no-github, render GitHub data as unknown and keep the
  coverage questions unconditional in that mode.
- Follow renames back to the commit that added a patch file instead of
  citing the renumbering commit.
- Replace the regex fallback with a YAML subset parser that agrees with
  PyYAML on all documents of openstack/ossa, and warn when PyYAML is
  missing. Notes, errata and errata history are no longer dropped.
- Do not map an unreleased version to a series by its major version;
  report the candidates as unverified.
- Choose exemplars by file name instead of mtime, fail when the
  checkout is not found, use a private temporary directory by default
  and derive the kolla reference branch from the series status.

Documentation: state the publication rule once (style.md) and make it
strict, drop the hardcoded release lists from style.md and template.md,
add rules for advisories covering several projects, the errata date and
inclusive upper bounds, and name dossier section 10 as the only
authority for the exemplars. Narrow allowed-tools to the hosts and
read-only endpoints the workflow uses and add the file tools.

Add scripts/test_collect.py (standard library only) and run it in the
Build workflow.

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Christian Berendt <berendt@osism.tech>
OSSA-2026-038 was published with the override
`glance_image: "registry.osism.tech/kolla/glance"` and had to be
corrected in #1086: neither the parameter nor the image exists, it is
`glance_api_image` with `kolla/glance-api`. The parameter names do not
follow one pattern (`keystone_image`, but `glance_api_image` and
`keystone_httpd_image` -> `httpd`); the only authority for them is
all/002-images-kolla.yml in osism/defaults. The dossier listed the
right names, but nothing checked the finished page against that file.

- Add scripts/check_images.py. It compares every `*_image` / `*_tag`
  line in the YAML blocks of an advisory with the defaults file: the
  parameter must exist, the image name must match and use the rolling
  `kolla` namespace, and in a block titled
  environments/kolla/images.yml a tag override needs the image
  parameters it governs and vice versa. Exit status 2 means that the
  defaults file could not be loaded and nothing was verified.
- Run it for all advisories in the Build workflow, so that hand-written
  advisories are covered as well.
- Generate a ready-to-copy override snippet from osism/defaults in
  dossier section 9; images confirmed missing from the rolling registry
  are left out, failed lookups are marked.
- SKILL.md, style.md, template.md and sources.md: copy the names from
  that snippet and never derive them by analogy with another service;
  run the check in the verify step.

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Christian Berendt <berendt@osism.tech>
@berendt
berendt force-pushed the security-advisory-skill branch from 23d77b7 to d5864b0 Compare September 21, 2026 18:47
@berendt
berendt requested a review from ideaship September 21, 2026 19:22
@berendt

berendt commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

#1089 generated with the current state of this PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

3 participants