Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions rapidfort-pr-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# feat: add RapidFort vulnerability provider

## Summary

Adds a new `rapidfort` provider that ingests security advisory data from the
[RapidFort security-advisories](https://github.com/rapidfort/security-advisories)
GitHub repository and normalizes it into vunnel's OSSchema format for consumption
by Grype.

RapidFort advisories are intended for use when scanning RapidFort-curated images
(identified via maintainer metadata) to apply RapidFort-specific version checks
that differ from upstream distro advisories.

## What's included

### New provider — `src/vunnel/providers/rapidfort/`

| File | Purpose |
|------|---------|
| `__init__.py` | `Provider` and `Config` classes; registered in the global provider registry |
| `parser.py` | Advisory loading, normalization, and event-range processing |
| `git.py` | Shallow-clone wrapper (`--depth=1`) with retry-backoff for the advisory repo |

**Supported OS types and version formats:**

| OS | Version format | Example namespace |
|----|---------------|-------------------|
| Ubuntu | `dpkg` | `rapidfort-ubuntu:22.04` |
| Alpine | `apk` | `rapidfort-alpine:3.15` |
| Red Hat | `rpm` | `rapidfort-redhat:el9` |

**Namespace isolation:** advisories are stored under `rapidfort-{os}:{version}`
(e.g. `rapidfort-ubuntu:20.04`) so Grype keeps them separate from standard
upstream distro scans.

### Key design decisions

- **Event-based version ranges** (mirrors GHSA semantics): each `introduced`/`fixed`
pair in an advisory event becomes a separate `FixedIn` entry with a
`VulnerableRange` field. A single CVE can produce multiple `FixedIn` entries
when it affects more than one release branch.
- **Release identifiers**: Red Hat advisories carry per-event identifiers (e.g.
`el9`, `fc36`) that are preserved in the `Identifier` field and reflected in
`VendorAdvisory.AdvisorySummary`.
- **Fix availability**: integrates with the existing `fixdate` system to populate
the `Available` field on each `FixedIn` entry.
- **Merge across packages**: when the same CVE appears in multiple package files,
`FixedIn` entries are merged into a single vulnerability record.

### Registration

- `src/vunnel/providers/__init__.py` — provider added to the global registry
- `src/vunnel/cli/config.py` — `Config` added to the `Providers` dataclass for
CLI/YAML configuration

### Tests — `tests/unit/providers/rapidfort/`

| Test class / function | What it covers |
|-----------------------|----------------|
| `TestEventsToRangePairs` | Range-pair conversion: single, multi-range, introduced-only, fixed-only, deduplication, identifier preservation |
| `TestNormalize` | Multi-range CVE produces multiple `FixedIn` entries; `Available` field present; Red Hat per-range identifiers and `VendorAdvisory` |
| `TestMergeIntoNamespace` | Same CVE in two packages merges into one record; distinct CVEs stay separate |
| `TestMapSeverity` | Case-insensitive known severities; `Unknown` for `None`, empty string, unrecognized values |
| `test_provider_schema` | Full provider output validates against `schema-1.1.0.json` |
| `test_provider_via_snapshot` | Regression snapshots for Ubuntu, Alpine, and Red Hat advisory output |

**Test fixtures** cover all three supported OS types:

```
test-fixtures/
├── input/rapidfort-advisories/OS/
│ ├── ubuntu/curl_advisory.json # multi-range CVE (2 events)
│ ├── alpine/zlib_advisory.json # single-range CVE, apk format
│ └── redhat/curl_advisory.json # per-release identifiers (el9/fc36/fc37)
└── snapshots/
├── rapidfort-ubuntu:20.04/
├── rapidfort-alpine:3.15/
└── rapidfort-redhat:el9/
```

## Test plan

- [ ] `pytest tests/unit/providers/rapidfort/` — all 17 tests pass
- [ ] Verify `vunnel run rapidfort` clones the advisory repo and writes results
- [ ] Confirm `vunnel list` shows `rapidfort` in the provider list
- [ ] Confirm Grype resolves `rapidfort-ubuntu:*` / `rapidfort-alpine:*` /
`rapidfort-redhat:*` namespaces when scanning RapidFort-curated images
1 change: 1 addition & 0 deletions src/vunnel/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class Providers:
minimos: providers.minimos.Config = field(default_factory=providers.minimos.Config)
nvd: providers.nvd.Config = field(default_factory=providers.nvd.Config)
oracle: providers.oracle.Config = field(default_factory=providers.oracle.Config)
rapidfort: providers.rapidfort.Config = field(default_factory=providers.rapidfort.Config)
rhel: providers.rhel.Config = field(default_factory=providers.rhel.Config)
rocky: providers.rocky.Config = field(default_factory=providers.rocky.Config)
secureos: providers.secureos.Config = field(default_factory=providers.secureos.Config)
Expand Down
2 changes: 2 additions & 0 deletions src/vunnel/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
nvd,
oracle,
photon,
rapidfort,
rhel,
rocky,
secureos,
Expand Down Expand Up @@ -52,6 +53,7 @@
nvd.Provider.name(): nvd.Provider,
oracle.Provider.name(): oracle.Provider,
photon.Provider.name(): photon.Provider,
rapidfort.Provider.name(): rapidfort.Provider,
rhel.Provider.name(): rhel.Provider,
rocky.Provider.name(): rocky.Provider,
secureos.Provider.name(): secureos.Provider,
Expand Down
74 changes: 74 additions & 0 deletions src/vunnel/providers/rapidfort/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""RapidFort security advisories provider.

Ingests vulnerability data from the RapidFort security-advisories GitHub repo
for Ubuntu and Alpine. Used when scanning RapidFort-curated images (identified
via maintainer metadata) to apply RapidFort-specific advisory and version checks.
"""

from __future__ import annotations

import os
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):
__schema__ = schema.OSSchema()
__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("config: %s", self.config)

self.parser = Parser(
workspace=self.workspace,
logger=self.logger,
)

provider.disallow_existing_input_policy(config.runtime)

@classmethod
def name(cls) -> str:
return "rapidfort"

@classmethod
def tags(cls) -> list[str]:
return ["vulnerability", "os"]

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 namespace, vuln_dict in self.parser.get():
namespace = namespace.lower()
for vuln_id, record in vuln_dict.items():
vuln_id = vuln_id.lower()
writer.write(
identifier=os.path.join(namespace, vuln_id),
schema=self.__schema__,
payload=record,
)

return self.parser.urls, len(writer)
66 changes: 66 additions & 0 deletions src/vunnel/providers/rapidfort/git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Git wrapper for cloning RapidFort security-advisories repo."""

from __future__ import annotations

import logging
import os
import shlex
import shutil
import subprocess
import tempfile
from dataclasses import dataclass

from vunnel import utils


class GitWrapper:
_check_cmd_ = "git --version"
_clone_cmd_ = "git clone --depth=1 -b {branch} {src} {dest}"

def __init__(
self,
source: str,
branch: str,
checkout_dest: str,
logger: logging.Logger | None = None,
):
self.src = source
self.branch = branch
self.dest = checkout_dest
self.workspace = tempfile.gettempdir()

if not logger:
logger = logging.getLogger(self.__class__.__name__)
self.logger = logger

try:
out = self._exec_cmd(self._check_cmd_)
self.logger.trace("git executable verified using cmd: %s, output: %s", self._check_cmd_, out) # type: ignore[attr-defined]
except Exception:
self.logger.exception('could not find required "git" executable. Please install git on host')
raise

def delete_repo(self) -> None:
if os.path.exists(self.dest):
self.logger.debug("deleting existing repository")
shutil.rmtree(self.dest, ignore_errors=True)

@utils.retry_with_backoff()
def clone_repo(self) -> None:
try:
self.logger.info("cloning git repository %s branch %s to %s", self.src, self.branch, self.dest)
cmd = self._clone_cmd_.format(src=self.src, dest=self.dest, branch=self.branch)
out = self._exec_cmd(cmd)
self.logger.debug("initialized git repo, cmd: %s, output: %s", cmd, out)
except Exception:
self.logger.exception("failed to clone git repository %s branch %s to %s", self.src, self.branch, self.dest)
raise

def _exec_cmd(self, cmd: str) -> str:
try:
self.logger.trace("running: %s", cmd) # type: ignore[attr-defined]
cmd_list = shlex.split(cmd)
return subprocess.check_output(cmd_list, text=True, stderr=subprocess.PIPE) # noqa: S603
except Exception:
self.logger.exception("error executing command: %s", cmd)
raise
Loading