Discover and persist common third-party domains
Add a domain-discovery step to the enrichment pipeline so the catalog's domain set, previously written only by the curated seed, grows automatically. A focused agent enumerates the registrable domains a vendor owns and operates - marketing, product and sub-brand, app, API, and CDN/asset domains - from links seen while browsing and from web search, anchored on the website resolved earlier in the run. A deterministic ownership gate reduces the candidates to eTLD+1 and keeps only those that clear a strict confidence floor and match the vendor by domain label. Shared tracker-delivery and CDN infrastructure is dropped unless the vendor itself is that provider, in which case its own brand-matching domain passes a stricter exact-label check. The survivors are upserted into common_third_party_domains in the run's final transaction and recorded in the enrichment payload, feeding the tracker-mapping domain step and disambiguation. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
102
pkg/thirdparty/common_third_party_domains_agent.go
vendored
Normal file
102
pkg/thirdparty/common_third_party_domains_agent.go
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package thirdparty
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/agent/tools/search"
|
||||
)
|
||||
|
||||
//go:embed prompts/common_third_party_domains.txt.tmpl
|
||||
var domainsPrompt string
|
||||
|
||||
// DomainsResult is the structured output of the domain-discovery agent
|
||||
// (Agent C): the registrable domains the vendor itself owns and operates,
|
||||
// including the marketing site, app, public API hosts, and CDN/asset
|
||||
// hosts. The worker reduces these to eTLD+1, applies an ownership gate,
|
||||
// and writes the survivors to common_third_party_domains so the
|
||||
// tracker-mapping domain step can attribute trackers to this vendor.
|
||||
type (
|
||||
DomainsResult struct {
|
||||
Domains []DomainCandidate `json:"domains" jsonschema:"The domains the vendor owns and operates. Empty when none can be confirmed."`
|
||||
}
|
||||
|
||||
DomainCandidate struct {
|
||||
Domain string `json:"domain" jsonschema:"A domain the vendor owns and operates (a registrable domain such as 'intercomcdn.com' or a full host such as 'api.vendor.com'). Never a third-party or shared-infrastructure host the vendor does not own."`
|
||||
Confidence float64 `json:"confidence" jsonschema:"Confidence from 0.0 to 1.0 that the vendor owns this domain. Use 0 when ownership is not confirmed."`
|
||||
SourceURL string `json:"source_url" jsonschema:"The URL where the vendor's ownership of this domain was observed, or an empty string."`
|
||||
}
|
||||
)
|
||||
|
||||
// buildCommonThirdPartyDomainsAgent builds Agent C. extraTools carries
|
||||
// the browser read-only toolset when a headless Chrome endpoint is
|
||||
// configured; it is empty otherwise, in which case the agent relies on
|
||||
// web_search alone.
|
||||
func buildCommonThirdPartyDomainsAgent(
|
||||
cfg EnrichmentConfig,
|
||||
logger *log.Logger,
|
||||
extraTools []agent.Tool,
|
||||
) *agent.Agent {
|
||||
tools := append([]agent.Tool{}, extraTools...)
|
||||
|
||||
if cfg.FirecrawlAPIKey != "" {
|
||||
tools = append(tools, search.FirecrawlSearchTool(cfg.FirecrawlAPIKey))
|
||||
}
|
||||
|
||||
outputType, err := agent.NewOutputType[DomainsResult]("common_third_party_domains")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("thirdparty: cannot build domains output type: %s", err))
|
||||
}
|
||||
|
||||
opts := []agent.Option{
|
||||
agent.WithInstructions(domainsPrompt),
|
||||
agent.WithModel(cfg.Model),
|
||||
agent.WithOutputType(outputType),
|
||||
agent.WithMaxTurns(resolveEnrichmentMaxTurns(cfg.MaxTurns)),
|
||||
agent.WithMaxTokens(resolveEnrichmentMaxTokens(cfg.MaxTokens)),
|
||||
agent.WithLogger(logger),
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
opts = append(opts, agent.WithTools(tools...))
|
||||
}
|
||||
|
||||
if cfg.Temperature != nil {
|
||||
opts = append(opts, agent.WithTemperature(*cfg.Temperature))
|
||||
}
|
||||
|
||||
return agent.New("common-third-party-domains", cfg.LLMClient, opts...)
|
||||
}
|
||||
|
||||
// buildCommonThirdPartyDomainsPrompt renders the per-row input for Agent
|
||||
// C, seeding it with the vendor name and the website resolved by Agent A
|
||||
// so it can anchor ownership to the vendor's own domain.
|
||||
func buildCommonThirdPartyDomainsPrompt(name, websiteURL string) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Find the domains owned and operated by this vendor.\n\n")
|
||||
fmt.Fprintf(&b, "<name> %s </name>\n", name)
|
||||
|
||||
if w := strings.TrimSpace(websiteURL); w != "" {
|
||||
fmt.Fprintf(&b, "<website> %s </website>\n", w)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
11
pkg/thirdparty/common_third_party_enrichment.go
vendored
11
pkg/thirdparty/common_third_party_enrichment.go
vendored
@@ -71,6 +71,16 @@ type (
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// EnrichmentDomainMeta is the provenance of one domain written to
|
||||
// common_third_party_domains during a run, recorded in the enrichment
|
||||
// payload so the discovered domain set is auditable.
|
||||
EnrichmentDomainMeta struct {
|
||||
Domain string `json:"domain"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// EnrichmentMetadata is the full payload stored in the enrichment
|
||||
// JSON column: run-level bookkeeping plus per-field provenance keyed
|
||||
// by the column name.
|
||||
@@ -80,6 +90,7 @@ type (
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Fields map[string]EnrichmentFieldMeta `json:"fields"`
|
||||
Domains []EnrichmentDomainMeta `json:"domains,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -56,6 +56,13 @@ const (
|
||||
// recorded in the enrichment metadata but not promoted.
|
||||
defaultEnrichmentConfidenceThreshold = 0.7
|
||||
|
||||
// defaultEnrichmentDomainConfidenceThreshold is the ownership-confidence
|
||||
// floor a discovered domain must clear before it is written to
|
||||
// common_third_party_domains. It is stricter than the field threshold
|
||||
// because a domain feeds cross-tenant tracker attribution: a wrong
|
||||
// domain mis-attributes every tracker served from it.
|
||||
defaultEnrichmentDomainConfidenceThreshold = 0.85
|
||||
|
||||
// defaultEnrichmentStaleAfter is the idle window after which a
|
||||
// claimed-but-unfinished enrichment is re-armed.
|
||||
defaultEnrichmentStaleAfter = 15 * time.Minute
|
||||
@@ -236,13 +243,15 @@ func (h *enrichmentHandler) Process(ctx context.Context, party coredata.CommonTh
|
||||
|
||||
runErrors = append(runErrors, "website_url unresolved: skipped compliance docs")
|
||||
|
||||
return h.persist(ctx, party, EnrichmentMetadata{
|
||||
payload := EnrichmentMetadata{
|
||||
Model: h.cfg.Model,
|
||||
AttemptedAt: now,
|
||||
Status: enrichmentStatusFailed,
|
||||
Error: strings.Join(runErrors, "; "),
|
||||
Fields: meta,
|
||||
}, nil, now)
|
||||
}
|
||||
|
||||
return h.persist(ctx, party, payload, nil, nil, now)
|
||||
}
|
||||
|
||||
legalName := effectiveLegalName(party, company, h.cfg.ConfidenceThreshold)
|
||||
@@ -256,6 +265,19 @@ func (h *enrichmentHandler) Process(ctx context.Context, party coredata.CommonTh
|
||||
anySuccess = true
|
||||
}
|
||||
|
||||
// Agent C: domains the vendor owns and operates. Anchored on the
|
||||
// resolved website, so it runs only on this website-resolved path.
|
||||
var owned []ownedDomain
|
||||
|
||||
domainsResult, err := h.runDomains(ctx, party.Name, website)
|
||||
if err != nil {
|
||||
h.logger.WarnCtx(ctx, "domains agent failed", log.Error(err), log.String("common_third_party_id", party.ID.String()))
|
||||
runErrors = append(runErrors, "domains: "+err.Error())
|
||||
} else {
|
||||
anySuccess = true
|
||||
owned = resolveOwnedDomains(party.Name, website, domainsResult, defaultEnrichmentDomainConfidenceThreshold)
|
||||
}
|
||||
|
||||
// Deterministic logo step (no LLM). Uploads to S3 outside the final
|
||||
// transaction; the File row is inserted below.
|
||||
logoFile := h.prepareLogo(ctx, party, website)
|
||||
@@ -267,6 +289,26 @@ func (h *enrichmentHandler) Process(ctx context.Context, party coredata.CommonTh
|
||||
applyCertifications(&party, meta, prior, compliance.Certifications, h.cfg.ConfidenceThreshold, now)
|
||||
applyLegalNameFallback(&party, meta, now)
|
||||
|
||||
domainRows := make([]coredata.CommonThirdPartyDomain, 0, len(owned))
|
||||
domainMeta := make([]EnrichmentDomainMeta, 0, len(owned))
|
||||
|
||||
for _, d := range owned {
|
||||
domainRows = append(domainRows, coredata.CommonThirdPartyDomain{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyDomainEntityType),
|
||||
CommonThirdPartyID: party.ID,
|
||||
Domain: d.Domain,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
|
||||
domainMeta = append(domainMeta, EnrichmentDomainMeta{
|
||||
Domain: d.Domain,
|
||||
Confidence: d.Confidence,
|
||||
SourceURL: d.SourceURL,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
status := enrichmentStatusDone
|
||||
|
||||
switch {
|
||||
@@ -282,20 +324,25 @@ func (h *enrichmentHandler) Process(ctx context.Context, party coredata.CommonTh
|
||||
Status: status,
|
||||
Error: strings.Join(runErrors, "; "),
|
||||
Fields: meta,
|
||||
Domains: domainMeta,
|
||||
}
|
||||
|
||||
return h.persist(ctx, party, payload, logoFile, now)
|
||||
return h.persist(ctx, party, payload, logoFile, domainRows, now)
|
||||
}
|
||||
|
||||
// persist marshals the enrichment payload onto the row and writes it in a
|
||||
// single transaction, inserting the logo File row and linking it when one
|
||||
// was prepared. It always writes an enrichment payload, even on a
|
||||
// no-result run, so stale recovery does not re-queue the row.
|
||||
// was prepared and upserting the discovered owned domains. It always
|
||||
// writes an enrichment payload, even on a no-result run, so stale
|
||||
// recovery does not re-queue the row. Domain upserts are idempotent
|
||||
// against the (common_third_party_id, domain) unique index, so re-runs
|
||||
// are safe and never conflict with curated seed rows.
|
||||
func (h *enrichmentHandler) persist(
|
||||
ctx context.Context,
|
||||
party coredata.CommonThirdParty,
|
||||
payload EnrichmentMetadata,
|
||||
logoFile *coredata.File,
|
||||
domains []coredata.CommonThirdPartyDomain,
|
||||
now time.Time,
|
||||
) error {
|
||||
raw, err := json.Marshal(payload)
|
||||
@@ -325,6 +372,12 @@ func (h *enrichmentHandler) persist(
|
||||
return fmt.Errorf("cannot persist common third party enrichment: %w", err)
|
||||
}
|
||||
|
||||
for i := range domains {
|
||||
if _, err := domains[i].Upsert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot upsert common third party domain: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.InfoCtx(
|
||||
ctx,
|
||||
"enriched common third party",
|
||||
@@ -332,6 +385,7 @@ func (h *enrichmentHandler) persist(
|
||||
log.String("name", party.Name),
|
||||
log.String("status", payload.Status),
|
||||
log.Bool("logo_stored", logoFile != nil),
|
||||
log.Int("domains_stored", len(domains)),
|
||||
)
|
||||
|
||||
return nil
|
||||
@@ -442,6 +496,49 @@ func (h *enrichmentHandler) runComplianceDocs(
|
||||
return result.Output, nil
|
||||
}
|
||||
|
||||
// runDomains builds Agent C with a per-run browser when a Chrome endpoint
|
||||
// is configured, then runs it. The browser is closed when the run
|
||||
// returns. It is not pinned to the vendor domain so the agent can follow
|
||||
// links to the vendor's other owned domains; SSRF protection still blocks
|
||||
// non-public hosts.
|
||||
func (h *enrichmentHandler) runDomains(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
website string,
|
||||
) (DomainsResult, error) {
|
||||
var browserTools []agent.Tool
|
||||
|
||||
if h.cfg.ChromeAddr != "" {
|
||||
webBrowser := browser.NewBrowser(ctx, h.cfg.ChromeAddr)
|
||||
defer webBrowser.Close()
|
||||
|
||||
browserTools = browser.NewReadOnlyToolset(webBrowser).Tools()
|
||||
}
|
||||
|
||||
domainsAgent := buildCommonThirdPartyDomainsAgent(h.cfg, h.logger, browserTools)
|
||||
|
||||
prompt := buildCommonThirdPartyDomainsPrompt(name, website)
|
||||
|
||||
agentCtx, cancel := context.WithTimeout(ctx, h.cfg.AgentTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := agent.RunTyped[DomainsResult](
|
||||
agentCtx,
|
||||
domainsAgent,
|
||||
[]llm.Message{
|
||||
{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: prompt}},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return DomainsResult{}, fmt.Errorf("domains agent run failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Output, nil
|
||||
}
|
||||
|
||||
// effectiveWebsiteURL is the website passed to Agent B and the logo step.
|
||||
// A curated value already on the row wins (seed data and human edits are
|
||||
// trusted); otherwise Agent A's value is used when it clears the
|
||||
|
||||
226
pkg/thirdparty/common_third_party_owned_domains.go
vendored
Normal file
226
pkg/thirdparty/common_third_party_owned_domains.go
vendored
Normal file
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package thirdparty
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
// minLabelOverlap is the shortest label length allowed for a substring
|
||||
// ownership match. Shorter labels (e.g. "id", "go") match too many
|
||||
// unrelated domains to be a trustworthy ownership signal, so they only
|
||||
// match on exact equality.
|
||||
const minLabelOverlap = 4
|
||||
|
||||
// ownedDomain is a vendor-owned eTLD+1 that cleared the ownership gate,
|
||||
// carrying the provenance the worker records in the enrichment payload.
|
||||
type ownedDomain struct {
|
||||
Domain string
|
||||
Confidence float64
|
||||
SourceURL string
|
||||
}
|
||||
|
||||
// resolveOwnedDomains reduces the domain-discovery agent's candidates to
|
||||
// the eTLD+1 domains the vendor demonstrably owns, ready to write to
|
||||
// common_third_party_domains. The vendor's own website eTLD+1 is always
|
||||
// included. Every other candidate must clear the confidence threshold and
|
||||
// be ownership-related to the vendor by a label match against the website
|
||||
// label or the normalized vendor name.
|
||||
//
|
||||
// Shared tracker-delivery / CDN infrastructure (uri.FilterSharedInfra...)
|
||||
// is dropped only when it is NOT owned: a generic provider domain cannot
|
||||
// be attributed to an unrelated vendor, but when the vendor itself is
|
||||
// that provider (e.g. enriching Cloudflare or Fastly) its own domain
|
||||
// passes a stricter exact-label match and is kept. Results are
|
||||
// deduplicated with insertion order preserved.
|
||||
func resolveOwnedDomains(
|
||||
name string,
|
||||
website string,
|
||||
result DomainsResult,
|
||||
threshold float64,
|
||||
) []ownedDomain {
|
||||
vendorLabels := vendorLabels(name, website)
|
||||
|
||||
var (
|
||||
owned []ownedDomain
|
||||
seen = make(map[string]struct{})
|
||||
)
|
||||
|
||||
add := func(domain string, confidence float64, sourceURL string) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := seen[domain]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
seen[domain] = struct{}{}
|
||||
|
||||
owned = append(owned, ownedDomain{
|
||||
Domain: domain,
|
||||
Confidence: confidence,
|
||||
SourceURL: sourceURL,
|
||||
})
|
||||
}
|
||||
|
||||
// The vendor's own website domain is owned by definition.
|
||||
if site := normalizeToETLD1(website); site != "" {
|
||||
add(site, 1.0, strings.TrimSpace(website))
|
||||
}
|
||||
|
||||
for _, candidate := range result.Domains {
|
||||
domain := normalizeToETLD1(candidate.Domain)
|
||||
if domain == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isVendorOwnedDomain(domain, candidate.Confidence, threshold, vendorLabels) {
|
||||
continue
|
||||
}
|
||||
|
||||
add(domain, candidate.Confidence, strings.TrimSpace(candidate.SourceURL))
|
||||
}
|
||||
|
||||
return owned
|
||||
}
|
||||
|
||||
// isVendorOwnedDomain reports whether an eTLD+1 candidate clears the
|
||||
// confidence threshold and is owned by the vendor. Shared-infrastructure
|
||||
// domains require an exact label match (the vendor must be that provider);
|
||||
// all other domains accept a substring label overlap.
|
||||
func isVendorOwnedDomain(
|
||||
domain string,
|
||||
confidence float64,
|
||||
threshold float64,
|
||||
vendorLabels []string,
|
||||
) bool {
|
||||
if confidence < threshold {
|
||||
return false
|
||||
}
|
||||
|
||||
label := uri.DomainLabel(domain)
|
||||
if label == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if isSharedInfrastructureDomain(domain) {
|
||||
return exactLabelMatch(label, vendorLabels)
|
||||
}
|
||||
|
||||
return relatedLabelMatch(label, vendorLabels)
|
||||
}
|
||||
|
||||
// isSharedInfrastructureDomain reports whether an eTLD+1 belongs to the
|
||||
// shared tracker-delivery / CDN infrastructure list, reusing the curated
|
||||
// set maintained in the uri package.
|
||||
func isSharedInfrastructureDomain(domain string) bool {
|
||||
return len(uri.FilterSharedInfrastructureDomains([]string{domain})) == 0
|
||||
}
|
||||
|
||||
// vendorLabels returns the lowercase ownership labels for a vendor: the
|
||||
// registrable label of its website and its name reduced to alphanumerics.
|
||||
func vendorLabels(name, website string) []string {
|
||||
var (
|
||||
labels []string
|
||||
seen = make(map[string]struct{})
|
||||
)
|
||||
|
||||
add := func(label string) {
|
||||
if label == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := seen[label]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
seen[label] = struct{}{}
|
||||
|
||||
labels = append(labels, label)
|
||||
}
|
||||
|
||||
add(uri.DomainLabel(website))
|
||||
add(normalizeAlnum(name))
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
// exactLabelMatch reports whether label equals any vendor label.
|
||||
func exactLabelMatch(label string, vendorLabels []string) bool {
|
||||
for _, vl := range vendorLabels {
|
||||
if label == vl {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// relatedLabelMatch reports whether label is the same as, contains, or is
|
||||
// contained by any vendor label. Substring matches require the shorter
|
||||
// string to be at least minLabelOverlap characters to avoid spurious hits
|
||||
// on very short labels.
|
||||
func relatedLabelMatch(label string, vendorLabels []string) bool {
|
||||
for _, vl := range vendorLabels {
|
||||
if label == vl {
|
||||
return true
|
||||
}
|
||||
|
||||
shorter, longer := label, vl
|
||||
if len(longer) < len(shorter) {
|
||||
shorter, longer = longer, shorter
|
||||
}
|
||||
|
||||
if len(shorter) >= minLabelOverlap && strings.Contains(longer, shorter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeToETLD1 reduces a bare domain or full URL to its eTLD+1,
|
||||
// tolerating candidates the agent returns without a scheme.
|
||||
func normalizeToETLD1(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
|
||||
return uri.ExtractDomain(raw)
|
||||
}
|
||||
|
||||
// normalizeAlnum lowercases a string and drops every non-alphanumeric
|
||||
// rune, so "Dark Reader" and "DarkReader, Inc." both reduce to a
|
||||
// comparable label root.
|
||||
func normalizeAlnum(s string) string {
|
||||
var b strings.Builder
|
||||
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
159
pkg/thirdparty/common_third_party_owned_domains_test.go
vendored
Normal file
159
pkg/thirdparty/common_third_party_owned_domains_test.go
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package thirdparty
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveOwnedDomains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 0.85
|
||||
|
||||
domainsOf := func(owned []ownedDomain) []string {
|
||||
out := make([]string, len(owned))
|
||||
for i, d := range owned {
|
||||
out[i] = d.Domain
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run(
|
||||
"keeps website and brand-related domains, drops unrelated and generic",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := resolveOwnedDomains(
|
||||
"Dark Reader",
|
||||
"https://darkreader.org",
|
||||
DomainsResult{
|
||||
Domains: []DomainCandidate{
|
||||
{Domain: "darkreader.org", Confidence: 0.95},
|
||||
{Domain: "darkreader.ltd", Confidence: 0.9},
|
||||
{Domain: "https://api.darkreader.org/v1", Confidence: 0.9},
|
||||
{Domain: "cloudflare.com", Confidence: 0.9},
|
||||
{Domain: "googleapis.com", Confidence: 0.95},
|
||||
},
|
||||
},
|
||||
threshold,
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"darkreader.org", "darkreader.ltd"}, domainsOf(got))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"always includes the website domain even when absent from candidates",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := resolveOwnedDomains(
|
||||
"Dark Reader",
|
||||
"https://darkreader.org",
|
||||
DomainsResult{},
|
||||
threshold,
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"darkreader.org"}, domainsOf(got))
|
||||
assert.Equal(t, 1.0, got[0].Confidence)
|
||||
assert.Equal(t, "https://darkreader.org", got[0].SourceURL)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"drops candidates below the confidence threshold",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := resolveOwnedDomains(
|
||||
"Dark Reader",
|
||||
"https://darkreader.org",
|
||||
DomainsResult{
|
||||
Domains: []DomainCandidate{
|
||||
{Domain: "darkreader.io", Confidence: 0.5},
|
||||
},
|
||||
},
|
||||
threshold,
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"darkreader.org"}, domainsOf(got))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"keeps a separate brand CDN domain",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := resolveOwnedDomains(
|
||||
"Intercom",
|
||||
"https://intercom.com",
|
||||
DomainsResult{
|
||||
Domains: []DomainCandidate{
|
||||
{Domain: "intercomcdn.com", Confidence: 0.9},
|
||||
},
|
||||
},
|
||||
threshold,
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"intercom.com", "intercomcdn.com"}, domainsOf(got))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"keeps the vendor's own shared-infrastructure domain when the vendor is that provider",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := resolveOwnedDomains(
|
||||
"Fastly",
|
||||
"https://fastly.com",
|
||||
DomainsResult{
|
||||
Domains: []DomainCandidate{
|
||||
{Domain: "fastly.net", Confidence: 0.95},
|
||||
},
|
||||
},
|
||||
threshold,
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"fastly.com", "fastly.net"}, domainsOf(got))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"drops shared-infrastructure domains for an unrelated vendor even at high confidence",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := resolveOwnedDomains(
|
||||
"Dark Reader",
|
||||
"https://darkreader.org",
|
||||
DomainsResult{
|
||||
Domains: []DomainCandidate{
|
||||
{Domain: "amazonaws.com", Confidence: 0.99},
|
||||
{Domain: "fastly.net", Confidence: 0.99},
|
||||
},
|
||||
},
|
||||
threshold,
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"darkreader.org"}, domainsOf(got))
|
||||
},
|
||||
)
|
||||
}
|
||||
37
pkg/thirdparty/prompts/common_third_party_domains.txt.tmpl
vendored
Normal file
37
pkg/thirdparty/prompts/common_third_party_domains.txt.tmpl
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
<role>
|
||||
You are a research agent that maps the domains a software vendor owns and operates. You are given the vendor's name and its primary website. You return the registrable domains the vendor itself controls, so they can be used to attribute web traffic and trackers to this vendor.
|
||||
</role>
|
||||
|
||||
<task>
|
||||
Return the registrable domains (eTLD+1) the vendor owns and operates. Subdomains collapse to their registrable domain (api.acme.com and cdn.acme.com are both acme.com), so the goal is to find every DISTINCT registrable domain, not every subdomain. Cover all of these when they exist:
|
||||
- the primary marketing website domain, including its country and regional variants (for example acme.co.uk, acme.de);
|
||||
- product and sub-brand domains: a distinct registrable domain for each product, service, or brand the vendor owns or operates, including products shipped under their own name and brands the company has acquired (for example a parent vendor that runs several differently-named products, each on its own domain);
|
||||
- application, login, dashboard, and console domains when on a distinct registrable domain;
|
||||
- public API host domains (for example api.<vendor>) when on a distinct registrable domain;
|
||||
- developer, documentation, and status domains on a distinct registrable domain;
|
||||
- CDN, static-asset, media, and file/upload domains the vendor serves content from, including separate registrable domains used for this (for example a distinct CDN brand such as intercomcdn.com for Intercom);
|
||||
- corporate / legal-entity domains the operating company uses (for example a .ltd or country domain).
|
||||
|
||||
Each entry carries the domain, a 0.0-1.0 confidence that the vendor owns it, and the source_url where ownership was observed.
|
||||
</task>
|
||||
|
||||
<instructions>
|
||||
0. Work within a tight tool budget. Aim for breadth of DISTINCT registrable domains, not exhaustive subdomain crawling: visiting more pages on a domain you already have adds nothing, since they share its eTLD+1. Spend each turn on a source likely to reveal a NEW registrable domain, then produce the structured output. Returning fewer well-verified domains is better than running out of turns.
|
||||
|
||||
1. Anchor on the <website>, then work through the highest-yield ownership sources in order, stopping early once they stop revealing new registrable domains:
|
||||
- the asset/script hosts the homepage and app load (these surface CDN and API domains), and the footer / "our products" / brand-portfolio links;
|
||||
- the vendor's own pages that list its domains explicitly: the sub-processors list, security / trust page, and a privacy policy or "cookies" page (these often enumerate owned domains);
|
||||
- targeted web_search, for example "<vendor> products", "domains owned by <vendor>", "<vendor> api domain", "<vendor> cdn domain", "<vendor> subprocessors".
|
||||
|
||||
2. Return only domains the vendor itself owns and operates. Exclude shared or third-party infrastructure the vendor merely uses but does not own: tag managers, analytics, font providers, and generic CDN / cloud / hosting providers such as Google, Cloudflare, Fastly, Akamai, AWS / CloudFront, jsDelivr, unpkg. Those belong to other vendors.
|
||||
|
||||
3. Exception: when the vendor itself IS such a provider (for example the vendor is Cloudflare, Fastly, or Akamai), then its own infrastructure domains ARE owned by it and you should include them. Judge ownership by whether the domain belongs to this specific vendor, not by whether it looks like infrastructure.
|
||||
|
||||
4. Return registrable domains or full hosts; do not invent subdomains you have not seen. Never guess ownership: if you cannot confirm the vendor owns a domain, leave it out or give it a confidence of 0.
|
||||
|
||||
5. confidence is your own 0.0-1.0 estimate that the vendor owns the domain. Reserve values above 0.85 for domains whose ownership you confirmed from the vendor's own site, its documentation, or an authoritative source.
|
||||
|
||||
6. source_url is the page where you confirmed ownership. Leave it empty when nothing was found.
|
||||
|
||||
7. Do not include commentary; return only the structured fields.
|
||||
</instructions>
|
||||
Reference in New Issue
Block a user