Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
67 changes: 67 additions & 0 deletions scanners/subdomain-takeover/internal/detect/body_matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package detect

import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"

"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/fingerprints"
)

type BodyFingerprintMatcher interface {
Contains(domain string, fingerprint string, mode fingerprints.FingerprintMode) bool
}

type HTTPBodyFingerprintMatcher struct {
client *http.Client
}

func NewHTTPBodyFingerprintMatcher(timeout time.Duration) *HTTPBodyFingerprintMatcher {
return &HTTPBodyFingerprintMatcher{
client: &http.Client{Timeout: timeout},
}
}

type NoopBodyFingerprintMatcher struct{}

func NewNoopBodyFingerprintMatcher() *NoopBodyFingerprintMatcher {
return &NoopBodyFingerprintMatcher{}
}

func (m *NoopBodyFingerprintMatcher) Contains(domain string, fingerprint string, mode fingerprints.FingerprintMode) bool {
return false
}

func (m *HTTPBodyFingerprintMatcher) Contains(domain string, fingerprint string, mode fingerprints.FingerprintMode) bool {
if m == nil || m.client == nil {
return false
}

url := fmt.Sprintf("http://%s", domain)
res, err := m.client.Get(url)
if err != nil {
return false
}
defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
return false
}

bodyText := string(body)
resolvedMode := fingerprints.NormalizeMode(mode, fingerprint)

if resolvedMode == fingerprints.FingerprintModeRegex {
re, err := regexp.Compile(fingerprint)
if err != nil {
return false
}
return re.MatchString(bodyText)
}

return strings.Contains(bodyText, fingerprint)
}
129 changes: 129 additions & 0 deletions scanners/subdomain-takeover/internal/detect/classifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package detect

import (
"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/model"
"github.com/rs/zerolog"
)

type Classifier struct {
Matcher BodyFingerprintMatcher
Source FingerprintSource
logger zerolog.Logger
}

func NewClassifier(matcher BodyFingerprintMatcher) *Classifier {
return &Classifier{Matcher: matcher, Source: GlobalFingerprintSource{}, logger: zerolog.Nop()}
}

func NewClassifierWithSource(matcher BodyFingerprintMatcher, source FingerprintSource) *Classifier {
if source == nil {
source = GlobalFingerprintSource{}
}

return &Classifier{Matcher: matcher, Source: source, logger: zerolog.Nop()}
}

func (c *Classifier) WithLogger(logger zerolog.Logger) *Classifier {
c.logger = logger.With().Str("component", "classifier").Logger()
SetLogger(logger)
return c
}

func (c *Classifier) Classify(input model.Input) ([]model.Finding, error) {
matcher := c.Matcher
if matcher == nil {
matcher = NewNoopBodyFingerprintMatcher()
}

source := c.Source
if source == nil {
source = GlobalFingerprintSource{}
}

logger := c.logger
if logger.GetLevel() == zerolog.NoLevel {
logger = zerolog.Nop()
}

return Classify(input, matcher, source, logger)
}

