Skip to content

Commit 76cad6d

Browse files
committed
clarify quota
1 parent 1b8a46c commit 76cad6d

2 files changed

Lines changed: 207 additions & 6 deletions

File tree

pkg/subscraping/sources/virustotal/virustotal.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package virustotal
44
import (
55
"context"
66
"fmt"
7+
"net/http"
78
"time"
89

910
jsoniter "github.com/json-iterator/go"
@@ -69,20 +70,27 @@ func (s *Source) Run(ctx context.Context, domain string, session *subscraping.Se
6970
s.requests++
7071
resp, err := session.Get(ctx, url, "", map[string]string{"x-apikey": randomApiKey})
7172
if err != nil {
73+
// The free tier grants 500 requests/day; once it is exhausted every
74+
// call returns HTTP 429. Surface an actionable message instead of the
75+
// generic "unexpected status code 429" so operators know to supply an
76+
// enterprise key or lower the scope (see #1718).
77+
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
78+
err = fmt.Errorf("virustotal quota exhausted (HTTP 429); some subdomains for %s may be missing", domain)
79+
}
7280
results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
7381
s.errors++
7482
session.DiscardHTTPResponse(resp)
7583
return
7684
}
77-
defer func() {
78-
if err := resp.Body.Close(); err != nil {
79-
results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
80-
s.errors++
81-
}
82-
}()
8385

8486
var data response
8587
err = jsoniter.NewDecoder(resp.Body).Decode(&data)
88+
// Close the body per iteration; deferring inside the loop would keep
89+
// every page's body (and its connection) open until the goroutine exits.
90+
if closeErr := resp.Body.Close(); closeErr != nil {
91+
results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: closeErr}
92+
s.errors++
93+
}
8694
if err != nil {
8795
results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
8896
s.errors++
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package virustotal
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math"
7+
"net/http"
8+
"net/http/httptest"
9+
"net/url"
10+
"sync/atomic"
11+
"testing"
12+
"time"
13+
14+
"github.com/projectdiscovery/ratelimit"
15+
"github.com/projectdiscovery/subfinder/v2/pkg/subscraping"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
// rewriteTransport sends every request to the test server's host while
21+
// preserving the original path/query, so the source's hardcoded
22+
// www.virustotal.com URL still resolves to our handler.
23+
type rewriteTransport struct {
24+
target *url.URL
25+
}
26+
27+
func (r *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
28+
req.URL.Scheme = r.target.Scheme
29+
req.URL.Host = r.target.Host
30+
return http.DefaultTransport.RoundTrip(req)
31+
}
32+
33+
func newTestSession(t *testing.T, server *httptest.Server, maxResults int) *subscraping.Session {
34+
t.Helper()
35+
target, err := url.Parse(server.URL)
36+
require.NoError(t, err)
37+
38+
mrl, err := ratelimit.NewMultiLimiter(context.Background(), &ratelimit.Options{
39+
Key: "virustotal",
40+
IsUnlimited: false,
41+
MaxCount: math.MaxInt32,
42+
Duration: time.Millisecond,
43+
})
44+
require.NoError(t, err)
45+
46+
return &subscraping.Session{
47+
Client: &http.Client{Transport: &rewriteTransport{target: target}, Timeout: 5 * time.Second},
48+
MultiRateLimiter: mrl,
49+
MaxResults: maxResults,
50+
}
51+
}
52+
53+
func runSource(t *testing.T, source *Source, session *subscraping.Session, domain string) (subs []string, errs []error) {
54+
t.Helper()
55+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
56+
defer cancel()
57+
ctxWithValue := context.WithValue(ctx, subscraping.CtxSourceArg, "virustotal")
58+
59+
for r := range source.Run(ctxWithValue, domain, session) {
60+
switch r.Type {
61+
case subscraping.Subdomain:
62+
subs = append(subs, r.Value)
63+
case subscraping.Error:
64+
errs = append(errs, r.Error)
65+
}
66+
}
67+
return
68+
}
69+
70+
func TestVirustotalSource_Metadata(t *testing.T) {
71+
source := &Source{}
72+
assert.Equal(t, "virustotal", source.Name())
73+
assert.True(t, source.IsDefault())
74+
assert.True(t, source.HasRecursiveSupport())
75+
assert.True(t, source.NeedsKey())
76+
}
77+
78+
func TestVirustotalSource_NoApiKey(t *testing.T) {
79+
source := &Source{}
80+
81+
ctx := context.Background()
82+
mrl, _ := ratelimit.NewMultiLimiter(ctx, &ratelimit.Options{
83+
Key: "virustotal",
84+
IsUnlimited: false,
85+
MaxCount: math.MaxInt32,
86+
Duration: time.Millisecond,
87+
})
88+
session := &subscraping.Session{Client: http.DefaultClient, MultiRateLimiter: mrl}
89+
ctxWithValue := context.WithValue(ctx, subscraping.CtxSourceArg, "virustotal")
90+
91+
var count int
92+
for range source.Run(ctxWithValue, "example.com", session) {
93+
count++
94+
}
95+
assert.Equal(t, 0, count, "expected no results without an api key")
96+
assert.Equal(t, 0, source.Statistics().Requests)
97+
}
98+
99+
// With no per-source limit the source must follow the cursor to the end and
100+
// return every subdomain, i.e. the default behaviour stays unbounded so paid
101+
// keys are never silently truncated.
102+
func TestVirustotalSource_FollowsCursorWhenUnlimited(t *testing.T) {
103+
var hits int32
104+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105+
n := atomic.AddInt32(&hits, 1)
106+
w.Header().Set("Content-Type", "application/json")
107+
// Three pages of one subdomain each, then the cursor terminates.
108+
if n < 3 {
109+
fmt.Fprintf(w, `{"data":[{"id":"sub%d.example.com"}],"meta":{"cursor":"next-%d"}}`, n, n)
110+
return
111+
}
112+
fmt.Fprintf(w, `{"data":[{"id":"sub%d.example.com"}],"meta":{"cursor":""}}`, n)
113+
}))
114+
defer server.Close()
115+
116+
source := &Source{}
117+
source.AddApiKeys([]string{"test-key"})
118+
119+
subs, errs := runSource(t, source, newTestSession(t, server, 0), "example.com")
120+
121+
assert.Empty(t, errs)
122+
assert.ElementsMatch(t, []string{"sub1.example.com", "sub2.example.com", "sub3.example.com"}, subs)
123+
assert.Equal(t, 3, source.Statistics().Requests)
124+
}
125+
126+
// When -max-results (session.MaxResults) is set, pagination must stop as soon
127+
// as the limit is reached so a single domain can't drain an API quota (#1718).
128+
func TestVirustotalSource_MaxResultsCapsPagination(t *testing.T) {
129+
var hits int32
130+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+
n := atomic.AddInt32(&hits, 1)
132+
w.Header().Set("Content-Type", "application/json")
133+
// Always return one subdomain plus a non-empty cursor so the source
134+
// would loop forever without the max-results cap.
135+
fmt.Fprintf(w, `{"data":[{"id":"sub%d.example.com"}],"meta":{"cursor":"next-%d"}}`, n, n)
136+
}))
137+
defer server.Close()
138+
139+
const limit = 3
140+
source := &Source{}
141+
source.AddApiKeys([]string{"test-key"})
142+
143+
subs, errs := runSource(t, source, newTestSession(t, server, limit), "example.com")
144+
145+
assert.Empty(t, errs, "no errors expected on the happy path")
146+
assert.Len(t, subs, limit, "should yield exactly max-results subdomains")
147+
assert.Equal(t, limit, source.Statistics().Requests, "request count must be capped by max-results")
148+
assert.Equal(t, int32(limit), atomic.LoadInt32(&hits))
149+
}
150+
151+
// Cursor exhaustion must not waste an extra request.
152+
func TestVirustotalSource_StopsWhenCursorEmpty(t *testing.T) {
153+
var hits int32
154+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+
n := atomic.AddInt32(&hits, 1)
156+
w.Header().Set("Content-Type", "application/json")
157+
if n == 1 {
158+
fmt.Fprint(w, `{"data":[{"id":"a.example.com"}],"meta":{"cursor":"more"}}`)
159+
return
160+
}
161+
fmt.Fprint(w, `{"data":[{"id":"b.example.com"}],"meta":{"cursor":""}}`)
162+
}))
163+
defer server.Close()
164+
165+
source := &Source{}
166+
source.AddApiKeys([]string{"test-key"})
167+
168+
subs, errs := runSource(t, source, newTestSession(t, server, 0), "example.com")
169+
170+
assert.Empty(t, errs)
171+
assert.ElementsMatch(t, []string{"a.example.com", "b.example.com"}, subs)
172+
assert.Equal(t, 2, source.Statistics().Requests)
173+
}
174+
175+
// On HTTP 429 (quota exhausted) the source must surface a human-readable error
176+
// mentioning the quota, not a generic "unexpected status code 429" string.
177+
func TestVirustotalSource_RateLimitedReturnsActionableError(t *testing.T) {
178+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
179+
w.WriteHeader(http.StatusTooManyRequests)
180+
}))
181+
defer server.Close()
182+
183+
source := &Source{}
184+
source.AddApiKeys([]string{"test-key"})
185+
186+
_, errs := runSource(t, source, newTestSession(t, server, 0), "example.com")
187+
188+
require.Len(t, errs, 1, "exactly one error expected on 429")
189+
assert.Contains(t, errs[0].Error(), "quota exhausted")
190+
assert.Contains(t, errs[0].Error(), "429")
191+
assert.Equal(t, 1, source.Statistics().Requests, "must not retry past the first 429")
192+
assert.Equal(t, 1, source.Statistics().Errors)
193+
}

0 commit comments

Comments
 (0)