Skip to content

Commit d3efdf6

Browse files
committed
Add CC-BY-4.0 attribution for Red Hat-sourced advisory content
Rocky advisories re-publish Red Hat advisory text (synopsis, description, topic), which Red Hat licenses under CC BY 4.0. That license requires crediting the source, linking to the original advisory and the license, and indicating that changes were made. The published outputs provided none of these. Add attribution across updateinfo.xml (the <rights> line plus source and license references), the v2 and v3 JSON APIs (a structured source object: name, url, vendor, license, licenseUrl), OSV (a source reference, a Red Hat credit, and license fields in database_specific), the RSS feed, and the web template. The wording and links are centralized in apollo/server/attribution.py so every format stays consistent. No schema change is required; the source advisory and its URL are derived from the existing red_hat_advisory relation.
1 parent 1b80805 commit d3efdf6

14 files changed

Lines changed: 403 additions & 24 deletions

File tree

.github/workflows/test.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
bazel test //apollo/tests:test_admin_routes_supported_products --test_output=all
4141
bazel test //apollo/tests:test_admin_users --test_output=all
4242
bazel test //apollo/tests:test_api_osv --test_output=all
43+
bazel test //apollo/tests:test_api_compat --test_output=all
4344
bazel test //apollo/tests:test_database_service --test_output=all
4445
bazel test //apollo/tests:test_rh_matcher_activities --test_output=all
4546

