Skip to content

fix(sources/virustotal): cap pagination + clearer 429 message#1788

Merged
Mzack9999 merged 9 commits into
projectdiscovery:devfrom
ChrisJr404:fix/virustotal-free-tier-limits-1718
Jul 2, 2026
Merged

fix(sources/virustotal): cap pagination + clearer 429 message#1788
Mzack9999 merged 9 commits into
projectdiscovery:devfrom
ChrisJr404:fix/virustotal-free-tier-limits-1718

Conversation

@ChrisJr404

@ChrisJr404 ChrisJr404 commented May 6, 2026

Copy link
Copy Markdown
Contributor

Closes #1718.

What the OP saw

Running subfinder -nW -dL FILE -s virustotal -stats against ~110 hosts ran the VT free-tier 500-req/day quota dry after roughly twenty hosts. Every subsequent VT call returned HTTP 429, which the source surfaced as unexpected status code 429 received from ... with no indication that the free tier was the cause.

The reason a single host can chew through so many requests is the cursor loop: limit=40 per page with no upper bound on pagination. A single popular root domain with thousands of subdomains will issue 50+ requests on its own.

Fix

  • Bound pagination at 10 pages (defaultMaxPages), giving up to 400 subdomains per domain on the default code path. That puts a single host at well under 1% of the daily quota even in the worst case.
  • Detect HTTP 429 explicitly and emit virustotal free-tier quota exhausted (HTTP 429); some subdomains for <domain> may be missing so the operator knows what happened and can either provide an enterprise key or accept the cap.

Mirrors the pattern used in #1776 for netlas's Community-tier cap.

Tests

pkg/subscraping/sources/virustotal/virustotal_test.go (5 cases):

  • Metadata / NoApiKey baseline coverage
  • PaginationCappedOnFreeTier mocks an endpoint that always returns a non-empty cursor and asserts the source stops at exactly defaultMaxPages requests
  • StopsWhenCursorEmpty asserts the cursor terminator still wins when reached before the cap
  • RateLimitedReturnsActionableError mocks a 429 response and asserts the new "quota exhausted" message is surfaced and pagination stops on the first 429
$ go test ./pkg/subscraping/sources/virustotal/...
ok    github.com/projectdiscovery/subfinder/v2/pkg/subscraping/sources/virustotal    0.023s
$ go build ./... && go vet ./...
$

Summary by CodeRabbit

Release Notes

  • New Features
    • Added --max-results/-mr to cap the number of results per source (use 0 for unlimited).
    • VirusTotal subdomain lookups now paginate, apply quota protection, and respect the result cap.
  • Bug Fixes
    • Improved rate-limit handling with clearer, actionable messaging when encountering HTTP 429.
  • Tests
    • Added a VirusTotal test suite covering API key requirements, pagination/cursors, result caps, and 429 behavior.
  • Documentation
    • Updated README CLI flag descriptions for --max-results/-mr.

Bundy01 and others added 5 commits January 29, 2026 14:15
Co-authored-by: Pepijn van der Stap  <205847092+x-stp@users.noreply.github.com>
Co-authored-by: Pepijn van der Stap  <205847092+x-stp@users.noreply.github.com>
Co-authored-by: Pepijn van der Stap  <205847092+x-stp@users.noreply.github.com>
@neo-by-projectdiscovery-dev

neo-by-projectdiscovery-dev Bot commented May 6, 2026

Copy link
Copy Markdown

Neo - PR Security Review

No security issues found

Comment @pdneo help for available commands. · Open in Neo

@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds a per-source maximum results limit, threads it from CLI/config through passive enumeration into session state, and updates the VirusTotal source to stop at that cap while handling HTTP 429 responses explicitly. New tests cover the new limit and pagination behavior.

Changes

Result limit propagation and VirusTotal scraping

Layer / File(s) Summary
Config and option wiring
pkg/runner/options.go, pkg/runner/validate.go, pkg/passive/passive.go, pkg/runner/enumerate.go, pkg/subscraping/types.go, README.md
A new MaxResults option is added, parsed, validated, and carried through passive enumeration into subscraping.Session; the CLI flag is documented in the README.
VirusTotal pagination and quota handling
pkg/subscraping/sources/virustotal/virustotal.go
The VirusTotal source reads session.MaxResults, stops emitting once the cap is reached, closes each page body explicitly, and returns a clearer error for HTTP 429 responses.
VirusTotal tests
pkg/subscraping/sources/virustotal/virustotal_test.go
A new test suite covers source metadata, missing API keys, cursor pagination, capped pagination, cursor exhaustion, and 429 handling using a local HTTP test server.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: dogancanbakir

