DEV Community

Cover image for ValtersIT CVE + Threat-IP API: Integration Guide for Firewalls, SIEM and CI/CD

ValtersIT CVE + Threat-IP API: Integration Guide for Firewalls, SIEM and CI/CD

Your firewall vendor publishes a critical advisory. Days later NVD assigns a score, a PoC lands on GitHub, and CISA adds it to KEV. Correlating those sources by hand is a tooling problem, not a knowledge problem.

The ValtersIT API puts CVE records (150,000+) and a threat-IP dataset (290,000+ addresses) behind one REST interface with one authentication scheme. This guide shows five integrations end to end. Every API request and response shape below was checked against the live API, including the failures.

What you get

Data Details
CVE records 150,000+. cvss, cvss_vector, epss, severity, cisa_kev (boolean), has_exploit, has_patch, poc_status, poc_url, cwe_id, mitre_tactic, shodan_dorks, affected_version, patch_details
Detection content yara_rule, wazuh_rule, elastic_rule on paid plans where a rule exists. sigma_rule and poc_code are Pro only
Threat IPs 290,000+ addresses. ip, threat_type, confidence, sources, cve_ids, malware_families, country_code, asn, org, ports, is_tor, is_vpn
Data honesty pending_fields lists what NVD has not filled in yet, backfilled_fields lists what we filled in after the fact

Base URL: https://api.valtersit.com/api/v1

Authentication: Authorization: Bearer <your key> on every request. Keys look like vit_v1_....

Threat IP types you can filter on: scanner, mixed, malware_dist, botnet_c2, phishing, tor, exploit_host, c2_server, spam, brute_force. Confidence scores are conservative: only a small number of IPs score 75 or above, so start with min_confidence=50.

Try it in 30 seconds

The public sandbox key needs no signup and costs nothing. It serves ten synthetic CVE records (CVE-2026-90001 to CVE-2026-90010) so you can test your parsing before you pay for anything.

# List all ten sandbox records
curl -s https://api.valtersit.com/api/v1/cve \
  -H "Authorization: Bearer vit_sandbox_free_demo_key_2026" \
  | jq '.data[] | {cve_id, vendor, cvss, severity, cisa_kev, poc_status}'

# One record
curl -s https://api.valtersit.com/api/v1/cve/CVE-2026-90001 \
  -H "Authorization: Bearer vit_sandbox_free_demo_key_2026" \
  | jq '{cve_id, vendor, cvss, severity, cisa_kev, poc_status, pending_fields}'
Enter fullscreen mode Exit fullscreen mode
{
  "cve_id": "CVE-2026-90001",
  "vendor": "SandboxSoft Firewall Pro",
  "cvss": 9.8,
  "severity": "critical",
  "cisa_kev": true,
  "poc_status": "Public",
  "pending_fields": []
}
Enter fullscreen mode Exit fullscreen mode

The sandbox has no IP feed. For real data, register for a free key at api.valtersit.com and top up, or subscribe.

Ground rules used in every example

  • Put the key in an environment variable: export VIT_API_KEY=vit_v1_...
  • GET /cve needs at least one filter (vendor, severity, cvss_min, cvss_max, epss_min, has_exploit, has_patch, changed_since, published_since). Pages hold at most 100 records. (The sandbox key is exempt from the filter rule.)
  • Single lookups such as GET /cve/CVE-2026-26084 return the record itself, not a data wrapper. List endpoints return {"data": [...], ...}.
  • Errors: 401 bad or missing key, 402 out of credits, 403 plan does not allow it, 404 unknown CVE, 422 bad parameter, 429 rate limit. Every metered response carries X-RateLimit-Limit and X-RateLimit-Remaining.
  • Pay-as-you-go spends one credit per CVE record returned. Standard and Pro track every CVE you look up, and re-checking one you have already fetched is free.

Use case 1: Firewall blocklist from the threat-IP feed

