Skip to content

Commit 4c96bd5

Browse files
Add echo provider to vunnel (#815)
* Add echo provider to vunnel Signed-off-by: Ori Zerah <[email protected]> * change to echo rolling Signed-off-by: Ori Zerah <[email protected]> * change unit test folder to echo:rolling Signed-off-by: Ori Zerah <[email protected]> * chore: use match labels from main Signed-off-by: Will Murphy <[email protected]> * chore: type hint Signed-off-by: Will Murphy <[email protected]> * chore: alphabetize providers Signed-off-by: Will Murphy <[email protected]> --------- Signed-off-by: Ori Zerah <[email protected]> Signed-off-by: Will Murphy <[email protected]> Co-authored-by: Will Murphy <[email protected]>
1 parent 1f2fac2 commit 4c96bd5

38 files changed

+994
-1
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Supported data sources:
1313
- Amazon (https://alas.aws.amazon.com/AL2/alas.rss & https://alas.aws.amazon.com/AL2022/alas.rss)
1414
- Azure (https://github.com/microsoft/AzureLinuxVulnerabilityData)
1515
- Debian (https://security-tracker.debian.org/tracker/data/json & https://salsa.debian.org/security-tracker-team/security-tracker/raw/master/data/DSA/list)
16+
- Echo (https://advisory.echohq.com/data.json)
1617
- GitHub Security Advisories (https://api.github.com/graphql)
1718
- NVD (https://services.nvd.nist.gov/rest/json/cves/2.0)
1819
- Oracle (https://linux.oracle.com/security/oval)
@@ -59,6 +60,7 @@ alpine
5960
amazon
6061
chainguard
6162
debian
63+
echo
6264
github
6365
mariner
6466
minimos

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ check_untyped_defs = true
6666
no_implicit_reexport = true
6767
disallow_untyped_defs = true
6868
ignore_missing_imports = true
69+
# Note: new files are expected to have type hints. Please do not add files to this
70+
# ignore list unless they are generated.
6971
exclude = '''(?x)(
7072
^src/vunnel/providers/alpine/parser\.py$ # ported from enterprise, never had type hints
7173
| ^src/vunnel/providers/amazon/parser\.py$ # ported from enterprise, never had type hints

src/vunnel/cli/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class Providers:
4848
bitnami: providers.bitnami.Config = field(default_factory=providers.bitnami.Config)
4949
chainguard: providers.chainguard.Config = field(default_factory=providers.chainguard.Config)
5050
debian: providers.debian.Config = field(default_factory=providers.debian.Config)
51+
echo: providers.echo.Config = field(default_factory=providers.echo.Config)
5152
epss: providers.epss.Config = field(default_factory=providers.epss.Config)
5253
github: providers.github.Config = field(default_factory=providers.github.Config)
5354
kev: providers.kev.Config = field(default_factory=providers.kev.Config)

src/vunnel/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
bitnami,
1212
chainguard,
1313
debian,
14+
echo,
1415
epss,
1516
github,
1617
kev,
@@ -35,6 +36,7 @@
3536
amazon.Provider.name(): amazon.Provider,
3637
bitnami.Provider.name(): bitnami.Provider,
3738
debian.Provider.name(): debian.Provider,
39+
echo.Provider.name(): echo.Provider,
3840
github.Provider.name(): github.Provider,
3941
mariner.Provider.name(): mariner.Provider,
4042
nvd.Provider.name(): nvd.Provider,

src/vunnel/providers/echo/__init__.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from dataclasses import dataclass, field
5+
from typing import TYPE_CHECKING
6+
7+
from vunnel import provider, result, schema
8+
9+
from .parser import Parser
10+
11+
if TYPE_CHECKING:
12+
import datetime
13+
14+
15+
@dataclass
16+
class Config:
17+
runtime: provider.RuntimeConfig = field(
18+
default_factory=lambda: provider.RuntimeConfig(
19+
result_store=result.StoreStrategy.SQLITE,
20+
existing_results=result.ResultStatePolicy.DELETE_BEFORE_WRITE,
21+
),
22+
)
23+
request_timeout: int = 125
24+
25+
26+
class Provider(provider.Provider):
27+
__schema__ = schema.OSSchema()
28+
__distribution_version__ = int(__schema__.major_version)
29+
30+
_url = "https://advisory.echohq.com/data.json"
31+
_namespace = "echo"
32+
33+
def __init__(self, root: str, config: Config | None = None):
34+
if not config:
35+
config = Config()
36+
super().__init__(root, runtime_cfg=config.runtime)
37+
self.config = config
38+
39+
self.logger.debug(f"config: {config}")
40+
41+
self.parser = Parser(
42+
workspace=self.workspace,
43+
url=self._url,
44+
namespace=self._namespace,
45+
download_timeout=self.config.request_timeout,
46+
logger=self.logger,
47+
)
48+
49+
# this provider requires the previous state from former runs
50+
provider.disallow_existing_input_policy(config.runtime)
51+
52+
@classmethod
53+
def name(cls) -> str:
54+
return "echo"
55+
56+
def update(self, last_updated: datetime.datetime | None) -> tuple[list[str], int]:
57+
with self.results_writer() as writer:
58+
# TODO: tech debt: on subsequent runs, we should only write new vulns (this currently re-writes all)
59+
for release, vuln_dict in self.parser.get():
60+
for vuln_id, record in vuln_dict.items():
61+
writer.write(
62+
identifier=os.path.join(f"{self._namespace.lower()}:{release.lower()}", vuln_id),
63+
schema=self.__schema__,
64+
payload=record,
65+
)
66+
67+
return [self._url], len(writer)

src/vunnel/providers/echo/parser.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import copy
4+
import logging
5+
import os
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING, Any
8+
9+
import orjson
10+
11+
from vunnel.utils import http_wrapper as http
12+
from vunnel.utils import vulnerability
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Generator
16+
17+
from vunnel import workspace
18+
19+
20+
class Parser:
21+
_release_ = "rolling"
22+
_advisories_dir = "echo-advisories"
23+
_advisories_filename = "data.json"
24+
25+
def __init__(
26+
self,
27+
workspace: workspace.Workspace,
28+
url: str,
29+
namespace: str,
30+
download_timeout: int = 125,
31+
logger: logging.Logger | None = None,
32+
):
33+
self.download_timeout = download_timeout
34+
self.advisories_dir_path = Path(workspace.input_path) / self._advisories_dir
35+
self.url = url
36+
self.namespace = namespace
37+
38+
if not logger:
39+
logger = logging.getLogger(self.__class__.__name__)
40+
self.logger = logger
41+
42+
def _download(self) -> None:
43+
"""
44+
Downloads echo advisories files
45+
:return:
46+
"""
47+
if not os.path.exists(self.advisories_dir_path):
48+
os.makedirs(self.advisories_dir_path, exist_ok=True)
49+
50+
try:
51+
self.logger.info(f"downloading {self.namespace} advisories {self.url}")
52+
r = http.get(self.url, self.logger, stream=True, timeout=self.download_timeout)
53+
file_path = self.advisories_dir_path / self._advisories_filename
54+
with open(file_path, "wb") as fp:
55+
for chunk in r.iter_content():
56+
fp.write(chunk)
57+
except Exception:
58+
self.logger.exception(f"Error downloading Echo advisories from {self.url}")
59+
raise
60+
61+
def _normalize(self, release: str, data: dict[str, Any]) -> dict[str, dict[str, Any]]:
62+
"""
63+
Normalize all the advisories entries into vulnerability payload records
64+
:param release:
65+
:param advisories_data_dict:
66+
:return:
67+
"""
68+
69+
self.logger.debug("normalizing vulnerability data")
70+
71+
vuln_dict = {}
72+
for package, package_cves in data.items():
73+
for cve_id, cve_info in package_cves.items():
74+
if cve_id not in vuln_dict:
75+
record = copy.deepcopy(vulnerability.vulnerability_element)
76+
record["Vulnerability"]["Name"] = cve_id
77+
record["Vulnerability"]["NamespaceName"] = self.namespace + ":" + str(release)
78+
reference_links = vulnerability.build_reference_links(cve_id)
79+
record["Vulnerability"]["Link"] = reference_links[0] if reference_links else ""
80+
record["Vulnerability"]["Severity"] = cve_info.get("severity", "Unknown")
81+
record["Vulnerability"]["FixedIn"] = []
82+
vuln_dict[cve_id] = record
83+
cve_record = vuln_dict[cve_id]
84+
cve_record["Vulnerability"]["FixedIn"].append( # type: ignore[union-attr]
85+
{
86+
"Name": package,
87+
"Version": cve_info.get("fixed_version", ""),
88+
"VersionFormat": "dpkg",
89+
"NamespaceName": self.namespace + ":" + str(release),
90+
},
91+
)
92+
return vuln_dict
93+
94+
def get(self) -> Generator[tuple[str, dict[str, dict[str, Any]]], None, None]:
95+
"""
96+
Download, load and normalize wolfi sec db and return a dict of release - list of vulnerability records
97+
:return:
98+
"""
99+
# download the data
100+
self._download()
101+
with open(f"{self.advisories_dir_path}/{self._advisories_filename}") as fh:
102+
advisories_data_dict = orjson.loads(fh.read())
103+
104+
yield self._release_, self._normalize(self._release_, advisories_data_dict)

tests/quality/config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ tests:
8484
validations:
8585
- *default-validations
8686

87+
- provider: echo
88+
additional_providers:
89+
- name: nvd
90+
use_cache: true
91+
images:
92+
- ghcr.io/buildecho/scanner-test:latest@sha256:60557350ad6976dad3b88d891de8f090b20b3271c660272d30d44b5d07b23edc
93+
expected_namespaces:
94+
- echo:distro:echo:rolling
95+
validations:
96+
- *default-validations
97+
8798
- provider: amazon
8899
validations:
89100
- <<: *default-validations

tests/unit/cli/test_cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,23 @@ def test_config(monkeypatch) -> None:
245245
result_store: sqlite
246246
skip_download: false
247247
skip_newer_archive_check: false
248+
echo:
249+
request_timeout: 125
250+
runtime:
251+
existing_input: keep
252+
existing_results: delete-before-write
253+
import_results_enabled: false
254+
import_results_host: ''
255+
import_results_path: providers/{provider_name}/listing.json
256+
on_error:
257+
action: fail
258+
input: keep
259+
results: keep
260+
retry_count: 3
261+
retry_delay: 5
262+
result_store: sqlite
263+
skip_download: false
264+
skip_newer_archive_check: false
248265
epss:
249266
dataset: current
250267
request_timeout: 125

tests/unit/providers/echo/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)