Skip to content

Commit e576a83

Browse files
authored
Merge pull request #5 from avithe-great/feat-orphan-api
feat(Orphan APIs): update orphan API detection logic
2 parents 1cab4c7 + 424f659 commit e576a83

File tree

7 files changed

+153
-45
lines changed

7 files changed

+153
-45
lines changed

cmd/root.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ func init() {
2727

2828
var RootCmd = &cobra.Command{
2929
Use: "speculator",
30-
Short: "speculator is a utility that identifies shadow and zombie APIs by analyzing API traffic against provided API specifications",
31-
Long: `speculator helps you to secure your APIs by identifying shadow and zombie APIs.
30+
Short: "speculator is a utility that identifies shadow, orphan, and zombie APIs by analyzing API traffic against provided API specifications",
31+
32+
Long: `speculator helps you secure your APIs by identifying shadow, orphan, and zombie APIs.
3233
3334
By analyzing API traffic in conjunction with your API specifications (e.g., OpenAPI, Swagger), speculator can detect:
3435
* Shadow APIs: Endpoints that are implemented and functional but not documented in your API specification.
35-
* Zombie APIs: Endpoints that are deprecated or abandoned in your API specification but they are still in use.
36+
* Zombie APIs: Endpoints that are deprecated or abandoned in your API specification but are still in use.
37+
* Orphan APIs: Endpoints that are defined in your API specification but are never invoked in the observed traffic.
3638
`,
3739
//Long: `A Utility to identify Shadow and Zombie APIs provided API Specification`,
3840
Run: func(cmd *cobra.Command, args []string) {

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ require (
88
github.com/pb33f/libopenapi v0.21.9
99
github.com/spf13/cobra v1.9.1
1010
github.com/spf13/viper v1.20.1
11+
github.com/stretchr/testify v1.10.0
1112
go.mongodb.org/mongo-driver v1.17.3
1213
go.uber.org/zap v1.27.0
1314
)
1415

1516
require (
1617
github.com/bahlo/generic-list-go v0.2.0 // indirect
1718
github.com/buger/jsonparser v1.1.1 // indirect
19+
github.com/davecgh/go-spew v1.1.1 // indirect
1820
github.com/fsnotify/fsnotify v1.9.0 // indirect
1921
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
2022
github.com/golang/snappy v0.0.4 // indirect
@@ -23,6 +25,7 @@ require (
2325
github.com/mailru/easyjson v0.9.0 // indirect
2426
github.com/montanaflynn/stats v0.7.1 // indirect
2527
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
28+
github.com/pmezard/go-difflib v1.0.0 // indirect
2629
github.com/rogpeppe/go-internal v1.12.0 // indirect
2730
github.com/sagikazarmark/locafero v0.9.0 // indirect
2831
github.com/sourcegraph/conc v0.3.0 // indirect

internal/apispec/pathparam.go

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,26 @@ import (
1414

1515
var digitCheck = regexp.MustCompile(`^[0-9]+$`)
1616

17-
func UnifyParameterizedPathIfApplicable(path string) string {
17+
// UnifyParameterizedPathIfApplicable normalizes a path by replacing dynamic segments with {paramN}.
18+
// If isSpec = true, also treats existing {param} segments in OpenAPI specs as parameters.
19+
func UnifyParameterizedPathIfApplicable(path string, isSpec bool) string {
20+
if path == "" {
21+
return ""
22+
}
23+
if path == "/" {
24+
return "/"
25+
}
26+
27+
pathParts := strings.Split(strings.Trim(path, "/"), "/")
1828
var parameterizedPathParts []string
1929
paramCount := 0
20-
pathParts := strings.Split(path, "/")
2130

2231
for _, part := range pathParts {
32+
if isSpec && strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
33+
parameterizedPathParts = append(parameterizedPathParts, part)
34+
continue
35+
}
36+
2337
if isSuspectPathParam(part) {
2438
paramCount++
2539
paramName := fmt.Sprintf("param%v", paramCount)
@@ -28,36 +42,19 @@ func UnifyParameterizedPathIfApplicable(path string) string {
2842
parameterizedPathParts = append(parameterizedPathParts, part)
2943
}
3044
}
31-
return strings.Join(parameterizedPathParts, "/")
32-
}
33-
34-
func isSuspectPathParam(pathPart string) bool {
35-
if isNumber(pathPart) {
36-
return true
37-
}
38-
if isUUID(pathPart) {
39-
return true
40-
}
41-
if isMixed(pathPart) {
42-
return true
43-
}
44-
if isString(pathPart) {
45-
return true
46-
}
47-
return false
45+
return "/" + strings.Join(parameterizedPathParts, "/")
4846
}
4947

50-
func isString(pathPart string) bool {
51-
return strings.Contains(pathPart, "{") &&
52-
strings.Contains(pathPart, "}")
48+
func isSuspectPathParam(part string) bool {
49+
return isNumber(part) || isUUID(part) || isMixed(part)
5350
}
5451

55-
func isNumber(pathPart string) bool {
56-
return digitCheck.MatchString(pathPart)
52+
func isNumber(s string) bool {
53+
return digitCheck.MatchString(s)
5754
}
5855

59-
func isUUID(pathPart string) bool {
60-
_, err := uuid.FromString(pathPart)
56+
func isUUID(s string) bool {
57+
_, err := uuid.FromString(s)
6158
return err == nil
6259
}
6360

internal/apispec/pathparam_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package apispec
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestUnifyParameterizedPathIfApplicable(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input string
13+
isSpec bool
14+
expected string
15+
}{
16+
{
17+
name: "static path - no change",
18+
input: "/users/list",
19+
isSpec: false,
20+
expected: "/users/list",
21+
},
22+
{
23+
name: "numeric ID in path",
24+
input: "/users/123",
25+
isSpec: false,
26+
expected: "/users/{param1}",
27+
},
28+
{
29+
name: "UUID in path",
30+
input: "/orders/550e8400-e29b-41d4-a716-446655440000",
31+
isSpec: false,
32+
expected: "/orders/{param1}",
33+
},
34+
{
35+
name: "mixed alphanumeric long string",
36+
input: "/data/abc12345xyz",
37+
isSpec: false,
38+
expected: "/data/{param1}",
39+
},
40+
{
41+
name: "short alphanumeric (not treated as param)",
42+
input: "/data/ab12",
43+
isSpec: false,
44+
expected: "/data/ab12",
45+
},
46+
{
47+
name: "multiple parameters in path",
48+
input: "/users/123/orders/550e8400-e29b-41d4-a716-446655440000",
49+
isSpec: false,
50+
expected: "/users/{param1}/orders/{param2}",
51+
},
52+
{
53+
name: "already parameterized spec path",
54+
input: "/users/{userId}",
55+
isSpec: true,
56+
expected: "/users/{userId}",
57+
},
58+
{
59+
name: "spec path with multiple params",
60+
input: "/users/{userId}/orders/{orderId}",
61+
isSpec: true,
62+
expected: "/users/{userId}/orders/{orderId}",
63+
},
64+
{
65+
name: "root path",
66+
input: "/",
67+
isSpec: false,
68+
expected: "/",
69+
},
70+
{
71+
name: "empty path",
72+
input: "",
73+
isSpec: false,
74+
expected: "",
75+
},
76+
}
77+
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
result := UnifyParameterizedPathIfApplicable(tt.input, tt.isSpec)
81+
assert.Equal(t, tt.expected, result)
82+
})
83+
}
84+
}

internal/apispec/query.go

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,13 @@ func ExtractQueryAndParams(path string) (string, url.Values) {
2121
return query, values
2222
}
2323

24-
func GetPathAndQuery(fullPath string) (path, query string) {
25-
// Example: "/example-path?param=value" returns "/example-path", "param=value"
26-
index := strings.IndexByte(fullPath, '?')
27-
if index == -1 {
28-
return fullPath, ""
24+
// GetPathAndQuery splits a URL into path and query components.
25+
func GetPathAndQuery(fullPath string) (string, string) {
26+
if idx := strings.IndexByte(fullPath, '?'); idx != -1 {
27+
if idx == len(fullPath)-1 {
28+
return fullPath[:idx], ""
29+
}
30+
return fullPath[:idx], fullPath[idx+1:]
2931
}
30-
31-
// Example: "/path?" returns "/path?", ""
32-
if index == (len(fullPath) - 1) {
33-
return fullPath, ""
34-
}
35-
36-
path = fullPath[:index]
37-
query = fullPath[index+1:]
38-
return
32+
return fullPath, ""
3933
}

internal/apispec/query_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2024 Authors of API-Speculator
3+
4+
package apispec
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestGetPathAndQuery(t *testing.T) {
13+
tests := []struct {
14+
input string
15+
wantPath string
16+
wantQuery string
17+
}{
18+
{"/api/v1/users?id=123", "/api/v1/users", "id=123"},
19+
{"/api/v1/users?", "/api/v1/users", ""},
20+
{"/api/v1/users", "/api/v1/users", ""},
21+
}
22+
23+
for _, tt := range tests {
24+
path, query := GetPathAndQuery(tt.input)
25+
assert.Equal(t, tt.wantPath, path)
26+
assert.Equal(t, tt.wantQuery, query)
27+
}
28+
}

internal/core/discovery.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (m *Manager) findOrphanApi(events *hashset.Set, model *libopenapi.DocumentM
8888
}
8989

9090
requestPath, _ := apispec.GetPathAndQuery(event.RequestPath)
91-
requestPath = apispec.UnifyParameterizedPathIfApplicable(requestPath)
91+
requestPath = apispec.UnifyParameterizedPathIfApplicable(requestPath, false)
9292
requestMethod := strings.ToUpper(event.RequestMethod)
9393
key := fmt.Sprintf("%v/%v", requestMethod, requestPath)
9494

@@ -99,7 +99,7 @@ func (m *Manager) findOrphanApi(events *hashset.Set, model *libopenapi.DocumentM
9999

100100
for pathItems := model.Model.Paths.PathItems.First(); pathItems != nil; pathItems = pathItems.Next() {
101101
for operations := pathItems.Value().GetOperations().First(); operations != nil; operations = operations.Next() {
102-
requestPath := apispec.UnifyParameterizedPathIfApplicable(pathItems.Key())
102+
requestPath := apispec.UnifyParameterizedPathIfApplicable(pathItems.Key(), true)
103103
requestMethod := strings.ToUpper(operations.Key())
104104
key := fmt.Sprintf("%v/%v", requestMethod, requestPath)
105105

0 commit comments

Comments
 (0)