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") - } -}