Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import (

type Scanner struct{}

func (Scanner) Version() int { return 1 }

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

var (
client = common.SaneHttpClient()
Expand Down
105 changes: 105 additions & 0 deletions pkg/detectors/jumpcloud/v2/jumpcloud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package jumpcloud

import (
"context"
"fmt"
"io"
"net/http"

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/detector_typepb"
)

type Scanner struct {
client *http.Client
}

func (Scanner) Version() int { return 2 }

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

var (
defaultClient = common.SaneHttpClient()

// jca_ prefix followed by 36 alphanumeric characters (40 total).
keyPat = regexp.MustCompile(`\b(jca_[a-zA-Z0-9]{36})\b`)
)

// Keywords are used for efficiently pre-filtering chunks.
// The jca_ prefix is self-identifying, no context keyword needed.
func (s Scanner) Keywords() []string {
return []string{"jca_"}
}

// FromData will find and optionally verify JumpCloud v2 API keys 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)

uniqueMatches := make(map[string]struct{})
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
uniqueMatches[match[1]] = struct{}{}
}

for match := range uniqueMatches {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_Jumpcloud,
Raw: []byte(match),
ExtraData: map[string]string{"version": "2"},
SecretParts: map[string]string{"key": match},
}

if verify {
client := s.client
if client == nil {
client = defaultClient
}

isVerified, verificationErr := verifyMatch(ctx, client, match)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, match)
}

results = append(results, s1)
}

return results, nil
}

func verifyMatch(ctx context.Context, client *http.Client, token string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://console.jumpcloud.com/api/v2/systemgroups", nil)
if err != nil {
return false, err
}

req.Header.Set("x-api-key", token)
res, err := client.Do(req)
if err != nil {
return false, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()

switch res.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
}
}

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_Jumpcloud
}

func (s Scanner) Description() string {
return "JumpCloud is a cloud-based directory service platform that offers user and device management, single sign-on, and other IAM features. JumpCloud v2 API keys use a jca_ prefix format and can be used to access and manage these services."
}
119 changes: 119 additions & 0 deletions pkg/detectors/jumpcloud/v2/jumpcloud_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package jumpcloud

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"

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

func TestJumpCloudV2_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})

tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern - env var",
input: `
# JumpCloud API configuration
export JUMPCLOUD_API_KEY="jca_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890"
`,
want: []string{"jca_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890"},
},
{
name: "valid pattern - config file",
input: `
api_key: jca_r7m2Xk9pL4nQ8vB3wF6yH1jD5sA0tC2eG4iK
server: https://console.jumpcloud.com
`,
want: []string{"jca_r7m2Xk9pL4nQ8vB3wF6yH1jD5sA0tC2eG4iK"},
},
{
name: "valid pattern - code usage",
input: `
func main() {
req, _ := http.NewRequest("GET", "https://console.jumpcloud.com/api/v2/systemgroups", nil)
req.Header.Set("x-api-key", "jca_Tm4nQ8vB3wF6yH1jD5sA0tC2eG4iK7oUp9xL")
client := &http.Client{}
resp, _ := client.Do(req)
defer func() { _ = resp.Body.Close() }()
}
`,
want: []string{"jca_Tm4nQ8vB3wF6yH1jD5sA0tC2eG4iK7oUp9xL"},
},
{
name: "valid pattern - deduplicates",
input: `
primary = "jca_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890"
backup = "jca_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890"
`,
want: []string{"jca_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890"},
},
{
name: "invalid pattern - too short",
input: `jca_aBcDeFgHiJkLmNoPqRsTuVwXyZ12345`,
want: nil,
},
{
name: "invalid pattern - too long",
input: `jca_aBcDeFgHiJkLmNoPqRsTuVwXyZ12345678901`,
want: nil,
},
{
name: "invalid pattern - special characters",
input: `jca_aBcDeFgHi-kLmNoPqRsTuVwXyZ123456789!`,
want: nil,
},
{
name: "invalid pattern - no jca_ prefix",
input: `JUMPCLOUD_API_KEY=aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890abcd`,
want: nil,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(matchedDetectors) == 0 {
if len(test.want) > 0 {
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
}
return
}

results, err := d.FromData(context.Background(), false, []byte(test.input))
require.NoError(t, err)

if len(results) != len(test.want) {
t.Errorf("expected %d results, got %d", len(test.want), len(results))
return
}

actual := make(map[string]struct{}, len(results))
for _, r := range results {
if len(r.RawV2) > 0 {
actual[string(r.RawV2)] = struct{}{}
} else {
actual[string(r.Raw)] = struct{}{}
}
}

expected := make(map[string]struct{}, len(test.want))
for _, v := range test.want {
expected[v] = struct{}{}
}

if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("%s diff: (-want +got)\n%s", test.name, diff)
}
})
}
}
6 changes: 4 additions & 2 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,8 @@ import (
jiratokenv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jiratoken/v1"
jiratokenv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jiratoken/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jotform"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jumpcloud"
jumpcloudv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jumpcloud/v1"
jumpcloudv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jumpcloud/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jupiterone"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/juro"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jwt"
Expand Down Expand Up @@ -1291,7 +1292,8 @@ func buildDetectorList() []detectors.Detector {
&jiratokenv1.Scanner{},
&jiratokenv2.Scanner{},
&jotform.Scanner{},
&jumpcloud.Scanner{},
&jumpcloudv1.Scanner{},
&jumpcloudv2.Scanner{},
&jupiterone.Scanner{},
&juro.Scanner{},
&jwt.Scanner{},
Expand Down