Sprint 6 Steps 188-190: command palette, wizards, vuln db
This commit is contained in:
395
editor/src/VulnerabilityDatabase.h
Normal file
395
editor/src/VulnerabilityDatabase.h
Normal file
@@ -0,0 +1,395 @@
|
||||
#pragma once
|
||||
// Step 190: Vulnerability knowledge base (OSV)
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <functional>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
struct VulnerabilityRecord {
|
||||
std::string ecosystem;
|
||||
std::string package;
|
||||
std::string cveId;
|
||||
std::string summary;
|
||||
std::string severity;
|
||||
std::vector<std::string> affectedVersions;
|
||||
std::vector<std::string> fixedVersions;
|
||||
std::vector<std::string> references;
|
||||
struct VersionRange {
|
||||
std::string introduced;
|
||||
std::string fixed;
|
||||
};
|
||||
std::vector<VersionRange> ranges;
|
||||
};
|
||||
|
||||
class VulnerabilityDatabase {
|
||||
public:
|
||||
using Fetcher = std::function<std::string(const std::string& ecosystem,
|
||||
const std::string& package)>;
|
||||
|
||||
void setCacheDirectory(const std::string& path) {
|
||||
std::scoped_lock lock(mu_);
|
||||
cacheDir_ = path;
|
||||
}
|
||||
|
||||
void setTTLHours(int hours) {
|
||||
std::scoped_lock lock(mu_);
|
||||
ttlHours_ = std::max(1, hours);
|
||||
}
|
||||
|
||||
void setOfflineMode(bool offline) {
|
||||
std::scoped_lock lock(mu_);
|
||||
offlineMode_ = offline;
|
||||
}
|
||||
|
||||
void setFetcher(Fetcher fetcher) {
|
||||
std::scoped_lock lock(mu_);
|
||||
fetcher_ = std::move(fetcher);
|
||||
}
|
||||
|
||||
void trackPackage(const std::string& ecosystem,
|
||||
const std::string& package) {
|
||||
std::scoped_lock lock(mu_);
|
||||
tracked_.push_back(makeKey(ecosystem, package));
|
||||
}
|
||||
|
||||
void startBackgroundRefresh() {
|
||||
std::thread([this]() { refreshTrackedPackages(); }).detach();
|
||||
}
|
||||
|
||||
std::vector<VulnerabilityRecord> query(const std::string& ecosystem,
|
||||
const std::string& package,
|
||||
const std::string& version) {
|
||||
ensureLoaded(ecosystem, package);
|
||||
const std::string key = makeKey(ecosystem, package);
|
||||
std::scoped_lock lock(mu_);
|
||||
auto it = cache_.find(key);
|
||||
if (it == cache_.end()) return {};
|
||||
if (version.empty()) return it->second;
|
||||
|
||||
std::vector<VulnerabilityRecord> out;
|
||||
for (const auto& record : it->second) {
|
||||
if (isAffected(record, version)) out.push_back(record);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string cacheDir_ = defaultCacheDir();
|
||||
int ttlHours_ = 24;
|
||||
bool offlineMode_ = false;
|
||||
Fetcher fetcher_;
|
||||
std::vector<std::string> tracked_;
|
||||
std::unordered_map<std::string, std::vector<VulnerabilityRecord>> cache_;
|
||||
std::mutex mu_;
|
||||
|
||||
static std::string defaultCacheDir() {
|
||||
const char* home = std::getenv("USERPROFILE");
|
||||
if (!home) home = std::getenv("HOME");
|
||||
std::filesystem::path base = home ? home : ".";
|
||||
return (base / ".whetstone" / "vuln_cache").string();
|
||||
}
|
||||
|
||||
static std::string makeKey(const std::string& ecosystem,
|
||||
const std::string& package) {
|
||||
return ecosystem + ":" + package;
|
||||
}
|
||||
|
||||
static std::string sanitizeFileName(const std::string& name) {
|
||||
std::string out;
|
||||
out.reserve(name.size());
|
||||
for (char c : name) {
|
||||
if (std::isalnum(static_cast<unsigned char>(c)) || c == '_' || c == '-') {
|
||||
out.push_back(c);
|
||||
} else if (c == ':' || c == '/' || c == '\\') {
|
||||
out.push_back('_');
|
||||
} else {
|
||||
out.push_back('_');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::filesystem::path cachePathFor(const std::string& ecosystem,
|
||||
const std::string& package) const {
|
||||
std::filesystem::path dir(cacheDir_);
|
||||
std::string file = sanitizeFileName(makeKey(ecosystem, package)) + ".json";
|
||||
return dir / file;
|
||||
}
|
||||
|
||||
bool cacheIsStale(const std::filesystem::path& path) const {
|
||||
if (!std::filesystem::exists(path)) return true;
|
||||
auto last = std::filesystem::last_write_time(path);
|
||||
auto now = std::filesystem::file_time_type::clock::now();
|
||||
auto age = now - last;
|
||||
auto hours = std::chrono::duration_cast<std::chrono::hours>(age).count();
|
||||
return hours >= ttlHours_;
|
||||
}
|
||||
|
||||
void ensureLoaded(const std::string& ecosystem,
|
||||
const std::string& package) {
|
||||
const std::string key = makeKey(ecosystem, package);
|
||||
{
|
||||
std::scoped_lock lock(mu_);
|
||||
if (cache_.find(key) != cache_.end()) return;
|
||||
}
|
||||
refreshPackage(ecosystem, package, false);
|
||||
}
|
||||
|
||||
void refreshTrackedPackages() {
|
||||
std::vector<std::string> tracked;
|
||||
{
|
||||
std::scoped_lock lock(mu_);
|
||||
tracked = tracked_;
|
||||
}
|
||||
for (const auto& key : tracked) {
|
||||
auto pos = key.find(':');
|
||||
if (pos == std::string::npos) continue;
|
||||
refreshPackage(key.substr(0, pos), key.substr(pos + 1), true);
|
||||
}
|
||||
}
|
||||
|
||||
void refreshPackage(const std::string& ecosystem,
|
||||
const std::string& package,
|
||||
bool allowFetch) {
|
||||
std::filesystem::path path = cachePathFor(ecosystem, package);
|
||||
if (!allowFetch && !cacheIsStale(path)) {
|
||||
loadCache(path, ecosystem, package);
|
||||
return;
|
||||
}
|
||||
if (allowFetch && !offlineMode_ && fetcher_) {
|
||||
std::string payload = fetcher_(ecosystem, package);
|
||||
if (!payload.empty()) {
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
std::ofstream out(path);
|
||||
if (out.is_open()) {
|
||||
out << payload;
|
||||
}
|
||||
loadCache(path, ecosystem, package);
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadCache(path, ecosystem, package);
|
||||
}
|
||||
|
||||
void loadCache(const std::filesystem::path& path,
|
||||
const std::string& ecosystem,
|
||||
const std::string& package) {
|
||||
std::ifstream in(path);
|
||||
if (!in.is_open()) return;
|
||||
nlohmann::json doc;
|
||||
try {
|
||||
in >> doc;
|
||||
} catch (...) {
|
||||
return;
|
||||
}
|
||||
auto records = parseOSV(doc);
|
||||
std::scoped_lock lock(mu_);
|
||||
cache_[makeKey(ecosystem, package)] = std::move(records);
|
||||
}
|
||||
|
||||
static std::vector<VulnerabilityRecord> parseOSV(const nlohmann::json& doc) {
|
||||
std::vector<VulnerabilityRecord> records;
|
||||
if (!doc.is_object()) return records;
|
||||
nlohmann::json vulns;
|
||||
if (doc.contains("vulnerabilities")) {
|
||||
vulns = doc.at("vulnerabilities");
|
||||
} else if (doc.contains("id")) {
|
||||
vulns = nlohmann::json::array({doc});
|
||||
} else {
|
||||
return records;
|
||||
}
|
||||
|
||||
for (const auto& vuln : vulns) {
|
||||
if (!vuln.is_object()) continue;
|
||||
VulnerabilityRecord record;
|
||||
record.summary = vuln.value("summary", "");
|
||||
record.cveId = pickCVE(vuln);
|
||||
record.severity = deriveSeverity(vuln);
|
||||
record.references = parseReferences(vuln);
|
||||
|
||||
if (!vuln.contains("affected") || !vuln["affected"].is_array()) continue;
|
||||
for (const auto& affected : vuln["affected"]) {
|
||||
if (!affected.is_object()) continue;
|
||||
if (!affected.contains("package")) continue;
|
||||
const auto& pkg = affected["package"];
|
||||
record.ecosystem = pkg.value("ecosystem", "");
|
||||
record.package = pkg.value("name", "");
|
||||
record.affectedVersions = parseVersions(affected);
|
||||
record.fixedVersions = parseFixedVersions(affected);
|
||||
record.ranges = parseRanges(affected);
|
||||
records.push_back(record);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
static std::vector<std::string> parseReferences(const nlohmann::json& vuln) {
|
||||
std::vector<std::string> refs;
|
||||
if (!vuln.contains("references")) return refs;
|
||||
for (const auto& ref : vuln["references"]) {
|
||||
if (!ref.is_object()) continue;
|
||||
std::string url = ref.value("url", "");
|
||||
if (!url.empty()) refs.push_back(url);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
static std::string pickCVE(const nlohmann::json& vuln) {
|
||||
if (vuln.contains("aliases")) {
|
||||
for (const auto& alias : vuln["aliases"]) {
|
||||
if (alias.is_string()) {
|
||||
std::string val = alias.get<std::string>();
|
||||
if (val.rfind("CVE-", 0) == 0) return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return vuln.value("id", "");
|
||||
}
|
||||
|
||||
static std::string deriveSeverity(const nlohmann::json& vuln) {
|
||||
if (vuln.contains("severity") && vuln["severity"].is_array()) {
|
||||
for (const auto& sev : vuln["severity"]) {
|
||||
std::string score = sev.value("score", "");
|
||||
double val = 0.0;
|
||||
try {
|
||||
val = std::stod(score);
|
||||
} catch (...) {
|
||||
val = 0.0;
|
||||
}
|
||||
if (val >= 9.0) return "Critical";
|
||||
if (val >= 7.0) return "High";
|
||||
if (val >= 4.0) return "Medium";
|
||||
if (val > 0.0) return "Low";
|
||||
}
|
||||
}
|
||||
if (vuln.contains("database_specific")) {
|
||||
const auto& db = vuln["database_specific"];
|
||||
if (db.contains("severity")) return db["severity"].get<std::string>();
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
static std::vector<std::string> parseVersions(const nlohmann::json& affected) {
|
||||
std::vector<std::string> versions;
|
||||
if (affected.contains("versions") && affected["versions"].is_array()) {
|
||||
for (const auto& v : affected["versions"]) {
|
||||
if (v.is_string()) versions.push_back(v.get<std::string>());
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
static std::vector<std::string> parseFixedVersions(const nlohmann::json& affected) {
|
||||
std::vector<std::string> versions;
|
||||
if (!affected.contains("ranges") || !affected["ranges"].is_array()) return versions;
|
||||
for (const auto& range : affected["ranges"]) {
|
||||
if (!range.contains("events")) continue;
|
||||
for (const auto& event : range["events"]) {
|
||||
if (event.contains("fixed")) {
|
||||
versions.push_back(event["fixed"].get<std::string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
static std::vector<VulnerabilityRecord::VersionRange>
|
||||
parseRanges(const nlohmann::json& affected) {
|
||||
std::vector<VulnerabilityRecord::VersionRange> ranges;
|
||||
if (!affected.contains("ranges") || !affected["ranges"].is_array()) return ranges;
|
||||
for (const auto& range : affected["ranges"]) {
|
||||
if (!range.contains("events")) continue;
|
||||
VulnerabilityRecord::VersionRange current;
|
||||
bool hasOpen = false;
|
||||
for (const auto& event : range["events"]) {
|
||||
if (event.contains("introduced")) {
|
||||
current.introduced = event["introduced"].get<std::string>();
|
||||
current.fixed.clear();
|
||||
hasOpen = true;
|
||||
} else if (event.contains("fixed")) {
|
||||
if (!hasOpen) current.introduced = "0";
|
||||
current.fixed = event["fixed"].get<std::string>();
|
||||
ranges.push_back(current);
|
||||
hasOpen = false;
|
||||
} else if (event.contains("last_affected")) {
|
||||
if (!hasOpen) current.introduced = "0";
|
||||
current.fixed = event["last_affected"].get<std::string>();
|
||||
ranges.push_back(current);
|
||||
hasOpen = false;
|
||||
}
|
||||
}
|
||||
if (hasOpen) {
|
||||
ranges.push_back(current);
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
static bool isAffected(const VulnerabilityRecord& record,
|
||||
const std::string& version) {
|
||||
for (const auto& v : record.affectedVersions) {
|
||||
if (v == version) return true;
|
||||
}
|
||||
for (const auto& range : record.ranges) {
|
||||
if (versionInRange(version, range.introduced, range.fixed)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int compareVersions(const std::string& a, const std::string& b) {
|
||||
auto split = [](const std::string& v) {
|
||||
std::vector<std::string> parts;
|
||||
std::string cur;
|
||||
for (char c : v) {
|
||||
if (std::isalnum(static_cast<unsigned char>(c)) || c == '.') {
|
||||
cur.push_back(c);
|
||||
} else if (!cur.empty()) {
|
||||
parts.push_back(cur);
|
||||
cur.clear();
|
||||
}
|
||||
}
|
||||
if (!cur.empty()) parts.push_back(cur);
|
||||
return parts;
|
||||
};
|
||||
auto pa = split(a);
|
||||
auto pb = split(b);
|
||||
size_t count = std::max(pa.size(), pb.size());
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
std::string sa = i < pa.size() ? pa[i] : "0";
|
||||
std::string sb = i < pb.size() ? pb[i] : "0";
|
||||
if (isNumber(sa) && isNumber(sb)) {
|
||||
long long ia = std::stoll(sa);
|
||||
long long ib = std::stoll(sb);
|
||||
if (ia < ib) return -1;
|
||||
if (ia > ib) return 1;
|
||||
} else {
|
||||
if (sa < sb) return -1;
|
||||
if (sa > sb) return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool isNumber(const std::string& s) {
|
||||
if (s.empty()) return false;
|
||||
return std::all_of(s.begin(), s.end(),
|
||||
[](unsigned char c) { return std::isdigit(c); });
|
||||
}
|
||||
|
||||
static bool versionInRange(const std::string& version,
|
||||
const std::string& introduced,
|
||||
const std::string& fixed) {
|
||||
if (!introduced.empty() && compareVersions(version, introduced) < 0) return false;
|
||||
if (!fixed.empty() && compareVersions(version, fixed) >= 0) return false;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user