From 908436b57756a27fe23ec0e0f7788ba50e30c60c Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 10 Feb 2026 01:59:12 -0700 Subject: [PATCH] Sprint 6 Step 191: dependency security badges --- PROGRESS.md | 1 + editor/CMakeLists.txt | 3 + editor/src/DependencyPanel.h | 247 +++++++++++++++++++++++++++++++-- editor/src/panels/SidePanels.h | 5 +- editor/tests/step191_test.cpp | 27 ++++ sprint6_plan.md | 2 +- 6 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 editor/tests/step191_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index 7b057b3..fafcd92 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -538,4 +538,5 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 188: Command palette upgrade (command/file modes, categories, recent section, context-aware filtering, inline params, fuzzy highlights). 1/1 tests pass (step188_test). | | 2026-02-10 | Codex | Step 189: Guided workflow wizards (annotate file, cross-language project, connect agent) with shared wizard framework. 1/1 tests pass (step189_test). | | 2026-02-10 | Codex | Step 190: Vulnerability database (OSV parsing, cache+TTL, offline mode, query/range matching, background refresh hook). 1/1 tests pass (step190_test). | +| 2026-02-10 | Codex | Step 191: Dependency security badges with severity colors, advisory details, safe upgrade path, and ignore list persistence. 1/1 tests pass (step191_test). | | 2026-02-10 | Codex | Step 166: Extract main.cpp into panel headers marked complete (already implemented). step166_test passes; file_limits_test 4/4 passes. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 480a244..4dfcb5d 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1110,6 +1110,9 @@ add_executable(step190_test tests/step190_test.cpp) target_include_directories(step190_test PRIVATE src) target_link_libraries(step190_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step191_test tests/step191_test.cpp) +target_include_directories(step191_test PRIVATE src) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/DependencyPanel.h b/editor/src/DependencyPanel.h index fa8a2b3..439166d 100644 --- a/editor/src/DependencyPanel.h +++ b/editor/src/DependencyPanel.h @@ -3,12 +3,14 @@ #include "DependencyParser.h" #include "PackageRegistry.h" #include "NotificationSystem.h" +#include "VulnerabilityDatabase.h" #include #include #include #include #include #include +#include #include struct DependencyFile { @@ -31,6 +33,9 @@ struct DependencyPanelState { PackageInfo lastLookup; std::string lastWorkspaceRoot; bool needsIndex = false; + bool vulnIgnoreLoaded = false; + std::unordered_map vulnIgnore; + char vulnIgnoreReason[128] = {}; }; static std::vector dependencyFileNames() { @@ -149,6 +154,136 @@ static bool writeRequirementsFile(const std::string& path, return true; } +static std::string osvEcosystem(PackageEcosystem eco) { + switch (eco) { + case PackageEcosystem::Python: return "PyPI"; + case PackageEcosystem::Npm: return "npm"; + case PackageEcosystem::Rust: return "crates.io"; + case PackageEcosystem::Go: return "Go"; + case PackageEcosystem::Java: return "Maven"; + case PackageEcosystem::Cpp: return "OSS-Fuzz"; + default: return ""; + } +} + +static std::filesystem::path vulnIgnorePath() { + const char* home = std::getenv("USERPROFILE"); + if (!home) home = std::getenv("HOME"); + std::filesystem::path base = home ? home : "."; + return base / ".whetstone" / "vuln_ignore.json"; +} + +static std::string vulnKey(const std::string& ecosystem, const std::string& package) { + return ecosystem + ":" + package; +} + +static void loadVulnIgnore(DependencyPanelState& state) { + state.vulnIgnoreLoaded = true; + state.vulnIgnore.clear(); + auto path = vulnIgnorePath(); + std::ifstream in(path); + if (!in.is_open()) return; + nlohmann::json j; + try { in >> j; } catch (...) { return; } + if (!j.is_object()) return; + for (auto it = j.begin(); it != j.end(); ++it) { + if (it.value().is_string()) { + state.vulnIgnore[it.key()] = it.value().get(); + } + } +} + +static void saveVulnIgnore(const DependencyPanelState& state, + NotificationSystem& notifications) { + auto path = vulnIgnorePath(); + std::filesystem::create_directories(path.parent_path()); + nlohmann::json j = nlohmann::json::object(); + for (const auto& [key, reason] : state.vulnIgnore) { + j[key] = reason; + } + std::ofstream out(path); + if (!out.is_open()) { + notifications.notify(NotificationLevel::Error, + "[deps] Failed to write vuln_ignore.json"); + return; + } + out << j.dump(2); +} + +static int severityRank(const std::string& severity) { + if (severity == "Critical") return 3; + if (severity == "High") return 2; + if (severity == "Medium") return 1; + return 0; +} + +static ImU32 severityColor(const std::string& severity) { + if (severity == "Critical" || severity == "High") { + return IM_COL32(220, 80, 80, 255); + } + if (severity == "Medium") { + return IM_COL32(230, 150, 60, 255); + } + return IM_COL32(150, 150, 150, 255); +} + +static void drawSecurityBadge(ImDrawList* draw, ImVec2 pos, float size, ImU32 color) { + ImVec2 top(pos.x, pos.y - size * 0.5f); + ImVec2 left(pos.x - size * 0.4f, pos.y - size * 0.1f); + ImVec2 right(pos.x + size * 0.4f, pos.y - size * 0.1f); + ImVec2 bottom(pos.x, pos.y + size * 0.5f); + draw->AddTriangleFilled(top, left, right, color); + draw->AddTriangleFilled(left, right, bottom, color); +} + +static int compareVersions(const std::string& a, const std::string& b) { + auto split = [](const std::string& v) { + std::vector parts; + std::string cur; + for (char c : v) { + if (std::isalnum(static_cast(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"; + bool na = !sa.empty() && std::all_of(sa.begin(), sa.end(), ::isdigit); + bool nb = !sb.empty() && std::all_of(sb.begin(), sb.end(), ::isdigit); + if (na && nb) { + 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 std::string bestFixedVersion(const std::vector& records) { + std::string best; + for (const auto& record : records) { + for (const auto& fixed : record.fixedVersions) { + if (best.empty() || compareVersions(fixed, best) > 0) { + best = fixed; + } + } + } + return best; +} + static bool writePackageJson(const std::string& path, const std::vector& deps, NotificationSystem& notifications) { @@ -319,7 +454,8 @@ static bool writeDependenciesForSource(const std::string& source, static void refreshDependencies(DependencyPanelState& state, const std::string& workspaceRoot, - NotificationSystem& notifications) { + NotificationSystem& notifications, + VulnerabilityDatabase& vulnDb) { state.deps.clear(); state.sources = discoverDependencyFiles(workspaceRoot); state.selected = -1; @@ -331,6 +467,13 @@ static void refreshDependencies(DependencyPanelState& state, auto parsed = DependencyParser::parseFile(file.path); state.deps.insert(state.deps.end(), parsed.begin(), parsed.end()); } + for (const auto& dep : state.deps) { + PackageEcosystem eco = ecosystemForSource(dep.source); + std::string osv = osvEcosystem(eco); + if (!osv.empty() && !dep.name.empty()) { + vulnDb.trackPackage(osv, dep.name); + } + } if (!state.sources.empty()) { if (state.sourceSelected < 0 || state.sourceSelected >= (int)state.sources.size()) { state.sourceSelected = 0; @@ -344,19 +487,23 @@ static void refreshDependencies(DependencyPanelState& state, static void renderDependencyPanel(DependencyPanelState& state, const std::string& workspaceRoot, - NotificationSystem& notifications) { + NotificationSystem& notifications, + VulnerabilityDatabase& vulnDb) { if (workspaceRoot.empty()) { ImGui::TextDisabled("Open a workspace to manage dependencies."); return; } + if (!state.vulnIgnoreLoaded) { + loadVulnIgnore(state); + } if (state.lastWorkspaceRoot != workspaceRoot || state.deps.empty()) { - refreshDependencies(state, workspaceRoot, notifications); + refreshDependencies(state, workspaceRoot, notifications, vulnDb); } ImGui::InputText("Search", state.searchBuf, sizeof(state.searchBuf)); ImGui::SameLine(); if (ImGui::Button("Refresh")) { - refreshDependencies(state, workspaceRoot, notifications); + refreshDependencies(state, workspaceRoot, notifications, vulnDb); } ImGui::SameLine(); if (ImGui::Button("Add Package")) { @@ -425,7 +572,7 @@ static void renderDependencyPanel(DependencyPanelState& state, } state.deps.push_back(dep); if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) { - refreshDependencies(state, workspaceRoot, notifications); + refreshDependencies(state, workspaceRoot, notifications, vulnDb); state.needsIndex = true; } } @@ -449,9 +596,35 @@ static void renderDependencyPanel(DependencyPanelState& state, std::string q = state.searchBuf; if (label.find(q) == std::string::npos) continue; } - if (ImGui::Selectable(label.c_str(), state.selected == i)) { + bool selected = (state.selected == i); + if (ImGui::Selectable(label.c_str(), selected)) { state.selected = i; } + PackageEcosystem eco = ecosystemForSource(dep.source); + std::string osv = osvEcosystem(eco); + std::string ignoreKey = vulnKey(osv, dep.name); + if (!osv.empty() && !dep.name.empty() && state.vulnIgnore.find(ignoreKey) == state.vulnIgnore.end()) { + auto vulns = vulnDb.query(osv, dep.name, dep.version); + if (!vulns.empty()) { + int maxRank = 0; + std::string maxSeverity = "Low"; + for (const auto& v : vulns) { + int rank = severityRank(v.severity); + if (rank > maxRank) { + maxRank = rank; + maxSeverity = v.severity; + } + } + ImVec2 max = ImGui::GetItemRectMax(); + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 center(max.x - 10.0f, (min.y + max.y) * 0.5f); + drawSecurityBadge(ImGui::GetWindowDrawList(), center, 10.0f, + severityColor(maxSeverity)); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Vulnerabilities detected (%s)", maxSeverity.c_str()); + } + } + } } ImGui::EndChild(); @@ -467,7 +640,7 @@ static void renderDependencyPanel(DependencyPanelState& state, if (ImGui::Button("Apply Version")) { dep.version = state.editVersionBuf; if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) { - refreshDependencies(state, workspaceRoot, notifications); + refreshDependencies(state, workspaceRoot, notifications, vulnDb); state.needsIndex = true; } } @@ -477,7 +650,7 @@ static void renderDependencyPanel(DependencyPanelState& state, state.deps.erase(state.deps.begin() + state.selected); state.selected = -1; if (writeDependenciesForSource(src, workspaceRoot, state.deps, notifications)) { - refreshDependencies(state, workspaceRoot, notifications); + refreshDependencies(state, workspaceRoot, notifications, vulnDb); state.needsIndex = true; } } @@ -488,7 +661,7 @@ static void renderDependencyPanel(DependencyPanelState& state, if (!info.versions.empty()) { dep.version = info.versions.back(); if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) { - refreshDependencies(state, workspaceRoot, notifications); + refreshDependencies(state, workspaceRoot, notifications, vulnDb); state.needsIndex = true; } } else { @@ -496,5 +669,61 @@ static void renderDependencyPanel(DependencyPanelState& state, "[deps] No version info for " + dep.name); } } + + PackageEcosystem eco = ecosystemForSource(dep.source); + std::string osv = osvEcosystem(eco); + std::string ignoreKey = vulnKey(osv, dep.name); + if (!osv.empty() && !dep.name.empty()) { + ImGui::Separator(); + ImGui::TextUnformatted("Security"); + auto vulns = vulnDb.query(osv, dep.name, dep.version); + bool ignored = state.vulnIgnore.find(ignoreKey) != state.vulnIgnore.end(); + if (ignored) { + ImGui::TextDisabled("Ignored: %s", state.vulnIgnore[ignoreKey].c_str()); + if (ImGui::Button("Unignore")) { + state.vulnIgnore.erase(ignoreKey); + saveVulnIgnore(state, notifications); + } + } else if (vulns.empty()) { + ImGui::TextDisabled("No known vulnerabilities."); + } else { + int maxRank = 0; + std::string maxSeverity = "Low"; + for (const auto& v : vulns) { + int rank = severityRank(v.severity); + if (rank > maxRank) { + maxRank = rank; + maxSeverity = v.severity; + } + } + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.2f, 1.0f), + "Vulnerabilities found (%s)", maxSeverity.c_str()); + for (const auto& v : vulns) { + ImGui::BulletText("%s: %s", v.cveId.c_str(), v.summary.c_str()); + } + std::string safeVersion = bestFixedVersion(vulns); + bool hasSafe = !safeVersion.empty(); + if (!hasSafe) ImGui::BeginDisabled(); + if (ImGui::Button("Upgrade to Safe Version")) { + dep.version = safeVersion; + if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) { + refreshDependencies(state, workspaceRoot, notifications, vulnDb); + state.needsIndex = true; + } + } + if (!hasSafe) ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::InputTextWithHint("##vulnIgnoreReason", + "Reason to ignore", + state.vulnIgnoreReason, + sizeof(state.vulnIgnoreReason)); + ImGui::SameLine(); + if (ImGui::Button("Ignore")) { + state.vulnIgnore[ignoreKey] = state.vulnIgnoreReason; + state.vulnIgnoreReason[0] = '\0'; + saveVulnIgnore(state, notifications); + } + } + } } } diff --git a/editor/src/panels/SidePanels.h b/editor/src/panels/SidePanels.h index 57948d6..7a70a00 100644 --- a/editor/src/panels/SidePanels.h +++ b/editor/src/panels/SidePanels.h @@ -83,7 +83,10 @@ static void renderDependenciesPanel(EditorState& state) { if (!state.library.showDependencyPanel) return; ImGui::Begin("Dependencies", &state.library.showDependencyPanel); ImGui::PushFont(state.uiFont); - renderDependencyPanel(state.library.dependencyPanel, state.workspaceRoot, state.notifications); + renderDependencyPanel(state.library.dependencyPanel, + state.workspaceRoot, + state.notifications, + state.library.vulnDb); ImGui::PopFont(); ImGui::End(); if (state.library.dependencyPanel.needsIndex) { diff --git a/editor/tests/step191_test.cpp b/editor/tests/step191_test.cpp new file mode 100644 index 0000000..9d89ae0 --- /dev/null +++ b/editor/tests/step191_test.cpp @@ -0,0 +1,27 @@ +// Step 191: Dependency security badges. + +#include +#include +#include + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +static void assertContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) != std::string::npos); +} + +int main() { + const std::string depPanel = readFile("src/DependencyPanel.h"); + assertContains(depPanel, "VulnerabilityDatabase"); + assertContains(depPanel, "Upgrade to Safe Version"); + assertContains(depPanel, "vuln_ignore.json"); + assertContains(depPanel, "drawSecurityBadge"); + + printf("step191_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index a9d59c2..e67a8fe 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -323,7 +323,7 @@ feature requests with polished UX. - Background refresh on editor startup (non-blocking) *New:* `VulnerabilityDatabase.h` -- [ ] **Step 191: Dependency security badges** +- [x] **Step 191: Dependency security badges** Enhance the Dependencies panel with security awareness: - Warning badge (shield icon) on packages with known vulnerabilities - Badge color by max severity: red (Critical/High), orange (Medium),