func Classify(input model.Input, matcher BodyFingerprintMatcher, source FingerprintSource, logger zerolog.Logger) ([]model.Finding, error) {
findings := []model.Finding{}

cnameProviderFingerprints := source.CNAME()
nsProviderFingerprints := source.NS()

cnameEvidence := ExtractCNAMEEvidence(input.Results)
if cnameEvidence != nil {
logger.Debug().Str("domain_key", input.DomainKey).Str("domain", cnameEvidence.Domain).Msg("cname evidence extracted")
cnameHit := MatchCNAMEFingerprints(*cnameEvidence, cnameProviderFingerprints, matcher)
if ShouldEmitCNAME(cnameHit) {
logger.Debug().
Str("domain_key", input.DomainKey).
Str("domain", cnameEvidence.Domain).
Str("provider", cnameHit.Provider).
Str("reason_code", string(cnameHit.ReasonCode)).
Msg("emitting cname finding")
findings = append(findings, model.Finding{
Domain: cnameEvidence.Domain,
DomainKey: input.DomainKey,
RecordType: model.RecordTypeCNAME,
Target: cnameEvidence.Target,
Provider: cnameHit.Provider,
ReasonCode: string(cnameHit.ReasonCode),
Confidence: ConfidenceForReason(cnameHit.ReasonCode),
})
}
if cnameHit != nil && !ShouldEmitCNAME(cnameHit) {
logger.Debug().
Str("domain_key", input.DomainKey).
Str("domain", cnameEvidence.Domain).
Str("provider", cnameHit.Provider).
Str("reason_code", string(cnameHit.ReasonCode)).
Msg("cname finding suppressed")
}
} else {
logger.Debug().Str("domain_key", input.DomainKey).Msg("no cname evidence")
}

nsEvidence := ExtractNSEvidence(input.Results)
if nsEvidence != nil {
logger.Debug().
Str("domain_key", input.DomainKey).
Str("domain", nsEvidence.Domain).
Int("ns_hosts", len(nsEvidence.NSHosts)).
Msg("ns evidence extracted")
nsHit := MatchNSProviderRules(*nsEvidence, nsProviderFingerprints)
if ShouldEmitNSHijack(nsHit) {
logger.Debug().
Str("domain_key", input.DomainKey).
Str("domain", nsEvidence.Domain).
Str("provider", nsHit.Provider).
Str("target", nsHit.Host).
Str("reason_code", string(nsHit.ReasonCode)).
Msg("emitting ns finding")
findings = append(findings, model.Finding{
Domain: nsEvidence.Domain,
DomainKey: input.DomainKey,
RecordType: model.RecordTypeNS,
Target: nsHit.Host,
Provider: nsHit.Provider,
ReasonCode: string(nsHit.ReasonCode),
Confidence: ConfidenceForReason(nsHit.ReasonCode),
})
} else if nsHit != nil {
logger.Debug().
Str("domain_key", input.DomainKey).
Str("domain", nsEvidence.Domain).
Str("provider", nsHit.Provider).
Str("target", nsHit.Host).
Str("reason_code", string(nsHit.ReasonCode)).
Msg("ns finding suppressed")
}
} else {
logger.Debug().Str("domain_key", input.DomainKey).Msg("no ns evidence")
}

return findings, nil
}
31 changes: 31 additions & 0 deletions scanners/subdomain-takeover/internal/detect/cname_evidence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package detect

import (
"strings"

"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/model"
)

type CNAMEEvidence struct {
Domain string
Target string
NoResolve bool
}

func ExtractCNAMEEvidence(results model.ScanResults) *CNAMEEvidence {
if results.CnameRecord == nil || results.Domain == nil {
return nil
}

return &CNAMEEvidence{
Domain: *results.Domain,
Target: parseCname(*results.CnameRecord),
NoResolve: len(results.ResolveChain) == 0,
}
}

func parseCname(record string) string {
normalRecord := normalizeHost(record)
recordTokens := strings.Split(normalRecord, " ")
return recordTokens[len(recordTokens)-1]
}
79 changes: 79 additions & 0 deletions scanners/subdomain-takeover/internal/detect/cname_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package detect

import "github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/fingerprints"

type CNAMEHit struct {
Matched bool
Provider string
ReasonCode ReasonCode
NeedsNX bool
}

