First release of open core
This commit is contained in:
147
pkg/adapters/adapters.go
Normal file
147
pkg/adapters/adapters.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package adapters
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
domain2 "epigas.gitea.cloud/RiskRancher/core/pkg/domain"
|
||||
)
|
||||
|
||||
func (h *Handler) HandleGetAdapters(w http.ResponseWriter, r *http.Request) {
|
||||
adapters, err := h.Store.GetAdapters(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(adapters)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleCreateAdapter(w http.ResponseWriter, r *http.Request) {
|
||||
var adapter domain2.Adapter
|
||||
if err := json.NewDecoder(r.Body).Decode(&adapter); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.SaveAdapter(r.Context(), adapter); err != nil {
|
||||
http.Error(w, "Failed to save adapter", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDeleteAdapter(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid adapter ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Store.DeleteAdapter(r.Context(), id); err != nil {
|
||||
http.Error(w, "Failed to delete adapter", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func getJSONValue(data interface{}, path string) interface{} {
|
||||
if path == "" || path == "." {
|
||||
return data // The root IS the array
|
||||
}
|
||||
keys := strings.Split(path, ".")
|
||||
current := data
|
||||
for _, key := range keys {
|
||||
if m, ok := current.(map[string]interface{}); ok {
|
||||
current = m[key]
|
||||
} else {
|
||||
return nil // Path broke
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func interfaceToString(val interface{}) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
return "" // Could expand this to handle ints/floats if needed
|
||||
}
|
||||
|
||||
// HandleAdapterIngest dynamically maps deeply nested JSON arrays into Tickets
|
||||
func (h *Handler) HandleAdapterIngest(w http.ResponseWriter, r *http.Request) {
|
||||
adapterName := r.PathValue("name")
|
||||
adapter, err := h.Store.GetAdapterByName(r.Context(), adapterName)
|
||||
if err != nil {
|
||||
http.Error(w, "Adapter not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var rawData interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&rawData); err != nil {
|
||||
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
findingsNode := getJSONValue(rawData, adapter.FindingsPath)
|
||||
findingsArray, ok := findingsNode.([]interface{})
|
||||
if !ok {
|
||||
http.Error(w, "Findings path did not resolve to a JSON array", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type groupKey struct {
|
||||
Source string
|
||||
Asset string
|
||||
}
|
||||
groupedTickets := make(map[groupKey][]domain2.Ticket)
|
||||
|
||||
for _, item := range findingsArray {
|
||||
finding, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ticket := domain2.Ticket{
|
||||
Source: adapter.SourceName,
|
||||
Status: "Waiting to be Triaged", // Explicitly set status
|
||||
Title: interfaceToString(finding[adapter.MappingTitle]),
|
||||
AssetIdentifier: interfaceToString(finding[adapter.MappingAsset]),
|
||||
Severity: interfaceToString(finding[adapter.MappingSeverity]),
|
||||
Description: interfaceToString(finding[adapter.MappingDescription]),
|
||||
RecommendedRemediation: interfaceToString(finding[adapter.MappingRemediation]),
|
||||
}
|
||||
|
||||
if ticket.Title != "" && ticket.AssetIdentifier != "" {
|
||||
hashInput := ticket.Source + "|" + ticket.AssetIdentifier + "|" + ticket.Title
|
||||
hash := sha256.Sum256([]byte(hashInput))
|
||||
ticket.DedupeHash = hex.EncodeToString(hash[:])
|
||||
key := groupKey{Source: ticket.Source, Asset: ticket.AssetIdentifier}
|
||||
groupedTickets[key] = append(groupedTickets[key], ticket)
|
||||
}
|
||||
}
|
||||
|
||||
for key, batch := range groupedTickets {
|
||||
err := h.Store.ProcessIngestionBatch(r.Context(), key.Source, key.Asset, batch)
|
||||
if err != nil {
|
||||
log.Printf("🔥 JSON Ingestion Error for Asset %s: %v", key.Asset, err)
|
||||
// 🚀 LOG THE BATCH FAILURE
|
||||
h.Store.LogSync(r.Context(), key.Source, "Failed", len(batch), err.Error())
|
||||
http.Error(w, "Database error processing JSON batch", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
// 🚀 LOG THE SUCCESS
|
||||
h.Store.LogSync(r.Context(), key.Source, "Success", len(batch), "")
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
142
pkg/adapters/adapters_test.go
Normal file
142
pkg/adapters/adapters_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package adapters
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"epigas.gitea.cloud/RiskRancher/core/pkg/datastore"
|
||||
"epigas.gitea.cloud/RiskRancher/core/pkg/domain"
|
||||
)
|
||||
|
||||
func setupTestAdapters(t *testing.T) (*Handler, *sql.DB) {
|
||||
db := datastore.InitDB(":memory:")
|
||||
store := datastore.NewSQLiteStore(db)
|
||||
return NewHandler(store), db
|
||||
}
|
||||
|
||||
func GetVIPCookie(store domain.Store) *http.Cookie {
|
||||
user, err := store.GetUserByEmail(context.Background(), "vip@RiskRancher.com")
|
||||
if err != nil {
|
||||
user, _ = store.CreateUser(context.Background(), "vip@RiskRancher.com", "Test VIP", "hash", "Sheriff")
|
||||
}
|
||||
|
||||
store.CreateSession(context.Background(), "vip_token_999", user.ID, time.Now().Add(1*time.Hour))
|
||||
return &http.Cookie{Name: "session_token", Value: "vip_token_999"}
|
||||
}
|
||||
|
||||
func TestHandleAdapterIngest(t *testing.T) {
|
||||
h, db := setupTestAdapters(t)
|
||||
defer db.Close()
|
||||
|
||||
adapterPayload := []byte(`{"name": "Trivy Test", "source_name": "TrivyScanner", "findings_path": "Results", "mapping_title": "VulnerabilityID", "mapping_asset": "Target", "mapping_severity": "Severity"}`)
|
||||
reqAdapter := httptest.NewRequest(http.MethodPost, "/api/adapters", bytes.NewBuffer(adapterPayload))
|
||||
reqAdapter.AddCookie(GetVIPCookie(h.Store))
|
||||
reqAdapter.Header.Set("Content-Type", "application/json")
|
||||
rrAdapter := httptest.NewRecorder()
|
||||
|
||||
h.HandleCreateAdapter(rrAdapter, reqAdapter)
|
||||
|
||||
payload := []byte(`{"SchemaVersion": 2, "Results": [{"VulnerabilityID": "CVE-1", "Target": "A", "Severity": "HIGH"}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ingest/Trivy%20Test", bytes.NewBuffer(payload))
|
||||
req.AddCookie(GetVIPCookie(h.Store))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
req.SetPathValue("name", "Trivy Test")
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleAdapterIngest(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201 Created, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdapters(t *testing.T) {
|
||||
h, db := setupTestAdapters(t)
|
||||
defer db.Close()
|
||||
|
||||
db.Exec(`INSERT INTO data_adapters (name, source_name, findings_path, mapping_title, mapping_asset, mapping_severity) VALUES ('Trivy Test', 'Trivy', 'Results', 'VulnerabilityID', 'PkgName', 'Severity')`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/adapters", nil)
|
||||
req.AddCookie(GetVIPCookie(h.Store))
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleGetAdapters(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200 OK, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdapter(t *testing.T) {
|
||||
h, db := setupTestAdapters(t)
|
||||
defer db.Close()
|
||||
|
||||
payload := []byte(`{"name": "AcmeSec", "source_name": "Acme", "findings_path": "issues", "mapping_title": "t", "mapping_asset": "a", "mapping_severity": "s"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/adapters", bytes.NewBuffer(payload))
|
||||
req.AddCookie(GetVIPCookie(h.Store))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.HandleCreateAdapter(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201 Created, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONIngestion(t *testing.T) {
|
||||
h, db := setupTestAdapters(t)
|
||||
defer db.Close()
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO data_adapters (
|
||||
id, name, source_name, findings_path,
|
||||
mapping_title, mapping_asset, mapping_severity
|
||||
) VALUES (
|
||||
998, 'NestedScanner', 'DeepScan', 'scan_data.results',
|
||||
'vuln_name', 'target_ip', 'risk_level'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to setup nested adapter: %v", err)
|
||||
}
|
||||
|
||||
payload := []byte(`{
|
||||
"metadata": { "version": "1.0" },
|
||||
"scan_data": {
|
||||
"results": [
|
||||
{
|
||||
"vuln_name": "Log4j RCE",
|
||||
"target_ip": "10.0.0.5",
|
||||
"risk_level": "Critical"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ingest/NestedScanner", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(GetVIPCookie(h.Store))
|
||||
|
||||
req.SetPathValue("name", "NestedScanner")
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleAdapterIngest(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201 Created, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var title, severity string
|
||||
err = db.QueryRow("SELECT title, severity FROM tickets WHERE source = 'DeepScan'").Scan(&title, &severity)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query ingested ticket: %v", err)
|
||||
}
|
||||
|
||||
if title != "Log4j RCE" || severity != "Critical" {
|
||||
t.Errorf("JSON Mapping failed! Expected 'Log4j RCE' / 'Critical', got '%s' / '%s'", title, severity)
|
||||
}
|
||||
}
|
||||
13
pkg/adapters/handler.go
Normal file
13
pkg/adapters/handler.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package adapters
|
||||
|
||||
import (
|
||||
"epigas.gitea.cloud/RiskRancher/core/pkg/domain"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store domain.Store
|
||||
}
|
||||
|
||||
func NewHandler(store domain.Store) *Handler {
|
||||
return &Handler{Store: store}
|
||||
}
|
||||
Reference in New Issue
Block a user