// Static-binary integrity check + CVE vulnerability scanner. // // Every binary destined for the Portable Tool Bin must pass the static // integrity check before it can be downloaded from the Cockpit WebUI. We run // an `ldd` equivalent in pure Go (parsing the ELF's PT_INTERP header) and // reject the Essence if any dynamic .so link is found — flagged as // "Dirty-Static". // // The CVE scanner queries the NVD (National Vulnerability Database) API v2 // with a local SQLite cache for offline operation. This replaces the former // hardcoded 2-entry map with a real vulnerability intelligence backend. package warding import ( "database/sql" "debug/elf" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" _ "github.com/mattn/go-sqlite3" ) // --------------------------------------------------------------------------- // Static ELF integrity verification // --------------------------------------------------------------------------- // VerifyStaticIntegrity returns nil iff `path` is a fully statically linked // ELF binary. Any PT_INTERP or PT_DYNAMIC content means the binary is // "dirty" and unsuitable for the Portable Bin. func VerifyStaticIntegrity(path string) error { f, err := elf.Open(path) if err != nil { return fmt.Errorf("warding: not an ELF: %w", err) } defer f.Close() if interp := interpreter(f); interp != "" { return fmt.Errorf("warding: dirty-static — has interpreter %q", interp) } imports, err := f.ImportedLibraries() if err != nil { return fmt.Errorf("warding: cannot read imports: %w", err) } if len(imports) > 0 { return fmt.Errorf("warding: dirty-static — imports %v", imports) } return nil } // IsStaticELF is a quick boolean wrapper used by the WebUI downloader. func IsStaticELF(path string) bool { return VerifyStaticIntegrity(path) == nil } // interpreter returns the PT_INTERP string (the dynamic linker path) if any. func interpreter(f *elf.File) string { for _, p := range f.Progs { if p.Type == elf.PT_INTERP { buf := make([]byte, p.Filesz) if _, err := p.ReadAt(buf, 0); err != nil { return "" } for i, b := range buf { if b == 0 { return string(buf[:i]) } } return string(buf) } } return "" } // FileExists is a tiny helper for the audit pipeline. func FileExists(path string) bool { _, err := os.Stat(path) return err == nil } // --------------------------------------------------------------------------- // CVE vulnerability scanner — NVD-backed with local SQLite cache // --------------------------------------------------------------------------- // CVESeverity classifies a CVE by its base score. type CVESeverity string const ( SeverityNone CVESeverity = "NONE" SeverityLow CVESeverity = "LOW" SeverityMedium CVESeverity = "MEDIUM" SeverityHigh CVESeverity = "HIGH" SeverityCritical CVESeverity = "CRITICAL" ) // CVERecord is a parsed CVE entry from the NVD. type CVERecord struct { ID string `json:"id"` Library string `json:"library"` Version string `json:"version"` Severity CVESeverity `json:"severity"` Score float64 `json:"score"` Description string `json:"description"` Published time.Time `json:"published"` LastModified time.Time `json:"last_modified"` URL string `json:"url"` Cached bool `json:"cached"` // true if from local cache } // CVEDB is the vulnerability database. It uses an embedded SQLite file // as the primary store and falls back to the NVD API v2 for misses. // // The SQLite cache is populated lazily — when a query misses, the scanner // fetches from NVD and caches the result. This means the system works fully // offline once a library has been queried once (e.g., during a build). type CVEDB struct { db *sql.DB cachePath string http *http.Client mu sync.RWMutex apiKey string // optional NVD API key (higher rate limits) } // NewCVEDB opens (or creates) the CVE database at the given path. // If cachePath is empty, it defaults to /var/lib/sorcery-go/cve.db. func NewCVEDB(cachePath string) (*CVEDB, error) { if cachePath == "" { cachePath = filepath.Join( os.Getenv("SORCERY_GO_ROOT"), "cve.db", ) if cachePath == "/cve.db" { cachePath = "/var/lib/sorcery-go/cve.db" } } if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil { return nil, fmt.Errorf("cve: mkdir %s: %w", filepath.Dir(cachePath), err) } db, err := sql.Open("sqlite3", cachePath+"?_journal_mode=WAL&_busy_timeout=5000") if err != nil { return nil, fmt.Errorf("cve: open db: %w", err) } cdb := &CVEDB{ db: db, cachePath: cachePath, http: &http.Client{ Timeout: 30 * time.Second, }, apiKey: os.Getenv("NVD_API_KEY"), } if err := cdb.initSchema(); err != nil { db.Close() return nil, err } return cdb, nil } // initSchema creates the cache tables if they don't exist. func (db *CVEDB) initSchema() error { schema := ` CREATE TABLE IF NOT EXISTS cve_cache ( library TEXT NOT NULL, version TEXT NOT NULL, cve_id TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'MEDIUM', score REAL NOT NULL DEFAULT 0.0, description TEXT NOT NULL DEFAULT '', published TEXT NOT NULL DEFAULT '', modified TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', fetched_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (library, version, cve_id) ); CREATE INDEX IF NOT EXISTS idx_cve_library ON cve_cache(library); CREATE INDEX IF NOT EXISTS idx_cve_libver ON cve_cache(library, version); CREATE INDEX IF NOT EXISTS idx_cve_severity ON cve_cache(severity); CREATE TABLE IF NOT EXISTS cve_query_log ( library TEXT NOT NULL, version TEXT NOT NULL, queried_at TEXT NOT NULL DEFAULT (datetime('now')), hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (library, version) ); ` _, err := db.db.Exec(schema) return err } // Close closes the database connection. func (db *CVEDB) Close() error { return db.db.Close() } // HasKnownCVE checks if a library+version combination has known CVEs. // This is the drop-in replacement for the former hardcoded map. It checks // the local SQLite cache first, then falls back to the NVD API. func (db *CVEDB) HasKnownCVE(library, version string) bool { results, err := db.QueryCVEs(library, version) if err != nil { // On error (offline, API down), fall back to cache-only. return db.hasCachedCVE(library, version) } return len(results) > 0 } // QueryCVEs returns all known CVEs for a library+version, checking the // local cache first and falling back to the NVD API v2. func (db *CVEDB) QueryCVEs(library, version string) ([]CVERecord, error) { // Normalize the library name (strip lib prefix for CPE matching). cpeName := normalizeCPENAME(library) // 1. Check local cache. cached, err := db.queryCache(cpeName, version) if err == nil && len(cached) > 0 { // Mark as cached and return. for i := range cached { cached[i].Cached = true } db.logQuery(library, version, len(cached)) return cached, nil } // 2. Cache miss — query NVD API. remote, err := db.queryNVD(cpeName, version) if err != nil { // If NVD is unreachable but we have any cached results (even empty), // return those. Otherwise propagate the error. if cached != nil { return cached, nil } return nil, fmt.Errorf("cve: nvd query failed: %w", err) } // 3. Cache the results. if err := db.cacheResults(cpeName, version, remote); err != nil { // Log but don't fail — caching is best-effort. fmt.Fprintf(os.Stderr, "cve: warning: failed to cache results: %v\n", err) } db.logQuery(library, version, len(remote)) return remote, nil } // QueryCVEsBatch checks multiple library+version pairs in one call. // Useful for scanning an entire Essence's dependency tree. func (db *CVEDB) QueryCVEsBatch(pairs map[string]string) map[string][]CVERecord { results := make(map[string][]CVERecord, len(pairs)) var mu sync.Mutex var wg sync.WaitGroup // Limit concurrency to avoid NVD rate limits (5 req/30s without key, // 50 req/30s with key). sem := make(chan struct{}, 5) for lib, ver := range pairs { wg.Add(1) sem <- struct{}{} go func(lib, ver string) { defer wg.Done() defer func() { <-sem }() cves, err := db.QueryCVEs(lib, ver) mu.Lock() if err != nil { results[lib] = []CVERecord{{ID: "QUERY_ERROR", Library: lib, Version: ver, Description: err.Error()}} } else { results[lib] = cves } mu.Unlock() }(lib, ver) } wg.Wait() return results } // SeverityCount returns a breakdown of CVE counts by severity for the // given results. func SeverityCount(cves []CVERecord) map[CVESeverity]int { counts := map[CVESeverity]int{ SeverityCritical: 0, SeverityHigh: 0, SeverityMedium: 0, SeverityLow: 0, SeverityNone: 0, } for _, c := range cves { counts[c.Severity]++ } return counts } // --------------------------------------------------------------------------- // Local cache queries // --------------------------------------------------------------------------- func (db *CVEDB) queryCache(library, version string) ([]CVERecord, error) { db.mu.RLock() defer db.mu.RUnlock() rows, err := db.db.Query( `SELECT cve_id, severity, score, description, published, modified, url FROM cve_cache WHERE library = ? AND version = ? ORDER BY score DESC`, library, version, ) if err != nil { return nil, err } defer rows.Close() var results []CVERecord for rows.Next() { var r CVERecord var pubStr, modStr string if err := rows.Scan(&r.ID, &r.Severity, &r.Score, &r.Description, &pubStr, &modStr, &r.URL); err != nil { return nil, err } r.Library = library r.Version = version r.Published = parseTime(pubStr) r.LastModified = parseTime(modStr) results = append(results, r) } return results, rows.Err() } func (db *CVEDB) hasCachedCVE(library, version string) bool { db.mu.RLock() defer db.mu.RUnlock() var count int err := db.db.QueryRow( `SELECT COUNT(*) FROM cve_cache WHERE library = ? AND version = ?`, library, version, ).Scan(&count) return err == nil && count > 0 } func (db *CVEDB) cacheResults(library, version string, results []CVERecord) error { db.mu.Lock() defer db.mu.Unlock() tx, err := db.db.Begin() if err != nil { return err } defer tx.Rollback() // Delete old cache entries for this library+version. if _, err := tx.Exec(`DELETE FROM cve_cache WHERE library = ? AND version = ?`, library, version); err != nil { return err } // Insert fresh results. stmt, err := tx.Prepare( `INSERT INTO cve_cache (library, version, cve_id, severity, score, description, published, modified, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) if err != nil { return err } defer stmt.Close() for _, r := range results { _, err := stmt.Exec( library, version, r.ID, r.Severity, r.Score, r.Description, r.Published.Format(time.RFC3339), r.LastModified.Format(time.RFC3339), r.URL, ) if err != nil { return err } } return tx.Commit() } func (db *CVEDB) logQuery(library, version string, hits int) { db.mu.Lock() defer db.mu.Unlock() db.db.Exec( `INSERT OR REPLACE INTO cve_query_log (library, version, queried_at, hits) VALUES (?, ?, datetime('now'), ?)`, library, version, hits, ) } // --------------------------------------------------------------------------- // NVD API v2 client // --------------------------------------------------------------------------- // nvdResponse is the top-level NVD API v2 response. type nvdResponse struct { TotalResults int `json:"totalResults"` Vulnerabilities []nvdVuln `json:"vulnerabilities"` } // nvdVuln wraps a CVE + its CPE matches. type nvdVuln struct { CVE struct { ID string `json:"id"` Published string `json:"published"` Modified string `json:"lastModified"` Metrics struct { CVSSMetric []struct { CVSSData struct { BaseScore float64 `json:"baseScore"` BaseSeverity string `json:"baseSeverity"` } `json:"cvssData"` } `json:"cvssMetricV31"` } `json:"metrics"` Descriptions []struct { Lang string `json:"lang"` Value string `json:"value"` } `json:"descriptions"` } `json:"cve"` } // queryNVD queries the NVD API v2 for CVEs matching a CPE software name. func (db *CVEDB) queryNVD(cpeName, version string) ([]CVERecord, error) { // Build the NVD search URL. // We search by CPE name (e.g., "openssl") and filter by version. u := fmt.Sprintf( "https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=%s&resultsPerPage=40", url.QueryEscape(cpeName), ) req, err := http.NewRequest("GET", u, nil) if err != nil { return nil, fmt.Errorf("cve: build request: %w", err) } req.Header.Set("Accept", "application/json") if db.apiKey != "" { req.Header.Set("apiKey", db.apiKey) } resp, err := db.http.Do(req) if err != nil { return nil, fmt.Errorf("cve: nvd request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { return nil, fmt.Errorf("cve: nvd rate limit exceeded (set NVD_API_KEY for higher limits)") } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("cve: nvd returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) } var nvd nvdResponse if err := json.NewDecoder(resp.Body).Decode(&nvd); err != nil { return nil, fmt.Errorf("cve: decode nvd response: %w", err) } // Filter and parse results. var results []CVERecord for _, v := range nvd.Vulnerabilities { cve := v.CVE // Extract the English description. var desc string for _, d := range cve.Descriptions { if d.Lang == "en" { desc = d.Value break } } // Get the best CVSS score (v3.1 preferred). var score float64 var severity string if len(cve.Metrics.CVSSMetric) > 0 { score = cve.Metrics.CVSSMetric[0].CVSSData.BaseScore severity = cve.Metrics.CVSSMetric[0].CVSSData.BaseSeverity } // Check if the version appears in the CVE description or CPE match. // NVD doesn't return CPE match details in the basic query, so we // do a substring match on the description as a heuristic. if version != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(version)) { // For exact version matching, we'd need the /cves/2.0?cpeName= // endpoint with the full CPE string. This is a best-effort filter. // We include the CVE if the version matches OR if we can't tell // (better to over-report than miss a vulnerability). continue } results = append(results, CVERecord{ ID: cve.ID, Library: cpeName, Version: version, Severity: CVESeverity(strings.ToUpper(severity)), Score: score, Description: desc, Published: parseTime(cve.Published), LastModified: parseTime(cve.Modified), URL: fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", cve.ID), }) } return results, nil } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // normalizeCPENAME converts a library name to a CPE-friendly form. // e.g., "libcurl" -> "curl", "libssl" -> "openssl", "zlib" -> "zlib" func normalizeCPENAME(library string) string { lower := strings.ToLower(library) // Strip "lib" prefix for CPE matching (CPE uses the project name). if strings.HasPrefix(lower, "lib") && len(lower) > 3 { return cpeAliases[lower[3:]] } // Known library name aliases (includes lib-prefixed forms). if alias, ok := cpeAliases[lower]; ok { return alias } return lower } // cpeAliases maps known library names to their CPE canonical form. var cpeAliases = map[string]string{ // Without lib prefix. "glibc": "glibc", "musl": "musl", "openssl": "openssl", "libressl": "libressl", "zlib": "zlib", "curl": "curl", "nghttp2": "nghttp2", "libssh2": "libssh2", "brotli": "brotli", "freetype": "freetype", "fontconfig": "fontconfig", "expat": "expat", "libxml2": "libxml2", "xz": "xz", "lz4": "lz4", "zstd": "zstd", "util-linux": "util-linux", // With lib prefix — resolves after strip. "libssl": "openssl", "libcrypto": "openssl", "libcurl": "curl", "libbz2": "bzip2", "libpng": "libpng", "libjpeg": "libjpeg-turbo", "libuuid": "util-linux", "libblkid": "util-linux", } // timeFormats lists formats to try, in order of specificity. var timeFormats = []string{ time.RFC3339, "2006-01-02T15:04:05", time.RFC3339Nano, "2006-01-02", } // parseTime parses an ISO 8601 / RFC 3339 timestamp. // Returns zero time on failure. func parseTime(s string) time.Time { for _, fmt := range timeFormats { if t, err := time.Parse(fmt, s); err == nil { return t } } return time.Time{} } // HasKnownCVE is the backward-compatible entry point used by the Warding's // Sentinel audit. It delegates to the global CVEDB instance. // // If no CVEDB has been initialized (nil), it falls back to a tiny embedded // table of known-critical CVEs — this ensures the function never panics // even during early boot before the DB is opened. var globalCVEDB *CVEDB var globalCVEDBOnce sync.Once // SetGlobalCVEDB sets the shared CVE database instance. Call this once at // startup (typically in cmd/sorcery/main.go). func SetGlobalCVEDB(db *CVEDB) { globalCVEDB = db } // HasKnownCVE checks if a library+version has known CVEs using the global // DB (if initialized) or a minimal embedded table (fallback). func HasKnownCVE(library, version string) bool { if globalCVEDB != nil { return globalCVEDB.HasKnownCVE(library, version) } // Fallback embedded table — only the most critical known-vuln versions. // This is the safety net for deployments that never call SetGlobalCVEDB. known := map[string]map[string]bool{ "openssl": {"1.1.1k": true, "1.1.1j": true, "1.0.2u": true}, "curl": {"7.79.0": true, "7.71.0": true}, "zlib": {"1.2.11": true}, "libc": {"2.31": true, "2.34": true}, // glibc "sudo": {"1.9.5p2": true}, } if v, ok := known[library]; ok { return v[version] } if v, ok := known[normalizeCPENAME(library)]; ok { return v[version] } return false } // BatchCheckDependencies is a convenience function that takes a map of // library->version (as produced by the Cast pipeline's DEPENDS parsing) // and returns all libraries with known CVEs. func BatchCheckDependencies(deps map[string]string) map[string][]CVERecord { if globalCVEDB != nil { return globalCVEDB.QueryCVEsBatch(deps) } // Fallback: use HasKnownCVE for each dep. results := make(map[string][]CVERecord) for lib, ver := range deps { if HasKnownCVE(lib, ver) { results[lib] = []CVERecord{{ ID: "EMBEDDED_CHECK", Library: lib, Version: ver, Severity: SeverityMedium, Description: "Vulnerable version detected by embedded table (upgrade NVD for full details)", }} } } return results }