Skip to content

Commit 94d5061

Browse files
authored
Harness Detector Implementation (#4085)
* task: harness detector implementation * task: regex fix (removed magic string) | test files updated * refactor: incorporated feedback regarding string conversion and moving struct to package level * refactor: Harness - ignore ExtraData field in integration test
1 parent 60e359b commit 94d5061

File tree

6 files changed

+390
-6
lines changed

6 files changed

+390
-6
lines changed

pkg/detectors/harness/harness.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package harness
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strconv"
10+
11+
regexp "github.com/wasilibs/go-re2"
12+
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
16+
)
17+
18+
// Response struct for decoding API responses.
19+
type response struct {
20+
Data struct {
21+
LastLogin int `json:"lastLogin"`
22+
} `json:"data"`
23+
}
24+
25+
type Scanner struct {
26+
client *http.Client
27+
}
28+
29+
// Ensure the Scanner satisfies the interface at compile time.
30+
var _ detectors.Detector = (*Scanner)(nil)
31+
32+
var (
33+
defaultClient = common.SaneHttpClient()
34+
35+
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"harness"}) + `\b(pat\.[A-Za-z0-9]{22}\.[0-9a-f]{24}\.[A-Za-z0-9]{20})\b`)
36+
)
37+
38+
func (s Scanner) getClient() *http.Client {
39+
if s.client != nil {
40+
return s.client
41+
}
42+
43+
return defaultClient
44+
}
45+
46+
// Keywords are used for efficiently pre-filtering chunks.
47+
// Use identifiers in the secret preferably, or the provider name.
48+
func (s Scanner) Keywords() []string {
49+
return []string{"harness"}
50+
}
51+
52+
// FromData will find and optionally verify Harness secrets in a given set of bytes.
53+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
54+
dataStr := string(data)
55+
56+
uniqueMatches := make(map[string]struct{})
57+
58+
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
59+
uniqueMatches[match[1]] = struct{}{}
60+
}
61+
62+
for match := range uniqueMatches {
63+
s1 := detectors.Result{
64+
DetectorType: detectorspb.DetectorType_Harness,
65+
Raw: []byte(match),
66+
}
67+
68+
if verify {
69+
client := s.getClient()
70+
71+
isVerified, extraData, verificationErr := verifyMatch(ctx, client, match)
72+
s1.Verified = isVerified
73+
s1.ExtraData = extraData
74+
s1.SetVerificationError(verificationErr, match)
75+
}
76+
77+
results = append(results, s1)
78+
}
79+
80+
return
81+
}
82+
83+
/*
84+
In the document, all of the APIs are required to provide API Key Token as header and
85+
accountIdentifier as query parameter. Although, the below API returns successful response
86+
without providing accountIdentifier as query parameter.
87+
We may need to update this if Harness decides to enforce this in the future.
88+
API Reference: https://apidocs.harness.io/tag/User/#operation/getCurrentUserInfo
89+
*/
90+
func verifyMatch(ctx context.Context, client *http.Client, token string) (bool, map[string]string, error) {
91+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://app.harness.io/ng/api/user/currentUser", nil)
92+
if err != nil {
93+
return false, nil, nil
94+
}
95+
96+
req.Header.Set("x-api-key", token)
97+
98+
res, err := client.Do(req)
99+
if err != nil {
100+
return false, nil, err
101+
}
102+
defer func() {
103+
_, _ = io.Copy(io.Discard, res.Body)
104+
_ = res.Body.Close()
105+
}()
106+
107+
switch res.StatusCode {
108+
case http.StatusOK:
109+
extraData := make(map[string]string)
110+
var response response
111+
if err := json.NewDecoder(res.Body).Decode(&response); err == nil {
112+
extraData["last_login"] = strconv.Itoa(response.Data.LastLogin)
113+
return true, extraData, nil
114+
}
115+
return true, nil, nil
116+
case http.StatusUnauthorized:
117+
return false, nil, nil
118+
default:
119+
return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
120+
}
121+
}
122+
123+
func (s Scanner) Type() detectorspb.DetectorType {
124+
return detectorspb.DetectorType_Harness
125+
}
126+
127+
func (s Scanner) Description() string {
128+
return "Harness.io is an AI-driven CI/CD platform that automates software delivery, streamlining code building, testing, and deployment with intelligent optimization and multi-cloud support."
129+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package harness
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/google/go-cmp/cmp/cmpopts"
14+
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
17+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
18+
)
19+
20+
func TestHarness_FromChunk(t *testing.T) {
21+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
22+
defer cancel()
23+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
24+
if err != nil {
25+
t.Fatalf("could not get test secrets from GCP: %s", err)
26+
}
27+
apiKeyToken := testSecrets.MustGetField("HARNESS_TOKEN")
28+
inactiveApiKeyToken := testSecrets.MustGetField("HARNESS_INACTIVE")
29+
30+
type args struct {
31+
ctx context.Context
32+
data []byte
33+
verify bool
34+
}
35+
tests := []struct {
36+
name string
37+
s Scanner
38+
args args
39+
want []detectors.Result
40+
wantErr bool
41+
wantVerificationErr bool
42+
}{
43+
{
44+
name: "found, verified",
45+
s: Scanner{},
46+
args: args{
47+
ctx: context.Background(),
48+
data: []byte(fmt.Sprintf("You can find a harness api key token %s within", apiKeyToken)),
49+
verify: true,
50+
},
51+
want: []detectors.Result{
52+
{
53+
DetectorType: detectorspb.DetectorType_Harness,
54+
Verified: true,
55+
},
56+
},
57+
wantErr: false,
58+
wantVerificationErr: false,
59+
},
60+
{
61+
name: "found, unverified",
62+
s: Scanner{},
63+
args: args{
64+
ctx: context.Background(),
65+
data: []byte(fmt.Sprintf("You can find a harness harness api key token %s within but not valid", inactiveApiKeyToken)), // the secret would satisfy the regex but not pass validation
66+
verify: true,
67+
},
68+
want: []detectors.Result{
69+
{
70+
DetectorType: detectorspb.DetectorType_Harness,
71+
Verified: false,
72+
},
73+
},
74+
wantErr: false,
75+
wantVerificationErr: false,
76+
},
77+
{
78+
name: "not found",
79+
s: Scanner{},
80+
args: args{
81+
ctx: context.Background(),
82+
data: []byte("You cannot find the api key token within"),
83+
verify: true,
84+
},
85+
want: nil,
86+
wantErr: false,
87+
wantVerificationErr: false,
88+
},
89+
{
90+
name: "found, would be verified if not for timeout",
91+
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)},
92+
args: args{
93+
ctx: context.Background(),
94+
data: []byte(fmt.Sprintf("You can find a harness api key token %s within", apiKeyToken)),
95+
verify: true,
96+
},
97+
want: []detectors.Result{
98+
{
99+
DetectorType: detectorspb.DetectorType_Harness,
100+
Verified: false,
101+
},
102+
},
103+
wantErr: false,
104+
wantVerificationErr: true,
105+
},
106+
{
107+
name: "found, verified but unexpected api surface",
108+
s: Scanner{client: common.ConstantResponseHttpClient(404, "")},
109+
args: args{
110+
ctx: context.Background(),
111+
data: []byte(fmt.Sprintf("You can find a harness api key token %s within", apiKeyToken)),
112+
verify: true,
113+
},
114+
want: []detectors.Result{
115+
{
116+
DetectorType: detectorspb.DetectorType_Harness,
117+
Verified: false,
118+
},
119+
},
120+
wantErr: false,
121+
wantVerificationErr: true,
122+
},
123+
}
124+
for _, tt := range tests {
125+
t.Run(tt.name, func(t *testing.T) {
126+
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
127+
if (err != nil) != tt.wantErr {
128+
t.Errorf("Harness.FromData() error = %v, wantErr %v", err, tt.wantErr)
129+
return
130+
}
131+
for i := range got {
132+
if len(got[i].Raw) == 0 {
133+
t.Fatalf("no raw secret present: \n %+v", got[i])
134+
}
135+
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
136+
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
137+
}
138+
}
139+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError", "ExtraData")
140+
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
141+
t.Errorf("Harness.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
142+
}
143+
})
144+
}
145+
}
146+
147+
func BenchmarkFromData(benchmark *testing.B) {
148+
ctx := context.Background()
149+
s := Scanner{}
150+
for name, data := range detectors.MustGetBenchmarkData() {
151+
benchmark.Run(name, func(b *testing.B) {
152+
b.ResetTimer()
153+
for n := 0; n < b.N; n++ {
154+
_, err := s.FromData(ctx, false, data)
155+
if err != nil {
156+
b.Fatal(err)
157+
}
158+
}
159+
})
160+
}
161+
}

