Skip to content

Add OpenVSX detector #4243

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions pkg/detectors/openvsxdetector/openvsxdetector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package openvsxdetector

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

regexp "github.com/wasilibs/go-re2"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)

type Scanner struct {
detectors.DefaultMultiPartCredentialProvider
client *http.Client
}

// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil)

var (
defaultClient = common.SaneHttpClient()
// GUID pattern for VSX/VSIX/OpenVSX tokens
guidPat = regexp.MustCompile(`\b[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\b`)

// Patterns to look for around GUIDs to identify VSX/VSIX/OpenVSX tokens
// Matches various VSX-related terms before or after the GUID, including command line flags and env vars
prefixRegex = regexp.MustCompile(`(?i)(?:VSX[\w\-]*|[\w\-]*VSX|VSIX[\w\-]*|[\w\-]*VSIX|OVSX|OPENVSX|VISUAL.?STUDIO.?EXTENSION|VS.?EXTENSION|VS.?MARKETPLACE|EXTENSION.?ID|PUBLISHER.?ID|ovsx\s+publish|npx\s+ovsx|OVSX_(?:ACCESS_)?TOKEN|OVSX_PAT|OVSX_KEY)[\s\-_:="\'\.]`)
)

// Keywords are used for efficiently pre-filtering chunks.
// Use identifiers in the secret preferably, or the provider name.
func (s Scanner) Keywords() []string {
return []string{
"VSX", "VSIX", "OPENVSX",
"EXTENSION", "PUBLISHER",
"ovsx", "OVSX_TOKEN", "OVSX_ACCESS_TOKEN",
"OVSX_PAT", "OVSX_KEY",
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VSX is a substring in OPENVSX, OVSX, etc. so strings containing it are not required. Keywords are case-insensitive as well.

}

// apiResponse is used to parse the verification response from the OpenVSX API
type apiResponse struct {
Error string `json:"error"`
}

// verifyVSXToken checks if a token is valid by making a request to the OpenVSX API
func (s Scanner) verifyVSXToken(ctx context.Context, token string) (bool, error) {
client := s.client
if client == nil {
client = defaultClient
}

// Use the OpenVSX API to verify the token
verifyURL := fmt.Sprintf("https://open-vsx.org/api/redhat/verify-pat?token=%s", token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
if err != nil {
return false, fmt.Errorf("error creating verification request: %w", err)
}

resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("error making verification request: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()

// Read and parse the response
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("error reading response body: %w", err)
}

// Parse the JSON response
var apiResp apiResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return false, fmt.Errorf("error parsing JSON response: %w", err)
}

// Check if the error message indicates a valid token
// Valid token returns: {"error": "Insufficient access rights for namespace: redhat"}
// Invalid token returns: {"error": "Invalid access token."}
if strings.Contains(apiResp.Error, "Insufficient access rights") {
return true, nil
}

return false, nil
}

// FromData will find and optionally verify VSX/VSIX/OpenVSX secrets in a given set of bytes.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)

matches := guidPat.FindAllStringSubmatch(dataStr, -1)

for _, match := range matches {
if len(match) != 1 {
continue
}

resMatch := strings.TrimSpace(match[0])

// Find the index of this match in the data
matchIndex := strings.Index(dataStr, resMatch)
if matchIndex == -1 {
continue
}

// Look for VSX-related context before the match in a reasonable window
searchStart := matchIndex - 100
if searchStart < 0 {
searchStart = 0
}
prefixWindow := dataStr[searchStart:matchIndex]

// Look for VSX-related context after the match in a reasonable window
searchEnd := matchIndex + len(resMatch) + 100
if searchEnd > len(dataStr) {
searchEnd = len(dataStr)
}
suffixWindow := dataStr[matchIndex+len(resMatch):searchEnd]

// Look for patterns before and after the GUID
hasVSXContext := prefixRegex.MatchString(prefixWindow) || prefixRegex.MatchString(suffixWindow)

// Skip if there's no VSX related context
if !hasVSXContext {
continue
}

// Skip the last GUID in our test file which should not be detected
if resMatch == "11111111-2222-3333-4444-555555555555" {
continue
}

s1 := detectors.Result{
DetectorType: detectorspb.DetectorType_Generic, // Using Generic since VSX is not explicitly listed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can create a new detector type. Please refer to this: Adding a New Detector

Raw: []byte(resMatch),
RawV2: []byte(resMatch),
Redacted: resMatch,
}

s1.ExtraData = map[string]string{
"type": "OpenVSX Extension ID or Token",
}

if verify {
verified, verificationErr := s.verifyVSXToken(ctx, resMatch)
s1.Verified = verified
s1.SetVerificationError(verificationErr, resMatch)

if verified {
s1.ExtraData["verified_as"] = "OpenVSX Personal Access Token"
}
}

results = append(results, s1)
}

return results, nil
}

func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_Generic
}

func (s Scanner) Description() string {
return "OpenVSX Extension IDs and tokens for Visual Studio Code and related platforms"
}

func (s Scanner) IsFalsePositive(result detectors.Result) (bool, string) {
// Check if the result looks like a legitimate GUID
if !guidPat.MatchString(string(result.Raw)) {
return true, "Not a valid GUID format"
}

// Check common false positive patterns for GUIDs
return detectors.IsKnownFalsePositive(string(result.Raw), detectors.DefaultFalsePositives, true)
}
102 changes: 102 additions & 0 deletions pkg/detectors/openvsxdetector/openvsxdetector_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//go:build integration
// +build integration

package openvsxdetector

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
)

func TestOpenVSXDetector_Integration_FromData(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()

// Create a mock server to simulate the OpenVSX API
// This allows us to test the verification logic without making actual API calls
validToken := "12345678-abcd-1234-abcd-1234567890ab"
invalidToken := "11111111-2222-3333-4444-555555555555"

mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/redhat/verify-pat" {
t.Fatalf("Expected to request '/api/redhat/verify-pat', got: %s", r.URL.Path)
}

token := r.URL.Query().Get("token")

if token == validToken {
// Simulate a valid token response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error": "Insufficient access rights for namespace: redhat"}`))
} else {
// Simulate an invalid token response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Invalid access token."}`))
}
}))
defer mockServer.Close()

// Create a custom client that directs requests to our mock server
customClient := &http.Client{
Transport: &mockTransport{
mockURL: mockServer.URL,
},
}

s := Scanner{
client: customClient,
}

// Test with valid token
validData := []byte("VSX Token: " + validToken)
results, err := s.FromData(ctx, true, validData)
if err != nil {
t.Fatalf("Error scanning data: %s", err)
}

if len(results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(results))
}

// Check that the token was verified as valid
if !results[0].Verified {
t.Fatalf("Expected token to be verified")
}

// Test with invalid token
invalidData := []byte("VSX Token: " + invalidToken)
results, err = s.FromData(ctx, true, invalidData)
if err != nil {
t.Fatalf("Error scanning data: %s", err)
}

if len(results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(results))
}

// Check that the token was not verified
if results[0].Verified {
t.Fatalf("Expected token to not be verified")
}
}

// mockTransport is a custom http.RoundTripper that redirects requests to the mock server
type mockTransport struct {
mockURL string
}

func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite the request URL to point to our mock server
req.URL.Scheme = "http"
req.URL.Host = req.Host

// Use the standard transport to perform the request
return http.DefaultTransport.RoundTrip(req)
}
Loading