|
| 1 | +# |
| 2 | +# Copyright (c) nexB Inc. and others. All rights reserved. |
| 3 | +# VulnerableCode is a trademark of nexB Inc. |
| 4 | +# SPDX-License-Identifier: Apache-2.0 |
| 5 | +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. |
| 6 | +# See https://github.com/aboutcode-org/vulnerablecode for support or download. |
| 7 | +# See https://aboutcode.org for more information about nexB OSS projects. |
| 8 | +# |
| 9 | + |
| 10 | +import json |
| 11 | +import logging |
| 12 | +import re |
| 13 | +from datetime import datetime |
| 14 | +from datetime import timezone |
| 15 | +from typing import Iterable |
| 16 | + |
| 17 | +from packageurl import PackageURL |
| 18 | +from univers.version_range import build_range_from_github_advisory_constraint |
| 19 | + |
| 20 | +from vulnerabilities.importer import AdvisoryDataV2 |
| 21 | +from vulnerabilities.importer import AffectedPackageV2 |
| 22 | +from vulnerabilities.importer import ReferenceV2 |
| 23 | +from vulnerabilities.importer import VulnerabilitySeverity |
| 24 | +from vulnerabilities.pipelines import VulnerableCodeBaseImporterPipelineV2 |
| 25 | +from vulnerabilities.severity_systems import SCORING_SYSTEMS |
| 26 | +from vulnerabilities.utils import fetch_response |
| 27 | +from vulnerabilities.utils import get_cwe_id |
| 28 | + |
| 29 | +logger = logging.getLogger(__name__) |
| 30 | + |
| 31 | +# Repos tracked per issue #1462: grafana/grafana, grafana/loki, |
| 32 | +# credativ/plutono (Grafana fork), credativ/vali (Loki fork). |
| 33 | +GRAFANA_REPOS = [ |
| 34 | + ("grafana", "grafana", "golang", "github.com/grafana/grafana"), |
| 35 | + ("grafana", "loki", "golang", "github.com/grafana/loki"), |
| 36 | + ("credativ", "plutono", "golang", "github.com/credativ/plutono"), |
| 37 | + ("credativ", "vali", "golang", "github.com/credativ/vali"), |
| 38 | +] |
| 39 | + |
| 40 | +GITHUB_ADVISORY_API = ( |
| 41 | + "https://api.github.com/repos/{owner}/{repo}/security-advisories?per_page=100&page={page}" |
| 42 | +) |
| 43 | + |
| 44 | + |
| 45 | +class GrafanaImporterPipeline(VulnerableCodeBaseImporterPipelineV2): |
| 46 | + """ |
| 47 | + Pipeline-based importer for Grafana security advisories from the GitHub |
| 48 | + Security Advisory REST API. Covers grafana/grafana, grafana/loki, |
| 49 | + credativ/plutono, and credativ/vali. |
| 50 | + """ |
| 51 | + |
| 52 | + pipeline_id = "grafana_importer" |
| 53 | + spdx_license_expression = "Apache-2.0" |
| 54 | + license_url = "https://github.com/grafana/grafana/blob/main/LICENSE" |
| 55 | + repo_url = "https://github.com/grafana/grafana" |
| 56 | + precedence = 200 |
| 57 | + |
| 58 | + @classmethod |
| 59 | + def steps(cls): |
| 60 | + return (cls.collect_and_store_advisories,) |
| 61 | + |
| 62 | + def advisories_count(self) -> int: |
| 63 | + count = 0 |
| 64 | + for owner, repo, _, _ in GRAFANA_REPOS: |
| 65 | + page = 1 |
| 66 | + while True: |
| 67 | + url = GITHUB_ADVISORY_API.format(owner=owner, repo=repo, page=page) |
| 68 | + try: |
| 69 | + advisories = fetch_response(url).json() |
| 70 | + except Exception as e: |
| 71 | + logger.error("Failed to fetch advisories from %s: %s", url, e) |
| 72 | + break |
| 73 | + if not advisories: |
| 74 | + break |
| 75 | + count += sum(1 for a in advisories if a.get("state") == "published") |
| 76 | + if len(advisories) < 100: |
| 77 | + break |
| 78 | + page += 1 |
| 79 | + return count |
| 80 | + |
| 81 | + def collect_advisories(self) -> Iterable[AdvisoryDataV2]: |
| 82 | + for owner, repo, purl_type, purl_namespace in GRAFANA_REPOS: |
| 83 | + yield from fetch_grafana_advisories( |
| 84 | + owner=owner, |
| 85 | + repo=repo, |
| 86 | + purl_type=purl_type, |
| 87 | + purl_namespace=purl_namespace, |
| 88 | + ) |
| 89 | + |
| 90 | + |
| 91 | +def fetch_grafana_advisories( |
| 92 | + owner: str, |
| 93 | + repo: str, |
| 94 | + purl_type: str, |
| 95 | + purl_namespace: str, |
| 96 | +) -> Iterable[AdvisoryDataV2]: |
| 97 | + """ |
| 98 | + Paginate through the GitHub Security Advisory REST API for the given |
| 99 | + owner/repo and yield parsed AdvisoryDataV2 objects. |
| 100 | + """ |
| 101 | + page = 1 |
| 102 | + while True: |
| 103 | + url = GITHUB_ADVISORY_API.format(owner=owner, repo=repo, page=page) |
| 104 | + try: |
| 105 | + advisories = fetch_response(url).json() |
| 106 | + except Exception as e: |
| 107 | + logger.error("Failed to fetch advisories from %s: %s", url, e) |
| 108 | + break |
| 109 | + if not advisories: |
| 110 | + break |
| 111 | + for advisory in advisories: |
| 112 | + if advisory.get("state") != "published": |
| 113 | + continue |
| 114 | + parsed = parse_advisory_data( |
| 115 | + advisory=advisory, |
| 116 | + purl_type=purl_type, |
| 117 | + purl_namespace=purl_namespace, |
| 118 | + ) |
| 119 | + if parsed: |
| 120 | + yield parsed |
| 121 | + if len(advisories) < 100: |
| 122 | + break |
| 123 | + page += 1 |
| 124 | + |
| 125 | + |
| 126 | +def parse_advisory_data(advisory: dict, purl_type: str, purl_namespace: str): |
| 127 | + """ |
| 128 | + Parse a GitHub Security Advisory REST API response for a Grafana repo and |
| 129 | + return an AdvisoryDataV2 object, or None if parsing fails. |
| 130 | +
|
| 131 | + ``advisory_id`` is set to the GHSA ID; any CVE ID goes into ``aliases``. |
| 132 | + Version ranges from the API (space-separated constraints) are normalized to |
| 133 | + comma-separated format before being passed to |
| 134 | + ``build_range_from_github_advisory_constraint``. |
| 135 | +
|
| 136 | + >>> advisory = { |
| 137 | + ... "ghsa_id": "GHSA-7rqg-hjwc-6mjf", |
| 138 | + ... "cve_id": "CVE-2023-22462", |
| 139 | + ... "html_url": "https://github.com/grafana/grafana/security/advisories/GHSA-7rqg-hjwc-6mjf", |
| 140 | + ... "summary": "Stored XSS in Text plugin", |
| 141 | + ... "description": "An attacker needs Editor role.", |
| 142 | + ... "severity": "medium", |
| 143 | + ... "state": "published", |
| 144 | + ... "published_at": "2023-03-01T08:59:53Z", |
| 145 | + ... "vulnerabilities": [ |
| 146 | + ... { |
| 147 | + ... "package": {"ecosystem": "", "name": "github.com/grafana/grafana"}, |
| 148 | + ... "vulnerable_version_range": ">=9.2.0 <9.2.10", |
| 149 | + ... "patched_versions": "9.2.10", |
| 150 | + ... "vulnerable_functions": [] |
| 151 | + ... } |
| 152 | + ... ], |
| 153 | + ... "cvss_severities": { |
| 154 | + ... "cvss_v3": {"vector_string": "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:N", "score": 6.4}, |
| 155 | + ... "cvss_v4": {"vector_string": None, "score": None} |
| 156 | + ... }, |
| 157 | + ... "cwes": [{"cwe_id": "CWE-79", "name": "Cross-site Scripting"}], |
| 158 | + ... "identifiers": [ |
| 159 | + ... {"value": "GHSA-7rqg-hjwc-6mjf", "type": "GHSA"}, |
| 160 | + ... {"value": "CVE-2023-22462", "type": "CVE"} |
| 161 | + ... ] |
| 162 | + ... } |
| 163 | + >>> result = parse_advisory_data(advisory, "golang", "github.com/grafana/grafana") |
| 164 | + >>> result.advisory_id |
| 165 | + 'GHSA-7rqg-hjwc-6mjf' |
| 166 | + >>> result.aliases |
| 167 | + ['CVE-2023-22462'] |
| 168 | + >>> result.summary |
| 169 | + 'Stored XSS in Text plugin' |
| 170 | + """ |
| 171 | + ghsa_id = advisory.get("ghsa_id") or "" |
| 172 | + cve_id = advisory.get("cve_id") or "" |
| 173 | + html_url = advisory.get("html_url") or "" |
| 174 | + summary = advisory.get("summary") or "" |
| 175 | + published_at = advisory.get("published_at") or "" |
| 176 | + |
| 177 | + if not ghsa_id: |
| 178 | + logger.error("Advisory has no GHSA ID, skipping.") |
| 179 | + return None |
| 180 | + |
| 181 | + aliases = [] |
| 182 | + if cve_id: |
| 183 | + aliases.append(cve_id) |
| 184 | + |
| 185 | + date_published = None |
| 186 | + if published_at: |
| 187 | + try: |
| 188 | + date_published = datetime.strptime(published_at, "%Y-%m-%dT%H:%M:%SZ").replace( |
| 189 | + tzinfo=timezone.utc |
| 190 | + ) |
| 191 | + except ValueError: |
| 192 | + logger.error("Cannot parse date %r for %s", published_at, ghsa_id) |
| 193 | + |
| 194 | + cvss_v3 = (advisory.get("cvss_severities") or {}).get("cvss_v3") or {} |
| 195 | + cvss_vector = cvss_v3.get("vector_string") or "" |
| 196 | + cvss_score = cvss_v3.get("score") |
| 197 | + |
| 198 | + severities = [] |
| 199 | + if cvss_vector: |
| 200 | + severities.append( |
| 201 | + VulnerabilitySeverity( |
| 202 | + system=SCORING_SYSTEMS["cvssv3.1"], |
| 203 | + value=str(cvss_score) if cvss_score is not None else "", |
| 204 | + scoring_elements=cvss_vector, |
| 205 | + ) |
| 206 | + ) |
| 207 | + |
| 208 | + references = [] |
| 209 | + if html_url: |
| 210 | + references.append(ReferenceV2(url=html_url)) |
| 211 | + |
| 212 | + weaknesses = [] |
| 213 | + for cwe in advisory.get("cwes") or []: |
| 214 | + cwe_string = cwe.get("cwe_id") or "" |
| 215 | + if cwe_string: |
| 216 | + cwe_int = get_cwe_id(cwe_string) |
| 217 | + if cwe_int: |
| 218 | + weaknesses.append(cwe_int) |
| 219 | + |
| 220 | + affected_packages = [] |
| 221 | + for vuln in advisory.get("vulnerabilities") or []: |
| 222 | + pkg_name = (vuln.get("package") or {}).get("name") or purl_namespace |
| 223 | + if not pkg_name: |
| 224 | + pkg_name = purl_namespace |
| 225 | + |
| 226 | + raw_range = vuln.get("vulnerable_version_range") or "" |
| 227 | + version_range = None |
| 228 | + if raw_range: |
| 229 | + # Normalize space-separated constraints to comma-separated format. |
| 230 | + # Example: ">=9.2.0 <9.2.10 >=9.3.0 <9.3.4" -> ">=9.2.0, <9.2.10, >=9.3.0, <9.3.4" |
| 231 | + normalized = re.sub(r"\s+(?=[<>!=])", ", ", raw_range.strip()) |
| 232 | + try: |
| 233 | + version_range = build_range_from_github_advisory_constraint( |
| 234 | + purl_type, normalized |
| 235 | + ) |
| 236 | + except Exception as e: |
| 237 | + logger.error( |
| 238 | + "Cannot parse version range %r for %s: %s", raw_range, ghsa_id, e |
| 239 | + ) |
| 240 | + |
| 241 | + if version_range is None: |
| 242 | + continue |
| 243 | + |
| 244 | + purl = PackageURL(type=purl_type, namespace="", name=pkg_name) |
| 245 | + try: |
| 246 | + affected_packages.append( |
| 247 | + AffectedPackageV2( |
| 248 | + package=purl, |
| 249 | + affected_version_range=version_range, |
| 250 | + ) |
| 251 | + ) |
| 252 | + except ValueError as e: |
| 253 | + logger.error("Cannot create AffectedPackageV2 for %s: %s", ghsa_id, e) |
| 254 | + |
| 255 | + return AdvisoryDataV2( |
| 256 | + advisory_id=ghsa_id, |
| 257 | + aliases=aliases, |
| 258 | + summary=summary, |
| 259 | + affected_packages=affected_packages, |
| 260 | + references=references, |
| 261 | + date_published=date_published, |
| 262 | + weaknesses=weaknesses, |
| 263 | + severities=severities, |
| 264 | + url=html_url, |
| 265 | + original_advisory_text=json.dumps(advisory, indent=2, ensure_ascii=False), |
| 266 | + ) |
0 commit comments