Poem

🐰 I hopped through pages, neat and small,
With max results set to guide them all.
Cursors waved, then paused in line,
And quota grumbles stayed benign.
A carrot-count, a tidy cap,
Keeps VirusTotal from the trap.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes add a configurable result limit and explicit 429 handling, matching the issue's quota and pagination goals.
Out of Scope Changes check ✅ Passed The non-VirusTotal edits support the new max-results feature end-to-end and do not appear unrelated to the issue.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main VirusTotal changes: pagination capping and a clearer 429 error message.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/subscraping/sources/virustotal/virustotal.go (1)

88-93: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

defer resp.Body.Close() inside a for loop accumulates open bodies until the goroutine exits.

Defers run at function exit, not at end of each loop iteration. With defaultMaxPages = 10 and a long cursor chain you can hold up to 10 HTTP response bodies (and their underlying TCP connections) open simultaneously, even though only one is being read at any moment. Close per iteration instead.

♻️ Proposed fix — close the body explicitly at the end of each iteration
 			resp, err := session.Get(ctx, url, "", map[string]string{"x-apikey": randomApiKey})
 			if err != nil {
 				if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
 					results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: fmt.Errorf("virustotal free-tier quota exhausted (HTTP 429); some subdomains for %s may be missing", domain)}
 				} else {
 					results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
 				}
 				s.errors++
 				session.DiscardHTTPResponse(resp)
 				return
 			}
-			defer func() {
-				if err := resp.Body.Close(); err != nil {
-					results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
-					s.errors++
-				}
-			}()
 
 			var data response
 			err = jsoniter.NewDecoder(resp.Body).Decode(&data)
+			if cerr := resp.Body.Close(); cerr != nil {
+				results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: cerr}
+				s.errors++
+			}
 			if err != nil {
 				results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
 				s.errors++
 				return
 			}

