-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added Cisco Meraki API Key detector (#3367)
* Added cisco meraki apikey detector * addressed the comments * handled api response and saving orgs data in extra data * fixed linter --------- Co-authored-by: Zachary Rice <zachary.rice@trufflesec.com>
- Loading branch information
1 parent
23e8ae4
commit ce5da50
Showing
6 changed files
with
1,404 additions
and
1,003 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
package meraki | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
regexp "github.com/wasilibs/go-re2" | ||
"io" | ||
"net/http" | ||
|
||
"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 { | ||
client *http.Client | ||
} | ||
|
||
// merakiOrganizations is the partial response from the /organizations api of cisco Meraki. | ||
// api docs: https://developer.cisco.com/meraki/api-v1/get-organizations/ | ||
type merakiOrganizations struct { | ||
ID string `json:"id"` | ||
Name string `json:"name"` | ||
} | ||
|
||
// Ensure the Scanner satisfies the interface at compile time. | ||
var _ detectors.Detector = (*Scanner)(nil) | ||
|
||
var ( | ||
defaultClient = common.SaneHttpClient() | ||
|
||
apiKey = regexp.MustCompile(detectors.PrefixRegex([]string{"meraki"}) + `([0-9a-f]{40})`) | ||
) | ||
|
||
func (s Scanner) getClient() *http.Client { | ||
if s.client != nil { | ||
return s.client | ||
} | ||
|
||
return defaultClient | ||
} | ||
|
||
// 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{"meraki"} | ||
} | ||
|
||
func (s Scanner) Description() string { | ||
return "Cisco Meraki is a cloud-managed IT solution that provides networking, security, and device management through an easy-to-use interface." + | ||
"Meraki APIs make it possible to rapidly deploy and manage networks at scale, build on a platform of intelligent, cloud-connected IT products, and engage with users in powerful new ways." | ||
} | ||
|
||
// FromData will find and optionally verify Meraki API Key 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) | ||
|
||
// uniqueMatches will hold unique match values and ensure we only process unique matches found in the data string | ||
var uniqueMatches = make(map[string]struct{}) | ||
|
||
for _, match := range apiKey.FindAllStringSubmatch(dataStr, -1) { | ||
uniqueMatches[match[1]] = struct{}{} | ||
} | ||
|
||
for match := range uniqueMatches { | ||
s1 := detectors.Result{ | ||
DetectorType: detectorspb.DetectorType_Meraki, | ||
Raw: []byte(match), | ||
ExtraData: make(map[string]string), | ||
} | ||
|
||
if verify { | ||
client := s.getClient() | ||
organizations, isVerified, verificationErr := verifyMerakiApiKey(ctx, client, match) | ||
s1.Verified = isVerified | ||
if verificationErr != nil { | ||
s1.SetVerificationError(verificationErr) | ||
} | ||
|
||
// if organizations are not nil, which means token was verified. | ||
for _, org := range organizations { | ||
// format: ExtraData{"organization_1": "Example", organization_2": "Example"} | ||
s1.ExtraData[fmt.Sprintf("organization_%s", org.ID)] = org.Name | ||
} | ||
|
||
} | ||
|
||
results = append(results, s1) | ||
} | ||
|
||
return results, nil | ||
} | ||
|
||
func (s Scanner) Type() detectorspb.DetectorType { | ||
return detectorspb.DetectorType_Meraki | ||
} | ||
|
||
/* | ||
verifyMerakiApiKey verifies if the passed matched api key for meraki is active or not. | ||
docs: https://developer.cisco.com/meraki/api-v1/authorization/#authorization | ||
*/ | ||
func verifyMerakiApiKey(ctx context.Context, client *http.Client, match string) ([]merakiOrganizations, bool, error) { | ||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.meraki.com/api/v1/organizations", http.NoBody) | ||
if err != nil { | ||
return nil, false, err | ||
} | ||
|
||
// set the required auth header | ||
req.Header.Set("X-Cisco-Meraki-API-Key", match) | ||
|
||
resp, err := client.Do(req) | ||
if err != nil { | ||
return nil, false, err | ||
} | ||
defer func() { | ||
_, _ = io.Copy(io.Discard, resp.Body) | ||
_ = resp.Body.Close() | ||
}() | ||
|
||
switch resp.StatusCode { | ||
case http.StatusOK: | ||
// in case token is verified, capture the organization id's and name which are accessible via token. | ||
var organizations []merakiOrganizations | ||
if err = json.NewDecoder(resp.Body).Decode(&organizations); err != nil { | ||
return nil, false, err | ||
} | ||
|
||
return organizations, true, nil | ||
case http.StatusUnauthorized: | ||
return nil, false, nil | ||
default: | ||
return nil, false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
package meraki | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"testing" | ||
"time" | ||
|
||
"github.com/google/go-cmp/cmp" | ||
"github.com/google/go-cmp/cmp/cmpopts" | ||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/common" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" | ||
) | ||
|
||
func TestMeraki_FromChunk(t *testing.T) { | ||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) | ||
defer cancel() | ||
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") | ||
if err != nil { | ||
t.Fatalf("could not get test secrets from GCP: %s", err) | ||
} | ||
secret := testSecrets.MustGetField("MERAKI") | ||
inactiveSecret := testSecrets.MustGetField("MERAKI_INACTIVE") | ||
|
||
type args struct { | ||
ctx context.Context | ||
data []byte | ||
verify bool | ||
} | ||
tests := []struct { | ||
name string | ||
s Scanner | ||
args args | ||
want []detectors.Result | ||
wantErr bool | ||
wantVerificationErr bool | ||
}{ | ||
{ | ||
name: "found, verified", | ||
s: Scanner{}, | ||
args: args{ | ||
ctx: ctx, | ||
data: []byte(fmt.Sprintf("You can find a meraki apikey %s within", secret)), | ||
verify: true, | ||
}, | ||
want: []detectors.Result{ | ||
{ | ||
DetectorType: detectorspb.DetectorType_Meraki, | ||
Verified: true, | ||
}, | ||
}, | ||
wantErr: false, | ||
wantVerificationErr: false, | ||
}, | ||
{ | ||
name: "found, unverified", | ||
s: Scanner{}, | ||
args: args{ | ||
ctx: ctx, | ||
data: []byte(fmt.Sprintf("You can find a meraki apikey secret %s within but not valid", inactiveSecret)), // the secret would satisfy the regex but not pass validation | ||
verify: true, | ||
}, | ||
want: []detectors.Result{ | ||
{ | ||
DetectorType: detectorspb.DetectorType_Meraki, | ||
Verified: false, | ||
}, | ||
}, | ||
wantErr: false, | ||
wantVerificationErr: false, | ||
}, | ||
{ | ||
name: "not found", | ||
s: Scanner{}, | ||
args: args{ | ||
ctx: context.Background(), | ||
data: []byte("You cannot find the secret within"), | ||
verify: true, | ||
}, | ||
want: nil, | ||
wantErr: false, | ||
wantVerificationErr: false, | ||
}, | ||
{ | ||
name: "found, would be verified if not for timeout", | ||
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)}, | ||
args: args{ | ||
ctx: context.Background(), | ||
data: []byte(fmt.Sprintf("You can find a meraki apikey %s within", secret)), | ||
verify: true, | ||
}, | ||
want: []detectors.Result{ | ||
{ | ||
DetectorType: detectorspb.DetectorType_Meraki, | ||
Verified: false, | ||
}, | ||
}, | ||
wantErr: false, | ||
wantVerificationErr: true, | ||
}, | ||
{ | ||
name: "found, verified but unexpected api surface", | ||
s: Scanner{client: common.ConstantResponseHttpClient(500, "")}, | ||
args: args{ | ||
ctx: context.Background(), | ||
data: []byte(fmt.Sprintf("You can find a meraki secret %s within", secret)), | ||
verify: true, | ||
}, | ||
want: []detectors.Result{ | ||
{ | ||
DetectorType: detectorspb.DetectorType_Meraki, | ||
Verified: false, | ||
}, | ||
}, | ||
wantErr: false, | ||
wantVerificationErr: true, | ||
}, | ||
} | ||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) | ||
if (err != nil) != tt.wantErr { | ||
t.Errorf("Meraki.FromData() error = %v, wantErr %v", err, tt.wantErr) | ||
return | ||
} | ||
for i := range got { | ||
if len(got[i].Raw) == 0 { | ||
t.Fatalf("no raw secret present: \n %+v", got[i]) | ||
} | ||
if (got[i].VerificationError() != nil) != tt.wantVerificationErr { | ||
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError()) | ||
} | ||
} | ||
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") | ||
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { | ||
t.Errorf("Meraki.FromData() %s diff: (-got +want)\n%s", tt.name, diff) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func BenchmarkFromData(benchmark *testing.B) { | ||
ctx := context.Background() | ||
s := Scanner{} | ||
for name, data := range detectors.MustGetBenchmarkData() { | ||
benchmark.Run(name, func(b *testing.B) { | ||
b.ResetTimer() | ||
for n := 0; n < b.N; n++ { | ||
_, err := s.FromData(ctx, false, data) | ||
if err != nil { | ||
b.Fatal(err) | ||
} | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
package meraki | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/google/go-cmp/cmp" | ||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" | ||
) | ||
|
||
var ( | ||
// example picked from: https://github.com/CiscoLearning/ciscolive-ltrcrt-1100/blob/cd0b8f14883ccd70e2db370f8f7a36534bdfe073/02-intro-python/code/meraki_api_info.md?plain=1#L8 | ||
validPattern = `Information used in API calls for meraki | ||
Variable name | Initial Value | ||
apiKey |e9e0f062f587b423bb6cc6328eb786d75b45783e | ||
baseUrl |https://api.meraki.com/api/v1 | ||
organizationId |646829496481091262 | ||
networkId |L_646829496481117067 | ||
serial |` | ||
|
||
validPatternWithNoKeyword = `Information used in API calls | ||
Variable name | Initial Value | ||
apiKey |e9e0f062f587b423bb6cc6328eb786d75b45783e | ||
baseUrl |https://api.meraki.com/api/v1 | ||
organizationId |646829496481091262 | ||
networkId |L_646829496481117067 | ||
serial |` | ||
|
||
invalidPattern = "001A1E0092C7a711d7679d%d0d442d59b05ce65D" | ||
) | ||
|
||
func TestMeraki_Pattern(t *testing.T) { | ||
d := Scanner{} | ||
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) | ||
|
||
tests := []struct { | ||
name string | ||
input string | ||
want []string | ||
}{ | ||
{ | ||
name: "valid pattern", | ||
input: fmt.Sprintf("meraki token = '%s'", validPattern), | ||
want: []string{"e9e0f062f587b423bb6cc6328eb786d75b45783e"}, | ||
}, | ||
{ | ||
name: "valid pattern - out of prefix range", | ||
input: fmt.Sprintf("meraki token keyword is not close to the real token = '%s'", validPatternWithNoKeyword), | ||
want: nil, | ||
}, | ||
{ | ||
name: "invalid pattern", | ||
input: fmt.Sprintf("meraki = '%s'", invalidPattern), | ||
want: nil, | ||
}, | ||
} | ||
|
||
for _, test := range tests { | ||
t.Run(test.name, func(t *testing.T) { | ||
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) | ||
if len(matchedDetectors) == 0 { | ||
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input) | ||
return | ||
} | ||
|
||
results, err := d.FromData(context.Background(), false, []byte(test.input)) | ||
if err != nil { | ||
t.Errorf("error = %v", err) | ||
return | ||
} | ||
|
||
if len(results) != len(test.want) { | ||
if len(results) == 0 { | ||
t.Errorf("did not receive result") | ||
} else { | ||
t.Errorf("expected %d results, only received %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) | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.