Compare commits

..

21 Commits

Author SHA1 Message Date
vmfunc 8421cb8daa test(scan): fix integration_test SQL arity after calibrate param
#180 added the calibrate bool to SQL but the integration-tagged test
(only built under -tags=integration, outside normal CI) still called the
4-arg form. pass false (no calibration) to restore behavior.
2026-06-22 22:10:40 -07:00
Tigah fb9b92a5bf fix(frameworks): make CodeIgniter detection specific (#156) 2026-06-22 22:00:26 -07:00
Tigah 184842f734 feat(modules): add wordlist file support to http modules (#158)
a wordlist file fuzzes the {{word}} path placeholder, one request per
non-empty line, reusing the dirlist scanner's line reader. composes with
the existing attack modes and matchers-condition; updates attack-mode
tests for the new signature.
2026-06-22 21:44:04 -07:00
Tigah 0c6a8db5a7 feat(modules): add favicon fingerprint demo module (#184)
a favicon-gitlab info module showing the favicon hash matcher in use,
with a sync test pinning the module's hash to the shared fingerprint pkg.
2026-06-22 21:42:43 -07:00
Tigah 54d1be288b fix(frameworks): make Spring Boot detection specific (#157) 2026-06-22 21:39:59 -07:00
celeste 17cf26cd82 test(modules): fix favicon_test checkMatchers arity after matchers-condition (#228)
#189 added the condition param to checkMatchers; the favicon matcher test
(#183) still called the 3-arg form, breaking the build once both landed.
pass "" (default and-condition) to restore single-matcher semantics.
2026-06-22 21:23:12 -07:00
Tigah 672858b1fe fix(frameworks): make Shopify detection specific (#155) 2026-06-22 20:49:05 -07:00
Tigah 0422b8b413 feat(modules): add favicon hash matcher (#183)
match the shodan-style mmh3 favicon hash of the response body; signed or
unsigned 32-bit values both accepted. validated at parse time.
2026-06-22 20:42:32 -07:00
Tigah f37094c9ee feat(modules): support or logic via matchers-condition (#189)
add matchers-condition (and default, or) so a module fires when any
matcher hits, not only when all do. validated at parse time.
2026-06-22 20:33:14 -07:00
Tigah d34db5582f fix(frameworks): drop bare-substring signatures that false-positive (#133)
replace bare gin/api/cake/svelte/ember/backbone/meteor substrings with
specific markers (Backbone.Model, ember-application, __meteor_runtime_config__,
CAKEPHP cookie, svelte/internal) so prose and CORS headers stop matching.
adds paired false-positive/positive coverage per framework.
2026-06-22 20:31:32 -07:00
Tigah 9cf7854ed8 feat(modules): add json extractor (#191)
extract values from a json body by gjson path; the first path that
exists is stored under the extractor name. gjson was already vendored.
2026-06-22 20:26:28 -07:00
Tigah af337bd094 fix(scan): apply reflected-path tolerance to soft-404 calibration (#180)
calibrate against reflecting catch-alls whose body size tracks path
length so exact-shape calibration no longer misses them; -ac now drives
both dirlist and sql. updates the admin-panel query test to the new SQL
signature. adds soft-404 + calibration coverage.
2026-06-22 20:26:19 -07:00
Tigah 7e104ac8d4 fix(scan): detect modern drupal by its headers (#170)
CMS only flagged Drupal on X-Drupal-Cache: HIT or the Drupal 7 Drupal.settings
marker, so Drupal 8-11 went undetected: a MISS cache header was ignored, and
cdn-fronted sites serve none of those markers in the body at all. verified live
that london.gov.uk and georgia.gov (both Drupal) were missed.

key on the Drupal-specific headers instead: any X-Drupal-Cache or
X-Drupal-Dynamic-Cache, plus X-Generator naming Drupal. these survive cdn
caching. drupalSettings (8+) and Drupal.settings (7) cover uncached bodies.
2026-06-22 20:20:36 -07:00
Tigah 5dc14ecf22 fix(scan): drop subdomain-takeover fingerprints for mitigated and generic services (#165)
prune fingerprints for providers can-i-take-over-xyz marks not-vulnerable
(fastly, zendesk, uservoice, acquia) and generic-content matches that
false-positive (kajabi/thinkific/tave 404s, activecampaign lighttpd page,
teamwork). keeps still-vulnerable detections intact; adds coverage.
2026-06-22 20:15:47 -07:00
Tigah b31234c1bc feat(modules): add netdata and cadvisor exposure modules (#217)
modules/recon/netdata-api-exposure.yaml flags an exposed Netdata agent through its
unauthenticated /api/v1/info endpoint, keyed on the mirrored_hosts and cores_total
fields a generic info response does not carry, then extracts the agent version.

modules/recon/cadvisor-api-exposure.yaml flags an exposed cAdvisor container monitor
through its /api/v1.3/machine endpoint, keyed on the machine_id and cpu_frequency_khz
fields, then extracts the machine id.

internal/modules/metrics_exposure_test.go drives both modules through
ExecuteHTTPModule and asserts the leak alongside the near misses a strict review
wants pinned: each service with one keying field missing, a generic json, a plain 200
and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 19:52:30 -07:00
Tigah caeff3944d feat(modules): add docker, kubernetes and kubelet api exposure modules (#212)
modules/recon/docker-api-exposure.yaml flags an unauthenticated Docker Engine
api, keyed on the api version paired with the minimum api version that a generic
version endpoint does not carry, then extracts the engine version.

modules/recon/kubernetes-api-exposure.yaml flags an internet reachable Kubernetes
api server through its anonymous version endpoint, keyed on the git version
paired with a build field, then extracts the version.

modules/recon/kubelet-api-exposure.yaml flags an exposed kubelet whose pod list
leaks the cluster workload, keyed on the PodList kind paired with an api version,
then extracts a pod namespace.

internal/modules/runtime_api_exposure_test.go drives the three modules end to end
through ExecuteHTTPModule and asserts the leak alongside the near misses a strict
review wants pinned: a generic version response, each service with one keying
field missing, a service list that is not a pod list, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 19:52:25 -07:00
Tigah 8c8f8afba3 feat(modules): add maven, gradle and nuget credential exposure modules (#209)
modules/recon/maven-settings-exposure.yaml flags an exposed settings.xml through
the settings or servers structure paired with a password element, so a mirror
only config is not reported, then extracts the server username.

modules/recon/gradle-properties-exposure.yaml flags an exposed gradle.properties
through a password, secret or token property with a value on the same line,
skipping comments and empty assignments, then extracts the property name.

modules/recon/nuget-config-exposure.yaml flags an exposed nuget.config through a
packageSourceCredentials section paired with a cleartext password key, so a
plain package source list or an appsettings password is not reported, then
extracts the feed username.

internal/modules/buildtool_credential_exposure_test.go drives the three modules
end to end through ExecuteHTTPModule and asserts the leak alongside the near
misses a strict review wants pinned: a mirror only settings, a non credential
properties file, a commented password, an empty value, a plain source list, an
appsettings password, an html tutorial for each file, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 19:52:21 -07:00
Tigah 1e47b6547e feat(modules): add terraform, kubeconfig and compose exposure modules (#201)
modules/recon/terraform-state-exposure.yaml flags an exposed terraform state
file on the terraform_version key paired with a state structure key, then
extracts the version. the structure key keeps a document that merely mentions
terraform_version from matching.

modules/recon/kubeconfig-exposure.yaml flags an exposed kubeconfig on the
kind: Config marker paired with a cluster or credential key, then extracts the
cluster api endpoint. it catches an exec auth kubeconfig with no embedded key
since the cluster block alone is a leak.

modules/recon/docker-compose-exposure.yaml flags an exposed compose file on the
services key paired with a service definition key, then extracts the first
image reference to surface the stack and its versions.

each module pairs a unique marker with a structure key and rejects an html
body, so a page that only names the marker is not a leak.

internal/modules/infra_config_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a bare terraform_version mention, a bare
kind: Config mention, a bare services key, an html page carrying the markers, a
plain 200 body and a 404, none of which may match.

verify: go test ./internal/modules, each marker, structure gate, guard and
extractor proven to bite (break -> red, restore -> green).
2026-06-22 19:52:16 -07:00
Tigah 368d658882 feat(modules): add grafana, kibana and jenkins login panel modules (#187)
* feat(modules): add grafana, kibana and jenkins login panel modules

* test(modules): cover the login panel modules
2026-06-22 19:52:12 -07:00
Tigah c6cedf3f55 feat(modules): add env file exposure module (#185)
* feat(modules): add env file exposure module

* test(modules): cover the env file exposure module
2026-06-22 19:52:07 -07:00
celeste 6dd1d9e7fe Add Claude Code GitHub Workflow (#226)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-06-22 18:38:05 -07:00
52 changed files with 3510 additions and 105 deletions
+1 -1
View File
@@ -176,7 +176,7 @@ sif has a modular architecture. modules are defined in yaml and can be extended
| `-fs` | dirlist: filter out responses of these body sizes (comma list) |
| `-fw` | dirlist: filter out responses with these word counts (comma list) |
| `-fr` | dirlist: filter out responses whose body matches this regex |
| `-ac` | dirlist: auto-calibrate the soft-404 wildcard baseline |
| `-ac` | auto-calibrate the soft-404 wildcard baseline (dirlist, sql) |
| `-w` | dirlist: custom wordlist (local file or url; overrides `-dirlist` size) |
| `-e` | dirlist: extensions appended to each word (comma list, e.g. php,bak,env) |
| `-dnslist` | subdomain enumeration (small/medium/large) |
+69
View File
@@ -127,6 +127,17 @@ http:
- `clusterbomb` (default) - every path is tried with every payload
- `pitchfork` - path and payload are paired by index, stopping at the shorter list
#### wordlist
a local file whose non-empty lines fuzz the `{{word}}` placeholder, one request
per word. paths without `{{word}}` are still requested as-is.
```yaml
http:
wordlist: /usr/share/wordlists/dirs.txt
paths:
- "{{BaseURL}}/{{word}}"
```
#### headers
custom headers to send.
@@ -223,6 +234,30 @@ matchers:
- 1337
```
### favicon matcher
match the shodan-style mmh3 hash of the response body. point the module at a
favicon and list the hashes of the tech you want to fingerprint.
```yaml
http:
paths:
- "{{BaseURL}}/favicon.ico"
matchers:
- type: status
status:
- 200
- type: favicon
hash:
- -235701012 # jenkins
- 1278322581 # grafana
```
the hash is shodan's `http.favicon.hash` value. paste it signed or unsigned;
both 32-bit forms are accepted, so values from shodan or any favicon-hash tool
drop in without conversion. pair it with a `status: 200` matcher so an error
page served for `/favicon.ico` is not hashed. a finding fires when the body
hashes to any listed value.
### combining matchers
multiple matchers are combined with AND logic by default.
@@ -242,6 +277,25 @@ matchers:
this matches responses with status 200 AND containing "ref: refs/".
to require any matcher instead of all, set `matchers-condition: or` on the http
block; the module then reports a finding when any one matcher matches.
```yaml
http:
matchers-condition: or
matchers:
- type: status
status:
- 401
- type: status
status:
- 403
```
this matches a 401 OR a 403 response. `matchers-condition` accepts `and` (the
default) or `or`; any other value fails at load.
## extractors
extractors pull data from responses.
@@ -271,6 +325,21 @@ extractors:
part: header
```
### json extractor
extract values from a json body by gjson path (github.com/tidwall/gjson); the
first path that exists is stored under name.
```yaml
extractors:
- type: json
name: version
part: body
json:
- "version"
- "data.version"
```
## examples
### exposed git repository
+1 -1
View File
@@ -14,6 +14,7 @@ require (
github.com/projectdiscovery/retryabledns v1.0.115
github.com/projectdiscovery/utils v0.11.1
github.com/rocketlaunchr/google-search v1.1.6
github.com/tidwall/gjson v1.18.0
github.com/twmb/murmur3 v1.1.8
golang.org/x/net v0.56.0
golang.org/x/time v0.15.0
@@ -330,7 +331,6 @@ require (
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/buntdb v1.3.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
+2 -2
View File
@@ -27,7 +27,7 @@ type Settings struct {
DirFilterSizes string // -fs dirlist: body sizes to drop
DirFilterWords string // -fw dirlist: word counts to drop
DirFilterRegex string // -fr dirlist: regex; body match drops response
DirCalibrate bool // -ac dirlist: auto-calibrate soft-404 baseline
Calibrate bool // -ac auto-calibrate the soft-404 baseline (dirlist, sql)
DirWordlist string // -w dirlist: custom wordlist (file path or url)
DirExtensions string // -e dirlist: extensions appended to each word
Dnslist string
@@ -131,7 +131,7 @@ func registerFlags(settings *Settings) *goflags.FlagSet {
flagSet.StringVar(&settings.DirFilterSizes, "fs", "", "Dirlist: filter out responses of these body sizes (comma list)"),
flagSet.StringVar(&settings.DirFilterWords, "fw", "", "Dirlist: filter out responses with these word counts (comma list)"),
flagSet.StringVar(&settings.DirFilterRegex, "fr", "", "Dirlist: filter out responses whose body matches this regex"),
flagSet.BoolVar(&settings.DirCalibrate, "ac", false, "Dirlist: auto-calibrate the soft-404 wildcard baseline"),
flagSet.BoolVar(&settings.Calibrate, "ac", false, "Auto-calibrate the soft-404 wildcard baseline (dirlist, sql)"),
flagSet.StringVar(&settings.DirWordlist, "w", "", "Dirlist: custom wordlist (local file path or url; overrides -dirlist size)"),
flagSet.StringVar(&settings.DirExtensions, "e", "", "Dirlist: extensions appended to each word (comma list, e.g. php,bak,env)"),
flagSet.EnumVar(&settings.Dnslist, "dnslist", Nil, "DNS fuzzing scan size (small/medium/large)", listSizes),
+5 -1
View File
@@ -60,7 +60,11 @@ func TestGenerateHTTPRequestsAttack(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &HTTPConfig{Paths: tt.paths, Payloads: tt.payloads, Attack: tt.attack}
got := reqURLs(generateHTTPRequests(target, cfg))
reqs, err := generateHTTPRequests(target, cfg)
if err != nil {
t.Fatalf("generateHTTPRequests: %v", err)
}
got := reqURLs(reqs)
want := append([]string(nil), tt.want...)
sort.Strings(want)
if !reflect.DeepEqual(got, want) {
@@ -0,0 +1,198 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runBuildCredModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func buildCredExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestBuildToolCredentialExposureModules(t *testing.T) {
const maven = "../../modules/recon/maven-settings-exposure.yaml"
const gradle = "../../modules/recon/gradle-properties-exposure.yaml"
const nuget = "../../modules/recon/nuget-config-exposure.yaml"
mavenSettings := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<settings xmlns=\"http://maven.apache.org/SETTINGS/1.0.0\">\n" +
" <servers>\n <server>\n <id>nexus-releases</id>\n" +
" <username>deploy</username>\n <password>S3cretDeployPass</password>\n" +
" </server>\n </servers>\n</settings>\n"
gradleProps := "org.gradle.jvmargs=-Xmx2g\nossrhUsername=deployer\n" +
"ossrhPassword=mySonatypeSecret\nsigning.password=mySigningSecret\n"
nugetConfig := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n" +
" <packageSourceCredentials>\n <MyFeed>\n" +
" <add key=\"Username\" value=\"deploy\" />\n" +
" <add key=\"ClearTextPassword\" value=\"S3cretFeedPass\" />\n" +
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
t.Run("an exposed maven settings leaks the server username", func(t *testing.T) {
res := runBuildCredModule(t, maven, 200, mavenSettings)
if len(res.Findings) == 0 {
t.Fatal("expected a maven finding")
}
if v := buildCredExtract(res, "maven_username"); v != "deploy" {
t.Errorf("maven_username=%q, want deploy", v)
}
})
t.Run("an exposed gradle properties leaks the secret property", func(t *testing.T) {
res := runBuildCredModule(t, gradle, 200, gradleProps)
if len(res.Findings) == 0 {
t.Fatal("expected a gradle finding")
}
if v := buildCredExtract(res, "gradle_secret_property"); v != "ossrhPassword" {
t.Errorf("gradle_secret_property=%q, want ossrhPassword", v)
}
})
t.Run("an exposed nuget config leaks the feed username", func(t *testing.T) {
res := runBuildCredModule(t, nuget, 200, nugetConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a nuget finding")
}
if v := buildCredExtract(res, "nuget_username"); v != "deploy" {
t.Errorf("nuget_username=%q, want deploy", v)
}
})
t.Run("a maven settings with mirrors but no password is not flagged", func(t *testing.T) {
body := "<settings>\n <mirrors>\n <mirror>\n <id>central</id>\n" +
" <url>https://repo.example.com/maven2</url>\n </mirror>\n </mirrors>\n</settings>\n"
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
t.Errorf("a settings without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a maven settings is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre><settings><server><password>x</password></server></settings></pre></body></html>"
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
t.Errorf("an html maven tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a gradle properties with no credential property is not flagged", func(t *testing.T) {
body := "org.gradle.jvmargs=-Xmx2g\nversion=1.0.0\norg.gradle.daemon=true\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("a non credential properties file should not match, got %d findings", len(res.Findings))
}
})
t.Run("a comment naming a password is not a credential property", func(t *testing.T) {
body := "# set your password=here before building\norg.gradle.daemon=true\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("a comment line should not match, got %d findings", len(res.Findings))
}
})
t.Run("an empty password property is not flagged", func(t *testing.T) {
body := "signing.password=\nsigning.keyId=24875D73\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("an empty value should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a gradle property is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html>\n<html><body><pre>\nossrhPassword=mySonatypeSecret\n</pre></body></html>\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("an html gradle tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a nuget config without a credentials section is not flagged", func(t *testing.T) {
body := "<configuration>\n <packageSources>\n" +
" <add key=\"nuget.org\" value=\"https://api.nuget.org/v3/index.json\" />\n" +
" </packageSources>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("a config without credentials should not match, got %d findings", len(res.Findings))
}
})
t.Run("a nuget credentials section without a password is not flagged", func(t *testing.T) {
body := "<configuration>\n <packageSourceCredentials>\n <MyFeed>\n" +
" <add key=\"Username\" value=\"deploy\" />\n" +
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("a credentials section without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings password is not a nuget feed credential", func(t *testing.T) {
body := "<configuration>\n <appSettings>\n" +
" <add key=\"Password\" value=\"appsecret\" />\n" +
" </appSettings>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("an appsettings password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a nuget config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre><packageSourceCredentials><add key=\"ClearTextPassword\" value=\"x\" /></packageSourceCredentials></pre></body></html>"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("an html nuget tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{maven, gradle, nuget} {
if res := runBuildCredModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{maven, gradle, nuget} {
if res := runBuildCredModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+84
View File
@@ -0,0 +1,84 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
// runEnvModule runs the env exposure module end to end against a server that
// returns the same status and body for every path it requests.
func runEnvModule(t *testing.T, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/env-file-exposure.yaml")
if err != nil {
t.Fatalf("parse: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
return res
}
func envLeakedKey(res *modules.Result) string {
for _, f := range res.Findings {
if v := f.Extracted["leaked_key"]; v != "" {
return v
}
}
return ""
}
func TestEnvFileExposureModule(t *testing.T) {
realEnv := "APP_NAME=Acme\nAPP_KEY=base64:Zm9vYmFy\nDB_PASSWORD=s3cr3t\nMAIL_PASSWORD=hunter2\n"
htmlMentionsSecret := "<!DOCTYPE html>\n<html><head><title>Docs</title></head><body>" +
"<code>APP_KEY=base64:...</code> put DB_PASSWORD= in your .env</body></html>"
t.Run("real env body leaks", func(t *testing.T) {
res := runEnvModule(t, 200, realEnv)
if len(res.Findings) == 0 {
t.Fatal("expected a finding for a real .env body")
}
if key := envLeakedKey(res); key != "APP_KEY" {
t.Errorf("leaked_key=%q, want APP_KEY", key)
}
})
t.Run("html page mentioning a key is not a leak", func(t *testing.T) {
if res := runEnvModule(t, 200, htmlMentionsSecret); len(res.Findings) > 0 {
t.Errorf("html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("secrets behind a 404 are not a leak", func(t *testing.T) {
if res := runEnvModule(t, 404, realEnv); len(res.Findings) > 0 {
t.Errorf("404 should not match, got %d findings", len(res.Findings))
}
})
}
+115 -21
View File
@@ -13,15 +13,19 @@
package modules
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/tidwall/gjson"
)
// MaxBodySize limits response body to prevent memory exhaustion.
@@ -69,7 +73,10 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
}
// Generate requests based on paths and payloads
requests := generateHTTPRequests(target, cfg)
requests, err := generateHTTPRequests(target, cfg)
if err != nil {
return nil, err
}
// Determine thread count
threads := cfg.Threads
@@ -123,9 +130,14 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
}
// generateHTTPRequests creates all requests based on paths and payloads.
func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
func generateHTTPRequests(target string, cfg *HTTPConfig) ([]*httpRequest, error) {
var requests []*httpRequest
paths, err := resolvePaths(cfg)
if err != nil {
return nil, err
}
// Ensure target has no trailing slash
target = strings.TrimSuffix(target, "/")
@@ -136,7 +148,7 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
// If no payloads, just use paths directly
if len(cfg.Payloads) == 0 {
for _, path := range cfg.Paths {
for _, path := range paths {
url := substituteVariables(path, target, "")
requests = append(requests, &httpRequest{
Method: method,
@@ -146,29 +158,82 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
Original: path,
})
}
return requests
return requests, nil
}
// pitchfork pairs path[i] with payload[i] and stops at the shorter list;
// clusterbomb (default) crosses every path with every payload.
if strings.EqualFold(cfg.Attack, "pitchfork") {
n := len(cfg.Paths)
n := len(paths)
if len(cfg.Payloads) < n {
n = len(cfg.Payloads)
}
for i := 0; i < n; i++ {
requests = append(requests, newPayloadRequest(method, target, cfg.Paths[i], cfg.Payloads[i], cfg))
requests = append(requests, newPayloadRequest(method, target, paths[i], cfg.Payloads[i], cfg))
}
return requests
return requests, nil
}
for _, path := range cfg.Paths {
for _, path := range paths {
for _, payload := range cfg.Payloads {
requests = append(requests, newPayloadRequest(method, target, path, payload, cfg))
}
}
return requests
return requests, nil
}
// resolvePaths expands a wordlist over any {{word}} path templates so one
// "{{BaseURL}}/{{word}}" path fuzzes the whole list; paths without {{word}}
// pass through literally. no wordlist leaves cfg.Paths untouched.
func resolvePaths(cfg *HTTPConfig) ([]string, error) {
if cfg.Wordlist == "" {
return cfg.Paths, nil
}
words, err := loadWordlist(cfg.Wordlist)
if err != nil {
return nil, err
}
var paths []string
for _, path := range cfg.Paths {
if !strings.Contains(path, "{{word}}") && !strings.Contains(path, "{{Word}}") {
paths = append(paths, path)
continue
}
for _, word := range words {
expanded := strings.ReplaceAll(path, "{{word}}", word)
expanded = strings.ReplaceAll(expanded, "{{Word}}", word)
paths = append(paths, expanded)
}
}
return paths, nil
}
// loadWordlist reads non-empty lines from a local wordlist file, mirroring the
// dirlist scanner's scanLines so a converted module fuzzes the identical words.
func loadWordlist(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
}
defer f.Close()
var words []string
scanner := bufio.NewScanner(f)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
words = append(words, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read wordlist %q: %w", path, err)
}
return words, nil
}
// newPayloadRequest builds one request with the path and body templates
@@ -239,45 +304,63 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest
bodyStr := string(respBody)
// Check matchers
if !checkMatchers(cfg.Matchers, resp, bodyStr) {
if !checkMatchers(cfg.Matchers, cfg.MatchersCondition, resp, bodyStr) {
return Finding{}, false
}
// Extract data
extracted := runExtractors(cfg.Extractors, resp, bodyStr)
// favicon-only matches fire on binary icon bytes; report the hash, not the body.
evidence := truncateEvidence(bodyStr)
if fav, ok := faviconEvidence(cfg.Matchers, bodyStr); ok {
evidence = fav
}
return Finding{
URL: r.URL,
Severity: severity,
Evidence: truncateEvidence(bodyStr),
Evidence: evidence,
Extracted: extracted,
}, true
}
// checkMatchers evaluates all matchers against the response.
func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool {
// checkMatchers combines matchers with condition "and" (default, all match) or "or" (any).
func checkMatchers(matchers []Matcher, condition string, resp *http.Response, body string) bool {
if len(matchers) == 0 {
return false
}
// Default to AND condition across matchers
or := strings.EqualFold(condition, "or")
for i := range matchers {
matched := checkMatcher(&matchers[i], resp, body)
if matchers[i].Negative {
matched = !matched
}
if !matched {
return false // AND logic
if or && matched {
return true
}
if !or && !matched {
return false
}
}
return true
// and: all matched; or: none matched.
return !or
}
// validateMatchersCondition rejects a matchers-condition that is not "", "and", or "or".
func validateMatchersCondition(condition string) error {
switch strings.ToLower(condition) {
case "", "and", "or":
return nil
default:
return fmt.Errorf("invalid matchers-condition %q (want \"and\" or \"or\")", condition)
}
}
// checkMatcher evaluates a single matcher.
func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
part := getPart(m.Part, resp, body)
switch m.Type {
case "status":
for _, status := range m.Status {
@@ -288,10 +371,13 @@ func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
return false
case "word":
return checkWords(part, m.Words, m.Condition)
return checkWords(getPart(m.Part, resp, body), m.Words, m.Condition)
case "regex":
return checkRegex(part, m.Regex, m.Condition)
return checkRegex(getPart(m.Part, resp, body), m.Regex, m.Condition)
case "favicon":
return checkFaviconHash(body, m.Hash)
case "size":
// size matches the response body length against any listed value.
@@ -416,6 +502,14 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
}
result[key] = strings.Join(v, ", ")
}
case "json":
part := getPart(e.Part, resp, body)
for _, path := range e.JSON {
if r := gjson.Get(part, path); r.Exists() {
result[e.Name] = r.String()
break
}
}
}
}
+44
View File
@@ -17,6 +17,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
@@ -322,6 +324,48 @@ func TestWrapperExecuteRoutesByType(t *testing.T) {
})
}
// TestExecuteHTTPModuleWordlist proves a {{word}} path templated against a local
// wordlist drives one real request per word, and only the path that exists fires.
func TestExecuteHTTPModuleWordlist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("login\nadmin\nbackup\n"), 0o600); err != nil {
t.Fatal(err)
}
def := &YAMLModule{
ID: "test-wordlist",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "low"},
HTTP: &HTTPConfig{
Method: "GET",
Wordlist: list,
Paths: []string{"{{BaseURL}}/{{word}}"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("got %d findings, want 1 (only /admin exists)", len(res.Findings))
}
if got := res.Findings[0].URL; got != srv.URL+"/admin" {
t.Errorf("finding url = %q, want %q", got, srv.URL+"/admin")
}
}
func TestTruncateEvidence(t *testing.T) {
short := "short evidence"
if got := truncateEvidence(short); got != short {
+82
View File
@@ -0,0 +1,82 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"fmt"
"math"
"github.com/dropalldatabases/sif/internal/fingerprint"
)
// checkFaviconHash reports whether the body's shodan mmh3 hash matches any
// configured value. only the body (the icon) is hashed; part is ignored.
func checkFaviconHash(body string, want []int64) bool {
if len(want) == 0 {
return false
}
got := fingerprint.FaviconHash([]byte(body))
for _, w := range want {
if n, ok := normalizeFaviconHash(w); ok && n == got {
return true
}
}
return false
}
// normalizeFaviconHash folds a hash to the signed int32 shodan stores, accepting
// either 32-bit form so a signed or unsigned value pastes in as-is. out-of-range
// values are rejected so a stray number can't wrap into a false match.
func normalizeFaviconHash(v int64) (int32, bool) {
if v < math.MinInt32 || v > math.MaxUint32 {
return 0, false
}
return int32(uint32(v)), true //nolint:gosec // intentional 32-bit fold to shodan's signed form
}
// faviconEvidence gives the hash as evidence for a favicon-only finding, and
// nothing when a word/regex matcher is present so its body evidence stands.
func faviconEvidence(matchers []Matcher, body string) (string, bool) {
favicon := false
for i := range matchers {
switch matchers[i].Type {
case "word", "regex":
return "", false
case "favicon":
favicon = true
}
}
if !favicon {
return "", false
}
return fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash([]byte(body))), true
}
// validateMatchers fails favicon matchers that would silently never fire (no
// hash, or one out of 32-bit range) at load rather than at match time.
func validateMatchers(matchers []Matcher) error {
for i := range matchers {
if matchers[i].Type != "favicon" {
continue
}
if len(matchers[i].Hash) == 0 {
return fmt.Errorf("favicon matcher requires at least one hash")
}
for _, h := range matchers[i].Hash {
if _, ok := normalizeFaviconHash(h); !ok {
return fmt.Errorf("favicon hash %d out of range (use a signed int32 or unsigned uint32 value)", h)
}
}
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"context"
"fmt"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/dropalldatabases/sif/internal/fingerprint"
"github.com/dropalldatabases/sif/internal/httpx"
)
// faviconFixture hashes to a negative int32, so its signed and unsigned forms
// differ and the unsigned-match case below actually exercises the fold.
var faviconFixture = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
func TestCheckMatcherFavicon(t *testing.T) {
body := string(faviconFixture)
signed := int64(fingerprint.FaviconHash(faviconFixture))
if signed >= 0 {
t.Fatalf("fixture must hash to a negative int32 for the unsigned case to be meaningful, got %d", signed)
}
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
tests := []struct {
name string
hashes []int64
expect bool
}{
{name: "signed match", hashes: []int64{signed}, expect: true},
{name: "unsigned match", hashes: []int64{unsigned}, expect: true},
{name: "one of many", hashes: []int64{1, 2, signed}, expect: true},
{name: "no match", hashes: []int64{1, 2, 3}, expect: false},
{name: "empty list", hashes: nil, expect: false},
{name: "out-of-range ignored", hashes: []int64{1 << 40}, expect: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Matcher{Type: "favicon", Hash: tt.hashes}
resp := fakeResponse(t, 200, nil)
if got := checkMatcher(m, resp, body); got != tt.expect {
t.Errorf("checkMatcher favicon = %v, want %v", got, tt.expect)
}
})
}
}
func TestNormalizeFaviconHash(t *testing.T) {
tests := []struct {
name string
in int64
want int32
wantOK bool
}{
{name: "signed passthrough", in: -235701012, want: -235701012, wantOK: true},
{name: "unsigned folds to signed", in: 4059266284, want: -235701012, wantOK: true},
{name: "positive in range", in: 116323821, want: 116323821, wantOK: true},
{name: "min int32", in: math.MinInt32, want: math.MinInt32, wantOK: true},
{name: "max uint32 folds to -1", in: math.MaxUint32, want: -1, wantOK: true},
{name: "above uint32 rejected", in: math.MaxUint32 + 1, wantOK: false},
{name: "below int32 rejected", in: math.MinInt32 - 1, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := normalizeFaviconHash(tt.in)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok && got != tt.want {
t.Errorf("normalizeFaviconHash(%d) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
func TestFaviconEvidence(t *testing.T) {
body := string(faviconFixture)
hashLine := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
tests := []struct {
name string
matchers []Matcher
want string
wantOK bool
}{
{name: "favicon only", matchers: []Matcher{{Type: "favicon"}}, want: hashLine, wantOK: true},
{name: "favicon with status", matchers: []Matcher{{Type: "status"}, {Type: "favicon"}}, want: hashLine, wantOK: true},
{name: "favicon with word keeps body", matchers: []Matcher{{Type: "word"}, {Type: "favicon"}}, wantOK: false},
{name: "favicon with regex keeps body", matchers: []Matcher{{Type: "regex"}, {Type: "favicon"}}, wantOK: false},
{name: "no favicon matcher", matchers: []Matcher{{Type: "status"}}, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := faviconEvidence(tt.matchers, body)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok && got != tt.want {
t.Errorf("evidence = %q, want %q", got, tt.want)
}
})
}
}
func TestValidateMatchers(t *testing.T) {
tests := []struct {
name string
matchers []Matcher
wantErr bool
}{
{name: "valid signed", matchers: []Matcher{{Type: "favicon", Hash: []int64{-235701012}}}, wantErr: false},
{name: "valid unsigned", matchers: []Matcher{{Type: "favicon", Hash: []int64{4059266284}}}, wantErr: false},
{name: "favicon with no hash", matchers: []Matcher{{Type: "favicon"}}, wantErr: true},
{name: "out-of-range hash", matchers: []Matcher{{Type: "favicon", Hash: []int64{99999999999}}}, wantErr: true},
{name: "non-favicon ignored", matchers: []Matcher{{Type: "word", Words: []string{"x"}}}, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateMatchers(tt.matchers); (err != nil) != tt.wantErr {
t.Errorf("validateMatchers err = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// favicon composes with the negative flag like any other matcher.
func TestCheckMatcherFaviconNegative(t *testing.T) {
signed := int64(fingerprint.FaviconHash(faviconFixture))
matchers := []Matcher{{Type: "favicon", Hash: []int64{signed}, Negative: true}}
resp := fakeResponse(t, 200, nil)
if checkMatchers(matchers, "", resp, string(faviconFixture)) {
t.Error("negative favicon matcher should not match its own hash")
}
}
// drives the full executor: fetch favicon, match on its hash, report the hash.
func TestExecuteHTTPModuleFavicon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/favicon.ico" {
w.Header().Set("Content-Type", "image/x-icon")
_, _ = w.Write(faviconFixture)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
// unsigned form must still match end to end
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
def := &YAMLModule{
ID: "favicon-fp",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/favicon.ico"},
Matchers: []Matcher{
{Type: "status", Status: []int{200}},
{Type: "favicon", Hash: []int64{unsigned}},
},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
if len(result.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(result.Findings))
}
wantEvidence := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
if got := result.Findings[0].Evidence; got != wantEvidence {
t.Errorf("evidence = %q, want %q", got, wantEvidence)
}
}
@@ -0,0 +1,151 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runInfraModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func infraExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestInfraConfigExposureModules(t *testing.T) {
const terraform = "../../modules/recon/terraform-state-exposure.yaml"
const kubeconfig = "../../modules/recon/kubeconfig-exposure.yaml"
const compose = "../../modules/recon/docker-compose-exposure.yaml"
t.Run("terraform state leaks the terraform version", func(t *testing.T) {
body := `{"version":4,"terraform_version":"1.5.7","serial":12,"lineage":"a1b2",` +
`"outputs":{},"resources":[{"type":"aws_db_instance","name":"main"}]}`
res := runInfraModule(t, terraform, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a terraform state finding")
}
if v := infraExtract(res, "terraform_version"); v != "1.5.7" {
t.Errorf("terraform_version=%q, want 1.5.7", v)
}
})
t.Run("terraform state with a pre-release version still extracts the number", func(t *testing.T) {
body := `{"version":4,"terraform_version":"0.12.0-beta1","serial":1,"lineage":"x","resources":[]}`
res := runInfraModule(t, terraform, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a terraform state finding")
}
if v := infraExtract(res, "terraform_version"); v != "0.12.0" {
t.Errorf("terraform_version=%q, want 0.12.0", v)
}
})
t.Run("kubeconfig leaks the cluster server", func(t *testing.T) {
body := "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n" +
" server: https://10.0.0.1:6443\n name: prod\ncurrent-context: prod\n"
res := runInfraModule(t, kubeconfig, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a kubeconfig finding")
}
if v := infraExtract(res, "cluster_server"); v != "https://10.0.0.1:6443" {
t.Errorf("cluster_server=%q, want https://10.0.0.1:6443", v)
}
})
t.Run("docker compose leaks the image version", func(t *testing.T) {
body := "version: \"3.8\"\nservices:\n web:\n image: nginx:1.25\n ports:\n" +
" - \"80:80\"\n db:\n image: postgres:15\n"
res := runInfraModule(t, compose, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a docker compose finding")
}
if v := infraExtract(res, "compose_image"); v != "nginx:1.25" {
t.Errorf("compose_image=%q, want nginx:1.25", v)
}
})
t.Run("a terraform_version mention without the state structure is not a leak", func(t *testing.T) {
body := `{"terraform_version":"1.5.7"}`
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare version mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a kind Config mention without the kubeconfig structure is not a leak", func(t *testing.T) {
body := "kind: Config\ndescription: an unrelated document\n"
if res := runInfraModule(t, kubeconfig, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare kind mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a services key without a service definition is not a leak", func(t *testing.T) {
body := "services: enabled\nnote: not a compose file\n"
if res := runInfraModule(t, compose, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare services key should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page carrying the markers is not a leak", func(t *testing.T) {
body := `<html><head><title>x</title></head><body>"terraform_version":"1.5.7" "lineage":"a1b2"</body></html>`
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{terraform, kubeconfig, compose} {
if res := runInfraModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{terraform, kubeconfig, compose} {
if res := runInfraModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+86
View File
@@ -0,0 +1,86 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/dropalldatabases/sif/internal/httpx"
)
func TestRunExtractorsJSON(t *testing.T) {
const body = `{"version":"1.2.3","app":{"name":"sif"},"items":[{"id":7}]}`
resp := fakeResponse(t, 200, nil)
tests := []struct {
name string
paths []string
want string // "" means the extractor should set nothing
}{
{"top level", []string{"version"}, "1.2.3"},
{"nested", []string{"app.name"}, "sif"},
{"array index", []string{"items.0.id"}, "7"},
{"first existing wins", []string{"missing", "version"}, "1.2.3"},
{"no match", []string{"nope"}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ex := []Extractor{{Type: "json", Name: "v", Part: "body", JSON: tt.paths}}
got := runExtractors(ex, resp, body)
if tt.want == "" {
if v, ok := got["v"]; ok {
t.Errorf("expected no extraction, got %q", v)
}
return
}
if got["v"] != tt.want {
t.Errorf("got %q, want %q", got["v"], tt.want)
}
})
}
}
func TestExecuteHTTPModuleJSONExtractor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"9.9.9"}`))
}))
defer srv.Close()
def := &YAMLModule{
ID: "j",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
Extractors: []Extractor{{Type: "json", Name: "version", Part: "body", JSON: []string{"version"}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatal(err)
}
if len(res.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(res.Findings))
}
if got := res.Findings[0].Extracted["version"]; got != "9.9.9" {
t.Errorf("extracted version = %q, want 9.9.9", got)
}
}
+106
View File
@@ -0,0 +1,106 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runLoginModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func loginExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLoginPanelModules(t *testing.T) {
const grafana = "../../modules/info/grafana-panel.yaml"
const kibana = "../../modules/info/kibana-panel.yaml"
const jenkins = "../../modules/info/jenkins-panel.yaml"
grafanaBody := `<body class="app-grafana"><grafana-app></grafana-app>` +
`<script>window.grafanaBootData = {"settings":{"buildInfo":{"version":"10.4.2","commit":"abc"}}};</script></body>`
kibanaBody := `<div data-test-subj="kibanaChrome"><kbn-injected-metadata data="x"></kbn-injected-metadata></div>`
t.Run("grafana login", func(t *testing.T) {
res := runLoginModule(t, grafana, 200, nil, grafanaBody)
if len(res.Findings) == 0 {
t.Fatal("expected a grafana finding")
}
if v := loginExtract(res, "grafana_version"); v != "10.4.2" {
t.Errorf("grafana_version=%q, want 10.4.2", v)
}
})
t.Run("kibana via response headers", func(t *testing.T) {
res := runLoginModule(t, kibana, 200, map[string]string{"kbn-version": "8.13.0", "kbn-name": "node-1"}, kibanaBody)
if len(res.Findings) == 0 {
t.Fatal("expected a kibana finding")
}
if v := loginExtract(res, "kibana_version"); v != "8.13.0" {
t.Errorf("kibana_version=%q, want 8.13.0", v)
}
})
t.Run("jenkins via X-Jenkins header on a 403", func(t *testing.T) {
res := runLoginModule(t, jenkins, 403, map[string]string{"X-Jenkins": "2.426.1"},
`<html><head><title>Authentication required</title></head></html>`)
if len(res.Findings) == 0 {
t.Fatal("expected a jenkins finding")
}
if v := loginExtract(res, "jenkins_version"); v != "2.426.1" {
t.Errorf("jenkins_version=%q, want 2.426.1", v)
}
})
t.Run("unrelated page is not a panel", func(t *testing.T) {
for _, file := range []string{grafana, kibana, jenkins} {
if res := runLoginModule(t, file, 200, nil, "<html><body>plain</body></html>"); len(res.Findings) > 0 {
t.Errorf("%s: unrelated page should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+132
View File
@@ -0,0 +1,132 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/dropalldatabases/sif/internal/httpx"
)
func TestCheckMatchersCondition(t *testing.T) {
const body = "hello world"
resp := fakeResponse(t, 200, nil)
status200 := Matcher{Type: "status", Status: []int{200}}
status500 := Matcher{Type: "status", Status: []int{500}}
wordHit := Matcher{Type: "word", Part: "body", Words: []string{"hello"}}
wordMiss := Matcher{Type: "word", Part: "body", Words: []string{"absent"}}
tests := []struct {
name string
condition string
matchers []Matcher
expect bool
}{
{"and both match", "and", []Matcher{status200, wordHit}, true},
{"and one fails", "and", []Matcher{status200, wordMiss}, false},
{"empty defaults to and", "", []Matcher{status200, wordMiss}, false},
{"or one matches", "or", []Matcher{status500, wordHit}, true},
{"or none match", "or", []Matcher{status500, wordMiss}, false},
{"or all match", "or", []Matcher{status200, wordHit}, true},
{"or is case-insensitive", "OR", []Matcher{status500, wordHit}, true},
{"and is case-insensitive", "AND", []Matcher{status200, wordMiss}, false},
{"or with negative pass", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true}}, true},
{"or all fail with negative", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"hello"}, Negative: true}, wordMiss}, false},
{"empty matcher list", "or", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkMatchers(tt.matchers, tt.condition, resp, body); got != tt.expect {
t.Errorf("checkMatchers(%q) = %v, want %v", tt.condition, got, tt.expect)
}
})
}
}
func TestValidateMatchersCondition(t *testing.T) {
for _, ok := range []string{"", "and", "or", "AND", "Or"} {
if err := validateMatchersCondition(ok); err != nil {
t.Errorf("%q should be valid: %v", ok, err)
}
}
for _, bad := range []string{"xor", "nand", "any", "&&"} {
if err := validateMatchersCondition(bad); err == nil {
t.Errorf("%q should be rejected", bad)
}
}
}
func TestParseMatchersConditionValidation(t *testing.T) {
write := func(cond string) string {
p := filepath.Join(t.TempDir(), "m.yaml")
body := fmt.Sprintf("id: mc\ntype: http\nhttp:\n method: GET\n paths: [\"{{BaseURL}}\"]\n matchers-condition: %s\n matchers:\n - type: status\n status: [200]\n", cond)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
if _, err := ParseYAMLModule(write("or")); err != nil {
t.Errorf("matchers-condition: or should parse: %v", err)
}
if _, err := ParseYAMLModule(write("xor")); err == nil {
t.Error("matchers-condition: xor should be rejected at load")
}
}
// or fires on the word match alone; and does not (status:500 fails).
func TestExecuteHTTPModuleMatchersConditionOr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "mc",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{
{Type: "status", Status: []int{500}},
{Type: "word", Part: "body", Words: []string{"hello"}},
},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
def.HTTP.MatchersCondition = "or"
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("or: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("or: got %d findings, want 1", len(res.Findings))
}
def.HTTP.MatchersCondition = ""
res, err = ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("and: %v", err)
}
if len(res.Findings) != 0 {
t.Fatalf("and: got %d findings, want 0 (status:500 fails)", len(res.Findings))
}
}
+70 -4
View File
@@ -14,6 +14,8 @@ package modules
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -184,7 +186,7 @@ func TestCheckMatchers(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkMatchers(tt.matchers, resp, body); got != tt.expect {
if got := checkMatchers(tt.matchers, "", resp, body); got != tt.expect {
t.Errorf("checkMatchers = %v, want %v", got, tt.expect)
}
})
@@ -413,7 +415,10 @@ func TestGenerateHTTPRequests(t *testing.T) {
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
}
// trailing slash on the target must be trimmed before substitution.
got := generateHTTPRequests("http://h/", cfg)
got, err := generateHTTPRequests("http://h/", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d requests, want 2", len(got))
}
@@ -432,7 +437,10 @@ func TestGenerateHTTPRequests(t *testing.T) {
Payloads: []string{"1", "2", "3"},
Body: "data={{payload}}",
}
got := generateHTTPRequests("http://h", cfg)
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d requests, want 3", len(got))
}
@@ -457,9 +465,67 @@ func TestGenerateHTTPRequests(t *testing.T) {
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
Payloads: []string{"x", "y"},
}
got := generateHTTPRequests("http://h", cfg)
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 4 {
t.Fatalf("got %d requests, want 4 (2 paths x 2 payloads)", len(got))
}
})
t.Run("wordlist expands {{word}} paths", func(t *testing.T) {
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("admin\n\nconfig\nbackup\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}", "{{BaseURL}}/.git/HEAD"},
Wordlist: list,
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
// 3 words (the blank line is skipped) fuzz the templated path, then the
// literal path passes through untouched.
want := []string{"http://h/admin", "http://h/config", "http://h/backup", "http://h/.git/HEAD"}
if len(got) != len(want) {
t.Fatalf("got %d requests, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].URL != w {
t.Errorf("req %d url = %q, want %q", i, got[i].URL, w)
}
}
})
t.Run("wordlist crosses with payloads", func(t *testing.T) {
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("a\nb\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}?q={{payload}}"},
Wordlist: list,
Payloads: []string{"1", "2", "3"},
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 6 {
t.Fatalf("got %d requests, want 6 (2 words x 3 payloads)", len(got))
}
})
t.Run("missing wordlist errors", func(t *testing.T) {
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}"},
Wordlist: filepath.Join(t.TempDir(), "nope.txt"),
}
if _, err := generateHTTPRequests("http://h", cfg); err == nil {
t.Fatal("want error for missing wordlist, got nil")
}
})
}
+138
View File
@@ -0,0 +1,138 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runMetricsModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func metricsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestMetricsExposureModules(t *testing.T) {
const netdata = "../../modules/recon/netdata-api-exposure.yaml"
const cadvisor = "../../modules/recon/cadvisor-api-exposure.yaml"
netdataInfo := `{"version":"v1.44.0","uid":"6c5c8a3f","mirrored_hosts":["localhost"],` +
`"mirrored_hosts_status":[{"guid":"6c5c8a3f","reachable":true}],"os_name":"Debian GNU/Linux",` +
`"cores_total":"8","total_disk_space":"512000000000"}`
cadvisorMachine := `{"num_cores":8,"num_physical_cores":4,"num_sockets":1,"cpu_frequency_khz":2904000,` +
`"memory_capacity":16777216000,"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",` +
`"system_uuid":"4C4C4544-0042-3110-8044-B7C04F564432","boot_id":"f0e1d2c3"}`
t.Run("an exposed netdata info endpoint is flagged and versioned", func(t *testing.T) {
res := runMetricsModule(t, netdata, 200, netdataInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a netdata finding")
}
if v := metricsExtract(res, "netdata_version"); v != "v1.44.0" {
t.Errorf("netdata_version=%q, want v1.44.0", v)
}
})
t.Run("an exposed cadvisor machine endpoint is flagged with the machine id", func(t *testing.T) {
res := runMetricsModule(t, cadvisor, 200, cadvisorMachine)
if len(res.Findings) == 0 {
t.Fatal("expected a cadvisor finding")
}
if v := metricsExtract(res, "cadvisor_machine_id"); v != "a1b2c3d4e5f60718293a4b5c6d7e8f90" {
t.Errorf("cadvisor_machine_id=%q, want the machine id", v)
}
})
t.Run("netdata mirrored hosts without cores total is not flagged", func(t *testing.T) {
body := `{"version":"v1.44.0","mirrored_hosts":["localhost"]}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("mirrored hosts alone should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("netdata cores total without mirrored hosts is not flagged", func(t *testing.T) {
body := `{"version":"v1.44.0","cores_total":"8"}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("cores total alone should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("cadvisor machine id without a cpu frequency is not flagged", func(t *testing.T) {
body := `{"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90","num_cores":8}`
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
t.Errorf("a machine id alone should not match cadvisor, got %d findings", len(res.Findings))
}
})
t.Run("cadvisor cpu frequency without a machine id is not flagged", func(t *testing.T) {
body := `{"cpu_frequency_khz":2904000,"num_cores":8}`
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
t.Errorf("a cpu frequency alone should not match cadvisor, got %d findings", len(res.Findings))
}
})
t.Run("a generic metrics json is not netdata", func(t *testing.T) {
body := `{"status":"ok","data":{"result":[]}}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic json should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{netdata, cadvisor} {
if res := runMetricsModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{netdata, cadvisor} {
if res := runMetricsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+3 -1
View File
@@ -86,12 +86,13 @@ type Finding struct {
// Matcher defines matching logic for module responses.
// Matchers are used to determine if a response indicates a vulnerability.
type Matcher struct {
Type string `yaml:"type"` // regex, status, word, size
Type string `yaml:"type"` // regex, status, word, favicon
Part string `yaml:"part"` // body, header, all
Regex []string `yaml:"regex,omitempty"`
Words []string `yaml:"words,omitempty"`
Status []int `yaml:"status,omitempty"`
Size []int `yaml:"size,omitempty"`
Hash []int64 `yaml:"hash,omitempty"` // favicon: shodan mmh3 hashes (signed or unsigned)
Condition string `yaml:"condition"` // and, or
Negative bool `yaml:"negative"`
}
@@ -103,5 +104,6 @@ type Extractor struct {
Name string `yaml:"name"`
Part string `yaml:"part"`
Regex []string `yaml:"regex,omitempty"`
JSON []string `yaml:"json,omitempty"`
Group int `yaml:"group"`
}
@@ -0,0 +1,175 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runRuntimeModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func runtimeExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRuntimeAPIExposureModules(t *testing.T) {
const docker = "../../modules/recon/docker-api-exposure.yaml"
const k8s = "../../modules/recon/kubernetes-api-exposure.yaml"
const kubelet = "../../modules/recon/kubelet-api-exposure.yaml"
dockerVersion := `{"Platform":{"Name":"Docker Engine - Community"},"Components":[` +
`{"Name":"Engine","Version":"24.0.7","Details":{"ApiVersion":"1.43"}},` +
`{"Name":"containerd","Version":"1.6.24"},{"Name":"runc","Version":"1.1.9"}],` +
`"Version":"24.0.7","ApiVersion":"1.43","MinAPIVersion":"1.12","GitCommit":"311b9ff",` +
`"GoVersion":"go1.20.10","Os":"linux","Arch":"amd64"}`
k8sVersion := `{"major":"1","minor":"28","gitVersion":"v1.28.2","gitCommit":"abc123",` +
`"gitTreeState":"clean","buildDate":"2023-09-13T09:35:49Z","goVersion":"go1.20.8",` +
`"compiler":"gc","platform":"linux/amd64"}`
kubeletPods := `{"kind":"PodList","apiVersion":"v1","metadata":{},"items":[{"metadata":` +
`{"name":"etcd-master","namespace":"kube-system"},"spec":{"containers":[{"name":"etcd"}]}}]}`
t.Run("an exposed docker api is flagged and versioned", func(t *testing.T) {
res := runRuntimeModule(t, docker, 200, dockerVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a docker finding")
}
if v := runtimeExtract(res, "docker_version"); v != "24.0.7" {
t.Errorf("docker_version=%q, want 24.0.7", v)
}
})
t.Run("an exposed kubernetes api is flagged and versioned", func(t *testing.T) {
res := runRuntimeModule(t, k8s, 200, k8sVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a kubernetes finding")
}
if v := runtimeExtract(res, "k8s_version"); v != "v1.28.2" {
t.Errorf("k8s_version=%q, want v1.28.2", v)
}
})
t.Run("an exposed kubelet leaks a pod namespace", func(t *testing.T) {
res := runRuntimeModule(t, kubelet, 200, kubeletPods)
if len(res.Findings) == 0 {
t.Fatal("expected a kubelet finding")
}
if v := runtimeExtract(res, "kubelet_namespace"); v != "kube-system" {
t.Errorf("kubelet_namespace=%q, want kube-system", v)
}
})
t.Run("a generic version json without the docker fields is not docker", func(t *testing.T) {
body := `{"version":"1.0.0","name":"myapp"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("an apiversion without a min api version is not docker", func(t *testing.T) {
body := `{"ApiVersion":"2.0","name":"otherservice"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("apiversion alone should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("a min api version without an api version is not docker", func(t *testing.T) {
body := `{"MinAPIVersion":"1.12","Os":"linux"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("min api version alone should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json without the git fields is not kubernetes", func(t *testing.T) {
body := `{"version":"1.2.3","build":"xyz"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a gitversion without a git tree state is not kubernetes", func(t *testing.T) {
body := `{"gitVersion":"v1.0.0","app":"custom"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("gitversion alone should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a build date without a gitversion is not kubernetes", func(t *testing.T) {
body := `{"buildDate":"2023-01-01T00:00:00Z","app":"custom"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("build date alone should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a service list is not a kubelet pod list", func(t *testing.T) {
body := `{"kind":"ServiceList","apiVersion":"v1","items":[]}`
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
t.Errorf("a service list should not match kubelet, got %d findings", len(res.Findings))
}
})
t.Run("a pod list without an api version is not flagged", func(t *testing.T) {
body := `{"kind":"PodList","items":[]}`
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
t.Errorf("a pod list without apiversion should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{docker, k8s, kubelet} {
if res := runRuntimeModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{docker, k8s, kubelet} {
if res := runRuntimeModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+26 -9
View File
@@ -53,15 +53,17 @@ type YAMLModuleInfo struct {
// HTTPConfig defines HTTP module settings
type HTTPConfig struct {
Method string `yaml:"method"`
Paths []string `yaml:"paths"`
Payloads []string `yaml:"payloads,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"`
Attack string `yaml:"attack,omitempty"` // clusterbomb (default), pitchfork
Threads int `yaml:"threads,omitempty"`
Matchers []Matcher `yaml:"matchers"`
Extractors []Extractor `yaml:"extractors,omitempty"`
Method string `yaml:"method"`
Paths []string `yaml:"paths"`
Wordlist string `yaml:"wordlist,omitempty"`
Payloads []string `yaml:"payloads,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"`
Attack string `yaml:"attack,omitempty"` // clusterbomb (default), pitchfork
Threads int `yaml:"threads,omitempty"`
Matchers []Matcher `yaml:"matchers"`
MatchersCondition string `yaml:"matchers-condition,omitempty"` // and (default), or
Extractors []Extractor `yaml:"extractors,omitempty"`
}
// DNSConfig defines DNS module settings
@@ -104,6 +106,21 @@ func ParseYAMLModule(path string) (*YAMLModule, error) {
if err := validateAttack(ym.HTTP.Attack); err != nil {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
if err := validateMatchersCondition(ym.HTTP.MatchersCondition); err != nil {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
}
var matchers []Matcher
switch {
case ym.HTTP != nil:
matchers = ym.HTTP.Matchers
case ym.DNS != nil:
matchers = ym.DNS.Matchers
case ym.TCP != nil:
matchers = ym.TCP.Matchers
}
if err := validateMatchers(matchers); err != nil {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
return &ym, nil
+12 -1
View File
@@ -78,7 +78,7 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
}
// Drupal
if strings.Contains(resp.Header.Get("X-Drupal-Cache"), "HIT") || strings.Contains(bodyString, "Drupal.settings") {
if detectDrupal(resp.Header, bodyString) {
spin.Stop()
result := &CMSResult{Name: "Drupal", Version: "Unknown"}
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
@@ -160,3 +160,14 @@ func detectJoomla(body string) bool {
strings.Contains(body, "/media/vendor/joomla") ||
strings.Contains(body, "/media/system/js/core.js")
}
// detectDrupal reports whether the response looks like Drupal. the X-Drupal-* and
// X-Generator headers survive cdn caching when the body markers do not, and an
// X-Drupal-Cache of any value (even MISS) is a tell.
func detectDrupal(header http.Header, body string) bool {
return strings.Contains(header.Get("X-Generator"), "Drupal") ||
header.Get("X-Drupal-Cache") != "" ||
header.Get("X-Drupal-Dynamic-Cache") != "" ||
strings.Contains(body, "Drupal.settings") ||
strings.Contains(body, "drupalSettings")
}
+69
View File
@@ -0,0 +1,69 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// header cases mirror live Drupal 8-11 (acquia, georgia, london): the X-Drupal-*
// and X-Generator headers tell even when the body has no marker.
func TestDetectDrupal_ModernSignals(t *testing.T) {
cases := []struct {
name string
header http.Header
body string
want bool
}{
{"x-generator drupal 10", http.Header{"X-Generator": {"Drupal 10 (https://www.drupal.org)"}}, "", true},
{"x-drupal-cache miss", http.Header{"X-Drupal-Cache": {"MISS"}}, "", true},
{"x-drupal-dynamic-cache", http.Header{"X-Drupal-Dynamic-Cache": {"HIT"}}, "", true},
{"drupalSettings body (8+)", http.Header{}, `<script>window.drupalSettings = {};</script>`, true},
{"Drupal.settings body (7)", http.Header{}, `<script>Drupal.settings = {};</script>`, true},
{"plain page", http.Header{"Server": {"nginx"}}, "<html><body>hello</body></html>", false},
{"x-generator wordpress", http.Header{"X-Generator": {"WordPress 6.5"}}, "", false},
{"bare drupal prose", http.Header{}, "we migrated off Drupal CMS last year", false},
}
for _, c := range cases {
if got := detectDrupal(c.header, c.body); got != c.want {
t.Errorf("%s: detectDrupal = %v, want %v", c.name, got, c.want)
}
}
}
// end-to-end: a modern Drupal whose only tell is X-Drupal-Dynamic-Cache (the live
// london.gov.uk case) must be detected.
func TestCMS_ModernDrupalDetected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// a real Drupal site has no wordpress paths; 404 them so the wordpress
// probe does not claim the host before the Drupal check runs.
if r.URL.Path != "/" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("X-Drupal-Dynamic-Cache", "MISS")
_, _ = w.Write([]byte("<html><body>news and updates</body></html>"))
}))
defer srv.Close()
result, err := CMS(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("CMS: %v", err)
}
if result == nil || result.Name != "Drupal" {
t.Errorf("modern Drupal (X-Drupal-Dynamic-Cache) not detected, got %+v", result)
}
}
+71 -18
View File
@@ -50,8 +50,9 @@ const dirlistBodyCap = 512 * 1024
// cannot exist, then treat any response shape they share as the wildcard
// baseline. deterministic (no rng) so the workflow stays reproducible.
const (
calibrationProbes = 3
calibrationPrefix = "/sif-cal-"
calibrationProbes = 3
calibrationPrefix = "/sif-cal-"
calibrationPadStep = 8 // per-probe suffix growth; see calibrationSuffix
)
// statusNotFound / statusForbidden are the historical default "not interesting"
@@ -90,6 +91,20 @@ type responseMeta struct {
words int
}
// anySize, as a baseline size, marks a catch-all whose body size is unreliable (it
// reflects the request path), so the baseline matches on status and word count alone.
const anySize = -1
// matchesBaseline reports whether meta looks like the calibrated soft-404 shape b.
// a normal baseline compares status, size and words exactly; a reflecting catch-all
// (b.size == anySize) compares status and words only, since its size is not stable.
func (b responseMeta) matchesBaseline(meta responseMeta) bool {
if b.status != meta.status || b.words != meta.words {
return false
}
return b.size == anySize || b.size == meta.size
}
// matcher decides whether a response is "interesting" using the same precedence
// as ffuf/feroxbuster: an explicit filter (-fc/-fs/-fw/-fr or a calibrated
// baseline) drops the response, otherwise the match-code set decides.
@@ -154,13 +169,10 @@ func newMatcher(opts *DirlistOptions) (*matcher, error) {
// over matches: a calibrated baseline, an -fc/-fs/-fw hit, or an -fr body match
// always drops the response; otherwise the -mc set (when set) gates it.
func (m *matcher) Matches(meta responseMeta, body []byte) bool {
// a calibrated soft-404 shape is the same response the catch-all hands every
// bogus path, so drop anything that matches a baseline exactly.
for i := 0; i < len(m.baselines); i++ {
b := m.baselines[i]
if b.status == meta.status && b.size == meta.size && b.words == meta.words {
return false
}
// a calibrated soft-404 shape is the response the catch-all hands every bogus
// path, so drop anything matching a baseline.
if containsBaseline(m.baselines, meta) {
return false
}
if _, drop := m.filterCodes[meta.status]; drop {
@@ -342,11 +354,18 @@ func scanLines(r io.Reader) ([]string, error) {
// calibrate probes a few paths that cannot exist and records the response shapes
// the catch-all hands them. those baselines feed the matcher so a soft-404 200
// (the SPA wildcard) is suppressed before the real run. deterministic by design:
// the probe paths come from the loop index, never a random source.
// (the SPA wildcard) is suppressed before the real run.
func calibrate(m *matcher, baseURL string, client *http.Client) {
m.baselines = baselinesFromProbes(probeCalibration(baseURL, client))
}
// probeCalibration requests calibrationProbes bogus paths and returns their soft
// (non-hard-404) response shapes. paths grow in length (see calibrationSuffix) so a
// path-reflecting catch-all is detectable. deterministic: paths come from the index.
func probeCalibration(baseURL string, client *http.Client) []responseMeta {
probes := make([]responseMeta, 0, calibrationProbes)
for i := 0; i < calibrationProbes; i++ {
probe := baseURL + calibrationPrefix + strconv.Itoa(i)
probe := baseURL + calibrationPrefix + calibrationSuffix(i)
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, probe, http.NoBody)
if err != nil {
charmlog.Debugf("dirlist: build calibration request: %v", err)
@@ -365,17 +384,51 @@ func calibrate(m *matcher, baseURL string, client *http.Client) {
if meta.status == statusNotFound {
continue
}
if !containsBaseline(m.baselines, meta) {
m.baselines = append(m.baselines, meta)
}
probes = append(probes, meta)
}
return probes
}
// containsBaseline reports whether the shape is already recorded, so repeated
// probes returning the same soft-404 don't bloat the baseline set.
// calibrationSuffix returns the i-th probe suffix. each suffix is unique and longer
// than the last, so a path-reflecting catch-all returns a different size per probe.
func calibrationSuffix(i int) string {
return strconv.Itoa(i) + strings.Repeat("a", i*calibrationPadStep)
}
// baselinesFromProbes reduces raw calibration responses to the soft-404 shapes to
// suppress. probes sharing status/word-count but differing in size are a reflecting
// catch-all, collapsed to one word-count-tolerant baseline (size anySize); others exact.
func baselinesFromProbes(probes []responseMeta) []responseMeta {
type shapeKey struct{ status, words int }
order := make([]shapeKey, 0, len(probes))
sizes := make(map[shapeKey]map[int]struct{})
for _, p := range probes {
k := shapeKey{p.status, p.words}
if sizes[k] == nil {
sizes[k] = make(map[int]struct{})
order = append(order, k)
}
sizes[k][p.size] = struct{}{}
}
baselines := make([]responseMeta, 0, len(order))
for _, k := range order {
size := anySize
if len(sizes[k]) == 1 {
// one stable size for this status/words: keep exact-shape matching.
for s := range sizes[k] {
size = s
}
}
baselines = append(baselines, responseMeta{status: k.status, size: size, words: k.words})
}
return baselines
}
// containsBaseline reports whether meta matches any calibrated soft-404 baseline.
func containsBaseline(baselines []responseMeta, meta responseMeta) bool {
for i := 0; i < len(baselines); i++ {
if baselines[i] == meta {
if baselines[i].matchesBaseline(meta) {
return true
}
}
+113
View File
@@ -381,3 +381,116 @@ func TestScanLinesErrorsOnOverlongLine(t *testing.T) {
t.Fatalf("scanLines err = %v, want bufio.ErrTooLong", err)
}
}
// reflectingWildcardApp serves a catch-all whose body echoes the request path, so
// its size tracks path length; /admin returns a distinct real page.
func reflectingWildcardApp() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body>admin control panel dashboard credentials</body></html>"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
return
}
w.Write([]byte("<html><body>page not found: " + r.URL.Path + "</body></html>"))
})
return httptest.NewServer(mux)
}
// a reflecting catch-all hands each path a different size, so exact-shape calibration
// misses it; the varied probe lengths expose it and -ac suppresses the bogus paths.
func TestDirlist_CalibrationSuppressesReflectingWildcard(t *testing.T) {
srv := reflectingWildcardApp()
defer srv.Close()
dir := t.TempDir()
wordlist := filepath.Join(dir, "words.txt")
if err := os.WriteFile(wordlist, []byte("admin\nnope\nbogus\nmissing\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
results, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{
Wordlist: wordlist,
Calibrate: true,
})
if err != nil {
t.Fatalf("Dirlist (-ac): %v", err)
}
got := pathSet(results)
if !has(got, "/admin") {
t.Errorf("real /admin must still surface, got %v", sortedKeys(got))
}
for _, bogus := range []string{"/nope", "/bogus", "/missing"} {
if has(got, bogus) {
t.Errorf("reflecting soft-404 %s should be suppressed by -ac, got %v", bogus, sortedKeys(got))
}
}
}
func TestBaselinesFromProbes(t *testing.T) {
tests := []struct {
name string
probes []responseMeta
want []responseMeta
}{
{
name: "stable catch-all keeps exact shape",
probes: []responseMeta{{status: 200, size: 63, words: 7}, {status: 200, size: 63, words: 7}},
want: []responseMeta{{status: 200, size: 63, words: 7}},
},
{
name: "reflecting catch-all collapses to words-tolerant",
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 200, size: 48, words: 4}, {status: 200, size: 56, words: 4}},
want: []responseMeta{{status: 200, size: anySize, words: 4}},
},
{
name: "distinct shapes kept separately",
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
want: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
},
{
name: "no probes yields no baseline",
probes: nil,
want: []responseMeta{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := baselinesFromProbes(tt.probes); !reflect.DeepEqual(got, tt.want) {
t.Errorf("baselinesFromProbes(%v) = %v, want %v", tt.probes, got, tt.want)
}
})
}
}
func TestMatchesBaseline_AnySize(t *testing.T) {
tolerant := responseMeta{status: 200, size: anySize, words: 4}
if !tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 4}) {
t.Error("anySize baseline should match any size with the same status/words")
}
if tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 5}) {
t.Error("anySize baseline must still discriminate on word count")
}
exact := responseMeta{status: 200, size: 42, words: 5}
if exact.matchesBaseline(responseMeta{status: 200, size: 43, words: 5}) {
t.Error("exact baseline should not match a different size")
}
}
func TestCalibrationSuffix_UniqueAndGrowing(t *testing.T) {
prev := -1
seen := make(map[string]struct{})
for i := 0; i < calibrationProbes; i++ {
s := calibrationSuffix(i)
if _, dup := seen[s]; dup {
t.Errorf("suffix %q repeats across probes", s)
}
seen[s] = struct{}{}
if len(s) <= prev {
t.Errorf("suffix %d %q length %d not greater than previous %d", i, s, len(s), prev)
}
prev = len(s)
}
}
+68
View File
@@ -0,0 +1,68 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"path/filepath"
"strings"
"testing"
"github.com/dropalldatabases/sif/internal/modules"
)
// favicon demo modules must reference a hash from faviconHashes that names the
// service in their filename, so a demo cannot drift from the scanner's map.
func TestFaviconDemoModulesMatchCanonicalMap(t *testing.T) {
matches, err := filepath.Glob("../../modules/info/favicon-*.yaml")
if err != nil {
t.Fatal(err)
}
if len(matches) == 0 {
t.Skip("no favicon demo modules present")
}
for _, path := range matches {
t.Run(filepath.Base(path), func(t *testing.T) {
def, err := modules.ParseYAMLModule(path)
if err != nil {
t.Fatalf("parse: %v", err)
}
if def.HTTP == nil {
t.Fatal("favicon demo is not an http module")
}
var hashes []int64
for _, m := range def.HTTP.Matchers {
if m.Type == "favicon" {
hashes = append(hashes, m.Hash...)
}
}
if len(hashes) == 0 {
t.Fatal("no favicon hash in module")
}
service := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "favicon-"), ".yaml")
for _, h := range hashes {
// hashes are range-checked at parse, so int32(h) is the canonical fold.
tech, ok := faviconHashes[int32(h)]
if !ok {
t.Errorf("hash %d is absent from faviconHashes; demo references a hash the scanner does not know", h)
continue
}
if !strings.Contains(strings.ToLower(tech), service) {
t.Errorf("hash %d maps to %q, but the file names service %q", h, tech, service)
}
}
})
}
}
+367 -11
View File
@@ -186,9 +186,6 @@ func TestDetectFramework_ASPNET(t *testing.T) {
}
}
// the dead "X-Powered-By: ASP.NET" signature only inflated the total weight
// (containsHeader never builds a "name: value" string to match it against), so a
// genuine asp.net response scored just under the threshold until it was removed.
func TestDetectFramework_ASPNETPoweredByHeader(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-AspNetMvc-Version", "5.2")
@@ -468,9 +465,6 @@ func TestDetectFramework_AdonisJS(t *testing.T) {
}
}
// a cosmetics brand page that merely contains "adonis" in its markup (CSS
// classes, asset paths, links) must not be fingerprinted as AdonisJS, as the
// old bare "adonis" substring signature did.
func TestDetectFramework_AdonisFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -528,8 +522,6 @@ func TestDetectFramework_Phoenix(t *testing.T) {
}
}
// a Phoenix, Arizona business page using "phx-" CSS class prefixes must not be
// fingerprinted as the Phoenix framework, as the old bare "phx-" signature did.
func TestDetectFramework_PhoenixFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -619,7 +611,6 @@ func TestDetectFramework_Ghost(t *testing.T) {
}
}
// ghost-button is a common generic CSS class and must not read as Ghost CMS.
func TestDetectFramework_GhostButtonNoMatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -643,8 +634,6 @@ func TestDetectFramework_GhostButtonNoMatch(t *testing.T) {
}
}
// the /ghost/api/ path is the only Ghost marker left for pages without the
// generator meta, so guard that it still detects on its own.
func TestDetectFramework_GhostAPIPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -780,3 +769,370 @@ func TestDetectFramework_Htmx(t *testing.T) {
t.Errorf("expected version '1.9.10', got '%s'", result.Version)
}
}
func TestDetectFramework_GinFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Welcome</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 && result.Name == "Gin" {
t.Errorf("false positive: detected Gin (confidence %.2f) on a CORS header", result.Confidence)
}
}
func TestDetectFramework_Gin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`404 page not found - powered by gin-gonic`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || result.Name != "Gin" {
t.Errorf("expected framework 'Gin', got '%v'", result)
}
}
func TestDetectFramework_MeteorFalsePositive(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><p>a meteor shower lit the sky while
meteorology students tracked the meteorite.</p></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 && result.Name == "Meteor" {
t.Errorf("false positive: detected Meteor (confidence %.2f) on prose about meteors", result.Confidence)
}
}
func TestDetectFramework_Meteor(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>
<script>__meteor_runtime_config__ = JSON.parse(decodeURIComponent("%7B%7D"));</script>
</head><body><div id="app"></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 || result.Name != "Meteor" {
t.Errorf("expected framework 'Meteor', got '%v'", result)
}
}
func TestDetectFramework_BackboneFalsePositive(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><p>our team is the backbone of the
company, the backbone network that keeps everything running.</p></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 && result.Name == "Backbone.js" {
t.Errorf("false positive: detected Backbone.js (confidence %.2f) on prose about backbones", result.Confidence)
}
}
func TestDetectFramework_Backbone(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><script src="/js/backbone.js"></script></head>
<body><script>var AppView = Backbone.View.extend({});</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 || result.Name != "Backbone.js" {
t.Errorf("expected framework 'Backbone.js', got '%v'", result)
}
}
func TestDetectFramework_CakePHPFalsePositive(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><p>our cupcake and cheesecake recipes,
plus the best pancake stack in town.</p></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 && result.Name == "CakePHP" {
t.Errorf("false positive: detected CakePHP (confidence %.2f) on prose about cakes", result.Confidence)
}
}
func TestDetectFramework_CakePHP(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", "CAKEPHP=abc123; path=/")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Home</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 || result.Name != "CakePHP" {
t.Errorf("expected framework 'CakePHP', got '%v'", result)
}
}
func TestDetectFramework_SvelteFalsePositive(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><p>the model cut a svelte figure on
the runway.</p></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 && result.Name == "Svelte" {
t.Errorf("false positive: detected Svelte (confidence %.2f) on prose with 'svelte'", result.Confidence)
}
}
func TestDetectFramework_StrapiFalsePositive(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><script>fetch("/api/v1/users")</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 && result.Name == "Strapi" {
t.Errorf("false positive: detected Strapi (confidence %.2f) on a plain /api/ path", result.Confidence)
}
}
func TestDetectFramework_Strapi(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><div>powered by strapi</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 || result.Name != "Strapi" {
t.Errorf("expected framework 'Strapi', got '%v'", result)
}
}
func TestDetectFramework_Ember(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>Ember App</title></head>
<body class="ember-application"><div id="ember123" class="ember-view">Content</div>
<script src="/assets/vendor.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 || result.Name != "Ember.js" {
t.Errorf("expected framework 'Ember.js', got '%v'", result)
}
}
func TestDetectFramework_EmberFalsePositive(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>Day of the Dead</title></head>
<body><p>a celebratory holiday to remember the dead; families remember departed
members every November and September.</p></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 && result.Name == "Ember.js" {
t.Errorf("false positive: detected Ember.js (confidence %.2f) on prose with 'remember'", result.Confidence)
}
}
func TestDetectFramework_Shopify(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Powered-By", "Shopify")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><link rel="stylesheet" href="https://cdn.shopify.com/s/files/1/theme.css"></head>
<body>
<div id="shopify-section-header" class="shopify-section">Store</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 != "Shopify" {
t.Errorf("expected framework 'Shopify', got '%s'", result.Name)
}
}
func TestDetectFramework_ShopifyFalsePositive(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>10 Best Shopify Alternatives in 2026</title></head>
<body>
<h1>Is Shopify Right For You?</h1>
<p>We compare Shopify with other e-commerce platforms.</p>
</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 && result.Name == "Shopify" {
t.Errorf("false positive: article mentioning Shopify detected as Shopify (%.2f)", result.Confidence)
}
}
func TestDetectFramework_SpringBoot(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`<html><body><h1>Whitelabel Error Page</h1>` +
`<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>` +
`<div>There was an unexpected error (type=Internal Server Error, status=500).</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 != "Spring Boot" {
t.Errorf("expected framework 'Spring Boot', got '%s'", result.Name)
}
}
func TestDetectFramework_SpringBootFalsePositive(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>
<h1>Getting started with spring-boot</h1>
<p>Add spring-boot-starter-web to your pom.xml and run the app.</p>
<a href="https://spring.io/projects/spring-boot">spring.io</a>
</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 && result.Name == "Spring Boot" {
t.Errorf("expected no Spring Boot match for prose mentioning it, got %.2f confidence", result.Confidence)
}
}
func TestDetectFramework_CodeIgniter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", "ci_session=a1b2c3d4e5; path=/; HttpOnly")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><h1>My Shop</h1></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 != "CodeIgniter" {
t.Errorf("expected framework 'CodeIgniter', got '%s'", result.Name)
}
}
func TestDetectFramework_CodeIgniterFalsePositive(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>
<h1>Best PHP frameworks in 2026</h1>
<p>Laravel and codeigniter both ship a router and an ORM.</p>
<a href="https://codeigniter.com">codeigniter.com</a>
<pre>composer create-project codeigniter4/appstarter</pre>
</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 && result.Name == "CodeIgniter" {
t.Errorf("expected no CodeIgniter match for prose mentioning it, got %.2f confidence", result.Confidence)
}
}
@@ -251,9 +251,9 @@ 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},
{Pattern: "Whitelabel Error Page", Weight: 0.5},
{Pattern: "This application has no explicit mapping for /error", Weight: 0.4},
{Pattern: "There was an unexpected error (type=", Weight: 0.3},
}
}
@@ -351,7 +351,6 @@ 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},
}
}
@@ -400,7 +399,6 @@ 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},
}
}
@@ -447,7 +445,7 @@ 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},
{Pattern: "CAKEPHP", Weight: 0.4, HeaderOnly: true},
}
}
@@ -470,7 +468,6 @@ 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},
}
}
+1 -1
View File
@@ -147,7 +147,7 @@ func (d *shopifyDetector) Name() string { return "Shopify" }
func (d *shopifyDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Shopify", Weight: 0.5},
{Pattern: "Shopify", Weight: 0.5, HeaderOnly: true},
{Pattern: "cdn.shopify.com", Weight: 0.4},
{Pattern: "shopify-section", Weight: 0.4},
{Pattern: "myshopify.com", Weight: 0.3},
+16 -9
View File
@@ -129,9 +129,9 @@ 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},
{Pattern: "svelte-", Weight: 0.4},
{Pattern: "svelte/internal", Weight: 0.4},
}
}
@@ -154,9 +154,12 @@ 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},
{Pattern: "ember-application", Weight: 0.5},
{Pattern: "ember-view", Weight: 0.4},
{Pattern: "ember.js", Weight: 0.4},
{Pattern: "ember.min.js", Weight: 0.4},
{Pattern: "ember-cli", Weight: 0.3},
{Pattern: `id="ember`, Weight: 0.4},
}
}
@@ -179,8 +182,11 @@ 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},
{Pattern: "Backbone.Model", Weight: 0.4},
{Pattern: "Backbone.View", Weight: 0.4},
{Pattern: "Backbone.Router", Weight: 0.4},
{Pattern: "backbone.js", Weight: 0.4},
{Pattern: "backbone-min.js", Weight: 0.4},
}
}
@@ -231,8 +237,9 @@ 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},
{Pattern: "__meteor_runtime_config__", Weight: 0.6},
{Pattern: "Meteor.startup", Weight: 0.3},
{Pattern: "/packages/meteor", Weight: 0.3},
}
}
+1 -1
View File
@@ -181,7 +181,7 @@ func TestIntegrationSQL(t *testing.T) {
srv := newVulnApp()
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 5, "")
result, err := SQL(srv.URL, 5*time.Second, 5, "", false)
if err != nil {
t.Fatalf("SQL: %v", err)
}
+45 -3
View File
@@ -115,7 +115,7 @@ var databaseErrorPatterns = []struct {
}
// SQL performs SQL reconnaissance on the target URL
func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*SQLResult, error) {
func SQL(targetURL string, timeout time.Duration, threads int, logdir string, calibrate bool) (*SQLResult, error) {
log := output.Module("SQL")
log.Start()
@@ -149,6 +149,17 @@ func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*
return nil
}
// a catch-all answering 200/403/401 for every path makes each probe look like a
// hit. with -ac, calibrate the soft-404 wildcard shape first (as dirlist does) and
// drop matching probes before isAdminPanel; off by default, so every hit is reported.
var baselines []responseMeta
if calibrate {
baselines = calibrateSQLBaseline(targetURL, client)
if len(baselines) > 0 {
log.Info("calibrated %d soft-404 baseline(s)", len(baselines))
}
}
// check for admin panels
wg.Add(threads)
adminPathsChan := make(chan int, len(sqlAdminPaths))
@@ -178,9 +189,12 @@ func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*
// 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
meta, body := readMeta(resp)
resp.Body.Close()
if err != nil {
// a catch-all hands the same shape to every path; drop it so a
// wildcard 200/403/401 is not reported as an admin panel.
if containsBaseline(baselines, meta) {
continue
}
bodyStr := string(body)
@@ -256,6 +270,34 @@ func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*
return result, nil
}
// calibrateSQLBaseline probes paths that cannot exist and records the soft-404
// shapes a catch-all returns, so wildcard 200/403/401 pages are suppressed before
// isAdminPanel runs. it shares dirlist's reflection-tolerant derivation.
func calibrateSQLBaseline(targetURL string, client *http.Client) []responseMeta {
base := strings.TrimSuffix(targetURL, "/")
probes := make([]responseMeta, 0, calibrationProbes)
for i := 0; i < calibrationProbes; i++ {
probe := base + calibrationPrefix + calibrationSuffix(i) + "/"
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, probe, http.NoBody)
if err != nil {
continue
}
resp, err := client.Do(req)
if err != nil {
continue
}
meta, _ := readMeta(resp)
resp.Body.Close()
// a hard 404 is already filtered by status; only soft 200/403/401 shapes
// need a baseline to suppress them.
if meta.status == statusNotFound {
continue
}
probes = append(probes, meta)
}
return baselinesFromProbes(probes)
}
func isAdminPanel(body string, panelType string) bool {
bodyLower := strings.ToLower(body)
+3 -3
View File
@@ -111,7 +111,7 @@ func TestSQL_JQueryCatchAllNotReported(t *testing.T) {
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "")
result, err := SQL(srv.URL, 5*time.Second, 4, "", false)
if err != nil {
t.Fatalf("SQL: %v", err)
}
@@ -133,7 +133,7 @@ func TestSQL_RealPhpMyAdminReported(t *testing.T) {
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "")
result, err := SQL(srv.URL, 5*time.Second, 4, "", false)
if err != nil {
t.Fatalf("SQL: %v", err)
}
@@ -163,7 +163,7 @@ func TestSQL_RealGenericPanelReported(t *testing.T) {
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "")
result, err := SQL(srv.URL, 5*time.Second, 4, "", false)
if err != nil {
t.Fatalf("SQL: %v", err)
}
+248
View File
@@ -0,0 +1,248 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// representative bodies, not padded to force a size delta: a generic homepage and a
// real admin login just have different content, hence different shapes. the catch-all
// body carries no db keyword, so the comparison turns on the baseline, not isAdminPanel.
const (
catchAllHome = `<!DOCTYPE html><html><head><title>Acme</title></head>` +
`<body><nav>Home About Contact</nav><main>Welcome to Acme.</main></body></html>`
realPMALogin = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">` +
`<title>phpMyAdmin</title><link rel="stylesheet" href="phpmyadmin.css.php"></head>` +
`<body class="loginform"><form method="post" action="index.php"><fieldset>` +
`<legend>Log in to phpMyAdmin</legend>` +
`<label>Username</label><input type="text" name="pma_username">` +
`<label>Password</label><input type="password" name="pma_password">` +
`<input type="submit" value="Go"></fieldset></form></body></html>`
realDBAdmin = `<!DOCTYPE html><html><head><title>Database Manager</title></head>` +
`<body><h1>MySQL Server 8.0</h1><table><tr><th>Database</th><th>Tables</th></tr>` +
`<tr><td>app_production</td><td>42</td></tr></table>` +
`<form action="/run"><textarea name="sql">SELECT 1</textarea><button>Run</button></form>` +
`</body></html>`
)
// countAdminPanels is nil-safe: SQL returns a nil result when nothing is found.
func countAdminPanels(r *SQLResult) int {
if r == nil {
return 0
}
return len(r.AdminPanels)
}
// hasPanelType reports whether a panel of the given type was found.
func hasPanelType(r *SQLResult, panelType string) bool {
if r == nil {
return false
}
for _, p := range r.AdminPanels {
if p.Type == panelType {
return true
}
}
return false
}
// a 200 catch-all (the SPA wildcard) is calibrated as a baseline shape.
func TestCalibrateSQLBaseline_CatchAll(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(catchAllHome))
}))
defer srv.Close()
baselines := calibrateSQLBaseline(srv.URL, &http.Client{Timeout: 5 * time.Second})
if len(baselines) == 0 {
t.Fatal("a 200 catch-all should produce at least one baseline shape")
}
}
// a server that hard-404s every bogus path needs no baseline (status already filters).
func TestCalibrateSQLBaseline_HardNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
baselines := calibrateSQLBaseline(srv.URL, &http.Client{Timeout: 5 * time.Second})
if len(baselines) != 0 {
t.Errorf("hard-404 server should yield no baseline, got %d", len(baselines))
}
}
// with -ac on, a 200 catch-all serving a db-topical page at every path yields no
// admin-panel finding once the wildcard shape is calibrated.
func TestSQL_CatchAllSuppressed(t *testing.T) {
page := "<html><body>database dashboard for our service</body></html>"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(page))
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if n := countAdminPanels(result); n != 0 {
t.Errorf("catch-all produced %d admin-panel finding(s) with -ac, want 0", n)
}
}
// a 403 WAF that blanket-blocks with a db-mentioning page is also a catch-all and
// must be suppressed (covers the 403 branch of the status set).
func TestSQL_403WAFCatchAllSuppressed(t *testing.T) {
page := "Request blocked: possible sql injection attempt detected"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(page))
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if n := countAdminPanels(result); n != 0 {
t.Errorf("403 WAF catch-all produced %d admin-panel finding(s) with -ac, want 0", n)
}
}
// suppression is opt-in: with -ac off (the default) the same catch-all is still
// reported, matching dirlist's behavior.
func TestSQL_CalibrateDisabledStillReports(t *testing.T) {
page := "<html><body>database dashboard for our service</body></html>"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(page))
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", false)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if countAdminPanels(result) == 0 {
t.Error("with -ac off, the catch-all should still be reported (suppression is opt-in)")
}
}
// a real phpMyAdmin hosted on a catch-all is still reported under -ac: a genuine
// login page is a different shape than the wildcard homepage, so it is not dropped.
func TestSQL_RealPanelAmongCatchAll(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/phpmyadmin/" {
_, _ = w.Write([]byte(realPMALogin))
return
}
_, _ = w.Write([]byte(catchAllHome))
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if !hasPanelType(result, "phpMyAdmin") {
t.Errorf("real phpMyAdmin on a catch-all should still be reported; panels=%d",
countAdminPanels(result))
}
}
// a real generic interface (a distinct db admin page at /db/) is still reported
// under -ac, so calibration does not over-suppress genuine findings.
func TestSQL_RealGenericPanelAmongCatchAll(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/db/" {
_, _ = w.Write([]byte(realDBAdmin))
return
}
_, _ = w.Write([]byte(catchAllHome))
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if countAdminPanels(result) == 0 {
t.Error("a real database interface at /db/ on a catch-all should still be reported")
}
}
// the normal case (no catch-all): a host that 404s everything except a real
// phpMyAdmin still reports it, since calibration finds no baseline.
func TestSQL_HardNotFoundRealPanelReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/phpmyadmin/" {
_, _ = w.Write([]byte(realPMALogin))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if !hasPanelType(result, "phpMyAdmin") {
t.Error("phpMyAdmin on a non-catch-all host should be reported")
}
}
// a catch-all that reflects the request path varies its size per path; the shared
// reflection-tolerant calibration suppresses it under -ac.
func TestSQL_ReflectedPathCatchAllSuppressed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "<html><body>no such page: %s (database)</body></html>", r.URL.Path)
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if n := countAdminPanels(result); n != 0 {
t.Errorf("reflected-path catch-all should be suppressed under -ac, got %d panel(s)", n)
}
}
// the word-count-tolerant baseline must not nuke real findings: a genuine phpMyAdmin
// hosted behind a reflecting catch-all has a distinct word count, so it still surfaces.
func TestSQL_ReflectingCatchAllRealPanelStillReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/phpmyadmin/" {
_, _ = w.Write([]byte(realPMALogin))
return
}
fmt.Fprintf(w, "<html><body>no such page: %s (database)</body></html>", r.URL.Path)
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
if err != nil {
t.Fatalf("SQL: %v", err)
}
if !hasPanelType(result, "phpMyAdmin") {
t.Errorf("real phpMyAdmin on a reflecting catch-all should still surface; panels=%d",
countAdminPanels(result))
}
}
-9
View File
@@ -180,27 +180,18 @@ func checkSubdomainTakeover(subdomain string, client *http.Client) (bool, string
"Bitbucket": "Repository not found",
"Ghost": "Failed to resolve DNS path for this host",
"Pantheon": "404 - Unknown site",
"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",
+84
View File
@@ -0,0 +1,84 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// serveFingerprint stands up a loopback server returning body at 200 OK and
// returns its host:port for checkSubdomainTakeover to treat as a live subdomain.
func serveFingerprint(t *testing.T, body string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return strings.TrimPrefix(srv.URL, "http://")
}
// a body fingerprint for a service that can-i-take-over-xyz marks "Not
// vulnerable" must not raise a takeover: the provider mitigated the vector, so a
// live page carrying the string is never claimable.
func TestCheckSubdomainTakeover_NotVulnerableServiceNotFlagged(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
mitigated := map[string]string{
"Fastly": "Fastly error: unknown domain",
"Zendesk": "Help Center Closed",
"UserVoice": "This UserVoice subdomain is currently available!",
"Acquia": "The site you are looking for could not be found.",
}
for service, fingerprint := range mitigated {
subdomain := serveFingerprint(t, "<html><body>"+fingerprint+"</body></html>")
vulnerable, got := checkSubdomainTakeover(subdomain, client)
if vulnerable || got != "" {
t.Errorf("%s fingerprint raised a takeover (vulnerable=%v service=%q); the provider mitigated the vector", service, vulnerable, got)
}
}
}
// a still-vulnerable provider's fingerprint keeps firing, so pruning the
// mitigated services does not silently drop real detections.
func TestCheckSubdomainTakeover_VulnerableServiceStillFlagged(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
subdomain := serveFingerprint(t, "<html><body>The specified bucket does not exist</body></html>")
vulnerable, service := checkSubdomainTakeover(subdomain, client)
if !vulnerable || service != "Amazon S3" {
t.Errorf("expected Amazon S3 takeover, got vulnerable=%v service=%q", vulnerable, service)
}
}
// removed fingerprints matched generic content, not a provider's unclaimed-domain
// page (none in can-i-take-over-xyz), so must not raise a takeover: activecampaign
// was lighttpd's default page, kajabi/thinkific/tave/teamwork generic "not found".
func TestCheckSubdomainTakeover_GenericFingerprintNotFlagged(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
bogus := map[string]string{
"lighttpd default page": `<html><body><img alt="LIGHTTPD - fly light." src="light.png"></body></html>`,
"generic 404 (kajabi)": "<html><body>The page you were looking for doesn't exist.</body></html>",
"generic 404 (thinkific)": "<html><body>You may have mistyped the address or the page may have moved.</body></html>",
"generic 404 (tave)": "<html><body>Sorry, this page is no longer available.</body></html>",
"generic 404 (teamwork)": "<html><body>Oops - We didn't find your site.</body></html>",
}
for name, body := range bogus {
subdomain := serveFingerprint(t, body)
vulnerable, service := checkSubdomainTakeover(subdomain, client)
if vulnerable || service != "" {
t.Errorf("%s raised a takeover (vulnerable=%v service=%q); it matches generic content, not an unclaimed-domain page", name, vulnerable, service)
}
}
}
+23
View File
@@ -0,0 +1,23 @@
# GitLab favicon fingerprint
id: favicon-gitlab
info:
name: GitLab Favicon Fingerprint
author: sif
severity: info
description: Detects GitLab by its default favicon hash
tags: [favicon, fingerprint, gitlab, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/favicon.ico"
matchers:
- type: status
status: [200]
- type: favicon
hash:
- -1255347784 # GitLab default favicon
+40
View File
@@ -0,0 +1,40 @@
# Grafana Login Panel Detection Module
id: grafana-panel
info:
name: Grafana Login Panel
author: sif
severity: info
description: Detects exposed Grafana dashboards and login panels
tags: [grafana, monitoring, panel, login, detection, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/login"
- "{{BaseURL}}"
matchers:
- type: status
status:
- 200
- type: word
part: all
condition: or
words:
- "window.grafanaBootData"
- 'grafana-app'
- "<title>Grafana</title>"
- "grafana_session"
extractors:
- type: regex
name: grafana_version
part: body
regex:
- '"buildInfo":\{[^}]*"version":"([0-9]+\.[0-9]+\.[0-9]+)"'
- 'Grafana v([0-9]+\.[0-9]+\.[0-9]+)'
group: 1
+41
View File
@@ -0,0 +1,41 @@
# Jenkins Login Panel Detection Module
id: jenkins-panel
info:
name: Jenkins Login Panel
author: sif
severity: info
description: Detects exposed Jenkins automation server login panels
tags: [jenkins, ci, automation, panel, login, detection, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}"
- "{{BaseURL}}/login"
matchers:
- type: status
status:
- 200
- 403
- type: word
part: all
condition: or
words:
- "X-Jenkins"
- "Dashboard [Jenkins]"
- "Welcome to Jenkins!"
- "j_username"
- "jenkins-session"
extractors:
- type: regex
name: jenkins_version
part: all
regex:
- 'X-Jenkins: ?([0-9]+\.[0-9]+(?:\.[0-9]+)?)'
group: 1
+41
View File
@@ -0,0 +1,41 @@
# Kibana Login Panel Detection Module
id: kibana-panel
info:
name: Kibana Login Panel
author: sif
severity: info
description: Detects exposed Kibana dashboards and login panels
tags: [kibana, elastic, monitoring, panel, login, detection, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}"
- "{{BaseURL}}/login"
- "{{BaseURL}}/app/kibana"
matchers:
- type: status
status:
- 200
- type: word
part: all
condition: or
words:
- "Kbn-Version"
- "Kbn-Name"
- "kbn-injected-metadata"
- "kbnLoadingMessage"
extractors:
- type: regex
name: kibana_version
part: all
regex:
- '[Kk]bn-[Vv]ersion: ?([0-9]+\.[0-9]+(?:\.[0-9]+)?)'
- '"version":"([0-9]+\.[0-9]+\.[0-9]+)","buildNumber"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# cAdvisor API Exposure Detection Module
id: cadvisor-api-exposure
info:
name: cAdvisor API Exposure
author: sif
severity: medium
description: Detects an exposed cAdvisor container monitor through its unauthenticated machine endpoint
tags: [cadvisor, container, monitoring, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/api/v1.3/machine"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"machine_id\""
- type: word
part: body
words:
- "\"cpu_frequency_khz\""
extractors:
- type: regex
name: cadvisor_machine_id
part: body
regex:
- '"machine_id"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Docker Engine API Exposure Detection Module
id: docker-api-exposure
info:
name: Docker Engine API Exposure
author: sif
severity: critical
description: Detects an unauthenticated Docker Engine api that grants control of the host
tags: [docker, container, api, rce, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/version"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"ApiVersion\""
- type: word
part: body
words:
- "\"MinAPIVersion\""
extractors:
- type: regex
name: docker_version
part: body
regex:
- '"Engine"[^}]*?"Version"\s*:\s*"([^"]+)"'
group: 1
@@ -0,0 +1,56 @@
# Docker Compose Exposure Detection Module
id: docker-compose-exposure
info:
name: Docker Compose Exposure
author: sif
severity: medium
description: Detects an exposed docker compose file that leaks service topology and image versions
tags: [docker, compose, container, info-disclosure, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/docker-compose.yml"
- "{{BaseURL}}/docker-compose.yaml"
- "{{BaseURL}}/docker-compose.prod.yml"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "services:"
- type: word
part: body
condition: or
words:
- "image:"
- "container_name:"
- "build:"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: compose_image
part: body
regex:
- 'image:\s*["'']?([^\s"'']+)'
group: 1
+71
View File
@@ -0,0 +1,71 @@
# Environment File Exposure Detection Module
id: env-file-exposure
info:
name: Environment File Exposure
author: sif
severity: high
description: Detects exposed .env files leaking application secrets and credentials
tags: [env, dotenv, secrets, credentials, exposure, misconfiguration, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.env"
- "{{BaseURL}}/.env.local"
- "{{BaseURL}}/.env.dev"
- "{{BaseURL}}/.env.development"
- "{{BaseURL}}/.env.prod"
- "{{BaseURL}}/.env.production"
- "{{BaseURL}}/.env.staging"
- "{{BaseURL}}/.env.backup"
- "{{BaseURL}}/.env.bak"
- "{{BaseURL}}/.env.save"
- "{{BaseURL}}/.env.old"
threads: 5
matchers:
- type: status
status:
- 200
- type: word
part: body
condition: or
words:
- "DB_PASSWORD="
- "DATABASE_URL="
- "APP_KEY="
- "APP_SECRET="
- "SECRET_KEY="
- "SECRET_KEY_BASE="
- "AWS_ACCESS_KEY_ID="
- "AWS_SECRET_ACCESS_KEY="
- "MYSQL_ROOT_PASSWORD="
- "POSTGRES_PASSWORD="
- "REDIS_PASSWORD="
- "MAIL_PASSWORD="
- "JWT_SECRET="
- type: word
part: body
condition: or
negative: true
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: leaked_key
part: body
regex:
- '(?m)^(DB_PASSWORD|DATABASE_URL|APP_KEY|APP_SECRET|SECRET_KEY|SECRET_KEY_BASE|AWS_SECRET_ACCESS_KEY|JWT_SECRET|MAIL_PASSWORD|REDIS_PASSWORD)='
group: 1
@@ -0,0 +1,43 @@
# Gradle Properties Credential Exposure Detection Module
id: gradle-properties-exposure
info:
name: Gradle Properties Credential Exposure
author: sif
severity: high
description: Detects an exposed gradle.properties that leaks a signing or publishing credential
tags: [gradle, properties, credentials, secrets, recon, exposure]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/gradle.properties"
- "{{BaseURL}}/.gradle/gradle.properties"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- '(?im)^[^#=\n]*(?:password|secret|token)[^=\n]*=[ \t]*\S+'
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<html"
extractors:
- type: regex
name: gradle_secret_property
part: body
regex:
- '(?im)^\s*([^#=\s]*(?:password|secret|token)[^=\s]*)\s*='
group: 1
+56
View File
@@ -0,0 +1,56 @@
# Kubeconfig Exposure Detection Module
id: kubeconfig-exposure
info:
name: Kubeconfig Exposure
author: sif
severity: high
description: Detects an exposed kubeconfig that leaks cluster endpoints and client credentials
tags: [kubernetes, kubeconfig, cluster, credentials, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.kube/config"
- "{{BaseURL}}/kube/config"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "kind: Config"
- type: word
part: body
condition: or
words:
- "clusters:"
- "current-context:"
- "client-certificate-data:"
- "client-key-data:"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: cluster_server
part: body
regex:
- 'server:\s*(https?://[^\s]+)'
group: 1
+40
View File
@@ -0,0 +1,40 @@
# Kubelet API Exposure Detection Module
id: kubelet-api-exposure
info:
name: Kubelet API Exposure
author: sif
severity: high
description: Detects an exposed kubelet api whose pod list leaks the cluster workload
tags: [kubernetes, kubelet, k8s, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/pods"
- "{{BaseURL}}/runningpods/"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- '"kind"\s*:\s*"PodList"'
- type: word
part: body
words:
- "\"apiVersion\""
extractors:
- type: regex
name: kubelet_namespace
part: body
regex:
- '"namespace"\s*:\s*"([^"]+)"'
group: 1
@@ -0,0 +1,42 @@
# Kubernetes API Server Exposure Detection Module
id: kubernetes-api-exposure
info:
name: Kubernetes API Server Exposure
author: sif
severity: medium
description: Detects an internet reachable Kubernetes api server through its anonymous version endpoint
tags: [kubernetes, k8s, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/version"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"gitVersion\""
- type: word
part: body
condition: or
words:
- "\"gitTreeState\""
- "\"buildDate\""
- "\"compiler\""
extractors:
- type: regex
name: k8s_version
part: body
regex:
- '"gitVersion"\s*:\s*"([^"]+)"'
group: 1
@@ -0,0 +1,50 @@
# Maven settings.xml Credential Exposure Detection Module
id: maven-settings-exposure
info:
name: Maven settings.xml Credential Exposure
author: sif
severity: high
description: Detects an exposed maven settings.xml that leaks server credentials in cleartext
tags: [maven, settings, credentials, secrets, recon, exposure]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.m2/settings.xml"
- "{{BaseURL}}/settings.xml"
matchers:
- type: status
status:
- 200
- type: word
part: body
condition: or
words:
- "<settings"
- "<servers>"
- type: word
part: body
words:
- "<password>"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<html"
extractors:
- type: regex
name: maven_username
part: body
regex:
- '<username>([^<]+)</username>'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Netdata API Exposure Detection Module
id: netdata-api-exposure
info:
name: Netdata API Exposure
author: sif
severity: medium
description: Detects an exposed Netdata agent through its unauthenticated info endpoint
tags: [netdata, monitoring, metrics, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/api/v1/info"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"mirrored_hosts\""
- type: word
part: body
words:
- "\"cores_total\""
extractors:
- type: regex
name: netdata_version
part: body
regex:
- '"version"\s*:\s*"([^"]+)"'
group: 1
+51
View File
@@ -0,0 +1,51 @@
# NuGet Config Credential Exposure Detection Module
id: nuget-config-exposure
info:
name: NuGet Config Credential Exposure
author: sif
severity: high
description: Detects an exposed nuget.config that leaks package source credentials in cleartext
tags: [nuget, dotnet, credentials, secrets, recon, exposure]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/nuget.config"
- "{{BaseURL}}/NuGet.Config"
- "{{BaseURL}}/.nuget/NuGet/NuGet.Config"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "packageSourceCredentials"
- type: word
part: body
condition: or
words:
- "ClearTextPassword"
- "\"Password\""
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<html"
extractors:
- type: regex
name: nuget_username
part: body
regex:
- 'key="Username"\s+value="([^"]+)"'
group: 1
@@ -0,0 +1,56 @@
# Terraform State Exposure Detection Module
id: terraform-state-exposure
info:
name: Terraform State Exposure
author: sif
severity: high
description: Detects an exposed terraform state file that leaks infrastructure secrets and resource attributes
tags: [terraform, iac, state, secrets, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/terraform.tfstate"
- "{{BaseURL}}/terraform.tfstate.backup"
- "{{BaseURL}}/.terraform/terraform.tfstate"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- '"terraform_version"'
- type: word
part: body
condition: or
words:
- '"lineage"'
- '"serial"'
- '"resources"'
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: terraform_version
part: body
regex:
- '"terraform_version"\s*:\s*"([0-9]+\.[0-9]+\.[0-9]+)'
group: 1
+2 -2
View File
@@ -354,7 +354,7 @@ func (app *App) Run() error {
FilterSizes: app.settings.DirFilterSizes,
FilterWords: app.settings.DirFilterWords,
FilterRegex: app.settings.DirFilterRegex,
Calibrate: app.settings.DirCalibrate,
Calibrate: app.settings.Calibrate,
Wordlist: app.settings.DirWordlist,
Extensions: app.settings.DirExtensions,
})
@@ -498,7 +498,7 @@ func (app *App) Run() error {
}
if app.settings.SQL {
result, err := scan.SQL(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
result, err := scan.SQL(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir, app.settings.Calibrate)
if err != nil {
log.Errorf("Error while running SQL reconnaissance: %s", err)
} else if result != nil {