mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-27 00:43:59 -07:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8421cb8daa | |||
| fb9b92a5bf | |||
| 184842f734 | |||
| 0c6a8db5a7 | |||
| 54d1be288b | |||
| 17cf26cd82 | |||
| 672858b1fe | |||
| 0422b8b413 | |||
| f37094c9ee | |||
| d34db5582f | |||
| 9cf7854ed8 | |||
| af337bd094 | |||
| 7e104ac8d4 | |||
| 5dc14ecf22 | |||
| b31234c1bc | |||
| caeff3944d | |||
| 8c8f8afba3 | |||
| 1e47b6547e | |||
| 368d658882 | |||
| c6cedf3f55 | |||
| 6dd1d9e7fe | |||
| 3f78eabf6d | |||
| 7a915d5ae7 | |||
| 1b41b5ed65 | |||
| d17f7c2074 | |||
| 301f758053 | |||
| 9f3b9eaa55 | |||
| 9be84be908 | |||
| d7d669e300 | |||
| db862992b5 | |||
| 0255e2d3ac | |||
| 7c635bae9f | |||
| 0c9419b374 | |||
| af759c7073 | |||
| 273dcdc30d | |||
| be56a90af1 | |||
| cf159ad4a9 | |||
| f22aa9e161 | |||
| 4a84790f02 | |||
| e2f59637ec | |||
| c68b077a22 | |||
| 8c732f9955 | |||
| 27a8a27880 | |||
| 9340a8be0e | |||
| 77f203e47c | |||
| 733578e6ec | |||
| 0fa3d03eb7 | |||
| 1bbc564170 | |||
| 0f11283b1e | |||
| f5ad97ac57 | |||
| 12b56661ac | |||
| 798f53d3f4 | |||
| a7f769b35a | |||
| fa3223ab31 | |||
| 657cb0cbb8 | |||
| c03fa7d336 | |||
| 45f5302e1f | |||
| 78a2ec364f | |||
| 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)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
@@ -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! │
|
||||
│ │
|
||||
|
||||
@@ -176,7 +176,7 @@ sif has a modular architecture. modules are defined in yaml and can be extended
|
||||
| `-fs` | dirlist: filter out responses of these body sizes (comma list) |
|
||||
| `-fw` | dirlist: filter out responses with these word counts (comma list) |
|
||||
| `-fr` | dirlist: filter out responses whose body matches this regex |
|
||||
| `-ac` | dirlist: auto-calibrate the soft-404 wildcard baseline |
|
||||
| `-ac` | auto-calibrate the soft-404 wildcard baseline (dirlist, sql) |
|
||||
| `-w` | dirlist: custom wordlist (local file or url; overrides `-dirlist` size) |
|
||||
| `-e` | dirlist: extensions appended to each word (comma list, e.g. php,bak,env) |
|
||||
| `-dnslist` | subdomain enumeration (small/medium/large) |
|
||||
@@ -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>
|
||||
|
||||
@@ -57,6 +57,12 @@ enable verbose logging:
|
||||
./sif -u https://example.com -d
|
||||
```
|
||||
|
||||
### templates
|
||||
|
||||
`-template` loads a batch of scan settings from a built-in preset or a local yaml file, so a run does not have to pass every flag. see the [usage guide](usage.md) for the presets and file format. command-line flags still take precedence over the template.
|
||||
|
||||
sif also reads an ambient config at `~/.config/sif/config.yaml` (created on first run) keyed by the same flag names. passing `-template` uses that template as the config for the run instead of the ambient file.
|
||||
|
||||
## user modules
|
||||
|
||||
place custom modules in:
|
||||
@@ -92,6 +98,38 @@ info:
|
||||
# ...
|
||||
```
|
||||
|
||||
## custom signatures
|
||||
|
||||
framework detection (`-framework`) also loads user-defined detectors from yaml
|
||||
files, so a framework sif does not ship can be detected without rebuilding:
|
||||
|
||||
- linux/macos: `~/.config/sif/signatures/`
|
||||
- windows: `%LOCALAPPDATA%\sif\signatures\`
|
||||
|
||||
each file defines one detector; place them directly in the directory, as
|
||||
subdirectories are not scanned. `header: true` matches a response header name or
|
||||
value (case-insensitive) instead of the body; the optional `version` block pulls
|
||||
a version out of the body.
|
||||
|
||||
```yaml
|
||||
# ~/.config/sif/signatures/ghost.yaml
|
||||
name: Ghost
|
||||
signatures:
|
||||
- pattern: 'content="Ghost'
|
||||
weight: 0.6
|
||||
- pattern: 'X-Ghost-Cache'
|
||||
weight: 0.4
|
||||
header: true
|
||||
version:
|
||||
regex: 'content="Ghost ([0-9.]+)'
|
||||
group: 1
|
||||
```
|
||||
|
||||
a detector reports a match once its matched signature weights sum past half, so
|
||||
weight your signatures to total about `1.0`. a name matching a built-in detector
|
||||
overrides it and inherits that built-in's version patterns and known cves, the
|
||||
same as user modules.
|
||||
|
||||
## performance tuning
|
||||
|
||||
### fast scans
|
||||
|
||||
+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
|
||||
|
||||
+94
-1
@@ -115,6 +115,29 @@ 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
|
||||
|
||||
#### wordlist
|
||||
|
||||
a local file whose non-empty lines fuzz the `{{word}}` placeholder, one request
|
||||
per word. paths without `{{word}}` are still requested as-is.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
wordlist: /usr/share/wordlists/dirs.txt
|
||||
paths:
|
||||
- "{{BaseURL}}/{{word}}"
|
||||
```
|
||||
#### headers
|
||||
|
||||
custom headers to send.
|
||||
@@ -199,6 +222,42 @@ 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
|
||||
```
|
||||
|
||||
### favicon matcher
|
||||
|
||||
match the shodan-style mmh3 hash of the response body. point the module at a
|
||||
favicon and list the hashes of the tech you want to fingerprint.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
paths:
|
||||
- "{{BaseURL}}/favicon.ico"
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
- type: favicon
|
||||
hash:
|
||||
- -235701012 # jenkins
|
||||
- 1278322581 # grafana
|
||||
```
|
||||
|
||||
the hash is shodan's `http.favicon.hash` value. paste it signed or unsigned;
|
||||
both 32-bit forms are accepted, so values from shodan or any favicon-hash tool
|
||||
drop in without conversion. pair it with a `status: 200` matcher so an error
|
||||
page served for `/favicon.ico` is not hashed. a finding fires when the body
|
||||
hashes to any listed value.
|
||||
### combining matchers
|
||||
|
||||
multiple matchers are combined with AND logic by default.
|
||||
@@ -218,6 +277,25 @@ matchers:
|
||||
|
||||
this matches responses with status 200 AND containing "ref: refs/".
|
||||
|
||||
to require any matcher instead of all, set `matchers-condition: or` on the http
|
||||
block; the module then reports a finding when any one matcher matches.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
matchers-condition: or
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 401
|
||||
|
||||
- type: status
|
||||
status:
|
||||
- 403
|
||||
```
|
||||
|
||||
this matches a 401 OR a 403 response. `matchers-condition` accepts `and` (the
|
||||
default) or `or`; any other value fails at load.
|
||||
|
||||
## extractors
|
||||
|
||||
extractors pull data from responses.
|
||||
@@ -238,7 +316,7 @@ extractors:
|
||||
|
||||
### kv extractor
|
||||
|
||||
extract key-value pairs.
|
||||
record every response header as a key-value pair, namespaced by `name`.
|
||||
|
||||
```yaml
|
||||
extractors:
|
||||
@@ -247,6 +325,21 @@ extractors:
|
||||
part: header
|
||||
```
|
||||
|
||||
### json extractor
|
||||
|
||||
extract values from a json body by gjson path (github.com/tidwall/gjson); the
|
||||
first path that exists is stored under name.
|
||||
|
||||
```yaml
|
||||
extractors:
|
||||
- type: json
|
||||
name: version
|
||||
part: body
|
||||
json:
|
||||
- "version"
|
||||
- "data.version"
|
||||
```
|
||||
|
||||
## examples
|
||||
|
||||
### exposed git repository
|
||||
|
||||
@@ -378,6 +378,33 @@ enable debug logging:
|
||||
./sif -u https://example.com -d
|
||||
```
|
||||
|
||||
### --template
|
||||
|
||||
load a batch of scan settings from a template instead of passing each flag. the value is either a built-in preset or a local yaml file keyed by flag long-names:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com --template recon
|
||||
./sif -u https://example.com --template ./my-scans.yaml
|
||||
```
|
||||
|
||||
built-in presets:
|
||||
|
||||
- `minimal`: liveness and fingerprint only (probe, headers, favicon)
|
||||
- `recon`: broad non-intrusive discovery, no attack payloads
|
||||
- `full`: every scan except the api-key ones (shodan, securitytrails), including the intrusive probes (xss, sql, lfi, redirect)
|
||||
|
||||
`full` sends attack payloads, so only run it against targets you are authorized to test.
|
||||
|
||||
a local template lists flag long-names, for example:
|
||||
|
||||
```yaml
|
||||
cms: true
|
||||
dirlist: medium
|
||||
threads: 20
|
||||
```
|
||||
|
||||
flags passed on the command line take precedence over the template, so `--template recon -xss` runs the recon preset with an added xss probe.
|
||||
|
||||
## http options
|
||||
|
||||
these apply to every outbound request across all scanners (proxy, custom headers, cookie and rate limiting share one client). a scanner that sets a header explicitly still wins over the global default.
|
||||
|
||||
@@ -4,19 +4,20 @@ 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/projectdiscovery/nuclei/v3 v3.9.0
|
||||
github.com/projectdiscovery/retryabledns v1.0.115
|
||||
github.com/projectdiscovery/utils v0.11.1
|
||||
github.com/rocketlaunchr/google-search v1.1.6
|
||||
github.com/twmb/murmur3 v1.1.6
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/time v0.14.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/twmb/murmur3 v1.1.8
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,16 +34,18 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect
|
||||
github.com/FalconOpsLLC/goexec v0.3.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d // indirect
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415 // indirect
|
||||
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 // indirect
|
||||
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||
github.com/akrylysov/pogreb v0.10.2 // indirect
|
||||
@@ -55,7 +58,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 +83,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 +100,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
|
||||
@@ -117,8 +120,7 @@ require (
|
||||
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/docker/docker v28.3.3+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -131,8 +133,8 @@ require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/geoffgarside/ber v1.1.0 // indirect
|
||||
github.com/gaissmai/bart v0.28.0 // indirect
|
||||
github.com/geoffgarside/ber v1.2.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.132.0 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.9.1 // indirect
|
||||
@@ -141,7 +143,7 @@ require (
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.19.1 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.11 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -163,7 +165,6 @@ require (
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
@@ -191,6 +192,7 @@ require (
|
||||
github.com/hdm/jarm-go v0.0.7 // indirect
|
||||
github.com/iangcarroll/cookiemonster v1.6.0 // indirect
|
||||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/indece-official/go-ebcdic v1.2.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/itchyny/gojq v0.12.17 // indirect
|
||||
@@ -199,6 +201,7 @@ require (
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
@@ -211,7 +214,7 @@ require (
|
||||
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/kitabisa/go-ci v1.0.3 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
@@ -229,8 +232,8 @@ require (
|
||||
github.com/mackerelio/go-osstat v0.2.6 // indirect
|
||||
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-isatty v0.0.22 // 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
|
||||
@@ -243,16 +246,21 @@ require (
|
||||
github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/moby/api v1.54.2 // indirect
|
||||
github.com/moby/moby/client v0.4.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
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
|
||||
github.com/oiweiwei/go-msrpc v1.2.12 // indirect
|
||||
github.com/oiweiwei/go-smb2.fork v1.0.0 // indirect
|
||||
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.0.8 // indirect
|
||||
@@ -268,40 +276,42 @@ require (
|
||||
github.com/praetorian-inc/fingerprintx v1.1.15 // indirect
|
||||
github.com/projectdiscovery/asnmap v1.1.1 // indirect
|
||||
github.com/projectdiscovery/blackrock v0.0.1 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.2.31 // indirect
|
||||
github.com/projectdiscovery/clistats v0.1.1 // indirect
|
||||
github.com/projectdiscovery/dsl v0.8.14 // indirect
|
||||
github.com/projectdiscovery/fastdialer v0.5.6 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.2.39 // indirect
|
||||
github.com/projectdiscovery/clistats v0.1.4 // indirect
|
||||
github.com/projectdiscovery/dsl v0.8.19 // indirect
|
||||
github.com/projectdiscovery/fastdialer v0.5.10 // indirect
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2 // indirect
|
||||
github.com/projectdiscovery/freeport v0.0.7 // indirect
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c // indirect
|
||||
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb // indirect
|
||||
github.com/projectdiscovery/gologger v1.1.68 // indirect
|
||||
github.com/projectdiscovery/gologger v1.1.70 // indirect
|
||||
github.com/projectdiscovery/gostruct v0.0.2 // indirect
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81 // indirect
|
||||
github.com/projectdiscovery/hmap v0.0.100 // indirect
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e // indirect
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 // indirect
|
||||
github.com/projectdiscovery/hmap v0.0.101 // indirect
|
||||
github.com/projectdiscovery/httpx v1.9.0 // indirect
|
||||
github.com/projectdiscovery/interactsh v1.3.1 // indirect
|
||||
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
|
||||
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
|
||||
github.com/projectdiscovery/mapcidr v1.1.97 // indirect
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 // indirect
|
||||
github.com/projectdiscovery/networkpolicy v0.1.36 // indirect
|
||||
github.com/projectdiscovery/ratelimit v0.0.85 // indirect
|
||||
github.com/projectdiscovery/networkpolicy v0.1.40 // indirect
|
||||
github.com/projectdiscovery/ratelimit v0.0.88 // indirect
|
||||
github.com/projectdiscovery/rawhttp v0.1.90 // indirect
|
||||
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917 // indirect
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.8 // indirect
|
||||
github.com/projectdiscovery/sarif v0.0.1 // indirect
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.14 // indirect
|
||||
github.com/projectdiscovery/sarif v0.1.0 // indirect
|
||||
github.com/projectdiscovery/tlsx v1.2.2 // indirect
|
||||
github.com/projectdiscovery/uncover v1.2.0 // indirect
|
||||
github.com/projectdiscovery/useragent v0.0.107 // indirect
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.76 // indirect
|
||||
github.com/projectdiscovery/uncover v1.2.1 // indirect
|
||||
github.com/projectdiscovery/useragent v0.0.108 // indirect
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.84 // indirect
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.6 // indirect
|
||||
github.com/redis/go-redis/v9 v9.11.0 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/remeh/sizedwaitgroup v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/sashabaranov/go-openai v1.37.0 // indirect
|
||||
github.com/segmentio/ksuid v1.0.4 // indirect
|
||||
@@ -310,17 +320,17 @@ require (
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
|
||||
github.com/sijms/go-ora/v2 v2.9.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/temoto/robotstxt v1.1.2 // indirect
|
||||
github.com/tidwall/btree v1.8.1 // indirect
|
||||
github.com/tidwall/buntdb v1.3.2 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/grect v0.1.4 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
@@ -363,35 +373,36 @@ require (
|
||||
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 // indirect
|
||||
github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 // indirect
|
||||
github.com/zmap/zgrab2 v0.1.8 // indirect
|
||||
gitlab.com/gitlab-org/api/client-go v0.130.1 // indirect
|
||||
gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
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/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 // 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.11 // 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
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
mellium.im/sasl v0.3.2 // indirect
|
||||
moul.io/http2curl v1.0.0 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||
)
|
||||
|
||||
@@ -73,8 +73,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/FalconOpsLLC/goexec v0.3.0 h1:ryLMkrGT6asnkqdc5rFMNOSTYdMH/iCfyEuwu0D6ZhA=
|
||||
github.com/FalconOpsLLC/goexec v0.3.0/go.mod h1:kiyxVbmFCGbbwXRyZmOSKlOy7PiK+JH2gq07Ztag/k8=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
@@ -86,6 +86,8 @@ github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb888350
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809/go.mod h1:upgc3Zs45jBDnBT4tVRgRcgm26ABpaP7MoTSdgysca4=
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d h1:DofPB5AcjTnOU538A/YD86/dfqSNTvQsAXgwagxmpu4=
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d/go.mod h1:uzdh/m6XQJI7qRvufeBPDa+lj5SVCJO8B9eLxTbtI5U=
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415 h1:lpSZPcbowbxvKFaFvE1reLTCStezWXcRVk0zzBtUatg=
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415/go.mod h1:Wvb2f1Aq6NVL4Fza/dPNxv6/canpeizpgmiTCBGMD50=
|
||||
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 h1:54I+OF5vS4a/rxnUrN5J3hi0VEYKcrTlpc8JosDyP+c=
|
||||
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697/go.mod h1:yNqYRqxYkSROY1J+LX+A0tOSA/6soXQs5m8hZSqYBac=
|
||||
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 h1:+Is1AS20q3naP+qJophNpxuvx1daFOx9C0kLIuI0GVk=
|
||||
@@ -101,6 +103,8 @@ github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBK
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687 h1:HHkHNwakgAFX3qlMm88c090vIYNFWiO5+4WyAr0xJuM=
|
||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687/go.mod h1:l6FRaC2TliS/JMNWskO3J1tmrcKJyOaMFhWC6hHeWno=
|
||||
github.com/RumbleDiscovery/rumble-tools v0.0.0-20201105153123-f2adbb3244d2/go.mod h1:jD2+mU+E2SZUuAOHZvZj4xP4frlOo+N/YrXDvASFhkE=
|
||||
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
@@ -125,8 +129,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexsnet/go-vnc v0.1.0 h1:vBCwPNy79WEL8V/Z5A0ngEFCvTWBAjmS048lkR2rdmY=
|
||||
github.com/alexsnet/go-vnc v0.1.0/go.mod h1:bbRsg41Sh3zvrnWsw+REKJVGZd8Of2+S0V1G0ZaBhlU=
|
||||
github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs=
|
||||
@@ -149,13 +153,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 +216,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=
|
||||
@@ -245,8 +250,6 @@ github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+Y
|
||||
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/censys/censys-sdk-go v0.19.1 h1:CG8rQKgwrKuoICd3oU0uddALMfJnboeMkDg/e74HYyc=
|
||||
github.com/censys/censys-sdk-go v0.19.1/go.mod h1:DgPz5NgL+EfoueXLPG9UG1e7hS0OhtlywgpkIuu3ZRE=
|
||||
@@ -259,14 +262,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=
|
||||
@@ -305,8 +308,7 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -330,10 +332,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
|
||||
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
@@ -373,10 +373,10 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
|
||||
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||
github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
|
||||
github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
|
||||
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
@@ -405,8 +405,8 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
@@ -464,12 +464,12 @@ 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
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=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
@@ -569,13 +569,15 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
|
||||
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
|
||||
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
|
||||
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
@@ -608,6 +610,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:
|
||||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||
github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY512Ry5Mwr4a0=
|
||||
github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
@@ -665,13 +669,12 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kitabisa/go-ci v1.0.3 h1:JmIUIvcercRQc/9x/v02ydCCqU4MadSHaNaOF8T2pGA=
|
||||
github.com/kitabisa/go-ci v1.0.3/go.mod h1:e3wBSzaJbcifXrr/Gw2ZBLn44MmeqP5WySwXyHlCK/U=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -722,13 +725,16 @@ github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr
|
||||
github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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=
|
||||
@@ -758,14 +764,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
|
||||
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
|
||||
github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM=
|
||||
github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
@@ -781,8 +783,6 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
|
||||
github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
@@ -791,6 +791,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=
|
||||
@@ -799,6 +801,12 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/oiweiwei/go-msrpc v1.2.12 h1:gaxnv1cyX3v9l+NNxyr4ONyvh/mnmh8Egel9r8zxNxE=
|
||||
github.com/oiweiwei/go-msrpc v1.2.12/go.mod h1:T6/ENmAoD1nYCr3NW8PS8AjIX0tZEAL7yO0tsejtK18=
|
||||
github.com/oiweiwei/go-smb2.fork v1.0.0 h1:xHq/eYPM8hQEO/nwCez8YwHWHC8mlcsgw/Neu52fPN4=
|
||||
github.com/oiweiwei/go-smb2.fork v1.0.0/go.mod h1:h0CzLVvGAmq39izdYVHKyI5cLv6aHdbQAMKEe4dz4N8=
|
||||
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6 h1:ZMXO5OtzPPSqZ7KPgknVuvHE5iAbSXq5JLgzrkiXknM=
|
||||
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6/go.mod h1:KEnkAYUYqZ5VwzxLFbv3JHlRhCvdFahjrdjjssMJJkI=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
@@ -847,14 +855,14 @@ github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kI
|
||||
github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
|
||||
github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
|
||||
github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
|
||||
github.com/projectdiscovery/cdncheck v1.2.31 h1:8iD/MLDdMdMziM3RA5FkjUxO6kIwwgAoxWaL6RBIIl0=
|
||||
github.com/projectdiscovery/cdncheck v1.2.31/go.mod h1:6/B6caF1+97hR9cICMlzIYR8hpAN/y3AlJPHI2q48PQ=
|
||||
github.com/projectdiscovery/clistats v0.1.1 h1:8mwbdbwTU4aT88TJvwIzTpiNeow3XnAB72JIg66c8wE=
|
||||
github.com/projectdiscovery/clistats v0.1.1/go.mod h1:4LtTC9Oy//RiuT1+76MfTg8Hqs7FQp1JIGBM3nHK6a0=
|
||||
github.com/projectdiscovery/dsl v0.8.14 h1:g9szcXk2RRdVf2rsHEzbTXOPxiny3haKonSncU6pg2w=
|
||||
github.com/projectdiscovery/dsl v0.8.14/go.mod h1:LYImt/EiBzqTWG1RswT3Yl0DZbfjUP93Nvq2Z/G7dcE=
|
||||
github.com/projectdiscovery/fastdialer v0.5.6 h1:kIBFmzbXrua41uf4fGsQClTZmT7cm7E3vVgcSj8gs6Q=
|
||||
github.com/projectdiscovery/fastdialer v0.5.6/go.mod h1:QxvCe02Jii+j8vA3hWYkymgZIY8cqMgs2s3Jbz6mvbs=
|
||||
github.com/projectdiscovery/cdncheck v1.2.39 h1:gNE2dyaK+ZqEdEWyVUFlq8PvromEhSxamhsmFZR2Voc=
|
||||
github.com/projectdiscovery/cdncheck v1.2.39/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w=
|
||||
github.com/projectdiscovery/clistats v0.1.4 h1:kDnXoNxIdOvQElOF7k2Mt6XosGa5GbMKPtRXdPHMVzU=
|
||||
github.com/projectdiscovery/clistats v0.1.4/go.mod h1:hjJYNcUubk9T3cuFvA+JkLhZGjzYW50fkC48dqUAtbU=
|
||||
github.com/projectdiscovery/dsl v0.8.19 h1:qA5OFJMfghSCjKqS4AdsEtnur/SoriXDw3geE7+mReU=
|
||||
github.com/projectdiscovery/dsl v0.8.19/go.mod h1:Twk93q7fxQ43v/8nR+0TJV8/eFTdBAC5tIXe3qzua9Y=
|
||||
github.com/projectdiscovery/fastdialer v0.5.10 h1:dB9MSu4cSo22qne4pHiK9iYSxfOgpwlKB6zfOHvz3RI=
|
||||
github.com/projectdiscovery/fastdialer v0.5.10/go.mod h1:W1ZkULr9mMR6i0oRFTztANnpVyEEzPUovK8sUM4eAw8=
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2 h1:h2cISk5xDhlJEinlBQS6RRx0vOlOirB2y3Yu4PJzpiA=
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2/go.mod h1:XYWWVMxnItd+r0GbjA1GCsUopMw1/XusuQxdyAIHMCw=
|
||||
github.com/projectdiscovery/freeport v0.0.7 h1:Q6uXo/j8SaV/GlAHkEYQi8WQoPXyJWxyspx+aFmz9Qk=
|
||||
@@ -865,14 +873,16 @@ github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb h1:rutG90
|
||||
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb/go.mod h1:FLjF1DmZ+POoGEiIQdWuYVwS++C/GwpX8YaCsTSm1RY=
|
||||
github.com/projectdiscovery/goflags v0.1.74 h1:n85uTRj5qMosm0PFBfsvOL24I7TdWRcWq/1GynhXS7c=
|
||||
github.com/projectdiscovery/goflags v0.1.74/go.mod h1:UMc9/7dFz2oln+10tv6cy+7WZKTHf9UGhaNkF95emh4=
|
||||
github.com/projectdiscovery/gologger v1.1.68 h1:KfdIO/3X7BtHssWZuqhxPZ+A946epCCx2cz+3NnRAnU=
|
||||
github.com/projectdiscovery/gologger v1.1.68/go.mod h1:Xae0t4SeqJVa0RQGK9iECx/+HfXhvq70nqOQp2BuW+o=
|
||||
github.com/projectdiscovery/gologger v1.1.70 h1:A1ZAsUJfRUXO6qqwTwyTWXLVlBrVu/Gpi1zzL1hg5LY=
|
||||
github.com/projectdiscovery/gologger v1.1.70/go.mod h1:kpLKNafZWRN9P7WpJYtIOY/XvY/v41GDdU9NzICdKmo=
|
||||
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
|
||||
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81 h1:yHh46pJovYbyiaHCV7oIDinFmy+Fyq36H1BowJgb0M0=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81/go.mod h1:9lmGPBDGZVANzCGjQg+V32n8Y3Cgjo/4kT0E88lsVTI=
|
||||
github.com/projectdiscovery/hmap v0.0.100 h1:DBZ3Req9lWf4P1YC9PRa4eiMvLY0Uxud43NRBcocPfs=
|
||||
github.com/projectdiscovery/hmap v0.0.100/go.mod h1:2O06pR8pHOP9wSmxAoxuM45U7E+UqOqOdlSIeddM0bA=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e h1:o+ulEIaC2+9V2Ezr6mI5xEhKWsf0V/+FUQIS723Aj6U=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70bbi6BBs7KpIM9w0LxygUN7uzT/oH+owDIQ+Fz/k=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
|
||||
github.com/projectdiscovery/hmap v0.0.101 h1:zXM6YtLmsn8Q0CUUw8QavhqWmiQYwaw+/U679Rr00pc=
|
||||
github.com/projectdiscovery/hmap v0.0.101/go.mod h1:w6N9/a5H8kvyx53AhtPDUWe5Qq3D6NBDPA23glHpa/Q=
|
||||
github.com/projectdiscovery/httpx v1.9.0 h1:5yn4ik/LqZ+v3MLgU7+CZJQyND9osW9NmZ3squylxsc=
|
||||
github.com/projectdiscovery/httpx v1.9.0/go.mod h1:jGTRyUHddo2WyK4klWIwQXgGF1Lu39XVyzlue4H3pX8=
|
||||
github.com/projectdiscovery/interactsh v1.3.1 h1:5HzeVGVCAX/cjTguJ+7ClOmML5r97Ty7op9s+/F7BiM=
|
||||
@@ -885,34 +895,34 @@ github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8Qu
|
||||
github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA=
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 h1:L/e8z8yw1pfT6bg35NiN7yd1XKtJap5Nk6lMwQ0RNi8=
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5/go.mod h1:pGW2ncnTxTxHtP9wzcIJAB+3/NMp6IiuQWd2NK7K+oc=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.36 h1:88EAYvEplBmn4vlGKenZJtzsGkEWALX3QzPiY930GtA=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.36/go.mod h1:lrm+DXxtH0cGpM4OKhILC+9ktnzrXVYcM0S2Jk+gQcc=
|
||||
github.com/projectdiscovery/nuclei/v3 v3.8.0 h1:UfIDjoHBsvACtvO4x8XIp6COffH+0G4sqco1qrijZqw=
|
||||
github.com/projectdiscovery/nuclei/v3 v3.8.0/go.mod h1:xBCCFK5nMafAuf3sWyOojzL9pKN91tj4Uwj2TK7HhOM=
|
||||
github.com/projectdiscovery/ratelimit v0.0.85 h1:TrqYis/+6Djac20n3kgFXQbN/xj7ywObJpH3xDOd+40=
|
||||
github.com/projectdiscovery/ratelimit v0.0.85/go.mod h1:enLZ8XGL02WPBhuoHAhgvMgOpuU9ALhFpFgCps5lxmM=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.40 h1:kYin4u1/dgb0nuz5fE1bz4Q0Zh66mOKIdkSHJ00bjGY=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.40/go.mod h1:9ULLaMbdv9UnT0C5rmuK4nIwYs0o776xMnkPUb8TtaE=
|
||||
github.com/projectdiscovery/nuclei/v3 v3.9.0 h1:kNHrWZH7mM8Ntf5qacYgHNCEGzmPtywcU0feKm2YnhU=
|
||||
github.com/projectdiscovery/nuclei/v3 v3.9.0/go.mod h1:6gkhTSiX+7ay5NTHM62+WUUCg7toWwHaWady+3tblbY=
|
||||
github.com/projectdiscovery/ratelimit v0.0.88 h1:AcurW9aLRzlEyPe9kSjnOpr3XzLMWTpiWAlW/w73ALU=
|
||||
github.com/projectdiscovery/ratelimit v0.0.88/go.mod h1:CU1s+68UUG2mctSl2wi32/DHLJA6TMg+4rxgP59LfVk=
|
||||
github.com/projectdiscovery/rawhttp v0.1.90 h1:LOSZ6PUH08tnKmWsIwvwv1Z/4zkiYKYOSZ6n+8RFKtw=
|
||||
github.com/projectdiscovery/rawhttp v0.1.90/go.mod h1:VZYAM25UI/wVB3URZ95ZaftgOnsbphxyAw/XnQRRz4Y=
|
||||
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917 h1:m03X4gBVSorSzvmm0bFa7gDV4QNSOWPL/fgZ4kTXBxk=
|
||||
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917/go.mod h1:JxXtZC9e195awe7EynrcnBJmFoad/BNDzW9mzFkK8Sg=
|
||||
github.com/projectdiscovery/retryabledns v1.0.114 h1:COyNKzhA7oa3C/1639WRXeXsKrUJx06paVbN64IHZ3E=
|
||||
github.com/projectdiscovery/retryabledns v1.0.114/go.mod h1:+DyanDr8naxQ2dRO9c4Ezo3NHHXhz8L0tTSRYWhiwyA=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.8 h1:TA075ioaVyaM65R3dSzKSbOCiJSvFrlGScxzScu4ik8=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.8/go.mod h1:/vas835LvB4aqK9vCPGSgKF7Q7hY/BRcIJ/TgM2sPAY=
|
||||
github.com/projectdiscovery/sarif v0.0.1 h1:C2Tyj0SGOKbCLgHrx83vaE6YkzXEVrMXYRGLkKCr/us=
|
||||
github.com/projectdiscovery/sarif v0.0.1/go.mod h1:cEYlDu8amcPf6b9dSakcz2nNnJsoz4aR6peERwV+wuQ=
|
||||
github.com/projectdiscovery/retryabledns v1.0.115 h1:RKV63FNIznFHUoawg/1hs53pVH3wqPtFhwstCuxVSoA=
|
||||
github.com/projectdiscovery/retryabledns v1.0.115/go.mod h1:+fEMWoPigw+M0lGNKY7AZ+g8FIgj+4sONjsinMmeL3k=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.14 h1:vCBLwK8iIuua3i97jEac5/+TWkYTLhTkGblHu9ETPVc=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.14/go.mod h1:reVhQ+DzMAPYEQHdawCQ6h0tX3CpFyMH4XjcAyq9+U8=
|
||||
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
|
||||
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
|
||||
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
|
||||
github.com/projectdiscovery/stringsutil v0.0.2/go.mod h1:EJ3w6bC5fBYjVou6ryzodQq37D5c6qbAYQpGmAy+DC0=
|
||||
github.com/projectdiscovery/tlsx v1.2.2 h1:Y96QBqeD2anpzEtBl4kqNbwzXh2TrzJuXfgiBLvK+SE=
|
||||
github.com/projectdiscovery/tlsx v1.2.2/go.mod h1:ZJl9F1sSl0sdwE+lR0yuNHVX4Zx6tCSTqnNxnHCFZB4=
|
||||
github.com/projectdiscovery/uncover v1.2.0 h1:31tjYa0v8FB8Ch8hJTxb+2t63vsljdOo0OSFylJcX4M=
|
||||
github.com/projectdiscovery/uncover v1.2.0/go.mod h1:ozqKb++p39Kmh1SmwIpbQ9p0aVGPXuwsb4/X2Kvx6ms=
|
||||
github.com/projectdiscovery/useragent v0.0.107 h1:45gSBda052fv2Gtxtnpx7cu2rWtUpZEQRGAoYGP6F5M=
|
||||
github.com/projectdiscovery/useragent v0.0.107/go.mod h1:yv5ZZLDT/kq6P+NvBcCPq6sjEVQtZGgO+OvvHzZ+WtY=
|
||||
github.com/projectdiscovery/utils v0.10.1 h1:9luYfL7PpN1L/cLO4bAES4+ltDaEBKOUnRiTn920XfM=
|
||||
github.com/projectdiscovery/utils v0.10.1/go.mod h1:x3jGS2YIxnUYxlpB9HWBKf0k+AE83nYCGRX/YStC8G8=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.76 h1:6zQt6Jmi/hIwD8InWswkk1yhJGWaVEAEzshTGiTGbeM=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.76/go.mod h1:hRsnKNleH693FFJsBOD5NMUDbxw/Q94f0Oq2OV04Q6M=
|
||||
github.com/projectdiscovery/uncover v1.2.1 h1:8U46T/96CLT7BPoXBgkTvWqB06lOyeTSLvh5+UjzATE=
|
||||
github.com/projectdiscovery/uncover v1.2.1/go.mod h1:0p8onrWxfpXQEYs90ZDzTSpu1107gWmodX1NWqu/+z4=
|
||||
github.com/projectdiscovery/useragent v0.0.108 h1:fb+uLuFJvC+MHZjCtxQJxtvp1X6A8n98CUGPyFcg3NE=
|
||||
github.com/projectdiscovery/useragent v0.0.108/go.mod h1:XdNRrlvtDmYfVL1Oybat4uMe+W6cLwsK9S18ond17CI=
|
||||
github.com/projectdiscovery/utils v0.11.1 h1:PWj1KjIASxt8icxommH72C0TQqNOvGkcSODRkiq0SQw=
|
||||
github.com/projectdiscovery/utils v0.11.1/go.mod h1:yktGrHGk2CTjNiccXovnvGrLHX9sV2bqz9nSnbA3V8M=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.84 h1:19c+ea8KZCnZIuZPztafFKK2uczDXxcZ/z6/l6DEEEs=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.84/go.mod h1:gMH0o5lBp65sKMwHx/tuUdOtW2RjodC6Ti+9QDsYMkY=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.6 h1:GCEdIRlQjDux28xTXKszM7n3jlMf152d5nqVpVoetas=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.6/go.mod h1:R5lWrNzP+7Oyn77NDVPnBsxx2/FyQZBBkIAaSaCQFxw=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
@@ -956,6 +966,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||
@@ -980,8 +992,9 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
@@ -996,8 +1009,10 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
@@ -1061,8 +1076,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=
|
||||
@@ -1133,7 +1149,6 @@ github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
@@ -1163,8 +1178,8 @@ github.com/zmap/zflags v1.4.0-beta.1.0.20200204220219-9d95409821b6/go.mod h1:HXD
|
||||
github.com/zmap/zgrab2 v0.1.8 h1:PFnXrIBcGjYFec1JNbxMKQuSXXzS+SbqE89luuF4ORY=
|
||||
github.com/zmap/zgrab2 v0.1.8/go.mod h1:5d8HSmUwvllx4q1qG50v/KXphkg45ZzWdaQtgTFnegE=
|
||||
github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8=
|
||||
gitlab.com/gitlab-org/api/client-go v0.130.1 h1:1xF5C5Zq3sFeNg3PzS2z63oqrxifne3n/OnbI7nptRc=
|
||||
gitlab.com/gitlab-org/api/client-go v0.130.1/go.mod h1:ZhSxLAWadqP6J9lMh40IAZOlOxBLPRh7yFOXR/bMJWM=
|
||||
gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8=
|
||||
gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||
@@ -1176,24 +1191,18 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -1234,8 +1243,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=
|
||||
@@ -1246,8 +1256,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 h1:VHEvKbpgPXcPXn40t9cDTGK3JZwMikIEyF/CTrFfu7k=
|
||||
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -1273,8 +1283,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=
|
||||
@@ -1310,7 +1320,6 @@ golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -1331,8 +1340,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=
|
||||
@@ -1352,7 +1362,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1361,8 +1370,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=
|
||||
@@ -1417,6 +1426,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1429,8 +1439,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 +1457,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,14 +1478,14 @@ 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=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -1513,17 +1525,15 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
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 +1560,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=
|
||||
@@ -1581,11 +1592,6 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -1598,8 +1604,6 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -1616,8 +1620,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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/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=
|
||||
@@ -1658,6 +1662,10 @@ mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
|
||||
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
|
||||
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
|
||||
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
@@ -26,7 +27,7 @@ type Settings struct {
|
||||
DirFilterSizes string // -fs dirlist: body sizes to drop
|
||||
DirFilterWords string // -fw dirlist: word counts to drop
|
||||
DirFilterRegex string // -fr dirlist: regex; body match drops response
|
||||
DirCalibrate bool // -ac dirlist: auto-calibrate soft-404 baseline
|
||||
Calibrate bool // -ac auto-calibrate the soft-404 baseline (dirlist, sql)
|
||||
DirWordlist string // -w dirlist: custom wordlist (file path or url)
|
||||
DirExtensions string // -e dirlist: extensions appended to each word
|
||||
Dnslist string
|
||||
@@ -110,9 +111,9 @@ const (
|
||||
Full
|
||||
)
|
||||
|
||||
func Parse() *Settings {
|
||||
settings := &Settings{}
|
||||
|
||||
// registerFlags builds the flag set for the given settings without parsing it,
|
||||
// so callers (Parse and tests) can inspect the registered flags.
|
||||
func registerFlags(settings *Settings) *goflags.FlagSet {
|
||||
flagSet := goflags.NewFlagSet()
|
||||
flagSet.SetDescription("a blazing-fast pentesting (recon/exploitation) suite")
|
||||
|
||||
@@ -130,7 +131,7 @@ func Parse() *Settings {
|
||||
flagSet.StringVar(&settings.DirFilterSizes, "fs", "", "Dirlist: filter out responses of these body sizes (comma list)"),
|
||||
flagSet.StringVar(&settings.DirFilterWords, "fw", "", "Dirlist: filter out responses with these word counts (comma list)"),
|
||||
flagSet.StringVar(&settings.DirFilterRegex, "fr", "", "Dirlist: filter out responses whose body matches this regex"),
|
||||
flagSet.BoolVar(&settings.DirCalibrate, "ac", false, "Dirlist: auto-calibrate the soft-404 wildcard baseline"),
|
||||
flagSet.BoolVar(&settings.Calibrate, "ac", false, "Auto-calibrate the soft-404 wildcard baseline (dirlist, sql)"),
|
||||
flagSet.StringVar(&settings.DirWordlist, "w", "", "Dirlist: custom wordlist (local file path or url; overrides -dirlist size)"),
|
||||
flagSet.StringVar(&settings.DirExtensions, "e", "", "Dirlist: extensions appended to each word (comma list, e.g. php,bak,env)"),
|
||||
flagSet.EnumVar(&settings.Dnslist, "dnslist", Nil, "DNS fuzzing scan size (small/medium/large)", listSizes),
|
||||
@@ -169,7 +170,7 @@ func Parse() *Settings {
|
||||
flagSet.DurationVarP(&settings.Timeout, "timeout", "t", 10*time.Second, "HTTP request timeout"),
|
||||
flagSet.StringVarP(&settings.LogDir, "log", "l", "", "Directory to store logs in"),
|
||||
flagSet.IntVar(&settings.Threads, "threads", 10, "Number of threads to run scans on"),
|
||||
flagSet.StringVar(&settings.Template, "template", "", "Sif runtime template to use"),
|
||||
flagSet.StringVar(&settings.Template, "template", "", "Load scan settings from a template (preset minimal/recon/full, or a local yaml file)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("http", "HTTP",
|
||||
@@ -194,7 +195,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",
|
||||
@@ -204,8 +205,32 @@ func Parse() *Settings {
|
||||
flagSet.BoolVarP(&settings.ListModules, "list-modules", "lm", false, "List available modules and exit"),
|
||||
)
|
||||
|
||||
if err := flagSet.Parse(); err != nil {
|
||||
log.Fatalf("Could not parse flags: %s", err)
|
||||
return flagSet
|
||||
}
|
||||
|
||||
func Parse() *Settings {
|
||||
settings := &Settings{}
|
||||
flagSet := registerFlags(settings)
|
||||
|
||||
// -template presets a batch of scans from a yaml file or named preset; point
|
||||
// goflags at it before Parse so it merges as config (cli flags still win) and
|
||||
// replaces the ambient config for this run.
|
||||
templatePath, cleanup, err := templateConfigPath(os.Args[1:])
|
||||
if err != nil {
|
||||
log.Fatalf("Could not load template: %s", err)
|
||||
}
|
||||
if templatePath != "" {
|
||||
flagSet.SetConfigFilePath(templatePath)
|
||||
}
|
||||
|
||||
// Parse merges the template config synchronously, so a temp preset file can
|
||||
// be removed right after, before any fatal exit (no leaking defer).
|
||||
parseErr := flagSet.Parse()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
if parseErr != nil {
|
||||
log.Fatalf("Could not parse flags: %s", parseErr)
|
||||
}
|
||||
|
||||
// threads feeds wg.Add directly; floor it so 0 isn't a silent no-op and a
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates/*.yaml
|
||||
var embeddedTemplates embed.FS
|
||||
|
||||
// presetNames are the templates shipped in the binary, listed in help and
|
||||
// error text. each presets a batch of scans without listing every flag.
|
||||
var presetNames = []string{"minimal", "recon", "full"}
|
||||
|
||||
// templateConfigPath resolves the -template value into a config file path for
|
||||
// goflags to merge, plus a cleanup to run after Parse (embedded presets are
|
||||
// written to a temp file). it returns "" when -template is unset.
|
||||
func templateConfigPath(args []string) (string, func(), error) {
|
||||
value := templateFlagValue(args)
|
||||
if value == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
return resolveTemplate(value)
|
||||
}
|
||||
|
||||
// templateFlagValue pulls the -template value out of raw args; the config path
|
||||
// has to be known before Parse, so it cannot come from the parsed flag itself.
|
||||
func templateFlagValue(args []string) string {
|
||||
for i, arg := range args {
|
||||
if arg == "-template" || arg == "--template" {
|
||||
if i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if v, ok := strings.CutPrefix(arg, "-template="); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(arg, "--template="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveTemplate turns the -template value into a config file path. an existing
|
||||
// local file wins; a named preset is materialized from the embedded set; a
|
||||
// path-shaped miss or an unknown name is a hard error.
|
||||
func resolveTemplate(value string) (string, func(), error) {
|
||||
info, err := os.Stat(value) //nolint:gosec // G304: user-supplied local template path, by design (same as the -f/-w wordlist paths)
|
||||
switch {
|
||||
case err == nil && info.IsDir():
|
||||
return "", nil, fmt.Errorf("template path %q is a directory", value)
|
||||
case err == nil:
|
||||
return value, nil, nil
|
||||
}
|
||||
if data, ok := embeddedPreset(value); ok {
|
||||
return materializePreset(data)
|
||||
}
|
||||
if looksLikePath(value) {
|
||||
return "", nil, fmt.Errorf("template file %q not found", value)
|
||||
}
|
||||
return "", nil, fmt.Errorf("unknown template %q; use a local yaml file or one of: %s",
|
||||
value, strings.Join(presetNames, ", "))
|
||||
}
|
||||
|
||||
// embeddedPreset returns the bytes of a named preset shipped in the binary.
|
||||
func embeddedPreset(name string) ([]byte, bool) {
|
||||
data, err := embeddedTemplates.ReadFile("templates/" + name + ".yaml")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
// materializePreset writes preset bytes to a temp file so goflags, which merges
|
||||
// a config by path, can read it; the cleanup removes the file after Parse.
|
||||
func materializePreset(data []byte) (string, func(), error) {
|
||||
file, err := os.CreateTemp("", "sif-template-*.yaml")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() { _ = os.Remove(file.Name()) }
|
||||
if _, err := file.Write(data); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
return file.Name(), cleanup, nil
|
||||
}
|
||||
|
||||
// looksLikePath reports whether the value addresses a file rather than a named
|
||||
// preset: a path separator or a yaml suffix marks a file.
|
||||
func looksLikePath(value string) bool {
|
||||
if strings.ContainsAny(value, `/\`) {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(value, ".yaml") || strings.HasSuffix(value, ".yml")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/goflags"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// writeTemplate drops a yaml template in a temp dir and returns its path.
|
||||
func writeTemplate(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "tmpl.yaml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write template: %s", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// loadPreset registers the real flags, merges the named embedded preset, and
|
||||
// returns the resulting settings (no cli scan flags set).
|
||||
func loadPreset(t *testing.T, name string) *Settings {
|
||||
t.Helper()
|
||||
goflags.DisableAutoConfigMigration = true
|
||||
path, cleanup, err := resolveTemplate(name)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve %s: %s", name, err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
settings := &Settings{}
|
||||
flagSet := registerFlags(settings)
|
||||
flagSet.SetConfigFilePath(path)
|
||||
if err := flagSet.Parse("-silent"); err != nil {
|
||||
t.Fatalf("parse %s: %s", name, err)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
// every key in an embedded preset must be a real flag long-name. goflags drops
|
||||
// unknown config keys silently, so a typo would otherwise ship as a dead no-op.
|
||||
func TestPresetKeysAreRegisteredFlags(t *testing.T) {
|
||||
valid := map[string]bool{}
|
||||
registerFlags(&Settings{}).CommandLine.VisitAll(func(f *flag.Flag) {
|
||||
valid[f.Name] = true
|
||||
})
|
||||
|
||||
for _, name := range presetNames {
|
||||
data, ok := embeddedPreset(name)
|
||||
if !ok {
|
||||
t.Errorf("preset %q is not embedded", name)
|
||||
continue
|
||||
}
|
||||
var keys map[string]any
|
||||
if err := yaml.Unmarshal(data, &keys); err != nil {
|
||||
t.Errorf("preset %q is not valid yaml: %s", name, err)
|
||||
continue
|
||||
}
|
||||
for key := range keys {
|
||||
if !valid[key] {
|
||||
t.Errorf("preset %q references unknown flag %q", name, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetMinimal(t *testing.T) {
|
||||
s := loadPreset(t, "minimal")
|
||||
if !s.Probe || !s.Headers || !s.Favicon {
|
||||
t.Errorf("minimal should enable probe/headers/favicon, got probe=%v headers=%v favicon=%v",
|
||||
s.Probe, s.Headers, s.Favicon)
|
||||
}
|
||||
if s.XSS || s.SQL || s.Nuclei {
|
||||
t.Error("minimal should not enable heavy or intrusive scans")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetReconIsNonIntrusive(t *testing.T) {
|
||||
s := loadPreset(t, "recon")
|
||||
if !s.Passive || !s.Whois || !s.CMS || !s.Probe {
|
||||
t.Errorf("recon should enable passive/whois/cms/probe, got %v/%v/%v/%v",
|
||||
s.Passive, s.Whois, s.CMS, s.Probe)
|
||||
}
|
||||
if s.XSS || s.SQL || s.LFI || s.Redirect {
|
||||
t.Errorf("recon must not enable payload-injecting scans: xss=%v sql=%v lfi=%v redirect=%v",
|
||||
s.XSS, s.SQL, s.LFI, s.Redirect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetFull(t *testing.T) {
|
||||
s := loadPreset(t, "full")
|
||||
if !s.XSS || !s.SQL || !s.LFI || !s.Redirect {
|
||||
t.Error("full should enable the intrusive scans")
|
||||
}
|
||||
if s.Dirlist != "large" || s.Ports != "full" {
|
||||
t.Errorf("full should set dirlist=large ports=full, got dirlist=%q ports=%q",
|
||||
s.Dirlist, s.Ports)
|
||||
}
|
||||
}
|
||||
|
||||
// the template merges as the goflags config: it fills flags left at their
|
||||
// default, an explicit cli flag still wins, and an untouched flag stays put.
|
||||
func TestTemplateConfigPrecedence(t *testing.T) {
|
||||
goflags.DisableAutoConfigMigration = true
|
||||
tmpl := writeTemplate(t, "cms: true\nthreads: 99\n")
|
||||
|
||||
var cms, sql bool
|
||||
var threads int
|
||||
flagSet := goflags.NewFlagSet()
|
||||
flagSet.BoolVar(&cms, "cms", false, "")
|
||||
flagSet.BoolVar(&sql, "sql", false, "")
|
||||
flagSet.IntVar(&threads, "threads", 10, "")
|
||||
|
||||
flagSet.SetConfigFilePath(tmpl)
|
||||
if err := flagSet.Parse("-threads", "5"); err != nil {
|
||||
t.Fatalf("parse: %s", err)
|
||||
}
|
||||
|
||||
if !cms {
|
||||
t.Error("expected template to set cms=true")
|
||||
}
|
||||
if threads != 5 {
|
||||
t.Errorf("expected cli threads 5 to win over template, got %d", threads)
|
||||
}
|
||||
if sql {
|
||||
t.Error("expected sql left untouched to stay false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFlagValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"long with space", []string{"-template", "a.yaml"}, "a.yaml"},
|
||||
{"double dash with space", []string{"--template", "b.yaml"}, "b.yaml"},
|
||||
{"long with equals", []string{"-template=c.yaml"}, "c.yaml"},
|
||||
{"double dash with equals", []string{"--template=d.yaml"}, "d.yaml"},
|
||||
{"absent", []string{"-u", "x"}, ""},
|
||||
{"trailing without value", []string{"-u", "x", "-template"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := templateFlagValue(tc.args); got != tc.want {
|
||||
t.Errorf("expected %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateExistingFile(t *testing.T) {
|
||||
path := writeTemplate(t, "cms: true\n")
|
||||
got, cleanup, err := resolveTemplate(path)
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTemplate: %s", err)
|
||||
}
|
||||
if got != path {
|
||||
t.Errorf("expected %q, got %q", path, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateNamedPreset(t *testing.T) {
|
||||
path, cleanup, err := resolveTemplate("recon")
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("recon preset should resolve: %s", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("expected a materialized preset path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateMissingFile(t *testing.T) {
|
||||
if _, _, err := resolveTemplate("./does-not-exist.yaml"); err == nil {
|
||||
t.Fatal("expected an error for a missing template file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateDirectory(t *testing.T) {
|
||||
if _, _, err := resolveTemplate(t.TempDir()); err == nil {
|
||||
t.Fatal("expected an error for a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateUnknownName(t *testing.T) {
|
||||
if _, _, err := resolveTemplate("bogus"); err == nil {
|
||||
t.Fatal("expected an error for an unknown template name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# full: thorough and active, including intrusive probes that inject payloads
|
||||
# (xss, sql, lfi, redirect). api-key scans (shodan, securitytrails) stay opt-in
|
||||
# via their own flags.
|
||||
passive: true
|
||||
whois: true
|
||||
dork: true
|
||||
favicon: true
|
||||
headers: true
|
||||
security-headers: true
|
||||
cms: true
|
||||
framework: true
|
||||
probe: true
|
||||
git: true
|
||||
js: true
|
||||
nuclei: true
|
||||
openapi: true
|
||||
cors: true
|
||||
jwt: true
|
||||
c3: true
|
||||
st: true
|
||||
crawl: true
|
||||
sql: true
|
||||
lfi: true
|
||||
xss: true
|
||||
redirect: true
|
||||
dirlist: large
|
||||
dnslist: large
|
||||
ports: full
|
||||
@@ -0,0 +1,4 @@
|
||||
# minimal: fast liveness + fingerprint, a handful of benign GETs per target.
|
||||
probe: true
|
||||
headers: true
|
||||
favicon: true
|
||||
@@ -0,0 +1,11 @@
|
||||
# recon: broad non-intrusive discovery (light traffic, no attack payloads).
|
||||
passive: true
|
||||
whois: true
|
||||
dork: true
|
||||
favicon: true
|
||||
headers: true
|
||||
security-headers: true
|
||||
cms: true
|
||||
framework: true
|
||||
probe: true
|
||||
dnslist: small
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
// Package fingerprint holds small response-fingerprinting primitives shared by
|
||||
// the scan checks and the module engine, so both compute identical values.
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/twmb/murmur3"
|
||||
)
|
||||
|
||||
// b64LineLen is python's base64.encodebytes line width. mmh3/shodan hash the
|
||||
// chunked base64 (newline every 76 chars, trailing newline), so we must wrap at
|
||||
// exactly this width to land on the same hash.
|
||||
const b64LineLen = 76
|
||||
|
||||
// FaviconHash computes shodan's favicon hash: murmur3 32-bit over the python
|
||||
// base64.encodebytes encoding of the raw icon (newline every 76 chars plus a
|
||||
// trailing newline), reinterpreted as a signed int32 (both load-bearing, golden-pinned).
|
||||
func FaviconHash(data []byte) int32 {
|
||||
encoded := encodeFaviconBase64(data)
|
||||
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
|
||||
}
|
||||
|
||||
// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
|
||||
// a newline inserted every 76 output characters and a trailing newline. this is
|
||||
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.
|
||||
func encodeFaviconBase64(data []byte) []byte {
|
||||
raw := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
var b strings.Builder
|
||||
// final size: the base64 body plus one '\n' per (full or partial) 76-char
|
||||
// line. preallocate so the builder never regrows mid-loop.
|
||||
b.Grow(len(raw) + len(raw)/b64LineLen + 1)
|
||||
for i := 0; i < len(raw); i += b64LineLen {
|
||||
end := i + b64LineLen
|
||||
if end > len(raw) {
|
||||
end = len(raw)
|
||||
}
|
||||
b.WriteString(raw[i:end])
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// goldenFaviconBytes is a fixed payload long enough to span multiple base64
|
||||
// lines, so the python-style 76-char chunking is actually exercised by the hash.
|
||||
var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
|
||||
|
||||
// goldenFaviconHash is the pinned shodan mmh3 hash of goldenFaviconBytes: the python
|
||||
// base64.encodebytes byte stream (76-char lines + trailing newline) through murmur3-32,
|
||||
// reinterpreted as a signed int32. if the chunking or signedness regress, this test fails.
|
||||
const goldenFaviconHash int32 = -1554620260
|
||||
|
||||
// goldenHelloHash pins a short single-line case so a regression in the trailing
|
||||
// newline (which the small case still has) is caught independently.
|
||||
const goldenHelloHash int32 = 1155597304
|
||||
|
||||
func TestFaviconHashGolden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []byte
|
||||
want int32
|
||||
}{
|
||||
{name: "multi-line fixture", in: goldenFaviconBytes, want: goldenFaviconHash},
|
||||
{name: "single-line hello", in: []byte("hello"), want: goldenHelloHash},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := FaviconHash(tt.in); got != tt.want {
|
||||
t.Errorf("FaviconHash = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFaviconBase64Chunking pins the encode step against python's
|
||||
// base64.encodebytes: a 60-byte input encodes to 80 base64 chars, so it must
|
||||
// wrap into two newline-terminated lines.
|
||||
func TestFaviconBase64Chunking(t *testing.T) {
|
||||
in := []byte(strings.Repeat("A", 60))
|
||||
got := string(encodeFaviconBase64(in))
|
||||
|
||||
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 wrapped lines, got %d: %q", len(lines), got)
|
||||
}
|
||||
if len(lines[0]) != b64LineLen {
|
||||
t.Errorf("first line = %d chars, want %d", len(lines[0]), b64LineLen)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\n") {
|
||||
t.Errorf("encoding must end in a trailing newline, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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,148 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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}
|
||||
reqs, err := generateHTTPRequests(target, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("generateHTTPRequests: %v", err)
|
||||
}
|
||||
got := reqURLs(reqs)
|
||||
want := append([]string(nil), tt.want...)
|
||||
sort.Strings(want)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
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,198 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runBuildCredModule(t *testing.T, file string, status int, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule(file)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", file, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func buildCredExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestBuildToolCredentialExposureModules(t *testing.T) {
|
||||
const maven = "../../modules/recon/maven-settings-exposure.yaml"
|
||||
const gradle = "../../modules/recon/gradle-properties-exposure.yaml"
|
||||
const nuget = "../../modules/recon/nuget-config-exposure.yaml"
|
||||
|
||||
mavenSettings := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<settings xmlns=\"http://maven.apache.org/SETTINGS/1.0.0\">\n" +
|
||||
" <servers>\n <server>\n <id>nexus-releases</id>\n" +
|
||||
" <username>deploy</username>\n <password>S3cretDeployPass</password>\n" +
|
||||
" </server>\n </servers>\n</settings>\n"
|
||||
|
||||
gradleProps := "org.gradle.jvmargs=-Xmx2g\nossrhUsername=deployer\n" +
|
||||
"ossrhPassword=mySonatypeSecret\nsigning.password=mySigningSecret\n"
|
||||
|
||||
nugetConfig := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n" +
|
||||
" <packageSourceCredentials>\n <MyFeed>\n" +
|
||||
" <add key=\"Username\" value=\"deploy\" />\n" +
|
||||
" <add key=\"ClearTextPassword\" value=\"S3cretFeedPass\" />\n" +
|
||||
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
|
||||
|
||||
t.Run("an exposed maven settings leaks the server username", func(t *testing.T) {
|
||||
res := runBuildCredModule(t, maven, 200, mavenSettings)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a maven finding")
|
||||
}
|
||||
if v := buildCredExtract(res, "maven_username"); v != "deploy" {
|
||||
t.Errorf("maven_username=%q, want deploy", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed gradle properties leaks the secret property", func(t *testing.T) {
|
||||
res := runBuildCredModule(t, gradle, 200, gradleProps)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a gradle finding")
|
||||
}
|
||||
if v := buildCredExtract(res, "gradle_secret_property"); v != "ossrhPassword" {
|
||||
t.Errorf("gradle_secret_property=%q, want ossrhPassword", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed nuget config leaks the feed username", func(t *testing.T) {
|
||||
res := runBuildCredModule(t, nuget, 200, nugetConfig)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a nuget finding")
|
||||
}
|
||||
if v := buildCredExtract(res, "nuget_username"); v != "deploy" {
|
||||
t.Errorf("nuget_username=%q, want deploy", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a maven settings with mirrors but no password is not flagged", func(t *testing.T) {
|
||||
body := "<settings>\n <mirrors>\n <mirror>\n <id>central</id>\n" +
|
||||
" <url>https://repo.example.com/maven2</url>\n </mirror>\n </mirrors>\n</settings>\n"
|
||||
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a settings without a password should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a maven settings is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre><settings><server><password>x</password></server></settings></pre></body></html>"
|
||||
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html maven tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a gradle properties with no credential property is not flagged", func(t *testing.T) {
|
||||
body := "org.gradle.jvmargs=-Xmx2g\nversion=1.0.0\norg.gradle.daemon=true\n"
|
||||
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a non credential properties file should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a comment naming a password is not a credential property", func(t *testing.T) {
|
||||
body := "# set your password=here before building\norg.gradle.daemon=true\n"
|
||||
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a comment line should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty password property is not flagged", func(t *testing.T) {
|
||||
body := "signing.password=\nsigning.keyId=24875D73\n"
|
||||
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an empty value should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a gradle property is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html>\n<html><body><pre>\nossrhPassword=mySonatypeSecret\n</pre></body></html>\n"
|
||||
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html gradle tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a nuget config without a credentials section is not flagged", func(t *testing.T) {
|
||||
body := "<configuration>\n <packageSources>\n" +
|
||||
" <add key=\"nuget.org\" value=\"https://api.nuget.org/v3/index.json\" />\n" +
|
||||
" </packageSources>\n</configuration>\n"
|
||||
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a config without credentials should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a nuget credentials section without a password is not flagged", func(t *testing.T) {
|
||||
body := "<configuration>\n <packageSourceCredentials>\n <MyFeed>\n" +
|
||||
" <add key=\"Username\" value=\"deploy\" />\n" +
|
||||
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
|
||||
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a credentials section without a password should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an appsettings password is not a nuget feed credential", func(t *testing.T) {
|
||||
body := "<configuration>\n <appSettings>\n" +
|
||||
" <add key=\"Password\" value=\"appsecret\" />\n" +
|
||||
" </appSettings>\n</configuration>\n"
|
||||
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an appsettings password should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a nuget config is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre><packageSourceCredentials><add key=\"ClearTextPassword\" value=\"x\" /></packageSourceCredentials></pre></body></html>"
|
||||
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html nuget tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{maven, gradle, nuget} {
|
||||
if res := runBuildCredModule(t, file, 200, "ok"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a 404 is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{maven, gradle, nuget} {
|
||||
if res := runBuildCredModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runCMSCfgModule(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 cmsCfgExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestCMSConfigExposureModules(t *testing.T) {
|
||||
const joomla = "../../modules/recon/joomla-config-exposure.yaml"
|
||||
const drupal = "../../modules/recon/drupal-config-exposure.yaml"
|
||||
const magento = "../../modules/recon/magento-config-exposure.yaml"
|
||||
|
||||
joomlaConfig := "<?php\nclass JConfig {\n\tpublic $offline = '0';\n" +
|
||||
"\tpublic $host = 'localhost';\n\tpublic $user = 'joomla_user';\n" +
|
||||
"\tpublic $password = 'S3cretJoomlaPass';\n\tpublic $db = 'joomla_db';\n" +
|
||||
"\tpublic $dbprefix = 'jos_';\n\tpublic $secret = 'AbCdEfGhIjKlMnOp';\n}\n"
|
||||
|
||||
drupalConfig := "<?php\n$databases['default']['default'] = array (\n" +
|
||||
" 'database' => 'drupal_db',\n 'username' => 'drupal_user',\n" +
|
||||
" 'password' => 'S3cretDrupalPass',\n 'host' => 'localhost',\n" +
|
||||
" 'driver' => 'mysql',\n);\n$settings['hash_salt'] = 'longrandomhashsalt';\n"
|
||||
|
||||
magentoConfig := "<?php\nreturn [\n 'backend' => ['frontName' => 'admin_x7y'],\n" +
|
||||
" 'crypt' => ['key' => 'a1b2c3d4e5f6g7h8'],\n 'db' => [\n" +
|
||||
" 'connection' => ['default' => [\n 'host' => 'localhost',\n" +
|
||||
" 'dbname' => 'magento',\n 'username' => 'magento_user',\n" +
|
||||
" 'password' => 'S3cretMagentoPass',\n ]],\n ],\n 'MAGE_MODE' => 'production',\n];\n"
|
||||
|
||||
t.Run("an exposed joomla configuration leaks the password", func(t *testing.T) {
|
||||
res := runCMSCfgModule(t, joomla, 200, joomlaConfig)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a joomla finding")
|
||||
}
|
||||
if v := cmsCfgExtract(res, "joomla_password"); v != "S3cretJoomlaPass" {
|
||||
t.Errorf("joomla_password=%q, want S3cretJoomlaPass", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed drupal settings leaks the password", func(t *testing.T) {
|
||||
res := runCMSCfgModule(t, drupal, 200, drupalConfig)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a drupal finding")
|
||||
}
|
||||
if v := cmsCfgExtract(res, "drupal_password"); v != "S3cretDrupalPass" {
|
||||
t.Errorf("drupal_password=%q, want S3cretDrupalPass", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed magento env leaks the crypt key", func(t *testing.T) {
|
||||
res := runCMSCfgModule(t, magento, 200, magentoConfig)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a magento finding")
|
||||
}
|
||||
if v := cmsCfgExtract(res, "magento_crypt_key"); v != "a1b2c3d4e5f6g7h8" {
|
||||
t.Errorf("magento_crypt_key=%q, want a1b2c3d4e5f6g7h8", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a joomla config missing the password is not flagged", func(t *testing.T) {
|
||||
body := "<?php\nclass JConfig {\n\tpublic $host = 'localhost';\n" +
|
||||
"\tpublic $db = 'joomla_db';\n\tpublic $dbprefix = 'jos_';\n}\n"
|
||||
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a config without a password should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a php class with a public password but no jconfig is not joomla", func(t *testing.T) {
|
||||
body := "<?php\nclass MyAuth {\n\tpublic $password = 'changeme';\n" +
|
||||
"\tpublic $username = 'admin';\n}\n"
|
||||
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic class should not match joomla, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a php array with a password but no databases is not drupal", func(t *testing.T) {
|
||||
body := "<?php\n$config = array('password' => 'x', 'host' => 'y');\n"
|
||||
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic array should not match drupal, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a drupal databases array without a password is not flagged", func(t *testing.T) {
|
||||
body := "<?php\n$databases['default']['default'] = array (\n" +
|
||||
" 'database' => 'drupal_db',\n 'host' => 'localhost',\n 'driver' => 'mysql',\n);\n"
|
||||
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a databases array without a password should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a php return array with a password but no magento markers is not flagged", func(t *testing.T) {
|
||||
body := "<?php\nreturn ['db' => ['password' => 'secret', 'host' => 'localhost']];\n"
|
||||
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic return array should not match magento, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a magento config without a credential is not flagged", func(t *testing.T) {
|
||||
body := "<?php\nreturn ['MAGE_MODE' => 'production', 'db' => ['host' => 'localhost']];\n"
|
||||
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a magento config without a credential should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a joomla config is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>class JConfig { public $password = 'x'; public $db = 'y'; }</pre></body></html>"
|
||||
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html joomla tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a drupal settings using env indirection is not a literal password leak", func(t *testing.T) {
|
||||
body := "<?php\n$databases['default']['default'] = array (\n" +
|
||||
" 'database' => 'drupal_db',\n 'username' => 'drupal_user',\n" +
|
||||
" 'password' => getenv('DB_PASS'),\n 'host' => 'localhost',\n);\n"
|
||||
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("env indirection should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a magento env with a cloud placeholder key is not a literal leak", func(t *testing.T) {
|
||||
body := "<?php\nreturn ['crypt' => ['key' => '#env.CRYPT_KEY#'], 'MAGE_MODE' => 'production'];\n"
|
||||
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a cloud placeholder should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a magento env with a placeholder key but a literal password is flagged not mis-extracted", func(t *testing.T) {
|
||||
body := "<?php\nreturn ['crypt' => ['key' => '#env.CRYPT_KEY#'],\n" +
|
||||
" 'db' => ['connection' => ['default' => ['password' => 'RealDbPass']]],\n" +
|
||||
" 'MAGE_MODE' => 'production'];\n"
|
||||
res := runCMSCfgModule(t, magento, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a magento finding on the literal password")
|
||||
}
|
||||
if v := cmsCfgExtract(res, "magento_crypt_key"); v == "#env.CRYPT_KEY#" {
|
||||
t.Errorf("extractor surfaced the placeholder %q as the crypt key", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a drupal config is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>$databases['default']['default'] = array('password' => 'x');</pre></body></html>"
|
||||
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html drupal tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a magento config is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>'crypt' => ['key' => 'x'], 'MAGE_MODE' => 'production'</pre></body></html>"
|
||||
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html magento tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{joomla, drupal, magento} {
|
||||
if res := runCMSCfgModule(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{joomla, drupal, magento} {
|
||||
if res := runCMSCfgModule(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,113 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runCredModule(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 credExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestCredentialExposureModules(t *testing.T) {
|
||||
const aws = "../../modules/recon/aws-credentials-exposure.yaml"
|
||||
const npmrc = "../../modules/recon/npmrc-exposure.yaml"
|
||||
const docker = "../../modules/recon/docker-config-exposure.yaml"
|
||||
|
||||
t.Run("aws credentials leak the access key id", func(t *testing.T) {
|
||||
body := "[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\n" +
|
||||
"aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n"
|
||||
res := runCredModule(t, aws, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an aws credentials finding")
|
||||
}
|
||||
if v := credExtract(res, "aws_access_key_id"); v != "AKIAIOSFODNN7EXAMPLE" {
|
||||
t.Errorf("aws_access_key_id=%q, want AKIAIOSFODNN7EXAMPLE", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("npmrc leaks the registry of an auth token", func(t *testing.T) {
|
||||
body := "//registry.npmjs.org/:_authToken=npm_AbCdEf0123456789AbCdEf0123456789\n"
|
||||
res := runCredModule(t, npmrc, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an npmrc finding")
|
||||
}
|
||||
if v := credExtract(res, "npm_registry"); v != "registry.npmjs.org" {
|
||||
t.Errorf("npm_registry=%q, want registry.npmjs.org", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("docker config leaks the registry host", func(t *testing.T) {
|
||||
body := `{"auths":{"registry.example.com":{"auth":"dXNlcm5hbWU6c3VwZXJzZWNyZXRwYXNz"}}}`
|
||||
res := runCredModule(t, docker, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a docker config finding")
|
||||
}
|
||||
if v := credExtract(res, "docker_registry"); v != "registry.example.com" {
|
||||
t.Errorf("docker_registry=%q, want registry.example.com", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("html page mentioning the key name is not a leak", func(t *testing.T) {
|
||||
body := `<html><head><title>Docs</title></head><body>` +
|
||||
`set your aws_secret_access_key in ~/.aws/credentials</body></html>`
|
||||
if res := runCredModule(t, aws, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html doc mentioning the key should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a 404 is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{aws, npmrc, docker} {
|
||||
if res := runCredModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 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{aws, npmrc, docker} {
|
||||
if res := runCredModule(t, file, 200, "nothing to see here"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a docker auth field holding a jwt is not a leak", func(t *testing.T) {
|
||||
body := `{"token":"x","auth":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"}`
|
||||
if res := runCredModule(t, docker, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a jwt in an auth field should not match, got %d findings", 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,88 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runDBModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule(file)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range headers {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", file, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func dbExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDBPanelModules(t *testing.T) {
|
||||
const adminer = "../../modules/info/adminer-panel.yaml"
|
||||
const phpmyadmin = "../../modules/info/phpmyadmin-panel.yaml"
|
||||
|
||||
adminerLogin := `<form action=""><input type="hidden" name="auth[driver]" value="server">` +
|
||||
`<input name="auth[username]"></form>` +
|
||||
`<p class="links"><a href="https://www.adminer.org/">Adminer</a> <span class="version">4.8.1</span></p>`
|
||||
pmaLogin := `<link rel="stylesheet" href="themes/pmahomme/css/theme.css">` +
|
||||
`<input type="text" name="pma_username"><script>var data = {"PMA_VERSION":"5.2.1"};</script>`
|
||||
|
||||
t.Run("adminer login", func(t *testing.T) {
|
||||
res := runDBModule(t, adminer, 200, nil, adminerLogin)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an adminer finding")
|
||||
}
|
||||
if v := dbExtract(res, "adminer_version"); v != "4.8.1" {
|
||||
t.Errorf("adminer_version=%q, want 4.8.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adminer unrelated page", func(t *testing.T) {
|
||||
if res := runDBModule(t, adminer, 200, nil, "<html><body>nothing</body></html>"); len(res.Findings) > 0 {
|
||||
t.Errorf("unrelated page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("phpmyadmin login", func(t *testing.T) {
|
||||
res := runDBModule(t, phpmyadmin, 200, map[string]string{"Set-Cookie": "phpMyAdmin=abc123; path=/"}, pmaLogin)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a phpmyadmin finding")
|
||||
}
|
||||
if v := dbExtract(res, "phpmyadmin_version"); v != "5.2.1" {
|
||||
t.Errorf("phpmyadmin_version=%q, want 5.2.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("phpmyadmin unrelated page", func(t *testing.T) {
|
||||
if res := runDBModule(t, phpmyadmin, 200, nil, "<html><body>nothing</body></html>"); len(res.Findings) > 0 {
|
||||
t.Errorf("unrelated page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runDebugModule(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 debugExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDebugExposureModules(t *testing.T) {
|
||||
const ignition = "../../modules/recon/laravel-ignition-exposure.yaml"
|
||||
const profiler = "../../modules/recon/symfony-profiler-exposure.yaml"
|
||||
const heapdump = "../../modules/recon/spring-heapdump-exposure.yaml"
|
||||
|
||||
t.Run("ignition health check exposes command execution", func(t *testing.T) {
|
||||
res := runDebugModule(t, ignition, 200, `{"can_execute_commands":true,"config":{}}`)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an ignition finding")
|
||||
}
|
||||
if v := debugExtract(res, "can_execute_commands"); v != "true" {
|
||||
t.Errorf("can_execute_commands=%q, want true", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignition exposed with debug off still flags and extracts false", func(t *testing.T) {
|
||||
res := runDebugModule(t, ignition, 200, `{"can_execute_commands":false}`)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an ignition finding even when command execution is off")
|
||||
}
|
||||
if v := debugExtract(res, "can_execute_commands"); v != "false" {
|
||||
t.Errorf("can_execute_commands=%q, want false", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symfony profiler exposes a request token", func(t *testing.T) {
|
||||
body := `<html><head><title>Symfony Profiler</title></head><body>` +
|
||||
`<a href="/_profiler/5f3a2b">GET /</a></body></html>`
|
||||
res := runDebugModule(t, profiler, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a symfony profiler finding")
|
||||
}
|
||||
if v := debugExtract(res, "profiler_token"); v != "5f3a2b" {
|
||||
t.Errorf("profiler_token=%q, want 5f3a2b", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spring heap dump exposes the hprof magic", func(t *testing.T) {
|
||||
body := "JAVA PROFILE 1.0.2\x00\x00\x00\x08heap bytes follow"
|
||||
res := runDebugModule(t, heapdump, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a heap dump finding")
|
||||
}
|
||||
if v := debugExtract(res, "hprof_version"); v != "1.0.2" {
|
||||
t.Errorf("hprof_version=%q, want 1.0.2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the hprof magic must be at the start not merely present", func(t *testing.T) {
|
||||
body := "<html><body>docs about the JAVA PROFILE 1.0.2 hprof header</body></html>"
|
||||
if res := runDebugModule(t, heapdump, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("the magic away from the start should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a page that only names ignition is not the endpoint", func(t *testing.T) {
|
||||
body := `<html><body>we use ignition to render errors in development</body></html>`
|
||||
if res := runDebugModule(t, ignition, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a prose mention 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{ignition, profiler, heapdump} {
|
||||
if res := runDebugModule(t, file, 200, "<html><body>plain</body></html>"); 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{ignition, profiler, heapdump} {
|
||||
if res := runDebugModule(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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runDotfileModule(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 dotfileExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDotfileCredentialExposureModules(t *testing.T) {
|
||||
const netrc = "../../modules/recon/netrc-exposure.yaml"
|
||||
const pgpass = "../../modules/recon/pgpass-exposure.yaml"
|
||||
const mycnf = "../../modules/recon/mysql-client-config-exposure.yaml"
|
||||
|
||||
netrcBody := "machine api.example.com\n login deploy\n password s3cr3tP@ss\n" +
|
||||
"machine ftp.example.com\n login anon\n password anon@site\n"
|
||||
|
||||
pgpassBody := "db.example.com:5432:appdb:appuser:Sup3rSecret\n*:*:*:replication:replpass\n"
|
||||
|
||||
mycnfBody := "[client]\nuser=root\npassword=R00tPass!\nhost=127.0.0.1\nport=3306\n"
|
||||
|
||||
t.Run("an exposed netrc leaks the machine host", func(t *testing.T) {
|
||||
res := runDotfileModule(t, netrc, 200, netrcBody)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a netrc finding")
|
||||
}
|
||||
if v := dotfileExtract(res, "netrc_machine"); v != "api.example.com" {
|
||||
t.Errorf("netrc_machine=%q, want api.example.com", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed pgpass leaks the host", func(t *testing.T) {
|
||||
res := runDotfileModule(t, pgpass, 200, pgpassBody)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a pgpass finding")
|
||||
}
|
||||
if v := dotfileExtract(res, "pgpass_host"); v != "db.example.com" {
|
||||
t.Errorf("pgpass_host=%q, want db.example.com", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed my.cnf leaks the client user", func(t *testing.T) {
|
||||
res := runDotfileModule(t, mycnf, 200, mycnfBody)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a my.cnf finding")
|
||||
}
|
||||
if v := dotfileExtract(res, "mysql_user"); v != "root" {
|
||||
t.Errorf("mysql_user=%q, want root", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prose that names machine login and password out of order is not a netrc", func(t *testing.T) {
|
||||
body := "this machine requires a login; store the password securely"
|
||||
if res := runDotfileModule(t, netrc, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("out of order prose should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a netrc is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>machine api.example.com login deploy password s3cret</pre></body></html>"
|
||||
if res := runDotfileModule(t, netrc, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html netrc tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a yaml db config with colon keys is not a pgpass", func(t *testing.T) {
|
||||
body := "database:\n host: db.example.com\n port: 5432\n user: appuser\n password: secret\n"
|
||||
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a yaml db config should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a pgpass shaped line with a non numeric port is not flagged", func(t *testing.T) {
|
||||
body := "db.example.com:default:appdb:appuser:Sup3rSecret\n"
|
||||
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a non numeric port should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a multi line config with a number field does not match across lines", func(t *testing.T) {
|
||||
body := "timeout:30:seconds configured\nsee http://docs.example.com:8080 for details\n"
|
||||
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("fields must stay on one line, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a pgpass is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html>\n<html><body><pre>\ndb.example.com:5432:appdb:appuser:secret\n</pre></body></html>\n"
|
||||
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html pgpass tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a my.cnf client section without a password is not flagged", func(t *testing.T) {
|
||||
body := "[client]\nuser=root\nhost=localhost\nport=3306\n"
|
||||
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a section without a password should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a password line without a my.cnf section is not flagged", func(t *testing.T) {
|
||||
body := "password=hunter2\nfoo=bar\n"
|
||||
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a password without a section should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating a my.cnf is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>[client]\npassword=secret</pre></body></html>"
|
||||
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html my.cnf tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{netrc, pgpass, mycnf} {
|
||||
if res := runDotfileModule(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{netrc, pgpass, mycnf} {
|
||||
if res := runDotfileModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
// runEnvModule runs the env exposure module end to end against a server that
|
||||
// returns the same status and body for every path it requests.
|
||||
func runEnvModule(t *testing.T, status int, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule("../../modules/recon/env-file-exposure.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func envLeakedKey(res *modules.Result) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted["leaked_key"]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestEnvFileExposureModule(t *testing.T) {
|
||||
realEnv := "APP_NAME=Acme\nAPP_KEY=base64:Zm9vYmFy\nDB_PASSWORD=s3cr3t\nMAIL_PASSWORD=hunter2\n"
|
||||
htmlMentionsSecret := "<!DOCTYPE html>\n<html><head><title>Docs</title></head><body>" +
|
||||
"<code>APP_KEY=base64:...</code> put DB_PASSWORD= in your .env</body></html>"
|
||||
|
||||
t.Run("real env body leaks", func(t *testing.T) {
|
||||
res := runEnvModule(t, 200, realEnv)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a finding for a real .env body")
|
||||
}
|
||||
if key := envLeakedKey(res); key != "APP_KEY" {
|
||||
t.Errorf("leaked_key=%q, want APP_KEY", key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("html page mentioning a key is not a leak", func(t *testing.T) {
|
||||
if res := runEnvModule(t, 200, htmlMentionsSecret); len(res.Findings) > 0 {
|
||||
t.Errorf("html page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("secrets behind a 404 are not a leak", func(t *testing.T) {
|
||||
if res := runEnvModule(t, 404, realEnv); len(res.Findings) > 0 {
|
||||
t.Errorf("404 should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
}
|
||||
+172
-32
@@ -13,15 +13,19 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// MaxBodySize limits response body to prevent memory exhaustion.
|
||||
@@ -69,7 +73,10 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
|
||||
}
|
||||
|
||||
// Generate requests based on paths and payloads
|
||||
requests := generateHTTPRequests(target, cfg)
|
||||
requests, err := generateHTTPRequests(target, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine thread count
|
||||
threads := cfg.Threads
|
||||
@@ -123,9 +130,14 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
|
||||
}
|
||||
|
||||
// generateHTTPRequests creates all requests based on paths and payloads.
|
||||
func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
|
||||
func generateHTTPRequests(target string, cfg *HTTPConfig) ([]*httpRequest, error) {
|
||||
var requests []*httpRequest
|
||||
|
||||
paths, err := resolvePaths(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure target has no trailing slash
|
||||
target = strings.TrimSuffix(target, "/")
|
||||
|
||||
@@ -136,7 +148,7 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
|
||||
|
||||
// If no payloads, just use paths directly
|
||||
if len(cfg.Payloads) == 0 {
|
||||
for _, path := range cfg.Paths {
|
||||
for _, path := range paths {
|
||||
url := substituteVariables(path, target, "")
|
||||
requests = append(requests, &httpRequest{
|
||||
Method: method,
|
||||
@@ -146,26 +158,106 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
|
||||
Original: path,
|
||||
})
|
||||
}
|
||||
return requests
|
||||
return requests, nil
|
||||
}
|
||||
|
||||
// Generate requests with payloads
|
||||
for _, path := range cfg.Paths {
|
||||
// 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(paths)
|
||||
if len(cfg.Payloads) < n {
|
||||
n = len(cfg.Payloads)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
requests = append(requests, newPayloadRequest(method, target, paths[i], cfg.Payloads[i], cfg))
|
||||
}
|
||||
return requests, nil
|
||||
}
|
||||
|
||||
for _, path := range 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
|
||||
return requests, nil
|
||||
}
|
||||
|
||||
// resolvePaths expands a wordlist over any {{word}} path templates so one
|
||||
// "{{BaseURL}}/{{word}}" path fuzzes the whole list; paths without {{word}}
|
||||
// pass through literally. no wordlist leaves cfg.Paths untouched.
|
||||
func resolvePaths(cfg *HTTPConfig) ([]string, error) {
|
||||
if cfg.Wordlist == "" {
|
||||
return cfg.Paths, nil
|
||||
}
|
||||
|
||||
words, err := loadWordlist(cfg.Wordlist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, path := range cfg.Paths {
|
||||
if !strings.Contains(path, "{{word}}") && !strings.Contains(path, "{{Word}}") {
|
||||
paths = append(paths, path)
|
||||
continue
|
||||
}
|
||||
for _, word := range words {
|
||||
expanded := strings.ReplaceAll(path, "{{word}}", word)
|
||||
expanded = strings.ReplaceAll(expanded, "{{Word}}", word)
|
||||
paths = append(paths, expanded)
|
||||
}
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// loadWordlist reads non-empty lines from a local wordlist file, mirroring the
|
||||
// dirlist scanner's scanLines so a converted module fuzzes the identical words.
|
||||
func loadWordlist(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var words []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
for scanner.Scan() {
|
||||
if line := scanner.Text(); line != "" {
|
||||
words = append(words, line)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read wordlist %q: %w", path, err)
|
||||
}
|
||||
|
||||
return words, nil
|
||||
}
|
||||
|
||||
// newPayloadRequest builds one request with the path and body templates
|
||||
// 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.
|
||||
@@ -212,45 +304,63 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest
|
||||
bodyStr := string(respBody)
|
||||
|
||||
// Check matchers
|
||||
if !checkMatchers(cfg.Matchers, resp, bodyStr) {
|
||||
if !checkMatchers(cfg.Matchers, cfg.MatchersCondition, resp, bodyStr) {
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
// Extract data
|
||||
extracted := runExtractors(cfg.Extractors, resp, bodyStr)
|
||||
|
||||
// favicon-only matches fire on binary icon bytes; report the hash, not the body.
|
||||
evidence := truncateEvidence(bodyStr)
|
||||
if fav, ok := faviconEvidence(cfg.Matchers, bodyStr); ok {
|
||||
evidence = fav
|
||||
}
|
||||
|
||||
return Finding{
|
||||
URL: r.URL,
|
||||
Severity: severity,
|
||||
Evidence: truncateEvidence(bodyStr),
|
||||
Evidence: evidence,
|
||||
Extracted: extracted,
|
||||
}, true
|
||||
}
|
||||
|
||||
// checkMatchers evaluates all matchers against the response.
|
||||
func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool {
|
||||
// checkMatchers combines matchers with condition "and" (default, all match) or "or" (any).
|
||||
func checkMatchers(matchers []Matcher, condition string, resp *http.Response, body string) bool {
|
||||
if len(matchers) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Default to AND condition across matchers
|
||||
or := strings.EqualFold(condition, "or")
|
||||
for i := range matchers {
|
||||
matched := checkMatcher(&matchers[i], resp, body)
|
||||
if matchers[i].Negative {
|
||||
matched = !matched
|
||||
}
|
||||
if !matched {
|
||||
return false // AND logic
|
||||
if or && matched {
|
||||
return true
|
||||
}
|
||||
if !or && !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
// and: all matched; or: none matched.
|
||||
return !or
|
||||
}
|
||||
|
||||
// validateMatchersCondition rejects a matchers-condition that is not "", "and", or "or".
|
||||
func validateMatchersCondition(condition string) error {
|
||||
switch strings.ToLower(condition) {
|
||||
case "", "and", "or":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid matchers-condition %q (want \"and\" or \"or\")", condition)
|
||||
}
|
||||
}
|
||||
|
||||
// checkMatcher evaluates a single matcher.
|
||||
func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
|
||||
part := getPart(m.Part, resp, body)
|
||||
|
||||
switch m.Type {
|
||||
case "status":
|
||||
for _, status := range m.Status {
|
||||
@@ -261,10 +371,22 @@ func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
|
||||
return false
|
||||
|
||||
case "word":
|
||||
return checkWords(part, m.Words, m.Condition)
|
||||
return checkWords(getPart(m.Part, resp, body), m.Words, m.Condition)
|
||||
|
||||
case "regex":
|
||||
return checkRegex(part, m.Regex, m.Condition)
|
||||
return checkRegex(getPart(m.Part, resp, body), m.Regex, m.Condition)
|
||||
|
||||
case "favicon":
|
||||
return checkFaviconHash(body, m.Hash)
|
||||
|
||||
case "size":
|
||||
// size matches the response body length against any listed value.
|
||||
for _, n := range m.Size {
|
||||
if len(body) == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
@@ -356,9 +478,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 +492,24 @@ 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, ", ")
|
||||
}
|
||||
case "json":
|
||||
part := getPart(e.Part, resp, body)
|
||||
for _, path := range e.JSON {
|
||||
if r := gjson.Get(part, path); r.Exists() {
|
||||
result[e.Name] = r.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -143,6 +145,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 {
|
||||
@@ -249,6 +324,48 @@ func TestWrapperExecuteRoutesByType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModuleWordlist proves a {{word}} path templated against a local
|
||||
// wordlist drives one real request per word, and only the path that exists fires.
|
||||
func TestExecuteHTTPModuleWordlist(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/admin" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
list := filepath.Join(t.TempDir(), "words.txt")
|
||||
if err := os.WriteFile(list, []byte("login\nadmin\nbackup\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-wordlist",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{Severity: "low"},
|
||||
HTTP: &HTTPConfig{
|
||||
Method: "GET",
|
||||
Wordlist: list,
|
||||
Paths: []string{"{{BaseURL}}/{{word}}"},
|
||||
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
|
||||
},
|
||||
}
|
||||
|
||||
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
||||
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(res.Findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1 (only /admin exists)", len(res.Findings))
|
||||
}
|
||||
if got := res.Findings[0].URL; got != srv.URL+"/admin" {
|
||||
t.Errorf("finding url = %q, want %q", got, srv.URL+"/admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateEvidence(t *testing.T) {
|
||||
short := "short evidence"
|
||||
if got := truncateEvidence(short); got != short {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/fingerprint"
|
||||
)
|
||||
|
||||
// checkFaviconHash reports whether the body's shodan mmh3 hash matches any
|
||||
// configured value. only the body (the icon) is hashed; part is ignored.
|
||||
func checkFaviconHash(body string, want []int64) bool {
|
||||
if len(want) == 0 {
|
||||
return false
|
||||
}
|
||||
got := fingerprint.FaviconHash([]byte(body))
|
||||
for _, w := range want {
|
||||
if n, ok := normalizeFaviconHash(w); ok && n == got {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeFaviconHash folds a hash to the signed int32 shodan stores, accepting
|
||||
// either 32-bit form so a signed or unsigned value pastes in as-is. out-of-range
|
||||
// values are rejected so a stray number can't wrap into a false match.
|
||||
func normalizeFaviconHash(v int64) (int32, bool) {
|
||||
if v < math.MinInt32 || v > math.MaxUint32 {
|
||||
return 0, false
|
||||
}
|
||||
return int32(uint32(v)), true //nolint:gosec // intentional 32-bit fold to shodan's signed form
|
||||
}
|
||||
|
||||
// faviconEvidence gives the hash as evidence for a favicon-only finding, and
|
||||
// nothing when a word/regex matcher is present so its body evidence stands.
|
||||
func faviconEvidence(matchers []Matcher, body string) (string, bool) {
|
||||
favicon := false
|
||||
for i := range matchers {
|
||||
switch matchers[i].Type {
|
||||
case "word", "regex":
|
||||
return "", false
|
||||
case "favicon":
|
||||
favicon = true
|
||||
}
|
||||
}
|
||||
if !favicon {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash([]byte(body))), true
|
||||
}
|
||||
|
||||
// validateMatchers fails favicon matchers that would silently never fire (no
|
||||
// hash, or one out of 32-bit range) at load rather than at match time.
|
||||
func validateMatchers(matchers []Matcher) error {
|
||||
for i := range matchers {
|
||||
if matchers[i].Type != "favicon" {
|
||||
continue
|
||||
}
|
||||
if len(matchers[i].Hash) == 0 {
|
||||
return fmt.Errorf("favicon matcher requires at least one hash")
|
||||
}
|
||||
for _, h := range matchers[i].Hash {
|
||||
if _, ok := normalizeFaviconHash(h); !ok {
|
||||
return fmt.Errorf("favicon hash %d out of range (use a signed int32 or unsigned uint32 value)", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/fingerprint"
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
)
|
||||
|
||||
// faviconFixture hashes to a negative int32, so its signed and unsigned forms
|
||||
// differ and the unsigned-match case below actually exercises the fold.
|
||||
var faviconFixture = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
|
||||
|
||||
func TestCheckMatcherFavicon(t *testing.T) {
|
||||
body := string(faviconFixture)
|
||||
signed := int64(fingerprint.FaviconHash(faviconFixture))
|
||||
if signed >= 0 {
|
||||
t.Fatalf("fixture must hash to a negative int32 for the unsigned case to be meaningful, got %d", signed)
|
||||
}
|
||||
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hashes []int64
|
||||
expect bool
|
||||
}{
|
||||
{name: "signed match", hashes: []int64{signed}, expect: true},
|
||||
{name: "unsigned match", hashes: []int64{unsigned}, expect: true},
|
||||
{name: "one of many", hashes: []int64{1, 2, signed}, expect: true},
|
||||
{name: "no match", hashes: []int64{1, 2, 3}, expect: false},
|
||||
{name: "empty list", hashes: nil, expect: false},
|
||||
{name: "out-of-range ignored", hashes: []int64{1 << 40}, expect: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Matcher{Type: "favicon", Hash: tt.hashes}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if got := checkMatcher(m, resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatcher favicon = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFaviconHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in int64
|
||||
want int32
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "signed passthrough", in: -235701012, want: -235701012, wantOK: true},
|
||||
{name: "unsigned folds to signed", in: 4059266284, want: -235701012, wantOK: true},
|
||||
{name: "positive in range", in: 116323821, want: 116323821, wantOK: true},
|
||||
{name: "min int32", in: math.MinInt32, want: math.MinInt32, wantOK: true},
|
||||
{name: "max uint32 folds to -1", in: math.MaxUint32, want: -1, wantOK: true},
|
||||
{name: "above uint32 rejected", in: math.MaxUint32 + 1, wantOK: false},
|
||||
{name: "below int32 rejected", in: math.MinInt32 - 1, wantOK: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := normalizeFaviconHash(tt.in)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if ok && got != tt.want {
|
||||
t.Errorf("normalizeFaviconHash(%d) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaviconEvidence(t *testing.T) {
|
||||
body := string(faviconFixture)
|
||||
hashLine := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
|
||||
tests := []struct {
|
||||
name string
|
||||
matchers []Matcher
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "favicon only", matchers: []Matcher{{Type: "favicon"}}, want: hashLine, wantOK: true},
|
||||
{name: "favicon with status", matchers: []Matcher{{Type: "status"}, {Type: "favicon"}}, want: hashLine, wantOK: true},
|
||||
{name: "favicon with word keeps body", matchers: []Matcher{{Type: "word"}, {Type: "favicon"}}, wantOK: false},
|
||||
{name: "favicon with regex keeps body", matchers: []Matcher{{Type: "regex"}, {Type: "favicon"}}, wantOK: false},
|
||||
{name: "no favicon matcher", matchers: []Matcher{{Type: "status"}}, wantOK: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := faviconEvidence(tt.matchers, body)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if ok && got != tt.want {
|
||||
t.Errorf("evidence = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMatchers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
matchers []Matcher
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid signed", matchers: []Matcher{{Type: "favicon", Hash: []int64{-235701012}}}, wantErr: false},
|
||||
{name: "valid unsigned", matchers: []Matcher{{Type: "favicon", Hash: []int64{4059266284}}}, wantErr: false},
|
||||
{name: "favicon with no hash", matchers: []Matcher{{Type: "favicon"}}, wantErr: true},
|
||||
{name: "out-of-range hash", matchers: []Matcher{{Type: "favicon", Hash: []int64{99999999999}}}, wantErr: true},
|
||||
{name: "non-favicon ignored", matchers: []Matcher{{Type: "word", Words: []string{"x"}}}, wantErr: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateMatchers(tt.matchers); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateMatchers err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// favicon composes with the negative flag like any other matcher.
|
||||
func TestCheckMatcherFaviconNegative(t *testing.T) {
|
||||
signed := int64(fingerprint.FaviconHash(faviconFixture))
|
||||
matchers := []Matcher{{Type: "favicon", Hash: []int64{signed}, Negative: true}}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if checkMatchers(matchers, "", resp, string(faviconFixture)) {
|
||||
t.Error("negative favicon matcher should not match its own hash")
|
||||
}
|
||||
}
|
||||
|
||||
// drives the full executor: fetch favicon, match on its hash, report the hash.
|
||||
func TestExecuteHTTPModuleFavicon(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/favicon.ico" {
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
_, _ = w.Write(faviconFixture)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// unsigned form must still match end to end
|
||||
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
|
||||
def := &YAMLModule{
|
||||
ID: "favicon-fp",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{Severity: "info"},
|
||||
HTTP: &HTTPConfig{
|
||||
Method: "GET",
|
||||
Paths: []string{"{{BaseURL}}/favicon.ico"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "status", Status: []int{200}},
|
||||
{Type: "favicon", Hash: []int64{unsigned}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
||||
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule: %v", err)
|
||||
}
|
||||
if len(result.Findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1", len(result.Findings))
|
||||
}
|
||||
|
||||
wantEvidence := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
|
||||
if got := result.Findings[0].Evidence; got != wantEvidence {
|
||||
t.Errorf("evidence = %q, want %q", got, wantEvidence)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,151 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runInfraModule(t *testing.T, file string, status int, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule(file)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", file, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func infraExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestInfraConfigExposureModules(t *testing.T) {
|
||||
const terraform = "../../modules/recon/terraform-state-exposure.yaml"
|
||||
const kubeconfig = "../../modules/recon/kubeconfig-exposure.yaml"
|
||||
const compose = "../../modules/recon/docker-compose-exposure.yaml"
|
||||
|
||||
t.Run("terraform state leaks the terraform version", func(t *testing.T) {
|
||||
body := `{"version":4,"terraform_version":"1.5.7","serial":12,"lineage":"a1b2",` +
|
||||
`"outputs":{},"resources":[{"type":"aws_db_instance","name":"main"}]}`
|
||||
res := runInfraModule(t, terraform, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a terraform state finding")
|
||||
}
|
||||
if v := infraExtract(res, "terraform_version"); v != "1.5.7" {
|
||||
t.Errorf("terraform_version=%q, want 1.5.7", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terraform state with a pre-release version still extracts the number", func(t *testing.T) {
|
||||
body := `{"version":4,"terraform_version":"0.12.0-beta1","serial":1,"lineage":"x","resources":[]}`
|
||||
res := runInfraModule(t, terraform, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a terraform state finding")
|
||||
}
|
||||
if v := infraExtract(res, "terraform_version"); v != "0.12.0" {
|
||||
t.Errorf("terraform_version=%q, want 0.12.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("kubeconfig leaks the cluster server", func(t *testing.T) {
|
||||
body := "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n" +
|
||||
" server: https://10.0.0.1:6443\n name: prod\ncurrent-context: prod\n"
|
||||
res := runInfraModule(t, kubeconfig, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a kubeconfig finding")
|
||||
}
|
||||
if v := infraExtract(res, "cluster_server"); v != "https://10.0.0.1:6443" {
|
||||
t.Errorf("cluster_server=%q, want https://10.0.0.1:6443", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("docker compose leaks the image version", func(t *testing.T) {
|
||||
body := "version: \"3.8\"\nservices:\n web:\n image: nginx:1.25\n ports:\n" +
|
||||
" - \"80:80\"\n db:\n image: postgres:15\n"
|
||||
res := runInfraModule(t, compose, 200, body)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a docker compose finding")
|
||||
}
|
||||
if v := infraExtract(res, "compose_image"); v != "nginx:1.25" {
|
||||
t.Errorf("compose_image=%q, want nginx:1.25", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a terraform_version mention without the state structure is not a leak", func(t *testing.T) {
|
||||
body := `{"terraform_version":"1.5.7"}`
|
||||
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare version mention should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a kind Config mention without the kubeconfig structure is not a leak", func(t *testing.T) {
|
||||
body := "kind: Config\ndescription: an unrelated document\n"
|
||||
if res := runInfraModule(t, kubeconfig, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare kind mention should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a services key without a service definition is not a leak", func(t *testing.T) {
|
||||
body := "services: enabled\nnote: not a compose file\n"
|
||||
if res := runInfraModule(t, compose, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a bare services key should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page carrying the markers is not a leak", func(t *testing.T) {
|
||||
body := `<html><head><title>x</title></head><body>"terraform_version":"1.5.7" "lineage":"a1b2"</body></html>`
|
||||
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{terraform, kubeconfig, compose} {
|
||||
if res := runInfraModule(t, file, 200, "ok"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a 404 is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{terraform, kubeconfig, compose} {
|
||||
if res := runInfraModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
)
|
||||
|
||||
func TestRunExtractorsJSON(t *testing.T) {
|
||||
const body = `{"version":"1.2.3","app":{"name":"sif"},"items":[{"id":7}]}`
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
want string // "" means the extractor should set nothing
|
||||
}{
|
||||
{"top level", []string{"version"}, "1.2.3"},
|
||||
{"nested", []string{"app.name"}, "sif"},
|
||||
{"array index", []string{"items.0.id"}, "7"},
|
||||
{"first existing wins", []string{"missing", "version"}, "1.2.3"},
|
||||
{"no match", []string{"nope"}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ex := []Extractor{{Type: "json", Name: "v", Part: "body", JSON: tt.paths}}
|
||||
got := runExtractors(ex, resp, body)
|
||||
if tt.want == "" {
|
||||
if v, ok := got["v"]; ok {
|
||||
t.Errorf("expected no extraction, got %q", v)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got["v"] != tt.want {
|
||||
t.Errorf("got %q, want %q", got["v"], tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHTTPModuleJSONExtractor(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"version":"9.9.9"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "j",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{Severity: "info"},
|
||||
HTTP: &HTTPConfig{
|
||||
Method: "GET",
|
||||
Paths: []string{"{{BaseURL}}/"},
|
||||
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
|
||||
Extractors: []Extractor{{Type: "json", Name: "version", Part: "body", JSON: []string{"version"}}},
|
||||
},
|
||||
}
|
||||
|
||||
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
||||
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1", len(res.Findings))
|
||||
}
|
||||
if got := res.Findings[0].Extracted["version"]; got != "9.9.9" {
|
||||
t.Errorf("extracted version = %q, want 9.9.9", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runLoginModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule(file)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range headers {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", file, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func loginExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestLoginPanelModules(t *testing.T) {
|
||||
const grafana = "../../modules/info/grafana-panel.yaml"
|
||||
const kibana = "../../modules/info/kibana-panel.yaml"
|
||||
const jenkins = "../../modules/info/jenkins-panel.yaml"
|
||||
|
||||
grafanaBody := `<body class="app-grafana"><grafana-app></grafana-app>` +
|
||||
`<script>window.grafanaBootData = {"settings":{"buildInfo":{"version":"10.4.2","commit":"abc"}}};</script></body>`
|
||||
kibanaBody := `<div data-test-subj="kibanaChrome"><kbn-injected-metadata data="x"></kbn-injected-metadata></div>`
|
||||
|
||||
t.Run("grafana login", func(t *testing.T) {
|
||||
res := runLoginModule(t, grafana, 200, nil, grafanaBody)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a grafana finding")
|
||||
}
|
||||
if v := loginExtract(res, "grafana_version"); v != "10.4.2" {
|
||||
t.Errorf("grafana_version=%q, want 10.4.2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("kibana via response headers", func(t *testing.T) {
|
||||
res := runLoginModule(t, kibana, 200, map[string]string{"kbn-version": "8.13.0", "kbn-name": "node-1"}, kibanaBody)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a kibana finding")
|
||||
}
|
||||
if v := loginExtract(res, "kibana_version"); v != "8.13.0" {
|
||||
t.Errorf("kibana_version=%q, want 8.13.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jenkins via X-Jenkins header on a 403", func(t *testing.T) {
|
||||
res := runLoginModule(t, jenkins, 403, map[string]string{"X-Jenkins": "2.426.1"},
|
||||
`<html><head><title>Authentication required</title></head></html>`)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a jenkins finding")
|
||||
}
|
||||
if v := loginExtract(res, "jenkins_version"); v != "2.426.1" {
|
||||
t.Errorf("jenkins_version=%q, want 2.426.1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrelated page is not a panel", func(t *testing.T) {
|
||||
for _, file := range []string{grafana, kibana, jenkins} {
|
||||
if res := runLoginModule(t, file, 200, nil, "<html><body>plain</body></html>"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: unrelated page should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
)
|
||||
|
||||
func TestCheckMatchersCondition(t *testing.T) {
|
||||
const body = "hello world"
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
|
||||
status200 := Matcher{Type: "status", Status: []int{200}}
|
||||
status500 := Matcher{Type: "status", Status: []int{500}}
|
||||
wordHit := Matcher{Type: "word", Part: "body", Words: []string{"hello"}}
|
||||
wordMiss := Matcher{Type: "word", Part: "body", Words: []string{"absent"}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
condition string
|
||||
matchers []Matcher
|
||||
expect bool
|
||||
}{
|
||||
{"and both match", "and", []Matcher{status200, wordHit}, true},
|
||||
{"and one fails", "and", []Matcher{status200, wordMiss}, false},
|
||||
{"empty defaults to and", "", []Matcher{status200, wordMiss}, false},
|
||||
{"or one matches", "or", []Matcher{status500, wordHit}, true},
|
||||
{"or none match", "or", []Matcher{status500, wordMiss}, false},
|
||||
{"or all match", "or", []Matcher{status200, wordHit}, true},
|
||||
{"or is case-insensitive", "OR", []Matcher{status500, wordHit}, true},
|
||||
{"and is case-insensitive", "AND", []Matcher{status200, wordMiss}, false},
|
||||
{"or with negative pass", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true}}, true},
|
||||
{"or all fail with negative", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"hello"}, Negative: true}, wordMiss}, false},
|
||||
{"empty matcher list", "or", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkMatchers(tt.matchers, tt.condition, resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatchers(%q) = %v, want %v", tt.condition, got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMatchersCondition(t *testing.T) {
|
||||
for _, ok := range []string{"", "and", "or", "AND", "Or"} {
|
||||
if err := validateMatchersCondition(ok); err != nil {
|
||||
t.Errorf("%q should be valid: %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"xor", "nand", "any", "&&"} {
|
||||
if err := validateMatchersCondition(bad); err == nil {
|
||||
t.Errorf("%q should be rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMatchersConditionValidation(t *testing.T) {
|
||||
write := func(cond string) string {
|
||||
p := filepath.Join(t.TempDir(), "m.yaml")
|
||||
body := fmt.Sprintf("id: mc\ntype: http\nhttp:\n method: GET\n paths: [\"{{BaseURL}}\"]\n matchers-condition: %s\n matchers:\n - type: status\n status: [200]\n", cond)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
if _, err := ParseYAMLModule(write("or")); err != nil {
|
||||
t.Errorf("matchers-condition: or should parse: %v", err)
|
||||
}
|
||||
if _, err := ParseYAMLModule(write("xor")); err == nil {
|
||||
t.Error("matchers-condition: xor should be rejected at load")
|
||||
}
|
||||
}
|
||||
|
||||
// or fires on the word match alone; and does not (status:500 fails).
|
||||
func TestExecuteHTTPModuleMatchersConditionOr(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "mc",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{Severity: "info"},
|
||||
HTTP: &HTTPConfig{
|
||||
Method: "GET",
|
||||
Paths: []string{"{{BaseURL}}/"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "status", Status: []int{500}},
|
||||
{Type: "word", Part: "body", Words: []string{"hello"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
||||
|
||||
def.HTTP.MatchersCondition = "or"
|
||||
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("or: %v", err)
|
||||
}
|
||||
if len(res.Findings) != 1 {
|
||||
t.Fatalf("or: got %d findings, want 1", len(res.Findings))
|
||||
}
|
||||
|
||||
def.HTTP.MatchersCondition = ""
|
||||
res, err = ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("and: %v", err)
|
||||
}
|
||||
if len(res.Findings) != 0 {
|
||||
t.Fatalf("and: got %d findings, want 0 (status:500 fails)", len(res.Findings))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ package modules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -184,7 +186,7 @@ func TestCheckMatchers(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkMatchers(tt.matchers, resp, body); got != tt.expect {
|
||||
if got := checkMatchers(tt.matchers, "", resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatchers = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
@@ -339,9 +341,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,
|
||||
},
|
||||
@@ -413,7 +415,10 @@ func TestGenerateHTTPRequests(t *testing.T) {
|
||||
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
||||
}
|
||||
// trailing slash on the target must be trimmed before substitution.
|
||||
got := generateHTTPRequests("http://h/", cfg)
|
||||
got, err := generateHTTPRequests("http://h/", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d requests, want 2", len(got))
|
||||
}
|
||||
@@ -432,7 +437,10 @@ func TestGenerateHTTPRequests(t *testing.T) {
|
||||
Payloads: []string{"1", "2", "3"},
|
||||
Body: "data={{payload}}",
|
||||
}
|
||||
got := generateHTTPRequests("http://h", cfg)
|
||||
got, err := generateHTTPRequests("http://h", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d requests, want 3", len(got))
|
||||
}
|
||||
@@ -457,9 +465,67 @@ func TestGenerateHTTPRequests(t *testing.T) {
|
||||
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
||||
Payloads: []string{"x", "y"},
|
||||
}
|
||||
got := generateHTTPRequests("http://h", cfg)
|
||||
got, err := generateHTTPRequests("http://h", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d requests, want 4 (2 paths x 2 payloads)", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wordlist expands {{word}} paths", func(t *testing.T) {
|
||||
list := filepath.Join(t.TempDir(), "words.txt")
|
||||
if err := os.WriteFile(list, []byte("admin\n\nconfig\nbackup\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/{{word}}", "{{BaseURL}}/.git/HEAD"},
|
||||
Wordlist: list,
|
||||
}
|
||||
got, err := generateHTTPRequests("http://h", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
// 3 words (the blank line is skipped) fuzz the templated path, then the
|
||||
// literal path passes through untouched.
|
||||
want := []string{"http://h/admin", "http://h/config", "http://h/backup", "http://h/.git/HEAD"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d requests, want %d", len(got), len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].URL != w {
|
||||
t.Errorf("req %d url = %q, want %q", i, got[i].URL, w)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wordlist crosses with payloads", func(t *testing.T) {
|
||||
list := filepath.Join(t.TempDir(), "words.txt")
|
||||
if err := os.WriteFile(list, []byte("a\nb\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/{{word}}?q={{payload}}"},
|
||||
Wordlist: list,
|
||||
Payloads: []string{"1", "2", "3"},
|
||||
}
|
||||
got, err := generateHTTPRequests("http://h", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("got %d requests, want 6 (2 words x 3 payloads)", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing wordlist errors", func(t *testing.T) {
|
||||
cfg := &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/{{word}}"},
|
||||
Wordlist: filepath.Join(t.TempDir(), "nope.txt"),
|
||||
}
|
||||
if _, err := generateHTTPRequests("http://h", cfg); err == nil {
|
||||
t.Fatal("want error for missing wordlist, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runMetricsModule(t *testing.T, file string, status int, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule(file)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", file, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func metricsExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestMetricsExposureModules(t *testing.T) {
|
||||
const netdata = "../../modules/recon/netdata-api-exposure.yaml"
|
||||
const cadvisor = "../../modules/recon/cadvisor-api-exposure.yaml"
|
||||
|
||||
netdataInfo := `{"version":"v1.44.0","uid":"6c5c8a3f","mirrored_hosts":["localhost"],` +
|
||||
`"mirrored_hosts_status":[{"guid":"6c5c8a3f","reachable":true}],"os_name":"Debian GNU/Linux",` +
|
||||
`"cores_total":"8","total_disk_space":"512000000000"}`
|
||||
|
||||
cadvisorMachine := `{"num_cores":8,"num_physical_cores":4,"num_sockets":1,"cpu_frequency_khz":2904000,` +
|
||||
`"memory_capacity":16777216000,"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",` +
|
||||
`"system_uuid":"4C4C4544-0042-3110-8044-B7C04F564432","boot_id":"f0e1d2c3"}`
|
||||
|
||||
t.Run("an exposed netdata info endpoint is flagged and versioned", func(t *testing.T) {
|
||||
res := runMetricsModule(t, netdata, 200, netdataInfo)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a netdata finding")
|
||||
}
|
||||
if v := metricsExtract(res, "netdata_version"); v != "v1.44.0" {
|
||||
t.Errorf("netdata_version=%q, want v1.44.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed cadvisor machine endpoint is flagged with the machine id", func(t *testing.T) {
|
||||
res := runMetricsModule(t, cadvisor, 200, cadvisorMachine)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a cadvisor finding")
|
||||
}
|
||||
if v := metricsExtract(res, "cadvisor_machine_id"); v != "a1b2c3d4e5f60718293a4b5c6d7e8f90" {
|
||||
t.Errorf("cadvisor_machine_id=%q, want the machine id", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("netdata mirrored hosts without cores total is not flagged", func(t *testing.T) {
|
||||
body := `{"version":"v1.44.0","mirrored_hosts":["localhost"]}`
|
||||
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("mirrored hosts alone should not match netdata, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("netdata cores total without mirrored hosts is not flagged", func(t *testing.T) {
|
||||
body := `{"version":"v1.44.0","cores_total":"8"}`
|
||||
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("cores total alone should not match netdata, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cadvisor machine id without a cpu frequency is not flagged", func(t *testing.T) {
|
||||
body := `{"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90","num_cores":8}`
|
||||
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a machine id alone should not match cadvisor, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cadvisor cpu frequency without a machine id is not flagged", func(t *testing.T) {
|
||||
body := `{"cpu_frequency_khz":2904000,"num_cores":8}`
|
||||
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a cpu frequency alone should not match cadvisor, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic metrics json is not netdata", func(t *testing.T) {
|
||||
body := `{"status":"ok","data":{"result":[]}}`
|
||||
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic json should not match netdata, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{netdata, cadvisor} {
|
||||
if res := runMetricsModule(t, file, 200, "ok"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a 404 is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{netdata, cadvisor} {
|
||||
if res := runMetricsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -86,11 +86,13 @@ type Finding struct {
|
||||
// Matcher defines matching logic for module responses.
|
||||
// Matchers are used to determine if a response indicates a vulnerability.
|
||||
type Matcher struct {
|
||||
Type string `yaml:"type"` // regex, status, word, size
|
||||
Type string `yaml:"type"` // regex, status, word, favicon
|
||||
Part string `yaml:"part"` // body, header, all
|
||||
Regex []string `yaml:"regex,omitempty"`
|
||||
Words []string `yaml:"words,omitempty"`
|
||||
Status []int `yaml:"status,omitempty"`
|
||||
Size []int `yaml:"size,omitempty"`
|
||||
Hash []int64 `yaml:"hash,omitempty"` // favicon: shodan mmh3 hashes (signed or unsigned)
|
||||
Condition string `yaml:"condition"` // and, or
|
||||
Negative bool `yaml:"negative"`
|
||||
}
|
||||
@@ -98,9 +100,10 @@ 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"`
|
||||
JSON []string `yaml:"json,omitempty"`
|
||||
Group int `yaml:"group"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
// runOpsModule runs a shipped module end to end against a server that returns
|
||||
// the same status and body for every path it requests.
|
||||
func runOpsModule(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 opsExtracted(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v, ok := f.Extracted[key]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestOpsPanelModules(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
status int
|
||||
body string
|
||||
wantFinding bool
|
||||
versionKey string
|
||||
versionVal string
|
||||
}{
|
||||
{
|
||||
name: "portainer status api", file: "../../modules/info/portainer-panel.yaml", status: 200,
|
||||
body: `{"Edition":"CE","Version":"2.19.4","InstanceID":"a1b2c3"}`,
|
||||
wantFinding: true, versionKey: "portainer_version", versionVal: "2.19.4",
|
||||
},
|
||||
{
|
||||
name: "portainer version-only json is not a match", file: "../../modules/info/portainer-panel.yaml", status: 200,
|
||||
body: `{"Version":"1.0.0"}`, wantFinding: false,
|
||||
},
|
||||
{
|
||||
name: "portainer real body behind a 404 is not a match", file: "../../modules/info/portainer-panel.yaml", status: 404,
|
||||
body: `{"Edition":"CE","Version":"2.19.4","InstanceID":"a1b2c3"}`, wantFinding: false,
|
||||
},
|
||||
{
|
||||
name: "traefik version api", file: "../../modules/info/traefik-panel.yaml", status: 200,
|
||||
body: `{"Version":"2.10.4","Codename":"saintnectaire","startDate":"2024-01-01T00:00:00Z"}`,
|
||||
wantFinding: true, versionKey: "traefik_version", versionVal: "2.10.4",
|
||||
},
|
||||
{
|
||||
name: "traefik without codename is not a match", file: "../../modules/info/traefik-panel.yaml", status: 200,
|
||||
body: `{"Version":"2.10.4"}`, wantFinding: false,
|
||||
},
|
||||
{
|
||||
name: "keycloak realm endpoint", file: "../../modules/info/keycloak-panel.yaml", status: 200,
|
||||
body: `{"realm":"master","public_key":"MIIBIjAN","token-service":"https://h/realms/master/protocol/openid-connect","account-service":"https://h/realms/master/account"}`,
|
||||
wantFinding: true, versionKey: "keycloak_realm", versionVal: "master",
|
||||
},
|
||||
{
|
||||
name: "keycloak partial realm json is not a match", file: "../../modules/info/keycloak-panel.yaml", status: 200,
|
||||
body: `{"realm":"master","public_key":"MIIBIjAN"}`, wantFinding: false,
|
||||
},
|
||||
{
|
||||
name: "rabbitmq management ui", file: "../../modules/info/rabbitmq-panel.yaml", status: 200,
|
||||
body: `<!DOCTYPE html><html><head><title>RabbitMQ Management</title></head><body><img src="img/rabbitmqlogo.svg"></body></html>`,
|
||||
wantFinding: true,
|
||||
},
|
||||
{
|
||||
name: "rabbitmq unrelated page is not a match", file: "../../modules/info/rabbitmq-panel.yaml", status: 200,
|
||||
body: `<html><body>nothing to see</body></html>`, wantFinding: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := runOpsModule(t, tc.file, tc.status, tc.body)
|
||||
got := len(res.Findings) > 0
|
||||
if got != tc.wantFinding {
|
||||
t.Fatalf("findings=%d, want match=%v", len(res.Findings), tc.wantFinding)
|
||||
}
|
||||
if tc.versionKey != "" {
|
||||
if v := opsExtracted(res, tc.versionKey); v != tc.versionVal {
|
||||
t.Errorf("extracted[%q]=%q, want %q", tc.versionKey, v, tc.versionVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,118 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runRegistryModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule(file)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range headers {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", file, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func registryExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestRegistryExposureModules(t *testing.T) {
|
||||
const dockerRegistry = "../../modules/recon/docker-registry-api-exposure.yaml"
|
||||
const harbor = "../../modules/recon/harbor-api-exposure.yaml"
|
||||
|
||||
registryHeader := map[string]string{"Docker-Distribution-Api-Version": "registry/2.0"}
|
||||
|
||||
harborInfo := `{"auth_mode":"db_auth","registry_url":"harbor.example.com",` +
|
||||
`"external_url":"https://harbor.example.com","harbor_version":"v2.9.1-1f4a3c9d",` +
|
||||
`"self_registration":true,"has_ca_root":false,"read_only":false}`
|
||||
|
||||
t.Run("an anonymous docker registry is flagged with its api version", func(t *testing.T) {
|
||||
res := runRegistryModule(t, dockerRegistry, 200, registryHeader, "{}")
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a docker registry finding")
|
||||
}
|
||||
if v := registryExtract(res, "docker_registry_api_version"); v != "registry/2.0" {
|
||||
t.Errorf("docker_registry_api_version=%q, want registry/2.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 without the registry header is not flagged", func(t *testing.T) {
|
||||
if res := runRegistryModule(t, dockerRegistry, 200, nil, "{}"); len(res.Findings) > 0 {
|
||||
t.Errorf("a 200 without the api-version header should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a registry that requires auth is not flagged", func(t *testing.T) {
|
||||
if res := runRegistryModule(t, dockerRegistry, 401, registryHeader, ""); len(res.Findings) > 0 {
|
||||
t.Errorf("a 401 registry should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed harbor systeminfo is flagged and versioned", func(t *testing.T) {
|
||||
res := runRegistryModule(t, harbor, 200, nil, harborInfo)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a harbor finding")
|
||||
}
|
||||
if v := registryExtract(res, "harbor_version"); v != "v2.9.1-1f4a3c9d" {
|
||||
t.Errorf("harbor_version=%q, want v2.9.1-1f4a3c9d", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a harbor version without an auth mode is not flagged", func(t *testing.T) {
|
||||
body := `{"harbor_version":"v2.9.1","registry_url":"harbor.example.com"}`
|
||||
if res := runRegistryModule(t, harbor, 200, nil, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a harbor version alone should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an auth mode without a harbor version is not flagged", func(t *testing.T) {
|
||||
body := `{"auth_mode":"db_auth","self_registration":true}`
|
||||
if res := runRegistryModule(t, harbor, 200, nil, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an auth mode alone 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{dockerRegistry, harbor} {
|
||||
if res := runRegistryModule(t, file, 200, nil, "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{dockerRegistry, harbor} {
|
||||
if res := runRegistryModule(t, file, 404, nil, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runRuntimeModule(t *testing.T, file string, status int, body string) *modules.Result {
|
||||
t.Helper()
|
||||
def, err := modules.ParseYAMLModule(file)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
|
||||
Timeout: 5 * time.Second,
|
||||
Threads: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", file, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func runtimeExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestRuntimeAPIExposureModules(t *testing.T) {
|
||||
const docker = "../../modules/recon/docker-api-exposure.yaml"
|
||||
const k8s = "../../modules/recon/kubernetes-api-exposure.yaml"
|
||||
const kubelet = "../../modules/recon/kubelet-api-exposure.yaml"
|
||||
|
||||
dockerVersion := `{"Platform":{"Name":"Docker Engine - Community"},"Components":[` +
|
||||
`{"Name":"Engine","Version":"24.0.7","Details":{"ApiVersion":"1.43"}},` +
|
||||
`{"Name":"containerd","Version":"1.6.24"},{"Name":"runc","Version":"1.1.9"}],` +
|
||||
`"Version":"24.0.7","ApiVersion":"1.43","MinAPIVersion":"1.12","GitCommit":"311b9ff",` +
|
||||
`"GoVersion":"go1.20.10","Os":"linux","Arch":"amd64"}`
|
||||
|
||||
k8sVersion := `{"major":"1","minor":"28","gitVersion":"v1.28.2","gitCommit":"abc123",` +
|
||||
`"gitTreeState":"clean","buildDate":"2023-09-13T09:35:49Z","goVersion":"go1.20.8",` +
|
||||
`"compiler":"gc","platform":"linux/amd64"}`
|
||||
|
||||
kubeletPods := `{"kind":"PodList","apiVersion":"v1","metadata":{},"items":[{"metadata":` +
|
||||
`{"name":"etcd-master","namespace":"kube-system"},"spec":{"containers":[{"name":"etcd"}]}}]}`
|
||||
|
||||
t.Run("an exposed docker api is flagged and versioned", func(t *testing.T) {
|
||||
res := runRuntimeModule(t, docker, 200, dockerVersion)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a docker finding")
|
||||
}
|
||||
if v := runtimeExtract(res, "docker_version"); v != "24.0.7" {
|
||||
t.Errorf("docker_version=%q, want 24.0.7", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed kubernetes api is flagged and versioned", func(t *testing.T) {
|
||||
res := runRuntimeModule(t, k8s, 200, k8sVersion)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a kubernetes finding")
|
||||
}
|
||||
if v := runtimeExtract(res, "k8s_version"); v != "v1.28.2" {
|
||||
t.Errorf("k8s_version=%q, want v1.28.2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed kubelet leaks a pod namespace", func(t *testing.T) {
|
||||
res := runRuntimeModule(t, kubelet, 200, kubeletPods)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a kubelet finding")
|
||||
}
|
||||
if v := runtimeExtract(res, "kubelet_namespace"); v != "kube-system" {
|
||||
t.Errorf("kubelet_namespace=%q, want kube-system", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic version json without the docker fields is not docker", func(t *testing.T) {
|
||||
body := `{"version":"1.0.0","name":"myapp"}`
|
||||
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic version should not match docker, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an apiversion without a min api version is not docker", func(t *testing.T) {
|
||||
body := `{"ApiVersion":"2.0","name":"otherservice"}`
|
||||
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("apiversion alone should not match docker, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a min api version without an api version is not docker", func(t *testing.T) {
|
||||
body := `{"MinAPIVersion":"1.12","Os":"linux"}`
|
||||
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("min api version alone should not match docker, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic version json without the git fields is not kubernetes", func(t *testing.T) {
|
||||
body := `{"version":"1.2.3","build":"xyz"}`
|
||||
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic version should not match kubernetes, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a gitversion without a git tree state is not kubernetes", func(t *testing.T) {
|
||||
body := `{"gitVersion":"v1.0.0","app":"custom"}`
|
||||
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("gitversion alone should not match kubernetes, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a build date without a gitversion is not kubernetes", func(t *testing.T) {
|
||||
body := `{"buildDate":"2023-01-01T00:00:00Z","app":"custom"}`
|
||||
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("build date alone should not match kubernetes, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a service list is not a kubelet pod list", func(t *testing.T) {
|
||||
body := `{"kind":"ServiceList","apiVersion":"v1","items":[]}`
|
||||
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a service list should not match kubelet, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a pod list without an api version is not flagged", func(t *testing.T) {
|
||||
body := `{"kind":"PodList","items":[]}`
|
||||
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a pod list without apiversion should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{docker, k8s, kubelet} {
|
||||
if res := runRuntimeModule(t, file, 200, "ok"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a 404 is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{docker, k8s, kubelet} {
|
||||
if res := runRuntimeModule(t, file, 404, "not found"); len(res.Findings) > 0 {
|
||||
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,155 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
func runVCSModule(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 vcsExtract(res *modules.Result, key string) string {
|
||||
for _, f := range res.Findings {
|
||||
if v := f.Extracted[key]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestVCSMetadataExposureModules(t *testing.T) {
|
||||
const svn = "../../modules/recon/svn-exposure.yaml"
|
||||
const hg = "../../modules/recon/mercurial-exposure.yaml"
|
||||
const bzr = "../../modules/recon/bazaar-exposure.yaml"
|
||||
|
||||
svnWcDb := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
|
||||
"CREATE TABLE WCROOT (id INTEGER PRIMARY KEY);" +
|
||||
"CREATE TABLE REPOSITORY (root TEXT, uuid TEXT);" +
|
||||
"\x01root\x00https://svn.example.com/myrepo/trunk\x00"
|
||||
|
||||
hgRequires := "revlogv1\nstore\nfncache\ndotencode\ngeneraldelta\nsparserevlog\n"
|
||||
|
||||
bzrFormat := "Bazaar-NG meta directory, format 1\n"
|
||||
|
||||
t.Run("an exposed svn wc.db leaks the repository url", func(t *testing.T) {
|
||||
res := runVCSModule(t, svn, 200, svnWcDb)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected an svn finding")
|
||||
}
|
||||
if v := vcsExtract(res, "svn_repository"); v != "https://svn.example.com/myrepo/trunk" {
|
||||
t.Errorf("svn_repository=%q, want https://svn.example.com/myrepo/trunk", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed mercurial requires is flagged", func(t *testing.T) {
|
||||
res := runVCSModule(t, hg, 200, hgRequires)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a mercurial finding")
|
||||
}
|
||||
if v := vcsExtract(res, "hg_requirement"); v != "revlogv1" {
|
||||
t.Errorf("hg_requirement=%q, want revlogv1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an exposed bazaar branch-format is flagged", func(t *testing.T) {
|
||||
res := runVCSModule(t, bzr, 200, bzrFormat)
|
||||
if len(res.Findings) == 0 {
|
||||
t.Fatal("expected a bazaar finding")
|
||||
}
|
||||
if v := vcsExtract(res, "bzr_format"); v != "Bazaar-NG meta directory, format 1" {
|
||||
t.Errorf("bzr_format=%q, want the meta directory signature", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic sqlite database without svn tables is not flagged", func(t *testing.T) {
|
||||
body := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
|
||||
"CREATE TABLE users (id INTEGER, name TEXT, email TEXT);"
|
||||
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic sqlite db should not match svn, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a generic sqlite with a nodes table is not an svn working copy", func(t *testing.T) {
|
||||
body := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
|
||||
"CREATE TABLE NODES (id INTEGER PRIMARY KEY, parent INTEGER, label TEXT);"
|
||||
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a generic nodes table should not match svn, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an svn magic that is not at byte zero is not flagged", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>SQLite format 3 WCROOT REPOSITORY</pre></body></html>"
|
||||
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an unanchored magic should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a page naming mercurial without the requires format is not flagged", func(t *testing.T) {
|
||||
body := "this project uses mercurial for distributed version control"
|
||||
if res := runVCSModule(t, hg, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("prose naming mercurial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating hg requires is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>revlogv1\nstore\ndotencode</pre></body></html>"
|
||||
if res := runVCSModule(t, hg, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html hg tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a bazaar marketplace page is not a repository", func(t *testing.T) {
|
||||
body := "Welcome to the Bazaar, the finest open air marketplace in town"
|
||||
if res := runVCSModule(t, bzr, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("a marketplace page should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an html page demonstrating bzr branch-format is not a leak", func(t *testing.T) {
|
||||
body := "<!DOCTYPE html><html><body><pre>Bazaar-NG meta directory, format 1</pre></body></html>"
|
||||
if res := runVCSModule(t, bzr, 200, body); len(res.Findings) > 0 {
|
||||
t.Errorf("an html bzr tutorial should not match, got %d findings", len(res.Findings))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
|
||||
for _, file := range []string{svn, hg, bzr} {
|
||||
if res := runVCSModule(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{svn, hg, bzr} {
|
||||
if res := runVCSModule(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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -53,15 +53,17 @@ type YAMLModuleInfo struct {
|
||||
|
||||
// HTTPConfig defines HTTP module settings
|
||||
type HTTPConfig struct {
|
||||
Method string `yaml:"method"`
|
||||
Paths []string `yaml:"paths"`
|
||||
Payloads []string `yaml:"payloads,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Attack string `yaml:"attack,omitempty"` // sniper, pitchfork, clusterbomb
|
||||
Threads int `yaml:"threads,omitempty"`
|
||||
Matchers []Matcher `yaml:"matchers"`
|
||||
Extractors []Extractor `yaml:"extractors,omitempty"`
|
||||
Method string `yaml:"method"`
|
||||
Paths []string `yaml:"paths"`
|
||||
Wordlist string `yaml:"wordlist,omitempty"`
|
||||
Payloads []string `yaml:"payloads,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Attack string `yaml:"attack,omitempty"` // clusterbomb (default), pitchfork
|
||||
Threads int `yaml:"threads,omitempty"`
|
||||
Matchers []Matcher `yaml:"matchers"`
|
||||
MatchersCondition string `yaml:"matchers-condition,omitempty"` // and (default), or
|
||||
Extractors []Extractor `yaml:"extractors,omitempty"`
|
||||
}
|
||||
|
||||
// DNSConfig defines DNS module settings
|
||||
@@ -100,6 +102,27 @@ 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)
|
||||
}
|
||||
if err := validateMatchersCondition(ym.HTTP.MatchersCondition); err != nil {
|
||||
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
|
||||
}
|
||||
}
|
||||
var matchers []Matcher
|
||||
switch {
|
||||
case ym.HTTP != nil:
|
||||
matchers = ym.HTTP.Matchers
|
||||
case ym.DNS != nil:
|
||||
matchers = ym.DNS.Matchers
|
||||
case ym.TCP != nil:
|
||||
matchers = ym.TCP.Matchers
|
||||
}
|
||||
if err := validateMatchers(matchers); err != nil {
|
||||
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
|
||||
}
|
||||
|
||||
return &ym, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -84,15 +84,22 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor
|
||||
}
|
||||
|
||||
func extractPotentialBuckets(url string) []string {
|
||||
// TODO: handle non-adjacent label combos and strip the tld
|
||||
parts := strings.Split(url, ".")
|
||||
var buckets []string
|
||||
for i, part := range parts {
|
||||
buckets = append(buckets, part, part+"-s3", "s3-"+part)
|
||||
labels := strings.Split(url, ".")
|
||||
// drop the tld label so we don't waste guesses on it ("com", "com-s3", ...);
|
||||
// a single-label host has no tld to strip.
|
||||
if len(labels) > 1 {
|
||||
labels = labels[:len(labels)-1]
|
||||
}
|
||||
|
||||
if i < len(parts)-1 {
|
||||
domainExtension := part + "-" + parts[i+1]
|
||||
buckets = append(buckets, domainExtension, parts[i+1]+"-"+part)
|
||||
var buckets []string
|
||||
for _, label := range labels {
|
||||
buckets = append(buckets, label, label+"-s3", "s3-"+label)
|
||||
}
|
||||
// combine every label with every other, not just adjacent ones, so a deep
|
||||
// host like shop.cdn.example yields shop-example too.
|
||||
for i, a := range labels {
|
||||
for _, b := range labels[i+1:] {
|
||||
buckets = append(buckets, a+"-"+b, b+"-"+a)
|
||||
}
|
||||
}
|
||||
return buckets
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package scan
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPotentialBuckets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
want []string // candidates that must be generated
|
||||
absent []string // candidates that must not be generated
|
||||
}{
|
||||
{
|
||||
name: "strips the tld and pairs labels both ways",
|
||||
host: "shop.example.com",
|
||||
want: []string{"shop", "shop-s3", "s3-shop", "example", "shop-example", "example-shop"},
|
||||
absent: []string{"com", "com-s3", "s3-com", "example-com", "com-example"},
|
||||
},
|
||||
{
|
||||
name: "combines non-adjacent labels",
|
||||
host: "a.b.c.example.com",
|
||||
want: []string{"a-c", "c-a", "a-example", "example-a", "b-example"},
|
||||
absent: []string{"com", "example-com"},
|
||||
},
|
||||
{
|
||||
name: "single-label host keeps its only label and makes no pairs",
|
||||
host: "localhost",
|
||||
want: []string{"localhost", "localhost-s3", "s3-localhost"},
|
||||
absent: []string{"localhost-localhost", ""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractPotentialBuckets(tt.host)
|
||||
for _, w := range tt.want {
|
||||
if !slices.Contains(got, w) {
|
||||
t.Errorf("extractPotentialBuckets(%q) missing %q; got %v", tt.host, w, got)
|
||||
}
|
||||
}
|
||||
for _, a := range tt.absent {
|
||||
if slices.Contains(got, a) {
|
||||
t.Errorf("extractPotentialBuckets(%q) should not generate %q; got %v", tt.host, a, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+40
-10
@@ -78,7 +78,7 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
|
||||
}
|
||||
|
||||
// Drupal
|
||||
if strings.Contains(resp.Header.Get("X-Drupal-Cache"), "HIT") || strings.Contains(bodyString, "Drupal.settings") {
|
||||
if detectDrupal(resp.Header, bodyString) {
|
||||
spin.Stop()
|
||||
result := &CMSResult{Name: "Drupal", Version: "Unknown"}
|
||||
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
|
||||
@@ -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))
|
||||
@@ -102,12 +102,12 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
|
||||
}
|
||||
|
||||
func detectWordPress(url string, client *http.Client, bodyString string) bool {
|
||||
// Check for common WordPress indicators in the HTML
|
||||
// wordpress asset paths only; the bare word "wordpress" matched pages that
|
||||
// merely mention it (wp-hosting marketing), so it is dropped.
|
||||
wpIndicators := []string{
|
||||
"wp-content",
|
||||
"wp-includes",
|
||||
"wp-json",
|
||||
"wordpress",
|
||||
}
|
||||
|
||||
for _, indicator := range wpIndicators {
|
||||
@@ -128,12 +128,23 @@ func detectWordPress(url string, client *http.Client, bodyString string) bool {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
|
||||
if err == nil {
|
||||
found := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound
|
||||
// status only; drain so the conn returns to the pool.
|
||||
httpx.DrainClose(resp)
|
||||
if found {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusFound {
|
||||
continue
|
||||
}
|
||||
// the client follows redirects, so soft-404 and catch-all sites also land
|
||||
// here with a 200; require an actual WordPress marker in the body.
|
||||
probeBody := string(body)
|
||||
for _, indicator := range wpIndicators {
|
||||
if strings.Contains(probeBody, indicator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -141,3 +152,22 @@ 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")
|
||||
}
|
||||
|
||||
// detectDrupal reports whether the response looks like Drupal. the X-Drupal-* and
|
||||
// X-Generator headers survive cdn caching when the body markers do not, and an
|
||||
// X-Drupal-Cache of any value (even MISS) is a tell.
|
||||
func detectDrupal(header http.Header, body string) bool {
|
||||
return strings.Contains(header.Get("X-Generator"), "Drupal") ||
|
||||
header.Get("X-Drupal-Cache") != "" ||
|
||||
header.Get("X-Drupal-Dynamic-Cache") != "" ||
|
||||
strings.Contains(body, "Drupal.settings") ||
|
||||
strings.Contains(body, "drupalSettings")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package scan
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// header cases mirror live Drupal 8-11 (acquia, georgia, london): the X-Drupal-*
|
||||
// and X-Generator headers tell even when the body has no marker.
|
||||
func TestDetectDrupal_ModernSignals(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header http.Header
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"x-generator drupal 10", http.Header{"X-Generator": {"Drupal 10 (https://www.drupal.org)"}}, "", true},
|
||||
{"x-drupal-cache miss", http.Header{"X-Drupal-Cache": {"MISS"}}, "", true},
|
||||
{"x-drupal-dynamic-cache", http.Header{"X-Drupal-Dynamic-Cache": {"HIT"}}, "", true},
|
||||
{"drupalSettings body (8+)", http.Header{}, `<script>window.drupalSettings = {};</script>`, true},
|
||||
{"Drupal.settings body (7)", http.Header{}, `<script>Drupal.settings = {};</script>`, true},
|
||||
{"plain page", http.Header{"Server": {"nginx"}}, "<html><body>hello</body></html>", false},
|
||||
{"x-generator wordpress", http.Header{"X-Generator": {"WordPress 6.5"}}, "", false},
|
||||
{"bare drupal prose", http.Header{}, "we migrated off Drupal CMS last year", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := detectDrupal(c.header, c.body); got != c.want {
|
||||
t.Errorf("%s: detectDrupal = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// end-to-end: a modern Drupal whose only tell is X-Drupal-Dynamic-Cache (the live
|
||||
// london.gov.uk case) must be detected.
|
||||
func TestCMS_ModernDrupalDetected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// a real Drupal site has no wordpress paths; 404 them so the wordpress
|
||||
// probe does not claim the host before the Drupal check runs.
|
||||
if r.URL.Path != "/" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Drupal-Dynamic-Cache", "MISS")
|
||||
_, _ = w.Write([]byte("<html><body>news and updates</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := CMS(srv.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CMS: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "Drupal" {
|
||||
t.Errorf("modern Drupal (X-Drupal-Dynamic-Cache) not detected, got %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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"
|
||||
)
|
||||
|
||||
// notFound serves 404 for every path so the file probe never fires; only the
|
||||
// passed homepage body decides the result.
|
||||
func notFound(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// a page that only mentions wordpress in prose (no asset paths) is not running it.
|
||||
func TestDetectWordPress_BareMentionNotFlagged(t *testing.T) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
body := "<html><body>we offer managed wordpress hosting</body></html>"
|
||||
if detectWordPress(notFound(t), client, body) {
|
||||
t.Error("a page merely mentioning wordpress was flagged as WordPress")
|
||||
}
|
||||
}
|
||||
|
||||
// a real wordpress homepage references wp-content asset paths.
|
||||
func TestDetectWordPress_AssetPathsDetected(t *testing.T) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
body := `<link href="/wp-content/themes/x/style.css">`
|
||||
if !detectWordPress(notFound(t), client, body) {
|
||||
t.Error("wp-content asset path should be detected as WordPress")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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 soft-404 (200 for every path) is not wordpress; a bare 200 on the probe must
|
||||
// not flag without a marker in the body.
|
||||
func TestDetectWordPress_SoftFourOhFourNotFlagged(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<html><body>welcome to my static site</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
if detectWordPress(srv.URL, client, "<html><body>welcome to my static site</body></html>") {
|
||||
t.Error("soft-404 site (200 for every path, no wordpress markers) wrongly detected as WordPress")
|
||||
}
|
||||
}
|
||||
|
||||
// a catch-all 302 is followed to a non-wordpress 200; without a marker it must
|
||||
// not flag.
|
||||
func TestDetectWordPress_CatchAllRedirectNotFlagged(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<html><body>landing page</body></html>"))
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/home", http.StatusFound)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
if detectWordPress(srv.URL, client, "<html><body>landing page</body></html>") {
|
||||
t.Error("catch-all 302 redirect to a non-wordpress homepage wrongly detected as WordPress")
|
||||
}
|
||||
}
|
||||
|
||||
// a real wp-login.php response references wp-includes assets even when the
|
||||
// homepage hides its wordpress markers, so the file probe should still detect it.
|
||||
func TestDetectWordPress_LoginPageProbeDetected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/wp-login.php" {
|
||||
_, _ = w.Write([]byte(`<link rel="stylesheet" href="/wp-includes/css/dashicons.min.css">`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
if !detectWordPress(srv.URL, client, "<html><body>custom theme, no markers</body></html>") {
|
||||
t.Error("wp-login.php referencing wp-includes assets should be detected as WordPress")
|
||||
}
|
||||
}
|
||||
|
||||
// end-to-end through CMS() with the real redirect-following client: a soft-404
|
||||
// host must not be reported as a CMS.
|
||||
func TestCMS_SoftFourOhFourNotWordPress(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<html><body>welcome to my static site</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := CMS(srv.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CMS: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("soft-404 host wrongly classified as CMS %q", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// a probe that hits a redirect loop errors out in the client; it must be skipped
|
||||
// gracefully, never panicking or counting as a detection.
|
||||
func TestDetectWordPress_RedirectLoopHandled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/loop", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
if detectWordPress(srv.URL, client, "<html><body>no markers</body></html>") {
|
||||
t.Error("redirect loop wrongly detected as WordPress")
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,6 @@ type CORSFinding struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// corsMaxRedirects caps the redirect chain so we read the cors headers off the
|
||||
// host we actually asked about, not whatever it bounces us to.
|
||||
const corsMaxRedirects = 3
|
||||
|
||||
// the sentinel attacker origin; if it comes back in Access-Control-Allow-Origin
|
||||
// the target reflects arbitrary origins and any site can read the response.
|
||||
const corsEvilOrigin = "https://sif-cors-probe.evil.com"
|
||||
@@ -97,11 +93,10 @@ func CORS(targetURL string, timeout time.Duration, threads int, logdir string) (
|
||||
host := parsedURL.Host
|
||||
|
||||
client := httpx.Client(timeout)
|
||||
client.CheckRedirect = func(_ *http.Request, via []*http.Request) error {
|
||||
if len(via) >= corsMaxRedirects {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
// don't follow redirects: cors is judged on the host we asked about, so a
|
||||
// bounce to a permissive third party can't be pinned on the target.
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
result := &CORSResult{Findings: make([]CORSFinding, 0, len(corsOrigins))}
|
||||
|
||||
@@ -15,6 +15,7 @@ package scan
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -132,6 +133,38 @@ func TestCORS_NoFalsePositiveOnSafeServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORS_JudgesRequestedHostNotRedirectTarget pins the redirect behavior: the
|
||||
// requested host bounces to a reflecting third party, so following the redirect would
|
||||
// pin that party's misconfig on the target. the counter proves we never left the host.
|
||||
func TestCORS_JudgesRequestedHostNotRedirectTarget(t *testing.T) {
|
||||
var destHits int32
|
||||
dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&destHits, 1)
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer dest.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, dest.URL, http.StatusFound)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
result, err := CORS(redirector.URL, 5*time.Second, 3, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CORS: %v", err)
|
||||
}
|
||||
if n := atomic.LoadInt32(&destHits); n != 0 {
|
||||
t.Errorf("followed the redirect to the reflecting host %d time(s); cors must stay on the requested host", n)
|
||||
}
|
||||
if result != nil && len(result.Findings) > 0 {
|
||||
t.Errorf("expected no findings: the reflection is on the redirect target, not the requested host; got %+v", result.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSResult_ResultType(t *testing.T) {
|
||||
r := &CORSResult{}
|
||||
if r.ResultType() != "cors" {
|
||||
|
||||
+85
-22
@@ -50,8 +50,9 @@ const dirlistBodyCap = 512 * 1024
|
||||
// cannot exist, then treat any response shape they share as the wildcard
|
||||
// baseline. deterministic (no rng) so the workflow stays reproducible.
|
||||
const (
|
||||
calibrationProbes = 3
|
||||
calibrationPrefix = "/sif-cal-"
|
||||
calibrationProbes = 3
|
||||
calibrationPrefix = "/sif-cal-"
|
||||
calibrationPadStep = 8 // per-probe suffix growth; see calibrationSuffix
|
||||
)
|
||||
|
||||
// statusNotFound / statusForbidden are the historical default "not interesting"
|
||||
@@ -90,6 +91,20 @@ type responseMeta struct {
|
||||
words int
|
||||
}
|
||||
|
||||
// anySize, as a baseline size, marks a catch-all whose body size is unreliable (it
|
||||
// reflects the request path), so the baseline matches on status and word count alone.
|
||||
const anySize = -1
|
||||
|
||||
// matchesBaseline reports whether meta looks like the calibrated soft-404 shape b.
|
||||
// a normal baseline compares status, size and words exactly; a reflecting catch-all
|
||||
// (b.size == anySize) compares status and words only, since its size is not stable.
|
||||
func (b responseMeta) matchesBaseline(meta responseMeta) bool {
|
||||
if b.status != meta.status || b.words != meta.words {
|
||||
return false
|
||||
}
|
||||
return b.size == anySize || b.size == meta.size
|
||||
}
|
||||
|
||||
// matcher decides whether a response is "interesting" using the same precedence
|
||||
// as ffuf/feroxbuster: an explicit filter (-fc/-fs/-fw/-fr or a calibrated
|
||||
// baseline) drops the response, otherwise the match-code set decides.
|
||||
@@ -154,13 +169,10 @@ func newMatcher(opts *DirlistOptions) (*matcher, error) {
|
||||
// over matches: a calibrated baseline, an -fc/-fs/-fw hit, or an -fr body match
|
||||
// always drops the response; otherwise the -mc set (when set) gates it.
|
||||
func (m *matcher) Matches(meta responseMeta, body []byte) bool {
|
||||
// a calibrated soft-404 shape is the same response the catch-all hands every
|
||||
// bogus path, so drop anything that matches a baseline exactly.
|
||||
for i := 0; i < len(m.baselines); i++ {
|
||||
b := m.baselines[i]
|
||||
if b.status == meta.status && b.size == meta.size && b.words == meta.words {
|
||||
return false
|
||||
}
|
||||
// a calibrated soft-404 shape is the response the catch-all hands every bogus
|
||||
// path, so drop anything matching a baseline.
|
||||
if containsBaseline(m.baselines, meta) {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, drop := m.filterCodes[meta.status]; drop {
|
||||
@@ -303,7 +315,11 @@ func fetchWordlist(listURL string, client *http.Client) ([]string, error) {
|
||||
return nil, fmt.Errorf("download wordlist %q: %w", listURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return scanLines(resp.Body), nil
|
||||
lines, err := scanLines(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download wordlist %q: %w", listURL, err)
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// readWordlistFile loads a local wordlist file.
|
||||
@@ -313,11 +329,15 @@ func readWordlistFile(path string) ([]string, error) {
|
||||
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
return scanLines(f), nil
|
||||
lines, err := scanLines(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read wordlist %q: %w", path, err)
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// scanLines reads non-empty lines into a slice.
|
||||
func scanLines(r io.Reader) []string {
|
||||
func scanLines(r io.Reader) ([]string, error) {
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
@@ -327,16 +347,25 @@ func scanLines(r io.Reader) []string {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
// a line past bufio's 64k cap halts the scan; surface it instead of
|
||||
// silently dropping that line and everything after it.
|
||||
return lines, scanner.Err()
|
||||
}
|
||||
|
||||
// calibrate probes a few paths that cannot exist and records the response shapes
|
||||
// the catch-all hands them. those baselines feed the matcher so a soft-404 200
|
||||
// (the SPA wildcard) is suppressed before the real run. deterministic by design:
|
||||
// the probe paths come from the loop index, never a random source.
|
||||
// (the SPA wildcard) is suppressed before the real run.
|
||||
func calibrate(m *matcher, baseURL string, client *http.Client) {
|
||||
m.baselines = baselinesFromProbes(probeCalibration(baseURL, client))
|
||||
}
|
||||
|
||||
// probeCalibration requests calibrationProbes bogus paths and returns their soft
|
||||
// (non-hard-404) response shapes. paths grow in length (see calibrationSuffix) so a
|
||||
// path-reflecting catch-all is detectable. deterministic: paths come from the index.
|
||||
func probeCalibration(baseURL string, client *http.Client) []responseMeta {
|
||||
probes := make([]responseMeta, 0, calibrationProbes)
|
||||
for i := 0; i < calibrationProbes; i++ {
|
||||
probe := baseURL + calibrationPrefix + strconv.Itoa(i)
|
||||
probe := baseURL + calibrationPrefix + calibrationSuffix(i)
|
||||
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, probe, http.NoBody)
|
||||
if err != nil {
|
||||
charmlog.Debugf("dirlist: build calibration request: %v", err)
|
||||
@@ -355,17 +384,51 @@ func calibrate(m *matcher, baseURL string, client *http.Client) {
|
||||
if meta.status == statusNotFound {
|
||||
continue
|
||||
}
|
||||
if !containsBaseline(m.baselines, meta) {
|
||||
m.baselines = append(m.baselines, meta)
|
||||
}
|
||||
probes = append(probes, meta)
|
||||
}
|
||||
return probes
|
||||
}
|
||||
|
||||
// containsBaseline reports whether the shape is already recorded, so repeated
|
||||
// probes returning the same soft-404 don't bloat the baseline set.
|
||||
// calibrationSuffix returns the i-th probe suffix. each suffix is unique and longer
|
||||
// than the last, so a path-reflecting catch-all returns a different size per probe.
|
||||
func calibrationSuffix(i int) string {
|
||||
return strconv.Itoa(i) + strings.Repeat("a", i*calibrationPadStep)
|
||||
}
|
||||
|
||||
// baselinesFromProbes reduces raw calibration responses to the soft-404 shapes to
|
||||
// suppress. probes sharing status/word-count but differing in size are a reflecting
|
||||
// catch-all, collapsed to one word-count-tolerant baseline (size anySize); others exact.
|
||||
func baselinesFromProbes(probes []responseMeta) []responseMeta {
|
||||
type shapeKey struct{ status, words int }
|
||||
order := make([]shapeKey, 0, len(probes))
|
||||
sizes := make(map[shapeKey]map[int]struct{})
|
||||
for _, p := range probes {
|
||||
k := shapeKey{p.status, p.words}
|
||||
if sizes[k] == nil {
|
||||
sizes[k] = make(map[int]struct{})
|
||||
order = append(order, k)
|
||||
}
|
||||
sizes[k][p.size] = struct{}{}
|
||||
}
|
||||
|
||||
baselines := make([]responseMeta, 0, len(order))
|
||||
for _, k := range order {
|
||||
size := anySize
|
||||
if len(sizes[k]) == 1 {
|
||||
// one stable size for this status/words: keep exact-shape matching.
|
||||
for s := range sizes[k] {
|
||||
size = s
|
||||
}
|
||||
}
|
||||
baselines = append(baselines, responseMeta{status: k.status, size: size, words: k.words})
|
||||
}
|
||||
return baselines
|
||||
}
|
||||
|
||||
// containsBaseline reports whether meta matches any calibrated soft-404 baseline.
|
||||
func containsBaseline(baselines []responseMeta, meta responseMeta) bool {
|
||||
for i := 0; i < len(baselines); i++ {
|
||||
if baselines[i] == meta {
|
||||
if baselines[i].matchesBaseline(meta) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
package scan
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -358,3 +360,137 @@ func has(set map[string]struct{}, key string) bool {
|
||||
_, ok := set[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func TestScanLines(t *testing.T) {
|
||||
got, err := scanLines(strings.NewReader("admin\n\nlogin\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("scanLines: %v", err)
|
||||
}
|
||||
want := []string{"admin", "login"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("scanLines = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLinesErrorsOnOverlongLine(t *testing.T) {
|
||||
// a line past bufio's 64k cap must surface as an error, not silently
|
||||
// truncate the wordlist (dropping that line and everything after it).
|
||||
huge := strings.Repeat("a", bufio.MaxScanTokenSize+1)
|
||||
_, err := scanLines(strings.NewReader("first\n" + huge + "\nlast\n"))
|
||||
if !errors.Is(err, bufio.ErrTooLong) {
|
||||
t.Fatalf("scanLines err = %v, want bufio.ErrTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
// reflectingWildcardApp serves a catch-all whose body echoes the request path, so
|
||||
// its size tracks path length; /admin returns a distinct real page.
|
||||
func reflectingWildcardApp() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("<html><body>admin control panel dashboard credentials</body></html>"))
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/admin" {
|
||||
return
|
||||
}
|
||||
w.Write([]byte("<html><body>page not found: " + r.URL.Path + "</body></html>"))
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
// a reflecting catch-all hands each path a different size, so exact-shape calibration
|
||||
// misses it; the varied probe lengths expose it and -ac suppresses the bogus paths.
|
||||
func TestDirlist_CalibrationSuppressesReflectingWildcard(t *testing.T) {
|
||||
srv := reflectingWildcardApp()
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
wordlist := filepath.Join(dir, "words.txt")
|
||||
if err := os.WriteFile(wordlist, []byte("admin\nnope\nbogus\nmissing\n"), 0o600); err != nil {
|
||||
t.Fatalf("write wordlist: %v", err)
|
||||
}
|
||||
|
||||
results, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{
|
||||
Wordlist: wordlist,
|
||||
Calibrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dirlist (-ac): %v", err)
|
||||
}
|
||||
|
||||
got := pathSet(results)
|
||||
if !has(got, "/admin") {
|
||||
t.Errorf("real /admin must still surface, got %v", sortedKeys(got))
|
||||
}
|
||||
for _, bogus := range []string{"/nope", "/bogus", "/missing"} {
|
||||
if has(got, bogus) {
|
||||
t.Errorf("reflecting soft-404 %s should be suppressed by -ac, got %v", bogus, sortedKeys(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaselinesFromProbes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
probes []responseMeta
|
||||
want []responseMeta
|
||||
}{
|
||||
{
|
||||
name: "stable catch-all keeps exact shape",
|
||||
probes: []responseMeta{{status: 200, size: 63, words: 7}, {status: 200, size: 63, words: 7}},
|
||||
want: []responseMeta{{status: 200, size: 63, words: 7}},
|
||||
},
|
||||
{
|
||||
name: "reflecting catch-all collapses to words-tolerant",
|
||||
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 200, size: 48, words: 4}, {status: 200, size: 56, words: 4}},
|
||||
want: []responseMeta{{status: 200, size: anySize, words: 4}},
|
||||
},
|
||||
{
|
||||
name: "distinct shapes kept separately",
|
||||
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
|
||||
want: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
|
||||
},
|
||||
{
|
||||
name: "no probes yields no baseline",
|
||||
probes: nil,
|
||||
want: []responseMeta{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := baselinesFromProbes(tt.probes); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("baselinesFromProbes(%v) = %v, want %v", tt.probes, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesBaseline_AnySize(t *testing.T) {
|
||||
tolerant := responseMeta{status: 200, size: anySize, words: 4}
|
||||
if !tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 4}) {
|
||||
t.Error("anySize baseline should match any size with the same status/words")
|
||||
}
|
||||
if tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 5}) {
|
||||
t.Error("anySize baseline must still discriminate on word count")
|
||||
}
|
||||
exact := responseMeta{status: 200, size: 42, words: 5}
|
||||
if exact.matchesBaseline(responseMeta{status: 200, size: 43, words: 5}) {
|
||||
t.Error("exact baseline should not match a different size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrationSuffix_UniqueAndGrowing(t *testing.T) {
|
||||
prev := -1
|
||||
seen := make(map[string]struct{})
|
||||
for i := 0; i < calibrationProbes; i++ {
|
||||
s := calibrationSuffix(i)
|
||||
if _, dup := seen[s]; dup {
|
||||
t.Errorf("suffix %q repeats across probes", s)
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
if len(s) <= prev {
|
||||
t.Errorf("suffix %d %q length %d not greater than previous %d", i, s, len(s), prev)
|
||||
}
|
||||
prev = len(s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ package scan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,10 +21,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/fingerprint"
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
"github.com/dropalldatabases/sif/internal/logger"
|
||||
"github.com/dropalldatabases/sif/internal/output"
|
||||
"github.com/twmb/murmur3"
|
||||
)
|
||||
|
||||
// FaviconResult is the computed shodan-style favicon hash plus the pivot query
|
||||
@@ -42,11 +41,6 @@ type FaviconResult struct {
|
||||
// stream forever.
|
||||
const faviconBodyReadCap = 1 << 20
|
||||
|
||||
// b64LineLen is python's base64.encodebytes line width. mmh3/shodan hash the
|
||||
// chunked base64 (newline every 76 chars, trailing newline), so we must wrap at
|
||||
// exactly this width to land on the same hash.
|
||||
const b64LineLen = 76
|
||||
|
||||
// faviconLinkRegex pulls the href off a <link rel="...icon..."> tag so we can
|
||||
// fall back to a declared icon when /favicon.ico is absent.
|
||||
var faviconLinkRegex = regexp.MustCompile(`(?i)<link[^>]+rel=["'][^"']*icon[^"']*["'][^>]*>`)
|
||||
@@ -95,7 +89,7 @@ func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconRe
|
||||
return nil, nil //nolint:nilnil // a missing favicon is not an error
|
||||
}
|
||||
|
||||
hash := FaviconHash(data)
|
||||
hash := fingerprint.FaviconHash(data)
|
||||
result := &FaviconResult{
|
||||
FaviconURL: iconURL,
|
||||
Hash: hash,
|
||||
@@ -216,38 +210,6 @@ func resolveFaviconURL(base, href string) string {
|
||||
return base + "/" + href
|
||||
}
|
||||
|
||||
// FaviconHash computes shodan's favicon hash: murmur3 32-bit over the python
|
||||
// base64.encodebytes encoding of the raw icon (newline every 76 chars plus a
|
||||
// trailing newline), reinterpreted as a signed int32. the chunking and the sign
|
||||
// are both load-bearing - shodan stores the value python's mmh3.hash() returns,
|
||||
// which is signed, over the wrapped base64, not the raw bytes. the golden test
|
||||
// pins this exactly.
|
||||
func FaviconHash(data []byte) int32 {
|
||||
encoded := encodeFaviconBase64(data)
|
||||
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
|
||||
}
|
||||
|
||||
// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
|
||||
// a newline inserted every 76 output characters and a trailing newline. this is
|
||||
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.
|
||||
func encodeFaviconBase64(data []byte) []byte {
|
||||
raw := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
var b strings.Builder
|
||||
// final size: the base64 body plus one '\n' per (full or partial) 76-char
|
||||
// line. preallocate so the builder never regrows mid-loop.
|
||||
b.Grow(len(raw) + len(raw)/b64LineLen + 1)
|
||||
for i := 0; i < len(raw); i += b64LineLen {
|
||||
end := i + b64LineLen
|
||||
if end > len(raw) {
|
||||
end = len(raw)
|
||||
}
|
||||
b.WriteString(raw[i:end])
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// ResultType identifies favicon findings for the result registry.
|
||||
func (r *FaviconResult) ResultType() string { return "favicon" }
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package scan
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
)
|
||||
|
||||
// favicon demo modules must reference a hash from faviconHashes that names the
|
||||
// service in their filename, so a demo cannot drift from the scanner's map.
|
||||
func TestFaviconDemoModulesMatchCanonicalMap(t *testing.T) {
|
||||
matches, err := filepath.Glob("../../modules/info/favicon-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Skip("no favicon demo modules present")
|
||||
}
|
||||
|
||||
for _, path := range matches {
|
||||
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||
def, err := modules.ParseYAMLModule(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if def.HTTP == nil {
|
||||
t.Fatal("favicon demo is not an http module")
|
||||
}
|
||||
|
||||
var hashes []int64
|
||||
for _, m := range def.HTTP.Matchers {
|
||||
if m.Type == "favicon" {
|
||||
hashes = append(hashes, m.Hash...)
|
||||
}
|
||||
}
|
||||
if len(hashes) == 0 {
|
||||
t.Fatal("no favicon hash in module")
|
||||
}
|
||||
|
||||
service := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "favicon-"), ".yaml")
|
||||
for _, h := range hashes {
|
||||
// hashes are range-checked at parse, so int32(h) is the canonical fold.
|
||||
tech, ok := faviconHashes[int32(h)]
|
||||
if !ok {
|
||||
t.Errorf("hash %d is absent from faviconHashes; demo references a hash the scanner does not know", h)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(tech), service) {
|
||||
t.Errorf("hash %d maps to %q, but the file names service %q", h, tech, service)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,48 +31,6 @@ var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-",
|
||||
// signedness regress, this number changes and the test fails.
|
||||
const goldenFaviconHash int32 = -1554620260
|
||||
|
||||
// goldenHelloHash pins a short single-line case so a regression in the trailing
|
||||
// newline (which the small case still has) is caught independently.
|
||||
const goldenHelloHash int32 = 1155597304
|
||||
|
||||
func TestFaviconHash_Golden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []byte
|
||||
want int32
|
||||
}{
|
||||
{name: "multi-line fixture", in: goldenFaviconBytes, want: goldenFaviconHash},
|
||||
{name: "single-line hello", in: []byte("hello"), want: goldenHelloHash},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FaviconHash(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("FaviconHash = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFaviconBase64Chunking pins the encode step against python's
|
||||
// base64.encodebytes: a 50-byte input encodes to >76 base64 chars, so it must
|
||||
// wrap into two newline-terminated lines.
|
||||
func TestFaviconBase64Chunking(t *testing.T) {
|
||||
in := []byte(strings.Repeat("A", 60)) // 60 bytes -> 80 base64 chars -> two lines
|
||||
got := string(encodeFaviconBase64(in))
|
||||
|
||||
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 wrapped lines, got %d: %q", len(lines), got)
|
||||
}
|
||||
if len(lines[0]) != b64LineLen {
|
||||
t.Errorf("first line = %d chars, want %d", len(lines[0]), b64LineLen)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\n") {
|
||||
t.Errorf("encoding must end in a trailing newline, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fixtureFaviconServer serves the golden bytes at /favicon.ico.
|
||||
func fixtureFaviconServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
/*
|
||||
|
||||
BSD 3-Clause License
|
||||
(c) 2022-2026 vmfunc, xyzeva & contributors
|
||||
|
||||
*/
|
||||
|
||||
package frameworks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
charmlog "github.com/charmbracelet/log"
|
||||
"github.com/dropalldatabases/sif/internal/output"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// customDetector is a Detector defined in a user yaml file rather than compiled
|
||||
// in. it scores with the same weighted signature match as the built-ins and
|
||||
// optionally pulls a version out of the body.
|
||||
type customDetector struct {
|
||||
BaseDetector
|
||||
versionRe *regexp.Regexp
|
||||
versionGroup int
|
||||
}
|
||||
|
||||
// Detect returns the weighted signature confidence and, when a version regex is
|
||||
// set and matches, the captured version. confidence is the matched-weight
|
||||
// fraction directly (not the built-ins' sigmoid), so it clears 0.5 only past half.
|
||||
func (d *customDetector) Detect(body string, headers http.Header) (float32, string) {
|
||||
confidence := d.MatchSignatures(body, headers)
|
||||
if confidence == 0 || d.versionRe == nil {
|
||||
return confidence, ""
|
||||
}
|
||||
matches := d.versionRe.FindStringSubmatch(body)
|
||||
if len(matches) > d.versionGroup {
|
||||
return confidence, matches[d.versionGroup]
|
||||
}
|
||||
return confidence, ""
|
||||
}
|
||||
|
||||
// signatureSpec / versionSpec / customDetectorSpec mirror the yaml on disk.
|
||||
type signatureSpec struct {
|
||||
Pattern string `yaml:"pattern"`
|
||||
Weight float32 `yaml:"weight"`
|
||||
Header bool `yaml:"header"`
|
||||
}
|
||||
|
||||
type versionSpec struct {
|
||||
Regex string `yaml:"regex"`
|
||||
Group int `yaml:"group"`
|
||||
}
|
||||
|
||||
type customDetectorSpec struct {
|
||||
Name string `yaml:"name"`
|
||||
Signatures []signatureSpec `yaml:"signatures"`
|
||||
Version *versionSpec `yaml:"version"`
|
||||
}
|
||||
|
||||
// build validates the parsed spec and turns it into a Detector, so a broken
|
||||
// file fails loudly instead of registering a detector that can never match.
|
||||
func (spec customDetectorSpec) build() (Detector, error) {
|
||||
name := strings.TrimSpace(spec.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing name")
|
||||
}
|
||||
if len(spec.Signatures) == 0 {
|
||||
return nil, fmt.Errorf("%q has no signatures", name)
|
||||
}
|
||||
|
||||
sigs := make([]Signature, 0, len(spec.Signatures))
|
||||
for i, s := range spec.Signatures {
|
||||
if s.Pattern == "" {
|
||||
return nil, fmt.Errorf("%q: signature %d has an empty pattern", name, i+1)
|
||||
}
|
||||
if s.Weight <= 0 || math.IsInf(float64(s.Weight), 0) || math.IsNaN(float64(s.Weight)) {
|
||||
return nil, fmt.Errorf("%q: signature %q needs a positive, finite weight", name, s.Pattern)
|
||||
}
|
||||
sigs = append(sigs, Signature{Pattern: s.Pattern, Weight: s.Weight, HeaderOnly: s.Header})
|
||||
}
|
||||
|
||||
d := &customDetector{BaseDetector: NewBaseDetector(name, sigs)}
|
||||
if spec.Version != nil {
|
||||
if spec.Version.Group < 0 {
|
||||
return nil, fmt.Errorf("%q: version group must be >= 0", name)
|
||||
}
|
||||
re, err := regexp.Compile(spec.Version.Regex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q: version regex: %w", name, err)
|
||||
}
|
||||
d.versionRe = re
|
||||
d.versionGroup = spec.Version.Group
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// parseCustomDetector reads and validates one signature file.
|
||||
func parseCustomDetector(path string) (Detector, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
var spec customDetectorSpec
|
||||
if err := yaml.Unmarshal(data, &spec); err != nil {
|
||||
return nil, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
return spec.build()
|
||||
}
|
||||
|
||||
// customSignaturesDir is the per-user directory that holds yaml-defined
|
||||
// detectors, alongside the user modules directory.
|
||||
func customSignaturesDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(home, "AppData", "Local", "sif", "signatures"), nil
|
||||
}
|
||||
return filepath.Join(home, ".config", "sif", "signatures"), nil
|
||||
}
|
||||
|
||||
// loadCustomDetectors registers every signature file under the user directory.
|
||||
// it is driven once, lazily, from DetectFramework.
|
||||
func loadCustomDetectors() {
|
||||
dir, err := customSignaturesDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loadCustomDetectorsFromDir(dir)
|
||||
}
|
||||
|
||||
// loadCustomDetectorsFromDir registers every signature file in dir and returns
|
||||
// how many loaded. a custom detector whose name matches a built-in overrides
|
||||
// it, matching the user-module convention.
|
||||
func loadCustomDetectorsFromDir(dir string) int {
|
||||
detectors := collectCustomDetectors(dir)
|
||||
for _, d := range detectors {
|
||||
Register(d)
|
||||
}
|
||||
if len(detectors) > 0 {
|
||||
output.Module("FRAMEWORK").Info("Loaded %d custom signatures", len(detectors))
|
||||
}
|
||||
return len(detectors)
|
||||
}
|
||||
|
||||
// collectCustomDetectors parses (without registering) the .yaml/.yml detectors
|
||||
// in dir, so discovery and validation stay pure and testable. a missing dir is
|
||||
// fine; an unparseable file warns and is skipped rather than failing the scan.
|
||||
func collectCustomDetectors(dir string) []Detector {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var detectors []Detector
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
switch filepath.Ext(e.Name()) {
|
||||
case ".yaml", ".yml":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
d, err := parseCustomDetector(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
charmlog.Warnf("custom signature %s: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
detectors = append(detectors, d)
|
||||
}
|
||||
return detectors
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package frameworks
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomDetectorSpecBuild(t *testing.T) {
|
||||
valid := customDetectorSpec{
|
||||
Name: "Ghost",
|
||||
Signatures: []signatureSpec{{Pattern: `content="Ghost`, Weight: 1.0}},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
spec customDetectorSpec
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", valid, false},
|
||||
{"empty name", customDetectorSpec{Signatures: valid.Signatures}, true},
|
||||
{"whitespace name", customDetectorSpec{Name: " ", Signatures: valid.Signatures}, true},
|
||||
{"no signatures", customDetectorSpec{Name: "X"}, true},
|
||||
{"empty pattern", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "", Weight: 1}}}, true},
|
||||
{"zero weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: 0}}}, true},
|
||||
{"negative weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: -1}}}, true},
|
||||
{"bad version regex", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: 1}}, Version: &versionSpec{Regex: "("}}, true},
|
||||
{"negative version group", customDetectorSpec{Name: "X", Signatures: valid.Signatures, Version: &versionSpec{Regex: `v([0-9]+)`, Group: -1}}, true},
|
||||
{"nan weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: float32(math.NaN())}}}, true},
|
||||
{"inf weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: float32(math.Inf(1))}}}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := tc.spec.build(); (err != nil) != tc.wantErr {
|
||||
t.Fatalf("build() err = %v, wantErr = %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDetectorDetect(t *testing.T) {
|
||||
// the version regex is independent of the signature patterns so a body that
|
||||
// matches it but no signature still must not surface a version.
|
||||
spec := customDetectorSpec{
|
||||
Name: "Acme",
|
||||
Signatures: []signatureSpec{
|
||||
{Pattern: "AcmeCMS", Weight: 0.6},
|
||||
{Pattern: "X-Acme", Weight: 0.4, Header: true},
|
||||
},
|
||||
Version: &versionSpec{Regex: `ver=([0-9.]+)`, Group: 1},
|
||||
}
|
||||
d, err := spec.build()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withHeader := func() http.Header {
|
||||
h := http.Header{}
|
||||
h.Set("X-Acme", "1")
|
||||
return h
|
||||
}
|
||||
|
||||
t.Run("all signatures match: confidence 1, version extracted", func(t *testing.T) {
|
||||
conf, ver := d.Detect("powered by AcmeCMS ver=4.2.0", withHeader())
|
||||
if conf != 1.0 {
|
||||
t.Errorf("confidence = %v, want 1.0", conf)
|
||||
}
|
||||
if ver != "4.2.0" {
|
||||
t.Errorf("version = %q, want 4.2.0", ver)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only body signature matches: linear 0.6", func(t *testing.T) {
|
||||
conf, ver := d.Detect("powered by AcmeCMS", http.Header{})
|
||||
if conf != 0.6 {
|
||||
t.Errorf("confidence = %v, want 0.6 (0.6/1.0 matched fraction)", conf)
|
||||
}
|
||||
if ver != "" {
|
||||
t.Errorf("version = %q, want empty", ver)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no signature matches: 0 confidence, no version even when present", func(t *testing.T) {
|
||||
conf, ver := d.Detect("ver=9.9.9 but no marker here", http.Header{})
|
||||
if conf != 0 {
|
||||
t.Errorf("confidence = %v, want 0", conf)
|
||||
}
|
||||
if ver != "" {
|
||||
t.Errorf("version = %q, want empty (not detected, so not extracted)", ver)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseCustomDetectorFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "fw.yaml")
|
||||
content := `name: Parsed
|
||||
signatures:
|
||||
- pattern: "Marker"
|
||||
weight: 0.5
|
||||
- pattern: "X-Hdr"
|
||||
weight: 0.5
|
||||
header: true
|
||||
version:
|
||||
regex: 'Parsed/([0-9.]+)'
|
||||
group: 1
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d, err := parseCustomDetector(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Name() != "Parsed" {
|
||||
t.Errorf("name = %q, want Parsed", d.Name())
|
||||
}
|
||||
if len(d.Signatures()) != 2 {
|
||||
t.Errorf("signatures = %d, want 2", len(d.Signatures()))
|
||||
}
|
||||
|
||||
h := http.Header{}
|
||||
h.Set("X-Hdr", "1")
|
||||
conf, ver := d.Detect("Marker Parsed/3.1", h)
|
||||
if conf != 1.0 {
|
||||
t.Errorf("confidence = %v, want 1.0", conf)
|
||||
}
|
||||
if ver != "3.1" {
|
||||
t.Errorf("version = %q, want 3.1", ver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectCustomDetectors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, content string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("good.yaml", "name: ZZCustomTest\nsignatures:\n - pattern: \"ZZCustomMarker\"\n weight: 1.0\n")
|
||||
write("bad.yaml", "name: \"\"\nsignatures: []\n") // invalid: skipped with a warning
|
||||
write("ignore.txt", "not a signature file") // wrong extension: ignored
|
||||
|
||||
got := collectCustomDetectors(dir)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("collected %d detectors, want 1 (good.yaml only)", len(got))
|
||||
}
|
||||
if got[0].Name() != "ZZCustomTest" {
|
||||
t.Errorf("detector name = %q, want ZZCustomTest", got[0].Name())
|
||||
}
|
||||
if conf, _ := got[0].Detect("page with ZZCustomMarker", http.Header{}); conf != 1.0 {
|
||||
t.Errorf("confidence = %v, want 1.0", conf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectCustomDetectorsMissingDir(t *testing.T) {
|
||||
if got := collectCustomDetectors(filepath.Join(t.TempDir(), "nope")); got != nil {
|
||||
t.Errorf("missing dir should yield nil, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,14 @@ type detectionResult struct {
|
||||
version string
|
||||
}
|
||||
|
||||
// loadCustomOnce loads the user signature directory the first time a scan runs,
|
||||
// so config-defined detectors join the registry without a per-target re-read.
|
||||
var loadCustomOnce sync.Once
|
||||
|
||||
// DetectFramework runs all registered detectors against the target URL.
|
||||
func DetectFramework(url string, timeout time.Duration, logdir string) (*FrameworkResult, error) {
|
||||
loadCustomOnce.Do(loadCustomDetectors)
|
||||
|
||||
log := output.Module("FRAMEWORK")
|
||||
log.Start()
|
||||
|
||||
|
||||
@@ -186,6 +186,27 @@ func TestDetectFramework_ASPNET(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_ASPNETPoweredByHeader(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-AspNetMvc-Version", "5.2")
|
||||
w.Header().Set("X-Powered-By", "ASP.NET")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><a href="/home/index.aspx">home</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 {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Name != "ASP.NET" {
|
||||
t.Errorf("expected framework 'ASP.NET', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_NoMatch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -424,6 +445,108 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 +584,82 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -518,3 +717,422 @@ func TestDetectorRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractVersion_Htmx(t *testing.T) {
|
||||
tests := []struct {
|
||||
body string
|
||||
expected string
|
||||
}{
|
||||
{`<script src="https://unpkg.com/htmx.org@1.9.10"></script>`, "1.9.10"},
|
||||
{`https://cdn.jsdelivr.net/npm/htmx@2.0.3/dist/htmx.min.js`, "2.0.3"},
|
||||
{`"htmx.org": "^1.9.12"`, "1.9.12"},
|
||||
{"no version", "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := frameworks.ExtractVersionOptimized(tt.body, "htmx").Version
|
||||
if result != tt.expected {
|
||||
t.Errorf("ExtractVersionOptimized(%q, 'htmx') = %q, want %q", tt.body, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Htmx(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head>
|
||||
<body>
|
||||
<button hx-get="/clicked" hx-target="#out" hx-swap="outerHTML">Click</button>
|
||||
<form hx-post="/submit" hx-boost="true"></form>
|
||||
<div id="out"></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 != "htmx" {
|
||||
t.Errorf("expected framework 'htmx', got '%s'", result.Name)
|
||||
}
|
||||
if result.Version != "1.9.10" {
|
||||
t.Errorf("expected version '1.9.10', got '%s'", result.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_GinFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body>Welcome</body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Gin" {
|
||||
t.Errorf("false positive: detected Gin (confidence %.2f) on a CORS header", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Gin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`404 page not found - powered by gin-gonic`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "Gin" {
|
||||
t.Errorf("expected framework 'Gin', got '%v'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_MeteorFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><p>a meteor shower lit the sky while
|
||||
meteorology students tracked the meteorite.</p></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Meteor" {
|
||||
t.Errorf("false positive: detected Meteor (confidence %.2f) on prose about meteors", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Meteor(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head>
|
||||
<script>__meteor_runtime_config__ = JSON.parse(decodeURIComponent("%7B%7D"));</script>
|
||||
</head><body><div id="app"></div></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "Meteor" {
|
||||
t.Errorf("expected framework 'Meteor', got '%v'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_BackboneFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><p>our team is the backbone of the
|
||||
company, the backbone network that keeps everything running.</p></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Backbone.js" {
|
||||
t.Errorf("false positive: detected Backbone.js (confidence %.2f) on prose about backbones", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Backbone(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><script src="/js/backbone.js"></script></head>
|
||||
<body><script>var AppView = Backbone.View.extend({});</script></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "Backbone.js" {
|
||||
t.Errorf("expected framework 'Backbone.js', got '%v'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_CakePHPFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><p>our cupcake and cheesecake recipes,
|
||||
plus the best pancake stack in town.</p></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "CakePHP" {
|
||||
t.Errorf("false positive: detected CakePHP (confidence %.2f) on prose about cakes", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_CakePHP(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Set-Cookie", "CAKEPHP=abc123; path=/")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body>Home</body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "CakePHP" {
|
||||
t.Errorf("expected framework 'CakePHP', got '%v'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_SvelteFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><p>the model cut a svelte figure on
|
||||
the runway.</p></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Svelte" {
|
||||
t.Errorf("false positive: detected Svelte (confidence %.2f) on prose with 'svelte'", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_StrapiFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><script>fetch("/api/v1/users")</script></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Strapi" {
|
||||
t.Errorf("false positive: detected Strapi (confidence %.2f) on a plain /api/ path", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Strapi(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><div>powered by strapi</div></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "Strapi" {
|
||||
t.Errorf("expected framework 'Strapi', got '%v'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Ember(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Ember App</title></head>
|
||||
<body class="ember-application"><div id="ember123" class="ember-view">Content</div>
|
||||
<script src="/assets/vendor.js"></script></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil || result.Name != "Ember.js" {
|
||||
t.Errorf("expected framework 'Ember.js', got '%v'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_EmberFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Day of the Dead</title></head>
|
||||
<body><p>a celebratory holiday to remember the dead; families remember departed
|
||||
members every November and September.</p></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Ember.js" {
|
||||
t.Errorf("false positive: detected Ember.js (confidence %.2f) on prose with 'remember'", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_Shopify(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Powered-By", "Shopify")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><link rel="stylesheet" href="https://cdn.shopify.com/s/files/1/theme.css"></head>
|
||||
<body>
|
||||
<div id="shopify-section-header" class="shopify-section">Store</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Name != "Shopify" {
|
||||
t.Errorf("expected framework 'Shopify', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_ShopifyFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>10 Best Shopify Alternatives in 2026</title></head>
|
||||
<body>
|
||||
<h1>Is Shopify Right For You?</h1>
|
||||
<p>We compare Shopify with other e-commerce platforms.</p>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Shopify" {
|
||||
t.Errorf("false positive: article mentioning Shopify detected as Shopify (%.2f)", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_SpringBoot(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`<html><body><h1>Whitelabel Error Page</h1>` +
|
||||
`<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>` +
|
||||
`<div>There was an unexpected error (type=Internal Server Error, status=500).</div>` +
|
||||
`</body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Name != "Spring Boot" {
|
||||
t.Errorf("expected framework 'Spring Boot', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_SpringBootFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Getting started with spring-boot</h1>
|
||||
<p>Add spring-boot-starter-web to your pom.xml and run the app.</p>
|
||||
<a href="https://spring.io/projects/spring-boot">spring.io</a>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "Spring Boot" {
|
||||
t.Errorf("expected no Spring Boot match for prose mentioning it, got %.2f confidence", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_CodeIgniter(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Set-Cookie", "ci_session=a1b2c3d4e5; path=/; HttpOnly")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><h1>My Shop</h1></body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Name != "CodeIgniter" {
|
||||
t.Errorf("expected framework 'CodeIgniter', got '%s'", result.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFramework_CodeIgniterFalsePositive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Best PHP frameworks in 2026</h1>
|
||||
<p>Laravel and codeigniter both ship a router and an ORM.</p>
|
||||
<a href="https://codeigniter.com">codeigniter.com</a>
|
||||
<pre>composer create-project codeigniter4/appstarter</pre>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil && result.Name == "CodeIgniter" {
|
||||
t.Errorf("expected no CodeIgniter match for prose mentioning it, got %.2f confidence", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,6 @@ func (d *aspnetDetector) Signatures() []fw.Signature {
|
||||
{Pattern: ".ashx", Weight: 0.2},
|
||||
{Pattern: ".asmx", Weight: 0.2},
|
||||
{Pattern: "asp.net_sessionid", Weight: 0.4, HeaderOnly: true},
|
||||
{Pattern: "X-Powered-By: ASP.NET", Weight: 0.4, HeaderOnly: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,9 +251,9 @@ func (d *springBootDetector) Name() string { return "Spring Boot" }
|
||||
|
||||
func (d *springBootDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "spring-boot", Weight: 0.5},
|
||||
{Pattern: "actuator", Weight: 0.3},
|
||||
{Pattern: "whitelabel", Weight: 0.2},
|
||||
{Pattern: "Whitelabel Error Page", Weight: 0.5},
|
||||
{Pattern: "This application has no explicit mapping for /error", Weight: 0.4},
|
||||
{Pattern: "There was an unexpected error (type=", Weight: 0.3},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +351,6 @@ func (d *ginDetector) Name() string { return "Gin" }
|
||||
func (d *ginDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "gin-gonic", Weight: 0.4},
|
||||
{Pattern: "gin", Weight: 0.2, HeaderOnly: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,9 +373,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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +399,6 @@ func (d *strapiDetector) Name() string { return "Strapi" }
|
||||
func (d *strapiDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "strapi", Weight: 0.4},
|
||||
{Pattern: "/api/", Weight: 0.2},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,8 +421,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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +445,7 @@ func (d *cakephpDetector) Name() string { return "CakePHP" }
|
||||
func (d *cakephpDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "cakephp", Weight: 0.4},
|
||||
{Pattern: "cake", Weight: 0.2},
|
||||
{Pattern: "CAKEPHP", Weight: 0.4, HeaderOnly: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,7 +468,6 @@ func (d *codeigniterDetector) Name() string { return "CodeIgniter" }
|
||||
|
||||
func (d *codeigniterDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "codeigniter", Weight: 0.4},
|
||||
{Pattern: "ci_session", Weight: 0.4, HeaderOnly: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ func (d *shopifyDetector) Name() string { return "Shopify" }
|
||||
|
||||
func (d *shopifyDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "Shopify", Weight: 0.5},
|
||||
{Pattern: "Shopify", Weight: 0.5, HeaderOnly: true},
|
||||
{Pattern: "cdn.shopify.com", Weight: 0.4},
|
||||
{Pattern: "shopify-section", Weight: 0.4},
|
||||
{Pattern: "myshopify.com", Weight: 0.3},
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func init() {
|
||||
fw.Register(&emberDetector{})
|
||||
fw.Register(&backboneDetector{})
|
||||
fw.Register(&meteorDetector{})
|
||||
fw.Register(&htmxDetector{})
|
||||
}
|
||||
|
||||
// reactDetector detects React framework.
|
||||
@@ -128,9 +129,9 @@ func (d *svelteDetector) Name() string { return "Svelte" }
|
||||
|
||||
func (d *svelteDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "svelte", Weight: 0.4},
|
||||
{Pattern: "__svelte", Weight: 0.5},
|
||||
{Pattern: "svelte-", Weight: 0.3},
|
||||
{Pattern: "svelte-", Weight: 0.4},
|
||||
{Pattern: "svelte/internal", Weight: 0.4},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +154,12 @@ func (d *emberDetector) Name() string { return "Ember.js" }
|
||||
|
||||
func (d *emberDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "ember", Weight: 0.4},
|
||||
{Pattern: "ember-cli", Weight: 0.4},
|
||||
{Pattern: "data-ember", Weight: 0.3},
|
||||
{Pattern: "ember-application", Weight: 0.5},
|
||||
{Pattern: "ember-view", Weight: 0.4},
|
||||
{Pattern: "ember.js", Weight: 0.4},
|
||||
{Pattern: "ember.min.js", Weight: 0.4},
|
||||
{Pattern: "ember-cli", Weight: 0.3},
|
||||
{Pattern: `id="ember`, Weight: 0.4},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +182,11 @@ func (d *backboneDetector) Name() string { return "Backbone.js" }
|
||||
|
||||
func (d *backboneDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "backbone", Weight: 0.4},
|
||||
{Pattern: "Backbone.", Weight: 0.4},
|
||||
{Pattern: "Backbone.Model", Weight: 0.4},
|
||||
{Pattern: "Backbone.View", Weight: 0.4},
|
||||
{Pattern: "Backbone.Router", Weight: 0.4},
|
||||
{Pattern: "backbone.js", Weight: 0.4},
|
||||
{Pattern: "backbone-min.js", Weight: 0.4},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +202,34 @@ func (d *backboneDetector) Detect(body string, headers http.Header) (float32, st
|
||||
return confidence, version
|
||||
}
|
||||
|
||||
// htmxDetector detects the htmx library.
|
||||
type htmxDetector struct{}
|
||||
|
||||
func (d *htmxDetector) Name() string { return "htmx" }
|
||||
|
||||
func (d *htmxDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "hx-get", Weight: 0.5},
|
||||
{Pattern: "hx-post", Weight: 0.5},
|
||||
{Pattern: "hx-swap", Weight: 0.4},
|
||||
{Pattern: "hx-target", Weight: 0.4},
|
||||
{Pattern: "hx-boost", Weight: 0.4},
|
||||
{Pattern: "htmx.org", Weight: 0.5},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *htmxDetector) Detect(body string, headers http.Header) (float32, string) {
|
||||
base := fw.NewBaseDetector(d.Name(), d.Signatures())
|
||||
score := base.MatchSignatures(body, headers)
|
||||
confidence := sigmoidConfidence(score)
|
||||
|
||||
var version string
|
||||
if confidence > 0.5 {
|
||||
version = fw.ExtractVersionOptimized(body, d.Name()).Version
|
||||
}
|
||||
return confidence, version
|
||||
}
|
||||
|
||||
// meteorDetector detects Meteor framework.
|
||||
type meteorDetector struct{}
|
||||
|
||||
@@ -202,8 +237,9 @@ func (d *meteorDetector) Name() string { return "Meteor" }
|
||||
|
||||
func (d *meteorDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "__meteor_runtime_config__", Weight: 0.5},
|
||||
{Pattern: "meteor", Weight: 0.3},
|
||||
{Pattern: "__meteor_runtime_config__", Weight: 0.6},
|
||||
{Pattern: "Meteor.startup", Weight: 0.3},
|
||||
{Pattern: "/packages/meteor", Weight: 0.3},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,10 @@ func init() {
|
||||
"SvelteKit": {
|
||||
{`"@sveltejs/kit":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
||||
},
|
||||
"htmx": {
|
||||
{`htmx(?:\.org)?@(\d+\.\d+(?:\.\d+)?)`, 0.85, "CDN reference"},
|
||||
{`"htmx\.org":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
||||
},
|
||||
"WordPress": {
|
||||
{`<meta name="generator" content="WordPress (\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
|
||||
{`WordPress (\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestIntegrationSQL(t *testing.T) {
|
||||
srv := newVulnApp()
|
||||
defer srv.Close()
|
||||
|
||||
result, err := SQL(srv.URL, 5*time.Second, 5, "")
|
||||
result, err := SQL(srv.URL, 5*time.Second, 5, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SQL: %v", err)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
package frameworks
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -37,6 +37,10 @@ import (
|
||||
// nextPagesRegex matches JavaScript file references in Next.js build manifest.
|
||||
var nextPagesRegex = regexp.MustCompile(`\[("([^"]+\.js)"(,?))`)
|
||||
|
||||
// maxManifestSize caps the build manifest read so a huge or hostile file
|
||||
// cannot exhaust memory.
|
||||
const maxManifestSize = 5 * 1024 * 1024
|
||||
|
||||
func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
|
||||
baseUrl, err := urlutil.Parse(scriptUrl)
|
||||
if err != nil {
|
||||
@@ -58,13 +62,14 @@ func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
for scanner.Scan() {
|
||||
sb.WriteString(scanner.Text())
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestSize))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return nil, err
|
||||
}
|
||||
manifestText := sb.String()
|
||||
// the manifest ships minified on one line; strip line breaks so the regex
|
||||
// treats a (rare) pretty-printed one the same as the minified form.
|
||||
manifestText := strings.NewReplacer("\r", "", "\n", "").Replace(string(body))
|
||||
|
||||
list := nextPagesRegex.FindAllStringSubmatch(manifestText, -1)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user