apollo/db/advisory.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ async def fetch_advisories(
124124
if fetch_related:
125125
for advisory in advisories:
126126
await advisory.fetch_related(
127+
"red_hat_advisory",
127128
"packages",
128129
"cves",
129130
"fixes",

apollo/db/serialize/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ class Advisory_Pydantic_V2_CVE(BaseModel):
8585
cwe: Optional[str]
8686

8787

88+
class Advisory_Pydantic_V2_Source(BaseModel):
89+
name: str
90+
url: str
91+
vendor: str
92+
license: str
93+
licenseUrl: str
94+
95+
8896
class Advisory_Pydantic_V2(BaseModel):
8997
type: str
9098
shortCode: str
@@ -102,6 +110,11 @@ class Advisory_Pydantic_V2(BaseModel):
102110
rpms: dict[str, Advisory_Pydantic_V2_RPMs]
103111
rebootSuggested: bool
104112
buildReferences: list[str]
113+
source: Optional[Advisory_Pydantic_V2_Source] = None
105114

106115
class Config:
107116
orm_mode = True
117+
118+
119+
class Advisory_Pydantic_WithSource(Advisory_Pydantic):
120+
source: Optional[Advisory_Pydantic_V2_Source] = None

apollo/server/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ load("//build/macros:fastapi.bzl", "fastapi_binary")
44
py_library(
55
name = "server_lib",
66
srcs = [
7+
"attribution.py",
78
"auth.py",
89
"models/__init__.py",
910
"models/workflow.py",

apollo/server/attribution.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Attribution for Red Hat-sourced advisory content.
2+
3+
Rocky advisories re-publish Red Hat advisory content, which Red Hat licenses
4+
under CC BY 4.0. That license requires crediting the source, linking to the
5+
original advisory and to the license, and indicating that changes were made.
6+
These helpers keep that notice identical across every output format
7+
(updateinfo.xml, the v2 API, OSV, and the web UI).
8+
"""
9+
10+
SOURCE_VENDOR = "Red Hat"
11+
SOURCE_LICENSE = "CC-BY-4.0"
12+
SOURCE_LICENSE_URL = "https://creativecommons.org/licenses/by/4.0/"
13+
RED_HAT_ERRATA_BASE_URL = "https://access.redhat.com/errata"
14+
15+
16+
def red_hat_errata_url(red_hat_advisory_name: str) -> str:
17+
"""Return the canonical Red Hat errata URL for a given advisory name."""
18+
return f"{RED_HAT_ERRATA_BASE_URL}/{red_hat_advisory_name}"
19+
20+
21+
def source_fields(red_hat_advisory_name: str) -> dict:
22+
"""Return the structured source/license fields for a Red Hat-derived advisory."""
23+
return {
24+
"name": red_hat_advisory_name,
25+
"url": red_hat_errata_url(red_hat_advisory_name),
26+
"vendor": SOURCE_VENDOR,
27+
"license": SOURCE_LICENSE,
28+
"licenseUrl": SOURCE_LICENSE_URL,
29+
}
30+
31+
32+
def attribution_rights(
33+
red_hat_advisory_name: str, company_name: str, year: int
34+
) -> str:
35+
"""Build the per-advisory rights line crediting the Red Hat source under CC BY 4.0."""
36+
url = red_hat_errata_url(red_hat_advisory_name)
37+
return (
38+
f"Copyright {year} {company_name}. "
39+
f"Advisory content derived from {SOURCE_VENDOR} {red_hat_advisory_name} "
40+
f"({url}), (C) {SOURCE_VENDOR}, Inc., used under CC BY 4.0 "
41+
f"({SOURCE_LICENSE_URL}); modified by {company_name}."
42+
)
43+
44+
45+
def attribution_notice(company_name: str) -> str:
46+
"""Build a source-agnostic attribution line for feed and site-wide use."""
47+
return (
48+
f"Advisory content is derived from {SOURCE_VENDOR} advisories, "
49+
f"(C) {SOURCE_VENDOR}, Inc., used under CC BY 4.0 "
50+
f"({SOURCE_LICENSE_URL}); modified by {company_name}."
51+
)

apollo/server/routes/api_advisories.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
from fastapi.exceptions import HTTPException
55
from fastapi_pagination import Params
66
from fastapi_pagination.links import Page
7-
from fastapi_pagination.ext.tortoise import paginate
7+
from fastapi_pagination.ext.tortoise import create_page
88

99
from apollo.db import Advisory, RedHatIndexState
10-
from apollo.db.serialize import Advisory_Pydantic
10+
from apollo.db.serialize import (
11+
Advisory_Pydantic,
12+
Advisory_Pydantic_V2_Source,
13+
Advisory_Pydantic_WithSource,
14+
)
15+
from apollo.server import attribution
1116

1217
router = APIRouter(tags=["advisories"])
1318

@@ -22,9 +27,19 @@ class Config:
2227
fields = {"items": {"alias": "advisories"}}
2328

2429

30+
async def _advisory_with_source(advisory: Advisory) -> Advisory_Pydantic_WithSource:
31+
base = await Advisory_Pydantic.from_tortoise_orm(advisory)
32+
source = None
33+
if advisory.red_hat_advisory_id:
34+
source = Advisory_Pydantic_V2_Source(
35+
**attribution.source_fields(advisory.red_hat_advisory.name)
36+
)
37+
return Advisory_Pydantic_WithSource(**base.dict(), source=source)
38+
39+
2540
@router.get(
2641
"/",
27-
response_model=Pagination[Advisory_Pydantic],
42+
response_model=Pagination[Advisory_Pydantic_WithSource],
2843
)
2944
async def list_advisories(
3045
params: Params = Depends(),
@@ -37,15 +52,18 @@ async def list_advisories(
3752
severity: Optional[str] = None,
3853
kind: Optional[str] = None,
3954
):
40-
advisories = await paginate(
41-
Advisory.all().prefetch_related(
42-
"red_hat_advisory",
43-
"packages",
44-
"cves",
45-
"fixes",
46-
"affected_products",
47-
).order_by("-published_at"),
48-
)
55+
query = Advisory.all().prefetch_related(
56+
"red_hat_advisory",
57+
"packages",
58+
"cves",
59+
"fixes",
60+
"affected_products",
61+
).order_by("-published_at")
62+
63+
total = await query.count()
64+
page_orm = await query.offset(params.size * (params.page - 1)).limit(params.size)
65+
items = [await _advisory_with_source(adv) for adv in page_orm]
66+
advisories = create_page(items, total, params)
4967

5068
state = await RedHatIndexState.first()
5169
advisories.last_updated_at = state.last_indexed_at.isoformat("T").replace(
@@ -58,10 +76,11 @@ async def list_advisories(
5876

5977
@router.get(
6078
"/{advisory_name}",
61-
response_model=Advisory_Pydantic,
79+
response_model=Advisory_Pydantic_WithSource,
6280
)
6381
async def get_advisory(advisory_name: str):
6482
advisory = await Advisory.filter(name=advisory_name).prefetch_related(
83+
"red_hat_advisory",
6584
"packages",
6685
"cves",
6786
"fixes",
@@ -74,4 +93,4 @@ async def get_advisory(advisory_name: str):
7493
if advisory is None:
7594
raise HTTPException(404)
7695

77-
return await Advisory_Pydantic.from_tortoise_orm(advisory)
96+
return await _advisory_with_source(advisory)

apollo/server/routes/api_compat.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
from apollo.db import Advisory, RedHatIndexState
2121
from apollo.db.advisory import fetch_advisories
22-
from apollo.db.serialize import Advisory_Pydantic_V2, Advisory_Pydantic_V2_CVE, Advisory_Pydantic_V2_Fix, Advisory_Pydantic_V2_RPMs
22+
from apollo.db.serialize import Advisory_Pydantic_V2, Advisory_Pydantic_V2_CVE, Advisory_Pydantic_V2_Fix, Advisory_Pydantic_V2_RPMs, Advisory_Pydantic_V2_Source
23+
from apollo.server import attribution
2324
from apollo.server.settings import UI_URL, COMPANY_NAME, MANAGING_EDITOR, get_setting
2425

2526
from common.fastapi import RenderErrorTemplateException, parse_rfc3339_date
@@ -137,6 +138,12 @@ def v3_advisory_to_v2(
137138
if severity == "NONE":
138139
severity = "UNKNOWN"
139140

141+
source = None
142+
if fetch_related and advisory.red_hat_advisory_id:
143+
source = Advisory_Pydantic_V2_Source(
144+
**attribution.source_fields(advisory.red_hat_advisory.name)
145+
)
146+
140147
return Advisory_Pydantic_V2(
141148
id=advisory.id,
142149
publishedAt=published_at,
@@ -155,6 +162,7 @@ def v3_advisory_to_v2(
155162
buildReferences=[],
156163
fixes=fixes,
157164
cves=cves,
165+
source=source,
158166
)
159167

160168

@@ -307,7 +315,9 @@ async def list_advisories_compat_v2_rss(
307315
fg.language("en")
308316
fg.description(f"Advisories issued by {company_name}")
309317
fg.copyright(
310-
f"(C) {company_name} {datetime.datetime.now().year}. All rights reserved. CVE sources are copyright of their respective owners."
318+
f"(C) {company_name} {datetime.datetime.now().year}. All rights reserved. "
319+
f"CVE sources are copyright of their respective owners. "
320+
f"{attribution.attribution_notice(company_name)}"
311321
)
312322
fg.managingEditor(f"{managing_editor} ({company_name})")
313323

@@ -332,6 +342,7 @@ async def list_advisories_compat_v2_rss(
332342
)
333343
async def get_advisory_compat_v2(advisory_name: str):
334344
advisory = await Advisory.filter(name=advisory_name).prefetch_related(
345+
"red_hat_advisory",
335346
"packages",
336347
"cves",
337348
"fixes",

apollo/server/routes/api_osv.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from apollo.db import Advisory, RedHatIndexState
1313
from apollo.db.advisory import fetch_advisories
1414
from apollo.rpmworker.repomd import EPOCH_RE, NEVRA_RE
15+
from apollo.server import attribution
1516
from apollo.server.settings import UI_URL, get_setting
1617

1718
from common.fastapi import Params, to_rfc3339_date
@@ -85,7 +86,9 @@ class OSVCredit(BaseModel):
8586

8687

8788
class OSVDatabaseSpecific(BaseModel):
88-
pass
89+
license: Optional[str] = None
90+
license_url: Optional[str] = None
91+
source_advisory: Optional[str] = None
8992

9093

9194
class OSVAdvisory(BaseModel):
@@ -194,8 +197,21 @@ def to_osv_advisory(ui_url: str, advisory: Advisory) -> OSVAdvisory:
194197
references.append(OSVReference(type="REPORT", url=fix.source))
195198

196199
osv_credits = [OSVCredit(name=x) for x in vendors]
197-
if advisory.red_hat_advisory:
198-
osv_credits.append(OSVCredit(name="Red Hat"))
200+
database_specific = None
201+
red_hat_advisory = advisory.red_hat_advisory
202+
if red_hat_advisory:
203+
references.append(
204+
OSVReference(
205+
type="ADVISORY",
206+
url=attribution.red_hat_errata_url(red_hat_advisory.name),
207+
)
208+
)
209+
osv_credits.append(OSVCredit(name=attribution.SOURCE_VENDOR))
210+
database_specific = OSVDatabaseSpecific(
211+
license=attribution.SOURCE_LICENSE,
212+
license_url=attribution.SOURCE_LICENSE_URL,
213+
source_advisory=red_hat_advisory.name,
214+
)
199215

200216
highest_cvss_base_score = 0.0
201217
final_score_vector = None
@@ -225,7 +241,7 @@ def to_osv_advisory(ui_url: str, advisory: Advisory) -> OSVAdvisory:
225241
affected=affected_pkgs,
226242
references=references,
227243
credits=osv_credits,
228-
database_specific=None,
244+
database_specific=database_specific,
229245
)
230246

231247

@@ -280,6 +296,7 @@ async def get_advisory_osv(advisory_id: str):
280296
advisory = (
281297
await Advisory.filter(name=advisory_id)
282298
.prefetch_related(
299+
"red_hat_advisory",
283300
"packages",
284301
"cves",
285302
"fixes",

apollo/server/routes/api_updateinfo.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from apollo.db import AdvisoryAffectedProduct, AdvisoryPackage, SupportedProduct
99
from tortoise.exceptions import DoesNotExist
1010
from tortoise.queryset import Prefetch
11+
from apollo.server import attribution
1112
from apollo.server.settings import COMPANY_NAME, MANAGING_EDITOR, UI_URL, get_setting
1213
from apollo.server.validation import Architecture
1314

@@ -151,9 +152,13 @@ def generate_updateinfo_xml(
151152
updated.set("date", advisory.updated_at.strftime(time_format))
152153

153154
now = datetime.datetime.utcnow()
154-
ET.SubElement(
155-
update, "rights"
156-
).text = f"Copyright {now.year} {company_name}"
155+
if advisory.red_hat_advisory_id:
156+
rights_text = attribution.attribution_rights(
157+
advisory.red_hat_advisory.name, company_name, now.year
158+
)
159+
else:
160+
rights_text = f"Copyright {now.year} {company_name}"
161+
ET.SubElement(update, "rights").text = rights_text
157162

158163
release_name = f"{supported_product_name} {major_version}"
159164
if minor_version:
@@ -190,6 +195,20 @@ def generate_updateinfo_xml(
190195
reference.set("type", "self")
191196
reference.set("title", advisory.name)
192197

198+
if advisory.red_hat_advisory_id:
199+
rh_name = advisory.red_hat_advisory.name
200+
source_ref = ET.SubElement(references, "reference")
201+
source_ref.set("href", attribution.red_hat_errata_url(rh_name))
202+
source_ref.set("id", rh_name)
203+
source_ref.set("type", "vendor")
204+
source_ref.set("title", f"{attribution.SOURCE_VENDOR} {rh_name}")
205+
206+
license_ref = ET.SubElement(references, "reference")
207+
license_ref.set("href", attribution.SOURCE_LICENSE_URL)
208+
license_ref.set("id", attribution.SOURCE_LICENSE)
209+
license_ref.set("type", "other")
210+
license_ref.set("title", f"License: {attribution.SOURCE_LICENSE}")
211+
193212
packages_element = ET.SubElement(update, "pkglist")
194213

195214
suffixes_to_skip = [
@@ -353,6 +372,7 @@ async def get_updateinfo(
353372
**filters
354373
).prefetch_related(
355374
"advisory",
375+
"advisory__red_hat_advisory",
356376
"advisory__cves",
357377
"advisory__fixes",
358378
"advisory__packages",
@@ -450,6 +470,7 @@ async def get_updateinfo_v2(
450470
**filters
451471
).prefetch_related(
452472
"advisory",
473+
"advisory__red_hat_advisory",
453474
"advisory__cves",
454475
"advisory__fixes",
455476
Prefetch(

apollo/server/templates/advisory.jinja

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
</h5>
3636
<h5 style="font-weight:500">Updated at: <span style="font-weight:400;">{{ advisory.updated_at.date() }}</span></h5>
3737
</div>
38+
{% if advisory.red_hat_advisory_id %}
39+
<p style="padding-top:0.5rem;font-size:var(--cds-body-short-01-font-size);color:var(--cds-text-02);">
40+
Advisory content derived from
41+
<a target="_blank" href="https://access.redhat.com/errata/{{ advisory.red_hat_advisory.name }}">Red Hat {{ advisory.red_hat_advisory.name }}</a>,
42+
&copy; Red Hat, Inc., used under
43+
<a target="_blank" href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>, with modifications.
44+
</p>
45+
{% endif %}
3846
</div>
3947

4048
<div class="bx--grid bx--grid--full-width" style="margin:3rem 0;">

0 commit comments

Comments
 (0)