diff --git a/.github/scripts/test_zenodo_release.py b/.github/scripts/test_zenodo_release.py new file mode 100644 index 0000000..c4248a3 --- /dev/null +++ b/.github/scripts/test_zenodo_release.py @@ -0,0 +1,166 @@ +#!/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") +# 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)) + +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/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/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/.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 new file mode 100644 index 0000000..81344b1 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,147 @@ +# 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): 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: >- + 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-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" +# Concept DOI: always resolves to the newest edition. Cite this one. +doi: 10.5281/zenodo.22109014 +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 + 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: 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: Vinnie + family-names: Giarrusso + - given-names: Sonu + family-names: Kumar + - given-names: Savio + family-names: Dsouza + - given-names: Arshi + family-names: Chadha + 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" + +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: 8 + license: CC-BY-SA-4.0 + url: "https://genai.owasp.org/llm-top-10/" + # 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: 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: Vinnie + family-names: Giarrusso + - given-names: Sonu + family-names: Kumar + - given-names: Savio + family-names: Dsouza + - given-names: Arshi + family-names: Chadha + 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 2bfecb0..8b2f47c 100644 --- a/README.md +++ b/README.md @@ -13,6 +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/). @@ -63,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 new file mode 100644 index 0000000..e742006 --- /dev/null +++ b/documentation/ZENODO.md @@ -0,0 +1,110 @@ +# Zenodo archival + +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 +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 | + +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}` 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, 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 + +```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 +python .github/scripts/test_zenodo_release.py # deposit flow against a stubbed API +``` + +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 + , 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 + +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 +``` + +The version DOI changes, the concept DOI does not. Afterwards, update the README +badge and if the citation text changed.