pkg/detectors/harness/harness_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package harness
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/google/go-cmp/cmp"
9+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
10+
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
11+
)
12+
13+
var (
14+
validKey = "pat.4oXWHvYFRNOGLVpFTZGGTA.68077fc826afe36865614d58.2fFEmr57WO3zPmev3jze"
15+
validKeyWithoutKeyword = `API Key Token: pat.4oXWHvYFRNOGLVpFTZGGTA.68077fc826afe36865614d58.2fFEmr57WO3zPmev3jze
16+
url |https://api.harness.io/`
17+
invalidKey = "pat.4oXWHvYFRNOGLVpFTZGGTA.6807c5bed9599c324f6368ce.usCT2fzvADwSoXzXc"
18+
keyword = "harness"
19+
)
20+
21+
func TestHarness_Pattern(t *testing.T) {
22+
d := Scanner{}
23+
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
24+
tests := []struct {
25+
name string
26+
input string
27+
want []string
28+
}{
29+
{
30+
name: "valid pattern",
31+
input: fmt.Sprintf("%s token = '%s'", keyword, validKey),
32+
want: []string{validKey},
33+
},
34+
{
35+
name: "valid pattern - no keyword",
36+
input: fmt.Sprintf("token = '%s'", validKeyWithoutKeyword),
37+
want: nil,
38+
},
39+
{
40+
name: "invalid pattern",
41+
input: fmt.Sprintf("%s token = '%s'", keyword, invalidKey),
42+
want: nil,
43+
},
44+
}
45+
46+
for _, test := range tests {
47+
t.Run(test.name, func(t *testing.T) {
48+
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
49+
if len(matchedDetectors) == 0 {
50+
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
51+
return
52+
}
53+
54+
results, err := d.FromData(context.Background(), false, []byte(test.input))
55+
if err != nil {
56+
t.Errorf("error = %v", err)
57+
return
58+
}
59+
60+
if len(results) != len(test.want) {
61+
if len(results) == 0 {
62+
t.Errorf("did not receive result")
63+
} else {
64+
t.Errorf("expected %d results, only received %d", len(test.want), len(results))
65+
}
66+
return
67+
}
68+
69+
actual := make(map[string]struct{}, len(results))
70+
for _, r := range results {
71+
if len(r.RawV2) > 0 {
72+
actual[string(r.RawV2)] = struct{}{}
73+
} else {
74+
actual[string(r.Raw)] = struct{}{}
75+
}
76+
}
77+
expected := make(map[string]struct{}, len(test.want))
78+
for _, v := range test.want {
79+
expected[v] = struct{}{}
80+
}
81+
82+
if diff := cmp.Diff(expected, actual); diff != "" {
83+
t.Errorf("%s diff: (-want +got)\n%s", test.name, diff)
84+
}
85+
})
86+
}
87+
}

pkg/engine/defaults/defaults.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ import (
339339
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/gumroad"
340340
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/gyazo"
341341
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/happyscribe"
342+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/harness"
342343
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/harvest"
343344
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/hellosign"
344345
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/helpcrunch"
@@ -1184,6 +1185,7 @@ func buildDetectorList() []detectors.Detector {
11841185
&gumroad.Scanner{},
11851186
&gyazo.Scanner{},
11861187
&happyscribe.Scanner{},
1188+
&harness.Scanner{},
11871189
&harvest.Scanner{},
11881190
&hellosign.Scanner{},
11891191
&helpcrunch.Scanner{},

0 commit comments

Comments
 (0)