Skip to content

Commit 562b016

Browse files
committed
feat: add Alpine security advisory importer for issue #2158
Adds AlpineSecurityImporterPipeline to collect advisories from https://security.alpinelinux.org via JSON-LD API. Active branches are discovered dynamically from the root API endpoint so new branches are picked up automatically. EOL branches with data but absent from the root response are listed in HISTORICAL_BRANCHES. CVSS 3.x version is detected from the vector string prefix. Includes 5 unit tests with real API fixture data covering CVSS, fixed states, missing id, malformed URLs, and unfixed states. Signed-off-by: newklei <magmacicada@proton.me>
1 parent 2dbbd38 commit 562b016

File tree

7 files changed

+460
-0
lines changed

7 files changed

+460
-0
lines changed

vulnerabilities/importers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
from vulnerabilities.pipelines import pypa_importer
4343
from vulnerabilities.pipelines import pysec_importer
4444
from vulnerabilities.pipelines.v2_importers import alpine_linux_importer as alpine_linux_importer_v2
45+
from vulnerabilities.pipelines.v2_importers import (
46+
alpine_security_importer as alpine_security_importer_v2,
47+
)
4548
from vulnerabilities.pipelines.v2_importers import aosp_importer as aosp_importer_v2
4649
from vulnerabilities.pipelines.v2_importers import apache_httpd_importer as apache_httpd_v2
4750
from vulnerabilities.pipelines.v2_importers import apache_kafka_importer as apache_kafka_importer_v2
@@ -118,6 +121,7 @@
118121
retiredotnet_importer_v2.RetireDotnetImporterPipeline,
119122
ubuntu_osv_importer_v2.UbuntuOSVImporterPipeline,
120123
alpine_linux_importer_v2.AlpineLinuxImporterPipeline,
124+
alpine_security_importer_v2.AlpineSecurityImporterPipeline,
121125
nvd_importer.NVDImporterPipeline,
122126
github_importer.GitHubAPIImporterPipeline,
123127
gitlab_importer.GitLabImporterPipeline,
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
from typing import Iterable
13+
14+
import requests
15+
from packageurl import PackageURL
16+
from univers.version_range import AlpineLinuxVersionRange
17+
from univers.versions import InvalidVersion
18+
19+
from vulnerabilities.importer import AdvisoryDataV2
20+
from vulnerabilities.importer import AffectedPackageV2
21+
from vulnerabilities.importer import ReferenceV2
22+
from vulnerabilities.importer import VulnerabilitySeverity
23+
from vulnerabilities.pipelines import VulnerableCodeBaseImporterPipelineV2
24+
from vulnerabilities.severity_systems import SCORING_SYSTEMS
25+
26+
logger = logging.getLogger(__name__)
27+
28+
ALPINE_SECURITY_ROOT = "https://security.alpinelinux.org/"
29+
BRANCH_URL = "https://security.alpinelinux.org/branch/{branch}"
30+
ADVISORY_HEADERS = {"Accept": "application/ld+json"}
31+
32+
# EOL branches with data that no longer appear in the root API index.
33+
# 3.13 through 3.16 are omitted because the API returns 0 items for them.
34+
HISTORICAL_BRANCHES = [
35+
"3.22-community",
36+
"3.18-main",
37+
"3.17-main",
38+
"3.12-main",
39+
"3.11-main",
40+
"3.10-main",
41+
]
42+
43+
44+
def get_branches() -> list:
45+
"""Discover active branches from the root API and append HISTORICAL_BRANCHES."""
46+
try:
47+
resp = requests.get(ALPINE_SECURITY_ROOT, headers=ADVISORY_HEADERS, timeout=30)
48+
resp.raise_for_status()
49+
data = resp.json()
50+
# Branch entries have dict values; scalar values indicate non-branch keys.
51+
active = [k for k, v in data.items() if isinstance(v, dict)]
52+
except Exception as e:
53+
logger.error("Failed to discover branches from root API: %s", e)
54+
active = []
55+
56+
seen = set(active)
57+
return active + [b for b in HISTORICAL_BRANCHES if b not in seen]
58+
59+
60+
class AlpineSecurityImporterPipeline(VulnerableCodeBaseImporterPipelineV2):
61+
"""Collect Alpine Linux advisories from https://security.alpinelinux.org/."""
62+
63+
pipeline_id = "alpine_security_importer"
64+
spdx_license_expression = "CC-BY-SA-4.0"
65+
license_url = "https://security.alpinelinux.org/"
66+
precedence = 200
67+
68+
@classmethod
69+
def steps(cls):
70+
return (cls.collect_and_store_advisories,)
71+
72+
def advisories_count(self) -> int:
73+
count = 0
74+
for branch in get_branches():
75+
url = BRANCH_URL.format(branch=branch)
76+
try:
77+
resp = requests.get(url, headers=ADVISORY_HEADERS, timeout=30)
78+
resp.raise_for_status()
79+
data = resp.json()
80+
except Exception as e:
81+
logger.error("Failed to fetch branch %s: %s", branch, e)
82+
continue
83+
count += len(data.get("items") or [])
84+
return count
85+
86+
def collect_advisories(self) -> Iterable[AdvisoryDataV2]:
87+
for branch in get_branches():
88+
url = BRANCH_URL.format(branch=branch)
89+
try:
90+
resp = requests.get(url, headers=ADVISORY_HEADERS, timeout=30)
91+
resp.raise_for_status()
92+
data = resp.json()
93+
except Exception as e:
94+
logger.error("Failed to fetch branch %s: %s", branch, e)
95+
continue
96+
for item in data.get("items") or []:
97+
advisory = parse_advisory(item)
98+
if advisory:
99+
yield advisory
100+
101+
102+
def parse_advisory(data: dict):
103+
"""Parse a JSON-LD advisory; return None if the advisory ID is missing."""
104+
cve_url = data.get("id") or ""
105+
cve_id = cve_url.rstrip("/").split("/")[-1]
106+
if not cve_id:
107+
return None
108+
109+
summary = data.get("description") or ""
110+
111+
references = []
112+
for ref in data.get("ref") or []:
113+
ref_url = ref.get("rel") or ""
114+
if ref_url:
115+
references.append(ReferenceV2(url=ref_url))
116+
117+
severities = []
118+
cvss3 = data.get("cvss3") or {}
119+
cvss_score = cvss3.get("score")
120+
cvss_vector = cvss3.get("vector") or ""
121+
if cvss_vector and cvss_score:
122+
if cvss_vector.startswith("CVSS:3.1/"):
123+
system = SCORING_SYSTEMS["cvssv3.1"]
124+
else:
125+
system = SCORING_SYSTEMS["cvssv3"]
126+
severities.append(
127+
VulnerabilitySeverity(
128+
system=system,
129+
value=str(cvss_score),
130+
scoring_elements=cvss_vector,
131+
)
132+
)
133+
134+
affected_packages = []
135+
for state in data.get("state") or []:
136+
if not state.get("fixed"):
137+
continue
138+
pkg_version_url = state.get("packageVersion") or ""
139+
repo = state.get("repo") or ""
140+
parts = pkg_version_url.rstrip("/").split("/")
141+
if len(parts) < 2:
142+
continue
143+
pkg_name = parts[-2]
144+
version = parts[-1]
145+
if not pkg_name or not version:
146+
continue
147+
repo_parts = repo.split("-", 1)
148+
if len(repo_parts) != 2:
149+
continue
150+
version_tag, reponame = repo_parts
151+
distroversion = version_tag if version_tag == "edge" else f"v{version_tag}"
152+
purl = PackageURL(
153+
type="apk",
154+
namespace="alpine",
155+
name=pkg_name,
156+
qualifiers={"distroversion": distroversion, "reponame": reponame},
157+
)
158+
try:
159+
fixed_version_range = AlpineLinuxVersionRange.from_versions([version])
160+
except InvalidVersion:
161+
logger.warning("Cannot parse Alpine version %r in %s", version, cve_id)
162+
continue
163+
affected_packages.append(
164+
AffectedPackageV2(
165+
package=purl,
166+
fixed_version_range=fixed_version_range,
167+
)
168+
)
169+
170+
return AdvisoryDataV2(
171+
advisory_id=cve_id,
172+
aliases=[],
173+
summary=summary,
174+
affected_packages=affected_packages,
175+
references=references,
176+
severities=severities,
177+
url=cve_url,
178+
original_advisory_text=json.dumps(data, indent=2, ensure_ascii=False),
179+
)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 os
11+
from unittest import TestCase
12+
13+
from vulnerabilities.pipelines.v2_importers.alpine_security_importer import parse_advisory
14+
from vulnerabilities.tests import util_tests
15+
from vulnerabilities.utils import load_json
16+
17+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
18+
TEST_DATA = os.path.join(BASE_DIR, "test_data/alpine_security")
19+
20+
21+
class TestAlpineSecurityImporter(TestCase):
22+
def test_parse_advisory_with_cvss(self):
23+
"""Advisory with CVSS 3.1 score, references, and no fixed versions."""
24+
data = load_json(os.path.join(TEST_DATA, "alpine_security_mock1.json"))
25+
expected = os.path.join(TEST_DATA, "expected_alpine_security_output1.json")
26+
result = parse_advisory(data)
27+
self.assertIsNotNone(result)
28+
util_tests.check_results_against_json(result.to_dict(), expected)
29+
30+
def test_parse_advisory_with_fixed_states(self):
31+
"""Advisory with no CVSS but multiple fixed package versions across branches."""
32+
data = load_json(os.path.join(TEST_DATA, "alpine_security_mock2.json"))
33+
expected = os.path.join(TEST_DATA, "expected_alpine_security_output2.json")
34+
result = parse_advisory(data)
35+
self.assertIsNotNone(result)
36+
util_tests.check_results_against_json(result.to_dict(), expected)
37+
38+
def test_parse_advisory_missing_id_returns_none(self):
39+
"""Advisory with an empty id field must be skipped."""
40+
data = {
41+
"id": "",
42+
"description": "test",
43+
"cvss3": {"score": 0.0, "vector": None},
44+
"ref": [],
45+
"state": [],
46+
}
47+
self.assertIsNone(parse_advisory(data))
48+
49+
def test_parse_advisory_skips_malformed_package_url(self):
50+
"""State entries with a packageVersion URL too short to parse must be skipped."""
51+
data = {
52+
"id": "https://security.alpinelinux.org/vuln/CVE-2099-00001",
53+
"description": "test",
54+
"cvss3": {"score": 0.0, "vector": None},
55+
"ref": [],
56+
"state": [
57+
{
58+
"fixed": True,
59+
"packageVersion": "https://security.alpinelinux.org/srcpkg/",
60+
"repo": "edge-main",
61+
}
62+
],
63+
}
64+
result = parse_advisory(data)
65+
self.assertIsNotNone(result)
66+
self.assertEqual(result.affected_packages, [])
67+
68+
def test_parse_advisory_skips_unfixed_states(self):
69+
"""State entries with fixed=False must not produce affected_packages."""
70+
data = {
71+
"id": "https://security.alpinelinux.org/vuln/CVE-2099-00002",
72+
"description": "test",
73+
"cvss3": {"score": 0.0, "vector": None},
74+
"ref": [],
75+
"state": [
76+
{
77+
"fixed": False,
78+
"packageVersion": "https://security.alpinelinux.org/srcpkg/curl/8.0.0-r0",
79+
"repo": "edge-main",
80+
}
81+
],
82+
}
83+
result = parse_advisory(data)
84+
self.assertIsNotNone(result)
85+
self.assertEqual(result.affected_packages, [])
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"@context":"https://security.alpinelinux.org/static/context.jsonld","cpeMatch":[{"@context":"https://security.alpinelinux.org/static/context.jsonld","cpeUri":"","id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#cpeMatch/940449","maximumVersion":"2.10","maximumVersionOp":"<=","minimumVersion":"0","minimumVersionOp":">=","package":"https://security.alpinelinux.org/srcpkg/net-tools","type":"CPEMatch","vuln":"https://security.alpinelinux.org/vuln/CVE-2025-46836"}],"cvss3":{"score":6.6,"vector":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:H"},"description":"net-tools is a collection of programs that form the base set of the NET-3 networking distribution for the Linux operating system. Inn versions up to and including 2.10, the Linux network utilities (like ifconfig) from the net-tools package do not properly validate the structure of /proc files when showing interfaces. `get_name()` in `interface.c` copies interface labels from `/proc/net/dev` into a fixed 16-byte stack buffer without bounds checking, leading to possible arbitrary code execution or crash. The known attack path does not require privilege but also does not provide privilege escalation in this scenario. A patch is available and expected to be part of version 2.20.","id":"https://security.alpinelinux.org/vuln/CVE-2025-46836","ref":[{"@context":"https://security.alpinelinux.org/static/context.jsonld","id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#ref/588613","referenceType":"MISC","rel":"https://github.com/ecki/net-tools/commit/7a8f42fb20013a1493d8cae1c43436f85e656f2d","type":"Reference"},{"@context":"https://security.alpinelinux.org/static/context.jsonld","id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#ref/588614","referenceType":"CONFIRM","rel":"https://github.com/ecki/net-tools/security/advisories/GHSA-pfwf-h6m3-63wf","type":"Reference"},{"@context":"https://security.alpinelinux.org/static/context.jsonld","id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#ref/593944","referenceType":"af854a3a-2127-422b-91ae-364da2661108","rel":"https://lists.debian.org/debian-lts-announce/2025/05/msg00053.html","type":"Reference"}],"state":[{"@context":"https://security.alpinelinux.org/static/context.jsonld","fixed":false,"id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#state/108203","packageVersion":"https://security.alpinelinux.org/srcpkg/net-tools/2.10-r3","repo":"edge-main","type":"VulnerabilityState","vuln":"https://security.alpinelinux.org/vuln/CVE-2025-46836"},{"@context":"https://security.alpinelinux.org/static/context.jsonld","fixed":false,"id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#state/415014","packageVersion":"https://security.alpinelinux.org/srcpkg/net-tools/2.10-r3","repo":"3.23-main","type":"VulnerabilityState","vuln":"https://security.alpinelinux.org/vuln/CVE-2025-46836"},{"@context":"https://security.alpinelinux.org/static/context.jsonld","fixed":false,"id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#state/125956","packageVersion":"https://security.alpinelinux.org/srcpkg/net-tools/2.10-r3","repo":"3.22-main","type":"VulnerabilityState","vuln":"https://security.alpinelinux.org/vuln/CVE-2025-46836"},{"@context":"https://security.alpinelinux.org/static/context.jsonld","fixed":false,"id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#state/127510","packageVersion":"https://security.alpinelinux.org/srcpkg/net-tools/2.10-r3","repo":"3.21-main","type":"VulnerabilityState","vuln":"https://security.alpinelinux.org/vuln/CVE-2025-46836"},{"@context":"https://security.alpinelinux.org/static/context.jsonld","fixed":false,"id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#state/127996","packageVersion":"https://security.alpinelinux.org/srcpkg/net-tools/2.10-r3","repo":"3.20-main","type":"VulnerabilityState","vuln":"https://security.alpinelinux.org/vuln/CVE-2025-46836"},{"@context":"https://security.alpinelinux.org/static/context.jsonld","fixed":false,"id":"https://security.alpinelinux.org/vuln/CVE-2025-46836#state/128449","packageVersion":"https://security.alpinelinux.org/srcpkg/net-tools/2.10-r3","repo":"3.19-main","type":"VulnerabilityState","vuln":"https://security.alpinelinux.org/vuln/CVE-2025-46836"}],"type":"Vulnerability"}

0 commit comments

Comments
 (0)