Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ace30b4d | ||
|
|
dab3edf3c2 |
@@ -104,6 +104,18 @@ CRITICAL came from, and an image with no window is not counted at all.
|
||||
distinction was the difference between two false zeros and the truth (both recipes turned out fine,
|
||||
but nothing in the survey said so).
|
||||
|
||||
### 2c. Know which recipes CANNOT see CVEs at all
|
||||
```
|
||||
python3 cc-ci-plan/audit-sources.py --security-sources
|
||||
```
|
||||
A recipe whose sources yield **no CVE data at all** cannot produce a meaningful `0` — nothing was
|
||||
measured, the same way a missing registry file cannot. As of 2026-08-11 that is **mattermost-lts**
|
||||
(its GitHub advisory feed is empty and its security bulletins are client-side rendered) and
|
||||
**mumble**. Render those as **`?`**, not `0`, and say why in the notes.
|
||||
|
||||
An *unparseable page* is NOT the same thing: it is harmless when the same project also publishes an
|
||||
advisory feed (redis, gitea, minio, clickhouse all do). Only "no usable source for this image" counts.
|
||||
|
||||
### 3. Run the advisory scan over that window
|
||||
```
|
||||
python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py <recipe> --from <old-app> --to <new-app> \
|
||||
|
||||
@@ -303,6 +303,20 @@ def _changelog_versions(text: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
_BLOB_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/blob/(.+)$")
|
||||
|
||||
|
||||
def _raw_if_blob(url: str) -> str:
|
||||
"""A GitHub *blob* URL is an HTML viewer, not the file.
|
||||
|
||||
The registry pointed ONLYOFFICE's CHANGELOG.md at its blob page. Fetching that returns 636KB of
|
||||
markup in which the release headings do not survive HTML-stripping, so 24 CVEs were visible and
|
||||
NONE attributable to a release — the same shape of blind spot as nginx. The raw URL attributes
|
||||
all 24. Normalising here fixes every registry entry at once, present and future."""
|
||||
m = _BLOB_RE.match(url)
|
||||
return f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/{m.group(3)}" if m else url
|
||||
|
||||
|
||||
def vendor_pages(urls: list[str]) -> list[dict]:
|
||||
"""Fetch each registry URL and regex out CVE ids, with a little surrounding context."""
|
||||
out = []
|
||||
@@ -317,7 +331,7 @@ def vendor_pages(urls: list[str]) -> list[dict]:
|
||||
continue
|
||||
entry = {"source": u, "status": "ok", "cves": [], "context": {}, "fixed_in": {}}
|
||||
try:
|
||||
text = _fetch(u)
|
||||
text = _fetch(_raw_if_blob(u))
|
||||
plain = re.sub(r"<[^>]+>", " ", text)
|
||||
for cve in sorted(set(CVE_RE.findall(plain))):
|
||||
entry["cves"].append(cve)
|
||||
|
||||
@@ -42,6 +42,11 @@ _spec = importlib.util.spec_from_file_location("resolve_images", os.path.join(HE
|
||||
RI = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(RI)
|
||||
|
||||
# advisory-scan supplies the source-fetching + changelog-attribution used by --security-sources
|
||||
_aspec = importlib.util.spec_from_file_location("advisory_scan", os.path.join(HERE, "advisory-scan.py"))
|
||||
A = importlib.util.module_from_spec(_aspec)
|
||||
_aspec.loader.exec_module(A)
|
||||
|
||||
REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", os.path.join(HERE, "upstream"))
|
||||
USED_RECIPES = os.path.join(HERE, "used-recipes.md")
|
||||
DEPRECATION_RE = re.compile(
|
||||
@@ -108,6 +113,36 @@ def newest_tag_date(registry: str, repo: str, tag: str) -> str | None:
|
||||
return (d.get("results") or [{}])[0].get("last_updated")
|
||||
|
||||
|
||||
def security_source_audit(recipe: str) -> list[dict]:
|
||||
"""Per source: are its CVEs USABLE, or merely visible?
|
||||
|
||||
The nginx lesson. nginx publishes no GitHub advisories; all its CVEs live in nginx.org/en/CHANGES.
|
||||
The scan saw them and could do nothing with them, because nothing said which release fixed which
|
||||
CVE — so every nginx bump in the fleet reported 0. Attribution (advisory-scan §2b) fixed that for
|
||||
changelogs organised by release, but a page that lists CVEs with NO release structure is still a
|
||||
blind spot: visible, uncountable. This finds those.
|
||||
|
||||
Per source: `advisory-feed` (structured, best), `changelog` (CVEs attributable to a release),
|
||||
`unattributable` (CVEs present but no release structure — BLIND), or `no-cve-data`.
|
||||
"""
|
||||
urls, _ = _registry_urls(recipe)
|
||||
out = []
|
||||
for entry in A.github_advisories(urls):
|
||||
out.append({"source": entry["source"], "kind": "advisory-feed",
|
||||
"status": entry["status"], "cves": len(entry.get("advisories") or []),
|
||||
"usable": len(entry.get("advisories") or [])})
|
||||
for entry in A.vendor_pages(urls):
|
||||
if entry["status"].startswith("skipped"):
|
||||
continue
|
||||
n = len(entry.get("cves") or [])
|
||||
attributed = len(entry.get("fixed_in") or {})
|
||||
kind = ("no-cve-data" if n == 0 else
|
||||
"changelog" if attributed else "unattributable")
|
||||
out.append({"source": entry["source"], "kind": kind, "status": entry["status"],
|
||||
"cves": n, "usable": attributed})
|
||||
return out
|
||||
|
||||
|
||||
def audit_recipe(recipe: str, ssh: str | None, quiet_days: int) -> dict:
|
||||
out = {"recipe": recipe, "findings": [], "images": [], "sources": []}
|
||||
try:
|
||||
@@ -214,9 +249,41 @@ def main() -> int:
|
||||
ap.add_argument("--ssh", default=None)
|
||||
ap.add_argument("--quiet-days", type=int, default=365)
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--security-sources", action="store_true",
|
||||
help="audit whether each recipe's CVE sources are USABLE (structured advisory "
|
||||
"feed / release-attributable changelog) or merely visible")
|
||||
a = ap.parse_args()
|
||||
|
||||
recipes = a.recipes or all_recipes()
|
||||
if a.security_sources:
|
||||
# What matters is whether the RECIPE can see CVEs at all — not whether some individual page
|
||||
# is unparseable. A page with no release structure is harmless when the same project also
|
||||
# publishes an advisory feed (redis, gitea, minio, clickhouse all do); it is only a blind
|
||||
# spot when nothing else covers that project.
|
||||
blind_recipes, noisy = [], 0
|
||||
for r in recipes:
|
||||
rows = security_source_audit(r)
|
||||
feeds = [x for x in rows if x["kind"] == "advisory-feed" and x["cves"] > 0]
|
||||
logs = [x for x in rows if x["kind"] == "changelog"]
|
||||
unattr = [x for x in rows if x["kind"] == "unattributable"]
|
||||
noisy += len(unattr)
|
||||
usable = len(feeds) + len(logs)
|
||||
if usable == 0:
|
||||
blind_recipes.append(r)
|
||||
print(f"!! {r}: NO USABLE CVE SOURCE — {len(unattr)} unparseable page(s), "
|
||||
f"0 advisory feeds, 0 attributable changelogs")
|
||||
for x in rows:
|
||||
print(f" {x['kind']:15} {x['source'][:64]} ({x['cves']} CVEs)")
|
||||
else:
|
||||
print(f"OK {r}: {len(feeds)} advisory-feed(s), {len(logs)} changelog(s)"
|
||||
+ (f", {len(unattr)} unparseable page(s) (redundant — covered by a feed)"
|
||||
if unattr else ""))
|
||||
for x in logs:
|
||||
print(f" changelog {x['source'][:62]} ({x['usable']}/{x['cves']})")
|
||||
print(f"\n{len(recipes)} recipes · {len(blind_recipes)} with NO usable CVE source"
|
||||
+ (f": {', '.join(blind_recipes)}" if blind_recipes else "")
|
||||
+ f" · {noisy} unparseable page(s) elsewhere (harmless where a feed covers them)")
|
||||
return 0
|
||||
reports = [audit_recipe(r, a.ssh, a.quiet_days) for r in recipes]
|
||||
if a.json:
|
||||
print(json.dumps(reports, indent=2))
|
||||
|
||||
Reference in New Issue
Block a user