Alternatively, wrap each iteration in an anonymous function so the defer fires per page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/subscraping/sources/virustotal/virustotal.go` around lines 88 - 93, The
defer resp.Body.Close() inside the loop (see resp and resp.Body.Close() in
virustotal.go) holds response bodies until the goroutine exits; replace the
deferred close with an explicit resp.Body.Close() call at the end of each loop
iteration (after you've finished reading/parsing the response) or wrap the
per-page logic in an anonymous function so the defer runs per-iteration; ensure
you still send a subscraping.Result error on close failure (the current results
<- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
handling should remain).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@pkg/subscraping/sources/virustotal/virustotal.go`:
- Around line 88-93: The defer resp.Body.Close() inside the loop (see resp and
resp.Body.Close() in virustotal.go) holds response bodies until the goroutine
exits; replace the deferred close with an explicit resp.Body.Close() call at the
end of each loop iteration (after you've finished reading/parsing the response)
or wrap the per-page logic in an anonymous function so the defer runs
per-iteration; ensure you still send a subscraping.Result error on close failure
(the current results <- subscraping.Result{Source: s.Name(), Type:
subscraping.Error, Error: err} handling should remain).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fa375abc-affc-4d3d-8f2b-d16780f49539

📥 Commits

Reviewing files that changed from the base of the PR and between 0ed1b0b and 85e2354.

📒 Files selected for processing (2)
  • pkg/subscraping/sources/virustotal/virustotal.go
  • pkg/subscraping/sources/virustotal/virustotal_test.go

@dogancanbakir dogancanbakir requested a review from Mzack9999 July 1, 2026 08:57
@Mzack9999 Mzack9999 force-pushed the fix/virustotal-free-tier-limits-1718 branch from 85e2354 to 76cad6d Compare July 2, 2026 20:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/runner/options.go`:
- Around line 72-76: The VirusTotal pagination cap still defaults to unlimited
because MaxResults is left at 0 and virustotal.Run reads session.MaxResults
directly. Update the defaulting logic in the runner options/session setup for
MaxResults so VirusTotal gets a nonzero cap out of the box, while keeping the
explicit --max-results override working as expected. Use the MaxResults field
and virustotal.Run as the main points to adjust.

In `@pkg/subscraping/sources/virustotal/virustotal_test.go`:
- Line 109: Fix the errcheck failures in the VirusTotal test-server handlers by
handling or explicitly discarding the return values from the fmt.Fprintf and
fmt.Fprint calls inside the test closures. Update the affected handlers in
virustotal_test.go so each write either checks the returned error with t or
assigns both return values to blank identifiers, keeping the existing test logic
intact.

In `@pkg/subscraping/sources/virustotal/virustotal.go`:
- Around line 51-54: The VirusTotal source currently only honors a result cap
when MaxResults is explicitly set, so standard runs still paginate indefinitely
and can consume quota. Update the default in pkg/runner/options.go to a non-zero
MaxResults value, and ensure virustotal.go’s maxResults handling in the
VirusTotal scraping flow continues to use session.MaxResults as the limit
source.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: acf2c836-f0f1-43e4-b75d-4b15977edfe7

📥 Commits

Reviewing files that changed from the base of the PR and between 85e2354 and 76cad6d.

📒 Files selected for processing (8)
  • README.md
  • pkg/passive/passive.go
  • pkg/runner/enumerate.go
  • pkg/runner/options.go
  • pkg/runner/validate.go
  • pkg/subscraping/sources/virustotal/virustotal.go
  • pkg/subscraping/sources/virustotal/virustotal_test.go
  • pkg/subscraping/types.go
✅ Files skipped from review due to trivial changes (1)
  • README.md

Comment thread pkg/runner/options.go
Comment on lines +72 to +76

// MaxResults limits the number of results requested per source.
// A value of 0 (default) means no limit. Only sources that paginate
// honor this (currently virustotal), to help stay within API quotas.
MaxResults int `yaml:"max-results,omitempty"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether virustotal.go defines a defaultMaxPages / default nonzero cap independent of session.MaxResults
rg -n 'defaultMaxPages|MaxResults' pkg/subscraping/sources/virustotal/virustotal.go

Repository: projectdiscovery/subfinder

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pkg/runner/options.go =="
sed -n '1,180p' pkg/runner/options.go | cat -n

echo
echo "== pkg/subscraping/sources/virustotal/virustotal.go =="
sed -n '1,220p' pkg/subscraping/sources/virustotal/virustotal.go | cat -n

echo
echo "== search for max-results flag/help/default wiring =="
rg -n 'max-results|MaxResults|defaultMaxPages|10 pages|400 subdomains|VirusTotal|virustotal' .

Repository: projectdiscovery/subfinder

Length of output: 21313


Apply a nonzero default cap to VirusTotal pagination The flag still defaults to 0, and virustotal.Run uses session.MaxResults directly, so the default path remains unlimited. If the goal is to protect the free-tier quota out of the box, set a nonzero default for VirusTotal instead of relying on --max-results.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/runner/options.go` around lines 72 - 76, The VirusTotal pagination cap
still defaults to unlimited because MaxResults is left at 0 and virustotal.Run
reads session.MaxResults directly. Update the defaulting logic in the runner
options/session setup for MaxResults so VirusTotal gets a nonzero cap out of the
box, while keeping the explicit --max-results override working as expected. Use
the MaxResults field and virustotal.Run as the main points to adjust.

Comment thread pkg/subscraping/sources/virustotal/virustotal_test.go Outdated
Comment on lines +51 to +54
// Honor an optional per-source result limit to avoid unnecessary
// pagination requests (e.g. to stay within API quotas). 0 = no limit.
maxResults := session.MaxResults

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether a non-zero default is set for MaxResults in runner options
rg -n -B3 -A3 'MaxResults' pkg/runner/options.go pkg/runner/validate.go

Repository: projectdiscovery/subfinder

Length of output: 1871


Set a non-zero default for MaxResults. The cap here only applies when --max-results is set, but pkg/runner/options.go still defaults it to 0 (unlimited). As-is, standard runs will keep paginating VirusTotal and can still burn through quota.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/subscraping/sources/virustotal/virustotal.go` around lines 51 - 54, The
VirusTotal source currently only honors a result cap when MaxResults is
explicitly set, so standard runs still paginate indefinitely and can consume
quota. Update the default in pkg/runner/options.go to a non-zero MaxResults
value, and ensure virustotal.go’s maxResults handling in the VirusTotal scraping
flow continues to use session.MaxResults as the limit source.

@Mzack9999 Mzack9999 merged commit 48ce2e0 into projectdiscovery:dev Jul 2, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Limiting Results With the Free Virustotal API

3 participants