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
84 changes: 84 additions & 0 deletions internal/scan/builtin/cdn_module.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

package builtin

import (
"context"
"fmt"
"net/http"

"github.com/vmfunc/sif/internal/httpx"
"github.com/vmfunc/sif/internal/modules"
"github.com/vmfunc/sif/internal/scan/frameworks"
)

type CDNModule struct{}

func (m *CDNModule) Info() modules.Info {
return modules.Info{
ID: "cdn-detection",
Name: "CDN/Hosting Provider Detection",
Author: "sif",
Severity: "info",
Description: "Fingerprints the cdn/edge/hosting provider fronting a target from response headers",
Tags: []string{"recon", "cdn", "hosting", "fingerprint"},
}
}

func (m *CDNModule) Type() modules.ModuleType {
return modules.TypeHTTP
}

// Execute fetches the target and runs the CDN detector pool over the response,
// independent of framework detection (see cdnRegistry in
// internal/scan/frameworks/cdn.go).
func (m *CDNModule) Execute(ctx context.Context, target string, opts modules.Options) (*modules.Result, error) {
client := opts.Client
if client == nil {
client = httpx.Client(opts.Timeout)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, http.NoBody)
if err != nil {
return nil, err
}
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return nil, err
}
// every CDN signature is HeaderOnly, so the body is never inspected; drain
// and close it (no per-target body allocation) and detect on headers alone.
defer httpx.DrainClose(resp)

result := &modules.Result{
ModuleID: m.Info().ID,
Target: target,
Findings: []modules.Finding{},
}

cdn := frameworks.DetectCDN("", resp.Header)
if cdn == nil {
return result, nil
}

result.Findings = append(result.Findings, modules.Finding{
URL: target,
Severity: "info",
Evidence: fmt.Sprintf("Fronted by %s (confidence: %.2f)", cdn.Name, cdn.Confidence),
Extracted: map[string]string{
"cdn": cdn.Name,
"confidence": fmt.Sprintf("%.2f", cdn.Confidence),
},
})

return result, nil
}
83 changes: 83 additions & 0 deletions internal/scan/builtin/cdn_module_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

/*

BSD 3-Clause License
(c) 2022-2026 vmfunc, xyzeva & contributors

*/

package builtin

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/vmfunc/sif/internal/modules"
// import the detectors package for its init() so the CDN detector pool
// is registered when the module runs.
_ "github.com/vmfunc/sif/internal/scan/frameworks/detectors"
)

func TestCDNModule_DetectsCloudflare(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("CF-RAY", "7d1f4a2b3c4d5e6f-LAX")
w.Header().Set("Server", "cloudflare")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Hello</body></html>`))
}))
defer server.Close()

m := &CDNModule{}
result, err := m.Execute(context.Background(), server.URL, modules.Options{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("Execute: unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected a result, got nil")
}
if len(result.Findings) != 1 {
t.Fatalf("expected 1 finding, got %d", len(result.Findings))
}
if got := result.Findings[0].Extracted["cdn"]; got != "Cloudflare" {
t.Errorf("expected cdn 'Cloudflare', got %q", got)
}
if sev := result.Findings[0].Severity; sev != "info" {
t.Errorf("expected severity 'info' (a cdn is a pure fingerprint), got %q", sev)
}
}

func TestCDNModule_NoCDN(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// generic origin headers, no edge-injected vendor marker.
w.Header().Set("Server", "nginx")
w.Header().Set("X-Powered-By", "PHP/8.2.0")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Plain origin</body></html>`))
}))
defer server.Close()

m := &CDNModule{}
result, err := m.Execute(context.Background(), server.URL, modules.Options{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("Execute: unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected a result, got nil")
}
if len(result.Findings) != 0 {
t.Errorf("expected no findings on a plain origin, got %d (%+v)", len(result.Findings), result.Findings)
}
}
1 change: 1 addition & 0 deletions internal/scan/builtin/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import "github.com/vmfunc/sif/internal/modules"
func Register() {
modules.Register(&ShodanModule{})
modules.Register(&FrameworksModule{})
modules.Register(&CDNModule{})
modules.Register(&NucleiModule{})
modules.Register(&WhoisModule{})
modules.Register(&SecurityTrailsModule{})
Expand Down
94 changes: 94 additions & 0 deletions internal/scan/frameworks/cdn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

/*

BSD 3-Clause License
(c) 2022-2026 vmfunc, xyzeva & contributors

*/

package frameworks

import (
"net/http"
"sync"
)

// cdnRegistry holds CDN/hosting/edge detectors, kept separate from registry
// (see detector.go) because a CDN/edge is orthogonal to an application
// framework: a target can be both Cloudflare-fronted and Next.js. DetectFramework
// takes a single global-argmax winner, so a CDN detector in registry would
// outrank the real framework (a bare cf-ray header scores ~0.999 via
// sigmoidConfidence) and report "Cloudflare" instead of "Next.js". A separate
// registry reduced by its own DetectCDN lets both answers coexist.
var (
cdnRegistryMu sync.RWMutex
cdnRegistry = make(map[string]Detector)
)

// RegisterCDN adds a CDN/hosting detector. should be called from init().
func RegisterCDN(d Detector) {
cdnRegistryMu.Lock()
defer cdnRegistryMu.Unlock()
cdnRegistry[d.Name()] = d
}

// GetCDNDetectors returns all registered CDN/hosting detectors.
func GetCDNDetectors() map[string]Detector {
cdnRegistryMu.RLock()
defer cdnRegistryMu.RUnlock()

result := make(map[string]Detector, len(cdnRegistry))
for k, v := range cdnRegistry {
result[k] = v
}
return result
}

// cdnDetectionThreshold mirrors detectionThreshold in detect.go: below this,
// report no CDN rather than a weak guess.
const cdnDetectionThreshold = 0.5

// CDNResult is CDN/hosting-provider detection output. It is a separate type from
// FrameworkResult because the two come from independent pools and either can be
// absent while the other is present.
type CDNResult struct {
Name string `json:"name"`
Confidence float32 `json:"confidence"`
}

// ResultType implements the ScanResult interface.
func (r *CDNResult) ResultType() string { return "cdn" }

// DetectCDN runs every registered CDN/hosting detector against an already-fetched
// response and returns the best match, or nil below cdnDetectionThreshold. Unlike
// DetectFramework it takes body/headers directly, so a caller with a response in
// hand can run both detections off one request.
func DetectCDN(body string, headers http.Header) *CDNResult {
detectors := GetCDNDetectors()
if len(detectors) == 0 {
return nil
}

var best CDNResult
for _, d := range detectors {
confidence, _ := d.Detect(body, headers)
if confidence > best.Confidence || (confidence == best.Confidence && confidence > 0 && d.Name() < best.Name) {
best = CDNResult{Name: d.Name(), Confidence: confidence}
}
}

if best.Confidence <= cdnDetectionThreshold {
return nil
}
return &best
}
Loading
Loading