func MatchCNAMEFingerprints(evidence CNAMEEvidence, providerFingerprints []fingerprints.CNAMEProviderFingerprint, matcher BodyFingerprintMatcher) *CNAMEHit {
for _, fp := range providerFingerprints {
if fp.ContainsTarget(evidence.Target) {
detectLogger.Debug().
Str("domain", evidence.Domain).
Str("target", evidence.Target).
Str("provider", fp.Name).
Bool("needs_nx", fp.Nxdomain).
Msg("cname provider target matched")

hit := CNAMEHit{
Matched: false,
Provider: fp.Name,
ReasonCode: "",
NeedsNX: fp.Nxdomain,
}

if hit.NeedsNX {
hit.ReasonCode = ReasonCNAMETargetMatchMissingNXDOMAIN
if evidence.NoResolve {
hit.Matched = true
hit.ReasonCode = ReasonCNAMEDanglingNXDOMAIN
}
} else {
hit.ReasonCode = ReasonCNAMETargetMatchMissingBodyFP
mode := fingerprints.NormalizeMode(fp.Mode, fp.Fingerprint)
detectLogger.Debug().
Str("domain", evidence.Domain).
Str("provider", fp.Name).
Str("fingerprint_mode", string(mode)).
Msg("checking cname body fingerprint")
if matcher != nil && matcher.Contains(evidence.Domain, fp.Fingerprint, mode) {
hit.Matched = true
hit.ReasonCode = ReasonCNAMEProviderFingerprintBodyMatch
detectLogger.Debug().
Str("domain", evidence.Domain).
Str("provider", fp.Name).
Str("reason_code", string(hit.ReasonCode)).
Msg("cname body fingerprint matched")
} else {
detectLogger.Debug().
Str("domain", evidence.Domain).
Str("provider", fp.Name).
Str("reason_code", string(hit.ReasonCode)).
Msg("cname finding suppressed: body fingerprint missing")
}
}

if hit.NeedsNX && !hit.Matched {
detectLogger.Debug().
Str("domain", evidence.Domain).
Str("provider", fp.Name).
Str("reason_code", string(hit.ReasonCode)).
Msg("cname finding suppressed: required nxdomain signal missing")
}

return &hit
}
}
return nil
}

func ShouldEmitCNAME(hit *CNAMEHit) bool {
if hit == nil {
return false
}
return hit.Matched
}
37 changes: 37 additions & 0 deletions scanners/subdomain-takeover/internal/detect/confidence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package detect

const (
ConfidenceSuspected = "suspected"
ConfidenceProbable = "probable"
ConfidenceConfirmed = "confirmed"
)

// ConfidenceForReason maps stable reason codes to confidence levels.
// Unknown reason codes default to suspected.
func ConfidenceForReason(reasonCode ReasonCode) string {
switch reasonCode {
case ReasonCNAMEDanglingNXDOMAIN:
return ConfidenceProbable
case ReasonCNAMEProviderFingerprintBodyMatch:
return ConfidenceProbable
case ReasonCNAMETargetMatchMissingNXDOMAIN:
return ConfidenceSuspected
case ReasonCNAMETargetMatchMissingBodyFP:
return ConfidenceSuspected

case ReasonNSFullLameProviderVulnerable:
return ConfidenceConfirmed
case ReasonNSPartialLameProviderVulnerable:
return ConfidenceProbable
case ReasonNSRegistrarProviderMatch:
return ConfidenceSuspected
case ReasonNSRegistrarContextInsufficient:
return ConfidenceSuspected
case ReasonNSLameProviderUnknown:
return ConfidenceSuspected
case ReasonNSProviderMatchOnly:
return ConfidenceSuspected
default:
return ConfidenceSuspected
}
}
18 changes: 18 additions & 0 deletions scanners/subdomain-takeover/internal/detect/fingerprint_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package detect

import "github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/fingerprints"

type FingerprintSource interface {
CNAME() []fingerprints.CNAMEProviderFingerprint
NS() []fingerprints.NSProviderFingerprint
}

type GlobalFingerprintSource struct{}

func (GlobalFingerprintSource) CNAME() []fingerprints.CNAMEProviderFingerprint {
return fingerprints.CNAME()
}

func (GlobalFingerprintSource) NS() []fingerprints.NSProviderFingerprint {
return fingerprints.NS()
}
9 changes: 9 additions & 0 deletions scanners/subdomain-takeover/internal/detect/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package detect

import "github.com/rs/zerolog"

var detectLogger = zerolog.Nop()

func SetLogger(logger zerolog.Logger) {
detectLogger = logger.With().Str("component", "detect").Logger()
}
Loading