mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-27 00:43:59 -07:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1feb0648b3 | |||
| f6f9a2bbf7 | |||
| a9fde8c695 | |||
| 94d375fc3b | |||
| d16391186f | |||
| c6741e0f16 | |||
| 6a8ce9c07b | |||
| 355df83b59 | |||
| 570592c317 | |||
| 761e570d59 | |||
| 28a01f0f83 | |||
| 7788550722 | |||
| 40482a8409 | |||
| 3ed9ea4b6f | |||
| 612bb61d00 | |||
| ec53d15a9f | |||
| 064484ff4d | |||
| e2a26c19c6 | |||
| 95523bc344 | |||
| d0e986736d | |||
| 72f59532cf | |||
| 24b573a368 | |||
| 4d680074b8 | |||
| f0aa1895e9 | |||
| 6d903a4752 | |||
| 9c241cf185 | |||
| 26ccbea888 | |||
| bca4831df1 | |||
| 291846dde5 | |||
| 21c1d1c8a5 | |||
| 68075b6901 | |||
| 1bbcefa685 | |||
| aa22e6965a | |||
| 33c1c421c3 | |||
| 27c76e350c | |||
| 2e89a94a25 | |||
| 6f88625997 | |||
| 4cc48597a5 | |||
| 82a36886fa | |||
| 33e8668456 | |||
| fc3f11fb61 | |||
| 28acb16a46 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"projectName": "sif",
|
||||
"projectOwner": "lunchcat",
|
||||
"projectOwner": "vmfunc",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: automatic rebase
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: check for large files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: check for large files
|
||||
run: |
|
||||
large_files=$(find . -path ./.git -prune -o -type f -size +5M -print)
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
security-events: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: dependency review
|
||||
uses: actions/dependency-review-action@v5
|
||||
continue-on-error: ${{ github.event_name == 'push' }}
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
matrix:
|
||||
go-version: ["1.25"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: run tests with coverage
|
||||
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||
- name: upload coverage to codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage.out
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
check-headers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: check license headers
|
||||
run: |
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
name: profanity check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Profanity check step
|
||||
uses: tailaiw/mind-your-language-action@v1.0.3
|
||||
env:
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: runner / markdownlint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: markdownlint
|
||||
uses: reviewdog/action-markdownlint@v0.26.2
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: runner / misspell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: misspell
|
||||
uses: reviewdog/action-misspell@v1.27.0
|
||||
with:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: pr bot
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
@@ -9,7 +9,7 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -23,7 +23,6 @@ jobs:
|
||||
size:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: label pr size
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
update-report-card:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: update go report card
|
||||
uses: creekorful/goreportcard-action@v1.0
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
security-events: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: run scorecard
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: runner / shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: shellcheck
|
||||
uses: reviewdog/action-shellcheck@v1.32.0
|
||||
with:
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: runner / yamllint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: yamllint
|
||||
uses: reviewdog/action-yamllint@v1.21.0
|
||||
with:
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ When opening an issue, please use the search tool and make sure that the issue h
|
||||
|
||||
### Development
|
||||
|
||||
To develop sif, you'll need version 1.23 or later of the Go toolchain. After making your changes, run the program using `go run ./cmd/sif` to make sure it compiles and runs properly.
|
||||
To develop sif, you'll need version 1.25 or later of the Go toolchain. After making your changes, run the program using `go run ./cmd/sif` to make sure it compiles and runs properly.
|
||||
|
||||
_Nix users:_ the repository provides a flake that can be used to develop and run sif. Use `nix run`, `nix develop`, `nix build`, etc. Make sure to run `gomod2nix` if `go.mod` is changed.
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ define SUPPORT_MESSAGE
|
||||
│ │
|
||||
│ 🌟 Enjoying sif? Please consider: │
|
||||
│ │
|
||||
│ • Starring our repo: https://github.com/lunchcat/sif │
|
||||
│ • Supporting the devs: https://lunchcat.dev │
|
||||
│ • Starring our repo: https://github.com/vmfunc/sif │
|
||||
│ │
|
||||
│ Your support helps us continue improving sif! │
|
||||
│ │
|
||||
|
||||
@@ -365,7 +365,7 @@ contributions welcome. see [contributing.md](CONTRIBUTING.md) for guidelines.
|
||||
gofmt -w .
|
||||
|
||||
# lint
|
||||
golangci-lint run
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
|
||||
|
||||
# test
|
||||
go test ./...
|
||||
@@ -385,13 +385,13 @@ join our discord for support, feature discussions, and pentesting tips:
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://vmfunc.re"><img src="https://avatars.githubusercontent.com/u/59031302?v=4?s=100" width="100px;" alt="vmfunc"/><br /><sub><b>vmfunc</b></sub></a><br /><a href="#maintenance-vmfunc" title="Maintenance">🚧</a> <a href="#mentoring-vmfunc" title="Mentoring">🧑🏫</a> <a href="#projectManagement-vmfunc" title="Project Management">📆</a> <a href="#security-vmfunc" title="Security">🛡️</a> <a href="https://github.com/lunchcat/sif/commits?author=vmfunc" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://vmfunc.re"><img src="https://avatars.githubusercontent.com/u/59031302?v=4?s=100" width="100px;" alt="vmfunc"/><br /><sub><b>vmfunc</b></sub></a><br /><a href="#maintenance-vmfunc" title="Maintenance">🚧</a> <a href="#mentoring-vmfunc" title="Mentoring">🧑🏫</a> <a href="#projectManagement-vmfunc" title="Project Management">📆</a> <a href="#security-vmfunc" title="Security">🛡️</a> <a href="https://github.com/vmfunc/sif/commits?author=vmfunc" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://projectdiscovery.io"><img src="https://avatars.githubusercontent.com/u/50994705?v=4?s=100" width="100px;" alt="ProjectDiscovery"/><br /><sub><b>ProjectDiscovery</b></sub></a><br /><a href="#platform-projectdiscovery" title="Packaging/porting to new platform">📦</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/macdoos"><img src="https://avatars.githubusercontent.com/u/127897805?v=4?s=100" width="100px;" alt="macdoos"/><br /><sub><b>macdoos</b></sub></a><br /><a href="https://github.com/lunchcat/sif/commits?author=macdoos" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/macdoos"><img src="https://avatars.githubusercontent.com/u/127897805?v=4?s=100" width="100px;" alt="macdoos"/><br /><sub><b>macdoos</b></sub></a><br /><a href="https://github.com/vmfunc/sif/commits?author=macdoos" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://epitech.eu"><img src="https://avatars.githubusercontent.com/u/75166283?v=4?s=100" width="100px;" alt="Matthieu Witrowiez"/><br /><sub><b>Matthieu Witrowiez</b></sub></a><br /><a href="#ideas-D3adPlays" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tessa-u-k"><img src="https://avatars.githubusercontent.com/u/109355732?v=4?s=100" width="100px;" alt="tessa "/><br /><sub><b>tessa </b></sub></a><br /><a href="#infra-tessa-u-k" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#question-tessa-u-k" title="Answering Questions">💬</a> <a href="#userTesting-tessa-u-k" title="User Testing">📓</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xyzeva"><img src="https://avatars.githubusercontent.com/u/133499694?v=4?s=100" width="100px;" alt="Eva"/><br /><sub><b>Eva</b></sub></a><br /><a href="#blog-xyzeva" title="Blogposts">📝</a> <a href="#content-xyzeva" title="Content">🖋</a> <a href="#research-xyzeva" title="Research">🔬</a> <a href="#security-xyzeva" title="Security">🛡️</a> <a href="https://github.com/lunchcat/sif/commits?author=xyzeva" title="Tests">⚠️</a> <a href="https://github.com/lunchcat/sif/commits?author=xyzeva" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vxfemboy"><img src="https://avatars.githubusercontent.com/u/79362520?v=4?s=100" width="100px;" alt="Zoa Hickenlooper"/><br /><sub><b>Zoa Hickenlooper</b></sub></a><br /><a href="https://github.com/lunchcat/sif/commits?author=vxfemboy" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xyzeva"><img src="https://avatars.githubusercontent.com/u/133499694?v=4?s=100" width="100px;" alt="Eva"/><br /><sub><b>Eva</b></sub></a><br /><a href="#blog-xyzeva" title="Blogposts">📝</a> <a href="#content-xyzeva" title="Content">🖋</a> <a href="#research-xyzeva" title="Research">🔬</a> <a href="#security-xyzeva" title="Security">🛡️</a> <a href="https://github.com/vmfunc/sif/commits?author=xyzeva" title="Tests">⚠️</a> <a href="https://github.com/vmfunc/sif/commits?author=xyzeva" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vxfemboy"><img src="https://avatars.githubusercontent.com/u/79362520?v=4?s=100" width="100px;" alt="Zoa Hickenlooper"/><br /><sub><b>Zoa Hickenlooper</b></sub></a><br /><a href="https://github.com/vmfunc/sif/commits?author=vxfemboy" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xatrilla"><img src="https://avatars.githubusercontent.com/u/107285362?v=4?s=100" width="100px;" alt="acxtrilla"/><br /><sub><b>acxtrilla</b></sub></a><br /><a href="#platform-0xatrilla" title="Packaging/porting to new platform">📦</a></td>
|
||||
|
||||
+5
-2
@@ -60,8 +60,11 @@ gofmt -w .
|
||||
|
||||
### lint
|
||||
|
||||
ci pins golangci-lint v2.11.4 (`.github/workflows/go.yml`); other versions
|
||||
report spurious issues against the v2 config, so pin it locally too:
|
||||
|
||||
```bash
|
||||
golangci-lint run
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
|
||||
```
|
||||
|
||||
### test
|
||||
@@ -164,7 +167,7 @@ go test -tags=integration ./internal/scan/...
|
||||
1. fork the repository
|
||||
2. create a feature branch
|
||||
3. make changes
|
||||
4. run `gofmt -w .` and `golangci-lint run`
|
||||
4. run `gofmt -w .` and `golangci-lint run` (pinned version, see [lint](#lint))
|
||||
5. submit pr
|
||||
|
||||
### commit messages
|
||||
|
||||
+25
-1
@@ -115,6 +115,18 @@ http:
|
||||
|
||||
each payload creates a separate request for each path.
|
||||
|
||||
#### attack
|
||||
|
||||
how paths and payloads combine into requests.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
attack: pitchfork
|
||||
```
|
||||
|
||||
- `clusterbomb` (default) - every path is tried with every payload
|
||||
- `pitchfork` - path and payload are paired by index, stopping at the shorter list
|
||||
|
||||
#### headers
|
||||
|
||||
custom headers to send.
|
||||
@@ -199,6 +211,18 @@ matchers:
|
||||
condition: or
|
||||
```
|
||||
|
||||
### size matcher
|
||||
|
||||
match the response body length in bytes (measured after the 5 MB response cap, so larger sizes never match).
|
||||
|
||||
```yaml
|
||||
matchers:
|
||||
- type: size
|
||||
size:
|
||||
- 0
|
||||
- 1337
|
||||
```
|
||||
|
||||
### combining matchers
|
||||
|
||||
multiple matchers are combined with AND logic by default.
|
||||
@@ -238,7 +262,7 @@ extractors:
|
||||
|
||||
### kv extractor
|
||||
|
||||
extract key-value pairs.
|
||||
record every response header as a key-value pair, namespaced by `name`.
|
||||
|
||||
```yaml
|
||||
extractors:
|
||||
|
||||
@@ -4,18 +4,18 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/antchfx/htmlquery v1.3.6
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/gocolly/colly/v2 v2.1.0
|
||||
github.com/gocolly/colly/v2 v2.3.0
|
||||
github.com/likexian/whois v1.15.7
|
||||
github.com/projectdiscovery/goflags v0.1.74
|
||||
github.com/projectdiscovery/nuclei/v3 v3.8.0
|
||||
github.com/projectdiscovery/retryabledns v1.0.114
|
||||
github.com/projectdiscovery/utils v0.10.1
|
||||
github.com/rocketlaunchr/google-search v1.1.6
|
||||
github.com/twmb/murmur3 v1.1.6
|
||||
golang.org/x/net v0.53.0
|
||||
github.com/twmb/murmur3 v1.1.8
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -55,7 +55,7 @@ require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/andygrunwald/go-jira v1.16.1 // indirect
|
||||
github.com/antchfx/xmlquery v1.4.4 // indirect
|
||||
github.com/antchfx/xmlquery v1.5.0 // indirect
|
||||
github.com/antchfx/xpath v1.3.6 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
@@ -80,7 +80,7 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.22.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bits-and-blooms/bloom/v3 v3.5.0 // indirect
|
||||
github.com/bluele/gcache v0.0.2 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
@@ -97,7 +97,7 @@ require (
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.2 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250908092851-c2208eb08494 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
@@ -230,7 +230,7 @@ require (
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.17 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||
github.com/maypok86/otter/v2 v2.2.1 // indirect
|
||||
github.com/mholt/acmez/v3 v3.1.3 // indirect
|
||||
@@ -250,6 +250,7 @@ require (
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
@@ -377,17 +378,17 @@ require (
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
goftp.io/server/v2 v2.0.1 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
|
||||
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
|
||||
@@ -149,13 +149,13 @@ github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9
|
||||
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
|
||||
github.com/antchfx/xmlquery v1.2.4/go.mod h1:KQQuESaxSlqugE2ZBcM/qn+ebIpt+d+4Xx7YcSGAIrM=
|
||||
github.com/antchfx/xmlquery v1.3.15/go.mod h1:zMDv5tIGjOxY/JCNNinnle7V/EwthZ5IT8eeCGJKRWA=
|
||||
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
|
||||
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
|
||||
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
|
||||
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
|
||||
github.com/antchfx/xpath v1.1.6/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
|
||||
github.com/antchfx/xpath v1.1.8/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
|
||||
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.2.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
@@ -212,8 +212,9 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.8.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
|
||||
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bloom/v3 v3.5.0 h1:AKDvi1V3xJCmSR6QhcBfHbCN4Vf8FfxeWkMNQfmAGhY=
|
||||
github.com/bits-and-blooms/bloom/v3 v3.5.0/go.mod h1:Y8vrn7nk1tPIlmLtW2ZPV+W7StdVMor6bC1xgpjMZFs=
|
||||
github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
|
||||
@@ -259,14 +260,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
||||
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
|
||||
github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
@@ -464,8 +465,9 @@ github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakr
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
|
||||
github.com/gocolly/colly/v2 v2.1.0 h1:k0DuZkDoCsx51bKpRJNEmcxcp+W5N8ziuwGaSDuFoGs=
|
||||
github.com/gocolly/colly/v2 v2.1.0/go.mod h1:I2MuhsLjQ+Ex+IzK3afNS8/1qP3AedHOusRPcRdC5o0=
|
||||
github.com/gocolly/colly/v2 v2.3.0 h1:HSFh0ckbgVd2CSGRE+Y/iA4goUhGROJwyQDCMXGFBWM=
|
||||
github.com/gocolly/colly/v2 v2.3.0/go.mod h1:Qp54s/kQbwCQvFVx8KzKCSTXVJ1wWT4QeAKEu33x1q8=
|
||||
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
@@ -727,8 +729,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
|
||||
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
@@ -791,6 +793,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
||||
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
|
||||
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
@@ -1061,8 +1065,9 @@ github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM=
|
||||
github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg=
|
||||
github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
|
||||
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
@@ -1234,8 +1239,9 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1273,8 +1279,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1331,8 +1337,9 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1361,8 +1368,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1429,8 +1436,9 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -1446,8 +1454,9 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1466,8 +1475,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1522,8 +1531,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -1550,8 +1559,9 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
@@ -1616,8 +1626,8 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -194,7 +194,7 @@ func Parse() *Settings {
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("api", "API",
|
||||
flagSet.BoolVar(&settings.ApiMode, "api", false, "Enable API mode. Only useful for internal lunchcat usage"),
|
||||
flagSet.BoolVar(&settings.ApiMode, "api", false, "Enable API mode. Only useful for internal usage"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("modules", "Modules",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runAnalyticsModule(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 analyticsExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAnalyticsUIExposureModules(t *testing.T) {
|
||||
const metabase = "../../modules/recon/metabase-api-exposure.yaml"
|
||||
const zeppelin = "../../modules/recon/zeppelin-api-exposure.yaml"
|
||||
const jupyter = "../../modules/recon/jupyter-api-exposure.yaml"
|
||||
|
||||
metabaseProps := `{"engines":{"postgres":{"driver-name":"PostgreSQL"}},` +
|
||||
`"setup-token":"245f5f7c-8f0b-4c20-9a1e-6b2d7e1f0a33","anon-tracking-enabled":true,` +
|
||||
`"available-locales":[["en","English"]],"password-complexity":{"total":6},` +
|
||||
`"version":{"date":"2023-10-01","tag":"v0.47.2","branch":"release-x.47.x","hash":"abc1234"}}`
|
||||
|
||||
zeppelinVersion := `{"status":"OK","message":"Zeppelin version",` +
|
||||
`"body":{"version":"0.10.1","git-commit-id":"a1b2c3d4e5","git-timestamp":"2022-01-15 10:00:00"}}`
|
||||
|
||||
jupyterStatus := `{"started":"2024-01-01T00:00:00.000000Z",` +
|
||||
`"last_activity":"2024-01-01T01:23:45.000000Z","connections":2,"kernels":3}`
|
||||
|
||||
t.Run("an exposed metabase properties api is flagged and versioned", func(t *testing.T) {
|
||||
res := runAnalyticsModule(t, metabase, 200, metabaseProps)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a metabase finding")
|
||||
}
|
||||
if v := analyticsExtract(res, "metabase_version"); v != "v0.47.2" {
|
||||
t.Errorf("metabase_version=%q, want v0.47.2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed zeppelin server is flagged and versioned", func(t *testing.T) {
|
||||
res := runAnalyticsModule(t, zeppelin, 200, zeppelinVersion)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a zeppelin finding")
|
||||
}
|
||||
if v := analyticsExtract(res, "zeppelin_version"); v != "0.10.1" {
|
||||
t.Errorf("zeppelin_version=%q, want 0.10.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed jupyter status api is flagged with the kernel count", func(t *testing.T) {
|
||||
res := runAnalyticsModule(t, jupyter, 200, jupyterStatus)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a jupyter finding")
|
||||
}
|
||||
if v := analyticsExtract(res, "jupyter_active_kernels"); v != "3" {
|
||||
t.Errorf("jupyter_active_kernels=%q, want 3", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a live metabase token without the tracking setting is not flagged", func(t *testing.T) {
|
||||
body := `{"setup-token":"245f5f7c-8f0b-4c20-9a1e-6b2d7e1f0a33","name":"app"}`
|
||||
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a setup token alone should not match metabase, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a metabase tracking setting without a setup token is not flagged", func(t *testing.T) {
|
||||
body := `{"anon-tracking-enabled":true,"name":"app"}`
|
||||
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a tracking setting alone should not match metabase, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a patched metabase with a null setup token is not flagged", func(t *testing.T) {
|
||||
body := `{"setup-token":null,"anon-tracking-enabled":true,` +
|
||||
`"version":{"tag":"v0.47.2"}}`
|
||||
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a null setup token should not match metabase, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a zeppelin banner without a git commit id is not flagged", func(t *testing.T) {
|
||||
body := `{"status":"OK","message":"Zeppelin version","body":{}}`
|
||||
if res := runAnalyticsModule(t, zeppelin, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a banner alone should not match zeppelin, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a git commit id without the zeppelin banner is not flagged", func(t *testing.T) {
|
||||
body := `{"git-commit-id":"a1b2c3d","name":"app"}`
|
||||
if res := runAnalyticsModule(t, zeppelin, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a commit id alone should not match zeppelin, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a jupyter status without a kernels field is not flagged", func(t *testing.T) {
|
||||
body := `{"started":"2024-01-01T00:00:00Z","last_activity":"2024-01-01T01:00:00Z","connections":2}`
|
||||
if res := runAnalyticsModule(t, jupyter, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a status without kernels should not match jupyter, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a jupyter status without a connections field is not flagged", func(t *testing.T) {
|
||||
body := `{"started":"2024-01-01T00:00:00Z","last_activity":"2024-01-01T01:00:00Z","kernels":3}`
|
||||
if res := runAnalyticsModule(t, jupyter, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a status without connections should not match jupyter, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic version json is not an analytics service", func(t *testing.T) {
|
||||
body := `{"version":"1.0.0","name":"app"}`
|
||||
for _, file := range []string{metabase, zeppelin, jupyter} {
|
||||
if res := runAnalyticsModule(t, file, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{metabase, zeppelin, jupyter} {
|
||||
if res := runAnalyticsModule(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{metabase, zeppelin, jupyter} {
|
||||
if res := runAnalyticsModule(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,173 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runAppCfgModule(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 appCfgExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAppConfigExposureModules(t *testing.T) {
|
||||
const spring = "../../modules/recon/spring-application-config-exposure.yaml"
|
||||
const appsettings = "../../modules/recon/appsettings-exposure.yaml"
|
||||
const wpconfig = "../../modules/recon/wp-config-backup-exposure.yaml"
|
||||
|
||||
springProps := "spring.application.name=billing\n" +
|
||||
"spring.datasource.url=jdbc:mysql://db.internal:3306/billing\n" +
|
||||
"spring.datasource.username=app\nspring.datasource.password=s3cr3tP@ss\n" +
|
||||
"spring.jpa.hibernate.ddl-auto=update\nserver.port=8080\n"
|
||||
|
||||
springYaml := "spring:\n datasource:\n url: jdbc:postgresql://pg.internal:5432/app\n" +
|
||||
" username: app\n password: hunter2\nserver:\n port: 8443\n"
|
||||
|
||||
appSettings := `{` + "\n" +
|
||||
` "Logging": { "LogLevel": { "Default": "Information" } },` + "\n" +
|
||||
` "ConnectionStrings": {` + "\n" +
|
||||
` "DefaultConnection": "Server=db;Database=app;User Id=sa;Password=P@ssw0rd;"` + "\n" +
|
||||
` },` + "\n" +
|
||||
` "AllowedHosts": "*"` + "\n}"
|
||||
|
||||
wpConfig := "<?php\ndefine( 'DB_NAME', 'wordpress' );\ndefine( 'DB_USER', 'wp' );\n" +
|
||||
"define( 'DB_PASSWORD', 'Tr0ub4dor&3' );\ndefine( 'DB_HOST', 'localhost' );\n" +
|
||||
"$table_prefix = 'wp_';\n"
|
||||
|
||||
t.Run("a spring properties file leaks the jdbc url", func(t *testing.T) {
|
||||
res := runAppCfgModule(t, spring, 200, springProps)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a spring config finding")
|
||||
}
|
||||
if v := appCfgExtract(res, "jdbc_url"); v != "jdbc:mysql://db.internal:3306/billing" {
|
||||
t.Errorf("jdbc_url=%q, want the mysql url", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a spring yaml file also matches and names the jdbc url", func(t *testing.T) {
|
||||
res := runAppCfgModule(t, spring, 200, springYaml)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a spring config finding for yaml")
|
||||
}
|
||||
if v := appCfgExtract(res, "jdbc_url"); v != "jdbc:postgresql://pg.internal:5432/app" {
|
||||
t.Errorf("jdbc_url=%q, want the postgres url", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an appsettings json leaks the connection string", func(t *testing.T) {
|
||||
res := runAppCfgModule(t, appsettings, 200, appSettings)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an appsettings finding")
|
||||
}
|
||||
want := "Server=db;Database=app;User Id=sa;Password=P@ssw0rd;"
|
||||
if v := appCfgExtract(res, "connection_string"); v != want {
|
||||
t.Errorf("connection_string=%q, want %q", v, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a wp-config backup leaks the database password", func(t *testing.T) {
|
||||
res := runAppCfgModule(t, wpconfig, 200, wpConfig)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a wp-config finding")
|
||||
}
|
||||
if v := appCfgExtract(res, "db_password"); v != "Tr0ub4dor&3" {
|
||||
t.Errorf("db_password=%q, want Tr0ub4dor&3", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a spring config with no credential is not flagged", func(t *testing.T) {
|
||||
body := "spring.application.name=app\nserver.port=8080\n"
|
||||
if res := runAppCfgModule(t, spring, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a credential-free config should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a spring config inside an html page is not flagged", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>spring.datasource.password=x</pre></body></html>"
|
||||
if res := runAppCfgModule(t, spring, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an appsettings without a connection string is not flagged", func(t *testing.T) {
|
||||
body := `{"Logging":{"LogLevel":{"Default":"Information"}},"AllowedHosts":"*"}`
|
||||
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a config without a connection string should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an appsettings with no password is not a credential leak", func(t *testing.T) {
|
||||
body := `{"ConnectionStrings":{"Db":"Server=db;Database=app;Integrated Security=true;"}}`
|
||||
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a passwordless connection string should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an appsettings password outside a connection strings section is not flagged", func(t *testing.T) {
|
||||
body := `{"Smtp":{"Host":"Server=mail;Password=relaypass;"}}`
|
||||
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a password outside ConnectionStrings should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prose that names the wp-config password is not a backup", func(t *testing.T) {
|
||||
body := "set the DB_PASSWORD env var before running the installer"
|
||||
if res := runAppCfgModule(t, wpconfig, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("prose naming DB_PASSWORD should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a wp-config shown in an html page is not flagged", func(t *testing.T) {
|
||||
body := "<html><head><title>setup</title></head><body>define( 'DB_PASSWORD', 'x' ); DB_NAME</body></html>"
|
||||
if res := runAppCfgModule(t, wpconfig, 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{spring, appsettings, wpconfig} {
|
||||
if res := runAppCfgModule(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{spring, appsettings, wpconfig} {
|
||||
if res := runAppCfgModule(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,92 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runArgocdModule(t *testing.T, status int, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule("../../modules/recon/argocd-api-exposure.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("parse argocd module: %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 argocd module: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func argocdExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestArgocdExposureModule(t *testing.T) {
|
||||
argocdVersion := `{"Version":"v2.9.3+a1b2c3d","BuildDate":"2024-01-15T12:00:00Z","GitCommit":"a1b2c3d",` +
|
||||
`"GitTreeState":"clean","GoVersion":"go1.21.5","Compiler":"gc","Platform":"linux/amd64",` +
|
||||
`"KustomizeVersion":"v5.2.1 2023-10-19","HelmVersion":"v3.13.2+gadc03ef",` +
|
||||
`"KubectlVersion":"v0.26.11","JsonnetVersion":"v0.20.0"}`
|
||||
|
||||
t.Run("an exposed argocd version endpoint is flagged and versioned", func(t *testing.T) {
|
||||
res := runArgocdModule(t, 200, argocdVersion)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an argocd finding")
|
||||
}
|
||||
if v := argocdExtract(res, "argocd_version"); v != "v2.9.3+a1b2c3d" {
|
||||
t.Errorf("argocd_version=%q, want v2.9.3+a1b2c3d", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an argocd kustomize version without a helm version is not flagged", func(t *testing.T) {
|
||||
body := `{"Version":"v2.9.3","KustomizeVersion":"v5.2.1 2023-10-19"}`
|
||||
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a kustomize version alone should not match argocd, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an argocd helm version without a kustomize version is not flagged", func(t *testing.T) {
|
||||
body := `{"Version":"v2.9.3","HelmVersion":"v3.13.2+gadc03ef"}`
|
||||
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a helm version alone should not match argocd, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic version endpoint is not argocd", func(t *testing.T) {
|
||||
body := `{"Version":"v1.0.0","GitCommit":"abc"}`
|
||||
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic version json should not match argocd, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
if res := runArgocdModule(t, 200, "ok"); len(res.Findings) > 0 {
|
||||
t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a 404 is not a leak", func(t *testing.T) {
|
||||
if res := runArgocdModule(t, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
)
|
||||
|
||||
func reqURLs(reqs []*httpRequest) []string {
|
||||
urls := make([]string, len(reqs))
|
||||
for i, r := range reqs {
|
||||
urls[i] = r.URL
|
||||
}
|
||||
sort.Strings(urls)
|
||||
return urls
|
||||
}
|
||||
|
||||
func TestGenerateHTTPRequestsAttack(t *testing.T) {
|
||||
const target = "http://t"
|
||||
paths2 := []string{"{{BaseURL}}/a?x={{payload}}", "{{BaseURL}}/b?x={{payload}}"}
|
||||
pay2 := []string{"1", "2"}
|
||||
cross := []string{"http://t/a?x=1", "http://t/a?x=2", "http://t/b?x=1", "http://t/b?x=2"}
|
||||
paired := []string{"http://t/a?x=1", "http://t/b?x=2"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
payloads []string
|
||||
attack string
|
||||
want []string
|
||||
}{
|
||||
{"clusterbomb default crosses all", paths2, pay2, "", cross},
|
||||
{"clusterbomb explicit crosses all", paths2, pay2, "clusterbomb", cross},
|
||||
{"pitchfork pairs by index", paths2, pay2, "pitchfork", paired},
|
||||
{"pitchfork stops at fewer payloads", append(paths2, "{{BaseURL}}/c?x={{payload}}"), pay2, "pitchfork", paired},
|
||||
{"pitchfork stops at fewer paths", paths2, []string{"1", "2", "3"}, "pitchfork", paired},
|
||||
{"attack is case insensitive", paths2, pay2, "Pitchfork", paired},
|
||||
{"no payloads ignores attack", []string{"{{BaseURL}}/a", "{{BaseURL}}/b"}, nil, "pitchfork", []string{"http://t/a", "http://t/b"}},
|
||||
}
|
||||
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))
|
||||
want := append([]string(nil), tt.want...)
|
||||
sort.Strings(want)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("attack %q:\n got %v\nwant %v", tt.attack, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAttack(t *testing.T) {
|
||||
for _, ok := range []string{"", "clusterbomb", "pitchfork", "Pitchfork", "CLUSTERBOMB"} {
|
||||
if err := validateAttack(ok); err != nil {
|
||||
t.Errorf("validateAttack(%q) = %v, want nil", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"sniper", "batteringram", "bogus"} {
|
||||
if err := validateAttack(bad); err == nil {
|
||||
t.Errorf("validateAttack(%q) = nil, want error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAttackValidation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
good := write("good.yaml", "id: ok\ntype: http\nhttp:\n attack: pitchfork\n paths: [\"{{BaseURL}}/\"]\n")
|
||||
if _, err := ParseYAMLModule(good); err != nil {
|
||||
t.Fatalf("valid attack rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := write("bad.yaml", "id: bad\ntype: http\nhttp:\n attack: sniper\n paths: [\"{{BaseURL}}/\"]\n")
|
||||
if _, err := ParseYAMLModule(bad); err == nil {
|
||||
t.Fatal("invalid attack accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModulePitchfork drives the executor end to end and confirms
|
||||
// pitchfork only fires the index-paired requests, not the full cross product.
|
||||
func TestExecuteHTTPModulePitchfork(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
hits = append(hits, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
mu.Unlock()
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "pf",
|
||||
Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Attack: "pitchfork",
|
||||
Paths: []string{"{{BaseURL}}/a?x={{payload}}", "{{BaseURL}}/b?x={{payload}}"},
|
||||
Payloads: []string{"1", "2"},
|
||||
Matchers: []Matcher{{Type: "word", Part: "body", Words: []string{"ok"}}},
|
||||
},
|
||||
}
|
||||
|
||||
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
||||
if _, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts); err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := append([]string(nil), hits...)
|
||||
mu.Unlock()
|
||||
sort.Strings(got)
|
||||
want := []string{"/a?x=1", "/b?x=2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("pitchfork hit %v, want %v (clusterbomb would also hit /a?x=2 and /b?x=1)", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runBigDataModule(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 bigDataExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestBigDataAPIExposureModules(t *testing.T) {
|
||||
const solr = "../../modules/recon/solr-api-exposure.yaml"
|
||||
const spark = "../../modules/recon/spark-api-exposure.yaml"
|
||||
const hadoop = "../../modules/recon/hadoop-yarn-api-exposure.yaml"
|
||||
|
||||
solrSystem := `{"responseHeader":{"status":0,"QTime":15},"mode":"std",` +
|
||||
`"solr_home":"/var/solr/data","lucene":{"solr-spec-version":"9.4.0",` +
|
||||
`"solr-impl-version":"9.4.0","lucene-spec-version":"9.8.0","lucene-impl-version":"9.8.0"},` +
|
||||
`"jvm":{"version":"17.0.9"}}`
|
||||
|
||||
sparkState := `{"url":"spark://master:7077","workers":[{"id":"worker-1","host":"10.0.0.5"}],` +
|
||||
`"aliveworkers":2,"cores":8,"coresused":0,"memory":15360,"activeapps":[],` +
|
||||
`"completedapps":[],"status":"ALIVE"}`
|
||||
|
||||
hadoopInfo := `{"clusterInfo":{"id":1700000000000,"startedOn":1700000000000,"state":"STARTED",` +
|
||||
`"haState":"ACTIVE","resourceManagerVersion":"3.3.6","resourceManagerBuildVersion":"3.3.6 from abc",` +
|
||||
`"hadoopVersion":"3.3.6","hadoopBuildVersion":"3.3.6 from abc","hadoopVersionBuiltOn":"2023-06-18"}}`
|
||||
|
||||
t.Run("an exposed solr admin api is flagged and versioned", func(t *testing.T) {
|
||||
res := runBigDataModule(t, solr, 200, solrSystem)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a solr finding")
|
||||
}
|
||||
if v := bigDataExtract(res, "solr_version"); v != "9.4.0" {
|
||||
t.Errorf("solr_version=%q, want 9.4.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed spark master leaks its url", func(t *testing.T) {
|
||||
res := runBigDataModule(t, spark, 200, sparkState)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a spark finding")
|
||||
}
|
||||
if v := bigDataExtract(res, "spark_master_url"); v != "spark://master:7077" {
|
||||
t.Errorf("spark_master_url=%q, want spark://master:7077", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed hadoop yarn api is flagged and versioned", func(t *testing.T) {
|
||||
res := runBigDataModule(t, hadoop, 200, hadoopInfo)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a hadoop finding")
|
||||
}
|
||||
if v := bigDataExtract(res, "hadoop_version"); v != "3.3.6" {
|
||||
t.Errorf("hadoop_version=%q, want 3.3.6", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a solr spec version without a solr home is not solr", func(t *testing.T) {
|
||||
body := `{"lucene":{"solr-spec-version":"9.4.0"},"name":"otherservice"}`
|
||||
if res := runBigDataModule(t, solr, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("spec version alone should not match solr, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a solr home without a spec version is not solr", func(t *testing.T) {
|
||||
body := `{"solr_home":"/var/solr/data","mode":"std"}`
|
||||
if res := runBigDataModule(t, solr, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("solr home alone should not match solr, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a spark url without alive workers is not flagged", func(t *testing.T) {
|
||||
body := `{"url":"spark://master:7077","workers":[],"status":"ALIVE"}`
|
||||
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a spark url alone should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alive workers behind a non spark url is not flagged", func(t *testing.T) {
|
||||
body := `{"url":"http://internal:8080","aliveworkers":2}`
|
||||
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a non spark url should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a cluster info without a resource manager version is not hadoop", func(t *testing.T) {
|
||||
body := `{"clusterInfo":{"id":1,"state":"STARTED","hadoopVersion":"3.3.6"}}`
|
||||
if res := runBigDataModule(t, hadoop, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("cluster info alone should not match hadoop, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a resource manager version without a cluster info is not hadoop", func(t *testing.T) {
|
||||
body := `{"resourceManagerVersion":"3.3.6","app":"custom"}`
|
||||
if res := runBigDataModule(t, hadoop, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("rm version alone should not match hadoop, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic json endpoint is not a spark master", func(t *testing.T) {
|
||||
body := `{"url":"http://app","workers":5,"name":"myservice"}`
|
||||
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic json should not match spark, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{solr, spark, hadoop} {
|
||||
if res := runBigDataModule(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{solr, spark, hadoop} {
|
||||
if res := runBigDataModule(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,151 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runPipelineModule(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 pipelineExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDataPipelineAPIExposureModules(t *testing.T) {
|
||||
const airflow = "../../modules/recon/airflow-api-exposure.yaml"
|
||||
const flink = "../../modules/recon/flink-api-exposure.yaml"
|
||||
const kafka = "../../modules/recon/kafka-connect-api-exposure.yaml"
|
||||
|
||||
airflowHealth := `{"metadatabase":{"status":"healthy"},"scheduler":{"status":"healthy",` +
|
||||
`"latest_scheduler_heartbeat":"2023-09-13T09:35:49.123456+00:00"}}`
|
||||
|
||||
flinkOverview := `{"taskmanagers":1,"slots-total":4,"slots-available":4,"jobs-running":0,` +
|
||||
`"jobs-finished":2,"jobs-cancelled":0,"jobs-failed":0,"flink-version":"1.17.1","flink-commit":"2750d5c"}`
|
||||
|
||||
kafkaConnect := `{"version":"3.5.0","commit":"c97b88d5db4de28d","kafka_cluster_id":"M_oad8FjQ1eMShri6_jjQg"}`
|
||||
|
||||
t.Run("an exposed airflow health endpoint is flagged", func(t *testing.T) {
|
||||
res := runPipelineModule(t, airflow, 200, airflowHealth)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an airflow finding")
|
||||
}
|
||||
if v := pipelineExtract(res, "airflow_scheduler_heartbeat"); v != "2023-09-13T09:35:49.123456+00:00" {
|
||||
t.Errorf("airflow_scheduler_heartbeat=%q, want the heartbeat timestamp", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed flink dashboard is flagged and versioned", func(t *testing.T) {
|
||||
res := runPipelineModule(t, flink, 200, flinkOverview)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a flink finding")
|
||||
}
|
||||
if v := pipelineExtract(res, "flink_version"); v != "1.17.1" {
|
||||
t.Errorf("flink_version=%q, want 1.17.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed kafka connect api is flagged and versioned", func(t *testing.T) {
|
||||
res := runPipelineModule(t, kafka, 200, kafkaConnect)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a kafka connect finding")
|
||||
}
|
||||
if v := pipelineExtract(res, "kafka_version"); v != "3.5.0" {
|
||||
t.Errorf("kafka_version=%q, want 3.5.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an airflow metadatabase without a scheduler is not flagged", func(t *testing.T) {
|
||||
body := `{"metadatabase":{"status":"healthy"}}`
|
||||
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("metadatabase alone should not match airflow, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an airflow scheduler without a metadatabase is not flagged", func(t *testing.T) {
|
||||
body := `{"scheduler":{"status":"healthy","latest_scheduler_heartbeat":"2023-09-13T09:35:49.123456+00:00"}}`
|
||||
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("scheduler alone should not match airflow, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a flink version without a slot total is not flagged", func(t *testing.T) {
|
||||
body := `{"flink-version":"1.17.1","taskmanagers":1}`
|
||||
if res := runPipelineModule(t, flink, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("flink version alone should not match flink, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a slot total without a flink version is not flagged", func(t *testing.T) {
|
||||
body := `{"slots-total":4,"jobs-running":0}`
|
||||
if res := runPipelineModule(t, flink, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a slot total alone should not match flink, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a kafka cluster id without a version is not flagged", func(t *testing.T) {
|
||||
body := `{"kafka_cluster_id":"M_oad8FjQ1eMShri6_jjQg","commit":"abc"}`
|
||||
if res := runPipelineModule(t, kafka, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a cluster id alone should not match kafka connect, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a version without a kafka cluster id is not flagged", func(t *testing.T) {
|
||||
body := `{"version":"3.5.0","name":"someservice"}`
|
||||
if res := runPipelineModule(t, kafka, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a version alone should not match kafka connect, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic health json is not airflow", func(t *testing.T) {
|
||||
body := `{"status":"UP","components":{"db":{"status":"UP"}}}`
|
||||
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic health should not match airflow, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{airflow, flink, kafka} {
|
||||
if res := runPipelineModule(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{airflow, flink, kafka} {
|
||||
if res := runPipelineModule(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,166 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runDBFileModule(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 dbFileExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDatabaseFileExposureModules(t *testing.T) {
|
||||
const sqlDump = "../../modules/recon/sql-dump-exposure.yaml"
|
||||
const sqlite = "../../modules/recon/sqlite-database-exposure.yaml"
|
||||
const redis = "../../modules/recon/redis-dump-exposure.yaml"
|
||||
|
||||
mysqldump := "-- MySQL dump 10.13 Distrib 8.0.32, for Linux (x86_64)\n--\n" +
|
||||
"-- Host: localhost Database: appdb\n--\n-- Server version\t8.0.32\n\n" +
|
||||
"DROP TABLE IF EXISTS `users`;\nCREATE TABLE `users` (\n" +
|
||||
" `id` int NOT NULL AUTO_INCREMENT,\n `email` varchar(255) DEFAULT NULL,\n" +
|
||||
" PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n" +
|
||||
"INSERT INTO `users` VALUES (1,'admin@x.com');\n"
|
||||
|
||||
pgdump := "--\n-- PostgreSQL database dump\n--\n\nSET statement_timeout = 0;\n" +
|
||||
"CREATE TABLE public.accounts (\n id integer NOT NULL,\n email text\n);\n" +
|
||||
"COPY public.accounts (id, email) FROM stdin;\n1\tadmin@x.com\n\\.\n"
|
||||
|
||||
sqliteFile := "SQLite format 3\x00" + strings.Repeat("\x00", 84) +
|
||||
"\x05\x00CREATE TABLE users(id INTEGER PRIMARY KEY, email TEXT, password TEXT)\x00"
|
||||
|
||||
redisDump := "REDIS0011\xfa\x09redis-ver\x055.0.7\xfa\x0aredis-bits\xc0@\xfe\x00\xfb\x02\x00" +
|
||||
"\x03key\x05value\xff\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
t.Run("a mysqldump leaks the dumped table", func(t *testing.T) {
|
||||
res := runDBFileModule(t, sqlDump, 200, mysqldump)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a sql dump finding")
|
||||
}
|
||||
if v := dbFileExtract(res, "dump_table"); v != "users" {
|
||||
t.Errorf("dump_table=%q, want users", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a postgresql dump also matches and names its table", func(t *testing.T) {
|
||||
res := runDBFileModule(t, sqlDump, 200, pgdump)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a sql dump finding for pg_dump")
|
||||
}
|
||||
if v := dbFileExtract(res, "dump_table"); v != "accounts" {
|
||||
t.Errorf("dump_table=%q, want accounts", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a sqlite database file leaks its schema table", func(t *testing.T) {
|
||||
res := runDBFileModule(t, sqlite, 200, sqliteFile)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a sqlite finding")
|
||||
}
|
||||
if v := dbFileExtract(res, "table_name"); v != "users" {
|
||||
t.Errorf("table_name=%q, want users", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a redis rdb snapshot leaks its format version", func(t *testing.T) {
|
||||
res := runDBFileModule(t, redis, 200, redisDump)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a redis rdb finding")
|
||||
}
|
||||
if v := dbFileExtract(res, "rdb_version"); v != "0011" {
|
||||
t.Errorf("rdb_version=%q, want 0011", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql shown inside an html page is not a dump", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><head><title>SQL tutorial</title></head><body>" +
|
||||
"<pre>DROP TABLE IF EXISTS users; CREATE TABLE users (id int); INSERT INTO users VALUES (1);</pre>" +
|
||||
"</body></html>"
|
||||
if res := runDBFileModule(t, sqlDump, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a sql file with no dump idiom is not flagged", func(t *testing.T) {
|
||||
body := "-- migration notes\nSELECT id FROM users WHERE active = 1;\n"
|
||||
if res := runDBFileModule(t, sqlDump, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare select should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a page that names the sqlite format is not the file", func(t *testing.T) {
|
||||
body := "This page documents the SQLite format 3 on-disk structure for readers."
|
||||
if res := runDBFileModule(t, sqlite, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("prose about sqlite should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a page that names redis is not an rdb snapshot", func(t *testing.T) {
|
||||
body := "redis-server is running on this host as the REDIS cache backend."
|
||||
if res := runDBFileModule(t, redis, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("prose about redis should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the sqlite magic only counts at the start of the file", func(t *testing.T) {
|
||||
body := "<pre>hexdump of a header: " + sqliteFile + "</pre>"
|
||||
if res := runDBFileModule(t, sqlite, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an embedded sqlite header should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the rdb magic only counts at the start of the file", func(t *testing.T) {
|
||||
body := "log line: loaded snapshot " + redisDump
|
||||
if res := runDBFileModule(t, redis, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an embedded rdb header 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{sqlDump, sqlite, redis} {
|
||||
if res := runDBFileModule(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{sqlDump, sqlite, redis} {
|
||||
if res := runDBFileModule(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,134 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runDeployModule(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 deployExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDeployConfigExposureModules(t *testing.T) {
|
||||
const vscode = "../../modules/recon/vscode-sftp-exposure.yaml"
|
||||
const sublime = "../../modules/recon/sublime-sftp-exposure.yaml"
|
||||
const ftpconfig = "../../modules/recon/ftpconfig-exposure.yaml"
|
||||
|
||||
t.Run("vscode sftp config leaks the deploy host", func(t *testing.T) {
|
||||
body := `{"name":"prod","host":"deploy.example.com","protocol":"sftp",` +
|
||||
`"username":"root","password":"s3cr3t","remotePath":"/var/www","uploadOnSave":true}`
|
||||
res := runDeployModule(t, vscode, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a vscode sftp finding")
|
||||
}
|
||||
if v := deployExtract(res, "remote_host"); v != "deploy.example.com" {
|
||||
t.Errorf("remote_host=%q, want deploy.example.com", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vscode sftp config with key auth still flags and extracts the host", func(t *testing.T) {
|
||||
body := `{"host":"key.example.com","protocol":"sftp",` +
|
||||
`"username":"deploy","privateKeyPath":"~/.ssh/id_rsa","uploadOnSave":true}`
|
||||
res := runDeployModule(t, vscode, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a vscode sftp finding for a key-auth config")
|
||||
}
|
||||
if v := deployExtract(res, "remote_host"); v != "key.example.com" {
|
||||
t.Errorf("remote_host=%q, want key.example.com", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sublime sftp config leaks the deploy host", func(t *testing.T) {
|
||||
body := `{"type":"sftp","host":"sftp.example.org","user":"www","password":"hunter2",` +
|
||||
`"remote_path":"/srv","upload_on_save":true,"sync_down_on_open":false}`
|
||||
res := runDeployModule(t, sublime, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a sublime sftp finding")
|
||||
}
|
||||
if v := deployExtract(res, "remote_host"); v != "sftp.example.org" {
|
||||
t.Errorf("remote_host=%q, want sftp.example.org", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("atom remote-ftp config leaks the deploy host", func(t *testing.T) {
|
||||
body := `{"protocol":"ftp","host":"ftp.example.net","port":21,"user":"upload",` +
|
||||
`"pass":"letmein","remote":"/","connTimeout":10000,"pasvTimeout":10000}`
|
||||
res := runDeployModule(t, ftpconfig, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an atom remote-ftp finding")
|
||||
}
|
||||
if v := deployExtract(res, "remote_host"); v != "ftp.example.net" {
|
||||
t.Errorf("remote_host=%q, want ftp.example.net", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html login page carrying the same keys is not a leak", func(t *testing.T) {
|
||||
body := `<html><head><title>Sign in</title></head><body>` +
|
||||
`config keys "remotePath" "password" "host":"evil.example.com"</body></html>`
|
||||
if res := runDeployModule(t, vscode, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain json config without the tool keys is not a leak", func(t *testing.T) {
|
||||
body := `{"host":"db.internal","username":"admin","user":"admin","pass":"x","password":"hunter2"}`
|
||||
for _, file := range []string{vscode, sublime, ftpconfig} {
|
||||
if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a config without the tool keys should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a tool config with a host but no credential field is not a leak", func(t *testing.T) {
|
||||
bodies := map[string]string{
|
||||
vscode: `{"host":"h.example.com","remotePath":"/var/www","uploadOnSave":true}`,
|
||||
sublime: `{"type":"sftp","host":"h.example.com","upload_on_save":true}`,
|
||||
ftpconfig: `{"protocol":"ftp","host":"h.example.com","connTimeout":10000,"pasvTimeout":10000}`,
|
||||
}
|
||||
for file, body := range bodies {
|
||||
if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a config with no credential field 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{vscode, sublime, ftpconfig} {
|
||||
if res := runDeployModule(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,159 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runDistDBModule(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 distDBExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDistributedDBExposureModules(t *testing.T) {
|
||||
const riak = "../../modules/recon/riak-api-exposure.yaml"
|
||||
const couchbase = "../../modules/recon/couchbase-api-exposure.yaml"
|
||||
const druid = "../../modules/recon/druid-api-exposure.yaml"
|
||||
|
||||
riakStats := `{"riak_kv_version":"3.0.16","riak_core_version":"3.0.99","riak_pipe_version":"3.0.16",` +
|
||||
`"sys_otp_release":"22","ring_members":["riak@10.0.0.1"],"ring_num_partitions":64,` +
|
||||
`"storage_backend":"riak_kv_bitcask_backend"}`
|
||||
|
||||
couchbasePools := `{"pools":[{"name":"default","uri":"/pools/default?uuid=abc",` +
|
||||
`"streamingUri":"/poolsStreaming/default?uuid=abc"}],"isAdminCreds":false,"isEnterprise":true,` +
|
||||
`"implementationVersion":"7.2.0-6053-enterprise","uuid":"abc",` +
|
||||
`"componentsVersion":{"ns_server":"7.2.0-6053","couchdb":"3.1.1"}}`
|
||||
|
||||
druidStatus := `{"version":"0.22.1","modules":[{"name":"org.apache.druid.server.initialization.jetty.JettyServerModule",` +
|
||||
`"artifact":"druid-server","version":"0.22.1"},{"name":"org.apache.druid.guice.AnnouncerModule",` +
|
||||
`"artifact":"druid-server","version":"0.22.1"}],"memory":{"maxMemory":1037959168,` +
|
||||
`"totalMemory":1037959168,"freeMemory":900000000,"directMemory":134217728}}`
|
||||
|
||||
t.Run("an exposed riak http api is flagged and versioned", func(t *testing.T) {
|
||||
res := runDistDBModule(t, riak, 200, riakStats)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a riak finding")
|
||||
}
|
||||
if v := distDBExtract(res, "riak_version"); v != "3.0.16" {
|
||||
t.Errorf("riak_version=%q, want 3.0.16", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed couchbase cluster api is flagged and versioned", func(t *testing.T) {
|
||||
res := runDistDBModule(t, couchbase, 200, couchbasePools)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a couchbase finding")
|
||||
}
|
||||
if v := distDBExtract(res, "couchbase_version"); v != "7.2.0-6053-enterprise" {
|
||||
t.Errorf("couchbase_version=%q, want 7.2.0-6053-enterprise", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed druid process is flagged and versioned", func(t *testing.T) {
|
||||
res := runDistDBModule(t, druid, 200, druidStatus)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a druid finding")
|
||||
}
|
||||
if v := distDBExtract(res, "druid_version"); v != "0.22.1" {
|
||||
t.Errorf("druid_version=%q, want 0.22.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a riak kv version without a core version is not flagged", func(t *testing.T) {
|
||||
body := `{"riak_kv_version":"3.0.16","name":"app"}`
|
||||
if res := runDistDBModule(t, riak, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a kv version alone should not match riak, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a riak core version without a kv version is not flagged", func(t *testing.T) {
|
||||
body := `{"riak_core_version":"3.0.16","name":"app"}`
|
||||
if res := runDistDBModule(t, riak, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a core version alone should not match riak, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a couchbase impl version without a components version is not flagged", func(t *testing.T) {
|
||||
body := `{"implementationVersion":"7.2.0","name":"app"}`
|
||||
if res := runDistDBModule(t, couchbase, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an impl version alone should not match couchbase, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a couchbase components version without an impl version is not flagged", func(t *testing.T) {
|
||||
body := `{"componentsVersion":{"ns_server":"7.2.0"},"name":"app"}`
|
||||
if res := runDistDBModule(t, couchbase, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a components version alone should not match couchbase, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a druid package without a memory block is not flagged", func(t *testing.T) {
|
||||
body := `{"modules":[{"name":"org.apache.druid.cli.Main"}],"app":"x"}`
|
||||
if res := runDistDBModule(t, druid, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a druid package alone should not match druid, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a memory block without a druid package is not flagged", func(t *testing.T) {
|
||||
body := `{"memory":{"maxMemory":123},"app":"x"}`
|
||||
if res := runDistDBModule(t, druid, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a memory block alone should not match druid, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic version json is not a distributed db", func(t *testing.T) {
|
||||
body := `{"version":"1.0.0","name":"app"}`
|
||||
for _, file := range []string{riak, couchbase, druid} {
|
||||
if res := runDistDBModule(t, file, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{riak, couchbase, druid} {
|
||||
if res := runDistDBModule(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{riak, couchbase, druid} {
|
||||
if res := runDistDBModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -149,25 +149,52 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
|
||||
return requests
|
||||
}
|
||||
|
||||
// Generate requests with payloads
|
||||
// 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)
|
||||
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))
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
for _, path := range cfg.Paths {
|
||||
for _, payload := range cfg.Payloads {
|
||||
url := substituteVariables(path, target, payload)
|
||||
body := substituteVariables(cfg.Body, target, payload)
|
||||
requests = append(requests, &httpRequest{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Headers: cfg.Headers,
|
||||
Body: body,
|
||||
Payload: payload,
|
||||
Original: path,
|
||||
})
|
||||
requests = append(requests, newPayloadRequest(method, target, path, payload, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// newPayloadRequest builds one request with the path and body templates
|
||||
// substituted for the given payload.
|
||||
func newPayloadRequest(method, target, path, payload string, cfg *HTTPConfig) *httpRequest {
|
||||
return &httpRequest{
|
||||
Method: method,
|
||||
URL: substituteVariables(path, target, payload),
|
||||
Headers: cfg.Headers,
|
||||
Body: substituteVariables(cfg.Body, target, payload),
|
||||
Payload: payload,
|
||||
Original: path,
|
||||
}
|
||||
}
|
||||
|
||||
// validateAttack rejects an attack mode that is not "", "clusterbomb", or
|
||||
// "pitchfork"; an empty value defaults to clusterbomb.
|
||||
func validateAttack(attack string) error {
|
||||
switch strings.ToLower(attack) {
|
||||
case "", "clusterbomb", "pitchfork":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid attack %q (want \"clusterbomb\" or \"pitchfork\")", attack)
|
||||
}
|
||||
}
|
||||
|
||||
// substituteVariables replaces template variables in a string.
|
||||
func substituteVariables(template, baseURL, payload string) string {
|
||||
result := template
|
||||
@@ -266,6 +293,15 @@ func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
|
||||
case "regex":
|
||||
return checkRegex(part, m.Regex, m.Condition)
|
||||
|
||||
case "size":
|
||||
// size matches the response body length against any listed value.
|
||||
for _, n := range m.Size {
|
||||
if len(body) == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -356,9 +392,9 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
|
||||
result := make(map[string]string)
|
||||
|
||||
for _, e := range extractors {
|
||||
part := getPart(e.Part, resp, body)
|
||||
|
||||
if e.Type == "regex" {
|
||||
switch e.Type {
|
||||
case "regex":
|
||||
part := getPart(e.Part, resp, body)
|
||||
for _, pattern := range e.Regex {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
@@ -370,6 +406,16 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
|
||||
break
|
||||
}
|
||||
}
|
||||
case "kv":
|
||||
// kv records response header key/values, namespaced by the extractor
|
||||
// name when set (e.g. a headers module surfacing every header).
|
||||
for k, v := range resp.Header {
|
||||
key := k
|
||||
if e.Name != "" {
|
||||
key = e.Name + "." + k
|
||||
}
|
||||
result[key] = strings.Join(v, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,79 @@ func TestExecuteHTTPModulePayloadExpansion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModuleSizeMatcher pins the size matcher: it fires when the
|
||||
// response body length equals a listed value and stays silent otherwise.
|
||||
func TestExecuteHTTPModuleSizeMatcher(t *testing.T) {
|
||||
body := "1234567890" // 10 bytes
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
||||
mod := func(id string, size int) *YAMLModule {
|
||||
return &YAMLModule{
|
||||
ID: id, Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/"},
|
||||
Matchers: []Matcher{{Type: "size", Size: []int{size}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
hit, err := ExecuteHTTPModule(context.Background(), srv.URL, mod("size-hit", len(body)), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule(hit): %v", err)
|
||||
}
|
||||
if len(hit.Findings) != 1 {
|
||||
t.Fatalf("size match: got %d findings, want 1", len(hit.Findings))
|
||||
}
|
||||
|
||||
miss, err := ExecuteHTTPModule(context.Background(), srv.URL, mod("size-miss", len(body)+1), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule(miss): %v", err)
|
||||
}
|
||||
if len(miss.Findings) != 0 {
|
||||
t.Fatalf("size mismatch: got %d findings, want 0", len(miss.Findings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModuleKvExtractor pins the kv extractor: it records response
|
||||
// header key/values onto the finding, namespaced by the extractor name.
|
||||
func TestExecuteHTTPModuleKvExtractor(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Server", "nginx/1.25.3")
|
||||
w.Header().Set("X-Powered-By", "PHP/8.2.0")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "kv-mod", Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/"},
|
||||
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
|
||||
Extractors: []Extractor{{Type: "kv", Name: "headers", Part: "header"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule: %v", err)
|
||||
}
|
||||
if len(result.Findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1", len(result.Findings))
|
||||
}
|
||||
ex := result.Findings[0].Extracted
|
||||
if ex["headers.Server"] != "nginx/1.25.3" {
|
||||
t.Errorf("kv headers.Server = %q, want nginx/1.25.3", ex["headers.Server"])
|
||||
}
|
||||
if ex["headers.X-Powered-By"] != "PHP/8.2.0" {
|
||||
t.Errorf("kv headers.X-Powered-By = %q, want PHP/8.2.0", ex["headers.X-Powered-By"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHTTPModuleNoConfig(t *testing.T) {
|
||||
def := &YAMLModule{ID: "x", Type: TypeHTTP}
|
||||
if _, err := ExecuteHTTPModule(context.Background(), "http://h", def, Options{}); err == nil {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runHTTPDBModule(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 httpdbExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestHTTPDatabaseExposureModules(t *testing.T) {
|
||||
const influxdb = "../../modules/recon/influxdb-api-exposure.yaml"
|
||||
const arangodb = "../../modules/recon/arangodb-api-exposure.yaml"
|
||||
const neo4j = "../../modules/recon/neo4j-api-exposure.yaml"
|
||||
|
||||
influxHealth := `{"name":"influxdb","message":"ready for queries and writes","status":"pass",` +
|
||||
`"checks":[],"version":"2.9.1","commit":"a1b2c3d4"}`
|
||||
|
||||
arangoVersion := `{"server":"arango","version":"3.11.5","license":"community"}`
|
||||
|
||||
neo4jDiscovery := `{"bolt_routing":"neo4j://localhost:7687","transaction":"http://localhost:7474/db/{databaseName}/tx",` +
|
||||
`"bolt_direct":"bolt://localhost:7687","neo4j_version":"5.13.0","neo4j_edition":"community"}`
|
||||
|
||||
t.Run("an exposed influxdb health endpoint is flagged and versioned", func(t *testing.T) {
|
||||
res := runHTTPDBModule(t, influxdb, 200, influxHealth)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an influxdb finding")
|
||||
}
|
||||
if v := httpdbExtract(res, "influxdb_version"); v != "2.9.1" {
|
||||
t.Errorf("influxdb_version=%q, want 2.9.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an anonymous arangodb version endpoint is flagged and versioned", func(t *testing.T) {
|
||||
res := runHTTPDBModule(t, arangodb, 200, arangoVersion)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an arangodb finding")
|
||||
}
|
||||
if v := httpdbExtract(res, "arangodb_version"); v != "3.11.5" {
|
||||
t.Errorf("arangodb_version=%q, want 3.11.5", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed neo4j discovery endpoint is flagged and versioned", func(t *testing.T) {
|
||||
res := runHTTPDBModule(t, neo4j, 200, neo4jDiscovery)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a neo4j finding")
|
||||
}
|
||||
if v := httpdbExtract(res, "neo4j_version"); v != "5.13.0" {
|
||||
t.Errorf("neo4j_version=%q, want 5.13.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an influxdb name without the health message is not flagged", func(t *testing.T) {
|
||||
body := `{"name":"influxdb","status":"pass"}`
|
||||
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an influxdb name alone should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a health message without the influxdb name is not flagged", func(t *testing.T) {
|
||||
body := `{"name":"telegraf","message":"ready for queries and writes"}`
|
||||
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("the message alone should not match influxdb, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an arango without a license field is still flagged", func(t *testing.T) {
|
||||
body := `{"server":"arango","version":"3.11.5"}`
|
||||
res := runHTTPDBModule(t, arangodb, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an arangodb finding without a license field (pre-3.12)")
|
||||
}
|
||||
if v := httpdbExtract(res, "arangodb_version"); v != "3.11.5" {
|
||||
t.Errorf("arangodb_version=%q, want 3.11.5", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-arango version response is not flagged", func(t *testing.T) {
|
||||
body := `{"server":"foundationdb","version":"1.0.0"}`
|
||||
if res := runHTTPDBModule(t, arangodb, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a non-arango server should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an arango response without a version is not flagged", func(t *testing.T) {
|
||||
body := `{"server":"arango"}`
|
||||
if res := runHTTPDBModule(t, arangodb, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an arango without a version should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an arango that requires auth is not flagged", func(t *testing.T) {
|
||||
if res := runHTTPDBModule(t, arangodb, 401, arangoVersion); len(res.Findings) > 0 {
|
||||
t.Errorf("a 401 arango should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a neo4j version without an edition is not flagged", func(t *testing.T) {
|
||||
body := `{"neo4j_version":"5.13.0","transaction":"http://localhost:7474/db/neo4j/tx"}`
|
||||
if res := runHTTPDBModule(t, neo4j, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a neo4j version alone should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a neo4j edition without a version is not flagged", func(t *testing.T) {
|
||||
body := `{"neo4j_edition":"community","bolt_routing":"neo4j://localhost:7687"}`
|
||||
if res := runHTTPDBModule(t, neo4j, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a neo4j edition alone should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic health json is not influxdb", func(t *testing.T) {
|
||||
body := `{"status":"UP","components":{"db":{"status":"UP"}}}`
|
||||
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic health should not match influxdb, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{influxdb, arangodb, neo4j} {
|
||||
if res := runHTTPDBModule(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{influxdb, arangodb, neo4j} {
|
||||
if res := runHTTPDBModule(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,155 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runMgmtModule(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 mgmtExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestManagementAPIExposureModules(t *testing.T) {
|
||||
const kong = "../../modules/recon/kong-api-exposure.yaml"
|
||||
const jolokia = "../../modules/recon/jolokia-api-exposure.yaml"
|
||||
const nats = "../../modules/recon/nats-api-exposure.yaml"
|
||||
|
||||
kongRoot := `{"version":"3.4.0","tagline":"Welcome to kong","hostname":"kong-node","node_id":"abc",` +
|
||||
`"lua_version":"LuaJIT 2.1.0","plugins":{"available_on_server":{}},` +
|
||||
`"configuration":{"database":"postgres","admin_listen":["0.0.0.0:8001"]}}`
|
||||
|
||||
jolokiaVersion := `{"request":{"type":"version"},"value":{"agent":"1.7.2","protocol":"7.2",` +
|
||||
`"config":{"agentType":"servlet"},"info":{"product":"tomcat"}},"status":200,"timestamp":1694598949}`
|
||||
|
||||
natsVarz := `{"server_id":"NDABC","server_name":"NDABC","version":"2.10.1","proto":1,"go":"go1.21.1",` +
|
||||
`"host":"0.0.0.0","port":4222,"max_connections":65536,"max_payload":1048576,"connections":3,"total_connections":10}`
|
||||
|
||||
t.Run("an exposed kong admin api is flagged and versioned", func(t *testing.T) {
|
||||
res := runMgmtModule(t, kong, 200, kongRoot)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a kong finding")
|
||||
}
|
||||
if v := mgmtExtract(res, "kong_version"); v != "3.4.0" {
|
||||
t.Errorf("kong_version=%q, want 3.4.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed jolokia agent is flagged and versioned", func(t *testing.T) {
|
||||
res := runMgmtModule(t, jolokia, 200, jolokiaVersion)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a jolokia finding")
|
||||
}
|
||||
if v := mgmtExtract(res, "jolokia_agent_version"); v != "1.7.2" {
|
||||
t.Errorf("jolokia_agent_version=%q, want 1.7.2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed nats monitor is flagged and versioned", func(t *testing.T) {
|
||||
res := runMgmtModule(t, nats, 200, natsVarz)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a nats finding")
|
||||
}
|
||||
if v := mgmtExtract(res, "nats_version"); v != "2.10.1" {
|
||||
t.Errorf("nats_version=%q, want 2.10.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an available plugins map without an admin listen is not flagged", func(t *testing.T) {
|
||||
body := `{"plugins":{"available_on_server":{}},"version":"3.4.0"}`
|
||||
if res := runMgmtModule(t, kong, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an available plugins map alone should not match kong, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an admin listen without an available plugins map is not flagged", func(t *testing.T) {
|
||||
body := `{"configuration":{"admin_listen":["0.0.0.0:8001"]},"version":"1.0"}`
|
||||
if res := runMgmtModule(t, kong, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an admin listen alone should not match kong, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a jolokia agent without a protocol is not flagged", func(t *testing.T) {
|
||||
body := `{"value":{"agent":"1.7.2"}}`
|
||||
if res := runMgmtModule(t, jolokia, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an agent alone should not match jolokia, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a jolokia protocol without an agent is not flagged", func(t *testing.T) {
|
||||
body := `{"value":{"protocol":"7.2"},"info":{}}`
|
||||
if res := runMgmtModule(t, jolokia, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a protocol alone should not match jolokia, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a nats server id without a max payload is not flagged", func(t *testing.T) {
|
||||
body := `{"server_id":"NDABC","version":"2.10.1"}`
|
||||
if res := runMgmtModule(t, nats, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a server id alone should not match nats, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a max payload without a nats server id is not flagged", func(t *testing.T) {
|
||||
body := `{"max_payload":1048576,"port":4222}`
|
||||
if res := runMgmtModule(t, nats, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a max payload alone should not match nats, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic version json is not a management api", func(t *testing.T) {
|
||||
body := `{"version":"1.0.0","name":"app"}`
|
||||
for _, file := range []string{kong, jolokia, nats} {
|
||||
if res := runMgmtModule(t, file, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{kong, jolokia, nats} {
|
||||
if res := runMgmtModule(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{kong, jolokia, nats} {
|
||||
if res := runMgmtModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -339,9 +339,9 @@ func TestRunExtractors(t *testing.T) {
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "non-regex extractor type is ignored",
|
||||
name: "unknown extractor type is ignored",
|
||||
extractors: []Extractor{
|
||||
{Type: "kval", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
||||
{Type: "bogus", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
|
||||
@@ -91,6 +91,7 @@ type Matcher struct {
|
||||
Regex []string `yaml:"regex,omitempty"`
|
||||
Words []string `yaml:"words,omitempty"`
|
||||
Status []int `yaml:"status,omitempty"`
|
||||
Size []int `yaml:"size,omitempty"`
|
||||
Condition string `yaml:"condition"` // and, or
|
||||
Negative bool `yaml:"negative"`
|
||||
}
|
||||
@@ -98,7 +99,7 @@ type Matcher struct {
|
||||
// Extractor defines data extraction from responses.
|
||||
// Extractors pull specific data from matched responses for reporting.
|
||||
type Extractor struct {
|
||||
Type string `yaml:"type"` // regex, kval, json
|
||||
Type string `yaml:"type"` // regex, kv, json
|
||||
Name string `yaml:"name"`
|
||||
Part string `yaml:"part"`
|
||||
Regex []string `yaml:"regex,omitempty"`
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runOrchModule(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 orchExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestOrchestrationAPIExposureModules(t *testing.T) {
|
||||
const vault = "../../modules/recon/vault-api-exposure.yaml"
|
||||
const consul = "../../modules/recon/consul-api-exposure.yaml"
|
||||
const etcd = "../../modules/recon/etcd-api-exposure.yaml"
|
||||
|
||||
vaultSeal := `{"type":"shamir","initialized":true,"sealed":false,"t":3,"n":5,` +
|
||||
`"progress":0,"nonce":"","version":"1.15.2","build_date":"2023-11-06T11:33:49Z",` +
|
||||
`"migration":false,"cluster_name":"vault-cluster-9d52b1f1","recovery_seal":false,` +
|
||||
`"storage_type":"raft"}`
|
||||
|
||||
consulSelf := `{"Config":{"Datacenter":"dc1","NodeName":"consul-server-1","Server":true,` +
|
||||
`"Version":"1.17.0"},"Member":{"Name":"consul-server-1","Addr":"10.0.0.5","Port":8301}}`
|
||||
|
||||
etcdVersion := `{"etcdserver":"3.5.9","etcdcluster":"3.5.0"}`
|
||||
|
||||
t.Run("an exposed vault seal-status is flagged and versioned", func(t *testing.T) {
|
||||
res := runOrchModule(t, vault, 200, vaultSeal)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a vault finding")
|
||||
}
|
||||
if v := orchExtract(res, "vault_version"); v != "1.15.2" {
|
||||
t.Errorf("vault_version=%q, want 1.15.2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed consul agent self leaks the datacenter", func(t *testing.T) {
|
||||
res := runOrchModule(t, consul, 200, consulSelf)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a consul finding")
|
||||
}
|
||||
if v := orchExtract(res, "consul_datacenter"); v != "dc1" {
|
||||
t.Errorf("consul_datacenter=%q, want dc1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed etcd version endpoint is flagged and versioned", func(t *testing.T) {
|
||||
res := runOrchModule(t, etcd, 200, etcdVersion)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an etcd finding")
|
||||
}
|
||||
if v := orchExtract(res, "etcd_version"); v != "3.5.9" {
|
||||
t.Errorf("etcd_version=%q, want 3.5.9", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a sealed flag without the other vault keys is not vault", func(t *testing.T) {
|
||||
body := `{"sealed":"yes","status":"ok"}`
|
||||
if res := runOrchModule(t, vault, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare sealed flag should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a datacenter field alone is not consul", func(t *testing.T) {
|
||||
body := `{"Datacenter":"dc1"}`
|
||||
if res := runOrchModule(t, consul, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare datacenter field should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a version response from another service is not etcd", func(t *testing.T) {
|
||||
body := `{"version":"1.2.3","service":"myapp"}`
|
||||
if res := runOrchModule(t, etcd, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("another service version should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an etcdserver without an etcdcluster is not flagged", func(t *testing.T) {
|
||||
body := `{"etcdserver":"3.5.9"}`
|
||||
if res := runOrchModule(t, etcd, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a partial etcd response 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{vault, consul, etcd} {
|
||||
if res := runOrchModule(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{vault, consul, etcd} {
|
||||
if res := runOrchModule(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,131 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runRailsModule(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 railsExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestRailsSecretExposureModules(t *testing.T) {
|
||||
const database = "../../modules/recon/rails-database-yml-exposure.yaml"
|
||||
const secrets = "../../modules/recon/rails-secrets-yml-exposure.yaml"
|
||||
const masterKey = "../../modules/recon/rails-master-key-exposure.yaml"
|
||||
|
||||
const keyBase = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
const masterKeyValue = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
t.Run("database config leaks the database name and credentials", func(t *testing.T) {
|
||||
body := "default: &default\n adapter: postgresql\n encoding: unicode\n pool: 5\n" +
|
||||
" username: app_user\n password: s3cr3tdbpass\n host: db.internal\n\n" +
|
||||
"production:\n <<: *default\n database: myapp_production\n"
|
||||
res := runRailsModule(t, database, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a database config finding")
|
||||
}
|
||||
if v := railsExtract(res, "database"); v != "myapp_production" {
|
||||
t.Errorf("database=%q, want myapp_production", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a credential free sqlite database config is not a leak", func(t *testing.T) {
|
||||
body := "production:\n adapter: sqlite3\n database: db/production.sqlite3\n pool: 5\n"
|
||||
if res := runRailsModule(t, database, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a sqlite config without credentials should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("secrets config leaks the secret key base", func(t *testing.T) {
|
||||
body := "development:\n secret_key_base: " + keyBase + "\n"
|
||||
res := runRailsModule(t, secrets, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a secrets config finding")
|
||||
}
|
||||
if v := railsExtract(res, "secret_key_base"); v != keyBase {
|
||||
t.Errorf("secret_key_base=%q, want %q", v, keyBase)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("master key file leaks the key", func(t *testing.T) {
|
||||
res := runRailsModule(t, masterKey, 200, masterKeyValue)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a master key finding")
|
||||
}
|
||||
if v := railsExtract(res, "master_key"); v != masterKeyValue {
|
||||
t.Errorf("master_key=%q, want %q", v, masterKeyValue)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a longer hex digest is not the master key", func(t *testing.T) {
|
||||
body := masterKeyValue + masterKeyValue
|
||||
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a 64 char digest should not match the 32 char key, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a hex value not at the body start is not the master key", func(t *testing.T) {
|
||||
body := "key=" + masterKeyValue
|
||||
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a hex value away from the start should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page naming the rails markers is not a leak", func(t *testing.T) {
|
||||
body := "<html><head><title>Error</title></head><body>secret_key_base: " + keyBase + "</body></html>"
|
||||
if res := runRailsModule(t, secrets, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a config without the rails markers is not a leak", func(t *testing.T) {
|
||||
body := "password: hunter2\nusername: admin\nhost: db.internal\n"
|
||||
for _, file := range []string{database, secrets, masterKey} {
|
||||
if res := runRailsModule(t, file, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a config without the rails markers 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{database, secrets, masterKey} {
|
||||
if res := runRailsModule(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,162 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runSecretModule(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 secretExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestSecretFileExposureModules(t *testing.T) {
|
||||
const privkey = "../../modules/recon/private-key-exposure.yaml"
|
||||
const gitcred = "../../modules/recon/git-credentials-exposure.yaml"
|
||||
const pypirc = "../../modules/recon/pypirc-exposure.yaml"
|
||||
|
||||
opensshKey := "-----BEGIN OPENSSH PRIVATE KEY-----\n" +
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQy\n" +
|
||||
"NTUxOQAAACD1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" +
|
||||
"-----END OPENSSH PRIVATE KEY-----\n"
|
||||
|
||||
rsaKey := "-----BEGIN RSA PRIVATE KEY-----\n" +
|
||||
"MIIEpAIBAAKCAQEArandombase64payloadthatstandsinforakeybodyhere1234567890\n" +
|
||||
"-----END RSA PRIVATE KEY-----\n"
|
||||
|
||||
gitCreds := "https://octocat:ghp_AbCdEf0123456789AbCdEf0123456789@github.com\n" +
|
||||
"https://deploy:s3cr3t@gitlab.example.com\n"
|
||||
|
||||
pypiConfig := "[distutils]\nindex-servers =\n pypi\n\n[pypi]\n" +
|
||||
"username = __token__\npassword = pypi-AgEIcHlwaS5vcmcCJDQ2Y2Q\n"
|
||||
|
||||
t.Run("an openssh private key is flagged and typed", func(t *testing.T) {
|
||||
res := runSecretModule(t, privkey, 200, opensshKey)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a private key finding")
|
||||
}
|
||||
if v := secretExtract(res, "key_type"); v != "OPENSSH" {
|
||||
t.Errorf("key_type=%q, want OPENSSH", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an rsa private key is flagged and typed", func(t *testing.T) {
|
||||
res := runSecretModule(t, privkey, 200, rsaKey)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a private key finding")
|
||||
}
|
||||
if v := secretExtract(res, "key_type"); v != "RSA" {
|
||||
t.Errorf("key_type=%q, want RSA", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a git credential store leaks its host", func(t *testing.T) {
|
||||
res := runSecretModule(t, gitcred, 200, gitCreds)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a git credential finding")
|
||||
}
|
||||
if v := secretExtract(res, "git_host"); v != "github.com" {
|
||||
t.Errorf("git_host=%q, want github.com", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a pypirc leaks the upload token", func(t *testing.T) {
|
||||
res := runSecretModule(t, pypirc, 200, pypiConfig)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a pypirc finding")
|
||||
}
|
||||
if v := secretExtract(res, "pypi_token"); v != "pypi-AgEIcHlwaS5vcmcCJDQ2Y2Q" {
|
||||
t.Errorf("pypi_token=%q, want the pypi- token", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a public key is not a private key", func(t *testing.T) {
|
||||
body := "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK\n" +
|
||||
"-----END PUBLIC KEY-----\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB user@host\n"
|
||||
if res := runSecretModule(t, privkey, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a public key should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prose that names a private key is not the key", func(t *testing.T) {
|
||||
body := "Generate your private key with ssh-keygen and keep id_rsa secret."
|
||||
if res := runSecretModule(t, privkey, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("prose about keys should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a git remote url without a password is not a credential store", func(t *testing.T) {
|
||||
body := "https://github.com/octocat/hello-world.git\n"
|
||||
if res := runSecretModule(t, gitcred, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare remote url should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a pypi section without a credential is not a leak", func(t *testing.T) {
|
||||
body := "[distutils]\nindex-servers =\n pypi\n"
|
||||
if res := runSecretModule(t, pypirc, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a section with no credential should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credentials shown in an html page are not a store", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body>clone with https://user:pass@host.example</body></html>"
|
||||
if res := runSecretModule(t, gitcred, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a pypi config inside an html page is not a leak", func(t *testing.T) {
|
||||
body := "<html><head><title>docs</title></head><body><pre>[pypi]\npassword = pypi-x</pre></body></html>"
|
||||
if res := runSecretModule(t, pypirc, 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{privkey, gitcred, pypirc} {
|
||||
if res := runSecretModule(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{privkey, gitcred, pypirc} {
|
||||
if res := runSecretModule(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,143 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runVectorDBModule(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 vectorDBExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestVectorDBExposureModules(t *testing.T) {
|
||||
const qdrant = "../../modules/recon/qdrant-api-exposure.yaml"
|
||||
const weaviate = "../../modules/recon/weaviate-api-exposure.yaml"
|
||||
const chroma = "../../modules/recon/chroma-api-exposure.yaml"
|
||||
|
||||
qdrantCollections := `{"result":{"collections":[{"name":"documents"},{"name":"embeddings"}]},` +
|
||||
`"status":"ok","time":0.000018}`
|
||||
|
||||
weaviateMeta := `{"hostname":"http://[::]:8080","modules":{"text2vec-openai":{"version":"v1.0.0"}},` +
|
||||
`"version":"1.23.7"}`
|
||||
|
||||
chromaHeartbeat := `{"nanosecond heartbeat":1718900000000000000}`
|
||||
|
||||
t.Run("a qdrant collections api is flagged and named", func(t *testing.T) {
|
||||
res := runVectorDBModule(t, qdrant, 200, qdrantCollections)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a qdrant finding")
|
||||
}
|
||||
if v := vectorDBExtract(res, "qdrant_collection"); v != "documents" {
|
||||
t.Errorf("qdrant_collection=%q, want documents", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a weaviate meta api is flagged with its hostname", func(t *testing.T) {
|
||||
res := runVectorDBModule(t, weaviate, 200, weaviateMeta)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a weaviate finding")
|
||||
}
|
||||
if v := vectorDBExtract(res, "weaviate_hostname"); v != "http://[::]:8080" {
|
||||
t.Errorf("weaviate_hostname=%q, want http://[::]:8080", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a chroma heartbeat api is flagged", func(t *testing.T) {
|
||||
res := runVectorDBModule(t, chroma, 200, chromaHeartbeat)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a chroma finding")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a qdrant status without a collections result is not flagged", func(t *testing.T) {
|
||||
body := `{"result":{"points":[{"id":1}]},"status":"ok","time":0.001}`
|
||||
if res := runVectorDBModule(t, qdrant, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a points result should not match qdrant, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a qdrant collections result without an ok status is not flagged", func(t *testing.T) {
|
||||
body := `{"result":{"collections":[{"name":"x"}]}}`
|
||||
if res := runVectorDBModule(t, qdrant, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a collections result without ok status should not match qdrant, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a weaviate meta without a version is not flagged", func(t *testing.T) {
|
||||
body := `{"hostname":"http://x:8080","modules":{"a":{}}}`
|
||||
if res := runVectorDBModule(t, weaviate, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a meta without a version should not match weaviate, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a weaviate hostname that is not a url is not flagged", func(t *testing.T) {
|
||||
body := `{"hostname":"db-internal","version":"1.23.7"}`
|
||||
if res := runVectorDBModule(t, weaviate, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare hostname should not match weaviate, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a chroma 200 without the heartbeat key is not flagged", func(t *testing.T) {
|
||||
body := `{"heartbeat":1718900000}`
|
||||
if res := runVectorDBModule(t, chroma, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a plain heartbeat key should not match chroma, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic version json is not a vector db", func(t *testing.T) {
|
||||
body := `{"version":"1.0.0","name":"app"}`
|
||||
for _, file := range []string{qdrant, weaviate, chroma} {
|
||||
if res := runVectorDBModule(t, file, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{qdrant, weaviate, chroma} {
|
||||
if res := runVectorDBModule(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{qdrant, weaviate, chroma} {
|
||||
if res := runVectorDBModule(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,136 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runWebSrvModule(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 webSrvExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestWebserverConfigExposureModules(t *testing.T) {
|
||||
const htpasswd = "../../modules/recon/htpasswd-exposure.yaml"
|
||||
const webconfig = "../../modules/recon/webconfig-exposure.yaml"
|
||||
const htaccess = "../../modules/recon/htaccess-exposure.yaml"
|
||||
|
||||
t.Run("htpasswd leaks the user and an apache md5 hash", func(t *testing.T) {
|
||||
body := "admin:$apr1$z9c.x1pq$Q8r6Jm0pYh0pX2yq4nN3l1\nbackup:$apr1$ab$cd\n"
|
||||
res := runWebSrvModule(t, htpasswd, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an htpasswd finding")
|
||||
}
|
||||
if v := webSrvExtract(res, "htpasswd_user"); v != "admin" {
|
||||
t.Errorf("htpasswd_user=%q, want admin", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("htpasswd with a bcrypt hash also matches", func(t *testing.T) {
|
||||
body := "deploy:$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZ\n"
|
||||
if res := runWebSrvModule(t, htpasswd, 200, body); len(res.Findings) == 0 {
|
||||
t.Fatal("expected an htpasswd finding for a bcrypt hash")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("web.config leaks a connection string", func(t *testing.T) {
|
||||
body := `<?xml version="1.0"?><configuration><connectionStrings>` +
|
||||
`<add name="Default" connectionString="Server=db;Database=app;User Id=sa;Password=p@ss;" ` +
|
||||
`providerName="System.Data.SqlClient" /></connectionStrings></configuration>`
|
||||
res := runWebSrvModule(t, webconfig, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a web.config finding")
|
||||
}
|
||||
want := "Server=db;Database=app;User Id=sa;Password=p@ss;"
|
||||
if v := webSrvExtract(res, "connection_string"); v != want {
|
||||
t.Errorf("connection_string=%q, want %q", v, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("htaccess leaks the password file path", func(t *testing.T) {
|
||||
body := "RewriteEngine On\nAuthType Basic\nAuthName \"Restricted\"\n" +
|
||||
"AuthUserFile /var/www/.htpasswd\nRequire valid-user\n"
|
||||
res := runWebSrvModule(t, htaccess, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an htaccess finding")
|
||||
}
|
||||
if v := webSrvExtract(res, "auth_user_file"); v != "/var/www/.htpasswd" {
|
||||
t.Errorf("auth_user_file=%q, want /var/www/.htpasswd", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a minimal htaccess with only access control still flags", func(t *testing.T) {
|
||||
body := "Options -Indexes\nDeny from all\n"
|
||||
if res := runWebSrvModule(t, htaccess, 200, body); len(res.Findings) == 0 {
|
||||
t.Fatal("expected a finding for a deny-from-all htaccess")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plaintext password line is not a hash", func(t *testing.T) {
|
||||
body := "admin:notahashedpassword\n"
|
||||
if res := runWebSrvModule(t, htpasswd, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a plaintext line should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a configuration element without a dotnet section is not a leak", func(t *testing.T) {
|
||||
body := `<?xml version="1.0"?><configuration><customRoot><foo/></customRoot></configuration>`
|
||||
if res := runWebSrvModule(t, webconfig, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a non dotnet configuration should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page is not an htaccess", func(t *testing.T) {
|
||||
body := "<html><head><title>x</title></head><body>RewriteEngine On AuthType Basic</body></html>"
|
||||
if res := runWebSrvModule(t, htaccess, 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{htpasswd, webconfig, htaccess} {
|
||||
if res := runWebSrvModule(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{htpasswd, webconfig, htaccess} {
|
||||
if res := runWebSrvModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -58,7 +58,7 @@ type HTTPConfig struct {
|
||||
Payloads []string `yaml:"payloads,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Attack string `yaml:"attack,omitempty"` // sniper, pitchfork, clusterbomb
|
||||
Attack string `yaml:"attack,omitempty"` // clusterbomb (default), pitchfork
|
||||
Threads int `yaml:"threads,omitempty"`
|
||||
Matchers []Matcher `yaml:"matchers"`
|
||||
Extractors []Extractor `yaml:"extractors,omitempty"`
|
||||
@@ -100,6 +100,12 @@ func ParseYAMLModule(path string) (*YAMLModule, error) {
|
||||
return nil, fmt.Errorf("module missing required field: type")
|
||||
}
|
||||
|
||||
if ym.HTTP != nil {
|
||||
if err := validateAttack(ym.HTTP.Attack); err != nil {
|
||||
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &ym, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
|
||||
}
|
||||
|
||||
// Joomla
|
||||
if strings.Contains(bodyString, "joomla") || strings.Contains(bodyString, "/media/system/js/core.js") {
|
||||
if detectJoomla(bodyString) {
|
||||
spin.Stop()
|
||||
result := &CMSResult{Name: "Joomla", Version: "Unknown"}
|
||||
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
|
||||
@@ -141,3 +141,11 @@ func detectWordPress(url string, client *http.Client, bodyString string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// detectJoomla keys on the capital Joomla! generator and joomla asset paths. a
|
||||
// bare "joomla" mention (the old check) matched marketing pages, so it is gone.
|
||||
func detectJoomla(body string) bool {
|
||||
return strings.Contains(body, `generator" content="Joomla!`) ||
|
||||
strings.Contains(body, "/media/vendor/joomla") ||
|
||||
strings.Contains(body, "/media/system/js/core.js")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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"
|
||||
)
|
||||
|
||||
// a bare "joomla" mention must not match; only the real signals do.
|
||||
func TestDetectJoomla_Signals(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"generator", `<meta name="generator" content="Joomla! - Open Source Content Management" />`, true},
|
||||
{"vendor asset path", `<script src="/media/vendor/joomla-custom-elements/js/joomla-alert.min.js"></script>`, true},
|
||||
{"core.js path", `<script src="/media/system/js/core.js"></script>`, true},
|
||||
{"bare mention", "we offer managed joomla hosting", false},
|
||||
{"capital prose", "migrating from Joomla to something else", false},
|
||||
{"tagline prose", "the Joomla! - Open Source Content Management project", false},
|
||||
{"plain", "<html><body>hello</body></html>", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := detectJoomla(c.body); got != c.want {
|
||||
t.Errorf("%s: detectJoomla = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// joomlaServer serves homeBody at / and 404s elsewhere, so the wordpress probe
|
||||
// cannot claim the host before the Joomla check.
|
||||
func joomlaServer(t *testing.T, homeBody string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(homeBody))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// the capital-J Joomla! generator was missed by the old lowercase check.
|
||||
func TestCMS_JoomlaGeneratorDetected(t *testing.T) {
|
||||
srv := joomlaServer(t, `<meta name="generator" content="Joomla! - Open Source Content Management" />`)
|
||||
result, err := CMS(srv.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CMS: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "Joomla" {
|
||||
t.Errorf("Joomla generator not detected, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCMS_JoomlaBareMentionNotFlagged(t *testing.T) {
|
||||
srv := joomlaServer(t, "<html><body>we offer managed joomla hosting</body></html>")
|
||||
result, err := CMS(srv.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CMS: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Joomla" {
|
||||
t.Error("a page merely mentioning joomla was flagged as Joomla")
|
||||
}
|
||||
}
|
||||
@@ -424,6 +424,113 @@ func TestDetectFramework_Joomla(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_AdonisJS(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Set-Cookie", "adonis-session=s%3Aabc.def; Path=/; HttpOnly")
|
||||
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 {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Name != "AdonisJS" {
|
||||
t.Errorf("expected framework 'AdonisJS', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Adonis Cosmetics</title>
|
||||
<link rel="stylesheet" href="/assets/adonis-theme.css">
|
||||
</head>
|
||||
<body class="adonis-store">
|
||||
<h1>Adonis Cosmetics</h1>
|
||||
<a href="/adonis/collections">Shop the adonis collection</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 == "AdonisJS" {
|
||||
t.Errorf("false positive: plain page mentioning 'Adonis' detected as AdonisJS (%.2f)", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Phoenix(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>Phoenix App</title></head>
|
||||
<body>
|
||||
<div data-phx-main data-phx-session="abc" data-phx-static="def" id="phx-F1a2B3">
|
||||
<span>Content</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Name != "Phoenix" {
|
||||
t.Errorf("expected framework 'Phoenix', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Phoenix AZ Roofing</title></head>
|
||||
<body class="phx-page">
|
||||
<nav class="phx-nav"><a href="/">Phoenix Home</a></nav>
|
||||
<section class="phx-hero">Serving Phoenix, Arizona since 1998.</section>
|
||||
</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 == "Phoenix" {
|
||||
t.Errorf("false positive: phx- CSS class page detected as Phoenix (%.2f)", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Astro(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -461,6 +568,85 @@ func TestDetectFramework_Astro(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Ghost(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="generator" content="Ghost 6.46">
|
||||
</head>
|
||||
<body>Content</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 != "Ghost" {
|
||||
t.Errorf("expected framework 'Ghost', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<a class="ghost-button" href="/signup">Sign up</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 == "Ghost" {
|
||||
t.Errorf("expected no Ghost detection for a ghost-button page, got confidence %.2f", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script src="/ghost/api/content/posts/?key=abc"></script>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Name != "Ghost" {
|
||||
t.Errorf("expected framework 'Ghost', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractVersion_Astro(t *testing.T) {
|
||||
tests := []struct {
|
||||
body string
|
||||
|
||||
@@ -375,9 +375,9 @@ func (d *phoenixDetector) Name() string { return "Phoenix" }
|
||||
|
||||
func (d *phoenixDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "_csrf_token", Weight: 0.4, HeaderOnly: true},
|
||||
{Pattern: "phx-", Weight: 0.3},
|
||||
{Pattern: "phoenix", Weight: 0.2},
|
||||
{Pattern: "data-phx-main", Weight: 0.4},
|
||||
{Pattern: "data-phx-session", Weight: 0.3},
|
||||
{Pattern: "data-phx-static", Weight: 0.3},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,8 +424,7 @@ func (d *adonisDetector) Name() string { return "AdonisJS" }
|
||||
|
||||
func (d *adonisDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "adonis", Weight: 0.4},
|
||||
{Pattern: "_csrf", Weight: 0.2, HeaderOnly: true},
|
||||
{Pattern: "adonis-session", Weight: 0.4, HeaderOnly: true},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ func (d *ghostDetector) Name() string { return "Ghost" }
|
||||
|
||||
func (d *ghostDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "ghost-", Weight: 0.4},
|
||||
{Pattern: `<meta name="generator" content="Ghost`, Weight: 0.4},
|
||||
{Pattern: "Ghost", Weight: 0.3, HeaderOnly: true},
|
||||
{Pattern: "/ghost/api/", Weight: 0.4},
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -234,7 +235,9 @@ func LFI(targetURL string, timeout time.Duration, threads int, logdir string) (*
|
||||
|
||||
// check for evidence patterns
|
||||
for _, evidence := range lfiEvidencePatterns {
|
||||
if evidence.pattern.MatchString(bodyStr) {
|
||||
match := evidence.pattern.FindString(bodyStr)
|
||||
// our own payload echoed back isn't proof of inclusion
|
||||
if match != "" && !strings.Contains(item.payload.payload, match) {
|
||||
key := item.param + "|" + item.payload.payload
|
||||
mu.Lock()
|
||||
if seen[key] {
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
package scan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDetectLFIFromResponse_EtcPasswd(t *testing.T) {
|
||||
@@ -314,3 +317,51 @@ func TestLFI_MockServer(t *testing.T) {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLFI_ReflectedPayloadIsNotEvidence(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, vs := range r.URL.Query() {
|
||||
for _, v := range vs {
|
||||
fmt.Fprintf(w, "Warning: include(%s): failed to open stream\n", v)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := LFI(srv.URL, 5*time.Second, 4, "")
|
||||
if err != nil {
|
||||
t.Fatalf("LFI: %v", err)
|
||||
}
|
||||
if result != nil && len(result.Vulnerabilities) > 0 {
|
||||
t.Errorf("reflected payload should not be flagged as LFI, got %+v", result.Vulnerabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLFI_GenuineBase64PHPStillDetected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Query().Get("file"), "convert.base64-encode") {
|
||||
// base64 php source carrying the PD9waHA marker
|
||||
_, _ = w.Write([]byte("PD9waHAgZWNobyAnc2VjcmV0Jzs="))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("<html>nothing to see</html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := LFI(srv.URL, 5*time.Second, 4, "")
|
||||
if err != nil {
|
||||
t.Fatalf("LFI: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("genuine base64-encoded php disclosure should still be detected")
|
||||
}
|
||||
var sawFilterHit bool
|
||||
for _, v := range result.Vulnerabilities {
|
||||
if v.Evidence == "base64 encoded PHP" && strings.Contains(v.Payload, "convert.base64-encode") {
|
||||
sawFilterHit = true
|
||||
}
|
||||
}
|
||||
if !sawFilterHit {
|
||||
t.Errorf("expected a base64-php disclosure via the convert.base64-encode filter, got %+v", result.Vulnerabilities)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func fetchCrtsh(ctx context.Context, client *http.Client, domain string) ([]stri
|
||||
for i := 0; i < len(entries); i++ {
|
||||
// name_value can pack several names separated by newlines.
|
||||
for _, name := range strings.Split(entries[i].NameValue, "\n") {
|
||||
if host := normalizeHost(name); host != "" {
|
||||
if host := normalizeHost(name); host != "" && inDomain(host, domain) {
|
||||
names = append(names, host)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func fetchCertspotter(ctx context.Context, client *http.Client, domain string) (
|
||||
var names []string
|
||||
for i := 0; i < len(entries); i++ {
|
||||
for _, name := range entries[i].DNSNames {
|
||||
if host := normalizeHost(name); host != "" {
|
||||
if host := normalizeHost(name); host != "" && inDomain(host, domain) {
|
||||
names = append(names, host)
|
||||
}
|
||||
}
|
||||
@@ -224,14 +224,21 @@ func passiveGET(ctx context.Context, client *http.Client, reqURL string) ([]byte
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// normalizeHost lowercases a name and strips a leading wildcard label so
|
||||
// "*.example.com" and "EXAMPLE.com" collapse to one canonical host.
|
||||
// normalizeHost lowercases a name and strips a leading wildcard label and a
|
||||
// trailing root dot so "*.example.com" and "example.com." collapse to one host.
|
||||
func normalizeHost(name string) string {
|
||||
host := strings.ToLower(strings.TrimSpace(name))
|
||||
host = strings.TrimPrefix(host, "*.")
|
||||
host = strings.TrimSuffix(host, ".")
|
||||
return host
|
||||
}
|
||||
|
||||
// inDomain rejects off-scope names a shared/multi-SAN cert happens to list.
|
||||
func inDomain(host, domain string) bool {
|
||||
domain = strings.ToLower(domain)
|
||||
return host == domain || strings.HasSuffix(host, "."+domain)
|
||||
}
|
||||
|
||||
// addAll inserts every value into the dedupe set.
|
||||
func addAll(set map[string]struct{}, values []string) {
|
||||
for _, v := range values {
|
||||
|
||||
@@ -145,6 +145,29 @@ func TestPassive_ResultType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassive_ScopesSubdomainsToTarget(t *testing.T) {
|
||||
// notexample.com guards the suffix-match trap: not a subdomain of example.com.
|
||||
const sharedCert = `[
|
||||
{"name_value": "www.example.com\nshared.othersite.com"},
|
||||
{"name_value": "notexample.com\n*.example.com"}
|
||||
]`
|
||||
fixtureServer(t, sharedCert, "[]", "")
|
||||
|
||||
result, err := Passive("https://example.com", 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Passive: %v", err)
|
||||
}
|
||||
|
||||
for _, off := range []string{"shared.othersite.com", "notexample.com"} {
|
||||
if urlsContain(result.Subdomains, off) {
|
||||
t.Errorf("off-scope name %q leaked as a subdomain: %v", off, result.Subdomains)
|
||||
}
|
||||
}
|
||||
if !urlsContain(result.Subdomains, "www.example.com") {
|
||||
t.Errorf("expected the in-scope subdomain to remain: %v", result.Subdomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
@@ -152,6 +175,7 @@ func TestNormalizeHost(t *testing.T) {
|
||||
}{
|
||||
{"www.example.com", "www.example.com"},
|
||||
{"*.example.com", "example.com"},
|
||||
{"www.example.com.", "www.example.com"},
|
||||
{" WWW.Example.COM ", "www.example.com"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ var metaRefreshRe = regexp.MustCompile(`(?i)<meta[^>]+http-equiv=["']?refresh["'
|
||||
// client-side redirects baked into a script body
|
||||
var jsRedirectRe = regexp.MustCompile(`(?i)(?:location\.(?:href|replace|assign)\s*(?:=|\()|window\.location\s*=)\s*["']([^"']+)["']`)
|
||||
|
||||
// a leading http(s) scheme and its authority slashes, however few.
|
||||
var schemeSlashesRe = regexp.MustCompile(`(?i)^(https?):/*`)
|
||||
|
||||
// Redirect probes the target's redirect-prone params for open-redirect.
|
||||
func Redirect(targetURL string, timeout time.Duration, threads int, logdir string) (*RedirectResult, error) {
|
||||
log := output.Module("REDIRECT")
|
||||
@@ -274,6 +277,9 @@ func pointsAtSentinel(location string) bool {
|
||||
// browsers treat backslashes in the authority as forward slashes
|
||||
normalized := strings.ReplaceAll(location, "\\", "/")
|
||||
|
||||
// "https:/host" still navigates off-site; normalise the slashes so it parses.
|
||||
normalized = schemeSlashesRe.ReplaceAllString(normalized, "$1://")
|
||||
|
||||
parsed, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
// unparseable but still naming the sentinel as the leading authority is a hit
|
||||
|
||||
@@ -140,8 +140,16 @@ func TestPointsAtSentinel(t *testing.T) {
|
||||
{"scheme-relative", "//" + redirectSentinel, true},
|
||||
{"backslash trick", "/\\" + redirectSentinel, true},
|
||||
{"with port", "https://" + redirectSentinel + ":443/", true},
|
||||
{"scheme missing slash", "https:/" + redirectSentinel, true},
|
||||
{"scheme no slashes", "https:" + redirectSentinel, true},
|
||||
{"scheme backslash authority", "https:\\\\" + redirectSentinel, true},
|
||||
{"scheme extra slashes", "https:///" + redirectSentinel, true},
|
||||
{"uppercase scheme", "HTTPS:/" + redirectSentinel, true},
|
||||
{"userinfo confusion", "https://x.com@" + redirectSentinel, true},
|
||||
{"empty", "", false},
|
||||
{"same-site path", "/dashboard", false},
|
||||
{"same-site path with colon", "/go:" + redirectSentinel, false},
|
||||
{"opaque scheme not off-site", "mailto:" + redirectSentinel, false},
|
||||
{"sentinel only in path", "https://safe.example.com/" + redirectSentinel, false},
|
||||
{"sentinel only in query", "https://safe.example.com/?to=" + redirectSentinel, false},
|
||||
}
|
||||
|
||||
@@ -151,7 +151,9 @@ func gradeSecurityHeaders(header http.Header, https bool) SecurityHeaderResults
|
||||
func hstsMaxAge(value string) int {
|
||||
for _, part := range strings.Split(value, ";") {
|
||||
if age, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(part)), "max-age="); ok {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(age))
|
||||
// rfc 6797 allows a quoted-string value
|
||||
age = strings.Trim(strings.TrimSpace(age), `"`)
|
||||
n, err := strconv.Atoi(age)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -110,6 +110,28 @@ func TestGradeSecurityHeaders_WeakHSTS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGradeSecurityHeaders_QuotedHSTS(t *testing.T) {
|
||||
// rfc 6797 allows a quoted value; strip the quotes before grading
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
flagged bool
|
||||
}{
|
||||
{"quoted strong", `max-age="63072000"; includeSubDomains`, false},
|
||||
{"quoted weak still flagged", `max-age="0"`, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := buildHeader(map[string]string{"Strict-Transport-Security": tt.value})
|
||||
_, flagged := findFinding(gradeSecurityHeaders(h, true), "Strict-Transport-Security")
|
||||
if flagged != tt.flagged {
|
||||
t.Errorf("value %q flagged=%v, want %v", tt.value, flagged, tt.flagged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGradeSecurityHeaders_Disclosure(t *testing.T) {
|
||||
h := buildHeader(map[string]string{
|
||||
"Server": "Apache/2.4.1 (Ubuntu)",
|
||||
|
||||
@@ -280,10 +280,10 @@ func isAdminPanel(body string, panelType string) bool {
|
||||
case "phpRedisAdmin":
|
||||
return strings.Contains(bodyLower, "phpredisadmin")
|
||||
default:
|
||||
// for generic database interfaces, check for common keywords
|
||||
// generic db paths have no product marker, so match db keywords. "query"
|
||||
// is dropped: it is a substring of jQuery/querySelector (on every js page).
|
||||
return strings.Contains(bodyLower, "database") ||
|
||||
strings.Contains(bodyLower, "sql") ||
|
||||
strings.Contains(bodyLower, "query") ||
|
||||
strings.Contains(bodyLower, "mysql") ||
|
||||
strings.Contains(bodyLower, "postgresql") ||
|
||||
strings.Contains(bodyLower, "mongodb")
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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"
|
||||
)
|
||||
|
||||
// genericDefaultPanelTypes are the sqlAdminPaths entries with no product-specific
|
||||
// case in isAdminPanel, so they fall through to the default keyword branch.
|
||||
var genericDefaultPanelTypes = []string{
|
||||
"SQL Interface", "Database Interface", "Database Admin", "MySQL Admin",
|
||||
"SQL Manager", "WebSQL", "SQLWeb", "MongoDB Interface", "Redis Interface",
|
||||
}
|
||||
|
||||
// an ordinary javascript page is not a database admin panel. "query" used to
|
||||
// match the default branch via jQuery/querySelector, flagging every js site.
|
||||
func TestIsAdminPanel_GenericJSPageNotFlagged(t *testing.T) {
|
||||
pages := []struct{ name, body string }{
|
||||
{"jquery script tag", `<script src="/assets/jquery-3.6.0.min.js"></script>`},
|
||||
{"querySelector call", `<script>document.querySelector(".nav").focus()</script>`},
|
||||
{"jquery invocation", `<script>jQuery(function(){ jQuery("#a").hide(); });</script>`},
|
||||
{"search query word", "<form><input name='q' placeholder='search query'></form>"},
|
||||
{"graphql query const", `<script>const QUERY = "{ user { id } }";</script>`},
|
||||
}
|
||||
for _, p := range pages {
|
||||
for _, pt := range genericDefaultPanelTypes {
|
||||
if isAdminPanel(p.body, pt) {
|
||||
t.Errorf("%s wrongly flagged as %q admin panel", p.name, pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropping "query" must not reduce recall: real db interfaces still match via
|
||||
// the sibling keywords (database/sql/mysql/postgresql/mongodb).
|
||||
func TestIsAdminPanel_RealGenericPanelsStillDetected(t *testing.T) {
|
||||
cases := []struct{ name, body string }{
|
||||
{"database manager", "<title>Database Manager</title>"},
|
||||
{"sql console", "<h1>SQL Console</h1>"},
|
||||
{"mysql admin", "<title>MySQL Administration</title>"},
|
||||
{"postgresql browser", "<div>PostgreSQL database browser</div>"},
|
||||
{"mongodb express", "<title>mongodb express</title>"},
|
||||
{"sql query interface", "<div>SQL Query Interface</div>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !isAdminPanel(c.body, "Database Interface") {
|
||||
t.Errorf("%s should still be detected as a database interface", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the precise change: a lone "query" no longer triggers, but "query" alongside
|
||||
// a db keyword still does, carried by the sibling.
|
||||
func TestIsAdminPanel_QueryRemovalPrecise(t *testing.T) {
|
||||
if isAdminPanel("<title>Query Console</title>", "Database Interface") {
|
||||
t.Error(`lone "query" should no longer trigger the default branch`)
|
||||
}
|
||||
if !isAdminPanel("<title>SQL Query Tool</title>", "Database Interface") {
|
||||
t.Error(`"query" with "sql" should still detect via "sql"`)
|
||||
}
|
||||
}
|
||||
|
||||
// the default-branch change must not disturb the product-specific cases.
|
||||
func TestIsAdminPanel_ExplicitCasesUnaffected(t *testing.T) {
|
||||
cases := []struct {
|
||||
panelType string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"phpMyAdmin", "<title>phpMyAdmin</title>", true},
|
||||
{"phpMyAdmin", "<script>var pma_token='1';</script>", true},
|
||||
{"phpMyAdmin", "<title>Home</title>", false},
|
||||
{"Adminer", "<title>Adminer</title>", true},
|
||||
{"Adminer", "nothing relevant", false},
|
||||
{"pgAdmin", "<title>pgAdmin 4</title>", true},
|
||||
{"phpPgAdmin", "<h1>phpPgAdmin</h1>", true},
|
||||
{"RockMongo", "<title>RockMongo</title>", true},
|
||||
{"Redis Commander", "<title>Redis Commander</title>", true},
|
||||
{"phpRedisAdmin", "<h1>phpRedisAdmin</h1>", true},
|
||||
{"phpMyAdmin", `<script src="jquery.js"></script>`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isAdminPanel(c.body, c.panelType); got != c.want {
|
||||
t.Errorf("isAdminPanel(%q, %q) = %v, want %v", c.body, c.panelType, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// end to end: a catch-all that serves a jquery page at every path (the common
|
||||
// soft-404-as-200 case) must not yield any admin-panel finding.
|
||||
func TestSQL_JQueryCatchAllNotReported(t *testing.T) {
|
||||
jq := `<!doctype html><html><head>
|
||||
<script src="/static/jquery.min.js"></script></head>
|
||||
<body><script>document.querySelector("#app")</script><p>Welcome</p></body></html>`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(jq))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := SQL(srv.URL, 5*time.Second, 4, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SQL: %v", err)
|
||||
}
|
||||
// SQL returns a nil result when nothing is found, which is the pass case here.
|
||||
if result != nil && len(result.AdminPanels) != 0 {
|
||||
t.Errorf("jquery catch-all produced %d admin-panel finding(s): %+v",
|
||||
len(result.AdminPanels), result.AdminPanels)
|
||||
}
|
||||
}
|
||||
|
||||
// end to end: a real phpMyAdmin install is still reported.
|
||||
func TestSQL_RealPhpMyAdminReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/phpmyadmin/" {
|
||||
_, _ = w.Write([]byte("<html><title>phpMyAdmin</title></html>"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := SQL(srv.URL, 5*time.Second, 4, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SQL: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected a phpMyAdmin finding, got nil result")
|
||||
}
|
||||
found := false
|
||||
for _, p := range result.AdminPanels {
|
||||
if p.Type == "phpMyAdmin" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("real phpMyAdmin not reported; panels=%+v", result.AdminPanels)
|
||||
}
|
||||
}
|
||||
|
||||
// end to end: a genuine generic db interface (db-topical body at a db path) is
|
||||
// still reported, so the change did not over-tighten the default branch.
|
||||
func TestSQL_RealGenericPanelReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/db/" {
|
||||
_, _ = w.Write([]byte("<html><title>Database Manager</title><body>MySQL server status</body></html>"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := SQL(srv.URL, 5*time.Second, 4, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SQL: %v", err)
|
||||
}
|
||||
if result == nil || len(result.AdminPanels) == 0 {
|
||||
t.Error("a real database interface at /db/ should still be reported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Ghost CMS Detection Module
|
||||
|
||||
id: cms-ghost
|
||||
info:
|
||||
name: Ghost Detection
|
||||
author: sif
|
||||
severity: info
|
||||
description: Detects Ghost publishing platform installations
|
||||
tags: [cms, ghost, detection, info]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}"
|
||||
- "{{BaseURL}}/ghost/"
|
||||
|
||||
matchers:
|
||||
- type: word
|
||||
part: all
|
||||
words:
|
||||
- 'generator" content="Ghost'
|
||||
- "/ghost/api/"
|
||||
- "data-ghost"
|
||||
- "ghost-portal"
|
||||
condition: or
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: ghost_version
|
||||
part: body
|
||||
regex:
|
||||
- 'generator" content="Ghost ([0-9]+(?:\.[0-9]+)*)'
|
||||
group: 1
|
||||
@@ -0,0 +1,36 @@
|
||||
# Joomla CMS Detection Module
|
||||
|
||||
id: cms-joomla
|
||||
info:
|
||||
name: Joomla Detection
|
||||
author: sif
|
||||
severity: info
|
||||
description: Detects Joomla CMS installations
|
||||
tags: [cms, joomla, detection, info]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}"
|
||||
- "{{BaseURL}}/administrator/"
|
||||
|
||||
matchers:
|
||||
- type: word
|
||||
part: all
|
||||
words:
|
||||
- 'generator" content="Joomla!'
|
||||
- "/media/system/js/core.js"
|
||||
- "/media/jui/"
|
||||
- "joomla-script-options"
|
||||
condition: or
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: joomla_version
|
||||
part: all
|
||||
regex:
|
||||
- 'Joomla! ([0-9]+(?:\.[0-9]+)*) - Open Source'
|
||||
- 'X-Content-Encoded-By: Joomla! ([0-9]+(?:\.[0-9]+)*)'
|
||||
group: 1
|
||||
@@ -0,0 +1,27 @@
|
||||
# Magento CMS Detection Module
|
||||
|
||||
id: cms-magento
|
||||
info:
|
||||
name: Magento Detection
|
||||
author: sif
|
||||
severity: info
|
||||
description: Detects Magento / Adobe Commerce e-commerce installations
|
||||
tags: [cms, magento, ecommerce, detection, info]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}"
|
||||
- "{{BaseURL}}/customer/account/login/"
|
||||
|
||||
matchers:
|
||||
- type: word
|
||||
part: all
|
||||
words:
|
||||
- "data-mage-init"
|
||||
- "text/x-magento-init"
|
||||
- "mage/cookies"
|
||||
- "Mage.Cookies"
|
||||
condition: or
|
||||
@@ -0,0 +1,27 @@
|
||||
# TYPO3 CMS Detection Module
|
||||
|
||||
id: cms-typo3
|
||||
info:
|
||||
name: TYPO3 Detection
|
||||
author: sif
|
||||
severity: info
|
||||
description: Detects TYPO3 CMS installations
|
||||
tags: [cms, typo3, detection, info]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}"
|
||||
- "{{BaseURL}}/typo3/"
|
||||
|
||||
matchers:
|
||||
- type: word
|
||||
part: all
|
||||
words:
|
||||
- "/typo3conf/"
|
||||
- "/typo3temp/"
|
||||
- "data-namespace-typo3-fluid"
|
||||
- 'generator" content="TYPO3'
|
||||
condition: or
|
||||
@@ -0,0 +1,39 @@
|
||||
# Apache Airflow API Exposure Detection Module
|
||||
|
||||
id: airflow-api-exposure
|
||||
info:
|
||||
name: Apache Airflow API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed Apache Airflow webserver through its unauthenticated health endpoint
|
||||
tags: [airflow, apache, pipeline, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/health"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"metadatabase\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"scheduler\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: airflow_scheduler_heartbeat
|
||||
part: body
|
||||
regex:
|
||||
- '"latest_scheduler_heartbeat"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,46 @@
|
||||
# Appsettings Exposure Detection Module
|
||||
|
||||
id: appsettings-exposure
|
||||
info:
|
||||
name: Appsettings Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed ASP.NET Core appsettings.json that leaks connection strings
|
||||
tags: [aspnet, dotnet, appsettings, connection-string, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/appsettings.json"
|
||||
- "{{BaseURL}}/appsettings.Production.json"
|
||||
- "{{BaseURL}}/appsettings.Development.json"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"ConnectionStrings\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "Password="
|
||||
- "password="
|
||||
- "Pwd="
|
||||
- "pwd="
|
||||
- "AccountKey="
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: connection_string
|
||||
part: body
|
||||
regex:
|
||||
- '((?:Server|Data Source|Host)=[^"]+)'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# ArangoDB API Exposure Detection Module
|
||||
|
||||
id: arangodb-api-exposure
|
||||
info:
|
||||
name: ArangoDB API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed ArangoDB instance through its unauthenticated version endpoint
|
||||
tags: [arangodb, database, graph, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/_api/version"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"arango\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"version\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: arangodb_version
|
||||
part: body
|
||||
regex:
|
||||
- '"version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Argo CD API Exposure Detection Module
|
||||
|
||||
id: argocd-api-exposure
|
||||
info:
|
||||
name: Argo CD API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed Argo CD api server through its unauthenticated version endpoint
|
||||
tags: [argocd, gitops, kubernetes, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/api/version"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"KustomizeVersion\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"HelmVersion\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: argocd_version
|
||||
part: body
|
||||
regex:
|
||||
- '"Version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,27 @@
|
||||
# Chroma Heartbeat API Exposure Detection Module
|
||||
|
||||
id: chroma-api-exposure
|
||||
info:
|
||||
name: Chroma Heartbeat API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects a reachable Chroma vector database by its unauthenticated heartbeat api
|
||||
tags: [chroma, vector, database, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/api/v1/heartbeat"
|
||||
- "{{BaseURL}}/api/v2/heartbeat"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"nanosecond heartbeat\""
|
||||
@@ -0,0 +1,44 @@
|
||||
# Consul API Exposure Detection Module
|
||||
|
||||
id: consul-api-exposure
|
||||
info:
|
||||
name: Consul API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Consul http api that leaks the agent config without an acl token
|
||||
tags: [consul, hashicorp, service-discovery, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/v1/agent/self"
|
||||
- "{{BaseURL}}/v1/catalog/nodes"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"Datacenter\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "\"NodeName\""
|
||||
- "\"Server\""
|
||||
- "\"Member\""
|
||||
- "\"Address\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: consul_datacenter
|
||||
part: body
|
||||
regex:
|
||||
- '"Datacenter"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Couchbase Cluster API Exposure Detection Module
|
||||
|
||||
id: couchbase-api-exposure
|
||||
info:
|
||||
name: Couchbase Cluster API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed Couchbase cluster management api that leaks the build and topology
|
||||
tags: [couchbase, database, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/pools"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"implementationVersion\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"componentsVersion\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: couchbase_version
|
||||
part: body
|
||||
regex:
|
||||
- '"implementationVersion"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Apache Druid API Exposure Detection Module
|
||||
|
||||
id: druid-api-exposure
|
||||
info:
|
||||
name: Apache Druid API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Apache Druid process that runs without authentication
|
||||
tags: [druid, apache, database, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/status"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "org.apache.druid"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"memory\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: druid_version
|
||||
part: body
|
||||
regex:
|
||||
- '"version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# etcd API Exposure Detection Module
|
||||
|
||||
id: etcd-api-exposure
|
||||
info:
|
||||
name: etcd API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed etcd api whose keyspace often holds kubernetes secrets
|
||||
tags: [etcd, kubernetes, key-value, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/version"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"etcdserver\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"etcdcluster\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: etcd_version
|
||||
part: body
|
||||
regex:
|
||||
- '"etcdserver"\s*:\s*"([0-9]+\.[0-9]+\.[0-9]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Apache Flink API Exposure Detection Module
|
||||
|
||||
id: flink-api-exposure
|
||||
info:
|
||||
name: Apache Flink API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Apache Flink dashboard reachable without authentication
|
||||
tags: [flink, apache, cluster, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/overview"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"flink-version\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"slots-total\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: flink_version
|
||||
part: body
|
||||
regex:
|
||||
- '"flink-version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,55 @@
|
||||
# Atom remote-ftp Deploy Config Exposure Detection Module
|
||||
|
||||
id: ftpconfig-exposure
|
||||
info:
|
||||
name: Atom remote-ftp Deploy Config Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed remote-ftp config that leaks deploy host and credentials
|
||||
tags: [atom, ftp, sftp, deploy, credentials, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/.ftpconfig"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- '"pasvTimeout"'
|
||||
- '"connTimeout"'
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- '"pass"'
|
||||
- '"user"'
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
negative: true
|
||||
condition: or
|
||||
words:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: remote_host
|
||||
part: body
|
||||
regex:
|
||||
- '"host"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,47 @@
|
||||
# Git Credentials Exposure Detection Module
|
||||
|
||||
id: git-credentials-exposure
|
||||
info:
|
||||
name: Git Credentials Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed git credential store that leaks tokens embedded in remote urls
|
||||
tags: [git, credentials, token, secret, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/.git-credentials"
|
||||
- "{{BaseURL}}/.git/credentials"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- 'https?://[^:/@\s]+:[^@/\s]+@[A-Za-z0-9._-]+'
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
negative: true
|
||||
condition: or
|
||||
words:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: git_host
|
||||
part: body
|
||||
regex:
|
||||
- 'https?://[^:/@\s]+:[^@/\s]+@([A-Za-z0-9._-]+)'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Apache Hadoop YARN API Exposure Detection Module
|
||||
|
||||
id: hadoop-yarn-api-exposure
|
||||
info:
|
||||
name: Apache Hadoop YARN API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Hadoop YARN resource manager api reachable without authentication
|
||||
tags: [hadoop, yarn, apache, cluster, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/ws/v1/cluster/info"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"clusterInfo\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"resourceManagerVersion\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: hadoop_version
|
||||
part: body
|
||||
regex:
|
||||
- '"hadoopVersion"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,62 @@
|
||||
# Htaccess Exposure Detection Module
|
||||
|
||||
id: htaccess-exposure
|
||||
info:
|
||||
name: Htaccess Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed htaccess file that leaks rewrite rules and access control config
|
||||
tags: [apache, htaccess, config, info-disclosure, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/.htaccess"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "RewriteEngine"
|
||||
- "RewriteRule"
|
||||
- "RewriteCond"
|
||||
- "AuthUserFile"
|
||||
- "AuthType"
|
||||
- "<FilesMatch"
|
||||
- "<Files "
|
||||
- "Order allow,deny"
|
||||
- "Deny from"
|
||||
- "Options -"
|
||||
- "Options +"
|
||||
- "ErrorDocument"
|
||||
- "DirectoryIndex"
|
||||
- "php_flag"
|
||||
- "php_value"
|
||||
- "Header set"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
negative: true
|
||||
condition: or
|
||||
words:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: auth_user_file
|
||||
part: body
|
||||
regex:
|
||||
- 'AuthUserFile\s+(\S+)'
|
||||
group: 1
|
||||
@@ -0,0 +1,36 @@
|
||||
# Htpasswd Exposure Detection Module
|
||||
|
||||
id: htpasswd-exposure
|
||||
info:
|
||||
name: Htpasswd Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed htpasswd file that leaks crackable basic auth password hashes
|
||||
tags: [apache, htpasswd, credentials, hashes, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/.htpasswd"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
condition: or
|
||||
regex:
|
||||
- '(?m)^[^:\s]+:\$(apr1|2[aby]|1|5|6)\$'
|
||||
- '(?m)^[^:\s]+:\{SHA\}'
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: htpasswd_user
|
||||
part: body
|
||||
regex:
|
||||
- '(?m)^([^:\s]+):'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# InfluxDB API Exposure Detection Module
|
||||
|
||||
id: influxdb-api-exposure
|
||||
info:
|
||||
name: InfluxDB API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed InfluxDB instance through its unauthenticated health endpoint
|
||||
tags: [influxdb, database, timeseries, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/health"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"influxdb\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "ready for queries and writes"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: influxdb_version
|
||||
part: body
|
||||
regex:
|
||||
- '"version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,42 @@
|
||||
# Jolokia API Exposure Detection Module
|
||||
|
||||
id: jolokia-api-exposure
|
||||
info:
|
||||
name: Jolokia API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Jolokia agent that bridges http to jmx for remote management
|
||||
tags: [jolokia, jmx, java, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/jolokia/version"
|
||||
- "{{BaseURL}}/actuator/jolokia/version"
|
||||
- "{{BaseURL}}/api/jolokia/version"
|
||||
- "{{BaseURL}}/hawtio/jolokia/version"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"agent\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"protocol\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: jolokia_agent_version
|
||||
part: body
|
||||
regex:
|
||||
- '"agent"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,44 @@
|
||||
# Jupyter Server API Exposure Detection Module
|
||||
|
||||
id: jupyter-api-exposure
|
||||
info:
|
||||
name: Jupyter Server API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects a Jupyter server whose status api answers without authentication
|
||||
tags: [jupyter, notebook, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/api/status"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"last_activity\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"connections\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"kernels\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: jupyter_active_kernels
|
||||
part: body
|
||||
regex:
|
||||
- '"kernels"\s*:\s*([0-9]+)'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Kafka Connect API Exposure Detection Module
|
||||
|
||||
id: kafka-connect-api-exposure
|
||||
info:
|
||||
name: Kafka Connect API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Kafka Connect rest api reachable without authentication
|
||||
tags: [kafka, connect, streaming, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"kafka_cluster_id\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"version\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: kafka_version
|
||||
part: body
|
||||
regex:
|
||||
- '"version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Kong Admin API Exposure Detection Module
|
||||
|
||||
id: kong-api-exposure
|
||||
info:
|
||||
name: Kong Admin API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Kong admin api that grants full control of the gateway
|
||||
tags: [kong, gateway, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"available_on_server\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"admin_listen\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: kong_version
|
||||
part: body
|
||||
regex:
|
||||
- '"version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Metabase Setup Token Exposure Detection Module
|
||||
|
||||
id: metabase-api-exposure
|
||||
info:
|
||||
name: Metabase Setup Token Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects a Metabase instance that exposes a live setup token without authentication
|
||||
tags: [metabase, analytics, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/api/session/properties"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- '"setup-token"\s*:\s*"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"'
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"anon-tracking-enabled\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: metabase_version
|
||||
part: body
|
||||
regex:
|
||||
- '"version"\s*:\s*\{[^}]*?"tag"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# NATS Monitoring API Exposure Detection Module
|
||||
|
||||
id: nats-api-exposure
|
||||
info:
|
||||
name: NATS Monitoring API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed NATS monitoring endpoint that leaks the server topology
|
||||
tags: [nats, messaging, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/varz"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"server_id\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"max_payload\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: nats_version
|
||||
part: body
|
||||
regex:
|
||||
- '"version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Neo4j API Exposure Detection Module
|
||||
|
||||
id: neo4j-api-exposure
|
||||
info:
|
||||
name: Neo4j API Exposure
|
||||
author: sif
|
||||
severity: medium
|
||||
description: Detects an exposed Neo4j instance through its unauthenticated discovery endpoint
|
||||
tags: [neo4j, database, graph, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"neo4j_version\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"neo4j_edition\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: neo4j_version
|
||||
part: body
|
||||
regex:
|
||||
- '"neo4j_version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,46 @@
|
||||
# Private Key Exposure Detection Module
|
||||
|
||||
id: private-key-exposure
|
||||
info:
|
||||
name: Private Key Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed PEM private key that grants ssh or tls access
|
||||
tags: [ssh, tls, private-key, secret, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/.ssh/id_rsa"
|
||||
- "{{BaseURL}}/id_rsa"
|
||||
- "{{BaseURL}}/id_ecdsa"
|
||||
- "{{BaseURL}}/id_ed25519"
|
||||
- "{{BaseURL}}/server.key"
|
||||
- "{{BaseURL}}/private.key"
|
||||
- "{{BaseURL}}/privkey.pem"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "-----BEGIN RSA PRIVATE KEY-----"
|
||||
- "-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
- "-----BEGIN EC PRIVATE KEY-----"
|
||||
- "-----BEGIN DSA PRIVATE KEY-----"
|
||||
- "-----BEGIN PRIVATE KEY-----"
|
||||
- "-----BEGIN ENCRYPTED PRIVATE KEY-----"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: key_type
|
||||
part: body
|
||||
regex:
|
||||
- '-----BEGIN ([A-Z0-9 ]+?) PRIVATE KEY-----'
|
||||
group: 1
|
||||
@@ -0,0 +1,57 @@
|
||||
# Pypirc Exposure Detection Module
|
||||
|
||||
id: pypirc-exposure
|
||||
info:
|
||||
name: Pypirc Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed pypirc that leaks the package index upload credentials
|
||||
tags: [python, pypi, credentials, token, secret, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/.pypirc"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "[pypi]"
|
||||
- "[testpypi]"
|
||||
- "[distutils]"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "password"
|
||||
- "username"
|
||||
- "pypi-"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
negative: true
|
||||
condition: or
|
||||
words:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: pypi_token
|
||||
part: body
|
||||
regex:
|
||||
- '(pypi-[A-Za-z0-9_-]{8,})'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Qdrant Collections API Exposure Detection Module
|
||||
|
||||
id: qdrant-api-exposure
|
||||
info:
|
||||
name: Qdrant Collections API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects a Qdrant vector database serving its collections without an api key
|
||||
tags: [qdrant, vector, database, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/collections"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- '"result"\s*:\s*\{\s*"collections"'
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- '"status"\s*:\s*"ok"'
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: qdrant_collection
|
||||
part: body
|
||||
regex:
|
||||
- '"collections"\s*:\s*\[\s*\{[^}]*?"name"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,53 @@
|
||||
# Rails Database Config Exposure Detection Module
|
||||
|
||||
id: rails-database-yml-exposure
|
||||
info:
|
||||
name: Rails Database Config Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Rails config/database.yml that leaks database credentials
|
||||
tags: [rails, ruby, database, credentials, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/config/database.yml"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "adapter:"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "password:"
|
||||
- "username:"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
negative: true
|
||||
condition: or
|
||||
words:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: database
|
||||
part: body
|
||||
regex:
|
||||
- 'database:\s*["'']?([A-Za-z0-9_./\-]+)'
|
||||
group: 1
|
||||
@@ -0,0 +1,35 @@
|
||||
# Rails Master Key Exposure Detection Module
|
||||
|
||||
id: rails-master-key-exposure
|
||||
info:
|
||||
name: Rails Master Key Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Rails master key that decrypts the encrypted credentials store
|
||||
tags: [rails, ruby, master-key, credentials, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/config/master.key"
|
||||
- "{{BaseURL}}/config/credentials/production.key"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- '^[a-f0-9]{32}\s*$'
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: master_key
|
||||
part: body
|
||||
regex:
|
||||
- '^([a-f0-9]{32})'
|
||||
group: 1
|
||||
@@ -0,0 +1,46 @@
|
||||
# Rails Secrets Config Exposure Detection Module
|
||||
|
||||
id: rails-secrets-yml-exposure
|
||||
info:
|
||||
name: Rails Secrets Config Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Rails config/secrets.yml that leaks the secret key base
|
||||
tags: [rails, ruby, secrets, secret-key-base, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/config/secrets.yml"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "secret_key_base:"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
negative: true
|
||||
condition: or
|
||||
words:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: secret_key_base
|
||||
part: body
|
||||
regex:
|
||||
- 'secret_key_base:\s*["'']?([a-f0-9]{32,})'
|
||||
group: 1
|
||||
@@ -0,0 +1,37 @@
|
||||
# Redis RDB Dump Exposure Detection Module
|
||||
|
||||
id: redis-dump-exposure
|
||||
info:
|
||||
name: Redis RDB Dump Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Redis RDB snapshot that leaks the full keyspace
|
||||
tags: [database, redis, rdb, dump, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/dump.rdb"
|
||||
- "{{BaseURL}}/redis/dump.rdb"
|
||||
- "{{BaseURL}}/data/dump.rdb"
|
||||
- "{{BaseURL}}/var/lib/redis/dump.rdb"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- '^REDIS00\d\d'
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: rdb_version
|
||||
part: body
|
||||
regex:
|
||||
- 'REDIS(\d{4})'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Riak HTTP API Exposure Detection Module
|
||||
|
||||
id: riak-api-exposure
|
||||
info:
|
||||
name: Riak HTTP API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Riak http api reachable without authentication
|
||||
tags: [riak, database, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/stats"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"riak_kv_version\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"riak_core_version\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: riak_version
|
||||
part: body
|
||||
regex:
|
||||
- '"riak_kv_version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Apache Solr API Exposure Detection Module
|
||||
|
||||
id: solr-api-exposure
|
||||
info:
|
||||
name: Apache Solr API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Apache Solr admin api reachable without authentication
|
||||
tags: [solr, apache, search, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/solr/admin/info/system"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"solr-spec-version\""
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"solr_home\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: solr_version
|
||||
part: body
|
||||
regex:
|
||||
- '"solr-spec-version"\s*:\s*"([^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Apache Spark Master API Exposure Detection Module
|
||||
|
||||
id: spark-api-exposure
|
||||
info:
|
||||
name: Apache Spark Master API Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Apache Spark master that leaks its cluster workers and applications
|
||||
tags: [spark, apache, cluster, api, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/json/"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- '"url"\s*:\s*"spark://'
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "\"aliveworkers\""
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: spark_master_url
|
||||
part: body
|
||||
regex:
|
||||
- '"url"\s*:\s*"(spark://[^"]+)"'
|
||||
group: 1
|
||||
@@ -0,0 +1,61 @@
|
||||
# Spring Application Config Exposure Detection Module
|
||||
|
||||
id: spring-application-config-exposure
|
||||
info:
|
||||
name: Spring Application Config Exposure
|
||||
author: sif
|
||||
severity: high
|
||||
description: Detects an exposed Spring application config that leaks the datasource credentials
|
||||
tags: [spring, java, application-properties, datasource, exposure, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/application.properties"
|
||||
- "{{BaseURL}}/application.yml"
|
||||
- "{{BaseURL}}/config/application.properties"
|
||||
- "{{BaseURL}}/config/application.yml"
|
||||
- "{{BaseURL}}/WEB-INF/classes/application.properties"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "spring.datasource"
|
||||
- "spring.application"
|
||||
- "datasource:"
|
||||
- "jdbc:"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
condition: or
|
||||
words:
|
||||
- "password"
|
||||
- "secret"
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
negative: true
|
||||
condition: or
|
||||
words:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: jdbc_url
|
||||
part: body
|
||||
regex:
|
||||
- '(jdbc:[a-zA-Z0-9]+://[^\s"'',]+)'
|
||||
group: 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user