From 325b7455c5940e552f7f8fd9ed700d56569dbf68 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 15 Jul 2026 09:12:24 +0530 Subject: [PATCH] chore: remove undocumented static-analysis files that duplicate sight's scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dependency_check.go, symbol_search.go, api_security.go, sbom.go, and archive.go all performed source/manifest-level static analysis — exactly what this repo's own README says belongs to sight ("Source diff review, code conventions, and repository static analysis belong to sight... do not import other engines"). Confirmed unreachable from both the CLI (none exists) and the MCP server (mcp/server.go only wires inspect_scan/ inspect_scan_dir to the website-crawling Scanner) — dead code, not just undocumented. dependency_check.go additionally hardcoded ~33 CVEs with no live-feed mechanism, which would have gone stale silently. Also removes ConvertArchiveToEntries from findings_store.go — the only caller of the now-deleted Archive type, and itself only ever called from its own test. --- api_security.go | 585 ----------------------------------- api_security_ext_test.go | 362 ---------------------- api_security_test.go | 514 ------------------------------ archive.go | 228 -------------- archive_test.go | 305 ------------------ dependency_check.go | 397 ------------------------ dependency_check_ext_test.go | 323 ------------------- dependency_check_test.go | 423 ------------------------- findings_store.go | 28 -- findings_store_test.go | 84 ----- sbom.go | 232 -------------- symbol_search.go | 268 ---------------- symbol_search_test.go | 75 ----- 13 files changed, 3824 deletions(-) delete mode 100644 api_security.go delete mode 100644 api_security_ext_test.go delete mode 100644 api_security_test.go delete mode 100644 archive.go delete mode 100644 archive_test.go delete mode 100644 dependency_check.go delete mode 100644 dependency_check_ext_test.go delete mode 100644 dependency_check_test.go delete mode 100644 sbom.go delete mode 100644 symbol_search.go delete mode 100644 symbol_search_test.go diff --git a/api_security.go b/api_security.go deleted file mode 100644 index f4f77cf..0000000 --- a/api_security.go +++ /dev/null @@ -1,585 +0,0 @@ -package inspect - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// APISecurityChecker performs security audits against REST API endpoints. -type APISecurityChecker struct { - BaseURL string - Headers map[string]string - Timeout time.Duration -} - -// NewAPISecurityChecker creates an APISecurityChecker with sensible defaults. -func NewAPISecurityChecker(baseURL string) *APISecurityChecker { - return &APISecurityChecker{ - BaseURL: strings.TrimRight(baseURL, "/"), - Headers: make(map[string]string), - Timeout: 10 * time.Second, - } -} - -// JWTClaims represents decoded JWT claims for analysis. -type JWTClaims struct { - Header map[string]interface{} - Payload map[string]interface{} -} - -// httpClient returns a configured HTTP client. -func (a *APISecurityChecker) httpClient() *http.Client { - return &http.Client{ - Timeout: a.Timeout, - } -} - -// newRequest creates a request with the configured headers. -func (a *APISecurityChecker) newRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return nil, err - } - for k, v := range a.Headers { - req.Header.Set(k, v) - } - return req, nil -} - -// CheckCORS tests for CORS misconfiguration by sending requests with various -// Origin headers and checking if the server reflects arbitrary origins. -func (a *APISecurityChecker) CheckCORS(url string) []Finding { - var findings []Finding - client := a.httpClient() - - testOrigins := []string{ - "https://evil.com", - "https://attacker.example.org", - "null", - } - - for _, origin := range testOrigins { - req, err := a.newRequest(context.Background(), "GET", url, nil) - if err != nil { - continue - } - req.Header.Set("Origin", origin) - - resp, err := client.Do(req) - if err != nil { - continue - } - _ = resp.Body.Close() - - acao := resp.Header.Get("Access-Control-Allow-Origin") - acac := resp.Header.Get("Access-Control-Allow-Credentials") - - if acao == "*" && acac == "true" { - findings = append(findings, Finding{ - Check: "api-cors", - Severity: SeverityCritical, - URL: url, - Message: "CORS allows all origins with credentials", - Evidence: fmt.Sprintf("Access-Control-Allow-Origin: %s, Access-Control-Allow-Credentials: %s", acao, acac), - Fix: "Restrict Access-Control-Allow-Origin to specific trusted domains and never combine wildcard with credentials", - }) - break - } - - if acao == origin && origin != "null" { - sev := SeverityMedium - if acac == "true" { - sev = SeverityHigh - } - findings = append(findings, Finding{ - Check: "api-cors", - Severity: sev, - URL: url, - Message: fmt.Sprintf("CORS reflects arbitrary origin: %s", origin), - Evidence: fmt.Sprintf("Access-Control-Allow-Origin: %s", acao), - Fix: "Validate Origin against a whitelist of trusted domains", - }) - } - - if acao == "null" { - findings = append(findings, Finding{ - Check: "api-cors", - Severity: SeverityMedium, - URL: url, - Message: "CORS allows null origin", - Evidence: "Access-Control-Allow-Origin: null", - Fix: "Do not allow null origin; use specific domain whitelist", - }) - } - } - - return findings -} - -// CheckRateLimiting sends rapid requests to detect missing rate limiting. -func (a *APISecurityChecker) CheckRateLimiting(url string) []Finding { - var findings []Finding - client := a.httpClient() - - const requestCount = 20 - successCount := 0 - var rateLimitHeaderFound bool - - for i := 0; i < requestCount; i++ { - req, err := a.newRequest(context.Background(), "GET", url, nil) - if err != nil { - continue - } - - resp, err := client.Do(req) - if err != nil { - continue - } - _ = resp.Body.Close() - - if resp.StatusCode == http.StatusTooManyRequests { - rateLimitHeaderFound = true - break - } - - // Check for rate limit headers - if resp.Header.Get("X-RateLimit-Limit") != "" || - resp.Header.Get("X-Rate-Limit-Limit") != "" || - resp.Header.Get("RateLimit-Limit") != "" || - resp.Header.Get("Retry-After") != "" { - rateLimitHeaderFound = true - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - successCount++ - } - } - - if !rateLimitHeaderFound && successCount == requestCount { - findings = append(findings, Finding{ - Check: "api-rate-limit", - Severity: SeverityMedium, - URL: url, - Message: fmt.Sprintf("No rate limiting detected after %d rapid requests", requestCount), - Evidence: fmt.Sprintf("All %d requests returned success with no rate limit headers", successCount), - Fix: "Implement rate limiting (e.g., X-RateLimit-Limit header and 429 responses)", - }) - } - - return findings -} - -// CheckAuthHeaders checks for missing security headers on API responses. -func (a *APISecurityChecker) CheckAuthHeaders(url string) []Finding { - var findings []Finding - client := a.httpClient() - - req, err := a.newRequest(context.Background(), "GET", url, nil) - if err != nil { - return findings - } - - resp, err := client.Do(req) - if err != nil { - return findings - } - _ = resp.Body.Close() - - requiredHeaders := []struct { - Name string - Severity Severity - Fix string - }{ - {"X-Content-Type-Options", SeverityMedium, "Add header: X-Content-Type-Options: nosniff"}, - {"Strict-Transport-Security", SeverityHigh, "Add header: Strict-Transport-Security: max-age=31536000; includeSubDomains"}, - {"X-Frame-Options", SeverityMedium, "Add header: X-Frame-Options: DENY or SAMEORIGIN"}, - {"Cache-Control", SeverityLow, "Add header: Cache-Control: no-store for sensitive API responses"}, - {"X-Request-Id", SeverityInfo, "Add X-Request-Id header for request tracing and debugging"}, - } - - for _, h := range requiredHeaders { - if resp.Header.Get(h.Name) == "" { - findings = append(findings, Finding{ - Check: "api-security-headers", - Severity: h.Severity, - URL: url, - Message: fmt.Sprintf("Missing security header: %s", h.Name), - Fix: h.Fix, - }) - } - } - - // Check for overly permissive cache headers on API responses - cacheControl := resp.Header.Get("Cache-Control") - if cacheControl != "" && !strings.Contains(cacheControl, "no-store") && !strings.Contains(cacheControl, "private") { - findings = append(findings, Finding{ - Check: "api-security-headers", - Severity: SeverityLow, - URL: url, - Message: "API response may be cached publicly", - Evidence: fmt.Sprintf("Cache-Control: %s", cacheControl), - Fix: "Use Cache-Control: no-store or private for API responses with sensitive data", - }) - } - - return findings -} - -// CheckVerbTampering tests unusual HTTP methods for unexpected access. -func (a *APISecurityChecker) CheckVerbTampering(url string) []Finding { - var findings []Finding - client := a.httpClient() - - dangerousMethods := []struct { - Method string - Message string - }{ - {"TRACE", "TRACE method enabled — may allow cross-site tracing (XST)"}, - {"PUT", "PUT method accepted — may allow unauthorized resource modification"}, - {"DELETE", "DELETE method accepted — may allow unauthorized resource deletion"}, - {"PATCH", "PATCH method accepted — may allow unauthorized resource modification"}, - } - - for _, m := range dangerousMethods { - req, err := a.newRequest(context.Background(), m.Method, url, nil) - if err != nil { - continue - } - - resp, err := client.Do(req) - if err != nil { - continue - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - _ = resp.Body.Close() - - // TRACE is especially dangerous if it echoes back the request - if m.Method == "TRACE" && resp.StatusCode == http.StatusOK { - findings = append(findings, Finding{ - Check: "api-verb-tampering", - Severity: SeverityHigh, - URL: url, - Message: m.Message, - Evidence: fmt.Sprintf("Status: %d", resp.StatusCode), - Fix: "Disable TRACE method on the server", - }) - continue - } - - // For other methods, flag if they return success without auth - if m.Method != "TRACE" && resp.StatusCode >= 200 && resp.StatusCode < 300 { - findings = append(findings, Finding{ - Check: "api-verb-tampering", - Severity: SeverityMedium, - URL: url, - Message: m.Message, - Evidence: fmt.Sprintf("Status: %d, Body: %s", resp.StatusCode, truncateEvidence(string(body), 80)), - Fix: fmt.Sprintf("Restrict %s method to authorized users or return 405 Method Not Allowed", m.Method), - }) - } - } - - // Check OPTIONS to see what methods are advertised - req, err := a.newRequest(context.Background(), "OPTIONS", url, nil) - if err == nil { - resp, err := client.Do(req) - if err == nil { - _ = resp.Body.Close() - allow := resp.Header.Get("Allow") - if allow == "" { - allow = resp.Header.Get("Access-Control-Allow-Methods") - } - if allow != "" { - upper := strings.ToUpper(allow) - if strings.Contains(upper, "TRACE") { - findings = append(findings, Finding{ - Check: "api-verb-tampering", - Severity: SeverityMedium, - URL: url, - Message: "OPTIONS response advertises TRACE method", - Evidence: fmt.Sprintf("Allow: %s", allow), - Fix: "Remove TRACE from allowed methods", - }) - } - } - } - } - - return findings -} - -// CheckErrorLeakage sends malformed requests and checks if error responses -// leak stack traces, internal paths, or version information. -func (a *APISecurityChecker) CheckErrorLeakage(url string) []Finding { - var findings []Finding - client := a.httpClient() - - // Patterns that indicate information leakage - leakPatterns := []struct { - Pattern string - Message string - }{ - {"at ", "Stack trace leaked in error response"}, - {"goroutine ", "Go stack trace leaked in error response"}, - {"Traceback", "Python traceback leaked in error response"}, - {"Exception in", "Exception details leaked in error response"}, - {"/usr/", "Internal file path leaked in error response"}, - {"/home/", "Internal file path leaked in error response"}, - {"/var/", "Internal file path leaked in error response"}, - {"C:\\", "Windows file path leaked in error response"}, - {"mysql", "Database information leaked in error response"}, - {"postgres", "Database information leaked in error response"}, - {"SQLSTATE", "SQL error leaked in error response"}, - {"X-Powered-By", "Server technology leaked via header"}, - } - - // Send malformed requests to trigger errors - malformedRequests := []struct { - Method string - Path string - ContentType string - Body string - }{ - {"GET", url + "/%00", "", ""}, - {"POST", url, "application/json", "{invalid json"}, - {"GET", url + "/../../../etc/passwd", "", ""}, - {"GET", url + "?id=1'OR'1'='1", "", ""}, - } - - for _, mr := range malformedRequests { - var bodyReader io.Reader - if mr.Body != "" { - bodyReader = strings.NewReader(mr.Body) - } - - req, err := a.newRequest(context.Background(), mr.Method, mr.Path, bodyReader) - if err != nil { - continue - } - if mr.ContentType != "" { - req.Header.Set("Content-Type", mr.ContentType) - } - - resp, err := client.Do(req) - if err != nil { - continue - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - _ = resp.Body.Close() - - bodyStr := string(body) - - // Check response headers for leakage - if server := resp.Header.Get("Server"); server != "" { - // Check if it reveals detailed version info - if strings.ContainsAny(server, "0123456789.") && len(server) > 5 { - findings = append(findings, Finding{ - Check: "api-error-leakage", - Severity: SeverityLow, - URL: mr.Path, - Message: "Server version information disclosed", - Evidence: fmt.Sprintf("Server: %s", server), - Fix: "Remove or genericize the Server header", - }) - } - } - if xpb := resp.Header.Get("X-Powered-By"); xpb != "" { - findings = append(findings, Finding{ - Check: "api-error-leakage", - Severity: SeverityLow, - URL: mr.Path, - Message: "X-Powered-By header discloses technology stack", - Evidence: fmt.Sprintf("X-Powered-By: %s", xpb), - Fix: "Remove the X-Powered-By header", - }) - } - - // Check body for leakage patterns - for _, lp := range leakPatterns { - if strings.Contains(bodyStr, lp.Pattern) { - findings = append(findings, Finding{ - Check: "api-error-leakage", - Severity: SeverityMedium, - URL: mr.Path, - Message: lp.Message, - Evidence: truncateEvidence(bodyStr, 120), - Fix: "Return generic error messages in production; log details server-side only", - }) - break // One finding per request is enough - } - } - } - - return findings -} - -// CheckJWTWeakness analyzes a JWT token structure for common weaknesses: -// none algorithm, weak signing, missing expiry, excessive claims. -func (a *APISecurityChecker) CheckJWTWeakness(token string) []Finding { - var findings []Finding - - claims, err := ParseJWT(token) - if err != nil { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityInfo, - URL: a.BaseURL, - Message: fmt.Sprintf("Could not parse JWT: %s", err.Error()), - }) - return findings - } - - // Check for "none" algorithm - if alg, ok := claims.Header["alg"]; ok { - algStr, _ := alg.(string) - algLower := strings.ToLower(algStr) - if algLower == "none" || algLower == "nonce" { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityCritical, - URL: a.BaseURL, - Message: "JWT uses 'none' algorithm — signature verification bypassed", - Evidence: fmt.Sprintf("alg: %s", algStr), - Fix: "Never accept tokens with alg=none; enforce RS256 or ES256", - }) - } - - // Check for weak algorithms - if algLower == "hs256" { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityLow, - URL: a.BaseURL, - Message: "JWT uses HS256 (symmetric) algorithm — consider asymmetric signing", - Evidence: fmt.Sprintf("alg: %s", algStr), - Fix: "Use RS256 or ES256 for better key management and rotation", - }) - } - } else { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityHigh, - URL: a.BaseURL, - Message: "JWT header missing 'alg' field", - Fix: "Include algorithm specification in JWT header", - }) - } - - // Check for missing expiry - if _, ok := claims.Payload["exp"]; !ok { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityHigh, - URL: a.BaseURL, - Message: "JWT missing expiration claim (exp)", - Fix: "Always include 'exp' claim with a reasonable TTL", - }) - } else { - // Check if expiry is excessively long (> 24 hours from token perspective) - if exp, ok := claims.Payload["exp"].(float64); ok { - if iat, ok := claims.Payload["iat"].(float64); ok { - duration := time.Duration(exp-iat) * time.Second - if duration > 24*time.Hour { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityLow, - URL: a.BaseURL, - Message: fmt.Sprintf("JWT has long expiry: %s", duration.String()), - Fix: "Use short-lived tokens (15min-1hr) with refresh tokens for better security", - }) - } - } - } - } - - // Check for missing issuer - if _, ok := claims.Payload["iss"]; !ok { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityLow, - URL: a.BaseURL, - Message: "JWT missing issuer claim (iss)", - Fix: "Include 'iss' claim and validate it on the server", - }) - } - - // Check for excessive claims (potential data exposure) - if len(claims.Payload) > 10 { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityLow, - URL: a.BaseURL, - Message: fmt.Sprintf("JWT contains %d claims — possible excessive data exposure", len(claims.Payload)), - Fix: "Minimize JWT payload; store detailed data server-side and reference by ID", - }) - } - - // Check for sensitive data in claims - sensitiveKeys := []string{"password", "secret", "ssn", "credit_card", "cc_number"} - for _, key := range sensitiveKeys { - if _, ok := claims.Payload[key]; ok { - findings = append(findings, Finding{ - Check: "api-jwt", - Severity: SeverityCritical, - URL: a.BaseURL, - Message: fmt.Sprintf("JWT contains sensitive data in claim: %s", key), - Fix: "Never store secrets or sensitive PII in JWT claims — they are base64 encoded, not encrypted", - }) - } - } - - return findings -} - -// ParseJWT decodes a JWT token without signature validation (for analysis purposes). -func ParseJWT(token string) (*JWTClaims, error) { - parts := strings.Split(token, ".") - if len(parts) < 2 || len(parts) > 3 { - return nil, fmt.Errorf("invalid JWT format: expected 2-3 parts, got %d", len(parts)) - } - - header, err := decodeJWTSegment(parts[0]) - if err != nil { - return nil, fmt.Errorf("invalid JWT header: %w", err) - } - - payload, err := decodeJWTSegment(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid JWT payload: %w", err) - } - - return &JWTClaims{Header: header, Payload: payload}, nil -} - -// decodeJWTSegment decodes a base64url-encoded JWT segment into a map. -func decodeJWTSegment(segment string) (map[string]interface{}, error) { - // Add padding if needed - switch len(segment) % 4 { - case 2: - segment += "==" - case 3: - segment += "=" - } - - data, err := base64.URLEncoding.DecodeString(segment) - if err != nil { - // Try standard encoding as fallback - data, err = base64.StdEncoding.DecodeString(segment) - if err != nil { - return nil, err - } - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return nil, err - } - - return result, nil -} diff --git a/api_security_ext_test.go b/api_security_ext_test.go deleted file mode 100644 index 5b23686..0000000 --- a/api_security_ext_test.go +++ /dev/null @@ -1,362 +0,0 @@ -package inspect - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestCheckRateLimiting_RateLimitLimitHeader(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("RateLimit-Limit", "100") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckRateLimiting(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings when RateLimit-Limit header present, got %d", len(findings)) - } -} - -func TestCheckRateLimiting_RetryAfterHeader(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckRateLimiting(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings when Retry-After header present, got %d", len(findings)) - } -} - -func TestCheckCORS_NoOriginSent(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Don't set any CORS headers - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckCORS(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings when no CORS headers returned, got %d", len(findings)) - } -} - -func TestCheckVerbTampering_OptionsAdvertisesTrace(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "GET": - w.WriteHeader(http.StatusOK) - case "TRACE": - w.WriteHeader(http.StatusMethodNotAllowed) - case "OPTIONS": - w.Header().Set("Allow", "GET, POST, TRACE") - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckVerbTampering(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "OPTIONS") && strings.Contains(f.Message, "TRACE") { - found = true - } - } - if !found { - t.Error("expected finding for OPTIONS advertising TRACE method") - } -} - -func TestCheckVerbTampering_PUTAccepted(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PUT": - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "created") - case "OPTIONS": - w.Header().Set("Allow", "GET, POST") - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckVerbTampering(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "PUT") && f.Severity == SeverityMedium { - found = true - } - } - if !found { - t.Error("expected medium severity finding for accepted PUT method") - } -} - -func TestCheckErrorLeakage_SQLLeak(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, "SQLSTATE[HY000]: General error: 1 table users does not exist") - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckErrorLeakage(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "SQL") { - found = true - } - } - if !found { - t.Error("expected finding for SQL error leakage") - } -} - -func TestCheckErrorLeakage_ServerVersionHeader(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Server", "nginx/1.21.6") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckErrorLeakage(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "Server version") || strings.Contains(f.Message, "Server") { - found = true - } - } - if !found { - t.Error("expected finding for server version disclosure") - } -} - -func TestCheckAuthHeaders_PublicCache(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "public, max-age=3600") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckAuthHeaders(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "cached publicly") { - found = true - } - } - if !found { - t.Error("expected finding for publicly cacheable API response") - } -} - -func TestCheckJWTWeakness_MissingAlg(t *testing.T) { - // JWT header without alg field - header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","exp":9999999999}`)) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "missing") && strings.Contains(f.Message, "alg") { - found = true - } - } - if !found { - t.Error("expected finding for missing alg field in JWT header") - } -} - -func TestCheckJWTWeakness_MissingIssuer(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","exp":9999999999}`)) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "issuer") { - found = true - } - } - if !found { - t.Error("expected finding for missing issuer claim") - } -} - -func TestCheckJWTWeakness_CleanToken(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","exp":9999999999,"iss":"auth.example.com"}`)) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - // Should have no findings for a clean token - if len(findings) != 0 { - t.Errorf("expected no findings for clean RS256 token, got %d: %+v", len(findings), findings) - } -} - -func TestParseJWT_TwoParts(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1"}`)) - token := header + "." + payload - - claims, err := ParseJWT(token) - if err != nil { - t.Fatalf("unexpected error for 2-part JWT: %v", err) - } - if claims.Header["alg"] != "RS256" { - t.Errorf("expected alg RS256, got %v", claims.Header["alg"]) - } -} - -func TestCheckErrorLeakage_PythonTraceback(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, "Traceback (most recent call last):\n File \"/app/main.py\", line 42") - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckErrorLeakage(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "Python traceback") || strings.Contains(f.Message, "Traceback") { - found = true - } - } - if !found { - t.Error("expected finding for Python traceback leakage") - } -} - -func TestCheckErrorLeakage_GoStackTrace(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, "goroutine 1 [running]:\nmain.handler()\n\t/app/main.go:42 +0x1234") - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckErrorLeakage(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "Go stack trace") || strings.Contains(f.Message, "goroutine") { - found = true - } - } - if !found { - t.Error("expected finding for Go stack trace leakage") - } -} - -func TestAPISecurityChecker_Timeout(t *testing.T) { - checker := NewAPISecurityChecker("https://api.example.com") - if checker.Timeout != 10*1e9 { // 10 seconds in nanoseconds - // Just verify it's set; exact comparison depends on time.Duration - if checker.Timeout == 0 { - t.Error("expected non-zero timeout") - } - } -} - -func TestCheckJWTWeakness_SensitiveDataCreditCard(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) - payloadData := map[string]interface{}{ - "sub": "1234", - "exp": float64(9999999999), - "iss": "auth", - "cc_number": "4111111111111111", - } - payloadBytes, _ := json.Marshal(payloadData) - payload := base64.RawURLEncoding.EncodeToString(payloadBytes) - token := header + "." + payload + ".sig" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if f.Severity == SeverityCritical && strings.Contains(f.Message, "cc_number") { - found = true - } - } - if !found { - t.Error("expected critical finding for credit card in JWT claims") - } -} - -func TestCheckCORS_MultipleOriginsReflected(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - if origin != "" { - w.Header().Set("Access-Control-Allow-Origin", origin) - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckCORS(server.URL + "/api") - - // Should find at least one reflected origin finding - if len(findings) == 0 { - t.Error("expected CORS findings for reflected origins") - } -} - -func TestCheckRateLimiting_PartialRateLimit(t *testing.T) { - requestCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ - if requestCount > 10 { - w.WriteHeader(http.StatusTooManyRequests) - return - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckRateLimiting(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings when 429 is returned, got %d", len(findings)) - } -} diff --git a/api_security_test.go b/api_security_test.go deleted file mode 100644 index a44cfec..0000000 --- a/api_security_test.go +++ /dev/null @@ -1,514 +0,0 @@ -package inspect - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestNewAPISecurityChecker(t *testing.T) { - checker := NewAPISecurityChecker("https://api.example.com/") - if checker.BaseURL != "https://api.example.com" { - t.Errorf("expected trailing slash stripped, got %s", checker.BaseURL) - } - if checker.Timeout != 10*time.Second { - t.Errorf("expected 10s timeout, got %s", checker.Timeout) - } - if checker.Headers == nil { - t.Error("expected non-nil Headers map") - } -} - -func TestCheckCORS_ReflectsOrigin(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - if origin != "" { - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Credentials", "true") - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckCORS(server.URL + "/api") - - if len(findings) == 0 { - t.Fatal("expected CORS findings for reflected origin") - } - - found := false - for _, f := range findings { - if f.Check == "api-cors" && f.Severity == SeverityHigh { - found = true - break - } - } - if !found { - t.Error("expected high severity finding for reflected origin with credentials") - } -} - -func TestCheckCORS_WildcardWithCredentials(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckCORS(server.URL + "/api") - - if len(findings) == 0 { - t.Fatal("expected CORS findings for wildcard with credentials") - } - - if findings[0].Severity != SeverityCritical { - t.Errorf("expected critical severity, got %s", findings[0].Severity) - } -} - -func TestCheckCORS_NullOrigin(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - if origin == "null" { - w.Header().Set("Access-Control-Allow-Origin", "null") - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckCORS(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "null origin") { - found = true - break - } - } - if !found { - t.Error("expected finding for null origin acceptance") - } -} - -func TestCheckCORS_Secure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Do not reflect origin — secure setup - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckCORS(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings for secure CORS, got %d", len(findings)) - } -} - -func TestCheckRateLimiting_NoLimiting(t *testing.T) { - requestCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckRateLimiting(server.URL + "/api") - - if len(findings) == 0 { - t.Fatal("expected rate limiting finding") - } - if findings[0].Check != "api-rate-limit" { - t.Errorf("expected check api-rate-limit, got %s", findings[0].Check) - } -} - -func TestCheckRateLimiting_WithRateLimiting(t *testing.T) { - requestCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ - if requestCount > 5 { - w.WriteHeader(http.StatusTooManyRequests) - return - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckRateLimiting(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings when rate limiting is active, got %d", len(findings)) - } -} - -func TestCheckRateLimiting_WithHeaders(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-RateLimit-Limit", "100") - w.Header().Set("X-RateLimit-Remaining", "99") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckRateLimiting(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings when rate limit headers present, got %d", len(findings)) - } -} - -func TestCheckAuthHeaders_MissingHeaders(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckAuthHeaders(server.URL + "/api") - - if len(findings) == 0 { - t.Fatal("expected findings for missing security headers") - } - - // Should detect missing X-Content-Type-Options, Strict-Transport-Security, etc. - headerNames := make(map[string]bool) - for _, f := range findings { - if strings.Contains(f.Message, "X-Content-Type-Options") { - headerNames["X-Content-Type-Options"] = true - } - if strings.Contains(f.Message, "Strict-Transport-Security") { - headerNames["Strict-Transport-Security"] = true - } - } - - if !headerNames["X-Content-Type-Options"] { - t.Error("expected finding for missing X-Content-Type-Options") - } - if !headerNames["Strict-Transport-Security"] { - t.Error("expected finding for missing Strict-Transport-Security") - } -} - -func TestCheckAuthHeaders_AllPresent(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Strict-Transport-Security", "max-age=31536000") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Request-Id", "abc123") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckAuthHeaders(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings when all headers present, got %d: %+v", len(findings), findings) - } -} - -func TestCheckVerbTampering_TraceEnabled(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "TRACE" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "TRACE echoed") - return - } - if r.Method == "OPTIONS" { - w.Header().Set("Allow", "GET, POST, TRACE") - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusMethodNotAllowed) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckVerbTampering(server.URL + "/api") - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "TRACE") && f.Severity == SeverityHigh { - found = true - break - } - } - if !found { - t.Error("expected high severity finding for TRACE enabled") - } -} - -func TestCheckVerbTampering_Secure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "GET": - w.WriteHeader(http.StatusOK) - case "OPTIONS": - w.Header().Set("Allow", "GET, POST") - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckVerbTampering(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings for secure verb handling, got %d: %+v", len(findings), findings) - } -} - -func TestCheckErrorLeakage_StackTrace(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Powered-By", "Express 4.18.2") - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, "Error at /usr/local/app/server.js:42\n at Function.handle (/usr/local/app/node_modules/express/lib/router/index.js:174:3)") - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckErrorLeakage(server.URL + "/api") - - if len(findings) == 0 { - t.Fatal("expected findings for leaked stack trace") - } - - foundXPB := false - foundPath := false - for _, f := range findings { - if strings.Contains(f.Message, "X-Powered-By") { - foundXPB = true - } - if strings.Contains(f.Message, "file path") || strings.Contains(f.Message, "Stack trace") { - foundPath = true - } - } - if !foundXPB { - t.Error("expected finding for X-Powered-By header") - } - if !foundPath { - t.Error("expected finding for leaked file path or stack trace") - } -} - -func TestCheckErrorLeakage_Clean(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"error": "Bad Request", "message": "Invalid input"}`) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - findings := checker.CheckErrorLeakage(server.URL + "/api") - - if len(findings) != 0 { - t.Errorf("expected no findings for clean error responses, got %d: %+v", len(findings), findings) - } -} - -func TestCheckJWTWeakness_NoneAlgorithm(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","name":"test"}`)) - token := header + "." + payload + "." - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if f.Severity == SeverityCritical && strings.Contains(f.Message, "none") { - found = true - break - } - } - if !found { - t.Error("expected critical finding for none algorithm") - } -} - -func TestCheckJWTWeakness_MissingExpiry(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","iss":"auth.example.com"}`)) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "expiration") { - found = true - break - } - } - if !found { - t.Error("expected finding for missing expiry claim") - } -} - -func TestCheckJWTWeakness_SensitiveData(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","exp":9999999999,"iss":"auth","password":"secret123"}`)) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if f.Severity == SeverityCritical && strings.Contains(f.Message, "password") { - found = true - break - } - } - if !found { - t.Error("expected critical finding for password in JWT claims") - } -} - -func TestCheckJWTWeakness_LongExpiry(t *testing.T) { - now := time.Now().Unix() - iat := now - exp := now + 7*24*3600 // 7 days - - payloadData := map[string]interface{}{ - "sub": "1234", - "iss": "auth.example.com", - "iat": iat, - "exp": exp, - } - payloadBytes, _ := json.Marshal(payloadData) - - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString(payloadBytes) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "long expiry") { - found = true - break - } - } - if !found { - t.Error("expected finding for long expiry JWT") - } -} - -func TestCheckJWTWeakness_HS256(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","exp":9999999999,"iss":"auth"}`)) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "HS256") { - found = true - break - } - } - if !found { - t.Error("expected finding for HS256 usage") - } -} - -func TestParseJWT_Valid(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234","name":"John"}`)) - token := header + "." + payload + ".signature" - - claims, err := ParseJWT(token) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if claims.Header["alg"] != "RS256" { - t.Errorf("expected alg RS256, got %v", claims.Header["alg"]) - } - if claims.Payload["sub"] != "1234" { - t.Errorf("expected sub 1234, got %v", claims.Payload["sub"]) - } -} - -func TestParseJWT_Invalid(t *testing.T) { - _, err := ParseJWT("not.a.valid.jwt.token") - if err == nil { - t.Error("expected error for invalid JWT") - } - - _, err = ParseJWT("single") - if err == nil { - t.Error("expected error for single-part token") - } -} - -func TestCheckJWTWeakness_ExcessiveClaims(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) - - claims := map[string]interface{}{ - "sub": "1234", "exp": 9999999999, "iss": "auth", - "claim1": "v1", "claim2": "v2", "claim3": "v3", - "claim4": "v4", "claim5": "v5", "claim6": "v6", - "claim7": "v7", "claim8": "v8", - } - payloadBytes, _ := json.Marshal(claims) - payload := base64.RawURLEncoding.EncodeToString(payloadBytes) - token := header + "." + payload + ".signature" - - checker := NewAPISecurityChecker("https://api.example.com") - findings := checker.CheckJWTWeakness(token) - - found := false - for _, f := range findings { - if strings.Contains(f.Message, "claims") && strings.Contains(f.Message, "excessive") { - found = true - break - } - } - if !found { - t.Error("expected finding for excessive claims") - } -} - -func TestAPISecurityChecker_CustomHeaders(t *testing.T) { - var receivedAuth string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedAuth = r.Header.Get("Authorization") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Strict-Transport-Security", "max-age=31536000") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Request-Id", "test") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - checker := NewAPISecurityChecker(server.URL) - checker.Headers["Authorization"] = "Bearer test-token" - checker.CheckAuthHeaders(server.URL + "/api") - - if receivedAuth != "Bearer test-token" { - t.Errorf("expected custom auth header to be sent, got %q", receivedAuth) - } -} diff --git a/archive.go b/archive.go deleted file mode 100644 index 8d12652..0000000 --- a/archive.go +++ /dev/null @@ -1,228 +0,0 @@ -package inspect - -import ( - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// ArchiveVersion is the current archive format version. -const ArchiveVersion = "1.0" - -// Archive is a structured container for scan results, designed for -// durable storage and interoperability between components. -type Archive struct { - // Version is the archive format version for forward compatibility. - Version string `json:"version"` - - // CreatedAt is when this archive was created. - CreatedAt time.Time `json:"created_at"` - - // ScanID is a unique identifier for the originating scan. - ScanID string `json:"scan_id"` - - // Metadata holds summary-level information about the scan. - Metadata ArchiveMetadata `json:"metadata"` - - // Results contains each individual scan entry. - Results []ArchiveEntry `json:"results"` -} - -// ArchiveMetadata captures high-level information about the scan that -// produced this archive. -type ArchiveMetadata struct { - // Host is the target hostname or IP. - Host string `json:"host"` - - // ScanDuration is how long the scan took. - ScanDuration time.Duration `json:"scan_duration"` - - // TotalURLs is the number of URLs that were attempted. - TotalURLs int `json:"total_urls"` - - // SuccessfulURLs is the count of URLs that returned a response. - SuccessfulURLs int `json:"successful_urls"` - - // FailedURLs is the count of URLs that returned an error. - FailedURLs int `json:"failed_urls"` - - // ToolVersion identifies the scanning tool version. - ToolVersion string `json:"tool_version"` -} - -// ArchiveEntry represents one scanned URL and its response. -type ArchiveEntry struct { - // URL is the fully-qualified URL that was scanned. - URL string `json:"url"` - - // StatusCode is the HTTP response status code. Zero indicates no response. - StatusCode int `json:"status_code"` - - // ContentType is the value of the Content-Type header. - ContentType string `json:"content_type"` - - // BodyHash is the SHA-256 hex digest of the full response body. - BodyHash string `json:"body_hash"` - - // Headers are the full set of response headers. - Headers http.Header `json:"headers"` - - // BodySnippet is the first 1 KB of the response body, useful for - // quick inspection without loading the full payload. - BodySnippet string `json:"body_snippet"` - - // Error holds the string representation of any error encountered - // during the request. Empty when the request succeeded. - Error string `json:"error,omitempty"` - - // ScannedAt is when this URL was scanned. - ScannedAt time.Time `json:"scanned_at"` - - // Tags are user-defined labels for this entry (e.g. "interesting", "redirect"). - Tags []string `json:"tags,omitempty"` -} - -// ArchiveSummary provides aggregate counts from an archive's results. -type ArchiveSummary struct { - // TotalEntries is the number of results in the archive. - TotalEntries int `json:"total_entries"` - - // StatusCounts maps each HTTP status code to its occurrence count. - StatusCounts map[int]int `json:"status_counts"` - - // ContentTypeCounts maps each Content-Type to its occurrence count. - ContentTypeCounts map[string]int `json:"content_type_counts"` - - // ErrorCount is how many entries have a non-empty Error field. - ErrorCount int `json:"error_count"` - - // ErrorRate is ErrorCount divided by TotalEntries (0.0 when empty). - ErrorRate float64 `json:"error_rate"` -} - -// NewArchive creates a new Archive with the given scan ID and host, -// initialised to version 1.0 and the current time. -func NewArchive(scanID, host string) *Archive { - return &Archive{ - Version: ArchiveVersion, - CreatedAt: time.Now().UTC(), - ScanID: scanID, - Metadata: ArchiveMetadata{ - Host: host, - }, - Results: make([]ArchiveEntry, 0), - } -} - -// AddEntry appends a scan result to the archive. -func (a *Archive) AddEntry(entry ArchiveEntry) { - a.Results = append(a.Results, entry) -} - -// SetMetadata replaces the archive's metadata. -func (a *Archive) SetMetadata(meta ArchiveMetadata) { - a.Metadata = meta -} - -// WriteTo writes the archive as gzipped JSON to w. -// It implements the io.WriterTo interface. -func (a *Archive) WriteTo(w io.Writer) (int64, error) { - gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) - if err != nil { - return 0, fmt.Errorf("inspect: creating gzip writer: %w", err) - } - defer gz.Close() - - cw := &countingWriter{w: gz} - enc := json.NewEncoder(cw) - enc.SetIndent("", " ") - if err := enc.Encode(a); err != nil { - return cw.n, fmt.Errorf("inspect: encoding archive: %w", err) - } - if err := gz.Close(); err != nil { - return cw.n, fmt.Errorf("inspect: closing gzip writer: %w", err) - } - return cw.n, nil -} - -// countingWriter wraps an io.Writer and counts bytes written through it. -type countingWriter struct { - w io.Writer - n int64 -} - -func (cw *countingWriter) Write(p []byte) (int, error) { - n, err := cw.w.Write(p) - cw.n += int64(n) - return n, err -} - -// ReadArchive reads a gzipped JSON archive from r. -func ReadArchive(r io.Reader) (*Archive, error) { - gz, err := gzip.NewReader(r) - if err != nil { - return nil, fmt.Errorf("inspect: creating gzip reader: %w", err) - } - defer gz.Close() - - var a Archive - dec := json.NewDecoder(gz) - if err := dec.Decode(&a); err != nil { - return nil, fmt.Errorf("inspect: decoding archive: %w", err) - } - return &a, nil -} - -// Summary returns an ArchiveSummary computed from the current results. -func (a *Archive) Summary() ArchiveSummary { - s := ArchiveSummary{ - TotalEntries: len(a.Results), - StatusCounts: make(map[int]int), - ContentTypeCounts: make(map[string]int), - } - - for _, e := range a.Results { - s.StatusCounts[e.StatusCode]++ - - ct := e.ContentType - if ct == "" { - ct = "unknown" - } - s.ContentTypeCounts[ct]++ - - if e.Error != "" { - s.ErrorCount++ - } - } - - if s.TotalEntries > 0 { - s.ErrorRate = float64(s.ErrorCount) / float64(s.TotalEntries) - } - - return s -} - -// BodyHash returns the SHA-256 hex digest of data. -func BodyHash(data []byte) string { - h := sha256.Sum256(data) - return fmt.Sprintf("%x", h) -} - -// TruncateBody returns the first maxLen bytes of data as a string. -// When data exceeds maxLen, it is truncated and "..." is appended. -// The caller is responsible for ensuring data is valid UTF-8 if -// they expect text output; this function treats the bytes opaquely. -func TruncateBody(data []byte, maxLen int) string { - if maxLen < 0 { - maxLen = 0 - } - if len(data) <= maxLen { - return string(data) - } - return string(bytes.TrimRight(data[:maxLen], "\x00")) + "..." -} diff --git a/archive_test.go b/archive_test.go deleted file mode 100644 index 60d8d3f..0000000 --- a/archive_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package inspect - -import ( - "bytes" - "compress/gzip" - "net/http" - "testing" - "time" -) - -func TestNewArchive(t *testing.T) { - a := NewArchive("scan-123", "example.com") - - if a.Version != ArchiveVersion { - t.Errorf("expected version %q, got %q", ArchiveVersion, a.Version) - } - if a.ScanID != "scan-123" { - t.Errorf("expected scan ID %q, got %q", "scan-123", a.ScanID) - } - if a.Metadata.Host != "example.com" { - t.Errorf("expected host %q, got %q", "example.com", a.Metadata.Host) - } - if a.Results == nil { - t.Error("expected Results to be initialized, got nil") - } -} - -func TestAddEntry(t *testing.T) { - a := NewArchive("scan-1", "test.com") - - entry := ArchiveEntry{ - URL: "https://test.com/page", - StatusCode: 200, - ScannedAt: time.Now().UTC(), - } - a.AddEntry(entry) - - if len(a.Results) != 1 { - t.Fatalf("expected 1 result, got %d", len(a.Results)) - } - if a.Results[0].URL != "https://test.com/page" { - t.Errorf("expected URL %q, got %q", "https://test.com/page", a.Results[0].URL) - } - if a.Results[0].StatusCode != 200 { - t.Errorf("expected status code 200, got %d", a.Results[0].StatusCode) - } -} - -func TestSetMetadata(t *testing.T) { - a := NewArchive("scan-1", "test.com") - - meta := ArchiveMetadata{ - Host: "updated.com", - ScanDuration: 5 * time.Second, - TotalURLs: 100, - SuccessfulURLs: 90, - FailedURLs: 10, - ToolVersion: "1.0.0", - } - a.SetMetadata(meta) - - if a.Metadata.Host != "updated.com" { - t.Errorf("expected host %q, got %q", "updated.com", a.Metadata.Host) - } - if a.Metadata.ScanDuration != 5*time.Second { - t.Errorf("expected scan duration %v, got %v", 5*time.Second, a.Metadata.ScanDuration) - } - if a.Metadata.TotalURLs != 100 { - t.Errorf("expected total URLs 100, got %d", a.Metadata.TotalURLs) - } -} - -func TestWriteToReadArchiveRoundTrip(t *testing.T) { - original := NewArchive("scan-rt", "roundtrip.com") - original.AddEntry(ArchiveEntry{ - URL: "https://roundtrip.com/api", - StatusCode: 200, - ContentType: "application/json", - BodyHash: "abc123", - BodySnippet: `{"ok": true}`, - Headers: http.Header{"X-Test": {"value"}}, - ScannedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), - Tags: []string{"test"}, - }) - original.SetMetadata(ArchiveMetadata{ - Host: "roundtrip.com", - ScanDuration: 3 * time.Second, - TotalURLs: 1, - SuccessfulURLs: 1, - ToolVersion: "1.0.0", - }) - - var buf bytes.Buffer - n, err := original.WriteTo(&buf) - if err != nil { - t.Fatalf("WriteTo failed: %v", err) - } - if n == 0 { - t.Error("expected bytes written > 0") - } - - restored, err := ReadArchive(&buf) - if err != nil { - t.Fatalf("ReadArchive failed: %v", err) - } - - if restored.Version != original.Version { - t.Errorf("version mismatch: %q vs %q", restored.Version, original.Version) - } - if restored.ScanID != original.ScanID { - t.Errorf("scan ID mismatch: %q vs %q", restored.ScanID, original.ScanID) - } - if len(restored.Results) != 1 { - t.Fatalf("expected 1 result, got %d", len(restored.Results)) - } - entry := restored.Results[0] - if entry.URL != "https://roundtrip.com/api" { - t.Errorf("URL mismatch: %q", entry.URL) - } - if entry.StatusCode != 200 { - t.Errorf("status code mismatch: %d", entry.StatusCode) - } - if entry.BodyHash != "abc123" { - t.Errorf("body hash mismatch: %q", entry.BodyHash) - } - if entry.Headers.Get("X-Test") != "value" { - t.Errorf("header mismatch: %q", entry.Headers.Get("X-Test")) - } -} - -func TestSummaryStatusCounts(t *testing.T) { - a := NewArchive("scan-1", "test.com") - a.AddEntry(ArchiveEntry{StatusCode: 200}) - a.AddEntry(ArchiveEntry{StatusCode: 200}) - a.AddEntry(ArchiveEntry{StatusCode: 404}) - a.AddEntry(ArchiveEntry{StatusCode: 301}) - - s := a.Summary() - - if s.TotalEntries != 4 { - t.Errorf("expected total 4, got %d", s.TotalEntries) - } - if s.StatusCounts[200] != 2 { - t.Errorf("expected 200 count 2, got %d", s.StatusCounts[200]) - } - if s.StatusCounts[404] != 1 { - t.Errorf("expected 404 count 1, got %d", s.StatusCounts[404]) - } - if s.StatusCounts[301] != 1 { - t.Errorf("expected 301 count 1, got %d", s.StatusCounts[301]) - } -} - -func TestSummaryErrorRate(t *testing.T) { - a := NewArchive("scan-1", "test.com") - a.AddEntry(ArchiveEntry{StatusCode: 200}) - a.AddEntry(ArchiveEntry{StatusCode: 200, Error: "timeout"}) - a.AddEntry(ArchiveEntry{StatusCode: 500}) - a.AddEntry(ArchiveEntry{StatusCode: 500, Error: "internal error"}) - - s := a.Summary() - - if s.ErrorCount != 2 { - t.Errorf("expected error count 2, got %d", s.ErrorCount) - } - expectedRate := 0.5 - if s.ErrorRate != expectedRate { - t.Errorf("expected error rate %v, got %v", expectedRate, s.ErrorRate) - } -} - -func TestBodyHash(t *testing.T) { - data := []byte("hello world") - hash1 := BodyHash(data) - hash2 := BodyHash(data) - - if hash1 != hash2 { - t.Errorf("expected consistent hash, got %q and %q", hash1, hash2) - } - - expected := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - if hash1 != expected { - t.Errorf("expected hash %q, got %q", expected, hash1) - } -} - -func TestTruncateBody(t *testing.T) { - short := []byte("short body") - result := TruncateBody(short, 1024) - if result != "short body" { - t.Errorf("expected %q, got %q", "short body", result) - } - - long := bytes.Repeat([]byte("a"), 2000) - result = TruncateBody(long, 1024) - if len(result) != 1024+3 { // 1024 bytes + "..." - t.Errorf("expected length 1027, got %d", len(result)) - } - if result[1024:] != "..." { - t.Errorf("expected trailing '...', got %q", result[1024:]) - } - - result = TruncateBody([]byte{}, 1024) - if result != "" { - t.Errorf("expected empty string, got %q", result) - } -} - -func TestEmptyArchiveRoundTrip(t *testing.T) { - original := NewArchive("empty-scan", "empty.com") - - var buf bytes.Buffer - _, err := original.WriteTo(&buf) - if err != nil { - t.Fatalf("WriteTo failed: %v", err) - } - - restored, err := ReadArchive(&buf) - if err != nil { - t.Fatalf("ReadArchive failed: %v", err) - } - - if restored.ScanID != "empty-scan" { - t.Errorf("scan ID mismatch: %q", restored.ScanID) - } - if len(restored.Results) != 0 { - t.Errorf("expected 0 results, got %d", len(restored.Results)) - } - if restored.Version != ArchiveVersion { - t.Errorf("version mismatch: %q", restored.Version) - } -} - -func TestMultipleEntriesStatusCodes(t *testing.T) { - a := NewArchive("multi-scan", "multi.com") - codes := []int{200, 200, 301, 404, 500, 500, 500} - for _, code := range codes { - a.AddEntry(ArchiveEntry{StatusCode: code}) - } - - s := a.Summary() - - if s.TotalEntries != 7 { - t.Errorf("expected total 7, got %d", s.TotalEntries) - } - if s.StatusCounts[200] != 2 { - t.Errorf("expected 200 count 2, got %d", s.StatusCounts[200]) - } - if s.StatusCounts[301] != 1 { - t.Errorf("expected 301 count 1, got %d", s.StatusCounts[301]) - } - if s.StatusCounts[404] != 1 { - t.Errorf("expected 404 count 1, got %d", s.StatusCounts[404]) - } - if s.StatusCounts[500] != 3 { - t.Errorf("expected 500 count 3, got %d", s.StatusCounts[500]) - } -} - -func TestGzipCompressionIsUsed(t *testing.T) { - original := NewArchive("gzip-scan", "gzip.com") - original.AddEntry(ArchiveEntry{ - URL: "https://gzip.com/test", - StatusCode: 200, - ScannedAt: time.Now().UTC(), - }) - - var buf bytes.Buffer - _, err := original.WriteTo(&buf) - if err != nil { - t.Fatalf("WriteTo failed: %v", err) - } - - // Keep a copy for the second check - data := make([]byte, buf.Len()) - copy(data, buf.Bytes()) - - // Attempt to read as gzip - should succeed - gz, err := gzip.NewReader(&buf) - if err != nil { - t.Fatalf("expected gzip data, got error: %v", err) - } - defer gz.Close() - - // Read the uncompressed content - decompressed := new(bytes.Buffer) - if _, err := decompressed.ReadFrom(gz); err != nil { - t.Fatalf("failed to read decompressed data: %v", err) - } - - // Verify we got valid JSON (the decompressed content should start with '{') - d := decompressed.Bytes() - if len(d) == 0 || d[0] != '{' { - t.Errorf("expected JSON object starting with '{'") - } - - // Also verify ReadArchive can round-trip the gzip data - restored, err := ReadArchive(bytes.NewReader(data)) - if err != nil { - t.Fatalf("ReadArchive failed on gzip data: %v", err) - } - if restored.ScanID != "gzip-scan" { - t.Errorf("expected scan ID %q, got %q", "gzip-scan", restored.ScanID) - } -} diff --git a/dependency_check.go b/dependency_check.go deleted file mode 100644 index 53bdea7..0000000 --- a/dependency_check.go +++ /dev/null @@ -1,397 +0,0 @@ -package inspect - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "strings" -) - -// DependencyChecker scans project dependency files for known vulnerable packages. -type DependencyChecker struct { - ProjectDir string -} - -// NewDependencyChecker creates a DependencyChecker for the given project directory. -func NewDependencyChecker(projectDir string) *DependencyChecker { - return &DependencyChecker{ProjectDir: projectDir} -} - -// VulnEntry represents a known vulnerability for a package version range. -type VulnEntry struct { - CVE string - Severity Severity - Description string - AffectedVersions []string // versions that are affected (prefix match) - FixedVersion string -} - -// KnownVulnerabilities maps package names to their known vulnerability entries. -// This covers the top 50 most commonly exploited vulnerabilities. -var KnownVulnerabilities = map[string][]VulnEntry{ - // Go packages - "golang.org/x/crypto": { - {CVE: "CVE-2022-27191", Severity: SeverityHigh, Description: "Denial of service via crafted Signer", AffectedVersions: []string{"0.0.0-2022"}, FixedVersion: "0.0.0-20220315160706"}, - }, - "golang.org/x/net": { - {CVE: "CVE-2022-41723", Severity: SeverityHigh, Description: "HTTP/2 HPACK decoder denial of service", AffectedVersions: []string{"0.0.0-2022", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6"}, FixedVersion: "0.7.0"}, - {CVE: "CVE-2023-44487", Severity: SeverityCritical, Description: "HTTP/2 rapid reset attack", AffectedVersions: []string{"0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7", "0.8", "0.9", "0.10", "0.11", "0.12", "0.13", "0.14", "0.15", "0.16"}, FixedVersion: "0.17.0"}, - }, - "golang.org/x/text": { - {CVE: "CVE-2022-32149", Severity: SeverityHigh, Description: "Denial of service via crafted Accept-Language header", AffectedVersions: []string{"0.0", "0.1", "0.2", "0.3"}, FixedVersion: "0.3.8"}, - }, - "github.com/gin-gonic/gin": { - {CVE: "CVE-2023-26125", Severity: SeverityHigh, Description: "Improper input validation allows path traversal", AffectedVersions: []string{"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9.0"}, FixedVersion: "1.9.1"}, - }, - "github.com/dgrijalva/jwt-go": { - {CVE: "CVE-2020-26160", Severity: SeverityHigh, Description: "Audience validation bypass", AffectedVersions: []string{"1", "2", "3"}, FixedVersion: "4.0.0 (use github.com/golang-jwt/jwt)"}, - }, - "github.com/tidwall/gjson": { - {CVE: "CVE-2021-42248", Severity: SeverityHigh, Description: "Denial of service via crafted JSON", AffectedVersions: []string{"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9.0", "1.9.1", "1.9.2"}, FixedVersion: "1.9.3"}, - }, - - // NPM packages - "lodash": { - {CVE: "CVE-2021-23337", Severity: SeverityCritical, Description: "Command injection via template function", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.0", "4.1", "4.2", "4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "4.10", "4.11", "4.12", "4.13", "4.14", "4.15", "4.16", "4.17.0", "4.17.1", "4.17.2", "4.17.3", "4.17.4", "4.17.5", "4.17.6", "4.17.7", "4.17.8", "4.17.9", "4.17.10", "4.17.11", "4.17.12", "4.17.13", "4.17.14", "4.17.15", "4.17.16", "4.17.17", "4.17.18", "4.17.19", "4.17.20"}, FixedVersion: "4.17.21"}, - {CVE: "CVE-2020-8203", Severity: SeverityHigh, Description: "Prototype pollution in zipObjectDeep", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.0", "4.1", "4.2", "4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "4.10", "4.11", "4.12", "4.13", "4.14", "4.15", "4.16", "4.17.0", "4.17.1", "4.17.2", "4.17.3", "4.17.4", "4.17.5", "4.17.6", "4.17.7", "4.17.8", "4.17.9", "4.17.10", "4.17.11", "4.17.12", "4.17.13", "4.17.14", "4.17.15"}, FixedVersion: "4.17.16"}, - }, - "minimist": { - {CVE: "CVE-2021-44906", Severity: SeverityCritical, Description: "Prototype pollution", AffectedVersions: []string{"0.", "1.0", "1.1", "1.2.0", "1.2.1", "1.2.2", "1.2.3", "1.2.4", "1.2.5"}, FixedVersion: "1.2.6"}, - }, - "node-forge": { - {CVE: "CVE-2022-24771", Severity: SeverityHigh, Description: "Signature verification bypass with RSA PKCS#1 v1.5", AffectedVersions: []string{"0.", "1.0", "1.1", "1.2"}, FixedVersion: "1.3.0"}, - }, - "jsonwebtoken": { - {CVE: "CVE-2022-23529", Severity: SeverityCritical, Description: "Insecure implementation of key retrieval function", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5.0"}, FixedVersion: "8.5.1"}, - }, - "express": { - {CVE: "CVE-2024-29041", Severity: SeverityMedium, Description: "Open redirect via malicious URL", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.0", "4.1", "4.2", "4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "4.10", "4.11", "4.12", "4.13", "4.14", "4.15", "4.16", "4.17", "4.18"}, FixedVersion: "4.19.2"}, - }, - "axios": { - {CVE: "CVE-2023-45857", Severity: SeverityHigh, Description: "CSRF token exposure via cross-site requests", AffectedVersions: []string{"0.", "1.0", "1.1", "1.2", "1.3", "1.4", "1.5"}, FixedVersion: "1.6.0"}, - }, - "semver": { - {CVE: "CVE-2022-25883", Severity: SeverityMedium, Description: "Regular expression denial of service", AffectedVersions: []string{"5.", "6.0", "6.1", "6.2", "6.3.0"}, FixedVersion: "6.3.1"}, - }, - "tar": { - {CVE: "CVE-2021-37701", Severity: SeverityHigh, Description: "Arbitrary file creation/overwrite via symlink", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.0", "4.1", "4.2", "4.3", "4.4.0", "4.4.1", "4.4.2", "4.4.3", "4.4.4", "4.4.5", "4.4.6", "4.4.7", "4.4.8", "4.4.9", "4.4.10", "4.4.11", "4.4.12"}, FixedVersion: "4.4.13"}, - }, - "glob-parent": { - {CVE: "CVE-2020-28469", Severity: SeverityHigh, Description: "Regular expression denial of service", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.", "5.0", "5.1.0"}, FixedVersion: "5.1.2"}, - }, - "postcss": { - {CVE: "CVE-2023-44270", Severity: SeverityMedium, Description: "Line return parsing error", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.0", "8.1", "8.2", "8.3", "8.4.0", "8.4.1", "8.4.2", "8.4.3", "8.4.4", "8.4.5", "8.4.6", "8.4.7", "8.4.8", "8.4.9", "8.4.10", "8.4.11", "8.4.12", "8.4.13", "8.4.14", "8.4.15", "8.4.16", "8.4.17", "8.4.18", "8.4.19", "8.4.20", "8.4.21", "8.4.22", "8.4.23", "8.4.24", "8.4.25", "8.4.26", "8.4.27", "8.4.28", "8.4.29", "8.4.30"}, FixedVersion: "8.4.31"}, - }, - "ua-parser-js": { - {CVE: "CVE-2022-25927", Severity: SeverityHigh, Description: "ReDoS vulnerability", AffectedVersions: []string{"0.7.0", "0.7.1", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.8", "0.7.9", "0.7.10", "0.7.11", "0.7.12", "0.7.13", "0.7.14", "0.7.15", "0.7.16", "0.7.17", "0.7.18", "0.7.19", "0.7.20", "0.7.21", "0.7.22", "0.7.23", "0.7.24", "0.7.25", "0.7.26", "0.7.27", "0.7.28", "0.7.29", "0.7.30", "0.7.31", "0.7.32"}, FixedVersion: "0.7.33"}, - }, - - // Python packages - "django": { - {CVE: "CVE-2023-36053", Severity: SeverityHigh, Description: "Potential ReDoS in EmailValidator/URLValidator", AffectedVersions: []string{"0.", "1.", "2.", "3.0", "3.1", "3.2.0", "3.2.1", "3.2.2", "3.2.3", "3.2.4", "3.2.5", "3.2.6", "3.2.7", "3.2.8", "3.2.9", "3.2.10", "3.2.11", "3.2.12", "3.2.13", "3.2.14", "3.2.15", "3.2.16", "3.2.17", "3.2.18", "3.2.19"}, FixedVersion: "3.2.20"}, - }, - "flask": { - {CVE: "CVE-2023-30861", Severity: SeverityHigh, Description: "Cookie exposure on cross-origin redirects", AffectedVersions: []string{"0.", "1.", "2.0", "2.1", "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.2.4"}, FixedVersion: "2.2.5"}, - }, - "requests": { - {CVE: "CVE-2023-32681", Severity: SeverityMedium, Description: "Leaking Proxy-Authorization header to destination server", AffectedVersions: []string{"0.", "1.", "2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13", "2.14", "2.15", "2.16", "2.17", "2.18", "2.19", "2.20", "2.21", "2.22", "2.23", "2.24", "2.25", "2.26", "2.27", "2.28", "2.29", "2.30"}, FixedVersion: "2.31.0"}, - }, - "pillow": { - {CVE: "CVE-2023-44271", Severity: SeverityHigh, Description: "Denial of service via uncontrolled resource consumption", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.", "9.", "10.0"}, FixedVersion: "10.1.0"}, - }, - "urllib3": { - {CVE: "CVE-2023-45803", Severity: SeverityMedium, Description: "Request body not stripped on redirect from 303", AffectedVersions: []string{"0.", "1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9", "1.10", "1.11", "1.12", "1.13", "1.14", "1.15", "1.16", "1.17", "1.18", "1.19", "1.20", "1.21", "1.22", "1.23", "1.24", "1.25", "1.26.0", "1.26.1", "1.26.2", "1.26.3", "1.26.4", "1.26.5", "1.26.6", "1.26.7", "1.26.8", "1.26.9", "1.26.10", "1.26.11", "1.26.12", "1.26.13", "1.26.14", "1.26.15", "1.26.16", "1.26.17"}, FixedVersion: "1.26.18"}, - }, - "cryptography": { - {CVE: "CVE-2023-49083", Severity: SeverityHigh, Description: "NULL pointer dereference in PKCS12 parsing", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.", "9.", "10.", "11.", "12.", "13.", "14.", "15.", "16.", "17.", "18.", "19.", "20.", "21.", "22.", "23.", "24.", "25.", "26.", "27.", "28.", "29.", "30.", "31.", "32.", "33.", "34.", "35.", "36.", "37.", "38.", "39.", "40.", "41.0.0", "41.0.1", "41.0.2", "41.0.3", "41.0.4", "41.0.5"}, FixedVersion: "41.0.6"}, - }, - "pyyaml": { - {CVE: "CVE-2020-14343", Severity: SeverityCritical, Description: "Arbitrary code execution via unsafe load", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.", "5.0", "5.1", "5.2", "5.3"}, FixedVersion: "5.4"}, - }, - "jinja2": { - {CVE: "CVE-2024-22195", Severity: SeverityMedium, Description: "Cross-site scripting via xmlattr filter", AffectedVersions: []string{"0.", "1.", "2.", "3.0", "3.1.0", "3.1.1", "3.1.2"}, FixedVersion: "3.1.3"}, - }, - "setuptools": { - {CVE: "CVE-2024-6345", Severity: SeverityHigh, Description: "Remote code execution via URL in package_index", AffectedVersions: []string{"0.", "1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.", "9.", "10.", "11.", "12.", "13.", "14.", "15.", "16.", "17.", "18.", "19.", "20.", "21.", "22.", "23.", "24.", "25.", "26.", "27.", "28.", "29.", "30.", "31.", "32.", "33.", "34.", "35.", "36.", "37.", "38.", "39.", "40.", "41.", "42.", "43.", "44.", "45.", "46.", "47.", "48.", "49.", "50.", "51.", "52.", "53.", "54.", "55.", "56.", "57.", "58.", "59.", "60.", "61.", "62.", "63.", "64.", "65.", "66.", "67.", "68.", "69.", "70.0"}, FixedVersion: "70.1.0"}, - }, - "numpy": { - {CVE: "CVE-2021-41495", Severity: SeverityMedium, Description: "NULL pointer dereference in numpy.sort", AffectedVersions: []string{"0.", "1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9", "1.10", "1.11", "1.12", "1.13", "1.14", "1.15", "1.16", "1.17", "1.18", "1.19"}, FixedVersion: "1.20.0"}, - }, - - // Java/Maven packages (commonly referenced) - "org.apache.logging.log4j:log4j-core": { - {CVE: "CVE-2021-44228", Severity: SeverityCritical, Description: "Log4Shell — Remote code execution via JNDI lookup", AffectedVersions: []string{"2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13", "2.14.0", "2.14.1"}, FixedVersion: "2.15.0"}, - {CVE: "CVE-2021-45046", Severity: SeverityCritical, Description: "Log4Shell bypass — incomplete fix in 2.15.0", AffectedVersions: []string{"2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13", "2.14", "2.15.0"}, FixedVersion: "2.16.0"}, - }, - "com.fasterxml.jackson.core:jackson-databind": { - {CVE: "CVE-2020-36518", Severity: SeverityHigh, Description: "Denial of service via deeply nested objects", AffectedVersions: []string{"2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12.0", "2.12.1", "2.12.2", "2.12.3", "2.12.4", "2.12.5", "2.12.6"}, FixedVersion: "2.12.7"}, - }, - "org.springframework:spring-core": { - {CVE: "CVE-2022-22965", Severity: SeverityCritical, Description: "Spring4Shell — RCE via data binding", AffectedVersions: []string{"5.0", "5.1", "5.2", "5.3.0", "5.3.1", "5.3.2", "5.3.3", "5.3.4", "5.3.5", "5.3.6", "5.3.7", "5.3.8", "5.3.9", "5.3.10", "5.3.11", "5.3.12", "5.3.13", "5.3.14", "5.3.15", "5.3.16", "5.3.17"}, FixedVersion: "5.3.18"}, - }, -} - -// ScanGoMod parses a go.mod file and checks for known vulnerable package versions. -func (d *DependencyChecker) ScanGoMod(path string) []Finding { - var findings []Finding - - file, err := os.Open(path) // #nosec G304 -- path is the go.mod location supplied to this dependency checker (a project file to be scanned for vulnerable packages), not attacker-controlled input - if err != nil { - findings = append(findings, Finding{ - Check: "dependency-gomod", - Severity: SeverityInfo, - URL: path, - Message: fmt.Sprintf("Could not open go.mod: %s", err.Error()), - }) - return findings - } - defer file.Close() - - scanner := bufio.NewScanner(file) - inRequire := false - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - // Track require blocks - if strings.HasPrefix(line, "require (") || strings.HasPrefix(line, "require(") { - inRequire = true - continue - } - if line == ")" { - inRequire = false - continue - } - - // Single-line require - if strings.HasPrefix(line, "require ") && !strings.Contains(line, "(") { - line = strings.TrimPrefix(line, "require ") - pkg, version := parseGoModDep(line) - if f := checkVulnerability(pkg, version, path, "dependency-gomod"); f != nil { - findings = append(findings, f...) - } - continue - } - - // Inside require block - if inRequire && line != "" && !strings.HasPrefix(line, "//") { - pkg, version := parseGoModDep(line) - if f := checkVulnerability(pkg, version, path, "dependency-gomod"); f != nil { - findings = append(findings, f...) - } - } - } - - return findings -} - -// ScanPackageJSON parses a package.json file and checks for known vulnerable packages. -func (d *DependencyChecker) ScanPackageJSON(path string) []Finding { - var findings []Finding - - data, err := os.ReadFile(path) // #nosec G304 -- path is the package.json location supplied to this dependency checker (a project file to be scanned for vulnerable packages), not attacker-controlled input - if err != nil { - findings = append(findings, Finding{ - Check: "dependency-npm", - Severity: SeverityInfo, - URL: path, - Message: fmt.Sprintf("Could not read package.json: %s", err.Error()), - }) - return findings - } - - var pkg struct { - Dependencies map[string]string `json:"dependencies"` - DevDependencies map[string]string `json:"devDependencies"` - } - - if err := json.Unmarshal(data, &pkg); err != nil { - findings = append(findings, Finding{ - Check: "dependency-npm", - Severity: SeverityInfo, - URL: path, - Message: fmt.Sprintf("Could not parse package.json: %s", err.Error()), - }) - return findings - } - - // Check both dependencies and devDependencies - for name, version := range pkg.Dependencies { - version = cleanNPMVersion(version) - if f := checkVulnerability(name, version, path, "dependency-npm"); f != nil { - findings = append(findings, f...) - } - } - - for name, version := range pkg.DevDependencies { - version = cleanNPMVersion(version) - if f := checkVulnerability(name, version, path, "dependency-npm"); f != nil { - findings = append(findings, f...) - } - } - - return findings -} - -// ScanRequirements parses a Python requirements.txt file and checks for known vulnerable packages. -func (d *DependencyChecker) ScanRequirements(path string) []Finding { - var findings []Finding - - file, err := os.Open(path) // #nosec G304 -- path is the requirements.txt location supplied to this dependency checker (a project file to be scanned for vulnerable packages), not attacker-controlled input - if err != nil { - findings = append(findings, Finding{ - Check: "dependency-python", - Severity: SeverityInfo, - URL: path, - Message: fmt.Sprintf("Could not open requirements.txt: %s", err.Error()), - }) - return findings - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { - continue - } - - name, version := parseRequirementLine(line) - if name == "" { - continue - } - - // Normalize package name (pip uses - and _ interchangeably) - normalizedName := strings.ToLower(strings.ReplaceAll(name, "-", "_")) - // Try original case first, then lowercase - if f := checkVulnerability(name, version, path, "dependency-python"); f != nil { - findings = append(findings, f...) - } else if normalizedName != name { - if f := checkVulnerability(normalizedName, version, path, "dependency-python"); f != nil { - findings = append(findings, f...) - } - } - } - - return findings -} - -// parseGoModDep extracts package name and version from a go.mod dependency line. -func parseGoModDep(line string) (string, string) { - // Remove // indirect suffix and other comments - if idx := strings.Index(line, "//"); idx >= 0 { - line = line[:idx] - } - line = strings.TrimSpace(line) - - parts := strings.Fields(line) - if len(parts) < 2 { - return "", "" - } - - pkg := parts[0] - version := strings.TrimPrefix(parts[1], "v") - return pkg, version -} - -// parseRequirementLine parses a pip requirements.txt line into package name and version. -func parseRequirementLine(line string) (string, string) { - // Remove environment markers - if idx := strings.Index(line, ";"); idx >= 0 { - line = line[:idx] - } - // Remove extras - if idx := strings.Index(line, "["); idx >= 0 { - endIdx := strings.Index(line, "]") - if endIdx > idx { - line = line[:idx] + line[endIdx+1:] - } - } - - line = strings.TrimSpace(line) - - // Split on version specifiers - for _, sep := range []string{"==", ">=", "<=", "~=", "!=", ">", "<"} { - if idx := strings.Index(line, sep); idx >= 0 { - name := strings.TrimSpace(line[:idx]) - version := strings.TrimSpace(line[idx+len(sep):]) - // Take just the first version if there are multiple constraints - if commaIdx := strings.Index(version, ","); commaIdx >= 0 { - version = version[:commaIdx] - } - return strings.ToLower(name), version - } - } - - // No version specified - return strings.ToLower(line), "" -} - -// cleanNPMVersion removes NPM version prefixes (^, ~, >=, etc.) -func cleanNPMVersion(version string) string { - version = strings.TrimSpace(version) - version = strings.TrimLeft(version, "^~>== 0 { - version = version[:idx] - } - return version -} - -// checkVulnerability checks if a package at a given version has known vulnerabilities. -func checkVulnerability(pkg, version, filePath, checkName string) []Finding { - vulns, exists := KnownVulnerabilities[pkg] - if !exists { - return nil - } - - if version == "" { - // Can't determine version, warn about it - return []Finding{{ - Check: checkName, - Severity: SeverityInfo, - URL: filePath, - Message: fmt.Sprintf("Package %s has known vulnerabilities but version could not be determined", pkg), - Fix: "Pin package version and ensure it is up to date", - }} - } - - var findings []Finding - for _, vuln := range vulns { - if isVersionAffected(version, vuln.AffectedVersions) { - findings = append(findings, Finding{ - Check: checkName, - Severity: vuln.Severity, - URL: filePath, - Element: fmt.Sprintf("%s@%s", pkg, version), - Message: fmt.Sprintf("%s: %s", vuln.CVE, vuln.Description), - Evidence: fmt.Sprintf("Installed: %s, Fixed in: %s", version, vuln.FixedVersion), - Fix: fmt.Sprintf("Upgrade %s to %s or later", pkg, vuln.FixedVersion), - }) - } - } - return findings -} - -// isVersionAffected checks if a version is older than the fixed version. -// It uses the AffectedVersions list with a "less than fixed" comparison approach: -// a version is considered affected if it starts with any of the affected prefixes -// (matched on segment boundaries). -func isVersionAffected(version string, affectedVersions []string) bool { - // Strip metadata suffixes like +incompatible for comparison - cleanVersion := version - if idx := strings.Index(cleanVersion, "+"); idx >= 0 { - cleanVersion = cleanVersion[:idx] - } - - for _, prefix := range affectedVersions { - if cleanVersion == prefix { - return true - } - if strings.HasPrefix(cleanVersion, prefix+".") { - return true - } - } - return false -} diff --git a/dependency_check_ext_test.go b/dependency_check_ext_test.go deleted file mode 100644 index eb50808..0000000 --- a/dependency_check_ext_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package inspect - -import ( - "os" - "path/filepath" - "testing" -) - -func TestScanPackageJSON_FileNotFound(t *testing.T) { - checker := NewDependencyChecker("/tmp") - findings := checker.ScanPackageJSON("/nonexistent/package.json") - - if len(findings) != 1 { - t.Fatalf("expected 1 info finding, got %d", len(findings)) - } - if findings[0].Severity != SeverityInfo { - t.Errorf("expected info severity, got %s", findings[0].Severity) - } - if findings[0].Check != "dependency-npm" { - t.Errorf("expected check 'dependency-npm', got %q", findings[0].Check) - } -} - -func TestScanPackageJSON_DevDependenciesVulnerable(t *testing.T) { - dir := t.TempDir() - pkgJSON := filepath.Join(dir, "package.json") - - content := `{ - "name": "test-app", - "version": "1.0.0", - "devDependencies": { - "minimist": "1.2.0", - "node-forge": "1.0.0" - } -}` - if err := os.WriteFile(pkgJSON, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanPackageJSON(pkgJSON) - - if len(findings) == 0 { - t.Fatal("expected findings for vulnerable devDependencies") - } - - foundMinimist := false - foundNodeForge := false - for _, f := range findings { - if f.Element == "minimist@1.2.0" { - foundMinimist = true - } - if f.Element == "node-forge@1.0.0" { - foundNodeForge = true - } - } - - if !foundMinimist { - t.Error("expected finding for minimist 1.2.0 in devDependencies") - } - if !foundNodeForge { - t.Error("expected finding for node-forge 1.0.0 in devDependencies") - } -} - -func TestScanPackageJSON_NPMTildePrefix(t *testing.T) { - dir := t.TempDir() - pkgJSON := filepath.Join(dir, "package.json") - - content := `{ - "name": "test-app", - "dependencies": { - "lodash": "~4.17.15" - } -}` - if err := os.WriteFile(pkgJSON, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanPackageJSON(pkgJSON) - - if len(findings) == 0 { - t.Fatal("expected finding for lodash ~4.17.15") - } - - if findings[0].Element != "lodash@4.17.15" { - t.Errorf("expected element 'lodash@4.17.15', got %q", findings[0].Element) - } -} - -func TestScanGoMod_IndirectDeps(t *testing.T) { - dir := t.TempDir() - gomod := filepath.Join(dir, "go.mod") - - content := `module example.com/myapp - -go 1.21 - -require ( - golang.org/x/net v0.7.0 // indirect - golang.org/x/text v0.3.0 -)` - if err := os.WriteFile(gomod, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanGoMod(gomod) - - if len(findings) < 2 { - t.Errorf("expected at least 2 findings for indirect+direct deps, got %d", len(findings)) - } -} - -func TestScanGoMod_EmptyRequire(t *testing.T) { - dir := t.TempDir() - gomod := filepath.Join(dir, "go.mod") - - content := `module example.com/myapp - -go 1.21 -` - if err := os.WriteFile(gomod, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanGoMod(gomod) - - if len(findings) != 0 { - t.Errorf("expected no findings for empty go.mod, got %d", len(findings)) - } -} - -func TestScanRequirements_EmptyFile(t *testing.T) { - dir := t.TempDir() - reqFile := filepath.Join(dir, "requirements.txt") - - if err := os.WriteFile(reqFile, []byte("# just comments\n\n"), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanRequirements(reqFile) - - if len(findings) != 0 { - t.Errorf("expected no findings for comment-only requirements.txt, got %d", len(findings)) - } -} - -func TestScanRequirements_NoVersion(t *testing.T) { - dir := t.TempDir() - reqFile := filepath.Join(dir, "requirements.txt") - - // Package with known vulns but no version specified - content := `django -flask -` - if err := os.WriteFile(reqFile, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanRequirements(reqFile) - - // Should get info-level findings about undetermined versions - for _, f := range findings { - if f.Severity != SeverityInfo { - t.Errorf("expected info severity for undetermined version, got %s", f.Severity) - } - } -} - -func TestIsVersionAffected_IncompatibleSuffix(t *testing.T) { - // Go modules use +incompatible suffix for major version upgrades - result := isVersionAffected("3.2.0+incompatible", []string{"1", "2", "3"}) - if !result { - t.Error("expected 3.2.0+incompatible to be affected by prefix '3'") - } -} - -func TestIsVersionAffected_NoMatch(t *testing.T) { - result := isVersionAffected("2.0.0", []string{"0.", "1.0", "1.1", "1.2"}) - if result { - t.Error("expected 2.0.0 to not match prefixes 0./1.x") - } -} - -func TestCheckVulnerability_UnknownPackage(t *testing.T) { - findings := checkVulnerability("some-unknown-package", "1.0.0", "go.mod", "test") - if len(findings) != 0 { - t.Errorf("expected no findings for unknown package, got %d", len(findings)) - } -} - -func TestCheckVulnerability_EmptyVersion(t *testing.T) { - findings := checkVulnerability("lodash", "", "package.json", "test") - if len(findings) != 1 { - t.Fatalf("expected 1 finding for empty version, got %d", len(findings)) - } - if findings[0].Severity != SeverityInfo { - t.Errorf("expected info severity, got %s", findings[0].Severity) - } -} - -func TestScanRequirements_PythonNormalization(t *testing.T) { - dir := t.TempDir() - reqFile := filepath.Join(dir, "requirements.txt") - - // PyYAML uses different casing in pip vs our database - content := `PyYAML==5.3 -` - if err := os.WriteFile(reqFile, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanRequirements(reqFile) - - found := false - for _, f := range findings { - if f.Element == "PyYAML@5.3" || f.Element == "pyyaml@5.3" { - found = true - } - } - if !found { - t.Error("expected finding for PyYAML 5.3 (case normalization)") - } -} - -func TestCleanNPMVersion_RangeWithSpace(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"^4.17.15", "4.17.15"}, - {"~1.2.3", "1.2.3"}, - {">=2.0.0", "2.0.0"}, - {"1.0.0 - 2.0.0", "1.0.0"}, - {" ^3.0.0 ", "3.0.0"}, - } - - for _, tt := range tests { - result := cleanNPMVersion(tt.input) - if result != tt.expected { - t.Errorf("cleanNPMVersion(%q) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestScanGoMod_CommentsIgnored(t *testing.T) { - dir := t.TempDir() - gomod := filepath.Join(dir, "go.mod") - - content := `module example.com/myapp - -go 1.21 - -require ( - // This is a comment - golang.org/x/net v0.7.0 // another comment -)` - if err := os.WriteFile(gomod, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanGoMod(gomod) - - // Should still detect the vulnerability despite comments - foundNet := false - for _, f := range findings { - if f.Element == "golang.org/x/net@0.7.0" { - foundNet = true - } - } - if !foundNet { - t.Error("expected finding for golang.org/x/net v0.7.0 despite comments") - } -} - -func TestKnownVulnerabilities_NPMCoverage(t *testing.T) { - npmPackages := []string{ - "lodash", "minimist", "node-forge", "jsonwebtoken", - "express", "axios", "semver", "tar", "glob-parent", - "postcss", "ua-parser-js", - } - - for _, pkg := range npmPackages { - if _, ok := KnownVulnerabilities[pkg]; !ok { - t.Errorf("expected %q in KnownVulnerabilities", pkg) - } - } -} - -func TestKnownVulnerabilities_PythonCoverage(t *testing.T) { - pythonPackages := []string{ - "django", "flask", "requests", "pillow", - "urllib3", "cryptography", "pyyaml", "jinja2", - "setuptools", "numpy", - } - - for _, pkg := range pythonPackages { - if _, ok := KnownVulnerabilities[pkg]; !ok { - t.Errorf("expected %q in KnownVulnerabilities", pkg) - } - } -} - -func TestKnownVulnerabilities_GoCoverage(t *testing.T) { - goPackages := []string{ - "golang.org/x/crypto", "golang.org/x/net", "golang.org/x/text", - "github.com/gin-gonic/gin", "github.com/dgrijalva/jwt-go", - "github.com/tidwall/gjson", - } - - for _, pkg := range goPackages { - if _, ok := KnownVulnerabilities[pkg]; !ok { - t.Errorf("expected %q in KnownVulnerabilities", pkg) - } - } -} diff --git a/dependency_check_test.go b/dependency_check_test.go deleted file mode 100644 index eeb88c0..0000000 --- a/dependency_check_test.go +++ /dev/null @@ -1,423 +0,0 @@ -package inspect - -import ( - "os" - "path/filepath" - "testing" -) - -func TestScanGoMod_VulnerablePackage(t *testing.T) { - dir := t.TempDir() - gomod := filepath.Join(dir, "go.mod") - - content := `module example.com/myapp - -go 1.21 - -require ( - golang.org/x/net v0.7.0 - github.com/gin-gonic/gin v1.9.0 - github.com/dgrijalva/jwt-go v3.2.0+incompatible -) -` - if err := os.WriteFile(gomod, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanGoMod(gomod) - - if len(findings) == 0 { - t.Fatal("expected vulnerability findings") - } - - // Check for specific vulnerabilities - foundNet := false - foundGin := false - foundJWT := false - for _, f := range findings { - if f.Element == "golang.org/x/net@0.7.0" { - foundNet = true - } - if f.Element == "github.com/gin-gonic/gin@1.9.0" { - foundGin = true - } - if f.Element == "github.com/dgrijalva/jwt-go@3.2.0+incompatible" { - foundJWT = true - } - } - - if !foundNet { - t.Error("expected finding for golang.org/x/net v0.7.0") - } - if !foundGin { - t.Error("expected finding for gin v1.9.0") - } - if !foundJWT { - t.Error("expected finding for jwt-go v3.2.0") - } -} - -func TestScanGoMod_SafeVersions(t *testing.T) { - dir := t.TempDir() - gomod := filepath.Join(dir, "go.mod") - - content := `module example.com/myapp - -go 1.21 - -require ( - golang.org/x/net v0.40.0 - github.com/gin-gonic/gin v1.10.0 -) -` - if err := os.WriteFile(gomod, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanGoMod(gomod) - - if len(findings) != 0 { - t.Errorf("expected no findings for safe versions, got %d: %+v", len(findings), findings) - } -} - -func TestScanGoMod_SingleLineRequire(t *testing.T) { - dir := t.TempDir() - gomod := filepath.Join(dir, "go.mod") - - content := `module example.com/myapp - -go 1.21 - -require golang.org/x/net v0.7.0 -` - if err := os.WriteFile(gomod, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanGoMod(gomod) - - if len(findings) == 0 { - t.Fatal("expected finding for single-line require") - } -} - -func TestScanGoMod_FileNotFound(t *testing.T) { - checker := NewDependencyChecker("/tmp") - findings := checker.ScanGoMod("/nonexistent/go.mod") - - if len(findings) != 1 { - t.Fatalf("expected 1 info finding, got %d", len(findings)) - } - if findings[0].Severity != SeverityInfo { - t.Errorf("expected info severity, got %s", findings[0].Severity) - } -} - -func TestScanPackageJSON_VulnerablePackages(t *testing.T) { - dir := t.TempDir() - pkgJSON := filepath.Join(dir, "package.json") - - content := `{ - "name": "test-app", - "version": "1.0.0", - "dependencies": { - "lodash": "^4.17.15", - "express": "4.17.1", - "axios": "^1.4.0" - }, - "devDependencies": { - "minimist": "1.2.5" - } -} -` - if err := os.WriteFile(pkgJSON, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanPackageJSON(pkgJSON) - - if len(findings) == 0 { - t.Fatal("expected vulnerability findings") - } - - foundLodash := false - foundMinimist := false - for _, f := range findings { - if f.Element == "lodash@4.17.15" { - foundLodash = true - } - if f.Element == "minimist@1.2.5" { - foundMinimist = true - } - } - - if !foundLodash { - t.Error("expected finding for lodash 4.17.15") - } - if !foundMinimist { - t.Error("expected finding for minimist 1.2.5") - } -} - -func TestScanPackageJSON_SafeVersions(t *testing.T) { - dir := t.TempDir() - pkgJSON := filepath.Join(dir, "package.json") - - content := `{ - "name": "test-app", - "version": "1.0.0", - "dependencies": { - "lodash": "^4.17.21", - "express": "4.19.2" - } -} -` - if err := os.WriteFile(pkgJSON, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanPackageJSON(pkgJSON) - - if len(findings) != 0 { - t.Errorf("expected no findings for safe versions, got %d: %+v", len(findings), findings) - } -} - -func TestScanPackageJSON_InvalidJSON(t *testing.T) { - dir := t.TempDir() - pkgJSON := filepath.Join(dir, "package.json") - - if err := os.WriteFile(pkgJSON, []byte("not json"), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanPackageJSON(pkgJSON) - - if len(findings) != 1 { - t.Fatalf("expected 1 info finding, got %d", len(findings)) - } - if findings[0].Severity != SeverityInfo { - t.Errorf("expected info severity, got %s", findings[0].Severity) - } -} - -func TestScanRequirements_VulnerablePackages(t *testing.T) { - dir := t.TempDir() - reqFile := filepath.Join(dir, "requirements.txt") - - content := `# Python dependencies -django==3.2.15 -flask>=2.2.3 -requests==2.28.0 -pillow==9.5.0 -urllib3==1.26.10 -pyyaml==5.3 -` - if err := os.WriteFile(reqFile, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanRequirements(reqFile) - - if len(findings) == 0 { - t.Fatal("expected vulnerability findings") - } - - foundDjango := false - foundFlask := false - foundPyyaml := false - for _, f := range findings { - if f.Element == "django@3.2.15" { - foundDjango = true - } - if f.Element == "flask@2.2.3" { - foundFlask = true - } - if f.Element == "pyyaml@5.3" { - foundPyyaml = true - } - } - - if !foundDjango { - t.Error("expected finding for django 3.2.15") - } - if !foundFlask { - t.Error("expected finding for flask 2.2.3") - } - if !foundPyyaml { - t.Error("expected finding for pyyaml 5.3") - } -} - -func TestScanRequirements_SafeVersions(t *testing.T) { - dir := t.TempDir() - reqFile := filepath.Join(dir, "requirements.txt") - - content := `django==4.2.0 -flask==3.0.0 -requests==2.31.0 -` - if err := os.WriteFile(reqFile, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanRequirements(reqFile) - - if len(findings) != 0 { - t.Errorf("expected no findings for safe versions, got %d: %+v", len(findings), findings) - } -} - -func TestScanRequirements_WithExtrasAndMarkers(t *testing.T) { - dir := t.TempDir() - reqFile := filepath.Join(dir, "requirements.txt") - - content := `requests[security]==2.28.0; python_version >= "3.6" -urllib3==1.26.10 -` - if err := os.WriteFile(reqFile, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - checker := NewDependencyChecker(dir) - findings := checker.ScanRequirements(reqFile) - - if len(findings) == 0 { - t.Fatal("expected findings for vulnerable packages with extras") - } -} - -func TestScanRequirements_FileNotFound(t *testing.T) { - checker := NewDependencyChecker("/tmp") - findings := checker.ScanRequirements("/nonexistent/requirements.txt") - - if len(findings) != 1 { - t.Fatalf("expected 1 info finding, got %d", len(findings)) - } - if findings[0].Severity != SeverityInfo { - t.Errorf("expected info severity, got %s", findings[0].Severity) - } -} - -func TestParseGoModDep(t *testing.T) { - tests := []struct { - line string - pkg string - version string - }{ - {"golang.org/x/net v0.7.0", "golang.org/x/net", "0.7.0"}, - {"github.com/foo/bar v1.2.3 // indirect", "github.com/foo/bar", "1.2.3"}, - {"github.com/dgrijalva/jwt-go v3.2.0+incompatible", "github.com/dgrijalva/jwt-go", "3.2.0+incompatible"}, - } - - for _, tt := range tests { - pkg, version := parseGoModDep(tt.line) - if pkg != tt.pkg { - t.Errorf("parseGoModDep(%q) pkg = %q, want %q", tt.line, pkg, tt.pkg) - } - if version != tt.version { - t.Errorf("parseGoModDep(%q) version = %q, want %q", tt.line, version, tt.version) - } - } -} - -func TestParseRequirementLine(t *testing.T) { - tests := []struct { - line string - name string - version string - }{ - {"django==3.2.15", "django", "3.2.15"}, - {"flask>=2.2.3", "flask", "2.2.3"}, - {"requests~=2.28.0", "requests", "2.28.0"}, - {"numpy", "numpy", ""}, - {"PyYAML==5.3", "pyyaml", "5.3"}, - {"requests[security]==2.28.0; python_version >= \"3.6\"", "requests", "2.28.0"}, - } - - for _, tt := range tests { - name, version := parseRequirementLine(tt.line) - if name != tt.name { - t.Errorf("parseRequirementLine(%q) name = %q, want %q", tt.line, name, tt.name) - } - if version != tt.version { - t.Errorf("parseRequirementLine(%q) version = %q, want %q", tt.line, version, tt.version) - } - } -} - -func TestCleanNPMVersion(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"^4.17.15", "4.17.15"}, - {"~1.2.3", "1.2.3"}, - {">=2.0.0", "2.0.0"}, - {"1.0.0", "1.0.0"}, - {">=1.0.0 <2.0.0", "1.0.0"}, - } - - for _, tt := range tests { - result := cleanNPMVersion(tt.input) - if result != tt.expected { - t.Errorf("cleanNPMVersion(%q) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestIsVersionAffected(t *testing.T) { - tests := []struct { - version string - affected []string - expected bool - }{ - {"4.17.15", []string{"4.17.0", "4.17.1", "4.17.15"}, true}, - {"4.17.21", []string{"4.17.0", "4.17.1", "4.17.15"}, false}, - {"0.7.0", []string{"0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7"}, true}, - {"0.40.0", []string{"0.0", "0.1", "0.2", "0.3"}, false}, - } - - for _, tt := range tests { - result := isVersionAffected(tt.version, tt.affected) - if result != tt.expected { - t.Errorf("isVersionAffected(%q, ...) = %v, want %v", tt.version, result, tt.expected) - } - } -} - -func TestKnownVulnerabilities_Coverage(t *testing.T) { - // Ensure we have a reasonable number of entries - if len(KnownVulnerabilities) < 20 { - t.Errorf("expected at least 20 packages in KnownVulnerabilities, got %d", len(KnownVulnerabilities)) - } - - // Verify Log4Shell is in the database - log4j, ok := KnownVulnerabilities["org.apache.logging.log4j:log4j-core"] - if !ok { - t.Fatal("expected log4j-core in KnownVulnerabilities") - } - - foundLog4Shell := false - for _, v := range log4j { - if v.CVE == "CVE-2021-44228" { - foundLog4Shell = true - if v.Severity != SeverityCritical { - t.Error("Log4Shell should be critical severity") - } - break - } - } - if !foundLog4Shell { - t.Error("expected CVE-2021-44228 (Log4Shell) in log4j vulnerabilities") - } -} diff --git a/findings_store.go b/findings_store.go index 9b7547b..328742c 100644 --- a/findings_store.go +++ b/findings_store.go @@ -121,34 +121,6 @@ func (s *FindingsStore) Size() int { return len(s.buffer) } -// ConvertArchiveToEntries converts the results in an Archive into -// FindingEntry records suitable for the store. -func ConvertArchiveToEntries(archive *Archive) []FindingEntry { - if archive == nil { - return nil - } - entries := make([]FindingEntry, 0, len(archive.Results)) - for _, r := range archive.Results { - passed := r.StatusCode >= 200 && r.StatusCode < 400 && r.Error == "" - msg := fmt.Sprintf("HTTP %d", r.StatusCode) - if r.Error != "" { - msg = r.Error - } - entries = append(entries, FindingEntry{ - ScanID: archive.ScanID, - URL: r.URL, - CheckName: "archive-entry", - Passed: passed, - Severity: severityFromStatusCode(r.StatusCode, r.Error), - Message: msg, - Details: r.BodySnippet, - Tags: r.Tags, - ScannedAt: r.ScannedAt, - }) - } - return entries -} - // ConvertScanResult converts a slice of Finding records from a scan into // FindingEntry records for the store. func ConvertScanResult(url string, findings []Finding) []FindingEntry { diff --git a/findings_store_test.go b/findings_store_test.go index ed8476c..fabe1a7 100644 --- a/findings_store_test.go +++ b/findings_store_test.go @@ -157,90 +157,6 @@ func TestFlushOnEmptyBufferIsNoOp(t *testing.T) { } } -// TestConvertArchiveToEntries verifies that an Archive is converted -// into the expected FindingEntry records. -func TestConvertArchiveToEntries(t *testing.T) { - now := time.Now().UTC() - archive := &Archive{ - ScanID: "scan-123", - Results: []ArchiveEntry{ - { - URL: "http://example.com/ok", - StatusCode: 200, - BodySnippet: "ok", - Tags: []string{"good"}, - ScannedAt: now, - }, - { - URL: "http://example.com/notfound", - StatusCode: 404, - ScannedAt: now, - }, - { - URL: "http://example.com/error", - StatusCode: 0, - Error: "connection refused", - ScannedAt: now, - }, - }, - } - - entries := ConvertArchiveToEntries(archive) - if len(entries) != 3 { - t.Fatalf("expected 3 entries, got %d", len(entries)) - } - - // Entry 0: 200 OK - if entries[0].ScanID != "scan-123" { - t.Errorf("entry 0: expected scan_id scan-123, got %s", entries[0].ScanID) - } - if entries[0].CheckName != "archive-entry" { - t.Errorf("entry 0: expected check_name archive-entry, got %s", entries[0].CheckName) - } - if !entries[0].Passed { - t.Error("entry 0: expected passed=true for status 200") - } - if entries[0].Severity != "info" { - t.Errorf("entry 0: expected severity info, got %s", entries[0].Severity) - } - if entries[0].Message != "HTTP 200" { - t.Errorf("entry 0: expected message 'HTTP 200', got %s", entries[0].Message) - } - if entries[0].Details != "ok" { - t.Errorf("entry 0: unexpected details %q", entries[0].Details) - } - if len(entries[0].Tags) != 1 || entries[0].Tags[0] != "good" { - t.Errorf("entry 0: unexpected tags %v", entries[0].Tags) - } - - // Entry 1: 404 - if entries[1].Passed { - t.Error("entry 1: expected passed=false for status 404") - } - if entries[1].Severity != "medium" { - t.Errorf("entry 1: expected severity medium, got %s", entries[1].Severity) - } - - // Entry 2: connection error - if entries[2].Passed { - t.Error("entry 2: expected passed=false for error entry") - } - if entries[2].Severity != "high" { - t.Errorf("entry 2: expected severity high, got %s", entries[2].Severity) - } - if entries[2].Message != "connection refused" { - t.Errorf("entry 2: expected message 'connection refused', got %s", entries[2].Message) - } -} - -// TestConvertArchiveToEntriesNil verifies nil archive returns nil. -func TestConvertArchiveToEntriesNil(t *testing.T) { - entries := ConvertArchiveToEntries(nil) - if entries != nil { - t.Errorf("expected nil entries for nil archive, got %v", entries) - } -} - // TestConvertScanResult verifies that Finding records are converted // to FindingEntry records with expected mappings. func TestConvertScanResult(t *testing.T) { diff --git a/sbom.go b/sbom.go deleted file mode 100644 index 3ebcfa4..0000000 --- a/sbom.go +++ /dev/null @@ -1,232 +0,0 @@ -package inspect - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -// SBOMDocument represents a CycloneDX 1.5 Software Bill of Materials. -type SBOMDocument struct { - BOMFormat string `json:"bomFormat"` - SpecVersion string `json:"specVersion"` - SerialNumber string `json:"serialNumber,omitempty"` - Version int `json:"version"` - Metadata SBOMMetadata `json:"metadata"` - Components []SBOMComponent `json:"components"` -} - -// SBOMMetadata contains metadata about the SBOM. -type SBOMMetadata struct { - Timestamp string `json:"timestamp"` - Tools []SBOMTool `json:"tools,omitempty"` - Component *SBOMComponent `json:"component,omitempty"` -} - -// SBOMTool describes the tool that generated the SBOM. -type SBOMTool struct { - Vendor string `json:"vendor"` - Name string `json:"name"` - Version string `json:"version"` -} - -// SBOMComponent represents a software component/dependency. -type SBOMComponent struct { - Type string `json:"type"` - Name string `json:"name"` - Version string `json:"version"` - PURL string `json:"purl,omitempty"` - Scope string `json:"scope,omitempty"` -} - -// GenerateSBOM produces a CycloneDX 1.5 SBOM from project dependency files. -func GenerateSBOM(projectDir string, version string) (*SBOMDocument, error) { - if version == "" { - version = "dev" - } - - doc := &SBOMDocument{ - BOMFormat: "CycloneDX", - SpecVersion: "1.5", - Version: 1, - Metadata: SBOMMetadata{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Tools: []SBOMTool{{ - Vendor: "GrayCodeAI", - Name: "inspect", - Version: version, - }}, - }, - } - - // Scan for dependency files - goModPath := filepath.Join(projectDir, "go.mod") - if _, err := os.Stat(goModPath); err == nil { - comps := scanGoModForSBOM(goModPath) - doc.Components = append(doc.Components, comps...) - } - - pkgJSONPath := filepath.Join(projectDir, "package.json") - if _, err := os.Stat(pkgJSONPath); err == nil { - comps := scanPackageJSONForSBOM(pkgJSONPath) - doc.Components = append(doc.Components, comps...) - } - - reqPath := filepath.Join(projectDir, "requirements.txt") - if _, err := os.Stat(reqPath); err == nil { - comps := scanRequirementsForSBOM(reqPath) - doc.Components = append(doc.Components, comps...) - } - - return doc, nil -} - -// GenerateSBOMJSON produces a JSON string of the SBOM. -func GenerateSBOMJSON(projectDir string, version string) (string, error) { - doc, err := GenerateSBOM(projectDir, version) - if err != nil { - return "", err - } - data, err := json.MarshalIndent(doc, "", " ") - if err != nil { - return "", fmt.Errorf("inspect: SBOM marshal failed: %w", err) - } - return string(data), nil -} - -func scanGoModForSBOM(path string) []SBOMComponent { - var components []SBOMComponent - - file, err := os.Open(path) // #nosec G304 -- path is filepath.Join(projectDir, "go.mod") computed by GenerateSBOM against the project directory being scanned, not attacker-controlled input - if err != nil { - return nil - } - defer file.Close() - - scanner := bufio.NewScanner(file) - inRequire := false - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "require (") || strings.HasPrefix(line, "require(") { - inRequire = true - continue - } - if line == ")" { - inRequire = false - continue - } - if strings.HasPrefix(line, "require ") && !strings.Contains(line, "(") { - line = strings.TrimPrefix(line, "require ") - if comp := goModLineToComponent(line); comp != nil { - components = append(components, *comp) - } - continue - } - if inRequire && line != "" && !strings.HasPrefix(line, "//") { - if comp := goModLineToComponent(line); comp != nil { - components = append(components, *comp) - } - } - } - - return components -} - -func goModLineToComponent(line string) *SBOMComponent { - if idx := strings.Index(line, "//"); idx >= 0 { - line = line[:idx] - } - line = strings.TrimSpace(line) - parts := strings.Fields(line) - if len(parts) < 2 { - return nil - } - pkg := parts[0] - version := strings.TrimPrefix(parts[1], "v") - return &SBOMComponent{ - Type: "library", - Name: pkg, - Version: version, - PURL: fmt.Sprintf("pkg:golang/%s@%s", pkg, version), - Scope: "required", - } -} - -func scanPackageJSONForSBOM(path string) []SBOMComponent { - var components []SBOMComponent - - data, err := os.ReadFile(path) // #nosec G304 -- path is filepath.Join(projectDir, "package.json") computed by GenerateSBOM against the project directory being scanned, not attacker-controlled input - if err != nil { - return nil - } - - var pkg struct { - Name string `json:"name"` - Version string `json:"version"` - Dependencies map[string]string `json:"dependencies"` - DevDependencies map[string]string `json:"devDependencies"` - } - if err := json.Unmarshal(data, &pkg); err != nil { - return nil - } - - for name, version := range pkg.Dependencies { - cleaned := cleanNPMVersion(version) - components = append(components, SBOMComponent{ - Type: "library", - Name: name, - Version: cleaned, - PURL: fmt.Sprintf("pkg:npm/%s@%s", name, cleaned), - Scope: "required", - }) - } - - for name, version := range pkg.DevDependencies { - cleaned := cleanNPMVersion(version) - components = append(components, SBOMComponent{ - Type: "library", - Name: name, - Version: cleaned, - PURL: fmt.Sprintf("pkg:npm/%s@%s", name, cleaned), - Scope: "optional", - }) - } - - return components -} - -func scanRequirementsForSBOM(path string) []SBOMComponent { - var components []SBOMComponent - - file, err := os.Open(path) // #nosec G304 -- path is filepath.Join(projectDir, "requirements.txt") computed by GenerateSBOM against the project directory being scanned, not attacker-controlled input - if err != nil { - return nil - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { - continue - } - name, version := parseRequirementLine(line) - if name == "" { - continue - } - components = append(components, SBOMComponent{ - Type: "library", - Name: name, - Version: version, - PURL: fmt.Sprintf("pkg:pypi/%s@%s", name, version), - Scope: "required", - }) - } - - return components -} diff --git a/symbol_search.go b/symbol_search.go deleted file mode 100644 index b7af71b..0000000 --- a/symbol_search.go +++ /dev/null @@ -1,268 +0,0 @@ -package inspect - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" -) - -// SymbolResult is a symbol found in a source file via AST-level search. -// The same API as sight.SymbolResult so hawk can use a uniform interface -// across both analysis engines. -type SymbolResult struct { - File string `json:"file"` - Symbol string `json:"symbol"` - Kind string `json:"kind"` // function, method, type, class, const, var - StartLine int `json:"start_line"` - EndLine int `json:"end_line"` - Body string `json:"body,omitempty"` -} - -// SearchBySymbol returns all symbols in filePath whose name contains -// nameFragment (case-insensitive). An empty nameFragment returns all symbols. -// An optional kind filter ("function", "method", "type", etc.) restricts results. -// -// inspect's SymbolSearch is used during the localization phase to identify -// which functions and types are involved in a dependency or API-security finding, -// so hawk can fetch only the relevant bodies rather than entire files. -func SearchBySymbol(filePath, nameFragment, kind string) ([]SymbolResult, error) { - ext := filepath.Ext(filePath) - patterns := symbolPatternsForExt(ext) - if len(patterns) == 0 { - return nil, nil - } - - lines, err := readLines(filePath) - if err != nil { - return nil, fmt.Errorf("inspect.SearchBySymbol: %w", err) - } - - fragLower := strings.ToLower(nameFragment) - kindLower := strings.ToLower(kind) - - var results []SymbolResult - for i, line := range lines { - trimmed := strings.TrimSpace(line) - for _, pat := range patterns { - m := pat.re.FindStringSubmatch(trimmed) - if m == nil { - continue - } - sym := extractSymbolName(m, pat.kind, ext) - if fragLower != "" && !strings.Contains(strings.ToLower(sym), fragLower) { - break - } - if kindLower != "" && pat.kind != kindLower { - break - } - - startLine := i + 1 - endLine := estimateEndLine(lines, i) - - results = append(results, SymbolResult{ - File: filePath, - Symbol: sym, - Kind: pat.kind, - StartLine: startLine, - EndLine: endLine, - }) - break - } - } - return results, nil -} - -// GetSymbolBody returns the source text of the first symbol in filePath -// whose name matches fqn (case-insensitive). Returns an error if not found. -func GetSymbolBody(filePath, fqn string) (SymbolResult, error) { - results, err := SearchBySymbol(filePath, fqn, "") - if err != nil { - return SymbolResult{}, err - } - lines, err := readLines(filePath) - if err != nil { - return SymbolResult{}, fmt.Errorf("inspect.GetSymbolBody: %w", err) - } - fqnLower := strings.ToLower(fqn) - for _, r := range results { - if strings.EqualFold(r.Symbol, fqnLower) || strings.HasSuffix(strings.ToLower(r.Symbol), "."+fqnLower) { - r.Body = extractBody(lines, r.StartLine-1, r.EndLine-1) - return r, nil - } - } - for _, r := range results { - if strings.Contains(strings.ToLower(r.Symbol), fqnLower) { - r.Body = extractBody(lines, r.StartLine-1, r.EndLine-1) - return r, nil - } - } - return SymbolResult{}, fmt.Errorf("inspect.GetSymbolBody: symbol %q not found in %s", fqn, filePath) -} - -func extractBody(lines []string, start, end int) string { - if start < 0 { - start = 0 - } - if end >= len(lines) { - end = len(lines) - 1 - } - if start > end { - return "" - } - return strings.Join(lines[start:end+1], "\n") -} - -func estimateEndLine(lines []string, startIdx int) int { - if startIdx >= len(lines) { - return startIdx + 1 - } - startLine := lines[startIdx] - isPython := !strings.Contains(startLine, "{") && strings.HasSuffix(strings.TrimSpace(startLine), ":") - - if isPython { - baseIndent := leadingSpaces(startLine) - for i := startIdx + 1; i < len(lines); i++ { - line := lines[i] - if strings.TrimSpace(line) == "" { - continue - } - if leadingSpaces(line) <= baseIndent { - return i - } - } - return len(lines) - } - - depth := strings.Count(startLine, "{") - strings.Count(startLine, "}") - if depth <= 0 && !strings.Contains(startLine, "{") { - for i := startIdx + 1; i < len(lines) && i < startIdx+5; i++ { - depth += strings.Count(lines[i], "{") - strings.Count(lines[i], "}") - if depth > 0 { - startIdx = i - break - } - } - } - for i := startIdx + 1; i < len(lines); i++ { - depth += strings.Count(lines[i], "{") - strings.Count(lines[i], "}") - if depth <= 0 { - return i + 1 - } - } - return len(lines) -} - -func leadingSpaces(s string) int { - n := 0 - for _, c := range s { - if c == ' ' { - n++ - continue - } - if c == '\t' { - n += 4 - continue - } - break - } - return n -} - -type symPattern struct { - re *regexp.Regexp - kind string -} - -func symbolPatternsForExt(ext string) []symPattern { - switch strings.ToLower(ext) { - case ".go": - return goSymPatterns - case ".py": - return pySymPatterns - case ".ts", ".tsx": - return tsSymPatterns - case ".js", ".jsx": - return jsSymPatterns - case ".rs": - return rsSymPatterns - case ".java": - return javaSymPatterns - } - return nil -} - -var goSymPatterns = []symPattern{ - {regexp.MustCompile(`^func\s+\(\s*\w+\s+\*?(\w+)\)\s+(\w+)\s*\(`), "method"}, - {regexp.MustCompile(`^func\s+(\w+)\s*\(`), "function"}, - {regexp.MustCompile(`^type\s+(\w+)\s+struct\b`), "type"}, - {regexp.MustCompile(`^type\s+(\w+)\s+interface\b`), "type"}, - {regexp.MustCompile(`^type\s+(\w+)\s+`), "type"}, - {regexp.MustCompile(`^var\s+(\w+)\s`), "var"}, - {regexp.MustCompile(`^const\s+(\w+)\s`), "const"}, -} - -var pySymPatterns = []symPattern{ - {regexp.MustCompile(`^class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^async\s+def\s+(\w+)`), "function"}, - {regexp.MustCompile(`^\s{4}async\s+def\s+(\w+)`), "method"}, - {regexp.MustCompile(`^def\s+(\w+)`), "function"}, - {regexp.MustCompile(`^\s{4}def\s+(\w+)`), "method"}, -} - -var tsSymPatterns = []symPattern{ - {regexp.MustCompile(`^(?:export\s+)?class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^(?:export\s+)?interface\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:export\s+)?type\s+(\w+)\s*=`), "type"}, - {regexp.MustCompile(`^(?:export\s+)?(?:async\s+)?function\s+(\w+)`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)`), "const"}, -} - -var jsSymPatterns = []symPattern{ - {regexp.MustCompile(`^(?:export\s+)?class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^(?:export\s+)?(?:async\s+)?function\s+(\w+)`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(`), "function"}, - {regexp.MustCompile(`^(?:export\s+)?const\s+(\w+)`), "const"}, -} - -var rsSymPatterns = []symPattern{ - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?fn\s+(\w+)`), "function"}, - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)`), "type"}, - {regexp.MustCompile(`^(?:pub(?:\([^)]*\))?\s+)?trait\s+(\w+)`), "type"}, - {regexp.MustCompile(`^impl(?:<[^>]*>)?\s+(\w+)`), "type"}, -} - -var javaSymPatterns = []symPattern{ - {regexp.MustCompile(`^(?:public|private|protected)?\s*(?:static\s+)?class\s+(\w+)`), "class"}, - {regexp.MustCompile(`^(?:public|private|protected)?\s*interface\s+(\w+)`), "type"}, - {regexp.MustCompile(`^\s+(?:public|private|protected)?\s*(?:static\s+)?(?:\w+(?:<[^>]*>)?)\s+(\w+)\s*\(`), "method"}, -} - -func extractSymbolName(m []string, kind, ext string) string { - if kind == "method" && strings.EqualFold(ext, ".go") && len(m) >= 3 { - return m[1] + "." + m[2] - } - if len(m) >= 2 { - return m[1] - } - return "" -} - -func readLines(path string) ([]string, error) { - f, err := os.Open(path) // #nosec G304 -- path is the source file under analysis, supplied by the scanning tool's own localization phase/CLI target; reading arbitrary project files is this tool's purpose - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - var lines []string - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 256*1024), 1024*1024) - for sc.Scan() { - lines = append(lines, sc.Text()) - } - return lines, sc.Err() -} diff --git a/symbol_search_test.go b/symbol_search_test.go deleted file mode 100644 index 364fdf0..0000000 --- a/symbol_search_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package inspect_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/GrayCodeAI/inspect" -) - -const goFixture = `package example - -type Handler struct { - path string -} - -func NewHandler(path string) *Handler { - return &Handler{path: path} -} - -func (h *Handler) ServeHTTP(w interface{}, r interface{}) {} - -const MaxConns = 100 -var DefaultTimeout = 30 -` - -func writeTempFile(t *testing.T, name, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write fixture: %v", err) - } - return path -} - -func TestSearchBySymbol_AllSymbols(t *testing.T) { - path := writeTempFile(t, "example.go", goFixture) - results, err := inspect.SearchBySymbol(path, "", "") - if err != nil { - t.Fatalf("SearchBySymbol: %v", err) - } - if len(results) == 0 { - t.Fatal("expected symbols, got none") - } -} - -func TestSearchBySymbol_NameFilter(t *testing.T) { - path := writeTempFile(t, "example.go", goFixture) - results, err := inspect.SearchBySymbol(path, "Handler", "") - if err != nil { - t.Fatalf("SearchBySymbol: %v", err) - } - if len(results) == 0 { - t.Fatal("expected at least one Handler symbol") - } -} - -func TestGetSymbolBody_Found(t *testing.T) { - path := writeTempFile(t, "example.go", goFixture) - result, err := inspect.GetSymbolBody(path, "NewHandler") - if err != nil { - t.Fatalf("GetSymbolBody: %v", err) - } - if result.Body == "" { - t.Error("body must not be empty") - } -} - -func TestGetSymbolBody_NotFound(t *testing.T) { - path := writeTempFile(t, "example.go", goFixture) - _, err := inspect.GetSymbolBody(path, "DoesNotExist") - if err == nil { - t.Error("expected error for missing symbol") - } -}