Browsing the feed by criteria needs Standard or Pro (Standard returns up to 500 rows per request, Pro up to 1,000). Pay-as-you-go can look up specific addresses with ?ips=1.2.3.4,5.6.7.8 (up to 10 per request) but cannot pull the list.

Pick your filters from the data, not from hope. At the time of writing:

Filter IPs returned
type=botnet_c2&min_confidence=50 about 10,800
type=malware_dist&min_confidence=50 about 31,000
type=scanner&min_confidence=50 about 125,000 (usually too broad to block outright)
#!/usr/bin/env python3
"""Pull botnet C2 IPs and write an nftables batch file."""
import ipaddress
import os
import subprocess

import requests

API = "https://api.valtersit.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}
PAGE = 500  # Standard cap. Pro allows 1000. A larger limit is clamped, not rejected.


def fetch(threat_type: str, min_confidence: int = 50) -> list[str]:
    ips, offset = [], 0
    while offset is not None:
        r = requests.get(
            f"{API}/ip/feed",
            headers=HEADERS,
            timeout=30,
            params={"type": threat_type, "min_confidence": min_confidence,
                    "limit": PAGE, "offset": offset},
        )
        r.raise_for_status()
        body = r.json()
        ips += [row["ip"] for row in body["data"]]
        offset = body["next_offset"]  # None on the last page
    return ips


def build_batch(ips: list[str]) -> str:
    v4 = [i for i in ips if ipaddress.ip_address(i).version == 4]
    v6 = [i for i in ips if ipaddress.ip_address(i).version == 6]
    lines = ["add table inet filter"]
    for name, family, members in (("vit_block4", "ipv4_addr", v4), ("vit_block6", "ipv6_addr", v6)):
        lines.append(f"add set inet filter {name} {{ type {family}; }}")
        lines.append(f"flush set inet filter {name}")
        for i in range(0, len(members), 1000):
            chunk = ", ".join(members[i:i + 1000])
            lines.append(f"add element inet filter {name} {{ {chunk} }}")
    return "\n".join(lines) + "\n"


if __name__ == "__main__":
    ips = sorted(set(fetch("botnet_c2")))
    path = "/var/lib/vit/blocklist.nft"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as f:
        f.write(build_batch(ips))
    subprocess.run(["nft", "-f", path], check=True)  # one atomic transaction
    print(f"loaded {len(ips)} addresses")
Enter fullscreen mode Exit fullscreen mode

Add the drop rules once, and run the script from cron (*/30 * * * *). Because it rebuilds the sets on every run, addresses that leave the feed age out on their own.

nft add rule inet filter input ip saddr @vit_block4 drop
nft add rule inet filter input ip6 saddr @vit_block6 drop
Enter fullscreen mode Exit fullscreen mode

⚠️ Roll this out carefully. Threat feeds contain false positives: shared hosting, CGNAT pools and reused addresses. Start with a log rule instead of drop, keep an allowlist for your own ranges and partners, and review what would have been blocked before you enforce anything. The API never returns private, loopback or reserved ranges.

pfSense / OPNsense: the API requires an authentication header, so a firewall cannot fetch the feed directly as a URL table alias. Run the fetch on any Linux host, write one address per line to a file you serve on your internal network, and point the alias at that internal URL.

Use case 2: Enrich SIEM alerts with CVE context

An IDS alert carries a CVE ID and nothing else. This helper turns it into a priority decision using fields that exist on every record.

import os
import time

import requests

API = "https://api.valtersit.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}


