-
Notifications
You must be signed in to change notification settings - Fork 60
providers: add BellSoft OSV provider #924
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
i-bs
wants to merge
6
commits into
anchore:main
Choose a base branch
from
i-bs:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2cb6b22
providers: add BellSoft OSV provider
i-bs f6749c1
normalize bellsoft cvss
willmurphyscode 17b61ae
Merge branch 'main' of github.com:anchore/vunnel into i-bs/main
willmurphyscode 0cc82f9
vendor OSV schema 1.7.5 (current latest)
willmurphyscode 0c13bbd
wire up fixdater
willmurphyscode 61ada85
switch to tar.gz stream over git clone
willmurphyscode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from vunnel import provider, result, schema | ||
| from vunnel.utils import timer | ||
|
|
||
| from .parser import Parser | ||
|
|
||
| if TYPE_CHECKING: | ||
| import datetime | ||
|
|
||
|
|
||
| @dataclass | ||
| class Config: | ||
| runtime: provider.RuntimeConfig = field( | ||
| default_factory=lambda: provider.RuntimeConfig( | ||
| result_store=result.StoreStrategy.SQLITE, | ||
| existing_results=result.ResultStatePolicy.DELETE_BEFORE_WRITE, | ||
| ), | ||
| ) | ||
| request_timeout: int = 125 | ||
|
|
||
|
|
||
| class Provider(provider.Provider): | ||
| # pin to the newest released OSV schema (vendored in schema/vulnerability/osv/): | ||
| # 1.x schema releases are additive, so the newest schema validates records | ||
| # authored against any older 1.x revision, but not vice versa | ||
| __schema__ = schema.OSVSchema(version="1.7.5") | ||
| __distribution_version__ = int(__schema__.major_version) | ||
|
|
||
| def __init__(self, root: str, config: Config | None = None): | ||
| if not config: | ||
| config = Config() | ||
|
|
||
| super().__init__(root, runtime_cfg=config.runtime) | ||
| self.config = config | ||
| self.logger.debug(f"config: {config}") | ||
|
|
||
| self.parser = Parser( | ||
| ws=self.workspace, | ||
| download_timeout=self.config.request_timeout, | ||
| logger=self.logger, | ||
| ) | ||
|
|
||
| # the parser refreshes its own input (fresh archive download each run), | ||
| # so the framework must not delete input state out from under it | ||
| provider.disallow_existing_input_policy(config.runtime) | ||
|
|
||
| @classmethod | ||
| def name(cls) -> str: | ||
| return "bellsoft" | ||
|
|
||
| @classmethod | ||
| def tags(cls) -> list[str]: | ||
| return ["vulnerability", "os"] | ||
|
|
||
| @classmethod | ||
| def compatible_schema(cls, schema_version: str) -> schema.Schema | None: | ||
| # a record's declared schema_version is metadata about when upstream | ||
| # authored it, not which schema we validate against: same-major records | ||
| # all validate under the provider's pinned schema (1.x revisions are | ||
| # additive), and the envelope URL must point at a schema file vunnel | ||
| # actually ships (e.g. upstream declares 1.6.7, which we don't vendor) | ||
| if schema.OSVSchema(schema_version).major_version == cls.__schema__.major_version: | ||
| return cls.__schema__ | ||
| return None | ||
|
|
||
| def update(self, last_updated: datetime.datetime | None) -> tuple[list[str], int]: | ||
| with timer(self.name(), self.logger): | ||
| with self.results_writer() as writer, self.parser: | ||
| for vuln_id, vuln_schema_version, record in self.parser.get(): | ||
| vuln_schema = self.compatible_schema(vuln_schema_version) | ||
| if not vuln_schema: | ||
| self.logger.warning( | ||
| f"skipping vulnerability {vuln_id} with schema version {vuln_schema_version} " | ||
| f"as it is incompatible with provider schema version {self.__schema__.version}", | ||
| ) | ||
| continue | ||
| writer.write( | ||
| identifier=vuln_id.lower(), | ||
| schema=vuln_schema, | ||
| payload=record, | ||
| ) | ||
|
|
||
| return self.parser.urls, len(writer) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import copy | ||
| import logging | ||
| import os | ||
| import tarfile | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| import orjson | ||
|
|
||
| from vunnel.tool import fixdate | ||
| from vunnel.utils import http_wrapper as http | ||
| from vunnel.utils import osv | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Generator | ||
| from types import TracebackType | ||
|
|
||
| from vunnel.workspace import Workspace | ||
|
|
||
| # Default CVSS vector-string prefix to prepend for each OSV severity type when | ||
| # the score does not already carry a "CVSS:x.y/" prefix. Downstream consumers | ||
| # (e.g. grype-db) expect the full CVSS vector string including this prefix. | ||
| _CVSS_TYPE_PREFIXES = { | ||
| "CVSS_V2": "CVSS:2.0/", | ||
| "CVSS_V3": "CVSS:3.0/", | ||
| "CVSS_V4": "CVSS:4.0/", | ||
| } | ||
|
|
||
|
|
||
| class Parser: | ||
| # an unauthenticated archive download (rather than a git clone) avoids any | ||
| # dependency on a git binary or the host's git configuration | ||
| _download_url_ = "https://github.com/bell-sw/osv-database/archive/refs/heads/master.tar.gz" | ||
| _archive_name_ = "osv-database.tar.gz" | ||
|
|
||
| def __init__( | ||
| self, | ||
| ws: Workspace, | ||
| fixdater: fixdate.Finder | None = None, | ||
| download_timeout: int = 125, | ||
| logger: logging.Logger | None = None, | ||
| ): | ||
| if not fixdater: | ||
| fixdater = fixdate.default_finder(ws) | ||
| self.fixdater = fixdater | ||
| self.workspace = ws | ||
| self.download_timeout = download_timeout | ||
| self.urls = [self._download_url_] | ||
| if not logger: | ||
| logger = logging.getLogger(self.__class__.__name__) | ||
| self.logger = logger | ||
|
|
||
| def __enter__(self) -> Parser: | ||
| self.fixdater.__enter__() | ||
| return self | ||
|
|
||
| def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: | ||
| self.fixdater.__exit__(exc_type, exc_val, exc_tb) | ||
|
|
||
| def _archive_path(self) -> str: | ||
| return os.path.join(self.workspace.input_path, self._archive_name_) | ||
|
|
||
| def _download(self) -> None: | ||
| self.logger.info(f"downloading vulnerability data from {self._download_url_}") | ||
| req = http.get(self._download_url_, self.logger, stream=True, timeout=self.download_timeout) | ||
| with open(self._archive_path(), "wb") as fp: | ||
| for chunk in req.iter_content(chunk_size=65536): | ||
| fp.write(chunk) | ||
|
|
||
| def _load(self) -> Generator[dict[str, Any]]: | ||
| self.logger.info("loading data from downloaded archive") | ||
|
|
||
| if not os.path.exists(self._archive_path()): | ||
| self.logger.warning("no downloaded archive to load") | ||
| return | ||
|
|
||
| # stream advisories straight out of the tarball rather than extracting | ||
| # ~16k small files to disk; members are never written out, so hostile | ||
| # member paths (traversal, symlinks) have nothing to act on. the github | ||
| # archive nests content under a "<repo>-<branch>/" top-level directory. | ||
| with tarfile.open(self._archive_path(), mode="r:gz") as tar: | ||
| for member in tar: | ||
| if not member.isfile() or "BELL-CVE" not in member.name.split("/"): | ||
| continue | ||
| if not member.name.endswith(".json"): | ||
| self.logger.debug(f"skipping non-JSON file: {member.name}") | ||
| continue | ||
| fh = tar.extractfile(member) | ||
| if fh is None: | ||
| continue | ||
| try: | ||
| yield orjson.loads(fh.read()) | ||
| except orjson.JSONDecodeError: | ||
| # one malformed advisory in the upstream repo should not | ||
| # abort the whole provider run | ||
| self.logger.warning(f"skipping malformed advisory file: {member.name}") | ||
|
|
||
| def _normalize_severities(self, vuln_entry: dict[str, Any]) -> dict[str, Any]: | ||
| # Normalize CVSS severity vector strings so that each carries a | ||
| # "CVSS:x.y/" prefix appropriate to its type. If a score already has a | ||
| # "CVSS:" prefix (e.g. "CVSS:3.1/...") it is preserved as-is. Empty | ||
| # scores and entries without severities are left untouched. The input | ||
| # entry is not mutated; a copy is returned. | ||
| severities = vuln_entry.get("severity") | ||
| if not severities: | ||
| return vuln_entry | ||
|
|
||
| normalized = copy.deepcopy(vuln_entry) | ||
| for severity in normalized["severity"]: | ||
| score = severity.get("score", "") | ||
| if not score or score.startswith("CVSS:"): | ||
| continue | ||
| prefix = _CVSS_TYPE_PREFIXES.get(severity.get("type", "")) | ||
| if prefix: | ||
| severity["score"] = prefix + score | ||
|
|
||
| return normalized | ||
|
|
||
| def _normalize(self, vuln_entry: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: | ||
| # We want to return the OSV record as it is (using OSV schema) | ||
| # We'll transform it into the Grype-specific vulnerability schema | ||
| # on grype-db | ||
| vuln_entry = self._normalize_severities(vuln_entry) | ||
| vuln_id = vuln_entry["id"] | ||
| # missing schema_version only matters for its major version: the provider | ||
| # maps any same-major value to its own pinned schema | ||
| vuln_schema = vuln_entry.get("schema_version", "1.0.0") | ||
|
|
||
| return vuln_id, vuln_schema, vuln_entry | ||
|
|
||
| def get(self) -> Generator[tuple[str, str, dict[str, Any]]]: | ||
| self._download() | ||
|
|
||
| self.fixdater.download() | ||
|
|
||
| for vuln_entry in self._load(): | ||
| if not isinstance(vuln_entry, dict) or not vuln_entry.get("id"): | ||
| self.logger.warning("skipping advisory without an id") | ||
| elif "withdrawn" in vuln_entry: | ||
| self.logger.debug(f"skipping withdrawn entry: {vuln_entry['id']}") | ||
| else: | ||
| # annotate each affected range with first-observed fix dates | ||
| # (database_specific.anchore.fixes), which grype-db surfaces as | ||
| # fix availability | ||
| osv.patch_fix_date(vuln_entry, self.fixdater) | ||
| # Normalize the loaded data. Note: CVSS_V2 severities are kept | ||
| # deliberately — _normalize_severities gives their bare vectors | ||
| # the "CVSS:2.0/" prefix that downstream consumers (grype-db) | ||
| # require to parse them. | ||
| yield self._normalize(vuln_entry) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably a good idea to delete the older OSV schema
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the suggestion. I'll take a look but I think there are old records that refer to old schemas and we'll just host the old schemas forever.