mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-27 00:43:59 -07:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8421cb8daa | |||
| fb9b92a5bf | |||
| 184842f734 | |||
| 0c6a8db5a7 | |||
| 54d1be288b | |||
| 17cf26cd82 | |||
| 672858b1fe | |||
| 0422b8b413 | |||
| f37094c9ee | |||
| d34db5582f | |||
| 9cf7854ed8 | |||
| af337bd094 | |||
| 7e104ac8d4 | |||
| 5dc14ecf22 | |||
| b31234c1bc | |||
| caeff3944d | |||
| 8c8f8afba3 | |||
| 1e47b6547e | |||
| 368d658882 | |||
| c6cedf3f55 | |||
| 6dd1d9e7fe |
@@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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’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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user