def enrich(cve_id: str) -> dict | None:
    for attempt in range(3):
        r = requests.get(f"{API}/cve/{cve_id}", headers=HEADERS, timeout=10)
        if r.status_code == 429:               # rate limited: wait and retry
            time.sleep(2 ** attempt)
            continue
        if r.status_code == 404:               # not in the database
            return None
        r.raise_for_status()                   # 401 / 402 / 5xx should be loud
        c = r.json()                           # the record itself, no "data" wrapper
        priority = "P1" if c["cisa_kev"] or (c["has_exploit"] and (c["cvss"] or 0) >= 9) else \
                   "P2" if c["has_exploit"] or (c["cvss"] or 0) >= 7 else "P3"
        return {
            "cve_id": c["cve_id"], "vendor": c["vendor"], "cvss": c["cvss"],
            "epss": c["epss"], "cisa_kev": c["cisa_kev"], "has_exploit": c["has_exploit"],
            "has_patch": c["has_patch"], "pending_fields": c["pending_fields"],
            "priority": priority,
        }
    raise RuntimeError("rate limited after 3 attempts")
Enter fullscreen mode Exit fullscreen mode

Print the result as JSON and this works as a scripted lookup, an n8n or Shuffle step, or a Splunk adaptive response action. For Elastic, the Logstash http filter does the same inline:

