Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions pkg/plugins/domains/domain_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package domains

import (
"strings"

"github.com/praetorian-inc/pius/pkg/plugins"
"github.com/praetorian-inc/pius/pkg/whois"
)

// domainFindings normalizes, deduplicates, and filters a raw list of domain
// strings into plausible FindingDomain entries with the pivot org attached.
// Shared by the reverse-whois plugins.
func domainFindings(source, pivotOrg string, rawDomains []string) []plugins.Finding {
Comment thread
EvanLeleux marked this conversation as resolved.
seen := make(map[string]struct{}, len(rawDomains))
var findings []plugins.Finding

for _, raw := range rawDomains {
domain := strings.TrimSuffix(strings.TrimSpace(strings.ToLower(raw)), ".")
if domain == "" || !whois.IsPlausibleDomain(domain) {
continue
}
if _, ok := seen[domain]; ok {
continue
}
seen[domain] = struct{}{}

findings = append(findings, plugins.Finding{
Type: plugins.FindingDomain,
Value: domain,
Source: source,
Data: map[string]any{"pivot_org": pivotOrg},
})
}
return findings
}
93 changes: 28 additions & 65 deletions pkg/plugins/domains/reverse_whois.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"net/url"
"os"
"strings"

"github.com/praetorian-inc/pius/pkg/client"
"github.com/praetorian-inc/pius/pkg/plugins"
Expand All @@ -16,10 +15,17 @@ func init() {
plugins.Register("reverse-whois", func() plugins.Plugin { return &ReverseWhoisPlugin{client: client.New()} })
}

// ReverseWhoisPlugin discovers related domains via ViewDNS reverse WHOIS.
// Emits FindingDomain with Data["pivot_org"]. Verification happens when Guard
// runs the whois capability on each discovered domain.
type ReverseWhoisPlugin struct {
client *client.Client
baseURL string // overridable for tests
resolver registrantResolver // overridable for tests; defaults to rdapWhoisResolver
client *client.Client
baseURL string // overridable for tests
}

// NewReverseWhoisPlugin creates a plugin with an injectable HTTP client.
func NewReverseWhoisPlugin(httpClient *client.Client) *ReverseWhoisPlugin {
return &ReverseWhoisPlugin{client: httpClient}
}

func (p *ReverseWhoisPlugin) apiBase() string {
Expand All @@ -29,94 +35,51 @@ func (p *ReverseWhoisPlugin) apiBase() string {
return "https://api.viewdns.info"
}

func (p *ReverseWhoisPlugin) Name() string { return "reverse-whois" }
func (p *ReverseWhoisPlugin) Description() string {
return "ViewDNS Reverse WHOIS: discovers domain portfolio (requires VIEWDNS_API_KEY)"
}
func (p *ReverseWhoisPlugin) Category() string { return "domain" }
func (p *ReverseWhoisPlugin) Phase() int { return 0 }
func (p *ReverseWhoisPlugin) Mode() string { return plugins.ModePassive }
func (p *ReverseWhoisPlugin) Name() string { return "reverse-whois" }
func (p *ReverseWhoisPlugin) Description() string { return "ViewDNS Reverse WHOIS (requires VIEWDNS_API_KEY)" }
func (p *ReverseWhoisPlugin) Category() string { return "domain" }
func (p *ReverseWhoisPlugin) Phase() int { return 0 }
func (p *ReverseWhoisPlugin) Mode() string { return plugins.ModePassive }

// Only runs if VIEWDNS_API_KEY is set and an org name or registrant email
// seed is provided.
func (p *ReverseWhoisPlugin) Accepts(input plugins.Input) bool {
return os.Getenv("VIEWDNS_API_KEY") != "" && (input.OrgName != "" || input.Email != "")
}

type viewDNSResponse struct {
Response struct {
Matches []struct {
Domain string `json:"domain"`
} `json:"matches"`
} `json:"response"`
}

func (p *ReverseWhoisPlugin) Run(ctx context.Context, input plugins.Input) ([]plugins.Finding, error) {
apiKey := os.Getenv("VIEWDNS_API_KEY")

// Active seed: org name by default, registrant email when only Email is set.
query := input.OrgName
if query == "" {
query = input.Email
}

// ViewDNS Reverse WHOIS API
reqURL := fmt.Sprintf(
"%s/reversewhois/?q=%s&apikey=%s&output=json",
p.apiBase(),
url.QueryEscape(query),
apiKey,
p.apiBase(), url.QueryEscape(query), apiKey,
)

body, err := p.client.Get(ctx, reqURL)
if err != nil {
// Return sanitized error — strip URL which contains the API key.
return nil, fmt.Errorf("reverse-whois: request failed")
}

var response struct {
Response struct {
Matches []struct {
Domain string `json:"domain"`
} `json:"matches"`
} `json:"response"`
}
var response viewDNSResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("reverse-whois: parse response: %w", err)
}
Comment thread
EvanLeleux marked this conversation as resolved.
Outdated

// Build an ordered, deduped candidate list (the resolve cap is applied
// downstream in verifyCandidates). A ViewDNS match is only
// a lead (broad substring/token search over the full WHOIS record), so each
// candidate is corroborated against its own registrant in verifyCandidates
// rather than emitted at a flat score here (ENG-5123).
cands := make([]candidate, 0, len(response.Response.Matches))
seen := make(map[string]struct{})
var rawDomains []string
for _, d := range response.Response.Matches {
if d.Domain == "" {
continue
}
domain := strings.TrimSuffix(strings.TrimSpace(strings.ToLower(d.Domain)), ".")
if domain == "" {
continue
}
if _, ok := seen[domain]; ok {
continue
}
seen[domain] = struct{}{}
cands = append(cands, candidate{
domain: domain,
finding: plugins.Finding{
Type: plugins.FindingDomain,
Value: domain,
Source: p.Name(),
Data: map[string]any{
"org": query,
},
},
})
rawDomains = append(rawDomains, d.Domain)
}

// Resolve into a local rather than mutating p.resolver: writing shared plugin
// state inside Run() would be a data race if an instance were ever reused or
// run concurrently (Gemini review, ENG-5123).
resolver := p.resolver
if resolver == nil {
resolver = &rdapWhoisResolver{}
}
// input.OrgName drives corroboration; email-mode (OrgName == "") short-
// circuits inside verifyCandidates. Data["org"] provenance stays the query.
return verifyCandidates(ctx, resolver, input.OrgName, cands)
return domainFindings(p.Name(), query, rawDomains), nil
}
Loading
Loading