From 8b8e95036aa8537327a8b22552c470591c6add0b Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Sun, 26 Jul 2026 12:37:59 +0300 Subject: [PATCH 1/3] Add CITATION.cff and automated Zenodo deposit for the 2026 edition Papers already cite the Top 10 (NVIDIA's garak paper cites the first version), but with no DOI there is no identifier for Crossref, OpenAIRE or Google Scholar to resolve against, so finding that use means manual searching. This adds citation metadata and a workflow that deposits each edition to Zenodo, which mints the DOI. CITATION.cff carries the authors of the 2026 entries. GitHub renders it as "Cite this repository", and the release workflow builds the Zenodo record from it, so the author list is maintained in one place rather than two. .github/zenodo.config.json holds the file list for each deposit. `{edition}` comes from the release tag, so the 2027 edition needs no config change. Each key under `deposits` is a separate Zenodo record, so any future publication from this repo can take its own concept DOI instead of becoming a version of the Top 10. .github/scripts/zenodo_release.py builds the bundle and deposits it through the Zenodo REST API, with `--check` to validate the config and CITATION.cff, `--dry-run` to print the file list, and `--sandbox` to target sandbox.zenodo.org. The workflow deposits on release and runs the first two on pull requests that touch these files. documentation/ZENODO.md covers setup and the release steps, and the README gains a citation section and a DOI badge, commented out until the first release supplies the number. The 2026 deposit holds 22 files (2.6 MB): the ten entries, the preface, the AISVS appendix, three published figures and CITATION.cff. It excludes the 2025 list, 2026/working/ and the diagram tool's scratch files under report/images/screenshots and report/images/uploads. Editions are versions of one Zenodo record, each with its own DOI under a concept DOI that resolves to the newest. Cite the concept DOI. This follows DataCite's versioning guidance for major revisions of a work. I did not use Zenodo's GitHub integration. It archives the whole repository tarball at the tag with no include/exclude option, so the 2026 DOI would ship the 2025 list and the working drafts, and it supports only one record per repository. Four values are still missing and marked TODO where they belong: the real publication date, which stands at the sprint plan's 2026-06-15 target; the contributor list, for reviewers and editors who are not credited as entry authors; the concept DOI, which Zenodo issues at the first publish; and author ORCIDs, which are optional. Publishing also needs the ZENODO_TOKEN secret, and the ZENODO_CONCEPT_RECID variable once the first publish reports it. Without the latter, each release mints an unrelated record and the citation history splits. Signed-off-by: Stefano Amorelli --- .github/scripts/zenodo_release.py | 408 +++++++++++++++++++++++++++ .github/workflows/zenodo-release.yml | 78 +++++ .github/zenodo.config.json | 48 ++++ CITATION.cff | 121 ++++++++ README.md | 17 ++ documentation/ZENODO.md | 74 +++++ 6 files changed, 746 insertions(+) create mode 100644 .github/scripts/zenodo_release.py create mode 100644 .github/workflows/zenodo-release.yml create mode 100644 .github/zenodo.config.json create mode 100644 CITATION.cff create mode 100644 documentation/ZENODO.md diff --git a/.github/scripts/zenodo_release.py b/.github/scripts/zenodo_release.py new file mode 100644 index 0000000..ffddd45 --- /dev/null +++ b/.github/scripts/zenodo_release.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +"""Build a scoped edition bundle and deposit it to Zenodo. + + zenodo_release.py --check # validate config + CITATION.cff + zenodo_release.py --edition 2026 --dry-run # print the bundle contents + zenodo_release.py --edition 2026 --sandbox # deposit a draft to sandbox + zenodo_release.py --edition 2026 --publish # deposit and publish for real + +File selection comes from .github/zenodo.config.json, citation metadata from +CITATION.cff. Neither names an edition, so a new edition needs no code change. + +Each entry under `deposits` is a separate Zenodo record. Editions of one deposit +become versions of that record. + +Environment: + ZENODO_TOKEN API token with `deposit:write`, plus `deposit:actions` to + publish. + Concept record id for one deposit, named by that deposit's + `concept_env` key. Set it after the first publish, otherwise + later releases create unrelated records. +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import os +import re +import sys +import zipfile +from pathlib import Path +from urllib.parse import urlparse + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONFIG_PATH = REPO_ROOT / ".github" / "zenodo.config.json" +CITATION_PATH = REPO_ROOT / "CITATION.cff" +EDITION_RE = re.compile(r"(\d{4})") +TIMEOUT = 120 + + +# --------------------------------------------------------------------------- # +# edition + config +# --------------------------------------------------------------------------- # + +def discover_editions() -> list[str]: + """Every NNNN/ directory in the repo that has a final/ subdirectory.""" + return sorted( + p.name + for p in REPO_ROOT.iterdir() + if p.is_dir() and re.fullmatch(r"\d{4}", p.name) and (p / "final").is_dir() + ) + + +def resolve_edition(requested: str | None) -> str: + """Read the edition from the argument (a tag like `v2026` works), or fall + back to the newest edition in the repo.""" + editions = discover_editions() + if not editions: + sys.exit("error: no NNNN/final/ directory found in the repository") + if not requested: + print(f"no edition given, using the newest in the repo: {editions[-1]}") + return editions[-1] + match = EDITION_RE.search(requested) + if not match: + sys.exit(f"error: cannot read an edition year from {requested!r}") + edition = match.group(1) + if edition not in editions: + sys.exit(f"error: {edition}/final/ does not exist (found: {', '.join(editions)})") + return edition + + +def load_config() -> dict: + if not CONFIG_PATH.is_file(): + sys.exit(f"error: missing {CONFIG_PATH.relative_to(REPO_ROOT)}") + config = json.loads(CONFIG_PATH.read_text()) + if not config.get("deposits"): + sys.exit("error: config has no `deposits`") + return config + + +def select_deposits(config: dict, requested: str | None) -> dict: + """All deposits, or just the one named.""" + deposits = config["deposits"] + if not requested: + return deposits + if requested not in deposits: + sys.exit(f"error: unknown deposit {requested!r} (have: {', '.join(deposits)})") + return {requested: deposits[requested]} + + +def load_citation() -> dict: + try: + import yaml + except ImportError: + sys.exit("error: PyYAML is required (pip install pyyaml)") + if not CITATION_PATH.is_file(): + sys.exit("error: missing CITATION.cff") + return yaml.safe_load(CITATION_PATH.read_text()) + + +# --------------------------------------------------------------------------- # +# CITATION.cff -> Zenodo metadata +# --------------------------------------------------------------------------- # + +def as_zenodo_person(entry: dict) -> dict: + """Convert a CFF author to a Zenodo creator. Entities keep their `name`.""" + if "name" in entry: + person = {"name": entry["name"]} + else: + family = entry.get("family-names", "").strip() + given = entry.get("given-names", "").strip() + person = {"name": f"{family}, {given}".strip(", ")} + if entry.get("affiliation"): + person["affiliation"] = entry["affiliation"] + if entry.get("orcid"): + person["orcid"] = entry["orcid"].rsplit("/", 1)[-1] + return person + + +def build_metadata(cff: dict, deposit: dict, edition: str, publication_date: str | None) -> dict: + # `$`-prefixed keys are comments for maintainers, not Zenodo fields. + configured = { + k: v for k, v in deposit.get("metadata", {}).items() if not k.startswith("$") + } + people = cff.get("authors", []) + creators = [as_zenodo_person(a) for a in people if "name" not in a] + entities = [as_zenodo_person(a) for a in people if "name" in a] + + abstract = " ".join(cff.get("abstract", cff["title"]).split()) + url = cff.get("url") + + metadata: dict = { + **configured, + "title": cff["title"], + "description": f"

{abstract}

" + + (f'

Project site: {url}

' if url else ""), + "version": edition, + "creators": creators, + "keywords": cff.get("keywords", []), + } + if cff.get("license"): + metadata["license"] = cff["license"].lower() + if publication_date: + metadata["publication_date"] = publication_date + elif cff.get("date-released"): + metadata["publication_date"] = str(cff["date-released"]) + # Contributors listed in the config come first, then organisations named as + # authors in CITATION.cff. + contributors = list(configured.get("contributors", [])) + contributors += [{**e, "type": "HostingInstitution"} for e in entities] + if contributors: + metadata["contributors"] = contributors + if url: + metadata.setdefault("related_identifiers", []).append( + {"identifier": url, "relation": "isSupplementTo", "scheme": "url"} + ) + return metadata + + +# --------------------------------------------------------------------------- # +# bundle +# --------------------------------------------------------------------------- # + +def collect_files(deposit: dict, edition: str) -> list[Path]: + bundle = deposit["bundle"] + includes = [p.format(edition=edition) for p in bundle["include"]] + excludes = [p.format(edition=edition) for p in bundle.get("exclude", [])] + + selected: list[Path] = [] + for entry in includes: + target = REPO_ROOT / entry + if target.is_file(): + selected.append(target) + elif target.is_dir(): + selected.extend(sorted(p for p in target.rglob("*") if p.is_file())) + else: + sys.exit(f"error: include path not found: {entry}") + + kept = [] + for path in selected: + rel = path.relative_to(REPO_ROOT).as_posix() + if any(fnmatch.fnmatch(rel, pattern) for pattern in excludes): + continue + kept.append(path) + if not kept: + sys.exit("error: bundle is empty, check `include`/`exclude` in the config") + return kept + + +def write_bundle(files: list[Path], destination: Path) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as archive: + for path in files: + archive.write(path, path.relative_to(REPO_ROOT).as_posix()) + return destination + + +def report_bundle(files: list[Path], stream=sys.stdout) -> None: + total = sum(f.stat().st_size for f in files) + print(f"{len(files)} files, {total / 1024:.0f} KiB", file=stream) + for path in files: + print(f" {path.relative_to(REPO_ROOT).as_posix()}", file=stream) + + +# --------------------------------------------------------------------------- # +# validation +# --------------------------------------------------------------------------- # + +def check(config: dict, cff: dict) -> int: + problems = [] + + for field in ("title", "authors", "license"): + if not cff.get(field): + problems.append(f"CITATION.cff is missing `{field}`") + + # preferred-citation repeats the person list, and drift there produces a + # citation that disagrees with the deposit. Entity authors map to Zenodo + # contributors instead, so they appear in only one of the two lists. + def people(entries): + return [e for e in (entries or []) if "name" not in e] + + preferred = (cff.get("preferred-citation") or {}).get("authors") + if preferred is not None and people(preferred) != people(cff.get("authors")): + problems.append("CITATION.cff: `authors` and `preferred-citation.authors` differ") + + editions = discover_editions() + if not editions: + problems.append("no NNNN/final/ directory found") + + for name, deposit in config["deposits"].items(): + if not deposit.get("concept_env"): + problems.append(f"deposit {name!r}: missing `concept_env`") + for edition in editions: + try: + collect_files(deposit, edition) + except SystemExit as exc: + problems.append(f"deposit {name!r}, edition {edition}: {exc}") + + for problem in problems: + print(f"FAIL: {problem}", file=sys.stderr) + if problems: + return 1 + print( + f"OK. deposits: {', '.join(config['deposits'])}. " + f"editions: {', '.join(editions)}" + ) + return 0 + + +# --------------------------------------------------------------------------- # +# Zenodo API +# --------------------------------------------------------------------------- # + +def api_call(response, what: str) -> dict: + if not response.ok: + sys.exit(f"error: {what} failed ({response.status_code}): {response.text}") + return response.json() if response.content else {} + + +def deposit( + bundle: Path, + metadata: dict, + concept_env: str, + sandbox: bool, + publish: bool, +) -> None: + import requests + + token = os.environ.get("ZENODO_TOKEN") + if not token: + sys.exit("error: ZENODO_TOKEN is not set") + base = "https://sandbox.zenodo.org/api" if sandbox else "https://zenodo.org/api" + params = {"access_token": token} + concept = os.environ.get(concept_env) + + if concept: + print(f"creating a new version of concept record {concept}") + latest = api_call( + requests.get(f"{base}/records/{concept}", params=params, timeout=TIMEOUT), + "resolving concept record", + ) + versioned = api_call( + requests.post( + f"{base}/deposit/depositions/{latest['id']}/actions/newversion", + params=params, + timeout=TIMEOUT, + ), + "creating new version", + ) + draft_id = urlparse(versioned["links"]["latest_draft"]).path.rstrip("/").rsplit("/", 1)[-1] + draft = api_call( + requests.get( + f"{base}/deposit/depositions/{draft_id}", params=params, timeout=TIMEOUT + ), + "fetching draft", + ) + # Zenodo copies the previous version's files into a new version. + for stale in draft.get("files", []): + api_call( + requests.delete( + f"{base}/deposit/depositions/{draft['id']}/files/{stale['id']}", + params=params, + timeout=TIMEOUT, + ), + f"removing inherited file {stale.get('filename')}", + ) + else: + print(f"{concept_env} is unset, creating the first deposition") + draft = api_call( + requests.post( + f"{base}/deposit/depositions", params=params, json={}, timeout=TIMEOUT + ), + "creating deposition", + ) + + with bundle.open("rb") as handle: + api_call( + requests.put( + f"{draft['links']['bucket']}/{bundle.name}", + data=handle, + params=params, + timeout=TIMEOUT, + ), + f"uploading {bundle.name}", + ) + print(f"uploaded {bundle.name} ({bundle.stat().st_size} bytes)") + + draft = api_call( + requests.put( + f"{base}/deposit/depositions/{draft['id']}", + params=params, + json={"metadata": metadata}, + timeout=TIMEOUT, + ), + "updating metadata", + ) + + if not publish: + print(f"draft ready for review: {draft['links'].get('html')}") + return + + published = api_call( + requests.post( + f"{base}/deposit/depositions/{draft['id']}/actions/publish", + params=params, + timeout=TIMEOUT, + ), + "publishing", + ) + print(f"version DOI: {published.get('doi')}") + print(f"concept DOI (cite this): {published.get('conceptdoi')}") + if not concept: + print(f"Next step: set {concept_env}={published.get('conceptrecid')}") + + +# --------------------------------------------------------------------------- # + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--edition", help="edition or tag, e.g. 2026 or v2026. default: newest") + parser.add_argument("--deposit", help="deposit key from the config; default: all") + parser.add_argument("--publication-date", help="ISO date; default: CITATION.cff") + parser.add_argument("--out", default=Path("dist"), type=Path, help="bundle output directory") + parser.add_argument("--check", action="store_true", help="validate config and exit") + parser.add_argument("--dry-run", action="store_true", help="build and list, do not deposit") + parser.add_argument("--sandbox", action="store_true", help="use sandbox.zenodo.org") + parser.add_argument("--publish", action="store_true", help="publish instead of leaving a draft") + args = parser.parse_args() + + config = load_config() + cff = load_citation() + + if args.check: + sys.exit(check(config, cff)) + + edition = resolve_edition(args.edition) + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + + for name, spec in select_deposits(config, args.deposit).items(): + print(f"\n=== deposit {name!r}, edition {edition} ===") + files = collect_files(spec, edition) + report_bundle(files) + + if summary_path: + with open(summary_path, "a") as handle: + print(f"### {name}, edition {edition}\n\n```", file=handle) + report_bundle(files, stream=handle) + print("```", file=handle) + + bundle_name = spec["bundle"]["name"].format(edition=edition) + bundle = write_bundle(files, args.out / f"{bundle_name}.zip") + + if args.dry_run: + print(f"dry run: wrote {bundle}, nothing deposited") + continue + + deposit( + bundle, + build_metadata(cff, spec, edition, args.publication_date), + concept_env=spec["concept_env"], + sandbox=args.sandbox, + publish=args.publish, + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/zenodo-release.yml b/.github/workflows/zenodo-release.yml new file mode 100644 index 0000000..afc28ea --- /dev/null +++ b/.github/workflows/zenodo-release.yml @@ -0,0 +1,78 @@ +name: Zenodo release + +on: + release: + types: [published] + pull_request: + paths: + - CITATION.cff + - .github/zenodo.config.json + - .github/scripts/zenodo_release.py + - .github/workflows/zenodo-release.yml + workflow_dispatch: + inputs: + edition: + description: "Edition to deposit (e.g. 2026). Blank = newest in the repo." + required: false + deposit: + description: "Deposit key from zenodo.config.json. Blank = all." + required: false + sandbox: + description: "Deposit to sandbox.zenodo.org" + type: boolean + default: true + publish: + description: "Publish (unchecked leaves a reviewable draft)" + type: boolean + default: false + +permissions: + contents: read + +jobs: + validate: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install --quiet pyyaml + - run: python .github/scripts/zenodo_release.py --check + - run: python .github/scripts/zenodo_release.py --dry-run + + deposit: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install --quiet pyyaml requests + + - name: Resolve edition and date + id: meta + run: | + if [ "${{ github.event_name }}" = "release" ]; then + date="${{ github.event.release.published_at }}" + echo "edition=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + else + date="$(date -u +%Y-%m-%d)" + echo "edition=${{ inputs.edition }}" >> "$GITHUB_OUTPUT" + fi + echo "date=${date%%T*}" >> "$GITHUB_OUTPUT" + + - name: Deposit + env: + ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }} + # One variable per deposit, named by its `concept_env` in the config. + ZENODO_CONCEPT_RECID: ${{ vars.ZENODO_CONCEPT_RECID }} + run: | + python .github/scripts/zenodo_release.py \ + ${{ steps.meta.outputs.edition && format('--edition {0}', steps.meta.outputs.edition) || '' }} \ + ${{ (github.event_name == 'workflow_dispatch' && inputs.deposit) && format('--deposit {0}', inputs.deposit) || '' }} \ + --publication-date "${{ steps.meta.outputs.date }}" \ + ${{ (github.event_name == 'workflow_dispatch' && inputs.sandbox) && '--sandbox' || '' }} \ + ${{ (github.event_name == 'release' || inputs.publish) && '--publish' || '' }} diff --git a/.github/zenodo.config.json b/.github/zenodo.config.json new file mode 100644 index 0000000..5de5dba --- /dev/null +++ b/.github/zenodo.config.json @@ -0,0 +1,48 @@ +{ + "$comment": [ + "Zenodo deposit configuration. See documentation/ZENODO.md.", + "", + "The script replaces `{edition}` with the edition it is releasing, which it", + "reads from the release tag. To add the 2027 edition, add 2027/final/ and", + "tag v2027.", + "", + "Each key under `deposits` is a separate Zenodo record with its own concept", + "DOI, read from the environment variable named by `concept_env`. Editions of", + "one deposit become versions of that record.", + "", + "Citation metadata comes from CITATION.cff, not from this file." + ], + "deposits": { + "top10": { + "concept_env": "ZENODO_CONCEPT_RECID", + "bundle": { + "name": "owasp-llm-top10-{edition}", + "include": [ + "{edition}/final", + "CITATION.cff" + ], + "exclude": [ + "*/report/images/screenshots/*", + "*/report/images/uploads/*", + "*/report/images/support.js", + "*/.DS_Store" + ] + }, + "metadata": { + "upload_type": "publication", + "publication_type": "report", + "access_right": "open", + "language": "eng", + "$comment_contributors": [ + "TODO(contributors): list the people who worked on the 2026 edition", + "without being credited as entry authors, such as reviewers and", + "editors. Entry authors belong in CITATION.cff, not here.", + "Format: { \"name\": \"Family, Given\", \"type\": \"Editor\" }.", + "Zenodo types: ContactPerson, DataCurator, Editor, Researcher,", + "ProjectLeader, ProjectMember, WorkPackageLeader, Other." + ], + "contributors": [] + } + } + } +} diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..656b998 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,121 @@ +# Machine-readable citation metadata. GitHub shows it as "Cite this repository", +# and the release workflow builds the Zenodo record from it. +# Spec: https://citation-file-format.github.io +# +# TODO(orcid): add `orcid:` to any author who has one. The release workflow +# passes it through to Zenodo, and it lets indexers tell apart authors with +# common names. + +cff-version: 1.2.0 +message: "If you use this work, please cite it as below." +title: "OWASP Top 10 for Large Language Model Applications" +abstract: >- + A standard awareness document describing the ten most critical security risks + in applications built on large language models, produced by the OWASP GenAI + Security Project. +type: dataset +version: "2026" +date-released: 2026-06-15 # TODO(release-date): sprint plan target, confirm the real date +license: CC-BY-SA-4.0 +url: "https://genai.owasp.org/llm-top-10/" +repository-code: "https://github.com/GenAI-Security-Project/GenAI-LLM-Top10" +# TODO(doi): uncomment and set to the concept DOI after the first publish. +# doi: 10.5281/zenodo.XXXXXXX +keywords: + - OWASP + - LLM security + - generative AI + - AI security + - application security + - prompt injection + - threat modeling +authors: + - given-names: Steve + family-names: Wilson + - given-names: Rock + family-names: Lambros + - given-names: Rachel + family-names: James + - given-names: Emmanuel + family-names: Guilherme + - given-names: Ken + family-names: Huang + - given-names: John + family-names: Sotiropoulos + - given-names: Stefano + family-names: Amorelli + - given-names: Sumeet + family-names: Jeswani + - given-names: Mark + family-names: Roxberry + - given-names: Rico + family-names: Komenda + - given-names: Glavin + family-names: Klondike + - given-names: Andy + family-names: Smith + - given-names: Alex + family-names: Leung + - given-names: Sonu + family-names: Kumar + - given-names: Vinnie + family-names: Giarrusso + - given-names: Savio + family-names: D'souza + - given-names: Arshi + family-names: Chadha + - given-names: Sahil + family-names: Mehta + - name: "OWASP GenAI Security Project" + website: "https://genai.owasp.org" + +preferred-citation: + type: report + title: "OWASP Top 10 for Large Language Model Applications" + institution: + name: "OWASP Foundation" + website: "https://owasp.org" + version: "2026" + year: 2026 + month: 6 + license: CC-BY-SA-4.0 + url: "https://genai.owasp.org/llm-top-10/" + # TODO(doi): uncomment and set to the concept DOI after the first publish. + # doi: 10.5281/zenodo.XXXXXXX + authors: + - given-names: Steve + family-names: Wilson + - given-names: Rock + family-names: Lambros + - given-names: Rachel + family-names: James + - given-names: Emmanuel + family-names: Guilherme + - given-names: Ken + family-names: Huang + - given-names: John + family-names: Sotiropoulos + - given-names: Stefano + family-names: Amorelli + - given-names: Sumeet + family-names: Jeswani + - given-names: Mark + family-names: Roxberry + - given-names: Rico + family-names: Komenda + - given-names: Glavin + family-names: Klondike + - given-names: Andy + family-names: Smith + - given-names: Alex + family-names: Leung + - given-names: Sonu + family-names: Kumar + - given-names: Vinnie + family-names: Giarrusso + - given-names: Savio + family-names: D'souza + - given-names: Arshi + family-names: Chadha + - given-names: Sahil + family-names: Mehta diff --git a/README.md b/README.md index 96e0523..307f800 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ Companion repository to the OWASP Top 10 for Large Language Model Applications, [![OWASP Flagship Status project](https://img.shields.io/badge/owasp-flagship-blue.svg)](https://owasp.org/projects/) [![License: CC BY-SA 4.0](https://img.shields.io/badge/License-CC%20BY--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-sa/4.0/) [![genai.owasp.org](https://img.shields.io/badge/officialsite-genai.owasp.org-032CFA.svg)](https://genai.owasp.org) + Welcome to the official repository for the OWASP Top 10 for Large Language Model Applications! @@ -55,6 +58,20 @@ We have a working group channel on the [OWASP Slack](https://owasp.org/slack/inv **Learn how to contribute:** [https://genai.owasp.org/contribute/](https://genai.owasp.org/contribute/) +## Citation 🎓 + +If you reference this work in a paper, report, or standard, cite it using the +metadata in [`CITATION.cff`](./CITATION.cff). GitHub renders that file as +**"Cite this repository"** in the sidebar, in APA and BibTeX. + +We archive each release on Zenodo, which mints a DOI that papers can cite. The +record holds the finalised entries for that edition, without the repository's +drafts or earlier editions. Cite the concept DOI, which resolves to the newest +edition. + +[`documentation/ZENODO.md`](./documentation/ZENODO.md) covers the process and the +maintainer steps for a release. + ## License This project is licensed under the terms of the [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0/). diff --git a/documentation/ZENODO.md b/documentation/ZENODO.md new file mode 100644 index 0000000..d633337 --- /dev/null +++ b/documentation/ZENODO.md @@ -0,0 +1,74 @@ +# Zenodo archival + +The release workflow deposits each edition to Zenodo, which mints a DOI. Papers +cite that DOI, which lets us find them. + +We deposit through the Zenodo API rather than Zenodo's GitHub integration, +because we publish a subset of the repository and expect more than one product +from it. + +## How editions map to DOIs + +Editions are versions of one Zenodo record. Each version gets its own DOI, and +Zenodo groups them under a concept DOI that resolves to the newest edition. + +Cite the concept DOI. Readers who need a specific edition can use the version +DOI on that record's page. [DataCite's versioning +guidance](https://support.datacite.org/docs/versioning) describes the same +approach. + +A separate publication from this repo would get its own entry under `deposits` +and its own concept DOI, rather than becoming a version of this one. + +## Files + +| File | Role | +| --- | --- | +| [`CITATION.cff`](../CITATION.cff) | Authors, title, licence, keywords | +| [`.github/zenodo.config.json`](../.github/zenodo.config.json) | Which files go into each deposit | +| [`.github/scripts/zenodo_release.py`](../.github/scripts/zenodo_release.py) | Builds the bundle and deposits it | +| [`.github/workflows/zenodo-release.yml`](../.github/workflows/zenodo-release.yml) | Runs the script on release, validates it on pull requests | + +The script reads citation metadata from `CITATION.cff` at deposit time, so the +author list lives in one file. + +## Adding an edition + +`{edition}` in the config comes from the release tag. To publish the 2027 +edition, add `2027/final/` and tag `v2027`. The config needs no change. + +Update `CITATION.cff` with that edition's authors in the same release. The +workflow checks out the tag, so each version records the people who wrote that +edition, and the bundle carries a copy of `CITATION.cff` as it stood. Earlier +versions keep their own author lists, and the concept DOI shows the newest. + +## Checking the bundle + +```sh +pip install pyyaml requests +python .github/scripts/zenodo_release.py --check # validate config and CITATION.cff +python .github/scripts/zenodo_release.py --dry-run # list what would be deposited +``` + +The workflow runs both on pull requests that touch these files, and prints the +file listing to the run summary. + +## Setup + +1. Create a token with `deposit:write` and `deposit:actions` at + . Add it as the + `ZENODO_TOKEN` repository secret. +2. Rehearse on sandbox: Actions > *Zenodo release* > **Run workflow**, with + *sandbox* checked and *publish* unchecked. Zenodo cannot delete a published + record, so get this right before the first real deposit. +3. After the first real publish, set the repository variable + `ZENODO_CONCEPT_RECID` to the concept record id from the log. Without it, + later releases create unrelated records and split the citation history. + +## Releasing + +Tag a GitHub release `v`, for example `v2026`. The workflow takes the +version from the tag and the date from the release, then deposits and publishes. + +Afterwards, add the concept DOI to `CITATION.cff` (two commented `doi:` lines), +the README badge, and . From b3e6502eaddec9fff80860d22eeaf0530b0121f8 Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Sun, 26 Jul 2026 13:00:42 +0300 Subject: [PATCH 2/3] Add tests for the Zenodo deposit flow The deposit path had no test coverage: every assertion so far came from --check and --dry-run, which never issue a request. test_zenodo_release.py stubs the requests module and runs both paths, the first deposition and a new version of an existing concept record. It asserts the call sequence, that inherited files are removed before the new bundle is uploaded, that draft mode skips the publish action, and that --sandbox never reaches the production host. It also pins the metadata shape: creators as 'Family, Given', publication_date as ISO8601, and no 569JNRXghklprefixed config comments leaking into the payload. Confirmed cc-by-sa-4.0 against Zenodo's live licence vocabulary. The /api/licenses path now redirects to /api/vocabularies/licenses. The tests stub the API, so they do not prove Zenodo accepts the metadata. That still needs a sandbox run with a real token. Signed-off-by: Stefano Amorelli --- .github/scripts/test_zenodo_release.py | 162 +++++++++++++++++++++++++ .github/workflows/zenodo-release.yml | 1 + documentation/ZENODO.md | 7 +- 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/test_zenodo_release.py diff --git a/.github/scripts/test_zenodo_release.py b/.github/scripts/test_zenodo_release.py new file mode 100644 index 0000000..f79d755 --- /dev/null +++ b/.github/scripts/test_zenodo_release.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Tests for zenodo_release.py. + +Runs the deposit flow against a stubbed Zenodo API, so it needs no token and +makes no network calls. This covers the request sequence, URLs and payloads. +It cannot prove that Zenodo accepts the metadata; only a sandbox run does that. + + python .github/scripts/test_zenodo_release.py +""" + +from __future__ import annotations + +import json +import sys +import types +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import zenodo_release as z # noqa: E402 + +CALLS: list[tuple[str, str, dict]] = [] +FAILURES: list[str] = [] + + +class FakeResponse: + def __init__(self, payload: dict, status: int = 200): + self._payload = payload + self.status_code = status + self.ok = status < 400 + self.content = json.dumps(payload).encode() + self.text = json.dumps(payload) + + def json(self) -> dict: + return self._payload + + +def draft_payload(deposit_id: int = 111, files: list | None = None) -> dict: + return { + "id": deposit_id, + "files": files or [], + "links": { + "bucket": f"https://example.invalid/api/files/bucket-{deposit_id}", + "html": f"https://example.invalid/deposit/{deposit_id}", + "latest_draft": f"https://example.invalid/api/deposit/depositions/{deposit_id}", + }, + } + + +def install_fake_requests(concept_exists: bool, inherited: list | None = None) -> None: + """Swap in a `requests` module that records calls and returns canned data.""" + + def record(method): + def call(url, **kwargs): + CALLS.append((method, url, kwargs)) + if method == "GET" and "/records/" in url: + return FakeResponse({"id": 999}) + if method == "GET" and "/deposit/depositions/" in url: + return FakeResponse(draft_payload(111, inherited)) + if method == "POST" and url.endswith("/actions/newversion"): + return FakeResponse(draft_payload(111, inherited)) + if method == "POST" and url.endswith("/actions/publish"): + return FakeResponse( + { + "doi": "10.5281/zenodo.222", + "conceptdoi": "10.5281/zenodo.221", + "conceptrecid": "221", + } + ) + if method == "POST" and url.endswith("/deposit/depositions"): + return FakeResponse(draft_payload(111)) + if method == "DELETE": + return FakeResponse({}) + return FakeResponse(draft_payload(111, inherited)) + + return call + + fake = types.ModuleType("requests") + for verb in ("get", "post", "put", "delete"): + setattr(fake, verb, record(verb.upper())) + sys.modules["requests"] = fake + + +def check(label: str, condition: bool, detail: str = "") -> None: + if condition: + print(f" ok {label}") + else: + print(f" FAIL {label} {detail}") + FAILURES.append(label) + + +def sequence() -> list[tuple[str, str]]: + return [(m, u.split("/api", 1)[-1] if "/api" in u else u) for m, u, _ in CALLS] + + +def run(name: str, *, concept: str | None, publish: bool, sandbox: bool, inherited=None): + global CALLS + CALLS = [] + install_fake_requests(bool(concept), inherited) + import os + + os.environ["ZENODO_TOKEN"] = "fake-token" + if concept: + os.environ["TEST_CONCEPT"] = concept + else: + os.environ.pop("TEST_CONCEPT", None) + + config = z.load_config() + cff = z.load_citation() + spec = config["deposits"]["top10"] + files = z.collect_files(spec, "2026") + bundle = z.write_bundle(files, Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp") / "t.zip") + metadata = z.build_metadata(cff, spec, "2026", "2026-06-15") + print(f"\n{name}") + z.deposit(bundle, metadata, concept_env="TEST_CONCEPT", sandbox=sandbox, publish=publish) + return metadata + + +# --------------------------------------------------------------------------- # + +md = run("first deposit, publish, production", concept=None, publish=True, sandbox=False) +seq = sequence() +check("creates a deposition", ("POST", "/deposit/depositions") in seq) +check("does not call newversion", not any("newversion" in u for _, u in seq)) +check("uploads to the bucket URL", any(m == "PUT" and "files/bucket" in u for m, u in seq)) +check("publishes", any(u.endswith("/actions/publish") for _, u in seq)) +# The bucket URL comes from Zenodo's response, so only check URLs we build. +ours = [u for _, u, _ in CALLS if "zenodo.org" in u] +check("uses the production API base", ours and all(u.startswith("https://zenodo.org/api") for u in ours)) +put_meta = [k for m, u, k in CALLS if m == "PUT" and "depositions" in u] +check("sends metadata under a `metadata` key", bool(put_meta) and "metadata" in put_meta[0]["json"]) +sent = put_meta[0]["json"]["metadata"] +check("no $-prefixed comment keys reach Zenodo", not any(k.startswith("$") for k in sent)) +check("license is the Zenodo vocabulary id", sent["license"] == "cc-by-sa-4.0", sent.get("license")) +check("upload_type/publication_type set", (sent["upload_type"], sent["publication_type"]) == ("publication", "report")) +check("publication_date is ISO8601", sent["publication_date"] == "2026-06-15") +check("creators are 'Family, Given'", sent["creators"][0]["name"] == "Wilson, Steve") +check("18 creators", len(sent["creators"]) == 18, str(len(sent["creators"]))) +check("contributor type is valid", sent["contributors"][0]["type"] == "HostingInstitution") +check("token passed as access_token", all(k.get("params", {}).get("access_token") == "fake-token" for _, _, k in CALLS)) + +run( + "new version with inherited files, sandbox, draft only", + concept="221", + publish=False, + sandbox=True, + inherited=[{"id": "f1", "filename": "old.zip"}], +) +seq = sequence() +check("resolves the concept record", any(m == "GET" and "/records/221" in u for m, u in seq)) +check("creates a new version", any(u.endswith("/actions/newversion") for _, u in seq)) +check("deletes the inherited file", any(m == "DELETE" and u.endswith("/files/f1") for m, u in seq)) +check("uploads the new bundle", any(m == "PUT" and "files/bucket" in u for m, u in seq)) +check("does NOT publish in draft mode", not any(u.endswith("/actions/publish") for _, u in seq)) +ours = [u for _, u, _ in CALLS if "zenodo.org" in u] +check("uses the sandbox API base", ours and all(u.startswith("https://sandbox.zenodo.org/api") for u in ours)) +check("never touches production", not any(u.startswith("https://zenodo.org/api") for u in ours)) + +print() +if FAILURES: + print(f"{len(FAILURES)} failed: {', '.join(FAILURES)}") + sys.exit(1) +print("all checks passed") diff --git a/.github/workflows/zenodo-release.yml b/.github/workflows/zenodo-release.yml index afc28ea..84396a8 100644 --- a/.github/workflows/zenodo-release.yml +++ b/.github/workflows/zenodo-release.yml @@ -41,6 +41,7 @@ jobs: - run: pip install --quiet pyyaml - run: python .github/scripts/zenodo_release.py --check - run: python .github/scripts/zenodo_release.py --dry-run + - run: python .github/scripts/test_zenodo_release.py deposit: if: github.event_name != 'pull_request' diff --git a/documentation/ZENODO.md b/documentation/ZENODO.md index d633337..bc482b2 100644 --- a/documentation/ZENODO.md +++ b/documentation/ZENODO.md @@ -48,10 +48,13 @@ versions keep their own author lists, and the concept DOI shows the newest. pip install pyyaml requests python .github/scripts/zenodo_release.py --check # validate config and CITATION.cff python .github/scripts/zenodo_release.py --dry-run # list what would be deposited +python .github/scripts/test_zenodo_release.py # deposit flow against a stubbed API ``` -The workflow runs both on pull requests that touch these files, and prints the -file listing to the run summary. +The workflow runs all three on pull requests that touch these files, and prints +the file listing to the run summary. The tests stub the Zenodo API, so they +check the request sequence and payloads but not whether Zenodo accepts the +metadata. A sandbox run is the only thing that proves that. ## Setup From 080d3874be9011a9598c9c67dc50d518c4f24e8e Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Sun, 9 Aug 2026 14:42:36 +0300 Subject: [PATCH 3/3] Record the 2026 Zenodo DOI and deposit by hand for now The 2026 edition is archived at https://zenodo.org/records/22109015. Both `CITATION.cff` and the README now carry the concept DOI, 10.5281/zenodo.22109014, which always resolves to the newest edition; the version DOI for 2026 alone is 10.5281/zenodo.22109015. The author list follows the Acknowledgements page of the published PDF rather than this repository. That page is the authoritative credit for an edition, and checking it added two entry leads who were missing here, Anitha Dakamarri (LLM05) and Venkata Sai Kishore Modalavalasa (LLM06), corrected Gavin Klondike and Savio Dsouza, and reordered the list to match: project leads, then entry leads LLM01 to LLM10. Five authors now carry an ORCID. The release workflow is removed. Running the deposit from CI means holding a Zenodo API token in a repository secret, and that token publishes under the identity of whoever minted it, so it should wait until the project decides who owns the record. The script, its config and its tests stay, and `documentation/ZENODO.md` covers running them by hand. Signed-off-by: Stefano Amorelli --- .github/scripts/test_zenodo_release.py | 6 +- .github/workflows/zenodo-release.yml | 79 ---------------------- .gitignore | 1 + CITATION.cff | 94 ++++++++++++++++---------- README.md | 18 ++++- documentation/ZENODO.md | 83 ++++++++++++++++------- 6 files changed, 139 insertions(+), 142 deletions(-) delete mode 100644 .github/workflows/zenodo-release.yml diff --git a/.github/scripts/test_zenodo_release.py b/.github/scripts/test_zenodo_release.py index f79d755..c4248a3 100644 --- a/.github/scripts/test_zenodo_release.py +++ b/.github/scripts/test_zenodo_release.py @@ -134,7 +134,11 @@ def run(name: str, *, concept: str | None, publish: bool, sandbox: bool, inherit check("upload_type/publication_type set", (sent["upload_type"], sent["publication_type"]) == ("publication", "report")) check("publication_date is ISO8601", sent["publication_date"] == "2026-06-15") check("creators are 'Family, Given'", sent["creators"][0]["name"] == "Wilson, Steve") -check("18 creators", len(sent["creators"]) == 18, str(len(sent["creators"]))) +# Not a fixed number: the author list changes with each edition. What matters +# is that every person in CITATION.cff reaches Zenodo, and the order survives. +people = [a for a in z.load_citation()["authors"] if "name" not in a] +check("every author is deposited", len(sent["creators"]) == len(people), str(len(sent["creators"]))) +check("author order survives", sent["creators"][-1]["name"] == "Klondike, Gavin") check("contributor type is valid", sent["contributors"][0]["type"] == "HostingInstitution") check("token passed as access_token", all(k.get("params", {}).get("access_token") == "fake-token" for _, _, k in CALLS)) diff --git a/.github/workflows/zenodo-release.yml b/.github/workflows/zenodo-release.yml deleted file mode 100644 index 84396a8..0000000 --- a/.github/workflows/zenodo-release.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Zenodo release - -on: - release: - types: [published] - pull_request: - paths: - - CITATION.cff - - .github/zenodo.config.json - - .github/scripts/zenodo_release.py - - .github/workflows/zenodo-release.yml - workflow_dispatch: - inputs: - edition: - description: "Edition to deposit (e.g. 2026). Blank = newest in the repo." - required: false - deposit: - description: "Deposit key from zenodo.config.json. Blank = all." - required: false - sandbox: - description: "Deposit to sandbox.zenodo.org" - type: boolean - default: true - publish: - description: "Publish (unchecked leaves a reviewable draft)" - type: boolean - default: false - -permissions: - contents: read - -jobs: - validate: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install --quiet pyyaml - - run: python .github/scripts/zenodo_release.py --check - - run: python .github/scripts/zenodo_release.py --dry-run - - run: python .github/scripts/test_zenodo_release.py - - deposit: - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install --quiet pyyaml requests - - - name: Resolve edition and date - id: meta - run: | - if [ "${{ github.event_name }}" = "release" ]; then - date="${{ github.event.release.published_at }}" - echo "edition=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" - else - date="$(date -u +%Y-%m-%d)" - echo "edition=${{ inputs.edition }}" >> "$GITHUB_OUTPUT" - fi - echo "date=${date%%T*}" >> "$GITHUB_OUTPUT" - - - name: Deposit - env: - ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }} - # One variable per deposit, named by its `concept_env` in the config. - ZENODO_CONCEPT_RECID: ${{ vars.ZENODO_CONCEPT_RECID }} - run: | - python .github/scripts/zenodo_release.py \ - ${{ steps.meta.outputs.edition && format('--edition {0}', steps.meta.outputs.edition) || '' }} \ - ${{ (github.event_name == 'workflow_dispatch' && inputs.deposit) && format('--deposit {0}', inputs.deposit) || '' }} \ - --publication-date "${{ steps.meta.outputs.date }}" \ - ${{ (github.event_name == 'workflow_dispatch' && inputs.sandbox) && '--sandbox' || '' }} \ - ${{ (github.event_name == 'release' || inputs.publish) && '--publish' || '' }} diff --git a/.gitignore b/.gitignore index 712bec9..ee1c095 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ *.pyc .DS_Store +dist/ diff --git a/CITATION.cff b/CITATION.cff index 656b998..81344b1 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,25 +2,33 @@ # and the release workflow builds the Zenodo record from it. # Spec: https://citation-file-format.github.io # -# TODO(orcid): add `orcid:` to any author who has one. The release workflow -# passes it through to Zenodo, and it lets indexers tell apart authors with -# common names. +# TODO(orcid): several authors still have no `orcid:`. Adding one lets indexers +# tell apart authors with common names, and it carries through to Zenodo. +# The author list and its order follow the Acknowledgements page of the +# published 2026 PDF: project leads, then entry leads LLM01 to LLM10. cff-version: 1.2.0 message: "If you use this work, please cite it as below." title: "OWASP Top 10 for Large Language Model Applications" abstract: >- - A standard awareness document describing the ten most critical security risks - in applications built on large language models, produced by the OWASP GenAI - Security Project. + OWASP Top 10 for LLM Applications 2026 is the latest community-driven guide to + the most critical security risks facing applications powered by large language + models. Developed by hundreds of AI security experts, this edition introduces + updated rankings, expanded threat coverage, and new research grounded in + thousands of real-world AI security incidents. The guide provides practical + explanations, attack scenarios, and actionable mitigations for developers, + architects, security teams, and CISOs, while mapping risks to leading industry + frameworks including NIST, MITRE ATLAS, CWE, and the OWASP Top 10 for Agentic + Applications, making it an essential resource for building and securing modern + AI. type: dataset version: "2026" -date-released: 2026-06-15 # TODO(release-date): sprint plan target, confirm the real date +date-released: 2026-08-04 license: CC-BY-SA-4.0 url: "https://genai.owasp.org/llm-top-10/" repository-code: "https://github.com/GenAI-Security-Project/GenAI-LLM-Top10" -# TODO(doi): uncomment and set to the concept DOI after the first publish. -# doi: 10.5281/zenodo.XXXXXXX +# Concept DOI: always resolves to the newest edition. Cite this one. +doi: 10.5281/zenodo.22109014 keywords: - OWASP - LLM security @@ -34,38 +42,47 @@ authors: family-names: Wilson - given-names: Rock family-names: Lambros + orcid: "https://orcid.org/0009-0000-7853-4836" - given-names: Rachel family-names: James - given-names: Emmanuel family-names: Guilherme - given-names: Ken family-names: Huang + orcid: "https://orcid.org/0009-0004-6502-3673" + - given-names: Andy + family-names: Smith - given-names: John family-names: Sotiropoulos - given-names: Stefano family-names: Amorelli + orcid: "https://orcid.org/0009-0004-4917-0999" - given-names: Sumeet family-names: Jeswani + orcid: "https://orcid.org/0009-0007-2477-9302" - given-names: Mark family-names: Roxberry - - given-names: Rico - family-names: Komenda - - given-names: Glavin - family-names: Klondike - - given-names: Andy - family-names: Smith + - given-names: Anitha + family-names: Dakamarri + - given-names: Sahil + family-names: Mehta + - given-names: Venkata Sai Kishore + family-names: Modalavalasa - given-names: Alex family-names: Leung - - given-names: Sonu - family-names: Kumar - given-names: Vinnie family-names: Giarrusso + - given-names: Sonu + family-names: Kumar - given-names: Savio - family-names: D'souza + family-names: Dsouza - given-names: Arshi family-names: Chadha - - given-names: Sahil - family-names: Mehta + orcid: "https://orcid.org/0009-0009-6479-5811" + - given-names: Rico + family-names: Komenda + - given-names: Gavin + family-names: Klondike - name: "OWASP GenAI Security Project" website: "https://genai.owasp.org" @@ -77,45 +94,54 @@ preferred-citation: website: "https://owasp.org" version: "2026" year: 2026 - month: 6 + month: 8 license: CC-BY-SA-4.0 url: "https://genai.owasp.org/llm-top-10/" - # TODO(doi): uncomment and set to the concept DOI after the first publish. - # doi: 10.5281/zenodo.XXXXXXX + # Concept DOI: always resolves to the newest edition. Cite this one. + doi: 10.5281/zenodo.22109014 authors: - given-names: Steve family-names: Wilson - given-names: Rock family-names: Lambros + orcid: "https://orcid.org/0009-0000-7853-4836" - given-names: Rachel family-names: James - given-names: Emmanuel family-names: Guilherme - given-names: Ken family-names: Huang + orcid: "https://orcid.org/0009-0004-6502-3673" + - given-names: Andy + family-names: Smith - given-names: John family-names: Sotiropoulos - given-names: Stefano family-names: Amorelli + orcid: "https://orcid.org/0009-0004-4917-0999" - given-names: Sumeet family-names: Jeswani + orcid: "https://orcid.org/0009-0007-2477-9302" - given-names: Mark family-names: Roxberry - - given-names: Rico - family-names: Komenda - - given-names: Glavin - family-names: Klondike - - given-names: Andy - family-names: Smith + - given-names: Anitha + family-names: Dakamarri + - given-names: Sahil + family-names: Mehta + - given-names: Venkata Sai Kishore + family-names: Modalavalasa - given-names: Alex family-names: Leung - - given-names: Sonu - family-names: Kumar - given-names: Vinnie family-names: Giarrusso + - given-names: Sonu + family-names: Kumar - given-names: Savio - family-names: D'souza + family-names: Dsouza - given-names: Arshi family-names: Chadha - - given-names: Sahil - family-names: Mehta + orcid: "https://orcid.org/0009-0009-6479-5811" + - given-names: Rico + family-names: Komenda + - given-names: Gavin + family-names: Klondike diff --git a/README.md b/README.md index e973a0e..8b2f47c 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,7 @@ [![OWASP Flagship Status project](https://img.shields.io/badge/owasp-flagship-blue.svg)](https://owasp.org/projects/) [![License: CC BY-SA 4.0](https://img.shields.io/badge/License-CC%20BY--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-sa/4.0/) [![Official site](https://img.shields.io/badge/official_publication-genai.owasp.org-032CFA.svg)](https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/) - +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22109014.svg)](https://doi.org/10.5281/zenodo.22109014) This is the official source repository for the **OWASP Top 10 for Large Language Model Applications**, maintained as a core initiative of the [OWASP GenAI Security Project](https://genai.owasp.org/). @@ -66,6 +64,20 @@ We have a working group channel on [OWASP Slack](https://owasp.org/slack/invite) For the broader project contribution process, visit [genai.owasp.org/contribute](https://genai.owasp.org/contribute/). +## Citing this work + +The 2026 edition is archived on Zenodo. Cite the concept DOI, which always +resolves to the newest edition: + +> OWASP GenAI Security Project. (2026). *OWASP Top 10 for Large Language Model +> Applications* (Version 2026). OWASP Foundation. +> + +To cite the 2026 edition specifically rather than the newest, use its version +DOI, [10.5281/zenodo.22109015](https://doi.org/10.5281/zenodo.22109015). +[`CITATION.cff`](./CITATION.cff) carries the same metadata in machine-readable +form, and GitHub renders it under "Cite this repository". + ## License This project is licensed under the [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0/). diff --git a/documentation/ZENODO.md b/documentation/ZENODO.md index bc482b2..e742006 100644 --- a/documentation/ZENODO.md +++ b/documentation/ZENODO.md @@ -1,7 +1,7 @@ # Zenodo archival -The release workflow deposits each edition to Zenodo, which mints a DOI. Papers -cite that DOI, which lets us find them. +Each edition is deposited to Zenodo, which mints a DOI. Papers cite that DOI, +which lets us find them. We deposit through the Zenodo API rather than Zenodo's GitHub integration, because we publish a subset of the repository and expect more than one product @@ -27,20 +27,23 @@ and its own concept DOI, rather than becoming a version of this one. | [`CITATION.cff`](../CITATION.cff) | Authors, title, licence, keywords | | [`.github/zenodo.config.json`](../.github/zenodo.config.json) | Which files go into each deposit | | [`.github/scripts/zenodo_release.py`](../.github/scripts/zenodo_release.py) | Builds the bundle and deposits it | -| [`.github/workflows/zenodo-release.yml`](../.github/workflows/zenodo-release.yml) | Runs the script on release, validates it on pull requests | + +No workflow is wired to CI yet, so the script is run by hand. See +[Setup](#setup) and [Releasing](#releasing). The script reads citation metadata from `CITATION.cff` at deposit time, so the author list lives in one file. ## Adding an edition -`{edition}` in the config comes from the release tag. To publish the 2027 -edition, add `2027/final/` and tag `v2027`. The config needs no change. +`{edition}` is passed on the command line. To publish the 2027 edition, add +`2027/final/` and run the script with `--edition 2027`. The config needs no +change. -Update `CITATION.cff` with that edition's authors in the same release. The -workflow checks out the tag, so each version records the people who wrote that -edition, and the bundle carries a copy of `CITATION.cff` as it stood. Earlier -versions keep their own author lists, and the concept DOI shows the newest. +Update `CITATION.cff` with that edition's authors in the same release, and build +the bundle from a committed revision rather than a dirty working tree, so the +deposit matches something anyone can check out. Earlier versions keep their own +author lists, and the concept DOI shows the newest. ## Checking the bundle @@ -51,27 +54,57 @@ python .github/scripts/zenodo_release.py --dry-run # list what would be depos python .github/scripts/test_zenodo_release.py # deposit flow against a stubbed API ``` -The workflow runs all three on pull requests that touch these files, and prints -the file listing to the run summary. The tests stub the Zenodo API, so they -check the request sequence and payloads but not whether Zenodo accepts the -metadata. A sandbox run is the only thing that proves that. +The tests stub the Zenodo API, so they check the request sequence and payloads +but not whether Zenodo accepts the metadata. A sandbox run is the only thing +that proves that. + +## The 2026 record + +The 2026 edition was deposited by hand on 26 August 2026. + +| | | +| --- | --- | +| Record | | +| Concept DOI (cite this) | [10.5281/zenodo.22109014](https://doi.org/10.5281/zenodo.22109014) | +| Version DOI (2026 only) | [10.5281/zenodo.22109015](https://doi.org/10.5281/zenodo.22109015) | +| Files | the published PDF, and a source bundle built from `origin/main` | + +Two things about that record are worth knowing before the next edition. + +The author list and its order come from the Acknowledgements page of the +published PDF, not from this repository. That page is the authoritative credit +for an edition, and `CITATION.cff` is kept in step with it. + +The published PDF is not in this repository, so the bundle in +`.github/zenodo.config.json` does not include it. It was uploaded by hand. An +automated release would deposit the source bundle alone unless the PDF is +committed under `/final/` first. ## Setup +The deposit runs from a laptop, not from CI. Automating it means putting a +Zenodo API token in a repository secret, and that token carries the identity of +whoever minted it: it can publish under their name, and any repository admin can +use it. Decide who owns the record before wiring that up. + 1. Create a token with `deposit:write` and `deposit:actions` at - . Add it as the - `ZENODO_TOKEN` repository secret. -2. Rehearse on sandbox: Actions > *Zenodo release* > **Run workflow**, with - *sandbox* checked and *publish* unchecked. Zenodo cannot delete a published - record, so get this right before the first real deposit. -3. After the first real publish, set the repository variable - `ZENODO_CONCEPT_RECID` to the concept record id from the log. Without it, - later releases create unrelated records and split the citation history. + , and export it + as `ZENODO_TOKEN`. +2. Rehearse against sandbox with `--sandbox` and without `--publish`. Zenodo + cannot delete a published record, so get this right first. +3. Export `ZENODO_CONCEPT_RECID=22109014` so the deposit becomes a new version of + the existing record. Without it the script creates an unrelated record and + splits the citation history. ## Releasing -Tag a GitHub release `v`, for example `v2026`. The workflow takes the -version from the tag and the date from the release, then deposits and publishes. +Add `/final/`, update `CITATION.cff` with that edition's authors, then: + +```sh +export ZENODO_TOKEN=... +export ZENODO_CONCEPT_RECID=22109014 +python .github/scripts/zenodo_release.py --edition 2027 --publish +``` -Afterwards, add the concept DOI to `CITATION.cff` (two commented `doi:` lines), -the README badge, and . +The version DOI changes, the concept DOI does not. Afterwards, update the README +badge and if the citation text changed.