filter {
  if [cve_id] {
    http {
      url => "https://api.valtersit.com/api/v1/cve/%{[cve_id]}"
      headers => { "Authorization" => "Bearer ${VIT_API_KEY}" }
      target_body => "vit"
    }
    if [vit][cvss] {
      mutate {
        add_field => {
          "[threat][cvss]"     => "%{[vit][cvss]}"
          "[threat][epss]"     => "%{[vit][epss]}"
          "[threat][kev]"      => "%{[vit][cisa_kev]}"
          "[threat][exploit]"  => "%{[vit][has_exploit]}"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Put a cache in front of the lookup (Logstash memcached filter, Redis, or a translate table). Alerts repeat the same CVE constantly, and on pay-as-you-go every fresh record costs a credit.

Use case 3: CI/CD prioritization gate

The API is not a scanner and it does not match package versions. Let your scanner (Trivy, Grype, OSV-Scanner) find the CVE IDs, then use the API to decide which ones should actually stop a build: known-exploited, exploit-available, or unpatched and severe.

#!/usr/bin/env python3
"""Fail the build on CVEs that are actively dangerous. Input: trivy JSON on stdin."""
import json
import os
import sys

import requests

API = "https://api.valtersit.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}
MAX_LOOKUPS = 50  # each new CVE costs a credit on pay-as-you-go

report = json.load(sys.stdin)
ids = sorted({v["VulnerabilityID"]
              for res in report.get("Results", [])
              for v in res.get("Vulnerabilities") or []
              if v["VulnerabilityID"].startswith("CVE-")})[:MAX_LOOKUPS]

blocked = []
for cve_id in ids:
    r = requests.get(f"{API}/cve/{cve_id}", headers=HEADERS, timeout=15)
    if r.status_code == 404:
        continue                                # unknown to us: leave it to the scanner
    if r.status_code == 402:
        sys.exit("::error::out of API credits, gate could not run")
    r.raise_for_status()
    c = r.json()
    if c["cisa_kev"] or (c["has_exploit"] and not c["has_patch"] and (c["cvss"] or 0) >= 9):
        blocked.append(f"{cve_id} cvss={c['cvss']} kev={c['cisa_kev']} exploit={c['has_exploit']} patch={c['has_patch']}")

if blocked:
    print("::error::blocking CVEs found:")
    print("\n".join(f"  {b}" for b in blocked))
    sys.exit(1)
print(f"gate passed ({len(ids)} CVEs checked)")
Enter fullscreen mode Exit fullscreen mode
- name: CVE gate
  env:
    VIT_API_KEY: ${{ secrets.VIT_API_KEY }}
  run: |
    trivy fs --format json --quiet . | python3 scripts/cve_gate.py
Enter fullscreen mode Exit fullscreen mode

You can also ask the API directly for the worst offenders of a vendor you depend on:

curl -s "https://api.valtersit.com/api/v1/cve?vendor=Fortinet&cvss_min=9&has_exploit=true&has_patch=false&limit=5" \
  -H "Authorization: Bearer $VIT_API_KEY" | jq '.total_count, (.data[] | {cve_id, cvss})'
Enter fullscreen mode Exit fullscreen mode

Use case 4: Change tracking

CVE data matures over days: NVD scores arrive late, patches appear, a PoC goes public. Three tools cover it.

Poll for changes. changed_since takes an ISO-8601 date or datetime. Store the time of your last run and ask for everything newer:

curl -s "https://api.valtersit.com/api/v1/cve?vendor=Microsoft&changed_since=2026-09-18T00:00:00Z&limit=50" \
  -H "Authorization: Bearer $VIT_API_KEY" \
  | jq '.total_count, (.data[] | {cve_id, cvss, has_exploit, updated_at})'
Enter fullscreen mode Exit fullscreen mode

Use Z for UTC. A +02:00 offset must be URL-encoded as %2B02:00, because a bare + in a query string turns into a space and the API answers 422.

Read pending_fields. Every record says which values are still missing, so you know when a result is provisional:

"pending_fields": ["license_model", "origin_country"]
Enter fullscreen mode Exit fullscreen mode

Re-check a record later and the list shrinks. backfilled_fields reports values we filled in after you paid for the record.

Push instead of poll (Pro). A watchlist entry sends a webhook when a matching CVE is published or updated:

curl -s -X POST https://api.valtersit.com/api/v1/watchlist \
  -H "Authorization: Bearer $VIT_API_KEY" -H "Content-Type: application/json" \
  -d '{"vendors": ["Fortinet"], "min_severity": "high", "webhook_url": "https://example.com/hooks/cve"}'
Enter fullscreen mode Exit fullscreen mode

Your endpoint receives a POST with cve_id, vendor, cvss, summary and url. Webhook URLs must be public http(s) endpoints (private and internal addresses are refused), up to 50 entries per account. Delivery is best-effort: a failed POST is not retried, so treat the webhook as a nudge and use changed_since as your source of truth.

Use case 5: Detection rules and PoC status in one call

import os

import requests

r = requests.get("https://api.valtersit.com/api/v1/cve/CVE-2026-26084",
                 headers={"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}, timeout=15)
r.raise_for_status()
c = r.json()

print(c["cve_id"], c["vendor"], "cvss", c["cvss"], "| PoC:", c["poc_status"], c["poc_url"] or "")

for field, ext in (("yara_rule", "yar"), ("wazuh_rule", "xml"), ("elastic_rule", "txt")):
    if c.get(field):
        with open(f"{c['cve_id']}.{ext}", "w") as f:
            f.write(c[field])
        print("wrote", f"{c['cve_id']}.{ext}")

if c["sigma_rule_locked"]:
    print("a Sigma rule exists for this CVE but needs the Pro plan")
Enter fullscreen mode Exit fullscreen mode

Not every CVE has every rule, so always check for null. Treat every rule as a starting point: read it, test it against known-good traffic in a lab, and only then deploy. poc_status and poc_url are on every plan. The poc_code field (sanitized exploit code where publicly available) and sigma_rule are Pro only.

Quick reference

Endpoint Purpose
GET /cve Search CVEs (at least one filter, up to 100 per page)
GET /cve/{id} One CVE record
GET /ip/feed Threat IPs: type, min_confidence, country, cve, changed_since, ips, limit, offset
GET /ip/{ip} One IP (404 if unknown, 400 for private ranges)
POST /watchlist, GET /watchlist Webhook alerts (Pro)
GET /history CVEs you have looked up, with change flags
GET /usage Credits and rate-limit status
GET /status Public service status
Plan Credits / month Rate limit API keys IP feed
Pay-as-you-go top-up, €0.08 per credit 10 / min 1 look up specific IPs (ips=, up to 10 per request)
Standard 5,000 30 / min 1 full browsing, 500 rows per request
Pro 20,000 100 / min 5 full browsing, 1,000 rows per request, plus proprietary sources

💡 Founding Member pricing — ends 31 Dec 2026. Standard €19/mo (list €29) and Pro €65/mo (list €99), kept for as long as your subscription stays active. See pricing →

Top comments (0)