mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-27 08:53:59 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66019996b1 | |||
| c20c37463a | |||
| 9190fa4741 |
+10
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"projectName": "sif",
|
||||
"projectOwner": "vmfunc",
|
||||
"projectOwner": "lunchcat",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
@@ -10,7 +10,7 @@
|
||||
"contributors": [
|
||||
{
|
||||
"login": "vmfunc",
|
||||
"name": "vmfunc",
|
||||
"name": "mel",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/59031302?v=4",
|
||||
"profile": "https://vmfunc.re",
|
||||
"contributions": [
|
||||
@@ -18,7 +18,12 @@
|
||||
"mentoring",
|
||||
"projectManagement",
|
||||
"security",
|
||||
"code"
|
||||
"test",
|
||||
"business",
|
||||
"code",
|
||||
"design",
|
||||
"financial",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -36,7 +41,6 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/127897805?v=4",
|
||||
"profile": "https://github.com/macdoos",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -48,7 +52,7 @@
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
{
|
||||
"login": "tessa-u-k",
|
||||
"name": "tessa ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/109355732?v=4",
|
||||
@@ -72,16 +76,6 @@
|
||||
"test",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "vxfemboy",
|
||||
"name": "Zoa Hickenlooper",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/79362520?v=4",
|
||||
"profile": "https://github.com/vxfemboy",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"repoType": "github"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
* @vmfunc
|
||||
@@ -1,15 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: vmfunc
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,17 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- deps
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- deps
|
||||
@@ -1,44 +0,0 @@
|
||||
ci:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/**"
|
||||
|
||||
deps:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
|
||||
scan:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "internal/scan/**"
|
||||
|
||||
nuclei:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "internal/nuclei/**"
|
||||
|
||||
modules:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "internal/modules/**"
|
||||
- "internal/scan/builtin/**"
|
||||
- "internal/scan/js/**"
|
||||
- "modules/**"
|
||||
|
||||
docs:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
tests:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "**/*_test.go"
|
||||
|
||||
config:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "internal/config/**"
|
||||
- ".golangci.yml"
|
||||
- ".editorconfig"
|
||||
@@ -1,12 +1,7 @@
|
||||
name: automatic rebase
|
||||
name: Automatic Rebase
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
@@ -14,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: automatic rebase
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@1.8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
name: check large files
|
||||
name: Check Large Files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-large-files:
|
||||
name: check for large files
|
||||
name: Check for large files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: check for large files
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check for large files
|
||||
run: |
|
||||
large_files=$(find . -path ./.git -prune -o -type f -size +5M -print)
|
||||
if [ -n "$large_files" ]; then
|
||||
echo "$large_files" | while read -r file; do
|
||||
echo "::error file=${file}::File ${file} is larger than 5MB"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
find . -type f -size +5M | while read file; do
|
||||
echo "::error file=${file}::File ${file} is larger than 5MB"
|
||||
done
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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 *)'
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
name: code quality
|
||||
|
||||
name: Qodana
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # monday 06:00 UTC
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
qodana:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- name: initialize codeql
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: go
|
||||
- name: build
|
||||
run: go build ./...
|
||||
- name: perform codeql analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:go"
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
- name: 'Qodana Scan'
|
||||
uses: JetBrains/qodana-action@v2024.3
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: dependency review
|
||||
name: "Dependency Review"
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
@@ -7,20 +7,16 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout repository
|
||||
uses: actions/checkout@v7
|
||||
- name: dependency review
|
||||
uses: actions/dependency-review-action@v5
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@v4
|
||||
continue-on-error: ${{ github.event_name == 'push' }}
|
||||
- name: check dependency review outcome
|
||||
- name: "Check Dependency Review Outcome"
|
||||
if: github.event_name == 'push' && failure()
|
||||
run: |
|
||||
echo "::warning::Dependency review failed. Please check the dependencies for potential issues."
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
name: go
|
||||
|
||||
name: Go
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.11.4
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ["1.25"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- name: build
|
||||
go-version: "1.23"
|
||||
- name: Build
|
||||
run: make
|
||||
- name: run tests with coverage
|
||||
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||
- name: upload coverage to codecov
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage.out
|
||||
fail_ci_if_error: false
|
||||
- name: run integration tests
|
||||
run: go test -tags=integration -race ./internal/scan/...
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: govulncheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # monday 06:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- name: install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
- name: run govulncheck
|
||||
run: govulncheck ./...
|
||||
continue-on-error: true
|
||||
@@ -8,14 +8,11 @@ on:
|
||||
paths:
|
||||
- '**.go'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-headers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: check license headers
|
||||
run: |
|
||||
@@ -44,7 +41,7 @@ jobs:
|
||||
echo ': █▀ █ █▀▀ · Blazing-fast pentesting suite :'
|
||||
echo ': ▄█ █ █▀ · BSD 3-Clause License :'
|
||||
echo ': :'
|
||||
echo ': (c) 2022-2026 vmfunc, xyzeva, :'
|
||||
echo ': (c) 2022-2025 vmfunc (vmfunc), xyzeva, :'
|
||||
echo ': lunchcat alumni & contributors :'
|
||||
echo ': :'
|
||||
echo '·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: mind your language
|
||||
name: Mind your language
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
@@ -12,19 +12,13 @@ on:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
echo_issue_comment:
|
||||
runs-on: ubuntu-latest
|
||||
name: profanity check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
- name: Profanity check step
|
||||
uses: tailaiw/mind-your-language-action@v1.0.3
|
||||
env:
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
name: markdown lint
|
||||
name: Markdown Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
markdownlint:
|
||||
name: runner / markdownlint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: markdownlint
|
||||
uses: reviewdog/action-markdownlint@v0.26.2
|
||||
uses: reviewdog/action-markdownlint@v0.24.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: github-pr-review
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
name: misspell check
|
||||
name: Misspell Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
misspell:
|
||||
name: runner / misspell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: misspell
|
||||
uses: reviewdog/action-misspell@v1.27.0
|
||||
uses: reviewdog/action-misspell@v1.26.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: github-pr-review
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
name: pr bot
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
|
||||
size:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: label pr size
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const changes = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
|
||||
|
||||
let size;
|
||||
if (changes < 10) size = "size/xs";
|
||||
else if (changes < 50) size = "size/s";
|
||||
else if (changes < 200) size = "size/m";
|
||||
else if (changes < 500) size = "size/l";
|
||||
else size = "size/xl";
|
||||
|
||||
const sizeLabels = ["size/xs", "size/s", "size/m", "size/l", "size/xl"];
|
||||
const currentLabels = context.payload.pull_request.labels.map(l => l.name);
|
||||
const toRemove = currentLabels.filter(l => sizeLabels.includes(l) && l !== size);
|
||||
|
||||
for (const label of toRemove) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
name: label,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [size],
|
||||
});
|
||||
|
||||
ci-summary:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [label, size]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const { data: checks } = await github.rest.checks.listForRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: pr.head.sha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const additions = files.reduce((sum, f) => sum + f.additions, 0);
|
||||
const deletions = files.reduce((sum, f) => sum + f.deletions, 0);
|
||||
const fileCount = files.length;
|
||||
|
||||
let body = `### pr summary\n\n`;
|
||||
body += `**${fileCount}** files changed (+${additions} -${deletions})\n\n`;
|
||||
|
||||
const goFiles = files.filter(f => f.filename.endsWith('.go')).length;
|
||||
const testFiles = files.filter(f => f.filename.endsWith('_test.go')).length;
|
||||
const ciFiles = files.filter(f => f.filename.startsWith('.github/')).length;
|
||||
const modFiles = files.filter(f => f.filename === 'go.mod' || f.filename === 'go.sum').length;
|
||||
|
||||
if (goFiles > 0 || testFiles > 0 || ciFiles > 0 || modFiles > 0) {
|
||||
body += `| category | files |\n|----------|-------|\n`;
|
||||
if (goFiles > 0) body += `| go source | ${goFiles} |\n`;
|
||||
if (testFiles > 0) body += `| tests | ${testFiles} |\n`;
|
||||
if (ciFiles > 0) body += `| ci/workflows | ${ciFiles} |\n`;
|
||||
if (modFiles > 0) body += `| deps | ${modFiles} |\n`;
|
||||
body += `\n`;
|
||||
}
|
||||
|
||||
// find existing bot comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
|
||||
const marker = '<!-- sif-pr-bot -->';
|
||||
body = marker + '\n' + body;
|
||||
|
||||
const existing = comments.find(c =>
|
||||
c.user.type === 'Bot' && c.body.includes(marker)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
+40
-175
@@ -1,9 +1,8 @@
|
||||
name: release
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -19,192 +18,58 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.23"
|
||||
|
||||
- name: extract version
|
||||
- name: Build for Windows
|
||||
run: |
|
||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
# single source of truth so the cross-compile steps can't drift
|
||||
echo "LDFLAGS=-s -w -X main.version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
GOOS=windows GOARCH=amd64 go build -o sif-windows-amd64.exe ./cmd/sif
|
||||
GOOS=windows GOARCH=386 go build -o sif-windows-386.exe ./cmd/sif
|
||||
|
||||
- name: build for windows
|
||||
- name: Build for macOS
|
||||
run: |
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-windows-amd64.exe ./cmd/sif
|
||||
GOOS=windows GOARCH=386 go build -ldflags="${{ env.LDFLAGS }}" -o sif-windows-386.exe ./cmd/sif
|
||||
GOOS=darwin GOARCH=amd64 go build -o sif-macos-amd64 ./cmd/sif
|
||||
GOOS=darwin GOARCH=arm64 go build -o sif-macos-arm64 ./cmd/sif
|
||||
|
||||
- name: build for macOS
|
||||
- name: Build for Linux
|
||||
run: |
|
||||
GOOS=darwin GOARCH=amd64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-macos-amd64 ./cmd/sif
|
||||
GOOS=darwin GOARCH=arm64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-macos-arm64 ./cmd/sif
|
||||
GOOS=linux GOARCH=amd64 go build -o sif-linux-amd64 ./cmd/sif
|
||||
GOOS=linux GOARCH=386 go build -o sif-linux-386 ./cmd/sif
|
||||
GOOS=linux GOARCH=arm64 go build -o sif-linux-arm64 ./cmd/sif
|
||||
|
||||
- name: build for linux
|
||||
run: |
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-linux-amd64 ./cmd/sif
|
||||
GOOS=linux GOARCH=386 go build -ldflags="${{ env.LDFLAGS }}" -o sif-linux-386 ./cmd/sif
|
||||
GOOS=linux GOARCH=arm64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-linux-arm64 ./cmd/sif
|
||||
- name: Set release version
|
||||
run: echo "RELEASE_VERSION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: package releases with modules
|
||||
run: |
|
||||
for binary in sif-linux-amd64 sif-linux-386 sif-linux-arm64 sif-macos-amd64 sif-macos-arm64; do
|
||||
mkdir -p "dist/${binary}"
|
||||
cp "${binary}" "dist/${binary}/sif"
|
||||
cp -r modules "dist/${binary}/"
|
||||
tar -czf "${binary}.tar.gz" -C dist "${binary}"
|
||||
done
|
||||
for binary in sif-windows-amd64 sif-windows-386; do
|
||||
mkdir -p "dist/${binary}"
|
||||
cp "${binary}.exe" "dist/${binary}/sif.exe"
|
||||
cp -r modules "dist/${binary}/"
|
||||
cd dist && zip -r "../${binary}.zip" "${binary}" && cd ..
|
||||
done
|
||||
|
||||
- name: build debian packages
|
||||
run: |
|
||||
declare -A arch_map=(
|
||||
["sif-linux-amd64"]="amd64"
|
||||
["sif-linux-386"]="i386"
|
||||
["sif-linux-arm64"]="arm64"
|
||||
)
|
||||
|
||||
for binary in sif-linux-amd64 sif-linux-386 sif-linux-arm64; do
|
||||
arch="${arch_map[$binary]}"
|
||||
pkg_dir="sif_${{ env.VERSION }}_${arch}"
|
||||
|
||||
mkdir -p "${pkg_dir}/DEBIAN"
|
||||
mkdir -p "${pkg_dir}/usr/bin"
|
||||
mkdir -p "${pkg_dir}/usr/share/sif/modules"
|
||||
|
||||
cp "${binary}" "${pkg_dir}/usr/bin/sif"
|
||||
chmod 755 "${pkg_dir}/usr/bin/sif"
|
||||
cp -r modules/* "${pkg_dir}/usr/share/sif/modules/"
|
||||
|
||||
cat > "${pkg_dir}/DEBIAN/control" << EOF
|
||||
Package: sif
|
||||
Version: ${{ env.VERSION }}
|
||||
Section: security
|
||||
Priority: optional
|
||||
Architecture: ${arch}
|
||||
Maintainer: vmfunc <celeste@linux.com>
|
||||
Homepage: https://github.com/vmfunc/sif
|
||||
Description: Modular pentesting toolkit
|
||||
sif is a fast, concurrent, and extensible pentesting toolkit written in Go.
|
||||
It supports multiple scan types including directory fuzzing, subdomain
|
||||
enumeration, port scanning, and vulnerability detection.
|
||||
EOF
|
||||
|
||||
dpkg-deb --build "${pkg_dir}"
|
||||
done
|
||||
|
||||
- name: generate checksums
|
||||
run: |
|
||||
sha256sum \
|
||||
sif-windows-amd64.zip \
|
||||
sif-windows-386.zip \
|
||||
sif-macos-amd64.tar.gz \
|
||||
sif-macos-arm64.tar.gz \
|
||||
sif-linux-amd64.tar.gz \
|
||||
sif-linux-386.tar.gz \
|
||||
sif-linux-arm64.tar.gz \
|
||||
sif_*.deb \
|
||||
> checksums-sha256.txt
|
||||
|
||||
- name: generate SBOM
|
||||
uses: anchore/sbom-action@v0
|
||||
- name: Create Release and Upload Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
artifact-name: sbom.spdx.json
|
||||
output-file: sbom.spdx.json
|
||||
|
||||
- name: generate changelog
|
||||
id: changelog
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data: releases } = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const prev = releases.length > 0 ? releases[0].tag_name : '';
|
||||
const range = prev ? `${prev}...${context.ref}` : '';
|
||||
|
||||
const { data: commits } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
basehead: prev ? `${prev}...${{ github.ref_name }}` : `${{ github.sha }}~10...${{ github.sha }}`,
|
||||
}).catch(() => ({ data: { commits: [] } }));
|
||||
|
||||
let log = '';
|
||||
for (const c of commits.commits || []) {
|
||||
const msg = c.commit.message.split('\n')[0];
|
||||
const sha = c.sha.substring(0, 7);
|
||||
log += `- ${msg} (${sha})\n`;
|
||||
}
|
||||
|
||||
return log || 'initial release';
|
||||
|
||||
- name: create release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: sif v${{ env.VERSION }}
|
||||
tag_name: automated-release-${{ env.RELEASE_VERSION }}
|
||||
name: Release ${{ env.RELEASE_VERSION }}
|
||||
body: |
|
||||
## what's changed
|
||||
Automated release v${{ env.RELEASE_VERSION }}
|
||||
|
||||
${{ steps.changelog.outputs.result }}
|
||||
## Assets
|
||||
- Windows (64-bit): `sif-windows-amd64.exe`
|
||||
- Windows (32-bit): `sif-windows-386.exe`
|
||||
- macOS (64-bit Intel): `sif-macos-amd64`
|
||||
- macOS (64-bit ARM): `sif-macos-arm64`
|
||||
- Linux (64-bit): `sif-linux-amd64`
|
||||
- Linux (32-bit): `sif-linux-386`
|
||||
- Linux (64-bit ARM): `sif-linux-arm64`
|
||||
|
||||
## install
|
||||
|
||||
**homebrew / linuxbrew**
|
||||
```bash
|
||||
# coming soon
|
||||
```
|
||||
|
||||
**debian / ubuntu**
|
||||
```bash
|
||||
sudo dpkg -i sif_${{ env.VERSION }}_amd64.deb
|
||||
```
|
||||
|
||||
**go install**
|
||||
```bash
|
||||
go install github.com/dropalldatabases/sif/cmd/sif@v${{ env.VERSION }}
|
||||
```
|
||||
|
||||
**binary download** - grab the right archive from below.
|
||||
|
||||
## verification
|
||||
|
||||
```bash
|
||||
sha256sum -c checksums-sha256.txt
|
||||
```
|
||||
For more details, check the [commit history](https://github.com/${{ github.repository }}/commits/main).
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
prerelease: false
|
||||
files: |
|
||||
sif-windows-amd64.zip
|
||||
sif-windows-386.zip
|
||||
sif-macos-amd64.tar.gz
|
||||
sif-macos-arm64.tar.gz
|
||||
sif-linux-amd64.tar.gz
|
||||
sif-linux-386.tar.gz
|
||||
sif-linux-arm64.tar.gz
|
||||
sif_*_amd64.deb
|
||||
sif_*_i386.deb
|
||||
sif_*_arm64.deb
|
||||
checksums-sha256.txt
|
||||
sbom.spdx.json
|
||||
sif-windows-amd64.exe
|
||||
sif-windows-386.exe
|
||||
sif-macos-amd64
|
||||
sif-macos-arm64
|
||||
sif-linux-amd64
|
||||
sif-linux-386
|
||||
sif-linux-arm64
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: push to cloudsmith
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
env:
|
||||
CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }}
|
||||
run: |
|
||||
pip install cloudsmith-cli
|
||||
for deb in sif_*.deb; do
|
||||
cloudsmith push deb sif/deb/any-distro/any-version "$deb" -k "$CLOUDSMITH_API_KEY"
|
||||
done
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: update report card
|
||||
name: Update Report Card
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,17 +7,10 @@ on:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-report-card:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: update go report card
|
||||
- uses: actions/checkout@v4
|
||||
- name: Update Go Report Card
|
||||
uses: creekorful/goreportcard-action@v1.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: functional test
|
||||
name: Functional Test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,39 +7,23 @@ on:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: set up go
|
||||
uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- name: build sif
|
||||
go-version: "1.23"
|
||||
- name: Build Sif
|
||||
run: make
|
||||
- name: run sif with features
|
||||
- name: Run Sif with features
|
||||
run: |
|
||||
./sif -u https://example.com -dnslist small -dirlist small -dork -git -whois -cms -framework
|
||||
./sif -u https://google.com -dnslist small -dirlist small -dork -git -whois -cms
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Sif ran successfully"
|
||||
else
|
||||
echo "Sif exited with an error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: test module system
|
||||
run: |
|
||||
echo "Listing modules..."
|
||||
./sif -lm
|
||||
echo "Running all modules..."
|
||||
./sif -u https://example.com -am
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Module system working"
|
||||
else
|
||||
echo "Module system failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: scorecard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # monday 06:00 UTC
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: run scorecard
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
- name: upload sarif results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -1,22 +1,18 @@
|
||||
name: shell check
|
||||
name: Shell Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.sh"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
shellcheck:
|
||||
name: runner / shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: shellcheck
|
||||
uses: reviewdog/action-shellcheck@v1.32.0
|
||||
uses: reviewdog/action-shellcheck@v1.27.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: github-pr-review
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: yaml lint
|
||||
name: YAML Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -6,18 +6,14 @@ on:
|
||||
- "**/*.yml"
|
||||
- "**/*.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
yamllint:
|
||||
name: runner / yamllint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: yamllint
|
||||
uses: reviewdog/action-yamllint@v1.21.0
|
||||
uses: reviewdog/action-yamllint@v1.19.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: github-pr-review
|
||||
|
||||
+16
-89
@@ -1,96 +1,23 @@
|
||||
---
|
||||
version: "2"
|
||||
linters:
|
||||
enable:
|
||||
- errcheck # check error returns
|
||||
- govet # suspicious constructs
|
||||
- staticcheck # advanced static analysis
|
||||
- unused # unused code
|
||||
- gosimple # simplifications
|
||||
- ineffassign # useless assignments
|
||||
- misspell # spelling mistakes
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
enable-all: true
|
||||
errcheck:
|
||||
check-blank: false
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
issues-exit-code: 1
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck # check error returns
|
||||
- govet # suspicious constructs
|
||||
- staticcheck # advanced static analysis (absorbs gosimple in v2)
|
||||
- unused # unused code
|
||||
- ineffassign # useless assignments
|
||||
- misspell # spelling mistakes
|
||||
- gocritic # opinionated lints
|
||||
- revive # replacement for golint
|
||||
- unconvert # unnecessary type conversions
|
||||
- bodyclose # http response body not closed
|
||||
- noctx # http requests without context
|
||||
- gosec # security issues
|
||||
- errorlint # error wrapping and comparison
|
||||
- nilnil # return nil, nil
|
||||
- wastedassign # assignments to variables never read
|
||||
- usetesting # os.Setenv in tests instead of t.Setenv, etc.
|
||||
|
||||
settings:
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
# too many structs to reorder, risks breaking serialization
|
||||
- fieldalignment
|
||||
- shadow # common Go pattern, too noisy
|
||||
- unusedwrite # false positives on test data structs
|
||||
errcheck:
|
||||
check-blank: false
|
||||
exclude-functions:
|
||||
# log writes are best-effort
|
||||
- github.com/dropalldatabases/sif/internal/logger.Write
|
||||
# Close on io.Closer is idiomatic best-effort
|
||||
- (io.Closer).Close
|
||||
- (*os.File).Close
|
||||
- (*net/http.Response).Body.Close
|
||||
# fmt.Fprint* returns are rarely actionable
|
||||
- fmt.Fprint
|
||||
- fmt.Fprintf
|
||||
- fmt.Fprintln
|
||||
staticcheck:
|
||||
# QF1003/QF1012 are v2 quickfix suggestions, not bugs.
|
||||
# ST1000/ST1003 were the stylecheck linter in v1
|
||||
# (not previously enabled); skipping to match prior parity.
|
||||
checks:
|
||||
- all
|
||||
- -QF1003
|
||||
- -QF1012
|
||||
- -ST1000
|
||||
- -ST1003
|
||||
revive:
|
||||
rules:
|
||||
# stuttering names (scan.ScanResult) need breaking API changes
|
||||
- name: exported
|
||||
disabled: true
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- style
|
||||
- performance
|
||||
disabled-checks:
|
||||
- commentedOutCode # too opinionated for a project with TODOs
|
||||
- paramTypeCombine # style-only, not worth churn
|
||||
- unnamedResult # style-only
|
||||
- unnecessaryDefer # common pattern in tests
|
||||
# inverting conditions in scan logic hurts readability
|
||||
- nestingReduce
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # errcheck covers this
|
||||
- G107 # pentesting tool -- variable URLs are the whole point
|
||||
- G110 # nuclei template decompression, acceptable context
|
||||
- G304 # sif reads user-supplied wordlist paths -- intentional
|
||||
- G305 # tar extraction is traversal-guarded (HasPrefix on the
|
||||
# cleaned target); gosec flags filepath.Join regardless
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# test files get some slack
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- noctx
|
||||
- gosec # fake credentials in secret-scanner fixtures are not real keys
|
||||
- bodyclose # synthetic *http.Response fixtures carry no socket to close
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 50
|
||||
max-same-issues: 50
|
||||
max-same-issues: 3
|
||||
|
||||
+5
-125
@@ -4,19 +4,17 @@ Thank you for taking the time to contribute to sif! All contributions are valued
|
||||
If you want to contribute but don't know where to start, worry not; there is no shortage of things to do.
|
||||
Even if you don't know any Go, don't let that stop you from trying to contribute! We're here to help.
|
||||
|
||||
_By contributing to this repository, you agree to adhere to the sif [Code of Conduct](https://github.com/vmfunc/sif/blob/main/CODE_OF_CONDUCT.md). Not doing so may result in a ban._
|
||||
*By contributing to this repository, you agree to adhere to the sif [Code of Conduct](https://github.com/dropalldatabases/sif/blob/main/CODE_OF_CONDUCT.md). Not doing so may result in a ban.*
|
||||
|
||||
## How can I help?
|
||||
|
||||
Here are some ways to get started:
|
||||
|
||||
- Have a look at our [issue tracker](https://github.com/vmfunc/sif/issues).
|
||||
- Have a look at our [issue tracker](https://github.com/dropalldatabases/sif/issues).
|
||||
- If you've encountered a bug, discuss it with us, [report it](#reporting-issues).
|
||||
- Once you've found a bug you believe you can fix, open a [pull request](#contributing-code) for it.
|
||||
- Alternatively, consider [packaging sif for your distribution](#packaging).
|
||||
|
||||
If you like the project, but don't have time to contribute, that's okay too! Here are other ways to show your appreciation for the project:
|
||||
|
||||
- Use sif (seriously, that's enough)
|
||||
- Star the repository
|
||||
- Share sif with your friends
|
||||
@@ -24,7 +22,7 @@ If you like the project, but don't have time to contribute, that's okay too! Her
|
||||
|
||||
## Reporting issues
|
||||
|
||||
If you believe you've found a bug, or you have a new feature to request, please hop on the [Discord server](https://discord.com/invite/sifcli) first to discuss it.
|
||||
If you believe you've found a bug, or you have a new feature to request, please hop on the [Discord server](https://discord.gg/dropalldatabases) first to discuss it.
|
||||
This way, if it's an easy fix, we could help you solve it more quickly, and if it's a feature request we could workshop it together into something more mature.
|
||||
|
||||
When opening an issue, please use the search tool and make sure that the issue has not been discussed before. In the case of a bug report, run sif with the `-d/-debug` flag for full debug logs.
|
||||
@@ -33,9 +31,9 @@ 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.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.
|
||||
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.
|
||||
|
||||
_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.
|
||||
*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.
|
||||
|
||||
### Submitting a pull request
|
||||
|
||||
@@ -55,124 +53,6 @@ When making a pull request, please adhere to the following conventions:
|
||||
|
||||
If you have any questions, feel free to ask around on the IRC channel.
|
||||
|
||||
## Contributing Framework Detection Patterns
|
||||
|
||||
The framework detection module (`internal/scan/frameworks/`) identifies web frameworks by analyzing HTTP headers and response bodies. Detectors are organized by category in the `detectors/` subdirectory:
|
||||
|
||||
### Adding a New Framework Detector
|
||||
|
||||
1. Create a detector struct in the appropriate file in `detectors/`:
|
||||
|
||||
```go
|
||||
// myframeworkDetector detects MyFramework.
|
||||
type myframeworkDetector struct{}
|
||||
|
||||
func (d *myframeworkDetector) Name() string { return "MyFramework" }
|
||||
|
||||
func (d *myframeworkDetector) Signatures() []fw.Signature {
|
||||
return []fw.Signature{
|
||||
{Pattern: "unique-identifier", Weight: 0.5},
|
||||
{Pattern: "header-signature", Weight: 0.4, HeaderOnly: true},
|
||||
{Pattern: "body-signature", Weight: 0.3},
|
||||
}
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
2. Register the detector in the `init()` function of the same file:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
fw.Register(&myframeworkDetector{})
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern Guidelines:**
|
||||
|
||||
- `Weight`: How much this signature contributes to detection (0.0-1.0)
|
||||
- `HeaderOnly`: Set to `true` for HTTP header patterns
|
||||
- Use unique identifiers that won't false-positive on other frameworks
|
||||
- Include multiple patterns for higher confidence
|
||||
|
||||
### Adding Version Detection
|
||||
|
||||
Add version patterns to `version.go` in the `rawPatterns` map inside `init()`:
|
||||
|
||||
```go
|
||||
"MyFramework": {
|
||||
{`<meta name="generator" content="MyFramework v?(\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
|
||||
{`MyFramework[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
||||
{`"myframework":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
||||
},
|
||||
```
|
||||
|
||||
### Adding CVE Data
|
||||
|
||||
Add known vulnerabilities to `cve.go` in the `knownCVEs` map:
|
||||
|
||||
```go
|
||||
"MyFramework": {
|
||||
{
|
||||
CVE: "CVE-YYYY-XXXXX",
|
||||
AffectedVersions: []string{"1.0.0", "1.0.1", "1.1.0"},
|
||||
FixedVersion: "1.2.0",
|
||||
Severity: "high", // critical, high, medium, low
|
||||
Description: "Brief description of the vulnerability",
|
||||
Recommendations: []string{"Update to 1.2.0 or later"},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Testing Your Changes
|
||||
|
||||
Always add tests for new frameworks in `detect_test.go`:
|
||||
|
||||
```go
|
||||
func TestDetectFramework_MyFramework(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<html><body>unique-identifier</body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
|
||||
// assertions...
|
||||
}
|
||||
```
|
||||
|
||||
Also add your framework to the registry test in `TestDetectorRegistry`:
|
||||
|
||||
```go
|
||||
expectedDetectors := []string{"Laravel", "Django", ..., "MyFramework"}
|
||||
```
|
||||
|
||||
### Future Enhancements (Help Wanted)
|
||||
|
||||
- **Custom Signature Support**: Allow users to define signatures via config file
|
||||
- **CVE API Integration**: Real-time CVE data from NVD or other sources
|
||||
- **Automated Signature Updates**: Fetch new signatures from a central repository
|
||||
- **Framework Fingerprint Database**: Community-maintained signature database
|
||||
|
||||
## Configuration
|
||||
|
||||
### Framework Detection Flags
|
||||
|
||||
| Flag | Description |
|
||||
| ------------ | ------------------------------------------ |
|
||||
| `-framework` | Enable framework detection |
|
||||
| `-timeout` | HTTP request timeout (affects all modules) |
|
||||
| `-threads` | Number of concurrent workers |
|
||||
| `-log` | Directory to save scan results |
|
||||
| `-debug` | Enable debug logging for verbose output |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------- | ------------------------------------ |
|
||||
| `SHODAN_API_KEY` | API key for Shodan host intelligence |
|
||||
|
||||
## Packaging
|
||||
|
||||
We'd love it if you helped us bring sif to your distribution.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2022-2025 vmfunc, xyzeva, lunchcat alumni,
|
||||
Copyright (c) 2022-2025 vmfunc (vmfunc), xyzeva, lunchcat alumni,
|
||||
and other sif contributors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
||||
@@ -9,12 +9,6 @@ RM ?= rm
|
||||
GOFLAGS ?=
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR ?= bin
|
||||
MANDIR ?= share/man/man1
|
||||
|
||||
# stamp local builds with the nearest v* tag (or short sha), matching the
|
||||
# release ci. --match keeps the automated-release-* tags out of the version.
|
||||
VERSION ?= $(shell git describe --tags --match 'v*' --always --dirty 2>/dev/null | sed 's/^v//')
|
||||
GO_LDFLAGS = -X main.version=$(VERSION)
|
||||
|
||||
define COPYRIGHT_ASCII
|
||||
╭────────────────────────────────────────────────────────────╮
|
||||
@@ -38,7 +32,8 @@ define SUPPORT_MESSAGE
|
||||
│ │
|
||||
│ 🌟 Enjoying sif? Please consider: │
|
||||
│ │
|
||||
│ • Starring our repo: https://github.com/vmfunc/sif │
|
||||
│ • Starring our repo: https://github.com/lunchcat/sif │
|
||||
│ • Supporting the devs: https://lunchcat.dev │
|
||||
│ │
|
||||
│ Your support helps us continue improving sif! │
|
||||
│ │
|
||||
@@ -61,7 +56,7 @@ sif: check_go_version
|
||||
@echo "📁 Current directory: $$(pwd)"
|
||||
@echo "🔧 Go flags: $(GOFLAGS)"
|
||||
@echo "📦 Building package: ./cmd/sif"
|
||||
$(GO) build -v $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" ./cmd/sif
|
||||
$(GO) build -v $(GOFLAGS) ./cmd/sif
|
||||
@echo "📊 Build info:"
|
||||
@$(GO) version -m sif
|
||||
@echo "✅ sif built successfully! 🚀"
|
||||
@@ -81,9 +76,6 @@ install: check_go_version
|
||||
fi
|
||||
@mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR))
|
||||
@cp -f sif $(DESTDIR)$(PREFIX)/$(BINDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo cp -f sif $(DESTDIR)$(PREFIX)/$(BINDIR))
|
||||
@echo "📖 Installing man page..."
|
||||
@mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR))
|
||||
@cp -f man/sif.1 $(DESTDIR)$(PREFIX)/$(MANDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo cp -f man/sif.1 $(DESTDIR)$(PREFIX)/$(MANDIR))
|
||||
@echo "✅ sif installed successfully! 🎊"
|
||||
|
||||
uninstall:
|
||||
@@ -94,7 +86,6 @@ uninstall:
|
||||
exit 1; \
|
||||
fi
|
||||
@$(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/sif || (echo "🔒 Permission denied. Trying with sudo..." && sudo $(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/sif)
|
||||
@$(RM) $(DESTDIR)$(PREFIX)/$(MANDIR)/sif.1 || (echo "🔒 Permission denied. Trying with sudo..." && sudo $(RM) $(DESTDIR)$(PREFIX)/$(MANDIR)/sif.1)
|
||||
@echo "✅ sif uninstalled successfully!"
|
||||
|
||||
.PHONY: all check_go_version sif clean install uninstall
|
||||
@@ -1,21 +1,19 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/banner.png" alt="sif" width="600">
|
||||
|
||||
<br><br>
|
||||
```
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ :
|
||||
: ▄█ █ █▀ blazing-fast pentesting suite :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
```
|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://github.com/vmfunc/sif/actions)
|
||||
[](LICENSE)
|
||||
[](https://aur.archlinux.org/packages/sif)
|
||||
[](https://search.nixos.org/packages?query=sif)
|
||||
[](https://github.com/vmfunc/homebrew-sif)
|
||||
[](https://cloudsmith.io/~sif/repos/deb/packages/)
|
||||
[](https://discord.gg/Yksy9J2BvE)
|
||||
|
||||
**[install](#install) · [usage](#usage) · [modules](#modules) · [docs](docs/) · [contribute](#contribute)**
|
||||
|
||||
*fast, concurrent recon to exploitation in one binary. every scanner shares one connection-pooled http client.*
|
||||
**[install](#install) · [usage](#usage) · [modules](#modules) · [contribute](#contribute)**
|
||||
|
||||
</div>
|
||||
|
||||
@@ -23,69 +21,14 @@
|
||||
|
||||
## what is sif?
|
||||
|
||||
sif is a recon and exploitation scanner that runs the whole chain in one binary: subdomain enum, port scan, crawler, nuclei, framework/cve detection, js secret extraction, web-vuln probes (cors/xss/redirect), cloud and takeover checks. 25+ scan types, one command.
|
||||
sif is a modular pentesting toolkit written in go. it's designed to be fast, concurrent, and extensible. run multiple scan types against targets with a single command.
|
||||
|
||||
```bash
|
||||
sif -u https://example.com -dnslist -ports -crawl -js -framework -nuclei
|
||||
./sif -u https://example.com -all
|
||||
```
|
||||
|
||||
nuclei and colly are compiled in as libraries rather than shelled out to (there's no `exec.Command` in the tree), so it's a single static binary with no runtime dependencies and nothing to wire together.
|
||||
|
||||
every scanner runs through one shared http client and a work-stealing worker pool. `-proxy`, `-H`, `-cookie` and `-rate-limit` apply to the whole run at once, connections get pooled and reused across the scan (a single-host run reuses one connection for ~50 requests instead of dialing 50 times), and a slow host doesn't hold the rest up. that shared client is the practical reason to use it over piping a stack of separate tools together. port scanning is `connect()`-based, so rustscan and nmap are still faster at raw port scans.
|
||||
|
||||
it reads targets from stdin and prints findings one per line under `-silent`, so it composes:
|
||||
|
||||
```bash
|
||||
subfinder -d example.com | sif -silent -crawl -js -nuclei | notify
|
||||
```
|
||||
|
||||
`-diff` turns a re-scan into a monitor that only reports what changed, `-notify` posts to slack/discord/telegram/webhook, and runs export to sarif and markdown.
|
||||
|
||||
## install
|
||||
|
||||
### homebrew (macos)
|
||||
|
||||
```bash
|
||||
brew tap vmfunc/sif
|
||||
brew install sif
|
||||
```
|
||||
|
||||
### arch linux (aur)
|
||||
|
||||
install using your preferred aur helper:
|
||||
|
||||
```bash
|
||||
yay -S sif
|
||||
# or
|
||||
paru -S sif
|
||||
```
|
||||
|
||||
### nix
|
||||
|
||||
```bash
|
||||
# nixpkgs (declarative: add to configuration.nix or home-manager)
|
||||
environment.systemPackages = [ pkgs.sif ];
|
||||
|
||||
# or imperatively
|
||||
nix profile install nixpkgs#sif
|
||||
|
||||
# or just run it without installing
|
||||
nix run nixpkgs#sif -- -u https://example.com -headers -sh -framework
|
||||
```
|
||||
|
||||
the repo also ships a flake if you want to build from source:
|
||||
|
||||
```bash
|
||||
nix run github:vmfunc/sif
|
||||
```
|
||||
|
||||
### debian/ubuntu (apt)
|
||||
|
||||
```bash
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/sif/deb/setup.deb.sh' | sudo -E bash
|
||||
sudo apt-get install sif
|
||||
```
|
||||
|
||||
### from releases
|
||||
|
||||
grab the latest binary from [releases](https://github.com/vmfunc/sif/releases).
|
||||
@@ -93,20 +36,12 @@ grab the latest binary from [releases](https://github.com/vmfunc/sif/releases).
|
||||
### from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vmfunc/sif.git
|
||||
git clone https://github.com/dropalldatabases/sif.git
|
||||
cd sif
|
||||
make
|
||||
```
|
||||
|
||||
requires go 1.25+
|
||||
|
||||
### aur (manual install)
|
||||
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/sif.git
|
||||
cd sif
|
||||
makepkg -si
|
||||
```
|
||||
requires go 1.23+
|
||||
|
||||
## usage
|
||||
|
||||
@@ -126,235 +61,28 @@ makepkg -si
|
||||
# javascript framework detection + cloud misconfig
|
||||
./sif -u https://example.com -js -c3
|
||||
|
||||
# shodan host intelligence (requires SHODAN_API_KEY env var)
|
||||
./sif -u https://example.com -shodan
|
||||
|
||||
# securitytrails domain discovery (requires SECURITYTRAILS_API_KEY env var)
|
||||
# discovers subdomains + associated domains, then scans all of them
|
||||
./sif -u https://example.com -securitytrails -headers
|
||||
|
||||
# sql recon + lfi scanning
|
||||
./sif -u https://example.com -sql -lfi
|
||||
|
||||
# web vuln probes (cors, open redirect, reflected xss)
|
||||
./sif -u https://example.com -cors -redirect -xss
|
||||
|
||||
# framework detection (with cve lookup)
|
||||
./sif -u https://example.com -framework
|
||||
|
||||
# a broad sweep
|
||||
./sif -u https://example.com -dirlist small -dnslist small -ports common -headers -sh -cms -framework -git -whois
|
||||
# everything
|
||||
./sif -u https://example.com -all
|
||||
```
|
||||
|
||||
run `./sif -h` for all options.
|
||||
|
||||
## commands
|
||||
|
||||
a couple of subcommands run without scanning:
|
||||
|
||||
```bash
|
||||
# print the version (release builds are stamped; local builds use git describe)
|
||||
./sif version
|
||||
|
||||
# show the latest release notes (also -pn)
|
||||
./sif patchnote
|
||||
```
|
||||
|
||||
the first time you run a new release, sif prints that release's notes once. set `SIF_NO_PATCHNOTES=1` to turn that off.
|
||||
|
||||
## modules
|
||||
|
||||
sif has a modular architecture. modules are defined in yaml and can be extended by users.
|
||||
|
||||
### built-in scan flags
|
||||
|
||||
| flag | description |
|
||||
|------|-------------|
|
||||
| `-dirlist` | directory and file fuzzing (small/medium/large) |
|
||||
| `-mc` | dirlist: match these status codes (comma list, e.g. 200,301) |
|
||||
| `-fc` | dirlist: filter out these status codes (comma list) |
|
||||
| `-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` | 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) |
|
||||
| `-ports` | port scanning (common/full) |
|
||||
| `-nuclei` | vulnerability scanning with nuclei templates |
|
||||
| `-dork` | automated google dorking |
|
||||
| `-js` | javascript analysis + secret and endpoint extraction |
|
||||
| `-c3` | cloud storage misconfiguration |
|
||||
| `-headers` | http header analysis |
|
||||
| `-sh` | security header analysis (missing/weak headers) |
|
||||
| `-st` | subdomain takeover detection |
|
||||
| `-cms` | cms detection |
|
||||
| `-whois` | whois lookups |
|
||||
| `-git` | exposed git repository detection |
|
||||
| `-shodan` | shodan lookup (requires SHODAN_API_KEY) |
|
||||
| `-securitytrails` | domain discovery + target expansion (requires SECURITYTRAILS_API_KEY) |
|
||||
| `-sql` | sql recon |
|
||||
| `-lfi` | local file inclusion |
|
||||
| `-jwt` | jwt discovery + offline weakness analysis (alg:none, weak hmac, exp, sensitive claims) |
|
||||
| `-openapi` | openapi/swagger spec exposure probe (enumerates paths + unauth endpoints) |
|
||||
| `-favicon` | favicon hash fingerprinting (shodan-style mmh3, tech match + pivot query) |
|
||||
| `-cors` | cors misconfiguration probe |
|
||||
| `-redirect` | open redirect probe |
|
||||
| `-xss` | reflected xss probe |
|
||||
| `-framework` | framework detection with cve lookup |
|
||||
| `-crawl` | web crawler (spider same-host links/scripts/forms) |
|
||||
| `-crawl-depth` | max crawl recursion depth (default 2) |
|
||||
| `-passive` | passive subdomain/url discovery (zero traffic to target) |
|
||||
| `-probe` | live-host probe (status, title, server, redirect chain) |
|
||||
|
||||
### http options
|
||||
|
||||
these apply to every outbound request across all scanners:
|
||||
|
||||
| flag | description |
|
||||
|------|-------------|
|
||||
| `-proxy` | route all traffic through a proxy (http/https/socks5 url) |
|
||||
| `-H`, `--header` | custom header to send (repeatable or comma-separated, `"Key: Value"`) |
|
||||
| `-cookie` | cookie header to send with every request |
|
||||
| `-rate-limit` | max requests per second (0 = unlimited, default 0) |
|
||||
|
||||
```bash
|
||||
# scan through a socks5 proxy with a custom header, cookie and 20 req/s cap
|
||||
./sif -u https://example.com -headers -proxy socks5://127.0.0.1:1080 -H "Authorization: Bearer tok" -cookie "session=abc" -rate-limit 20
|
||||
```
|
||||
|
||||
a scanner that sets a header explicitly (e.g. an api key) always wins over the global default.
|
||||
|
||||
### report export
|
||||
|
||||
write the run's findings out to a file for ci/cd or triage:
|
||||
|
||||
| flag | description |
|
||||
|------|-------------|
|
||||
| `-sarif` | write a sarif 2.1.0 report to this file |
|
||||
| `-markdown`, `-md` | write a markdown report to this file |
|
||||
| `-silent` | plain output: chrome to stderr, one finding per line to stdout (for pipelines) |
|
||||
| `-diff` | surface only findings added/removed since the last snapshot of each target |
|
||||
| `-store` | snapshot directory for `-diff` (default: log dir, else `<user-config>/sif/state`) |
|
||||
|
||||
```bash
|
||||
# scan and emit both a sarif and markdown report
|
||||
./sif -u https://example.com -headers -cors -sarif out.sarif -md out.md
|
||||
```
|
||||
|
||||
sarif output is ingestable by github code scanning; markdown is a readable per-target summary.
|
||||
|
||||
### diff mode
|
||||
|
||||
`-diff` turns a re-scan into a monitor: sif snapshots each target's normalized findings to a json file, and on the next run reports only the delta (`+ new` / `- gone`) against that snapshot, then overwrites it. the first run for a target has no baseline, so everything is `+ new`. snapshots land in `-store` (one sanitized file per target); when unset they reuse the log dir, falling back to `<user-config>/sif/state`.
|
||||
|
||||
```bash
|
||||
# baseline run, then re-scan later and see only what moved
|
||||
./sif -u https://example.com -sh -cors -diff
|
||||
./sif -u https://example.com -sh -cors -diff
|
||||
```
|
||||
|
||||
the snapshot is always rewritten, so each run diffs against the previous one. the delta is chrome (it rides the normal output sink / stderr under `-silent`), not the findings stream.
|
||||
|
||||
### notify
|
||||
|
||||
ship findings to a chat/webhook sink so a continuous-recon run alerts on what it turns up. every provider is a single POST through the shared http client, so the global proxy/rate-limit/header config applies.
|
||||
|
||||
| flag | description |
|
||||
|------|-------------|
|
||||
| `-notify` | ship findings to every configured provider after the scan |
|
||||
| `-notify-severity` | minimum severity to send (`info`/`low`/`medium`/`high`/`critical`, default `medium`) |
|
||||
| `-notify-config` | path to a notify-compatible yaml config (overrides env vars) |
|
||||
|
||||
providers are configured env-first; a yaml file (`-notify-config`) overrides per-field. the yaml keys match [projectdiscovery/notify](https://github.com/projectdiscovery/notify) so an existing config ports over:
|
||||
|
||||
| env var | yaml key | provider |
|
||||
|---------|----------|----------|
|
||||
| `SLACK_WEBHOOK_URL` | `slack_webhook_url` | slack incoming webhook |
|
||||
| `DISCORD_WEBHOOK_URL` | `discord_webhook_url` | discord webhook |
|
||||
| `TELEGRAM_BOT_TOKEN` | `telegram_api_key` | telegram bot api (needs chat id too) |
|
||||
| `TELEGRAM_CHAT_ID` | `telegram_chat_id` | telegram destination chat |
|
||||
| `NOTIFY_WEBHOOK_URL` | `webhook_url` | generic json webhook (structured findings) |
|
||||
|
||||
```bash
|
||||
# alert slack on medium+ findings discovered during a scan
|
||||
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
|
||||
./sif -u https://example.com -cors -xss -notify -notify-severity medium
|
||||
```
|
||||
|
||||
a provider with no destination is skipped; with nothing configured, `-notify` is a silent no-op. slack/discord/telegram receive a fixed-width finding block; the generic webhook receives structured json (`{count, findings[]}`).
|
||||
|
||||
### pipe mode
|
||||
|
||||
sif reads targets from stdin and accepts naked hosts, so it drops into a unix pipeline. `-silent` routes all banner/spinner/log chrome to stderr and prints one normalized finding per line (`[severity] target module title`) to stdout:
|
||||
|
||||
```bash
|
||||
# subfinder feeds hosts, sif probes them, notify ships the findings
|
||||
subfinder -d example.com | sif -silent -probe | notify
|
||||
```
|
||||
|
||||
| flag | description |
|
||||
|------|-------------|
|
||||
| stdin | a piped target stream (one host/url per line) is read alongside `-u`/`-f` |
|
||||
|
||||
scheme-less hosts default to `https://`; an explicit `http://`/`https://` is kept; any other scheme (`ftp://`, ...) is rejected.
|
||||
|
||||
### yaml modules
|
||||
|
||||
list available modules:
|
||||
|
||||
```bash
|
||||
./sif -lm
|
||||
```
|
||||
|
||||
run specific modules:
|
||||
|
||||
```bash
|
||||
# run by id
|
||||
./sif -u https://example.com -m sqli-error-based,xss-reflected
|
||||
|
||||
# run by tag
|
||||
./sif -u https://example.com -mt owasp-top10
|
||||
|
||||
# run all modules
|
||||
./sif -u https://example.com -am
|
||||
```
|
||||
|
||||
### custom modules
|
||||
|
||||
create your own modules in `~/.config/sif/modules/`. modules use a yaml format similar to nuclei templates:
|
||||
|
||||
```yaml
|
||||
id: my-custom-check
|
||||
info:
|
||||
name: my custom security check
|
||||
author: you
|
||||
severity: medium
|
||||
description: checks for something specific
|
||||
tags: [custom, recon]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/admin"
|
||||
- "{{BaseURL}}/login"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "admin panel"
|
||||
- "login"
|
||||
condition: or
|
||||
```
|
||||
|
||||
see [docs/modules.md](docs/modules.md) for the full module format.
|
||||
| module | description |
|
||||
|--------|-------------|
|
||||
| `dirlist` | directory and file fuzzing |
|
||||
| `dnslist` | subdomain enumeration |
|
||||
| `ports` | port and service scanning |
|
||||
| `nuclei` | vulnerability scanning with nuclei templates |
|
||||
| `dork` | automated google dorking |
|
||||
| `js` | javascript framework detection (next.js, supabase) |
|
||||
| `c3` | cloud storage misconfiguration scanning |
|
||||
| `headers` | http header analysis |
|
||||
| `takeover` | subdomain takeover detection |
|
||||
| `cms` | cms detection |
|
||||
| `whois` | whois lookups |
|
||||
| `git` | exposed git repository detection |
|
||||
|
||||
## contribute
|
||||
|
||||
@@ -365,18 +93,12 @@ contributions welcome. see [contributing.md](CONTRIBUTING.md) for guidelines.
|
||||
gofmt -w .
|
||||
|
||||
# lint
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
|
||||
golangci-lint run
|
||||
|
||||
# test
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## community
|
||||
|
||||
join our discord for support, feature discussions, and pentesting tips:
|
||||
|
||||
[](https://discord.gg/sifcli)
|
||||
|
||||
## contributors
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
@@ -385,16 +107,12 @@ 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/vmfunc/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="mel"/><br /><sub><b>mel</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="#test-vmfunc" title="Tests">⚠️</a> <a href="#business-vmfunc" title="Business development">💼</a> <a href="#code-vmfunc" title="Code">💻</a> <a href="#design-vmfunc" title="Design">🎨</a> <a href="#financial-vmfunc" title="Financial">💵</a> <a href="#ideas-vmfunc" title="Ideas, Planning, & Feedback">🤔</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/vmfunc/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="#code-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/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>
|
||||
<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="#test-xyzeva" title="Tests">⚠️</a> <a href="#code-xyzeva" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
# security policy
|
||||
|
||||
## reporting a vulnerability
|
||||
|
||||
if you find a security issue in sif, email celeste@linux.com directly.
|
||||
don't open a public issue.
|
||||
|
||||
expect a response within 48 hours. if it's confirmed, i'll push a fix
|
||||
and credit you in the release notes (unless you'd rather stay anonymous).
|
||||
|
||||
## scope
|
||||
|
||||
sif is a pentesting tool — "it can scan things" is not a vulnerability.
|
||||
actual bugs: command injection in user input handling, path traversal in
|
||||
template extraction, credential leaks, that kind of thing.
|
||||
+2
-34
@@ -4,7 +4,7 @@
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
@@ -13,38 +13,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dropalldatabases/sif"
|
||||
"github.com/dropalldatabases/sif/internal/config"
|
||||
"github.com/dropalldatabases/sif/internal/patchnotes"
|
||||
ver "github.com/dropalldatabases/sif/internal/version"
|
||||
|
||||
// Register framework detectors
|
||||
_ "github.com/dropalldatabases/sif/internal/scan/frameworks/detectors"
|
||||
"github.com/dropalldatabases/sif/pkg/config"
|
||||
)
|
||||
|
||||
// version is stamped at release time via -ldflags "-X main.version=...";
|
||||
// ver.Resolve falls back to the build info or "dev" for other builds.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
version = ver.Resolve(version)
|
||||
sif.Version = version
|
||||
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "patchnote", "patchnotes", "-pn", "--patchnotes":
|
||||
patchnotes.Print("")
|
||||
return
|
||||
case "version", "-version", "--version":
|
||||
fmt.Printf("sif %s\n", version)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
settings := config.Parse()
|
||||
|
||||
app, err := sif.New(settings)
|
||||
@@ -52,12 +26,6 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// patchnotes print to stdout; skip them in api/silent mode so the only thing
|
||||
// on stdout is the machine-readable result stream.
|
||||
if !settings.ApiMode && !settings.Silent {
|
||||
patchnotes.ShowOnce(version)
|
||||
}
|
||||
|
||||
err = app.Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# sif documentation
|
||||
|
||||
welcome to the sif documentation. sif is a modular pentesting toolkit designed to be fast, concurrent, and extensible.
|
||||
|
||||
## table of contents
|
||||
|
||||
### getting started
|
||||
|
||||
- [installation](installation.md) - how to install sif
|
||||
- [quickstart](quickstart.md) - get up and running in minutes
|
||||
- [usage](usage.md) - command line options and examples
|
||||
|
||||
### features
|
||||
|
||||
- [scans](scans.md) - built-in security scans
|
||||
- [modules](modules.md) - yaml module system and custom modules
|
||||
|
||||
### reference
|
||||
|
||||
- [configuration](configuration.md) - runtime configuration options
|
||||
- [api mode](api-mode.md) - json output for automation
|
||||
|
||||
### contributing
|
||||
|
||||
- [development](development.md) - setting up a dev environment
|
||||
- [writing modules](modules.md#writing-modules) - create your own modules
|
||||
|
||||
---
|
||||
|
||||
## quick links
|
||||
|
||||
```bash
|
||||
# install
|
||||
git clone https://github.com/dropalldatabases/sif.git && cd sif && make
|
||||
|
||||
# basic scan
|
||||
./sif -u https://example.com
|
||||
|
||||
# list modules
|
||||
./sif -lm
|
||||
|
||||
# run all modules
|
||||
./sif -u https://example.com -am
|
||||
|
||||
# help
|
||||
./sif -h
|
||||
```
|
||||
|
||||
## support
|
||||
|
||||
- [github issues](https://github.com/vmfunc/sif/issues) - bug reports and feature requests
|
||||
- [discord](https://discord.gg/sifcli) - community chat
|
||||
@@ -1,160 +0,0 @@
|
||||
# api mode
|
||||
|
||||
use sif's json output for automation and integration.
|
||||
|
||||
## enabling api mode
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api
|
||||
```
|
||||
|
||||
## output format
|
||||
|
||||
api mode outputs json to stdout:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"results": [
|
||||
{
|
||||
"id": "module-id",
|
||||
"data": {
|
||||
"module_id": "module-id",
|
||||
"target": "https://example.com",
|
||||
"findings": [
|
||||
{
|
||||
"url": "https://example.com/.git/HEAD",
|
||||
"severity": "high",
|
||||
"evidence": "ref: refs/heads/main",
|
||||
"extracted": {
|
||||
"branch": "main"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## fields
|
||||
|
||||
### url
|
||||
|
||||
the target url that was scanned.
|
||||
|
||||
### results
|
||||
|
||||
array of module results.
|
||||
|
||||
### results[].id
|
||||
|
||||
module identifier.
|
||||
|
||||
### results[].data.findings
|
||||
|
||||
array of security findings from the module.
|
||||
|
||||
### findings[].url
|
||||
|
||||
the specific url where the finding was detected.
|
||||
|
||||
### findings[].severity
|
||||
|
||||
severity level: `info`, `low`, `medium`, `high`, `critical`
|
||||
|
||||
### findings[].evidence
|
||||
|
||||
evidence that triggered the finding (matched content, etc).
|
||||
|
||||
### findings[].extracted
|
||||
|
||||
extracted data from the response (versions, keys, etc).
|
||||
|
||||
## examples
|
||||
|
||||
### save to file
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api -am > results.json
|
||||
```
|
||||
|
||||
### pipe to jq
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api -am | jq '.results[].data.findings[]'
|
||||
```
|
||||
|
||||
### filter high severity
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api -am | jq '.results[].data.findings[] | select(.severity == "high")'
|
||||
```
|
||||
|
||||
### extract urls
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api -am | jq -r '.results[].data.findings[].url'
|
||||
```
|
||||
|
||||
## ci/cd integration
|
||||
|
||||
### github actions
|
||||
|
||||
```yaml
|
||||
- name: run sif scan
|
||||
run: |
|
||||
./sif -u ${{ env.TARGET_URL }} -api -am > sif-results.json
|
||||
|
||||
- name: check for high severity findings
|
||||
run: |
|
||||
HIGH_COUNT=$(jq '[.results[].data.findings[] | select(.severity == "high" or .severity == "critical")] | length' sif-results.json)
|
||||
if [ "$HIGH_COUNT" -gt 0 ]; then
|
||||
echo "Found $HIGH_COUNT high/critical severity findings"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### gitlab ci
|
||||
|
||||
```yaml
|
||||
security_scan:
|
||||
script:
|
||||
- ./sif -u $TARGET_URL -api -am > sif-results.json
|
||||
- |
|
||||
if jq -e '.results[].data.findings[] | select(.severity == "critical")' sif-results.json > /dev/null; then
|
||||
echo "Critical findings detected"
|
||||
exit 1
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- sif-results.json
|
||||
```
|
||||
|
||||
## multiple targets
|
||||
|
||||
when scanning multiple urls, each target outputs a separate json object:
|
||||
|
||||
```bash
|
||||
./sif -u https://site1.com,https://site2.com -api
|
||||
```
|
||||
|
||||
outputs:
|
||||
|
||||
```json
|
||||
{"url":"https://site1.com","results":[...]}
|
||||
{"url":"https://site2.com","results":[...]}
|
||||
```
|
||||
|
||||
use `jq -s` to combine into an array:
|
||||
|
||||
```bash
|
||||
./sif -u https://site1.com,https://site2.com -api | jq -s '.'
|
||||
```
|
||||
|
||||
## notes
|
||||
|
||||
- api mode suppresses banner and interactive output
|
||||
- all output goes to stdout
|
||||
- errors and warnings still go to stderr
|
||||
- combine with `-l` flag to also save detailed logs
|
||||
@@ -1,200 +0,0 @@
|
||||
# configuration
|
||||
|
||||
runtime configuration options for sif.
|
||||
|
||||
## environment variables
|
||||
|
||||
### SHODAN_API_KEY
|
||||
|
||||
required for shodan lookups.
|
||||
|
||||
```bash
|
||||
export SHODAN_API_KEY=your-api-key-here
|
||||
./sif -u https://example.com -shodan
|
||||
```
|
||||
|
||||
## command line options
|
||||
|
||||
### timeout
|
||||
|
||||
default request timeout is 10 seconds.
|
||||
|
||||
```bash
|
||||
# increase for slow targets
|
||||
./sif -u https://example.com -t 30s
|
||||
|
||||
# decrease for fast scans
|
||||
./sif -u https://example.com -t 5s
|
||||
```
|
||||
|
||||
### threads
|
||||
|
||||
default is 10 concurrent threads.
|
||||
|
||||
```bash
|
||||
# more threads for faster scanning
|
||||
./sif -u https://example.com --threads 50
|
||||
|
||||
# fewer threads to reduce load
|
||||
./sif -u https://example.com --threads 5
|
||||
```
|
||||
|
||||
### logging
|
||||
|
||||
save output to files:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -l ./logs
|
||||
```
|
||||
|
||||
creates timestamped log files in the specified directory.
|
||||
|
||||
### debug mode
|
||||
|
||||
enable verbose logging:
|
||||
|
||||
```bash
|
||||
./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:
|
||||
|
||||
- linux/macos: `~/.config/sif/modules/`
|
||||
- windows: `%LOCALAPPDATA%\sif\modules\`
|
||||
|
||||
### directory structure
|
||||
|
||||
```
|
||||
~/.config/sif/
|
||||
├── modules/
|
||||
│ ├── http/
|
||||
│ │ └── my-sqli-check.yaml
|
||||
│ ├── recon/
|
||||
│ │ └── custom-paths.yaml
|
||||
│ └── my-module.yaml
|
||||
```
|
||||
|
||||
modules can be organized in subdirectories or placed directly in the modules folder.
|
||||
|
||||
### overriding built-in modules
|
||||
|
||||
user modules with the same id as built-in modules will override them:
|
||||
|
||||
```yaml
|
||||
# ~/.config/sif/modules/sqli-error-based.yaml
|
||||
# this overrides the built-in sqli-error-based module
|
||||
|
||||
id: sqli-error-based
|
||||
info:
|
||||
name: my custom sqli check
|
||||
# ...
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com \
|
||||
--threads 50 \
|
||||
-t 5s \
|
||||
-dirlist small \
|
||||
-dnslist small
|
||||
```
|
||||
|
||||
### thorough scans
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com \
|
||||
--threads 10 \
|
||||
-t 30s \
|
||||
-dirlist large \
|
||||
-dnslist large \
|
||||
-ports full
|
||||
```
|
||||
|
||||
### low-impact scans
|
||||
|
||||
reduce load on target:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com \
|
||||
--threads 2 \
|
||||
-t 10s
|
||||
```
|
||||
|
||||
## output formats
|
||||
|
||||
### console (default)
|
||||
|
||||
human-readable output with colors and formatting.
|
||||
|
||||
### json (api mode)
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api
|
||||
```
|
||||
|
||||
returns structured json:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"results": [
|
||||
{
|
||||
"id": "sqli-error-based",
|
||||
"data": {
|
||||
"findings": [...]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### log files
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -l ./logs
|
||||
```
|
||||
|
||||
creates separate log files for each scan type.
|
||||
@@ -1,196 +0,0 @@
|
||||
# development
|
||||
|
||||
setting up a development environment for sif.
|
||||
|
||||
## prerequisites
|
||||
|
||||
- go 1.25 or later
|
||||
- git
|
||||
- make
|
||||
|
||||
## clone and build
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dropalldatabases/sif.git
|
||||
cd sif
|
||||
make
|
||||
```
|
||||
|
||||
## project structure
|
||||
|
||||
```
|
||||
sif/
|
||||
├── cmd/sif/ # entry point
|
||||
│ └── main.go
|
||||
├── sif.go # main application logic
|
||||
├── internal/ # private packages
|
||||
│ ├── config/ # configuration parsing
|
||||
│ ├── logger/ # logging utilities
|
||||
│ ├── modules/ # module system
|
||||
│ ├── scan/ # built-in scans
|
||||
│ └── styles/ # terminal styling
|
||||
├── modules/ # built-in yaml modules
|
||||
│ ├── http/ # http-based modules
|
||||
│ ├── info/ # information gathering
|
||||
│ └── recon/ # reconnaissance modules
|
||||
├── docs/ # documentation
|
||||
└── assets/ # images, etc
|
||||
```
|
||||
|
||||
## running locally
|
||||
|
||||
```bash
|
||||
# build
|
||||
make
|
||||
|
||||
# run
|
||||
./sif -u https://example.com
|
||||
|
||||
# run with debug
|
||||
./sif -u https://example.com -d
|
||||
```
|
||||
|
||||
## code quality
|
||||
|
||||
### format
|
||||
|
||||
```bash
|
||||
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
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
|
||||
```
|
||||
|
||||
### test
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### race detection
|
||||
|
||||
```bash
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
## adding a new scan
|
||||
|
||||
1. create a new file in `internal/scan/`
|
||||
2. implement the scan function
|
||||
3. add flag to `internal/config/config.go`
|
||||
4. integrate in `sif.go`
|
||||
|
||||
see existing scans for examples.
|
||||
|
||||
## adding a new module
|
||||
|
||||
create a yaml file in `modules/`:
|
||||
|
||||
```yaml
|
||||
id: my-new-module
|
||||
info:
|
||||
name: my new security check
|
||||
author: your-name
|
||||
severity: medium
|
||||
description: what this checks for
|
||||
tags: [custom, security]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/path"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
```
|
||||
|
||||
see [modules.md](modules.md) for the full format.
|
||||
|
||||
## module system internals
|
||||
|
||||
the module system is in `internal/modules/`:
|
||||
|
||||
- `module.go` - core interface and types
|
||||
- `registry.go` - module registration
|
||||
- `loader.go` - discovery and loading
|
||||
- `yaml.go` - yaml parsing
|
||||
- `executor.go` - http execution
|
||||
|
||||
### adding a new module type
|
||||
|
||||
1. add type constant to `module.go`
|
||||
2. implement executor in new file
|
||||
3. update loader to handle new extension/type
|
||||
|
||||
## testing
|
||||
|
||||
### unit tests
|
||||
|
||||
```bash
|
||||
go test ./internal/...
|
||||
```
|
||||
|
||||
### integration tests
|
||||
|
||||
run the scanners against a local testbed that plants the artifacts each one
|
||||
should find (network-free, behind a build tag):
|
||||
|
||||
```bash
|
||||
go test -tags=integration ./internal/scan/...
|
||||
```
|
||||
|
||||
### functional test
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -am
|
||||
```
|
||||
|
||||
### test modules
|
||||
|
||||
```bash
|
||||
./sif -lm # list modules
|
||||
./sif -u https://example.com -m my-module -d # test specific module
|
||||
```
|
||||
|
||||
## pull requests
|
||||
|
||||
1. fork the repository
|
||||
2. create a feature branch
|
||||
3. make changes
|
||||
4. run `gofmt -w .` and `golangci-lint run` (pinned version, see [lint](#lint))
|
||||
5. submit pr
|
||||
|
||||
### commit messages
|
||||
|
||||
use lowercase, present tense:
|
||||
|
||||
```
|
||||
add sql injection module
|
||||
fix timeout handling in http executor
|
||||
update readme with new flags
|
||||
```
|
||||
|
||||
## release process
|
||||
|
||||
releases are automated via github actions on push to main.
|
||||
|
||||
binaries are built for:
|
||||
- linux (amd64, 386, arm64)
|
||||
- macos (amd64, arm64)
|
||||
- windows (amd64, 386)
|
||||
|
||||
## resources
|
||||
|
||||
- [go documentation](https://golang.org/doc/)
|
||||
- [goflags](https://github.com/projectdiscovery/goflags) - cli parsing
|
||||
- [nuclei templates](https://github.com/projectdiscovery/nuclei-templates) - module format inspiration
|
||||
@@ -1,93 +0,0 @@
|
||||
# installation
|
||||
|
||||
## from releases
|
||||
|
||||
download the latest binary for your platform from [releases](https://github.com/vmfunc/sif/releases).
|
||||
|
||||
### linux
|
||||
|
||||
```bash
|
||||
# download
|
||||
curl -LO https://github.com/vmfunc/sif/releases/latest/download/sif-linux-amd64
|
||||
|
||||
# make executable
|
||||
chmod +x sif-linux-amd64
|
||||
|
||||
# move to path (optional)
|
||||
sudo mv sif-linux-amd64 /usr/local/bin/sif
|
||||
```
|
||||
|
||||
### macos
|
||||
|
||||
```bash
|
||||
# intel
|
||||
curl -LO https://github.com/vmfunc/sif/releases/latest/download/sif-macos-amd64
|
||||
|
||||
# apple silicon
|
||||
curl -LO https://github.com/vmfunc/sif/releases/latest/download/sif-macos-arm64
|
||||
|
||||
chmod +x sif-macos-*
|
||||
sudo mv sif-macos-* /usr/local/bin/sif
|
||||
```
|
||||
|
||||
### windows
|
||||
|
||||
download `sif-windows-amd64.exe` from releases and add to your PATH.
|
||||
|
||||
## from source
|
||||
|
||||
requires go 1.25+
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dropalldatabases/sif.git
|
||||
cd sif
|
||||
make
|
||||
```
|
||||
|
||||
the binary will be created in the current directory.
|
||||
|
||||
### install to system
|
||||
|
||||
```bash
|
||||
sudo make install
|
||||
```
|
||||
|
||||
this installs to `/usr/local/bin/sif`.
|
||||
|
||||
### uninstall
|
||||
|
||||
```bash
|
||||
sudo make uninstall
|
||||
```
|
||||
|
||||
## verify installation
|
||||
|
||||
```bash
|
||||
./sif -h
|
||||
```
|
||||
|
||||
you should see the help output with available flags.
|
||||
|
||||
## updating
|
||||
|
||||
### from releases
|
||||
|
||||
download the new binary and replace the old one.
|
||||
|
||||
### from source
|
||||
|
||||
```bash
|
||||
cd sif
|
||||
git pull
|
||||
make clean
|
||||
make
|
||||
```
|
||||
|
||||
## modules directory
|
||||
|
||||
sif looks for modules in these locations:
|
||||
|
||||
- **built-in**: `modules/` directory next to the sif binary
|
||||
- **user modules**: `~/.config/sif/modules/` (linux/macos) or `%LOCALAPPDATA%\sif\modules\` (windows)
|
||||
|
||||
user modules override built-in modules with the same id.
|
||||
-480
@@ -1,480 +0,0 @@
|
||||
# writing sif modules
|
||||
|
||||
sif modules are yaml files that define security checks. they're similar to nuclei templates but designed specifically for sif.
|
||||
|
||||
## module locations
|
||||
|
||||
- **built-in**: `modules/` directory in the sif installation
|
||||
- **user-defined**: `~/.config/sif/modules/` (linux/macos) or `%LOCALAPPDATA%\sif\modules\` (windows)
|
||||
|
||||
user modules can override built-in modules with the same id.
|
||||
|
||||
## basic structure
|
||||
|
||||
```yaml
|
||||
id: unique-module-id
|
||||
info:
|
||||
name: human readable name
|
||||
author: your-name
|
||||
severity: low|medium|high|critical|info
|
||||
description: what this module checks for
|
||||
tags: [tag1, tag2, tag3]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/path"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
```
|
||||
|
||||
## fields
|
||||
|
||||
### id (required)
|
||||
|
||||
unique identifier for the module. use lowercase with hyphens.
|
||||
|
||||
```yaml
|
||||
id: sqli-error-based
|
||||
```
|
||||
|
||||
### info (required)
|
||||
|
||||
metadata about the module.
|
||||
|
||||
```yaml
|
||||
info:
|
||||
name: SQL Injection Detection
|
||||
author: sif
|
||||
severity: high
|
||||
description: detects sql injection via error messages
|
||||
tags: [sqli, injection, owasp-top10]
|
||||
```
|
||||
|
||||
**severity levels:**
|
||||
- `info` - informational finding
|
||||
- `low` - minor issue
|
||||
- `medium` - moderate security concern
|
||||
- `high` - serious vulnerability
|
||||
- `critical` - critical security flaw
|
||||
|
||||
### type (required)
|
||||
|
||||
module type. currently only `http` is supported.
|
||||
|
||||
```yaml
|
||||
type: http
|
||||
```
|
||||
|
||||
### http
|
||||
|
||||
http request configuration.
|
||||
|
||||
#### method
|
||||
|
||||
http method to use.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
method: GET
|
||||
```
|
||||
|
||||
supported: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`
|
||||
|
||||
#### paths
|
||||
|
||||
urls to check. use `{{BaseURL}}` as placeholder for the target.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
paths:
|
||||
- "{{BaseURL}}/.git/HEAD"
|
||||
- "{{BaseURL}}/.git/config"
|
||||
- "{{BaseURL}}/admin"
|
||||
```
|
||||
|
||||
#### payloads
|
||||
|
||||
values to inject into paths. use `{{payload}}` as placeholder.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
paths:
|
||||
- "{{BaseURL}}/?id={{payload}}"
|
||||
|
||||
payloads:
|
||||
- "'"
|
||||
- "1' OR '1'='1"
|
||||
- "1; DROP TABLE--"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
headers:
|
||||
User-Agent: "Mozilla/5.0"
|
||||
X-Custom-Header: "value"
|
||||
```
|
||||
|
||||
#### body
|
||||
|
||||
request body for POST/PUT requests.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
method: POST
|
||||
body: '{"username": "admin", "password": "{{payload}}"}'
|
||||
```
|
||||
|
||||
#### threads
|
||||
|
||||
concurrent requests (default: 10).
|
||||
|
||||
```yaml
|
||||
http:
|
||||
threads: 5
|
||||
```
|
||||
|
||||
## matchers
|
||||
|
||||
matchers determine if a response indicates a finding.
|
||||
|
||||
### status matcher
|
||||
|
||||
match http status codes.
|
||||
|
||||
```yaml
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
- 301
|
||||
- 302
|
||||
```
|
||||
|
||||
### word matcher
|
||||
|
||||
match words in response.
|
||||
|
||||
```yaml
|
||||
matchers:
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "admin"
|
||||
- "login"
|
||||
condition: or
|
||||
```
|
||||
|
||||
**parts:**
|
||||
- `body` - response body
|
||||
- `header` - response headers
|
||||
|
||||
**conditions:**
|
||||
- `or` - match any word (default)
|
||||
- `and` - match all words
|
||||
|
||||
### regex matcher
|
||||
|
||||
match regex patterns.
|
||||
|
||||
```yaml
|
||||
matchers:
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "SQL syntax.*MySQL"
|
||||
- "ORA-[0-9]+"
|
||||
- "PostgreSQL.*ERROR"
|
||||
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.
|
||||
|
||||
```yaml
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "ref: refs/"
|
||||
condition: or
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### regex extractor
|
||||
|
||||
```yaml
|
||||
extractors:
|
||||
- type: regex
|
||||
name: version
|
||||
part: body
|
||||
regex:
|
||||
- "version[\"']?\\s*[:=]\\s*[\"']?([0-9.]+)"
|
||||
group: 1
|
||||
```
|
||||
|
||||
**group**: capture group to extract (0 = full match, 1+ = groups)
|
||||
|
||||
### kv extractor
|
||||
|
||||
record every response header as a key-value pair, namespaced by `name`.
|
||||
|
||||
```yaml
|
||||
extractors:
|
||||
- type: kv
|
||||
name: headers
|
||||
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
|
||||
|
||||
```yaml
|
||||
id: git-exposed
|
||||
info:
|
||||
name: exposed git repository
|
||||
author: sif
|
||||
severity: high
|
||||
description: detects exposed .git directories
|
||||
tags: [git, exposure, source-code]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/.git/HEAD"
|
||||
- "{{BaseURL}}/.git/config"
|
||||
|
||||
matchers:
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "ref: refs/"
|
||||
- "[core]"
|
||||
condition: or
|
||||
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: branch
|
||||
part: body
|
||||
regex:
|
||||
- "ref: refs/heads/(.+)"
|
||||
group: 1
|
||||
```
|
||||
|
||||
### sql injection detection
|
||||
|
||||
```yaml
|
||||
id: sqli-error-based
|
||||
info:
|
||||
name: sql injection (error-based)
|
||||
author: sif
|
||||
severity: high
|
||||
description: detects sql injection via database errors
|
||||
tags: [sqli, injection, database]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/?id={{payload}}"
|
||||
- "{{BaseURL}}/search?q={{payload}}"
|
||||
|
||||
payloads:
|
||||
- "'"
|
||||
- "1' OR '1'='1"
|
||||
- "1; SELECT * FROM--"
|
||||
|
||||
threads: 10
|
||||
|
||||
matchers:
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "SQL syntax.*MySQL"
|
||||
- "ORA-[0-9]+"
|
||||
- "PostgreSQL.*ERROR"
|
||||
- "Microsoft SQL Server"
|
||||
condition: or
|
||||
```
|
||||
|
||||
### security headers check
|
||||
|
||||
```yaml
|
||||
id: security-headers
|
||||
info:
|
||||
name: security headers analysis
|
||||
author: sif
|
||||
severity: info
|
||||
description: checks for missing security headers
|
||||
tags: [headers, security, info]
|
||||
|
||||
type: http
|
||||
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
|
||||
extractors:
|
||||
- type: kv
|
||||
name: headers
|
||||
part: header
|
||||
```
|
||||
|
||||
## tips
|
||||
|
||||
1. **use specific paths** - don't just check `/`, be specific about what you're looking for
|
||||
|
||||
2. **combine matchers** - use status + content matchers together to reduce false positives
|
||||
|
||||
3. **limit payloads** - too many payloads slow down scans, pick the most effective ones
|
||||
|
||||
4. **tag properly** - use consistent tags so modules can be filtered with `-mt`
|
||||
|
||||
5. **test locally** - run your module against a test target before sharing
|
||||
|
||||
## running modules
|
||||
|
||||
```bash
|
||||
# list all modules
|
||||
./sif -lm
|
||||
|
||||
# run specific module
|
||||
./sif -u https://example.com -m git-exposed
|
||||
|
||||
# run multiple modules
|
||||
./sif -u https://example.com -m git-exposed,sqli-error-based
|
||||
|
||||
# run by tag
|
||||
./sif -u https://example.com -mt owasp-top10
|
||||
|
||||
# run all modules
|
||||
./sif -u https://example.com -am
|
||||
```
|
||||
@@ -1,102 +0,0 @@
|
||||
# quickstart
|
||||
|
||||
get up and running with sif in minutes.
|
||||
|
||||
## basic scan
|
||||
|
||||
run a basic scan against a target:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com
|
||||
```
|
||||
|
||||
this performs a base scan checking robots.txt, common files, and basic reconnaissance.
|
||||
|
||||
## add more scans
|
||||
|
||||
enable additional scan types with flags:
|
||||
|
||||
```bash
|
||||
# directory fuzzing
|
||||
./sif -u https://example.com -dirlist medium
|
||||
|
||||
# subdomain enumeration
|
||||
./sif -u https://example.com -dnslist small
|
||||
|
||||
# port scanning
|
||||
./sif -u https://example.com -ports common
|
||||
|
||||
# framework detection
|
||||
./sif -u https://example.com -framework
|
||||
```
|
||||
|
||||
## run modules
|
||||
|
||||
sif has a modular architecture with yaml-based security checks:
|
||||
|
||||
```bash
|
||||
# list available modules
|
||||
./sif -lm
|
||||
|
||||
# run all modules
|
||||
./sif -u https://example.com -am
|
||||
|
||||
# run specific modules
|
||||
./sif -u https://example.com -m sqli-error-based,xss-reflected
|
||||
|
||||
# run by tag
|
||||
./sif -u https://example.com -mt owasp-top10
|
||||
```
|
||||
|
||||
## multiple targets
|
||||
|
||||
scan multiple urls:
|
||||
|
||||
```bash
|
||||
./sif -u https://site1.com,https://site2.com
|
||||
```
|
||||
|
||||
or from a file:
|
||||
|
||||
```bash
|
||||
./sif -f targets.txt
|
||||
```
|
||||
|
||||
## save output
|
||||
|
||||
save results to a log directory:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -l ./logs
|
||||
```
|
||||
|
||||
## json output
|
||||
|
||||
for automation, use api mode:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api
|
||||
```
|
||||
|
||||
## full scan example
|
||||
|
||||
run everything:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com \
|
||||
-dirlist medium \
|
||||
-dnslist small \
|
||||
-ports common \
|
||||
-framework \
|
||||
-js \
|
||||
-headers \
|
||||
-git \
|
||||
-am \
|
||||
-l ./logs
|
||||
```
|
||||
|
||||
## next steps
|
||||
|
||||
- [usage](usage.md) - all command line options
|
||||
- [scans](scans.md) - detailed scan descriptions
|
||||
- [modules](modules.md) - write custom modules
|
||||
-239
@@ -1,239 +0,0 @@
|
||||
# scans
|
||||
|
||||
detailed information about sif's built-in security scans.
|
||||
|
||||
## base scan
|
||||
|
||||
runs automatically unless `-noscan` is specified.
|
||||
|
||||
checks:
|
||||
- robots.txt parsing
|
||||
- common files (sitemap.xml, security.txt, etc)
|
||||
- basic reconnaissance
|
||||
|
||||
## directory fuzzing (-dirlist)
|
||||
|
||||
brute-forces directories and files using wordlists.
|
||||
|
||||
### sizes
|
||||
|
||||
| size | entries | use case |
|
||||
|------|---------|----------|
|
||||
| small | ~1k | quick scan, low noise |
|
||||
| medium | ~10k | balanced coverage |
|
||||
| large | ~100k | thorough, takes longer |
|
||||
|
||||
### what it finds
|
||||
|
||||
- hidden directories (/admin, /backup, /config)
|
||||
- backup files (.bak, .old, .zip)
|
||||
- configuration files
|
||||
- development artifacts
|
||||
|
||||
## subdomain enumeration (-dnslist)
|
||||
|
||||
discovers subdomains via dns brute-forcing.
|
||||
|
||||
### sizes
|
||||
|
||||
| size | entries | use case |
|
||||
|------|---------|----------|
|
||||
| small | ~1k | quick discovery |
|
||||
| medium | ~10k | common subdomains |
|
||||
| large | ~100k | comprehensive |
|
||||
|
||||
### what it finds
|
||||
|
||||
- dev/staging environments
|
||||
- internal services
|
||||
- forgotten subdomains
|
||||
- api endpoints
|
||||
|
||||
## port scanning (-ports)
|
||||
|
||||
scans for open ports and identifies services.
|
||||
|
||||
### scopes
|
||||
|
||||
| scope | ports | description |
|
||||
|-------|-------|-------------|
|
||||
| common | top 1000 | most common services |
|
||||
| full | 1-65535 | all ports, slow |
|
||||
|
||||
### what it finds
|
||||
|
||||
- web servers (80, 443, 8080)
|
||||
- databases (3306, 5432, 27017)
|
||||
- admin interfaces (8443, 9090)
|
||||
- development servers
|
||||
|
||||
## framework detection (-framework)
|
||||
|
||||
identifies web frameworks and their versions.
|
||||
|
||||
### detects
|
||||
|
||||
- react, vue, angular, next.js
|
||||
- django, flask, rails
|
||||
- laravel, symfony, express
|
||||
- wordpress, drupal, joomla
|
||||
|
||||
### features
|
||||
|
||||
- version detection
|
||||
- cve lookup for known vulnerabilities
|
||||
- confidence scoring
|
||||
|
||||
## javascript analysis (-js)
|
||||
|
||||
analyzes javascript files for security issues.
|
||||
|
||||
### finds
|
||||
|
||||
- api endpoints and keys
|
||||
- hardcoded credentials
|
||||
- internal urls
|
||||
- framework configurations
|
||||
- source maps
|
||||
|
||||
## http headers (-headers)
|
||||
|
||||
dumps the target's response headers.
|
||||
|
||||
## security headers (-sh)
|
||||
|
||||
flags missing or weak security headers and headers that leak server internals.
|
||||
|
||||
### checks
|
||||
|
||||
- strict-transport-security (https only)
|
||||
- content-security-policy
|
||||
- x-frame-options
|
||||
- x-content-type-options (expects nosniff)
|
||||
- referrer-policy
|
||||
- permissions-policy
|
||||
- cross-origin-opener-policy
|
||||
|
||||
### flagged as disclosure
|
||||
|
||||
- server
|
||||
- x-powered-by
|
||||
- x-aspnet-version / x-aspnetmvc-version
|
||||
|
||||
## cms detection (-cms)
|
||||
|
||||
identifies content management systems.
|
||||
|
||||
### detects
|
||||
|
||||
- wordpress (with version)
|
||||
- drupal
|
||||
- joomla
|
||||
- magento
|
||||
- shopify
|
||||
- ghost
|
||||
|
||||
## git repository (-git)
|
||||
|
||||
checks for exposed git repositories.
|
||||
|
||||
### finds
|
||||
|
||||
- .git/HEAD
|
||||
- .git/config
|
||||
- .git/index
|
||||
- source code exposure risk
|
||||
|
||||
## cloud storage (-c3)
|
||||
|
||||
checks for cloud storage misconfigurations.
|
||||
|
||||
### checks
|
||||
|
||||
- s3 bucket access
|
||||
- azure blob storage
|
||||
- gcp storage buckets
|
||||
- open bucket policies
|
||||
|
||||
## subdomain takeover (-st)
|
||||
|
||||
detects subdomain takeover vulnerabilities.
|
||||
|
||||
requires `-dnslist` to enumerate subdomains first.
|
||||
|
||||
### checks
|
||||
|
||||
- dangling cname records
|
||||
- unclaimed cloud services
|
||||
- expired third-party services
|
||||
|
||||
## shodan lookup (-shodan)
|
||||
|
||||
queries shodan for host intelligence.
|
||||
|
||||
requires `SHODAN_API_KEY` environment variable.
|
||||
|
||||
### returns
|
||||
|
||||
- open ports
|
||||
- services and versions
|
||||
- known vulnerabilities
|
||||
- ssl/tls info
|
||||
- organization data
|
||||
|
||||
## sql reconnaissance (-sql)
|
||||
|
||||
detects sql-related exposures.
|
||||
|
||||
### finds
|
||||
|
||||
- admin panels (/phpmyadmin, /adminer)
|
||||
- database error messages
|
||||
- sql injection indicators
|
||||
|
||||
## lfi scanning (-lfi)
|
||||
|
||||
checks for local file inclusion vulnerabilities.
|
||||
|
||||
### tests
|
||||
|
||||
- path traversal (../)
|
||||
- null byte injection
|
||||
- common lfi payloads
|
||||
- sensitive file disclosure
|
||||
|
||||
## whois lookup (-whois)
|
||||
|
||||
performs whois lookups on target domains.
|
||||
|
||||
### returns
|
||||
|
||||
- registrar info
|
||||
- creation/expiration dates
|
||||
- nameservers
|
||||
- registrant info (if available)
|
||||
|
||||
## google dorking (-dork)
|
||||
|
||||
automated google dorking for target.
|
||||
|
||||
### searches
|
||||
|
||||
- indexed sensitive files
|
||||
- exposed admin panels
|
||||
- configuration files
|
||||
- backup files
|
||||
- error pages
|
||||
|
||||
## nuclei scanning (-nuclei)
|
||||
|
||||
runs nuclei vulnerability templates.
|
||||
|
||||
requires nuclei to be installed.
|
||||
|
||||
### templates
|
||||
|
||||
- cve detection
|
||||
- misconfigurations
|
||||
- exposures
|
||||
- default credentials
|
||||
-620
@@ -1,620 +0,0 @@
|
||||
# usage
|
||||
|
||||
complete guide to sif command line options.
|
||||
|
||||
## target options
|
||||
|
||||
### -u, --urls
|
||||
|
||||
specify target urls (comma-separated):
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com
|
||||
./sif -u https://site1.com,https://site2.com
|
||||
```
|
||||
|
||||
### -f, --file
|
||||
|
||||
read targets from a file (one url per line):
|
||||
|
||||
```bash
|
||||
./sif -f targets.txt
|
||||
```
|
||||
|
||||
### stdin (pipe mode)
|
||||
|
||||
when stdin is a pipe, sif reads one target per line from it, alongside any `-u`/`-f` targets. this lets sif slot into a unix pipeline:
|
||||
|
||||
```bash
|
||||
subfinder -d example.com | sif -silent -probe | notify
|
||||
```
|
||||
|
||||
### naked hosts
|
||||
|
||||
targets without a scheme default to `https://`; an explicit `http://`/`https://` is kept as given. any other scheme (`ftp://`, `file://`, ...) is rejected:
|
||||
|
||||
```bash
|
||||
./sif -u example.com # scanned as https://example.com
|
||||
echo example.com | sif -probe # same, over stdin
|
||||
```
|
||||
|
||||
## scan options
|
||||
|
||||
### directory fuzzing
|
||||
|
||||
`-dirlist <size>` - fuzz for directories and files
|
||||
|
||||
sizes: `small`, `medium`, `large`
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -dirlist medium
|
||||
```
|
||||
|
||||
#### response filters
|
||||
|
||||
modern apps serve a catch-all 200 for unknown routes, so a naive scan reports
|
||||
every path. these ffuf-style filters cut the noise (a filter always wins over a
|
||||
match):
|
||||
|
||||
- `-mc <codes>` - match only these status codes (comma list, e.g. `200,301`)
|
||||
- `-fc <codes>` - filter out these status codes
|
||||
- `-fs <sizes>` - filter out responses of these body sizes
|
||||
- `-fw <counts>` - filter out responses with these word counts
|
||||
- `-fr <regex>` - filter out responses whose body matches this regex
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -dirlist medium -mc 200,301 -fs 1234
|
||||
```
|
||||
|
||||
#### wildcard calibration
|
||||
|
||||
`-ac` probes a few paths that cannot exist, learns the soft-404 baseline
|
||||
(status + size + words), and auto-drops any response matching it - so SPA
|
||||
catch-all 200s stop flooding the output:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -dirlist medium -ac
|
||||
```
|
||||
|
||||
#### custom wordlists and extensions
|
||||
|
||||
`-w <path|url>` overrides the size switch with your own list (local file or
|
||||
remote url); `-e <exts>` appends each extension to every word, keeping the bare
|
||||
word too:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -w /path/to/words.txt -e php,bak,env
|
||||
```
|
||||
|
||||
### subdomain enumeration
|
||||
|
||||
`-dnslist <size>` - enumerate subdomains
|
||||
|
||||
sizes: `small`, `medium`, `large`
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -dnslist small
|
||||
```
|
||||
|
||||
### port scanning
|
||||
|
||||
`-ports <scope>` - scan for open ports
|
||||
|
||||
scopes: `common` (top ports), `full` (all ports)
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -ports common
|
||||
```
|
||||
|
||||
### google dorking
|
||||
|
||||
`-dork` - automated google dorking
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -dork
|
||||
```
|
||||
|
||||
### git repository detection
|
||||
|
||||
`-git` - check for exposed git repositories
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -git
|
||||
```
|
||||
|
||||
### nuclei scanning
|
||||
|
||||
`-nuclei` - run nuclei vulnerability templates
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -nuclei
|
||||
```
|
||||
|
||||
### javascript analysis
|
||||
|
||||
`-js` - analyze javascript files + secret and endpoint extraction
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -js
|
||||
```
|
||||
|
||||
### cms detection
|
||||
|
||||
`-cms` - detect content management systems
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -cms
|
||||
```
|
||||
|
||||
### http headers
|
||||
|
||||
`-headers` - dump the target's response headers
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -headers
|
||||
```
|
||||
|
||||
### security headers
|
||||
|
||||
`-sh` - flag missing/weak security headers (hsts, csp, x-frame-options, ...) and headers that leak server internals
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -sh
|
||||
```
|
||||
|
||||
### cloud storage
|
||||
|
||||
`-c3` - check for cloud storage misconfigurations
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -c3
|
||||
```
|
||||
|
||||
### subdomain takeover
|
||||
|
||||
`-st` - check for subdomain takeover vulnerabilities
|
||||
|
||||
requires `-dnslist` to be enabled
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -dnslist small -st
|
||||
```
|
||||
|
||||
### shodan lookup
|
||||
|
||||
`-shodan` - query shodan for host intelligence
|
||||
|
||||
requires `SHODAN_API_KEY` environment variable
|
||||
|
||||
```bash
|
||||
export SHODAN_API_KEY=your-api-key
|
||||
./sif -u https://example.com -shodan
|
||||
```
|
||||
|
||||
### sql reconnaissance
|
||||
|
||||
`-sql` - detect sql admin panels and error disclosure
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -sql
|
||||
```
|
||||
|
||||
### lfi scanning
|
||||
|
||||
`-lfi` - local file inclusion vulnerability checks
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -lfi
|
||||
```
|
||||
|
||||
### cors probe
|
||||
|
||||
`-cors` - probe for cors misconfigurations (reflected/permissive origins)
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -cors
|
||||
```
|
||||
|
||||
### open redirect probe
|
||||
|
||||
`-redirect` - probe redirect-prone params for open redirects
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com/login?next=home -redirect
|
||||
```
|
||||
|
||||
### reflected xss probe
|
||||
|
||||
`-xss` - inject a canary into params and report unescaped reflections
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com/search?q=test -xss
|
||||
```
|
||||
|
||||
### jwt analysis
|
||||
|
||||
`-jwt` - fetch the target once, harvest jwts from response headers, cookies and body, then analyze each one entirely offline
|
||||
|
||||
flags alg:none, the rs256->hs256 confusion surface, missing/expired exp, plaintext sensitive claims, and cracks a small bundled weak-hmac wordlist. no token is ever sent off-box.
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -jwt
|
||||
```
|
||||
|
||||
### openapi/swagger exposure
|
||||
|
||||
`-openapi` - probe the conventional spec paths (`/swagger.json`, `/openapi.json`, `/v3/api-docs`, ...), parse the first hit (json or yaml) and enumerate every path+method, flagging operations with no security requirement
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -openapi
|
||||
```
|
||||
|
||||
### favicon fingerprint
|
||||
|
||||
`-favicon` - fetch `/favicon.ico` (or the declared `<link rel=icon>`), compute the shodan-style mmh3 hash, match it against a bundled tech map and print the `http.favicon.hash:<n>` pivot query
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -favicon
|
||||
```
|
||||
|
||||
### framework detection
|
||||
|
||||
`-framework` - detect web frameworks with version and cve lookup
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -framework
|
||||
```
|
||||
|
||||
### web crawler
|
||||
|
||||
`-crawl` - spider the target, following same-host links, scripts and forms
|
||||
|
||||
`-crawl-depth` - max recursion depth (default 2). respects robots.txt and stays on the target host.
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -crawl -crawl-depth 3
|
||||
```
|
||||
|
||||
### passive discovery
|
||||
|
||||
`-passive` - gather subdomains from certificate transparency (crt.sh, certspotter) and historical urls from the wayback machine
|
||||
|
||||
keyless and zero traffic to the target itself - all lookups hit third-party feeds.
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -passive
|
||||
```
|
||||
|
||||
### live-host probe
|
||||
|
||||
`-probe` - check whether the target is alive and report its final status, page title, server header, content-length and the redirect chain it walked
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -probe
|
||||
```
|
||||
|
||||
### whois lookup
|
||||
|
||||
`-whois` - perform whois lookups
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -whois
|
||||
```
|
||||
|
||||
### skip base scan
|
||||
|
||||
`-noscan` - skip the base url scan (robots.txt, etc)
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -noscan -dirlist medium
|
||||
```
|
||||
|
||||
## module options
|
||||
|
||||
### -lm, --list-modules
|
||||
|
||||
list all available modules:
|
||||
|
||||
```bash
|
||||
./sif -lm
|
||||
```
|
||||
|
||||
### -m, --modules
|
||||
|
||||
run specific modules by id (comma-separated):
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -m sqli-error-based,xss-reflected
|
||||
```
|
||||
|
||||
### -mt, --module-tags
|
||||
|
||||
run modules matching tags:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -mt owasp-top10
|
||||
./sif -u https://example.com -mt injection
|
||||
```
|
||||
|
||||
### -am, --all-modules
|
||||
|
||||
run all available modules:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -am
|
||||
```
|
||||
|
||||
## runtime options
|
||||
|
||||
### -t, --timeout
|
||||
|
||||
http request timeout (default: 10s):
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -t 30s
|
||||
```
|
||||
|
||||
### --threads
|
||||
|
||||
number of concurrent threads (default: 10). values below 1 are clamped to 1:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com --threads 20
|
||||
```
|
||||
|
||||
### -l, --log
|
||||
|
||||
directory to save log files:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -l ./logs
|
||||
```
|
||||
|
||||
### -d, --debug
|
||||
|
||||
enable debug logging:
|
||||
|
||||
```bash
|
||||
./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.
|
||||
|
||||
### -proxy
|
||||
|
||||
route all traffic through a proxy. supports http, https and socks5 urls:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -proxy socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
### -H, --header
|
||||
|
||||
add a custom header to every request. repeatable or comma-separated, `"Key: Value"`:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -H "Authorization: Bearer tok" -H "X-Env: staging"
|
||||
```
|
||||
|
||||
### -cookie
|
||||
|
||||
cookie header to send with every request:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -cookie "session=abc; theme=dark"
|
||||
```
|
||||
|
||||
### -rate-limit
|
||||
|
||||
cap outbound requests per second (0 = unlimited, default 0):
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -rate-limit 20
|
||||
```
|
||||
|
||||
## output options
|
||||
|
||||
write the collected findings out to a file after the scan. both formats can be requested in the same run.
|
||||
|
||||
### -sarif
|
||||
|
||||
write a sarif 2.1.0 report (one run, tool `sif`, one result per finding). ingestable by github code scanning and other sarif consumers:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -headers -cors -sarif out.sarif
|
||||
```
|
||||
|
||||
### -md, --markdown
|
||||
|
||||
write a readable markdown report grouped by target, then by module:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -headers -cors -md report.md
|
||||
```
|
||||
|
||||
### -silent
|
||||
|
||||
plain output for pipelines: all banner/spinner/log chrome goes to stderr and stdout carries one normalized finding per line, formatted `[severity] target module title`. implies non-interactive (no spinners), so a downstream consumer sees nothing but findings:
|
||||
|
||||
```bash
|
||||
subfinder -d example.com | sif -silent -probe -sh | notify
|
||||
```
|
||||
|
||||
### -diff
|
||||
|
||||
turn a re-scan into a monitor. sif snapshots each target's normalized findings to a json file under the store dir; on the next run it loads that snapshot, diffs the current findings against it by finding key, and prints only the delta (`+ new` for findings that appeared, `- gone` for findings that vanished). it always rewrites the snapshot afterwards, so each run compares against the previous one.
|
||||
|
||||
the first run for a target has no snapshot, so every finding shows as `+ new`. when nothing changed, sif notes that and writes a fresh snapshot anyway.
|
||||
|
||||
```bash
|
||||
# baseline, then re-scan and see only what moved
|
||||
./sif -u https://example.com -sh -cors -diff
|
||||
./sif -u https://example.com -sh -cors -diff
|
||||
```
|
||||
|
||||
the delta is chrome, not the findings stream: under `-silent` it rides stderr with the rest of the chrome, leaving stdout for the full findings.
|
||||
|
||||
### -store
|
||||
|
||||
snapshot directory for `-diff`. precedence when unset: the `-log` dir if one is given, else `<user-config>/sif/state` (`$XDG_CONFIG_HOME/sif/state` on linux, `~/Library/Application Support/sif/state` on macos). one sanitized file per target, created at `0750`, written `0600`.
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -sh -diff -store ./snapshots
|
||||
```
|
||||
|
||||
|
||||
## notify options
|
||||
|
||||
ship findings to a chat/webhook sink after the scan. every provider is a single POST through the shared http client, so the global proxy/rate-limit/header config applies. with nothing configured, `-notify` is a silent no-op.
|
||||
|
||||
### -notify
|
||||
|
||||
enable delivery to every configured provider:
|
||||
|
||||
```bash
|
||||
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
|
||||
./sif -u https://example.com -cors -xss -notify
|
||||
```
|
||||
|
||||
### -notify-severity
|
||||
|
||||
minimum severity to send: `info`, `low`, `medium`, `high` or `critical` (default `medium`). findings below the floor are dropped, so info-level recon noise doesn't flood a channel. an unrecognized value falls back to `medium`:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -cors -notify -notify-severity high
|
||||
```
|
||||
|
||||
### -notify-config
|
||||
|
||||
path to a yaml config that overrides the env vars per-field. the keys match [projectdiscovery/notify](https://github.com/projectdiscovery/notify) so an existing config ports over:
|
||||
|
||||
```yaml
|
||||
slack_webhook_url: https://hooks.slack.com/services/...
|
||||
discord_webhook_url: https://discord.com/api/webhooks/...
|
||||
telegram_api_key: 123456:abcdef
|
||||
telegram_chat_id: "987654"
|
||||
webhook_url: https://example.internal/sif-findings
|
||||
```
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -cors -notify -notify-config notify.yaml
|
||||
```
|
||||
|
||||
providers are resolved env-first, then overlaid by the yaml file:
|
||||
|
||||
| env var | yaml key | provider |
|
||||
|---------|----------|----------|
|
||||
| `SLACK_WEBHOOK_URL` | `slack_webhook_url` | slack incoming webhook |
|
||||
| `DISCORD_WEBHOOK_URL` | `discord_webhook_url` | discord webhook |
|
||||
| `TELEGRAM_BOT_TOKEN` | `telegram_api_key` | telegram bot api (needs chat id too) |
|
||||
| `TELEGRAM_CHAT_ID` | `telegram_chat_id` | telegram destination chat |
|
||||
| `NOTIFY_WEBHOOK_URL` | `webhook_url` | generic json webhook (structured findings) |
|
||||
|
||||
slack/discord/telegram receive a fixed-width finding block; the generic webhook receives structured json (`{count, findings[]}`) for downstream automation.
|
||||
|
||||
## api options
|
||||
|
||||
### -api
|
||||
|
||||
enable api mode for json output:
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -api
|
||||
```
|
||||
|
||||
output is a json object with scan results.
|
||||
|
||||
## commands
|
||||
|
||||
these run without scanning a target.
|
||||
|
||||
### version
|
||||
|
||||
print the sif version. release builds are stamped via ldflags, local `make` builds derive it from `git describe`, and `go install`ed builds read it from the module build info:
|
||||
|
||||
```bash
|
||||
./sif version
|
||||
```
|
||||
|
||||
### patchnote
|
||||
|
||||
show the latest release's notes, fetched from github (also `-pn`):
|
||||
|
||||
```bash
|
||||
./sif patchnote
|
||||
```
|
||||
|
||||
the first time you run a new release sif also prints that release's notes once. set `SIF_NO_PATCHNOTES=1` to disable that.
|
||||
|
||||
## examples
|
||||
|
||||
### quick recon
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -framework -headers -git
|
||||
```
|
||||
|
||||
### full scan
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com \
|
||||
-dirlist large \
|
||||
-dnslist medium \
|
||||
-ports full \
|
||||
-framework \
|
||||
-js \
|
||||
-headers \
|
||||
-cms \
|
||||
-git \
|
||||
-sql \
|
||||
-lfi \
|
||||
-cors \
|
||||
-redirect \
|
||||
-xss \
|
||||
-am
|
||||
```
|
||||
|
||||
### ci/cd pipeline
|
||||
|
||||
```bash
|
||||
./sif -u https://staging.example.com -api -am > results.json
|
||||
```
|
||||
|
||||
### batch scanning
|
||||
|
||||
```bash
|
||||
echo "https://site1.com
|
||||
https://site2.com
|
||||
https://site3.com" > targets.txt
|
||||
|
||||
./sif -f targets.txt -am -l ./logs
|
||||
```
|
||||
Generated
+62
-4
@@ -1,12 +1,35 @@
|
||||
{
|
||||
"nodes": {
|
||||
"gomod2nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"utils": [
|
||||
"utils"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1677459247,
|
||||
"narHash": "sha256-JbakfAiPYmCCV224yAMq/XO0udN5coWv/oazblMKdoY=",
|
||||
"owner": "tweag",
|
||||
"repo": "gomod2nix",
|
||||
"rev": "3cbf3a51fe32e2f57af4c52744e7228bab22983d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tweag",
|
||||
"repo": "gomod2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1780930886,
|
||||
"narHash": "sha256-rppURzHviaQN131F+nLiLdGfcb0uCd9gGP0E5+iw9MI=",
|
||||
"lastModified": 1693844670,
|
||||
"narHash": "sha256-t69F2nBB8DNQUWHD809oJZJVE+23XBrth4QZuVd6IE0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8c3cede7ddc26bd659d2d383b5610efbd2c7a16e",
|
||||
"rev": "3c15feef7770eb5500a4b8792623e2d6f598c9c1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,7 +41,42 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
"gomod2nix": "gomod2nix",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1692799911,
|
||||
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,57 +1,36 @@
|
||||
{
|
||||
description = "A blazing-fast pentesting (recon/exploitation) suite";
|
||||
description = "a blazing-fast pentesting (recon/exploitation) suite";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
|
||||
gomod2nix = {
|
||||
url = "github:tweag/gomod2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.utils.follows = "utils";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.buildGoModule {
|
||||
pname = "sif";
|
||||
version = "unstable-${self.shortRev or self.dirtyShortRev or "dev"}";
|
||||
src = ./.;
|
||||
|
||||
vendorHash = "sha256-fR63/dStMsZon22vancuLWIAvZiEYMLjMwY1kmRDNgM=";
|
||||
|
||||
# Tests require network access (httptest)
|
||||
doCheck = false;
|
||||
|
||||
ldflags = [ "-s" "-w" ];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Modular pentesting toolkit written in Go";
|
||||
homepage = "https://github.com/vmfunc/sif";
|
||||
license = licenses.bsd3;
|
||||
mainProgram = "sif";
|
||||
maintainers = [ ];
|
||||
};
|
||||
};
|
||||
|
||||
sif = self.packages.${system}.default;
|
||||
});
|
||||
|
||||
overlays.default = final: prev: {
|
||||
sif = self.packages.${final.system}.default;
|
||||
outputs = { self, nixpkgs, utils, gomod2nix }:
|
||||
utils.lib.eachDefaultSystem (system:
|
||||
let pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ gomod2nix.overlays.default ];
|
||||
};
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [ go gopls ];
|
||||
};
|
||||
});
|
||||
};
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.buildGoApplication {
|
||||
pname = "sif";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
modules = ./gomod2nix.toml;
|
||||
};
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
go
|
||||
gomod2nix.packages.${system}.default
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,408 +1,226 @@
|
||||
module github.com/dropalldatabases/sif
|
||||
|
||||
go 1.25.7
|
||||
go 1.23
|
||||
|
||||
toolchain go1.25.5
|
||||
|
||||
require (
|
||||
github.com/antchfx/htmlquery v1.3.6
|
||||
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.3.0
|
||||
github.com/likexian/whois v1.15.7
|
||||
github.com/projectdiscovery/goflags v0.1.74
|
||||
github.com/projectdiscovery/nuclei/v3 v3.9.0
|
||||
github.com/projectdiscovery/retryabledns v1.0.115
|
||||
github.com/projectdiscovery/utils v0.11.1
|
||||
github.com/antchfx/htmlquery v1.3.0
|
||||
github.com/charmbracelet/lipgloss v0.8.0
|
||||
github.com/charmbracelet/log v0.2.4
|
||||
github.com/likexian/whois v1.15.1
|
||||
github.com/projectdiscovery/goflags v0.1.54
|
||||
github.com/projectdiscovery/nuclei/v2 v2.9.14
|
||||
github.com/projectdiscovery/ratelimit v0.0.9
|
||||
github.com/projectdiscovery/utils v0.1.1
|
||||
github.com/rocketlaunchr/google-search v1.1.6
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/twmb/murmur3 v1.1.8
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.3.0 // indirect
|
||||
carvel.dev/ytt v0.52.0 // indirect
|
||||
code.gitea.io/sdk/gitea v0.17.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
aead.dev/minisign v0.2.0 // indirect
|
||||
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
|
||||
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/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/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect
|
||||
github.com/Masterminds/semver/v3 v3.2.1 // 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/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||
github.com/akrylysov/pogreb v0.10.2 // indirect
|
||||
github.com/akrylysov/pogreb v0.10.1 // indirect
|
||||
github.com/alecthomas/chroma v0.10.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 // indirect
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
||||
github.com/alexsnet/go-vnc v0.1.0 // indirect
|
||||
github.com/alitto/pond v1.9.2 // indirect
|
||||
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.5.0 // indirect
|
||||
github.com/antchfx/xpath v1.3.6 // indirect
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/andygrunwald/go-jira v1.16.0 // indirect
|
||||
github.com/antchfx/xmlquery v1.3.15 // indirect
|
||||
github.com/antchfx/xpath v1.2.4 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.82 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.19.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.19.3 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
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.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
|
||||
github.com/bodgit/sevenzip v1.6.1 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/caddyserver/certmagic v0.25.0 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||
github.com/censys/censys-sdk-go v0.19.1 // indirect
|
||||
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.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
|
||||
github.com/cheggaaa/pb/v3 v3.1.7 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/caddyserver/certmagic v0.19.2 // indirect
|
||||
github.com/charmbracelet/glamour v0.6.0 // indirect
|
||||
github.com/cheggaaa/pb/v3 v3.1.4 // indirect
|
||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/cloudflare/circl v1.3.7 // indirect
|
||||
github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08 // indirect
|
||||
github.com/corpix/uarand v0.2.0 // indirect
|
||||
github.com/dimchansky/utfbom v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
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/go-connections v0.7.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.8.1 // 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
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
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.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
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
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.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
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-pdf/fpdf v0.9.0 // indirect
|
||||
github.com/go-pg/pg/v10 v10.15.0 // indirect
|
||||
github.com/go-pg/zerochecker v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-rod/rod v0.116.2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.1 // indirect
|
||||
github.com/go-rod/rod v0.114.0 // indirect
|
||||
github.com/goburrow/cache v0.1.4 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
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/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
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/certificate-transparency-go v1.3.2 // indirect
|
||||
github.com/gobwas/ws v1.2.1 // indirect
|
||||
github.com/gocolly/colly/v2 v2.1.0 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.4 // indirect
|
||||
github.com/google/go-github v17.0.0+incompatible // indirect
|
||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gosimple/slug v1.15.0 // indirect
|
||||
github.com/gosimple/unidecode v1.0.1 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/h2non/filetype v1.1.3 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf // indirect
|
||||
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
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
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
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect
|
||||
github.com/itchyny/gojq v0.12.13 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0 // indirect
|
||||
github.com/k14s/starlark-go v0.0.0-20200720175618-3a5c849cc368 // indirect
|
||||
github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect
|
||||
github.com/kataras/jwt v0.1.10 // indirect
|
||||
github.com/kataras/jwt v0.1.8 // indirect
|
||||
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.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
|
||||
github.com/labstack/echo/v4 v4.13.4 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa // indirect
|
||||
github.com/lib/pq v1.11.2 // indirect
|
||||
github.com/libdns/libdns v1.1.1 // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/klauspost/pgzip v1.2.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/libdns/libdns v0.2.1 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/logrusorgru/aurora/v4 v4.0.0 // indirect
|
||||
github.com/lor00x/goldap v0.0.0-20240304151906-8d785c64d1c8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
||||
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.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
|
||||
github.com/mholt/archives v0.1.5 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.2 // indirect
|
||||
github.com/miekg/dns v1.1.68 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mackerelio/go-osstat v0.2.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/mholt/acmez v1.2.0 // indirect
|
||||
github.com/mholt/archiver v3.1.1+incompatible // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 // indirect
|
||||
github.com/miekg/dns v1.1.56 // indirect
|
||||
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/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
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.23 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/praetorian-inc/fingerprintx v1.1.15 // indirect
|
||||
github.com/projectdiscovery/asnmap v1.1.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/projectdiscovery/asnmap v1.1.0 // indirect
|
||||
github.com/projectdiscovery/blackrock v0.0.1 // 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/cdncheck v1.0.9 // indirect
|
||||
github.com/projectdiscovery/clistats v0.0.19 // indirect
|
||||
github.com/projectdiscovery/dsl v0.0.20 // indirect
|
||||
github.com/projectdiscovery/fastdialer v0.1.1 // 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.70 // indirect
|
||||
github.com/projectdiscovery/gostruct v0.0.2 // 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/freeport v0.0.5 // indirect
|
||||
github.com/projectdiscovery/gologger v1.1.12 // indirect
|
||||
github.com/projectdiscovery/gostruct v0.0.1 // indirect
|
||||
github.com/projectdiscovery/hmap v0.0.45 // indirect
|
||||
github.com/projectdiscovery/httpx v1.3.4 // indirect
|
||||
github.com/projectdiscovery/interactsh v1.2.0 // 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.40 // indirect
|
||||
github.com/projectdiscovery/ratelimit v0.0.88 // indirect
|
||||
github.com/projectdiscovery/rawhttp v0.1.90 // indirect
|
||||
github.com/projectdiscovery/machineid v0.0.0-20240226150047-2e2c51e35983 // indirect
|
||||
github.com/projectdiscovery/mapcidr v1.1.34 // indirect
|
||||
github.com/projectdiscovery/networkpolicy v0.0.8 // indirect
|
||||
github.com/projectdiscovery/rawhttp v0.1.18 // indirect
|
||||
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917 // 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.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/projectdiscovery/retryabledns v1.0.62 // indirect
|
||||
github.com/projectdiscovery/retryablehttp-go v1.0.63 // indirect
|
||||
github.com/projectdiscovery/sarif v0.0.1 // indirect
|
||||
github.com/projectdiscovery/tlsx v1.1.4 // indirect
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.4 // indirect
|
||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||
github.com/refraction-networking/utls v1.5.4 // 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/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/sashabaranov/go-openai v1.37.0 // indirect
|
||||
github.com/sashabaranov/go-openai v1.14.2 // indirect
|
||||
github.com/segmentio/ksuid v1.0.4 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
||||
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.4 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.23.7 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/cast v1.5.1 // 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/btree v1.6.0 // indirect
|
||||
github.com/tidwall/buntdb v1.3.0 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/grect v0.1.4 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/rtred v0.1.2 // indirect
|
||||
github.com/tidwall/tinyqueue v0.1.1 // indirect
|
||||
github.com/tim-ywliu/nested-logrus-formatter v1.3.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/trivago/tgo v1.0.7 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/ulikunitz/xz v0.5.11 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/vmihailenco/bufpool v0.1.11 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect
|
||||
github.com/vmihailenco/tagparser v0.1.2 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/vulncheck-oss/go-exploit v1.51.0 // indirect
|
||||
github.com/weppos/publicsuffix-go v0.50.3 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yassinebenaid/godump v0.11.1 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db // indirect
|
||||
github.com/xanzy/go-gitlab v0.84.0 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
github.com/yl2chen/cidranger v1.0.2 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/got v0.34.1 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.13 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
github.com/ysmood/leakless v0.8.0 // indirect
|
||||
github.com/yuin/goldmark v1.5.4 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zcalusic/sysinfo v1.1.3 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
github.com/zcalusic/sysinfo v1.0.2 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
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 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.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
|
||||
github.com/zmap/zcrypto v0.0.0-20230422215203-9a665e1e9968 // indirect
|
||||
go.etcd.io/bbolt v1.3.7 // 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
|
||||
go.uber.org/zap v1.25.0 // indirect
|
||||
goftp.io/server/v2 v2.0.1 // indirect
|
||||
golang.org/x/arch v0.3.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.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
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/oauth2 v0.11.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/term v0.23.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // 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/djherbis/times.v1 v1.3.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
mellium.im/sasl v0.3.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
moul.io/http2curl v1.0.0 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||
)
|
||||
|
||||
+624
@@ -0,0 +1,624 @@
|
||||
schema = 3
|
||||
|
||||
[mod]
|
||||
[mod."aead.dev/minisign"]
|
||||
version = "v0.2.0"
|
||||
hash = "sha256-2a05wSk811IdX9WSfMsrAvjPe6XVXEd4cvojrV+zqJ4="
|
||||
[mod."git.mills.io/prologic/smtpd"]
|
||||
version = "v0.0.0-20210710122116-a525b76c287a"
|
||||
hash = "sha256-tbfKCLDJKAoZE3BvimQQLPn1cou2eA2wyMB0y1zPJEc="
|
||||
[mod."github.com/Knetic/govaluate"]
|
||||
version = "v3.0.1-0.20171022003610-9aa49832a739+incompatible"
|
||||
hash = "sha256-Qs7qeK+Mrlm4ToAEYvN+OY6X7SRFV808frvKNr6gNhE="
|
||||
[mod."github.com/Masterminds/semver/v3"]
|
||||
version = "v3.2.1"
|
||||
hash = "sha256-VKHIquwriyOL8A0qgtmap/3cGEOpDokOLtPg1w4xjMA="
|
||||
[mod."github.com/Mzack9999/gcache"]
|
||||
version = "v0.0.0-20230410081825-519e28eab057"
|
||||
hash = "sha256-ofR592gukVdlEqA5ny9BPRDL4q2DrDTZeh4x1lrEmnQ="
|
||||
[mod."github.com/Mzack9999/go-http-digest-auth-client"]
|
||||
version = "v0.6.1-0.20220414142836-eb8883508809"
|
||||
hash = "sha256-N4W589FOd0Oej0hpWsH0FaOBFxrYmAyX+L6eFW5sXDA="
|
||||
[mod."github.com/Mzack9999/ldapserver"]
|
||||
version = "v1.0.2-0.20211229000134-b44a0d6ad0dd"
|
||||
hash = "sha256-s7X5Zd9Py8mKjJ/xWfgtrmYXl6ynpETwf0KXlnj3rRc="
|
||||
[mod."github.com/PuerkitoBio/goquery"]
|
||||
version = "v1.8.1"
|
||||
hash = "sha256-z2RaB8PVPEzSJdMUfkfNjT616yXWTjW2gkhNOh989ZU="
|
||||
[mod."github.com/VividCortex/ewma"]
|
||||
version = "v1.2.0"
|
||||
hash = "sha256-mHprIVRUOgs1qyYpiMO3bh6fCzDrqasDsaTaRE0oHXI="
|
||||
[mod."github.com/akrylysov/pogreb"]
|
||||
version = "v0.10.1"
|
||||
hash = "sha256-f1BoPiR4KghX68eDPYQVuv1AVj97X1a+biip4vCrQ/s="
|
||||
[mod."github.com/alecthomas/chroma"]
|
||||
version = "v0.10.0"
|
||||
hash = "sha256-p721vddVTv4iv1O0/dqpdk5xF6x9iLIHcrfh8JEVnqQ="
|
||||
[mod."github.com/alecthomas/jsonschema"]
|
||||
version = "v0.0.0-20211022214203-8b29eab41725"
|
||||
hash = "sha256-l0OFXpa2E/t839tJGLY6jJUCuQC0SLCseYKsfM5o2vI="
|
||||
[mod."github.com/alecthomas/template"]
|
||||
version = "v0.0.0-20190718012654-fb15b899a751"
|
||||
hash = "sha256-RsS4qxdRQ3q+GejA8D9Iu31A/mZNms4LbJ7518jWiu4="
|
||||
[mod."github.com/alecthomas/units"]
|
||||
version = "v0.0.0-20211218093645-b94a6e3cc137"
|
||||
hash = "sha256-uriYmwxT69xbmWKO/5OAyeMa2lFBOJDrU2KtQh/+ZjY="
|
||||
[mod."github.com/andybalholm/brotli"]
|
||||
version = "v1.0.5"
|
||||
hash = "sha256-/qS8wU8yZQJ+uTOg66rEl9s7spxq9VIXF5L1BcaEClc="
|
||||
[mod."github.com/andybalholm/cascadia"]
|
||||
version = "v1.3.2"
|
||||
hash = "sha256-Nc9SkqJO/ecincVcUBFITy24TMmMGj5o0Q8EgdNhrEk="
|
||||
[mod."github.com/andygrunwald/go-jira"]
|
||||
version = "v1.16.0"
|
||||
hash = "sha256-veyWp65T9uYYmw9o0g4w6tqn5Svq5++WFXNfy4vI+HA="
|
||||
[mod."github.com/antchfx/htmlquery"]
|
||||
version = "v1.3.0"
|
||||
hash = "sha256-tldRSQPTmUodUepZkOnISWjfWPY37MzNN2Pd2/zmvoo="
|
||||
[mod."github.com/antchfx/xmlquery"]
|
||||
version = "v1.3.15"
|
||||
hash = "sha256-uenaH5HiVcIswTjfwm2qqOA0ljY5la0BI4NiH4LjFD4="
|
||||
[mod."github.com/antchfx/xpath"]
|
||||
version = "v1.2.4"
|
||||
hash = "sha256-rT5AtOv49/iGdR6X42Ho+ZEw6+YGQqfNUcYkSp1CU/g="
|
||||
[mod."github.com/asaskevich/govalidator"]
|
||||
version = "v0.0.0-20230301143203-a9d515a09cc2"
|
||||
hash = "sha256-UCENzt1c1tFgsAzK2TNq5s2g0tQMQ5PxFaQKe8hTL/A="
|
||||
[mod."github.com/aws/aws-sdk-go-v2"]
|
||||
version = "v1.19.0"
|
||||
hash = "sha256-z4UJRyk3eLx0yQ3kTl3zKH6bEM7MK1sqPQKvbP8d2Ec="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/config"]
|
||||
version = "v1.18.28"
|
||||
hash = "sha256-zFNtrknzaJ0zQr8EOT/3Y1qqZ/YcRMizRUZHxt9QY0I="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/credentials"]
|
||||
version = "v1.13.27"
|
||||
hash = "sha256-so4NK+rlyZnBtxgUNLld/G7vQKP/wp1A6wRJtaZT2pU="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/feature/ec2/imds"]
|
||||
version = "v1.13.5"
|
||||
hash = "sha256-zseMGwUW3NjzhD5IixiTiwp7x9hRAvpMbADEaYIB6Ig="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/internal/configsources"]
|
||||
version = "v1.1.35"
|
||||
hash = "sha256-TuDsdVuVbqUQbV4Y2E9Exmlu2an0yrfMGgdTHhXY85E="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/internal/endpoints/v2"]
|
||||
version = "v2.4.29"
|
||||
hash = "sha256-P+9wAU5sbBn1tQqS1nFwisaoa3999czJilowwO2rO3Y="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/internal/ini"]
|
||||
version = "v1.3.36"
|
||||
hash = "sha256-9VmY8oidPMnAfpt2AyiCSSascqBZGGLtIizTydlK8k8="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/service/internal/presigned-url"]
|
||||
version = "v1.9.29"
|
||||
hash = "sha256-mXNOY17gXxhS2NV7azA0mxrARkROGrrpeN0Lgg7KQSw="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/service/sso"]
|
||||
version = "v1.12.13"
|
||||
hash = "sha256-F4tTYdgFvDImOQNuKQFFsLwd6bX1CO50Ab3KYqY32Lc="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/service/ssooidc"]
|
||||
version = "v1.14.13"
|
||||
hash = "sha256-XGj/ccaj00wNN32J3JTuuqthCbxrTfmxfSYJLf/hK8Y="
|
||||
[mod."github.com/aws/aws-sdk-go-v2/service/sts"]
|
||||
version = "v1.19.3"
|
||||
hash = "sha256-Q8NFgFRjNUFldTmr/Ya9DyAUNfsC9AuWPkSFMrVF/jg="
|
||||
[mod."github.com/aws/smithy-go"]
|
||||
version = "v1.13.5"
|
||||
hash = "sha256-lu1UnvPnLzXjDPBk2FJ4ZImKRQf7aj43mLbuolFdE64="
|
||||
[mod."github.com/aymanbagabas/go-osc52/v2"]
|
||||
version = "v2.0.1"
|
||||
hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg="
|
||||
[mod."github.com/aymerick/douceur"]
|
||||
version = "v0.2.0"
|
||||
hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
|
||||
[mod."github.com/bluele/gcache"]
|
||||
version = "v0.0.2"
|
||||
hash = "sha256-gU44V3jqw6K3Mjgh6DG9f7DU+ft3wA9GDmH4AgMTjxE="
|
||||
[mod."github.com/caddyserver/certmagic"]
|
||||
version = "v0.19.2"
|
||||
hash = "sha256-ruz2oG5E834tMjyL/HdFPaWlNuwBg/fxqVitZX3sQR0="
|
||||
[mod."github.com/charmbracelet/glamour"]
|
||||
version = "v0.6.0"
|
||||
hash = "sha256-L5V2P/9EPP80703KJUSMDiAPgjW1B4i1IbJADPmUCoY="
|
||||
[mod."github.com/charmbracelet/lipgloss"]
|
||||
version = "v0.8.0"
|
||||
hash = "sha256-m+cRJCCJjNyxJKxCk1ftu32OMesYDIUw/EVUzTZjo9I="
|
||||
[mod."github.com/charmbracelet/log"]
|
||||
version = "v0.2.4"
|
||||
hash = "sha256-LQe3fQHf/v6q8pegS5E54eSfU0Y5tnKXM+Mk6uzeWvU="
|
||||
[mod."github.com/cheggaaa/pb/v3"]
|
||||
version = "v3.1.4"
|
||||
hash = "sha256-Fl0bM8ag8sKr8C/hj5qaxN+VjmRA403xXcQoTdQ19LU="
|
||||
[mod."github.com/cloudflare/cfssl"]
|
||||
version = "v1.6.4"
|
||||
hash = "sha256-dAUHPutZ+bpDgJ0mWrALLIbQqNF2d1OkgSAWzQkxXWY="
|
||||
[mod."github.com/cloudflare/circl"]
|
||||
version = "v1.3.3"
|
||||
hash = "sha256-ItdVkU53Ep01553/tJ4MdAwoTpPljRxiBW9sAd7p0xI="
|
||||
[mod."github.com/cnf/structhash"]
|
||||
version = "v0.0.0-20201127153200-e1b16c1ebc08"
|
||||
hash = "sha256-hvJSTpbaPHgWnJ16B9a4cFVblplAgCw5OkGSUFmJBvg="
|
||||
[mod."github.com/corpix/uarand"]
|
||||
version = "v0.2.0"
|
||||
hash = "sha256-/2ZqTtYPEbfn5adf5tIU9p8jwHFRkBYzi4WE5h2AwkI="
|
||||
[mod."github.com/dimchansky/utfbom"]
|
||||
version = "v1.1.1"
|
||||
hash = "sha256-w8KEprK54zJkMat78T6zldjDwvhbc/O8s6pVFzfmg1I="
|
||||
[mod."github.com/dlclark/regexp2"]
|
||||
version = "v1.8.1"
|
||||
hash = "sha256-Xm4I+Qrpwn21QsWcUMden00zWapbloa6K1yJ83tTOVE="
|
||||
[mod."github.com/docker/go-units"]
|
||||
version = "v0.5.0"
|
||||
hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE="
|
||||
[mod."github.com/dsnet/compress"]
|
||||
version = "v0.0.1"
|
||||
hash = "sha256-HCqu3cKayMvx1YIUPkJ+u4UM6WN8nrsNIhdvGJIJgwg="
|
||||
[mod."github.com/fatih/color"]
|
||||
version = "v1.15.0"
|
||||
hash = "sha256-7b+scFVQeEUoXfeCDd8X2gS8GMoWA+HxjK8wfbypa5s="
|
||||
[mod."github.com/fatih/structs"]
|
||||
version = "v1.1.0"
|
||||
hash = "sha256-OCmubTLF1anwNnkvFZDYHnF6hFlX0WDoe/9+dDlaMPM="
|
||||
[mod."github.com/gabriel-vasile/mimetype"]
|
||||
version = "v1.4.2"
|
||||
hash = "sha256-laV+IkgbnEG07h1eFfPISqp0ctnLXfzchz/CLR1lftk="
|
||||
[mod."github.com/gaukas/godicttls"]
|
||||
version = "v0.0.4"
|
||||
hash = "sha256-Tok6mN6P7rnqK+VCiI6LOV9DBnOTjGyGrgfzZdMCMVk="
|
||||
[mod."github.com/go-logfmt/logfmt"]
|
||||
version = "v0.6.0"
|
||||
hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg="
|
||||
[mod."github.com/go-ole/go-ole"]
|
||||
version = "v1.2.6"
|
||||
hash = "sha256-+oxitLeJxYF19Z6g+6CgmCHJ1Y5D8raMi2Cb3M6nXCs="
|
||||
[mod."github.com/go-playground/locales"]
|
||||
version = "v0.14.1"
|
||||
hash = "sha256-BMJGAexq96waZn60DJXZfByRHb8zA/JP/i6f/YrW9oQ="
|
||||
[mod."github.com/go-playground/universal-translator"]
|
||||
version = "v0.18.1"
|
||||
hash = "sha256-2/B2qP51zfiY+k8G0w0D03KXUc7XpWj6wKY7NjNP/9E="
|
||||
[mod."github.com/go-playground/validator/v10"]
|
||||
version = "v10.14.1"
|
||||
hash = "sha256-13J8JqIuhI7lbBagaR7INykFRXqRbB7tjXtMZI3PNvA="
|
||||
[mod."github.com/go-rod/rod"]
|
||||
version = "v0.114.0"
|
||||
hash = "sha256-YQwPbgeBPziMTmFg8kulEQkdTi3OTUutlX+8CmCdQ94="
|
||||
[mod."github.com/goburrow/cache"]
|
||||
version = "v0.1.4"
|
||||
hash = "sha256-3imkv1DlePYg0aBswzxqOn1EzZFwMXW+D3Dq0u0GEEQ="
|
||||
[mod."github.com/gobwas/glob"]
|
||||
version = "v0.2.3"
|
||||
hash = "sha256-hYHMUdwxVkMOjSKjR7UWO0D0juHdI4wL8JEy5plu/Jc="
|
||||
[mod."github.com/gobwas/httphead"]
|
||||
version = "v0.1.0"
|
||||
hash = "sha256-6wFni/JkK2GqtVs3IW+GxHRNoSu4EJfzaBRGX2hF1IA="
|
||||
[mod."github.com/gobwas/pool"]
|
||||
version = "v0.2.1"
|
||||
hash = "sha256-py8/+Wo5Q83EbYMUKK5U/4scRcyMo2MjOoxqi5y+sUY="
|
||||
[mod."github.com/gobwas/ws"]
|
||||
version = "v1.2.1"
|
||||
hash = "sha256-5kWY244Vuyj01BzgTJuaJUJJwTXaKZ0UzPruKATByEg="
|
||||
[mod."github.com/gocolly/colly/v2"]
|
||||
version = "v2.1.0"
|
||||
hash = "sha256-yWhPcNwGj31wWJrnHWOa3jBO1qZXfqOWuHDlmpSPuyg="
|
||||
[mod."github.com/golang-jwt/jwt/v4"]
|
||||
version = "v4.5.0"
|
||||
hash = "sha256-dyKL8wQRApkdCkKxJ1knllvixsrBLw+BtRS0SjlN7NQ="
|
||||
[mod."github.com/golang/groupcache"]
|
||||
version = "v0.0.0-20210331224755-41bb18bfe9da"
|
||||
hash = "sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0="
|
||||
[mod."github.com/golang/protobuf"]
|
||||
version = "v1.5.3"
|
||||
hash = "sha256-svogITcP4orUIsJFjMtp+Uv1+fKJv2Q5Zwf2dMqnpOQ="
|
||||
[mod."github.com/golang/snappy"]
|
||||
version = "v0.0.4"
|
||||
hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA="
|
||||
[mod."github.com/google/certificate-transparency-go"]
|
||||
version = "v1.1.4"
|
||||
hash = "sha256-/V18IcVehgvhkT+w7y8vpXaVAtdV3BAsxOnbRBromGw="
|
||||
[mod."github.com/google/go-github"]
|
||||
version = "v17.0.0+incompatible"
|
||||
hash = "sha256-5EGZnkefwLCEODLICIgaq39UoOzBJqpeLraoc2hJfM8="
|
||||
[mod."github.com/google/go-github/v30"]
|
||||
version = "v30.1.0"
|
||||
hash = "sha256-u6m+wWJl440UI64Q2tpX0qFF3LyEH3hPww82hIEf6/Q="
|
||||
[mod."github.com/google/go-querystring"]
|
||||
version = "v1.1.0"
|
||||
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
|
||||
[mod."github.com/google/uuid"]
|
||||
version = "v1.3.1"
|
||||
hash = "sha256-JxAEAB2bFlGPShFreyOWjUahjaGV3xYS5TpfUOikod0="
|
||||
[mod."github.com/gorilla/css"]
|
||||
version = "v1.0.0"
|
||||
hash = "sha256-Mmt/IqHpgrtWpbr/AKcJyf/USQTqEuv1HVivY4eHzoQ="
|
||||
[mod."github.com/h2non/filetype"]
|
||||
version = "v1.1.3"
|
||||
hash = "sha256-lSX/fSbT3MVlNK7d1U6Q/lBHtGXXAQ/HY4zW6Bppqhc="
|
||||
[mod."github.com/hashicorp/go-cleanhttp"]
|
||||
version = "v0.5.2"
|
||||
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
|
||||
[mod."github.com/hashicorp/go-retryablehttp"]
|
||||
version = "v0.7.2"
|
||||
hash = "sha256-PcLyolWF7G409rs7j3tnwgQK6xhgWYk9/iK2bO13TGQ="
|
||||
[mod."github.com/hashicorp/go-version"]
|
||||
version = "v1.6.0"
|
||||
hash = "sha256-UV0equpmW6BiJnp4W3TZlSJ+PTHuTA+CdOs2JTeHhjs="
|
||||
[mod."github.com/hbakhtiyor/strsim"]
|
||||
version = "v0.0.0-20190107154042-4d2bbb273edf"
|
||||
hash = "sha256-vK4ghGQy9IGvAq0/3roEDiE/ybNOePULr4s/V8ZHLj8="
|
||||
[mod."github.com/hdm/jarm-go"]
|
||||
version = "v0.0.7"
|
||||
hash = "sha256-4SnBXV+O7iWPO0Yt9/D1BhaF7MEvNUrwBj116uMt5j0="
|
||||
[mod."github.com/iancoleman/orderedmap"]
|
||||
version = "v0.0.0-20190318233801-ac98e3ecb4b0"
|
||||
hash = "sha256-IIm0P6GnYSBGHzOYc7ljp+5LPoWBmmqXt1Yi4vBRdsQ="
|
||||
[mod."github.com/itchyny/gojq"]
|
||||
version = "v0.12.13"
|
||||
hash = "sha256-tlnj0CCsPZRQjIZCvNPjN0JD6oqRDvdWOCYR3tYMPUA="
|
||||
[mod."github.com/itchyny/timefmt-go"]
|
||||
version = "v0.1.5"
|
||||
hash = "sha256-FvgqEW8fnZsfbHpV+X4FQvDzzneNOpdQtQLXovh1YmI="
|
||||
[mod."github.com/json-iterator/go"]
|
||||
version = "v1.1.12"
|
||||
hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM="
|
||||
[mod."github.com/julienschmidt/httprouter"]
|
||||
version = "v1.3.0"
|
||||
hash = "sha256-YVbnyFLVZX1mtqcwM1SStQdhcQsPHyi1ltpOrD3w2qg="
|
||||
[mod."github.com/kataras/jwt"]
|
||||
version = "v0.1.8"
|
||||
hash = "sha256-3AKX8wmQ6RaRMAyhe1JirEl1P0ZiMNRJZ3D1yzBRuCU="
|
||||
[mod."github.com/kennygrant/sanitize"]
|
||||
version = "v1.2.4"
|
||||
hash = "sha256-PRNblaLosaB7tvUVgAOZORMZGUo+7Wy7h1Z1mpJLd5c="
|
||||
[mod."github.com/klauspost/compress"]
|
||||
version = "v1.16.7"
|
||||
hash = "sha256-8miX/lnXyNLPSqhhn5BesLauaIAxETpQpWtr1cu2f+0="
|
||||
[mod."github.com/klauspost/cpuid/v2"]
|
||||
version = "v2.2.5"
|
||||
hash = "sha256-/M8CHNah2/EPr0va44r1Sx+3H6E+jN8bGFi5jQkLBrM="
|
||||
[mod."github.com/leodido/go-urn"]
|
||||
version = "v1.2.4"
|
||||
hash = "sha256-N2HO7ChScxI79KGvXI9LxoIlr+lkBNdDZP9OPGwPRK0="
|
||||
[mod."github.com/libdns/libdns"]
|
||||
version = "v0.2.1"
|
||||
hash = "sha256-bxEY0wYu4Um0t7sakLyMwMPDXfv2x07gjckKSyAypsc="
|
||||
[mod."github.com/logrusorgru/aurora"]
|
||||
version = "v2.0.3+incompatible"
|
||||
hash = "sha256-7o5Fh4jscdYKgXfnNMbcD68Kjw8Z4LcPgHcr4ZyQYrI="
|
||||
[mod."github.com/lor00x/goldap"]
|
||||
version = "v0.0.0-20180618054307-a546dffdd1a3"
|
||||
hash = "sha256-wE3bDMJqd+drbrYK0QPF3GMQOzgB8u9uN2T0uUX9xow="
|
||||
[mod."github.com/lucasb-eyer/go-colorful"]
|
||||
version = "v1.2.0"
|
||||
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
|
||||
[mod."github.com/lufia/plan9stats"]
|
||||
version = "v0.0.0-20211012122336-39d0f177ccd0"
|
||||
hash = "sha256-thb+rkDx5IeWMgw5/5jgu5gZ+6RjJAUXeMgSkJHhRlA="
|
||||
[mod."github.com/mackerelio/go-osstat"]
|
||||
version = "v0.2.4"
|
||||
hash = "sha256-WW5VbvDedsNRxclUjI/pvlf4vB4VyDKEGlpvcLqiAyo="
|
||||
[mod."github.com/mattn/go-colorable"]
|
||||
version = "v0.1.13"
|
||||
hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8="
|
||||
[mod."github.com/mattn/go-isatty"]
|
||||
version = "v0.0.19"
|
||||
hash = "sha256-wYQqGxeqV3Elkmn26Md8mKZ/viw598R4Ych3vtt72YE="
|
||||
[mod."github.com/mattn/go-runewidth"]
|
||||
version = "v0.0.14"
|
||||
hash = "sha256-O3QdxqAcJgQ+HL1v8oBA4iKBwJ2AlDN+F464027hWMU="
|
||||
[mod."github.com/mholt/acmez"]
|
||||
version = "v1.2.0"
|
||||
hash = "sha256-zfj14WFQr1/AO64gYsbFk4a4T0dsMEs+W3uIa9968/M="
|
||||
[mod."github.com/mholt/archiver"]
|
||||
version = "v3.1.1+incompatible"
|
||||
hash = "sha256-+XCbzKmuqktmYveDdJCNWB8B6Ya8yJM8H7uugYxrhhA="
|
||||
[mod."github.com/microcosm-cc/bluemonday"]
|
||||
version = "v1.0.25"
|
||||
hash = "sha256-/crG5s6cDrJ55nkDBwugLUpY7U+vQuHpCkKm7nnN8Zc="
|
||||
[mod."github.com/miekg/dns"]
|
||||
version = "v1.1.55"
|
||||
hash = "sha256-Jbii9veDSpqF7yIkdrzb/bEUM3wZG41mNEAYV3VEAJo="
|
||||
[mod."github.com/minio/selfupdate"]
|
||||
version = "v0.6.0"
|
||||
hash = "sha256-CupJKkF1MNaOEMBPjfCxF+k/k3yNWXfWShmJfezg3O4="
|
||||
[mod."github.com/mitchellh/go-homedir"]
|
||||
version = "v1.1.0"
|
||||
hash = "sha256-oduBKXHAQG8X6aqLEpqZHs5DOKe84u6WkBwi4W6cv3k="
|
||||
[mod."github.com/modern-go/concurrent"]
|
||||
version = "v0.0.0-20180306012644-bacd9c7ef1dd"
|
||||
hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo="
|
||||
[mod."github.com/modern-go/reflect2"]
|
||||
version = "v1.0.2"
|
||||
hash = "sha256-+W9EIW7okXIXjWEgOaMh58eLvBZ7OshW2EhaIpNLSBU="
|
||||
[mod."github.com/muesli/reflow"]
|
||||
version = "v0.3.0"
|
||||
hash = "sha256-Pou2ybE9SFSZG6YfZLVV1Eyfm+X4FuVpDPLxhpn47Cc="
|
||||
[mod."github.com/muesli/termenv"]
|
||||
version = "v0.15.2"
|
||||
hash = "sha256-Eum/SpyytcNIchANPkG4bYGBgcezLgej7j/+6IhqoMU="
|
||||
[mod."github.com/nwaples/rardecode"]
|
||||
version = "v1.1.3"
|
||||
hash = "sha256-X7Cg0kEygyy6Xw6sxRF9HirgefkH9tn9UPPelxRaAGg="
|
||||
[mod."github.com/olekukonko/tablewriter"]
|
||||
version = "v0.0.5"
|
||||
hash = "sha256-/5i70IkH/qSW5KjGzv8aQNKh9tHoz98tqtL0K2DMFn4="
|
||||
[mod."github.com/pierrec/lz4"]
|
||||
version = "v2.6.1+incompatible"
|
||||
hash = "sha256-5+4i5SN97wG71knAF9eUgEEG5k03HW4wPnAdPd6JSfE="
|
||||
[mod."github.com/pkg/errors"]
|
||||
version = "v0.9.1"
|
||||
hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
|
||||
[mod."github.com/power-devops/perfstat"]
|
||||
version = "v0.0.0-20210106213030-5aafc221ea8c"
|
||||
hash = "sha256-ywykDYuqcMt0TvZOz1l9Z6Z2JMTYQw8cP2fT8AtpmX4="
|
||||
[mod."github.com/projectdiscovery/asnmap"]
|
||||
version = "v1.0.4"
|
||||
hash = "sha256-J5Dn5eDzwj+ApwQ3ibTsMbwCobRAb1Cli+hbf74I9VQ="
|
||||
[mod."github.com/projectdiscovery/blackrock"]
|
||||
version = "v0.0.1"
|
||||
hash = "sha256-E66IuBQ3meaGTVk26YzlUDwdUV4kP7VLhrhLnQShkHA="
|
||||
[mod."github.com/projectdiscovery/cdncheck"]
|
||||
version = "v1.0.9"
|
||||
hash = "sha256-fJngwA9mAYB2awhEhS1gWXhOlmKeLrNV8WQj0r5y7Q0="
|
||||
[mod."github.com/projectdiscovery/clistats"]
|
||||
version = "v0.0.19"
|
||||
hash = "sha256-vW7h0Eqm578jI/REU48rexVXGAeZt7JThRSeFm3gUt4="
|
||||
[mod."github.com/projectdiscovery/dsl"]
|
||||
version = "v0.0.20"
|
||||
hash = "sha256-wkDZVgSU6EK5t6tH+g6EsEaTZ9bDNqIdix3I2MnQXOE="
|
||||
[mod."github.com/projectdiscovery/fastdialer"]
|
||||
version = "v0.0.37"
|
||||
hash = "sha256-XxUFV6yfbH3Qw+Euogk/YFlHDxJtB4AIpOoFDK7poBY="
|
||||
[mod."github.com/projectdiscovery/fasttemplate"]
|
||||
version = "v0.0.2"
|
||||
hash = "sha256-kl0lxr7Zhubs3b8Xgt5DRHVj6XxM/WtEAiVkecy62O4="
|
||||
[mod."github.com/projectdiscovery/freeport"]
|
||||
version = "v0.0.5"
|
||||
hash = "sha256-14FrV/9ImnzdH8Pgl8VmgNhtEoqJtJGMO4QoYHdEZig="
|
||||
[mod."github.com/projectdiscovery/goflags"]
|
||||
version = "v0.1.19"
|
||||
hash = "sha256-x72o/EiV2cTf9BW2XRwDGxW7rYFuXnmVc4MJyjoNvIg="
|
||||
[mod."github.com/projectdiscovery/gologger"]
|
||||
version = "v1.1.11"
|
||||
hash = "sha256-ujoMwz77PRSqwE7Dr+MCm8144trX4le8z3l5yVNhMVs="
|
||||
[mod."github.com/projectdiscovery/gostruct"]
|
||||
version = "v0.0.1"
|
||||
hash = "sha256-OhglrSmIVlNBWkY9WrIQB4SL4P47H/uqX9l+LjNZhSQ="
|
||||
[mod."github.com/projectdiscovery/hmap"]
|
||||
version = "v0.0.16"
|
||||
hash = "sha256-mgnvUmgvTm7S71t5rK87eIxRHXZKsR7dUxAOuputtsE="
|
||||
[mod."github.com/projectdiscovery/httpx"]
|
||||
version = "v1.3.4"
|
||||
hash = "sha256-Ye5xYjMaZamigmumgFzo8f3suXRJMOfJQa1S4OV2Gks="
|
||||
[mod."github.com/projectdiscovery/interactsh"]
|
||||
version = "v1.1.6"
|
||||
hash = "sha256-kkUiuODfQwGesZi5w+t6f2BAIe9PLBDb24ltpbOqzp0="
|
||||
[mod."github.com/projectdiscovery/mapcidr"]
|
||||
version = "v1.1.2"
|
||||
hash = "sha256-MXY4WRzRZ7OwuUxq5pCFgipHNakCB9U0UaNjYA5xnm8="
|
||||
[mod."github.com/projectdiscovery/networkpolicy"]
|
||||
version = "v0.0.6"
|
||||
hash = "sha256-TEuxI6vJly0Sh1vkYhrr+EHZdFNZKOvNaU3q3cNyIlA="
|
||||
[mod."github.com/projectdiscovery/nuclei/v2"]
|
||||
version = "v2.9.14"
|
||||
hash = "sha256-mTx6QCs0sTEHQX9/frJ6J1F+sJgmc4TqeoXR1esuTMY="
|
||||
[mod."github.com/projectdiscovery/ratelimit"]
|
||||
version = "v0.0.9"
|
||||
hash = "sha256-/puvEIORXvDGDzotR0DhQnRXQramZYNtjaxjV0KgrN8="
|
||||
[mod."github.com/projectdiscovery/rawhttp"]
|
||||
version = "v0.1.18"
|
||||
hash = "sha256-RkXxq/MAkPLTPzFvG90JgGtOeH/5oOPhCb42HCBweqs="
|
||||
[mod."github.com/projectdiscovery/rdap"]
|
||||
version = "v0.9.1-0.20221108103045-9865884d1917"
|
||||
hash = "sha256-BEZDRPZPjhkNoyj/8Tk21UM98plLNitZ1W52GktJvMs="
|
||||
[mod."github.com/projectdiscovery/retryabledns"]
|
||||
version = "v1.0.35"
|
||||
hash = "sha256-pGq+ZSETmt10PzBBY7ePnq+JW9YBJa9xq9+r1TmJY1E="
|
||||
[mod."github.com/projectdiscovery/retryablehttp-go"]
|
||||
version = "v1.0.25"
|
||||
hash = "sha256-O2OksMSebG5fyiKlkTqC/draHa4g4ERYwuOmsZLPqec="
|
||||
[mod."github.com/projectdiscovery/sarif"]
|
||||
version = "v0.0.1"
|
||||
hash = "sha256-m1s98hDVLAYbXgB0AEqHktZw2N89QeojqPZ7ConL4OE="
|
||||
[mod."github.com/projectdiscovery/tlsx"]
|
||||
version = "v1.1.4"
|
||||
hash = "sha256-EMTNd5NOvaFbVxv31j3pBU//mWQQpThswCT8bMNx5Qw="
|
||||
[mod."github.com/projectdiscovery/utils"]
|
||||
version = "v0.0.52"
|
||||
hash = "sha256-TOUCrtkO976RqBy6w4mQXJ8n/5klkg9tWuEMHdMooHg="
|
||||
[mod."github.com/projectdiscovery/yamldoc-go"]
|
||||
version = "v1.0.4"
|
||||
hash = "sha256-ufjSaGHdRzyusbg5XKG6NVX/UyrUu2PBvGBl0Bour6I="
|
||||
[mod."github.com/quic-go/quic-go"]
|
||||
version = "v0.37.4"
|
||||
hash = "sha256-EXsOITb0kh48+Wy2bIZyyNeGVuJmiL6xB0mtPOBUY/Y="
|
||||
[mod."github.com/refraction-networking/utls"]
|
||||
version = "v1.5.2"
|
||||
hash = "sha256-QwYwEFkpo82NP4l6n6/+5HXzcFt6bEYqy4jFomushkw="
|
||||
[mod."github.com/remeh/sizedwaitgroup"]
|
||||
version = "v1.0.0"
|
||||
hash = "sha256-CtjNoNeep0TnfkuRN/rc48diAo0jUog1fOz3I/z6jfc="
|
||||
[mod."github.com/rivo/uniseg"]
|
||||
version = "v0.4.4"
|
||||
hash = "sha256-B8tbL9K6ICLdm0lEhs9+h4cpjAfvFtNiFMGvQZmw0bM="
|
||||
[mod."github.com/rocketlaunchr/google-search"]
|
||||
version = "v1.1.6"
|
||||
hash = "sha256-2BMD4RXtrxMKC8AaxyeU/p1i92MvGIQjv4KOA4giXfk="
|
||||
[mod."github.com/rs/xid"]
|
||||
version = "v1.5.0"
|
||||
hash = "sha256-u0QLm2YFMJqEjUhpWcLwfoS9lNHUxc2A79MObsqVbVU="
|
||||
[mod."github.com/saintfish/chardet"]
|
||||
version = "v0.0.0-20230101081208-5e3ef4b5456d"
|
||||
hash = "sha256-JXlHMCbXB8iRQ9wQBGCeTjDSfgaBwUVOpvcjj0iVn5A="
|
||||
[mod."github.com/sashabaranov/go-openai"]
|
||||
version = "v1.14.2"
|
||||
hash = "sha256-dc1SL5n3sOZPL018JDnqM6W/8pTwg7xUtxEnON4v+lM="
|
||||
[mod."github.com/segmentio/ksuid"]
|
||||
version = "v1.0.4"
|
||||
hash = "sha256-50molk1vt8/n4Y+ruayW/EAn9NeeQ8ApmLJQVePhieE="
|
||||
[mod."github.com/shirou/gopsutil/v3"]
|
||||
version = "v3.23.7"
|
||||
hash = "sha256-UppGryc5MO0sY3PuOC4H3hYsSomVTaXhgEprOsNFqe4="
|
||||
[mod."github.com/shoenig/go-m1cpu"]
|
||||
version = "v0.1.6"
|
||||
hash = "sha256-hT+JP30BBllsXosK/lo89HV/uxxPLsUyO3dRaDiLnCg="
|
||||
[mod."github.com/spaolacci/murmur3"]
|
||||
version = "v1.1.0"
|
||||
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
|
||||
[mod."github.com/spf13/cast"]
|
||||
version = "v1.5.1"
|
||||
hash = "sha256-/tQNGGQv+Osp+2jepQaQe6GlncZbqdxzSR82FieiUBU="
|
||||
[mod."github.com/syndtr/goleveldb"]
|
||||
version = "v1.0.0"
|
||||
hash = "sha256-rW7SW6nehede0oMZo4NBatM6Eizbnlb7xYoX/dcDUxA="
|
||||
[mod."github.com/temoto/robotstxt"]
|
||||
version = "v1.1.2"
|
||||
hash = "sha256-/0zXEWCnvefGjU2RNxoyZu15KU6WYe9C4m58kyLU6zo="
|
||||
[mod."github.com/tidwall/btree"]
|
||||
version = "v1.6.0"
|
||||
hash = "sha256-H4S46Yk3tVfOtrEhVWUrF4S1yWYmzU43W80HlzS9rcY="
|
||||
[mod."github.com/tidwall/buntdb"]
|
||||
version = "v1.3.0"
|
||||
hash = "sha256-tXp+wcPYogh/Thubk4baFLpbwrCGVf0URvlBXwGg3eQ="
|
||||
[mod."github.com/tidwall/gjson"]
|
||||
version = "v1.14.4"
|
||||
hash = "sha256-3DS2YNL95wG0qSajgRtIABD32J+oblaKVk8LIw+KSOc="
|
||||
[mod."github.com/tidwall/grect"]
|
||||
version = "v0.1.4"
|
||||
hash = "sha256-iSS8YjTqtmlzK9T3PFXoLx5xF/vC8864yNzGw0KYwKs="
|
||||
[mod."github.com/tidwall/match"]
|
||||
version = "v1.1.1"
|
||||
hash = "sha256-M2klhPId3Q3T3VGkSbOkYl/2nLHnsG+yMbXkPkyrRdg="
|
||||
[mod."github.com/tidwall/pretty"]
|
||||
version = "v1.2.1"
|
||||
hash = "sha256-S0uTDDGD8qr415Ut7QinyXljCp0TkL4zOIrlJ+9OMl8="
|
||||
[mod."github.com/tidwall/rtred"]
|
||||
version = "v0.1.2"
|
||||
hash = "sha256-C4p3rZWRLuNgbfVVPr83PZjbD8rZNN3a3YGQJQJlSQU="
|
||||
[mod."github.com/tidwall/tinyqueue"]
|
||||
version = "v0.1.1"
|
||||
hash = "sha256-vsVVA0dAkYtX/C/pk0nDUiu6kURZrK+rxVBRB4wY78Q="
|
||||
[mod."github.com/tklauser/go-sysconf"]
|
||||
version = "v0.3.11"
|
||||
hash = "sha256-io8s7PJi4OX+wXkCm+v5pKy4yiqA/RE/I4ksy6mKX30="
|
||||
[mod."github.com/tklauser/numcpus"]
|
||||
version = "v0.6.0"
|
||||
hash = "sha256-6jssTsP5L6yVl43tXfqDdgeI+tEkBp3BpiWwKXLTHAM="
|
||||
[mod."github.com/trivago/tgo"]
|
||||
version = "v1.0.7"
|
||||
hash = "sha256-VzCbopX6wKWVWmcr/qnKf4ruMicwyEeNfCEWc0UxoxI="
|
||||
[mod."github.com/ulikunitz/xz"]
|
||||
version = "v0.5.11"
|
||||
hash = "sha256-SUyrjc2wyN3cTGKe5JdBEXjtZC1rJySRxJHVUZ59row="
|
||||
[mod."github.com/ulule/deepcopier"]
|
||||
version = "v0.0.0-20200430083143-45decc6639b6"
|
||||
hash = "sha256-zyn5rHS5bU/4KajCVg+6pex42KVdXLZS8DFqRDUpn0E="
|
||||
[mod."github.com/valyala/bytebufferpool"]
|
||||
version = "v1.0.0"
|
||||
hash = "sha256-I9FPZ3kCNRB+o0dpMwBnwZ35Fj9+ThvITn8a3Jr8mAY="
|
||||
[mod."github.com/valyala/fasttemplate"]
|
||||
version = "v1.2.2"
|
||||
hash = "sha256-gp+lNXE8zjO+qJDM/YbS6V43HFsYP6PKn4ux1qa5lZ0="
|
||||
[mod."github.com/weppos/publicsuffix-go"]
|
||||
version = "v0.30.1-0.20230422193905-8fecedd899db"
|
||||
hash = "sha256-Hw5S8ACINl+z/qZmLhcQcXjrXHzYM9HsqQF91RbDoB4="
|
||||
[mod."github.com/xanzy/go-gitlab"]
|
||||
version = "v0.84.0"
|
||||
hash = "sha256-1Se9LHWGnmvAm0QHrb8Zw2jkyaKH2o3j0wvdMp289IQ="
|
||||
[mod."github.com/xi2/xz"]
|
||||
version = "v0.0.0-20171230120015-48954b6210f8"
|
||||
hash = "sha256-2J4cb9KUnGHn1WZ2+g/S+yiHGLDt6KU0cP3fJpQDGZ0="
|
||||
[mod."github.com/yl2chen/cidranger"]
|
||||
version = "v1.0.2"
|
||||
hash = "sha256-rPZApwakcZ1D3lmZnFds79+TFr9IlYkovTA7o52N9h0="
|
||||
[mod."github.com/ysmood/fetchup"]
|
||||
version = "v0.2.3"
|
||||
hash = "sha256-sJ9PBMJ/PH3Es/ngAJkrxTPNAXr7AFjdsblF67mP2Hc="
|
||||
[mod."github.com/ysmood/goob"]
|
||||
version = "v0.4.0"
|
||||
hash = "sha256-o0yVrxQRbN1dSjBH359VHADzPmkyrYOp7jn1GqIYhvw="
|
||||
[mod."github.com/ysmood/got"]
|
||||
version = "v0.34.1"
|
||||
hash = "sha256-dCLb+1Yt/HAZhfQlVkEQoVG9Uv7iBGSqhxdunoakLTU="
|
||||
[mod."github.com/ysmood/gson"]
|
||||
version = "v0.7.3"
|
||||
hash = "sha256-Dn5cTopPKtKCjQ7G6nlvPW2d7G4c5NfIdLVM9eLgR0E="
|
||||
[mod."github.com/ysmood/leakless"]
|
||||
version = "v0.8.0"
|
||||
hash = "sha256-+D41mvLU29dPR4Lf9iWYq3oATgKHpRnUKahO0hTiCDc="
|
||||
[mod."github.com/yuin/goldmark"]
|
||||
version = "v1.5.4"
|
||||
hash = "sha256-4he5sGi0uj1LogdqvgpvN8b7p6qlKMGuWXRFzh+FK8s="
|
||||
[mod."github.com/yuin/goldmark-emoji"]
|
||||
version = "v1.0.1"
|
||||
hash = "sha256-liYCi6/EYG4obl51CzCaOmXf3fdzrU43J9VBZyHggEo="
|
||||
[mod."github.com/yusufpapurcu/wmi"]
|
||||
version = "v1.2.3"
|
||||
hash = "sha256-HOLI8i58AMWeTotvYtdZessgrLwUG2aiS37eeHgsneY="
|
||||
[mod."github.com/zeebo/blake3"]
|
||||
version = "v0.2.3"
|
||||
hash = "sha256-ZepnzkvOyicTGL078O1F84q0TzBAouJlB5AMmfsiOIg="
|
||||
[mod."github.com/zmap/rc2"]
|
||||
version = "v0.0.0-20190804163417-abaa70531248"
|
||||
hash = "sha256-yMyZfFjcLynxiNXmUdfSfUlWekdtlXV3jGIoJMxMDz4="
|
||||
[mod."github.com/zmap/zcrypto"]
|
||||
version = "v0.0.0-20230422215203-9a665e1e9968"
|
||||
hash = "sha256-nDBTEGDBv764XaC3KEwMtKGim0dEy4cjgo8XwnvyLh4="
|
||||
[mod."go.etcd.io/bbolt"]
|
||||
version = "v1.3.7"
|
||||
hash = "sha256-poZk8tPLDWwW95oCOkTJcQtEvOJTD9UXAZ2TqGJutwk="
|
||||
[mod."go.uber.org/multierr"]
|
||||
version = "v1.11.0"
|
||||
hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0="
|
||||
[mod."go.uber.org/zap"]
|
||||
version = "v1.25.0"
|
||||
hash = "sha256-aU270ds5r37xtfFFDVrvjOTTOv1aZNd7ffvHZJB6VIQ="
|
||||
[mod."goftp.io/server/v2"]
|
||||
version = "v2.0.1"
|
||||
hash = "sha256-lI1UZVC9zQnyarOK6AR3Llw4exPqvNn3BZqwKlAOYbQ="
|
||||
[mod."golang.org/x/crypto"]
|
||||
version = "v0.12.0"
|
||||
hash = "sha256-Wes72EA9ICTG8o0nEYWZk9xjpqlniorFeY6o26GExns="
|
||||
[mod."golang.org/x/exp"]
|
||||
version = "v0.0.0-20230626212559-97b1e661b5df"
|
||||
hash = "sha256-aoesDZqls2sBtDmZ/ZSLzIudLuD8GDtGEEucyiqbCjY="
|
||||
[mod."golang.org/x/mod"]
|
||||
version = "v0.12.0"
|
||||
hash = "sha256-M/oXnzm7odpJdQzEnG6W0pNYtl0uhOM/l7qgfGVpU2M="
|
||||
[mod."golang.org/x/net"]
|
||||
version = "v0.14.0"
|
||||
hash = "sha256-QScKgO7lBWOsd0Y31wLRzFETv3tjqdB/eRQWW5q7aV4="
|
||||
[mod."golang.org/x/oauth2"]
|
||||
version = "v0.11.0"
|
||||
hash = "sha256-ztz1lRVZXq6lTN/q4b4Y+P6L1EkP8ZJuhUbSJ0QvCw4="
|
||||
[mod."golang.org/x/sys"]
|
||||
version = "v0.11.0"
|
||||
hash = "sha256-g/LjhABK2c/u6v7M2aAIrHvZjmx/ikGHkef86775N38="
|
||||
[mod."golang.org/x/text"]
|
||||
version = "v0.12.0"
|
||||
hash = "sha256-aNQaW3EgCK9ehpnBzIAkZX6TmiUU1S175YlJUH7P5Qg="
|
||||
[mod."golang.org/x/time"]
|
||||
version = "v0.3.0"
|
||||
hash = "sha256-/hmc9skIswMYbivxNS7R8A6vCTUF9k2/7tr/ACkcEaM="
|
||||
[mod."golang.org/x/tools"]
|
||||
version = "v0.11.0"
|
||||
hash = "sha256-3fNsrCbUnbI5kwZRTx/olHLxR2DJhfvEQ3x0yeeZ8JY="
|
||||
[mod."google.golang.org/appengine"]
|
||||
version = "v1.6.7"
|
||||
hash = "sha256-zIxGRHiq4QBvRqkrhMGMGCaVL4iM4TtlYpAi/hrivS4="
|
||||
[mod."google.golang.org/protobuf"]
|
||||
version = "v1.31.0"
|
||||
hash = "sha256-UdIk+xRaMfdhVICvKRk1THe3R1VU+lWD8hqoW/y8jT0="
|
||||
[mod."gopkg.in/alecthomas/kingpin.v2"]
|
||||
version = "v2.2.6"
|
||||
hash = "sha256-uViE2kPj7tMrGYVjjdLOl2jFDmmu+3P7GvnZBse2zVY="
|
||||
[mod."gopkg.in/corvus-ch/zbase32.v1"]
|
||||
version = "v1.0.0"
|
||||
hash = "sha256-T6PzD4SJv6ipfCkr8CVHXjmKvYRGcLOypHTa238GGlw="
|
||||
[mod."gopkg.in/djherbis/times.v1"]
|
||||
version = "v1.3.0"
|
||||
hash = "sha256-0ZIFWjtY4KyTPIRjUVIGKMXSXe++6vxBckckluhBYLY="
|
||||
[mod."gopkg.in/yaml.v2"]
|
||||
version = "v2.4.0"
|
||||
hash = "sha256-uVEGglIedjOIGZzHW4YwN1VoRSTK8o0eGZqzd+TNdd0="
|
||||
[mod."gopkg.in/yaml.v3"]
|
||||
version = "v3.0.1"
|
||||
hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU="
|
||||
[mod."moul.io/http2curl"]
|
||||
version = "v1.0.0"
|
||||
hash = "sha256-1ZP4V71g1K3oTvz5nGWUBD5h84hXga/RUQwWTpSnphM="
|
||||
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/projectdiscovery/goflags"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Dirlist string
|
||||
DirMatchCodes string // -mc dirlist: status codes to keep
|
||||
DirFilterCodes string // -fc dirlist: status codes to drop
|
||||
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
|
||||
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
|
||||
Resolvers string // -resolvers dnslist: comma list overriding the bundled pool
|
||||
Debug bool
|
||||
LogDir string
|
||||
NoScan bool
|
||||
Ports string
|
||||
Dorking bool
|
||||
Git bool
|
||||
Whois bool
|
||||
Threads int
|
||||
Nuclei bool
|
||||
JavaScript bool
|
||||
Timeout time.Duration
|
||||
URLs goflags.StringSlice
|
||||
File string
|
||||
ApiMode bool
|
||||
Template string
|
||||
CMS bool
|
||||
Headers bool
|
||||
SecurityHeaders bool
|
||||
CloudStorage bool
|
||||
SubdomainTakeover bool
|
||||
Shodan bool
|
||||
SecurityTrails bool
|
||||
SQL bool
|
||||
LFI bool
|
||||
JWT bool
|
||||
OpenAPI bool
|
||||
Favicon bool
|
||||
CORS bool
|
||||
Redirect bool
|
||||
XSS bool
|
||||
Framework bool
|
||||
Crawl bool
|
||||
CrawlDepth int
|
||||
Passive bool
|
||||
Probe bool
|
||||
SARIF string // path to write a sarif 2.1.0 report to ("" = off)
|
||||
Markdown string // path to write a markdown report to ("" = off)
|
||||
Silent bool // route chrome to stderr, print one finding per line to stdout
|
||||
Diff bool // surface only findings added/removed vs the last snapshot
|
||||
Store string // snapshot dir for diff mode ("" = default state dir)
|
||||
Modules string // Comma-separated list of module IDs to run
|
||||
ModuleTags string // Run modules matching these tags
|
||||
AllModules bool // Run all loaded modules
|
||||
ListModules bool // List available modules and exit
|
||||
Proxy string
|
||||
Header goflags.StringSlice // custom request headers ("Key: Value")
|
||||
Cookie string
|
||||
RateLimit int
|
||||
Notify bool // -notify: ship findings to configured providers
|
||||
NotifySeverity string // -notify-severity: minimum severity to send (info..critical)
|
||||
NotifyConfig string // -notify-config: path to a notify-compatible yaml file
|
||||
}
|
||||
|
||||
// minThreads is the floor for the worker count. Threads feeds wg.Add across the
|
||||
// scanners, so 0 silently runs nothing and a negative value panics with
|
||||
// "negative WaitGroup counter"; clamp the parsed value up to this.
|
||||
const minThreads = 1
|
||||
|
||||
// defaultCrawlDepth bounds how far the spider recurses by default; deep enough
|
||||
// to find linked pages without crawling an entire site.
|
||||
const defaultCrawlDepth = 2
|
||||
|
||||
// defaultNotifySeverity is the floor notify sends at when -notify-severity is
|
||||
// unset: medium drops pure recon/info noise so alerts stay actionable.
|
||||
const defaultNotifySeverity = "medium"
|
||||
|
||||
const (
|
||||
Nil goflags.EnumVariable = iota
|
||||
|
||||
// list sizes
|
||||
Small
|
||||
Medium
|
||||
Large
|
||||
|
||||
// port scan scopes
|
||||
Common
|
||||
Full
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
flagSet.CreateGroup("target", "Targets",
|
||||
flagSet.StringSliceVarP(&settings.URLs, "urls", "u", nil, "List of URLs to check (comma-separated)", goflags.FileCommaSeparatedStringSliceOptions),
|
||||
flagSet.StringVarP(&settings.File, "file", "f", "", "File that includes URLs to check"),
|
||||
)
|
||||
|
||||
listSizes := goflags.AllowdTypes{"small": Small, "medium": Medium, "large": Large, "none": Nil}
|
||||
portScopes := goflags.AllowdTypes{"common": Common, "full": Full, "none": Nil}
|
||||
flagSet.CreateGroup("scans", "Scans",
|
||||
flagSet.EnumVar(&settings.Dirlist, "dirlist", Nil, "Directory fuzzing scan size (small/medium/large)", listSizes),
|
||||
flagSet.StringVar(&settings.DirMatchCodes, "mc", "", "Dirlist: match these status codes (comma list, e.g. 200,301)"),
|
||||
flagSet.StringVar(&settings.DirFilterCodes, "fc", "", "Dirlist: filter out these status codes (comma list)"),
|
||||
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.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),
|
||||
flagSet.StringVar(&settings.Resolvers, "resolvers", "", "Dnslist: DNS resolvers to use (comma list, e.g. 1.1.1.1,8.8.8.8; overrides the bundled pool)"),
|
||||
flagSet.EnumVar(&settings.Ports, "ports", Nil, "Port scanning scope (common/full)", portScopes),
|
||||
flagSet.BoolVar(&settings.Dorking, "dork", false, "Enable Google dorking"),
|
||||
flagSet.BoolVar(&settings.Git, "git", false, "Enable git repository scanning"),
|
||||
flagSet.BoolVar(&settings.Nuclei, "nuclei", false, "Enable scanning using nuclei templates"),
|
||||
flagSet.BoolVar(&settings.NoScan, "noscan", false, "Do not perform base URL (robots.txt, etc) scanning"),
|
||||
flagSet.BoolVar(&settings.Whois, "whois", false, "Enable WHOIS lookup"),
|
||||
flagSet.BoolVar(&settings.JavaScript, "js", false, "Enable JavaScript scans"),
|
||||
flagSet.BoolVar(&settings.CMS, "cms", false, "Enable CMS detection"),
|
||||
flagSet.BoolVar(&settings.Headers, "headers", false, "Enable HTTP Header Analysis"),
|
||||
flagSet.BoolVarP(&settings.SecurityHeaders, "security-headers", "sh", false, "Enable security header analysis (missing/weak headers)"),
|
||||
flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"),
|
||||
flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"),
|
||||
flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"),
|
||||
flagSet.BoolVar(&settings.SecurityTrails, "securitytrails", false, "Enable SecurityTrails domain discovery (requires SECURITYTRAILS_API_KEY env var)"),
|
||||
flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"),
|
||||
flagSet.BoolVar(&settings.LFI, "lfi", false, "Enable LFI (Local File Inclusion) reconnaissance"),
|
||||
flagSet.BoolVar(&settings.JWT, "jwt", false, "Enable JWT discovery + offline weakness analysis"),
|
||||
flagSet.BoolVar(&settings.OpenAPI, "openapi", false, "Enable OpenAPI/Swagger spec exposure probe"),
|
||||
flagSet.BoolVar(&settings.Favicon, "favicon", false, "Enable favicon hash fingerprinting (shodan-style)"),
|
||||
flagSet.BoolVar(&settings.CORS, "cors", false, "Enable CORS misconfiguration probe"),
|
||||
flagSet.BoolVar(&settings.Redirect, "redirect", false, "Enable open redirect probe"),
|
||||
flagSet.BoolVar(&settings.XSS, "xss", false, "Enable reflected XSS probe"),
|
||||
flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"),
|
||||
flagSet.BoolVar(&settings.Crawl, "crawl", false, "Enable web crawling (spider same-host links/scripts/forms)"),
|
||||
flagSet.IntVar(&settings.CrawlDepth, "crawl-depth", defaultCrawlDepth, "Max crawl recursion depth"),
|
||||
flagSet.BoolVar(&settings.Passive, "passive", false, "Enable passive subdomain/url discovery (zero traffic to target)"),
|
||||
flagSet.BoolVar(&settings.Probe, "probe", false, "Probe the target for liveness (status, title, server, redirect chain)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("runtime", "Runtime",
|
||||
flagSet.BoolVarP(&settings.Debug, "debug", "d", false, "Enable debug logging"),
|
||||
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", "", "Load scan settings from a template (preset minimal/recon/full, or a local yaml file)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("http", "HTTP",
|
||||
flagSet.StringVar(&settings.Proxy, "proxy", "", "Proxy for all requests (http/https/socks5 url)"),
|
||||
flagSet.StringSliceVarP(&settings.Header, "header", "H", nil, "Custom header to send (repeatable or comma-separated, \"Key: Value\")", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.StringVar(&settings.Cookie, "cookie", "", "Cookie header to send with every request"),
|
||||
flagSet.IntVar(&settings.RateLimit, "rate-limit", 0, "Max requests per second (0 = unlimited)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("output", "Output",
|
||||
flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"),
|
||||
flagSet.StringVarP(&settings.Markdown, "markdown", "md", "", "Write a markdown report to this file"),
|
||||
flagSet.BoolVar(&settings.Silent, "silent", false, "Plain output: chrome to stderr, one finding per line to stdout (for pipelines)"),
|
||||
flagSet.BoolVar(&settings.Diff, "diff", false, "Diff mode: surface only findings added/removed since the last snapshot of each target"),
|
||||
flagSet.StringVar(&settings.Store, "store", "", "Snapshot directory for -diff (default: log dir, else <user-config>/sif/state)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("notify", "Notify",
|
||||
flagSet.BoolVar(&settings.Notify, "notify", false, "Ship findings to configured providers (slack/discord/telegram/webhook)"),
|
||||
flagSet.StringVar(&settings.NotifySeverity, "notify-severity", defaultNotifySeverity, "Minimum severity to notify on (info/low/medium/high/critical)"),
|
||||
flagSet.StringVar(&settings.NotifyConfig, "notify-config", "", "Path to a notify-compatible yaml config (overrides env vars)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("api", "API",
|
||||
flagSet.BoolVar(&settings.ApiMode, "api", false, "Enable API mode. Only useful for internal usage"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("modules", "Modules",
|
||||
flagSet.StringVarP(&settings.Modules, "modules", "m", "", "Comma-separated list of module IDs to run"),
|
||||
flagSet.StringVarP(&settings.ModuleTags, "module-tags", "mt", "", "Run modules matching these tags"),
|
||||
flagSet.BoolVarP(&settings.AllModules, "all-modules", "am", false, "Run all loaded modules"),
|
||||
flagSet.BoolVarP(&settings.ListModules, "list-modules", "lm", false, "List available modules and exit"),
|
||||
)
|
||||
|
||||
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
|
||||
// negative value can't panic the waitgroup.
|
||||
if settings.Threads < minThreads {
|
||||
settings.Threads = minThreads
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSettingsDefaults(t *testing.T) {
|
||||
settings := &Settings{}
|
||||
|
||||
// noscan should default to false (base scan runs by default)
|
||||
if settings.NoScan != false {
|
||||
t.Errorf("expected NoScan default to be false, got %v", settings.NoScan)
|
||||
}
|
||||
|
||||
// other scan flags should default to false
|
||||
if settings.Dorking != false {
|
||||
t.Errorf("expected Dorking default to be false, got %v", settings.Dorking)
|
||||
}
|
||||
if settings.Git != false {
|
||||
t.Errorf("expected Git default to be false, got %v", settings.Git)
|
||||
}
|
||||
if settings.Nuclei != false {
|
||||
t.Errorf("expected Nuclei default to be false, got %v", settings.Nuclei)
|
||||
}
|
||||
if settings.JavaScript != false {
|
||||
t.Errorf("expected JavaScript default to be false, got %v", settings.JavaScript)
|
||||
}
|
||||
if settings.CMS != false {
|
||||
t.Errorf("expected CMS default to be false, got %v", settings.CMS)
|
||||
}
|
||||
if settings.Headers != false {
|
||||
t.Errorf("expected Headers default to be false, got %v", settings.Headers)
|
||||
}
|
||||
if settings.CloudStorage != false {
|
||||
t.Errorf("expected CloudStorage default to be false, got %v", settings.CloudStorage)
|
||||
}
|
||||
if settings.SubdomainTakeover != false {
|
||||
t.Errorf("expected SubdomainTakeover default to be false, got %v", settings.SubdomainTakeover)
|
||||
}
|
||||
|
||||
// enum settings should default to empty string
|
||||
if settings.Dirlist != "" {
|
||||
t.Errorf("expected Dirlist default to be empty, got %v", settings.Dirlist)
|
||||
}
|
||||
if settings.Dnslist != "" {
|
||||
t.Errorf("expected Dnslist default to be empty, got %v", settings.Dnslist)
|
||||
}
|
||||
if settings.Ports != "" {
|
||||
t.Errorf("expected Ports default to be empty, got %v", settings.Ports)
|
||||
}
|
||||
|
||||
// diff mode is opt-in and its store dir defaults empty (resolved at runtime).
|
||||
if settings.Diff != false {
|
||||
t.Errorf("expected Diff default to be false, got %v", settings.Diff)
|
||||
}
|
||||
if settings.Store != "" {
|
||||
t.Errorf("expected Store default to be empty, got %v", settings.Store)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsNoScanBehavior(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noScan bool
|
||||
shouldBaseScan bool
|
||||
}{
|
||||
{
|
||||
name: "default - base scan should run",
|
||||
noScan: false,
|
||||
shouldBaseScan: true,
|
||||
},
|
||||
{
|
||||
name: "noscan enabled - base scan should not run",
|
||||
noScan: true,
|
||||
shouldBaseScan: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
settings := &Settings{NoScan: tt.noScan}
|
||||
|
||||
// the condition in sif.go is: if !app.settings.NoScan { scan.Scan(...) }
|
||||
shouldRun := !settings.NoScan
|
||||
if shouldRun != tt.shouldBaseScan {
|
||||
t.Errorf("expected shouldBaseScan=%v, got %v", tt.shouldBaseScan, shouldRun)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsTimeoutDefault(t *testing.T) {
|
||||
settings := &Settings{}
|
||||
|
||||
// timeout defaults to zero value, actual default (10s) is set in Parse()
|
||||
if settings.Timeout != 0 {
|
||||
t.Errorf("expected Timeout zero value, got %v", settings.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsThreadsDefault(t *testing.T) {
|
||||
settings := &Settings{}
|
||||
|
||||
// threads defaults to zero value, actual default (10) is set in Parse()
|
||||
if settings.Threads != 0 {
|
||||
t.Errorf("expected Threads zero value, got %v", settings.Threads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsWithValues(t *testing.T) {
|
||||
settings := &Settings{
|
||||
NoScan: true,
|
||||
Dorking: true,
|
||||
Git: true,
|
||||
Nuclei: true,
|
||||
JavaScript: true,
|
||||
CMS: true,
|
||||
Headers: true,
|
||||
CloudStorage: true,
|
||||
SubdomainTakeover: true,
|
||||
Dirlist: "medium",
|
||||
Dnslist: "large",
|
||||
Ports: "common",
|
||||
Timeout: 30 * time.Second,
|
||||
Threads: 20,
|
||||
Debug: true,
|
||||
LogDir: "/tmp/logs",
|
||||
ApiMode: true,
|
||||
}
|
||||
|
||||
if !settings.NoScan {
|
||||
t.Error("expected NoScan to be true")
|
||||
}
|
||||
if !settings.Dorking {
|
||||
t.Error("expected Dorking to be true")
|
||||
}
|
||||
if settings.Dirlist != "medium" {
|
||||
t.Errorf("expected Dirlist 'medium', got '%s'", settings.Dirlist)
|
||||
}
|
||||
if settings.Dnslist != "large" {
|
||||
t.Errorf("expected Dnslist 'large', got '%s'", settings.Dnslist)
|
||||
}
|
||||
if settings.Ports != "common" {
|
||||
t.Errorf("expected Ports 'common', got '%s'", settings.Ports)
|
||||
}
|
||||
if settings.Timeout != 30*time.Second {
|
||||
t.Errorf("expected Timeout 30s, got %v", settings.Timeout)
|
||||
}
|
||||
if settings.Threads != 20 {
|
||||
t.Errorf("expected Threads 20, got %d", settings.Threads)
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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")
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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")
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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
|
||||
@@ -1,4 +0,0 @@
|
||||
# minimal: fast liveness + fingerprint, a handful of benign GETs per target.
|
||||
probe: true
|
||||
headers: true
|
||||
favicon: true
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,270 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
// Package dnsx resolves subdomain candidates against a bundled resolver pool
|
||||
// before anything is probed over http, so the slow/inaccurate path of HTTP-ing
|
||||
// every wordlist entry through the OS resolver is gone. it also fingerprints
|
||||
// wildcard zones (a zone that answers every random label) so a catch-all
|
||||
// nameserver can't flood the caller with phantom subdomains.
|
||||
package dnsx
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
retryabledns "github.com/projectdiscovery/retryabledns"
|
||||
)
|
||||
|
||||
// bundled default resolver pool. anycast cloudflare/google/quad9 - fast, public,
|
||||
// and unlikely to rate-limit a recon sweep. -resolvers overrides this set.
|
||||
const (
|
||||
resolverCloudflare = "1.1.1.1:53"
|
||||
resolverGoogle = "8.8.8.8:53"
|
||||
resolverQuad9 = "9.9.9.9:53"
|
||||
)
|
||||
|
||||
// defaultResolvers is the bundled pool used when the caller passes none.
|
||||
var defaultResolvers = []string{resolverCloudflare, resolverGoogle, resolverQuad9}
|
||||
|
||||
const (
|
||||
// defaultRetries is how many times retryabledns rotates through the pool on a
|
||||
// timeout before giving up on a name. low enough to stay fast on a big list.
|
||||
defaultRetries = 3
|
||||
|
||||
// wildcardProbes is how many random nonexistent labels we resolve to
|
||||
// fingerprint a wildcard zone. more samples make a rotating catch-all (one
|
||||
// that hands back a different ip per query) harder to miss, but each is a
|
||||
// real lookup so this stays small.
|
||||
wildcardProbes = 3
|
||||
|
||||
// randomLabelLen is the length of each random wildcard-probe label. long
|
||||
// enough that a collision with a real host is astronomically unlikely.
|
||||
randomLabelLen = 16
|
||||
)
|
||||
|
||||
// randomLabelAlphabet is the lowercase-alnum set wildcard probe labels draw
|
||||
// from; a valid dns label so the query isn't rejected before it leaves.
|
||||
const randomLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
// defaultDNSPort is appended to any resolver entry given without an explicit
|
||||
// port, so "1.1.1.1" and "1.1.1.1:53" both work on the cli.
|
||||
const defaultDNSPort = "53"
|
||||
|
||||
// ParseResolvers splits a comma list of resolvers into a normalized slice,
|
||||
// appending the default port to bare ips/hosts. an empty or blank input returns
|
||||
// nil so the caller falls back to the bundled pool.
|
||||
func ParseResolvers(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for i := 0; i < len(parts); i++ {
|
||||
entry := strings.TrimSpace(parts[i])
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// a bare ip/host gets the default port; an entry already carrying ":port"
|
||||
// (or a bracketed ipv6 literal) is left as-is.
|
||||
if !strings.Contains(entry, ":") {
|
||||
entry += ":" + defaultDNSPort
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// resolution is the resolved address set for one host. empty Addrs means the
|
||||
// name did not resolve (nxdomain / no records).
|
||||
type resolution struct {
|
||||
Addrs []string
|
||||
}
|
||||
|
||||
// resolved reports whether the name returned any address.
|
||||
func (r resolution) resolved() bool {
|
||||
return len(r.Addrs) > 0
|
||||
}
|
||||
|
||||
// resolverFn is the test seam: every lookup the package makes goes through this
|
||||
// var, so a fake can answer without touching the network. real runs point it at
|
||||
// a retryabledns-backed client via NewResolver.
|
||||
var resolverFn func(host string) (resolution, error)
|
||||
|
||||
// Resolver resolves candidates against a pool and filters wildcard answers. it
|
||||
// is built once per scan and shared across the worker goroutines; the
|
||||
// underlying retryabledns client is safe for concurrent use.
|
||||
type Resolver struct {
|
||||
// wildcardSigs holds the address sets a wildcard zone answers random labels
|
||||
// with. nil/empty means the zone is not wildcard. a candidate whose answer is
|
||||
// covered by one of these is a catch-all hit, not a real host.
|
||||
wildcardSigs []map[string]struct{}
|
||||
}
|
||||
|
||||
// NewResolver wires resolverFn to a retryabledns client over the given pool
|
||||
// (bundled default when resolvers is empty) and returns a Resolver. it does not
|
||||
// fingerprint anything yet - call FingerprintWildcard with the apex first.
|
||||
func NewResolver(resolvers []string) (*Resolver, error) {
|
||||
pool := resolvers
|
||||
if len(pool) == 0 {
|
||||
pool = defaultResolvers
|
||||
}
|
||||
|
||||
client, err := retryabledns.New(pool, defaultRetries)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnsx: build resolver over %v: %w", pool, err)
|
||||
}
|
||||
|
||||
// only install the real client when a test hasn't already injected a fake;
|
||||
// the seam wins so hermetic tests never reach this client.
|
||||
if resolverFn == nil {
|
||||
resolverFn = func(host string) (resolution, error) {
|
||||
data, err := client.Resolve(host)
|
||||
if err != nil {
|
||||
return resolution{}, fmt.Errorf("dnsx: resolve %q: %w", host, err)
|
||||
}
|
||||
return resolution{Addrs: mergeAddrs(data)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &Resolver{}, nil
|
||||
}
|
||||
|
||||
// FingerprintWildcard resolves wildcardProbes random labels under apex. any that
|
||||
// answer mean the zone is a catch-all, so their address sets are recorded as
|
||||
// signatures to filter real candidates against later. a clean zone leaves the
|
||||
// signature list empty and nothing gets filtered.
|
||||
func (r *Resolver) FingerprintWildcard(apex string) error {
|
||||
apex = strings.TrimSuffix(apex, ".")
|
||||
for i := 0; i < wildcardProbes; i++ {
|
||||
label, err := randomLabel(randomLabelLen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dnsx: wildcard probe label: %w", err)
|
||||
}
|
||||
|
||||
res, err := resolverFn(label + "." + apex)
|
||||
if err != nil {
|
||||
// a probe failure (timeout / nxdomain surfaced as error) just means this
|
||||
// sample says "not wildcard"; don't abort the whole fingerprint on it.
|
||||
continue
|
||||
}
|
||||
if res.resolved() {
|
||||
r.wildcardSigs = append(r.wildcardSigs, toSet(res.Addrs))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve looks up host and reports whether it is a real, non-wildcard hit. a
|
||||
// name that doesn't resolve, or whose answer matches a recorded wildcard
|
||||
// signature, returns false so the caller skips probing it.
|
||||
func (r *Resolver) Resolve(host string) (bool, error) {
|
||||
res, err := resolverFn(host)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("dnsx: resolve %q: %w", host, err)
|
||||
}
|
||||
if !res.resolved() {
|
||||
return false, nil
|
||||
}
|
||||
if r.isWildcard(res.Addrs) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isWildcard reports whether addrs is covered by any recorded wildcard
|
||||
// signature. a candidate whose every address appears in a wildcard answer is a
|
||||
// catch-all hit; a host with even one address outside the signature is a real,
|
||||
// distinct record and survives.
|
||||
func (r *Resolver) isWildcard(addrs []string) bool {
|
||||
if len(r.wildcardSigs) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(r.wildcardSigs); i++ {
|
||||
if subset(addrs, r.wildcardSigs[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// mergeAddrs flattens the A and AAAA answers into one sorted, deduped slice so
|
||||
// two equal answers compare equal regardless of record ordering.
|
||||
func mergeAddrs(data *retryabledns.DNSData) []string {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(data.A)+len(data.AAAA))
|
||||
for i := 0; i < len(data.A); i++ {
|
||||
seen[data.A[i]] = struct{}{}
|
||||
}
|
||||
for i := 0; i < len(data.AAAA); i++ {
|
||||
seen[data.AAAA[i]] = struct{}{}
|
||||
}
|
||||
|
||||
addrs := make([]string, 0, len(seen))
|
||||
for addr := range seen {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
sort.Strings(addrs)
|
||||
|
||||
return addrs
|
||||
}
|
||||
|
||||
// toSet turns addrs into a lookup set for subset checks.
|
||||
func toSet(addrs []string) map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(addrs))
|
||||
for i := 0; i < len(addrs); i++ {
|
||||
set[addrs[i]] = struct{}{}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
// subset reports whether every addr is present in sig (and addrs is non-empty);
|
||||
// an empty addrs can't be a wildcard match.
|
||||
func subset(addrs []string, sig map[string]struct{}) bool {
|
||||
if len(addrs) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(addrs); i++ {
|
||||
if _, ok := sig[addrs[i]]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// randomLabel returns a cryptographically-random lowercase-alnum dns label of
|
||||
// length n. crypto/rand (not math/rand) so a target can't predict the probe
|
||||
// labels and special-case them to defeat wildcard detection.
|
||||
func randomLabel(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
alphabetLen := big.NewInt(int64(len(randomLabelAlphabet)))
|
||||
for i := 0; i < n; i++ {
|
||||
idx, err := rand.Int(rand.Reader, alphabetLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dnsx: random index: %w", err)
|
||||
}
|
||||
b.WriteByte(randomLabelAlphabet[idx.Int64()])
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package dnsx
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withFakeResolver swaps resolverFn for fn for the duration of one test, then
|
||||
// restores it - the seam that keeps every case below network-free.
|
||||
func withFakeResolver(t *testing.T, fn func(host string) (resolution, error)) {
|
||||
t.Helper()
|
||||
orig := resolverFn
|
||||
resolverFn = fn
|
||||
t.Cleanup(func() { resolverFn = orig })
|
||||
}
|
||||
|
||||
// newFingerprinted builds a Resolver and runs the wildcard fingerprint against
|
||||
// apex using the already-injected fake; fatal on error.
|
||||
func newFingerprinted(t *testing.T, apex string) *Resolver {
|
||||
t.Helper()
|
||||
r := &Resolver{}
|
||||
if err := r.FingerprintWildcard(apex); err != nil {
|
||||
t.Fatalf("FingerprintWildcard: %v", err)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
const testApex = "example.com"
|
||||
|
||||
// a host that resolves to a real address, in a clean (non-wildcard) zone, is a
|
||||
// genuine hit.
|
||||
func TestResolve_FoundInCleanZone(t *testing.T) {
|
||||
withFakeResolver(t, func(host string) (resolution, error) {
|
||||
// nothing answers a random wildcard probe -> clean zone.
|
||||
if strings.HasSuffix(host, "."+testApex) && host != "www."+testApex {
|
||||
return resolution{}, nil
|
||||
}
|
||||
if host == "www."+testApex {
|
||||
return resolution{Addrs: []string{"93.184.216.34"}}, nil
|
||||
}
|
||||
return resolution{}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
if len(r.wildcardSigs) != 0 {
|
||||
t.Fatalf("clean zone should record no wildcard signatures, got %d", len(r.wildcardSigs))
|
||||
}
|
||||
|
||||
ok, err := r.Resolve("www." + testApex)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("a resolving host in a clean zone should be a hit")
|
||||
}
|
||||
}
|
||||
|
||||
// nxdomain (no addresses) is not a hit, so the caller skips probing it.
|
||||
func TestResolve_NxdomainSkipped(t *testing.T) {
|
||||
withFakeResolver(t, func(string) (resolution, error) {
|
||||
// every name, probes included, returns no records.
|
||||
return resolution{}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
|
||||
ok, err := r.Resolve("ghost." + testApex)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("an nxdomain host must not count as found")
|
||||
}
|
||||
}
|
||||
|
||||
// a wildcard zone answers the random probe labels, so a candidate that resolves
|
||||
// to the same catch-all address is filtered out.
|
||||
func TestResolve_WildcardFiltered(t *testing.T) {
|
||||
const catchAll = "10.0.0.1"
|
||||
withFakeResolver(t, func(string) (resolution, error) {
|
||||
// the zone answers everything - probes and candidates alike - with one ip.
|
||||
return resolution{Addrs: []string{catchAll}}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
if len(r.wildcardSigs) == 0 {
|
||||
t.Fatal("wildcard zone should record at least one signature")
|
||||
}
|
||||
|
||||
ok, err := r.Resolve("anything." + testApex)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("a candidate matching the wildcard answer must be filtered")
|
||||
}
|
||||
}
|
||||
|
||||
// a real host in a wildcard zone that resolves to a distinct address (not the
|
||||
// catch-all) still survives the filter - one address outside the signature is
|
||||
// enough to be a genuine record.
|
||||
func TestResolve_DistinctHostSurvivesWildcard(t *testing.T) {
|
||||
const catchAll = "10.0.0.1"
|
||||
const realHost = "api." + testApex
|
||||
withFakeResolver(t, func(host string) (resolution, error) {
|
||||
if host == realHost {
|
||||
return resolution{Addrs: []string{"203.0.113.7"}}, nil
|
||||
}
|
||||
// everything else (probes + other candidates) hits the catch-all.
|
||||
return resolution{Addrs: []string{catchAll}}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
if len(r.wildcardSigs) == 0 {
|
||||
t.Fatal("wildcard zone should record at least one signature")
|
||||
}
|
||||
|
||||
ok, err := r.Resolve(realHost)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("a host resolving to a distinct address should survive the wildcard filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolvers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"empty falls back to bundled", "", nil},
|
||||
{"blank falls back to bundled", " ", nil},
|
||||
{"bare ips get default port", "1.1.1.1,8.8.8.8", []string{"1.1.1.1:53", "8.8.8.8:53"}},
|
||||
{"explicit port preserved", "9.9.9.9:5353", []string{"9.9.9.9:5353"}},
|
||||
{"whitespace and empties trimmed", " 1.1.1.1 , ,8.8.8.8 ", []string{"1.1.1.1:53", "8.8.8.8:53"}},
|
||||
{"mixed bare and ported", "1.1.1.1,9.9.9.9:5353", []string{"1.1.1.1:53", "9.9.9.9:5353"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ParseResolvers(tt.in); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParseResolvers(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResolver_DefaultsToBundledPool(t *testing.T) {
|
||||
// keep the seam already installed so New doesn't replace it with a real
|
||||
// client; we only assert the constructor accepts an empty override.
|
||||
withFakeResolver(t, func(string) (resolution, error) { return resolution{}, nil })
|
||||
|
||||
r, err := NewResolver(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewResolver(nil): %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("NewResolver returned nil resolver")
|
||||
}
|
||||
}
|
||||
@@ -1,730 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
// Package finding is the one normalization layer between the scan results and
|
||||
// the consumers that don't want to know about ~two dozen result structs: notify
|
||||
// (later) gates and renders on it, diff (later) keys runs off it. Flatten is the
|
||||
// single type-switch; adding a scanner without teaching Flatten about it trips
|
||||
// the guard test in flatten_test.go, on purpose.
|
||||
package finding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
"github.com/dropalldatabases/sif/internal/scan"
|
||||
"github.com/dropalldatabases/sif/internal/scan/frameworks"
|
||||
"github.com/dropalldatabases/sif/internal/scan/js"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
)
|
||||
|
||||
// Finding is the normalized shape every scanner result collapses to. one
|
||||
// Finding is one underlying item (a single header, one cors hit, one nuclei
|
||||
// match) rather than a whole module's blob, so consumers diff and notify at
|
||||
// item granularity.
|
||||
type Finding struct {
|
||||
Target string // the url/host the scan ran against
|
||||
Module string // the ResultType() of the source scanner
|
||||
Severity Severity // ranked severity, SeverityUnknown when the source has none
|
||||
Key string // stable identity for dedup/diff: module + ":" + identifier
|
||||
Title string // short human label
|
||||
Raw string // short evidence string, not the full body
|
||||
}
|
||||
|
||||
// Line renders a finding as one stable, terse, machine-friendly line for the
|
||||
// -silent plain sink: "[severity] target module title". no styling, no color -
|
||||
// a downstream pipe (notify, grep, awk) keys off the bracketed severity and the
|
||||
// fixed field order, so the shape stays frozen. pointer receiver: Finding is
|
||||
// wide enough that copying it per line is wasteful.
|
||||
func (f *Finding) Line() string {
|
||||
return fmt.Sprintf("[%s] %s %s %s", f.Severity, f.Target, f.Module, f.Title)
|
||||
}
|
||||
|
||||
// static per-module severities for results that carry no severity field of
|
||||
// their own. these are the editorial baseline; a scanner that emits its own
|
||||
// severity (cors, xss, nuclei, ...) overrides this on a per-item basis.
|
||||
const (
|
||||
// a live admin panel / takeover / public bucket is high on its own.
|
||||
sevTakeover = SeverityHigh
|
||||
sevPublicS3 = SeverityHigh
|
||||
sevAdminPanel = SeverityHigh
|
||||
// disclosure-grade signals: dberrors, secrets, supabase keys.
|
||||
sevDBError = SeverityMedium
|
||||
sevSecret = SeverityMedium
|
||||
// pure recon/inventory: headers, crawl urls, passive hosts, ports.
|
||||
sevRecon = SeverityInfo
|
||||
)
|
||||
|
||||
// keySep joins the module id and the per-item identifier into a Key. kept as a
|
||||
// const so the diff layer can split on it without re-deriving the separator.
|
||||
const keySep = ":"
|
||||
|
||||
// key builds a stable per-item identity: module:identifier. identifier is
|
||||
// whatever uniquely names the item within its module (a url, a header name, a
|
||||
// subdomain) so the same finding across two runs produces the same Key.
|
||||
func key(module, identifier string) string {
|
||||
return module + keySep + identifier
|
||||
}
|
||||
|
||||
// Flatten normalizes one module's result into zero or more Findings. result is
|
||||
// the raw data carried in a ModuleResult; the type switch covers every scan
|
||||
// result struct. an unrecognized type yields a single SeverityUnknown finding
|
||||
// keyed "module:unhandled" so a new scanner surfaces loudly instead of
|
||||
// vanishing - the guard test asserts this never happens for a known type.
|
||||
func Flatten(target, module string, result any) []Finding {
|
||||
switch r := result.(type) {
|
||||
case *scan.ShodanResult:
|
||||
return flattenShodan(target, r)
|
||||
case *scan.SQLResult:
|
||||
return flattenSQL(target, r)
|
||||
case *scan.LFIResult:
|
||||
return flattenLFI(target, r)
|
||||
case *scan.JWTResult:
|
||||
return flattenJWT(target, r)
|
||||
case *scan.OpenAPIResult:
|
||||
return flattenOpenAPI(target, r)
|
||||
case *scan.FaviconResult:
|
||||
return flattenFavicon(target, r)
|
||||
case *scan.CMSResult:
|
||||
return flattenCMS(target, r)
|
||||
case *scan.SecurityTrailsResult:
|
||||
return flattenSecurityTrails(target, r)
|
||||
case *scan.CORSResult:
|
||||
return flattenCORS(target, r)
|
||||
case *scan.RedirectResult:
|
||||
return flattenRedirect(target, r)
|
||||
case *scan.XSSResult:
|
||||
return flattenXSS(target, r)
|
||||
case *scan.CrawlResult:
|
||||
return flattenCrawl(target, r)
|
||||
case *scan.PassiveResult:
|
||||
return flattenPassive(target, r)
|
||||
case *scan.ProbeResult:
|
||||
return flattenProbe(target, r)
|
||||
case scan.HeaderResults:
|
||||
return flattenHeaders(target, r)
|
||||
case []scan.HeaderResult:
|
||||
// the headers module appends a literal []HeaderResult, not the named
|
||||
// slice type; both reach here so cover both.
|
||||
return flattenHeaders(target, r)
|
||||
case scan.SecurityHeaderResults:
|
||||
return flattenSecurityHeaders(target, r)
|
||||
case []scan.SecurityHeaderResult:
|
||||
return flattenSecurityHeaders(target, r)
|
||||
case scan.DirectoryResults:
|
||||
return flattenDirlist(target, r)
|
||||
case []scan.DirectoryResult:
|
||||
return flattenDirlist(target, r)
|
||||
case scan.CloudStorageResults:
|
||||
return flattenCloudStorage(target, r)
|
||||
case []scan.CloudStorageResult:
|
||||
return flattenCloudStorage(target, r)
|
||||
case scan.DorkResults:
|
||||
return flattenDork(target, r)
|
||||
case []scan.DorkResult:
|
||||
return flattenDork(target, r)
|
||||
case scan.SubdomainTakeoverResults:
|
||||
return flattenTakeover(target, r)
|
||||
case []scan.SubdomainTakeoverResult:
|
||||
return flattenTakeover(target, r)
|
||||
case *frameworks.FrameworkResult:
|
||||
return flattenFramework(target, r)
|
||||
case *js.JavascriptScanResult:
|
||||
return flattenJS(target, r)
|
||||
case *modules.Result:
|
||||
// yaml/builtin modules carry their own module id; honor it over the
|
||||
// passed-in module so per-module findings stay attributed correctly.
|
||||
return flattenModule(target, r)
|
||||
case []output.ResultEvent:
|
||||
return flattenNuclei(target, r)
|
||||
case []string:
|
||||
// dnslist/portscan/git all hand back a bare []string of discovered
|
||||
// items; module disambiguates which inventory it is.
|
||||
return flattenStrings(target, module, r)
|
||||
default:
|
||||
// unknown type: emit a loud placeholder rather than dropping it.
|
||||
return []Finding{{
|
||||
Target: target,
|
||||
Module: module,
|
||||
Severity: SeverityUnknown,
|
||||
Key: key(module, "unhandled"),
|
||||
Title: fmt.Sprintf("unhandled result type %T", result),
|
||||
Raw: fmt.Sprintf("%T", result),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
func flattenShodan(target string, r *scan.ShodanResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
// one host snapshot -> one inventory finding; vulns are the interesting bit
|
||||
// so they bump severity and ride along in the evidence string.
|
||||
sev := sevRecon
|
||||
if len(r.Vulns) > 0 {
|
||||
sev = SeverityHigh
|
||||
}
|
||||
raw := fmt.Sprintf("%d ports", len(r.Ports))
|
||||
if len(r.Vulns) > 0 {
|
||||
raw = fmt.Sprintf("%s, %d vulns", raw, len(r.Vulns))
|
||||
}
|
||||
return []Finding{{
|
||||
Target: target,
|
||||
Module: "shodan",
|
||||
Severity: sev,
|
||||
Key: key("shodan", r.IP),
|
||||
Title: "shodan host " + r.IP,
|
||||
Raw: raw,
|
||||
}}
|
||||
}
|
||||
|
||||
func flattenSQL(target string, r *scan.SQLResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.AdminPanels)+len(r.DatabaseErrors)+len(r.ExposedPorts))
|
||||
for i := 0; i < len(r.AdminPanels); i++ {
|
||||
p := r.AdminPanels[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "sql",
|
||||
Severity: sevAdminPanel,
|
||||
Key: key("sql", "admin:"+p.URL),
|
||||
Title: p.Type + " admin panel",
|
||||
Raw: fmt.Sprintf("%s (%d)", p.URL, p.Status),
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(r.DatabaseErrors); i++ {
|
||||
e := r.DatabaseErrors[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "sql",
|
||||
Severity: sevDBError,
|
||||
Key: key("sql", "dberr:"+e.URL+":"+e.DatabaseType),
|
||||
Title: e.DatabaseType + " error disclosure",
|
||||
Raw: e.ErrorPattern,
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(r.ExposedPorts); i++ {
|
||||
p := r.ExposedPorts[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "sql",
|
||||
Severity: SeverityMedium,
|
||||
Key: key("sql", fmt.Sprintf("port:%d", p)),
|
||||
Title: fmt.Sprintf("exposed db port %d", p),
|
||||
Raw: fmt.Sprintf("%d", p),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenLFI(target string, r *scan.LFIResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.Vulnerabilities))
|
||||
for i := 0; i < len(r.Vulnerabilities); i++ {
|
||||
v := r.Vulnerabilities[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "lfi",
|
||||
Severity: ParseSeverity(v.Severity),
|
||||
Key: key("lfi", v.URL+":"+v.Parameter),
|
||||
Title: "lfi via " + v.Parameter,
|
||||
Raw: v.Evidence,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenJWT(target string, r *scan.JWTResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.Tokens))
|
||||
for i := 0; i < len(r.Tokens); i++ {
|
||||
t := r.Tokens[i]
|
||||
// one finding per weakness, not per token: a token with alg:none and a
|
||||
// weak key is two distinct issues a consumer wants to diff separately.
|
||||
for j := 0; j < len(t.Issues); j++ {
|
||||
iss := t.Issues[j]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "jwt",
|
||||
Severity: ParseSeverity(iss.Severity),
|
||||
Key: key("jwt", t.Source+":"+iss.Kind),
|
||||
Title: "jwt " + iss.Kind,
|
||||
Raw: iss.Detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenOpenAPI(target string, r *scan.OpenAPIResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return []Finding{{
|
||||
Target: target,
|
||||
Module: "openapi",
|
||||
Severity: ParseSeverity(r.Severity),
|
||||
Key: key("openapi", r.SpecURL),
|
||||
Title: "openapi spec exposed",
|
||||
Raw: fmt.Sprintf("%s (%d endpoints)", r.SpecURL, len(r.Endpoints)),
|
||||
}}
|
||||
}
|
||||
|
||||
func flattenFavicon(target string, r *scan.FaviconResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
// a matched fingerprint is a real signal; an unmatched hash is just inventory
|
||||
// (still useful as a shodan pivot, so we keep it at recon).
|
||||
sev := sevRecon
|
||||
title := fmt.Sprintf("favicon hash %d", r.Hash)
|
||||
if r.Tech != "" {
|
||||
sev = SeverityLow
|
||||
title = r.Tech + " (favicon)"
|
||||
}
|
||||
return []Finding{{
|
||||
Target: target,
|
||||
Module: "favicon",
|
||||
Severity: sev,
|
||||
Key: key("favicon", fmt.Sprintf("%d", r.Hash)),
|
||||
Title: title,
|
||||
Raw: r.ShodanQ,
|
||||
}}
|
||||
}
|
||||
|
||||
func flattenCMS(target string, r *scan.CMSResult) []Finding {
|
||||
if r == nil || r.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return []Finding{{
|
||||
Target: target,
|
||||
Module: "cms",
|
||||
Severity: sevRecon,
|
||||
Key: key("cms", r.Name),
|
||||
Title: r.Name + " detected",
|
||||
Raw: strings.TrimSpace(r.Name + " " + r.Version),
|
||||
}}
|
||||
}
|
||||
|
||||
func flattenSecurityTrails(target string, r *scan.SecurityTrailsResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.Subdomains)+len(r.AssociatedDomains))
|
||||
for i := 0; i < len(r.Subdomains); i++ {
|
||||
d := r.Subdomains[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "securitytrails",
|
||||
Severity: sevRecon,
|
||||
Key: key("securitytrails", "sub:"+d),
|
||||
Title: "subdomain " + d,
|
||||
Raw: d,
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(r.AssociatedDomains); i++ {
|
||||
d := r.AssociatedDomains[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "securitytrails",
|
||||
Severity: sevRecon,
|
||||
Key: key("securitytrails", "assoc:"+d),
|
||||
Title: "associated domain " + d,
|
||||
Raw: d,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenCORS(target string, r *scan.CORSResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.Findings))
|
||||
for i := 0; i < len(r.Findings); i++ {
|
||||
f := r.Findings[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "cors",
|
||||
Severity: ParseSeverity(f.Severity),
|
||||
Key: key("cors", f.URL+":"+f.OriginTested),
|
||||
Title: f.Note,
|
||||
Raw: "allow-origin: " + f.AllowOrigin,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenRedirect(target string, r *scan.RedirectResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.Findings))
|
||||
for i := 0; i < len(r.Findings); i++ {
|
||||
f := r.Findings[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "redirect",
|
||||
Severity: ParseSeverity(f.Severity),
|
||||
Key: key("redirect", f.URL+":"+f.Parameter+":"+f.Via),
|
||||
Title: "open redirect via " + f.Parameter,
|
||||
Raw: f.Location,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenXSS(target string, r *scan.XSSResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.Findings))
|
||||
for i := 0; i < len(r.Findings); i++ {
|
||||
f := r.Findings[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "xss",
|
||||
Severity: ParseSeverity(f.Severity),
|
||||
Key: key("xss", f.URL+":"+f.Parameter+":"+f.Context),
|
||||
Title: "reflected xss in " + f.Parameter,
|
||||
Raw: strings.Join(f.SurvivedRaw, " "),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenCrawl(target string, r *scan.CrawlResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.URLs))
|
||||
for i := 0; i < len(r.URLs); i++ {
|
||||
u := r.URLs[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "crawl",
|
||||
Severity: sevRecon,
|
||||
Key: key("crawl", u),
|
||||
Title: "crawled url",
|
||||
Raw: u,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenPassive(target string, r *scan.PassiveResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(r.Subdomains)+len(r.URLs))
|
||||
for i := 0; i < len(r.Subdomains); i++ {
|
||||
s := r.Subdomains[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "passive",
|
||||
Severity: sevRecon,
|
||||
Key: key("passive", "sub:"+s),
|
||||
Title: "passive subdomain " + s,
|
||||
Raw: s,
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(r.URLs); i++ {
|
||||
u := r.URLs[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "passive",
|
||||
Severity: sevRecon,
|
||||
Key: key("passive", "url:"+u),
|
||||
Title: "passive url",
|
||||
Raw: u,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenProbe(target string, r *scan.ProbeResult) []Finding {
|
||||
if r == nil || !r.Alive {
|
||||
// a dead probe isn't a finding, just an absent host.
|
||||
return nil
|
||||
}
|
||||
return []Finding{{
|
||||
Target: target,
|
||||
Module: "probe",
|
||||
Severity: sevRecon,
|
||||
Key: key("probe", r.URL),
|
||||
Title: fmt.Sprintf("alive %d", r.StatusCode),
|
||||
Raw: strings.TrimSpace(fmt.Sprintf("%d %s", r.StatusCode, r.Title)),
|
||||
}}
|
||||
}
|
||||
|
||||
func flattenHeaders(target string, rs []scan.HeaderResult) []Finding {
|
||||
out := make([]Finding, 0, len(rs))
|
||||
for i := 0; i < len(rs); i++ {
|
||||
h := rs[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "headers",
|
||||
Severity: sevRecon,
|
||||
Key: key("headers", h.Name),
|
||||
Title: h.Name,
|
||||
Raw: h.Value,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenSecurityHeaders(target string, rs []scan.SecurityHeaderResult) []Finding {
|
||||
out := make([]Finding, 0, len(rs))
|
||||
for i := 0; i < len(rs); i++ {
|
||||
h := rs[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "security_headers",
|
||||
Severity: ParseSeverity(h.Severity),
|
||||
Key: key("security_headers", h.Header),
|
||||
Title: h.Header,
|
||||
Raw: h.Note,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dirInteresting bounds the "noteworthy" 3xx range for a listed directory; a
|
||||
// redirect (>=300) or anything past it is worth more than a plain 200 hit.
|
||||
const dirRedirectFloor = 300
|
||||
|
||||
func flattenDirlist(target string, rs []scan.DirectoryResult) []Finding {
|
||||
out := make([]Finding, 0, len(rs))
|
||||
for i := 0; i < len(rs); i++ {
|
||||
d := rs[i]
|
||||
sev := sevRecon
|
||||
if d.StatusCode >= dirRedirectFloor {
|
||||
sev = SeverityLow
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "dirlist",
|
||||
Severity: sev,
|
||||
Key: key("dirlist", d.Url),
|
||||
Title: fmt.Sprintf("%s [%d]", d.Url, d.StatusCode),
|
||||
Raw: fmt.Sprintf("status=%d size=%d", d.StatusCode, d.Size),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenCloudStorage(target string, rs []scan.CloudStorageResult) []Finding {
|
||||
out := make([]Finding, 0, len(rs))
|
||||
for i := 0; i < len(rs); i++ {
|
||||
b := rs[i]
|
||||
sev := sevRecon
|
||||
if b.IsPublic {
|
||||
sev = sevPublicS3
|
||||
}
|
||||
title := "bucket " + b.BucketName
|
||||
if b.IsPublic {
|
||||
title = "public bucket " + b.BucketName
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "cloudstorage",
|
||||
Severity: sev,
|
||||
Key: key("cloudstorage", b.BucketName),
|
||||
Title: title,
|
||||
Raw: fmt.Sprintf("public=%t", b.IsPublic),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenDork(target string, rs []scan.DorkResult) []Finding {
|
||||
out := make([]Finding, 0, len(rs))
|
||||
for i := 0; i < len(rs); i++ {
|
||||
d := rs[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "dork",
|
||||
Severity: sevRecon,
|
||||
Key: key("dork", d.Url),
|
||||
Title: "dork hit",
|
||||
Raw: d.Url,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenTakeover(target string, rs []scan.SubdomainTakeoverResult) []Finding {
|
||||
out := make([]Finding, 0, len(rs))
|
||||
for i := 0; i < len(rs); i++ {
|
||||
t := rs[i]
|
||||
// only the vulnerable ones are findings; a safe cname is noise here.
|
||||
if !t.Vulnerable {
|
||||
continue
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "subdomain_takeover",
|
||||
Severity: sevTakeover,
|
||||
Key: key("subdomain_takeover", t.Subdomain),
|
||||
Title: "takeover: " + t.Subdomain,
|
||||
Raw: t.Service,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenFramework(target string, r *frameworks.FrameworkResult) []Finding {
|
||||
if r == nil || r.Name == "" {
|
||||
return nil
|
||||
}
|
||||
// framework risk maps onto severity; an unset risk falls back to recon.
|
||||
sev := ParseSeverity(r.RiskLevel)
|
||||
if sev == SeverityUnknown {
|
||||
sev = sevRecon
|
||||
}
|
||||
raw := strings.TrimSpace(r.Name + " " + r.Version)
|
||||
if len(r.CVEs) > 0 {
|
||||
raw = fmt.Sprintf("%s, %d cves", raw, len(r.CVEs))
|
||||
}
|
||||
return []Finding{{
|
||||
Target: target,
|
||||
Module: "framework",
|
||||
Severity: sev,
|
||||
Key: key("framework", r.Name),
|
||||
Title: r.Name + " detected",
|
||||
Raw: raw,
|
||||
}}
|
||||
}
|
||||
|
||||
func flattenJS(target string, r *js.JavascriptScanResult) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
supabase := r.SupabaseFindings()
|
||||
out := make([]Finding, 0, len(r.SecretMatches)+len(supabase)+len(r.Endpoints)+len(r.FoundEnvironmentVars))
|
||||
for i := 0; i < len(r.SecretMatches); i++ {
|
||||
s := r.SecretMatches[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "js",
|
||||
Severity: sevSecret,
|
||||
Key: key("js", "secret:"+s.Rule+":"+s.Source),
|
||||
Title: "secret: " + s.Rule,
|
||||
Raw: s.Source,
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(supabase); i++ {
|
||||
s := supabase[i]
|
||||
// a non-anon role on an exposed key is the real bug; anon is just recon.
|
||||
sev := sevRecon
|
||||
if s.Role != "" && s.Role != "anon" {
|
||||
sev = SeverityHigh
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "js",
|
||||
Severity: sev,
|
||||
Key: key("js", "supabase:"+s.ProjectId),
|
||||
Title: "supabase project " + s.ProjectId,
|
||||
Raw: fmt.Sprintf("role=%s collections=%d", s.Role, s.Collections),
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(r.Endpoints); i++ {
|
||||
e := r.Endpoints[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "js",
|
||||
Severity: sevRecon,
|
||||
Key: key("js", "endpoint:"+e),
|
||||
Title: "js endpoint",
|
||||
Raw: e,
|
||||
})
|
||||
}
|
||||
// env vars are a map; sort-free since the Key carries the name, and diff
|
||||
// keys on the Key not on iteration order.
|
||||
for name, value := range r.FoundEnvironmentVars {
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "js",
|
||||
Severity: sevSecret,
|
||||
Key: key("js", "env:"+name),
|
||||
Title: "env var " + name,
|
||||
Raw: value,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenModule(target string, r *modules.Result) []Finding {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
module := r.ResultType()
|
||||
out := make([]Finding, 0, len(r.Findings))
|
||||
for i := 0; i < len(r.Findings); i++ {
|
||||
f := r.Findings[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: module,
|
||||
Severity: ParseSeverity(f.Severity),
|
||||
Key: key(module, f.URL),
|
||||
Title: module + " finding",
|
||||
Raw: f.Evidence,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenNuclei(target string, events []output.ResultEvent) []Finding {
|
||||
out := make([]Finding, 0, len(events))
|
||||
for i := 0; i < len(events); i++ {
|
||||
e := events[i]
|
||||
// host is the most reliable per-hit identifier; matched-at sharpens it
|
||||
// when several templates fire on one host.
|
||||
ident := e.TemplateID + ":" + e.Host
|
||||
if e.Matched != "" {
|
||||
ident = e.TemplateID + ":" + e.Matched
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: "nuclei",
|
||||
Severity: ParseSeverity(e.Info.SeverityHolder.Severity.String()),
|
||||
Key: key("nuclei", ident),
|
||||
Title: e.Info.Name,
|
||||
Raw: e.Matched,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenStrings(target, module string, items []string) []Finding {
|
||||
out := make([]Finding, 0, len(items))
|
||||
for i := 0; i < len(items); i++ {
|
||||
v := items[i]
|
||||
out = append(out, Finding{
|
||||
Target: target,
|
||||
Module: module,
|
||||
Severity: sevRecon,
|
||||
Key: key(module, v),
|
||||
Title: module + " item",
|
||||
Raw: v,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package finding
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/modules"
|
||||
"github.com/dropalldatabases/sif/internal/scan"
|
||||
"github.com/dropalldatabases/sif/internal/scan/frameworks"
|
||||
"github.com/dropalldatabases/sif/internal/scan/js"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
)
|
||||
|
||||
// scanResultType mirrors the minimal interface the scan packages implement; the
|
||||
// coverage table below carries a value per ResultType() so a new scanner whose
|
||||
// ResultType isn't represented (or isn't handled by Flatten) trips a failure.
|
||||
type scanResultType interface {
|
||||
ResultType() string
|
||||
}
|
||||
|
||||
// coverageCase is one representative, non-empty instance of a result type plus
|
||||
// its expected module attribution. wantItems is how many findings Flatten must
|
||||
// emit for the populated instance, proving the per-item fan-out works.
|
||||
type coverageCase struct {
|
||||
value any // the result as it reaches Flatten
|
||||
typed scanResultType // same value when it implements ResultType(), else nil
|
||||
module string // module id Flatten should stamp
|
||||
wantItems int // findings the populated instance must produce
|
||||
}
|
||||
|
||||
// coverageCases is the registry the guard checks against. there must be one
|
||||
// entry per distinct ResultType() in the scan tree (plus the raw []string and
|
||||
// nuclei []ResultEvent that flow through the report without a ResultType). add a
|
||||
// scanner without adding it here and TestFlattenCoversEveryResultType fails.
|
||||
func coverageCases() []coverageCase {
|
||||
return []coverageCase{
|
||||
{
|
||||
value: &scan.ShodanResult{IP: "1.2.3.4", Ports: []int{80}, Vulns: []string{"CVE-1"}},
|
||||
typed: &scan.ShodanResult{},
|
||||
module: "shodan",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.SQLResult{
|
||||
AdminPanels: []scan.SQLAdminPanel{{URL: "http://x/pma", Type: "phpMyAdmin", Status: 200}},
|
||||
DatabaseErrors: []scan.SQLDatabaseError{{URL: "http://x", DatabaseType: "mysql", ErrorPattern: "syntax"}},
|
||||
ExposedPorts: []int{3306},
|
||||
},
|
||||
typed: &scan.SQLResult{},
|
||||
module: "sql",
|
||||
wantItems: 3,
|
||||
},
|
||||
{
|
||||
value: &scan.LFIResult{Vulnerabilities: []scan.LFIVulnerability{
|
||||
{URL: "http://x", Parameter: "file", Evidence: "root:x", Severity: "high"},
|
||||
}},
|
||||
typed: &scan.LFIResult{},
|
||||
module: "lfi",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.JWTResult{Tokens: []scan.JWTToken{{
|
||||
Source: "header:Authorization",
|
||||
Alg: "none",
|
||||
Issues: []scan.JWTIssue{
|
||||
{Kind: "alg:none", Severity: "critical", Detail: "no signature"},
|
||||
{Kind: "missing exp", Severity: "medium", Detail: "no expiry"},
|
||||
},
|
||||
}}},
|
||||
typed: &scan.JWTResult{},
|
||||
module: "jwt",
|
||||
wantItems: 2,
|
||||
},
|
||||
{
|
||||
value: &scan.OpenAPIResult{
|
||||
SpecURL: "http://x/openapi.json",
|
||||
Severity: "high",
|
||||
Endpoints: []scan.OpenAPIEndpoint{{Path: "/users", Method: "GET", Unauth: true}},
|
||||
},
|
||||
typed: &scan.OpenAPIResult{},
|
||||
module: "openapi",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.FaviconResult{Hash: 116323821, Tech: "Apache Tomcat", ShodanQ: "http.favicon.hash:116323821"},
|
||||
typed: &scan.FaviconResult{},
|
||||
module: "favicon",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.CMSResult{Name: "WordPress", Version: "6.1"},
|
||||
typed: &scan.CMSResult{},
|
||||
module: "cms",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.SecurityTrailsResult{Domain: "x.com", Subdomains: []string{"a.x.com"}, AssociatedDomains: []string{"y.com"}},
|
||||
typed: &scan.SecurityTrailsResult{},
|
||||
module: "securitytrails",
|
||||
wantItems: 2,
|
||||
},
|
||||
{
|
||||
value: &scan.CORSResult{Findings: []scan.CORSFinding{{URL: "http://x", OriginTested: "null", AllowOrigin: "null", Severity: "medium", Note: "null origin"}}},
|
||||
typed: &scan.CORSResult{},
|
||||
module: "cors",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.RedirectResult{Findings: []scan.RedirectFinding{{URL: "http://x", Parameter: "next", Location: "http://evil", Via: "header", Severity: "medium"}}},
|
||||
typed: &scan.RedirectResult{},
|
||||
module: "redirect",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.XSSResult{Findings: []scan.XSSFinding{{URL: "http://x", Parameter: "q", Context: "html", SurvivedRaw: []string{"<"}, Severity: "high"}}},
|
||||
typed: &scan.XSSResult{},
|
||||
module: "xss",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.CrawlResult{URLs: []string{"http://x/a"}},
|
||||
typed: &scan.CrawlResult{},
|
||||
module: "crawl",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &scan.PassiveResult{Subdomains: []string{"a.x.com"}, URLs: []string{"http://x/old"}},
|
||||
typed: &scan.PassiveResult{},
|
||||
module: "passive",
|
||||
wantItems: 2,
|
||||
},
|
||||
{
|
||||
value: &scan.ProbeResult{URL: "http://x", Alive: true, StatusCode: 200, Title: "home"},
|
||||
typed: &scan.ProbeResult{},
|
||||
module: "probe",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: scan.HeaderResults{{Name: "Server", Value: "nginx"}},
|
||||
typed: scan.HeaderResults{},
|
||||
module: "headers",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: scan.SecurityHeaderResults{{Header: "Content-Security-Policy", Present: false, Severity: "medium", Note: "missing"}},
|
||||
typed: scan.SecurityHeaderResults{},
|
||||
module: "security_headers",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: scan.DirectoryResults{{Url: "http://x/admin", StatusCode: 301, Size: 10, Words: 2}},
|
||||
typed: scan.DirectoryResults{},
|
||||
module: "dirlist",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: scan.CloudStorageResults{{BucketName: "x-assets", IsPublic: true}},
|
||||
typed: scan.CloudStorageResults{},
|
||||
module: "cloudstorage",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: scan.DorkResults{{Url: "http://x/leak", Count: 1}},
|
||||
typed: scan.DorkResults{},
|
||||
module: "dork",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: scan.SubdomainTakeoverResults{{Subdomain: "old.x.com", Vulnerable: true, Service: "GitHub Pages"}},
|
||||
typed: scan.SubdomainTakeoverResults{},
|
||||
module: "subdomain_takeover",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &frameworks.FrameworkResult{Name: "Laravel", Version: "9.0", RiskLevel: "high", CVEs: []string{"CVE-2"}},
|
||||
typed: &frameworks.FrameworkResult{},
|
||||
module: "framework",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
value: &js.JavascriptScanResult{
|
||||
SecretMatches: []js.SecretMatch{{Rule: "aws-key", Match: "AKIA...", Source: "http://x/app.js"}},
|
||||
Endpoints: []string{"/api/v1"},
|
||||
},
|
||||
typed: &js.JavascriptScanResult{},
|
||||
module: "js",
|
||||
wantItems: 2,
|
||||
},
|
||||
{
|
||||
value: &modules.Result{ModuleID: "custom-mod", Target: "http://x", Findings: []modules.Finding{{URL: "http://x", Severity: "low", Evidence: "hit"}}},
|
||||
typed: &modules.Result{ModuleID: "custom-mod"},
|
||||
module: "custom-mod",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
// nuclei results aren't ScanResult-typed; they ride through the report
|
||||
// as a raw []ResultEvent, so cover that shape explicitly.
|
||||
value: []output.ResultEvent{{TemplateID: "t1", Host: "x", Matched: "http://x", Info: model.Info{Name: "n", SeverityHolder: severity.Holder{Severity: severity.High}}}},
|
||||
module: "nuclei",
|
||||
wantItems: 1,
|
||||
},
|
||||
{
|
||||
// dnslist/portscan/git all hand Flatten a bare []string keyed only by
|
||||
// the module argument.
|
||||
value: []string{"sub.x.com"},
|
||||
module: "dnslist",
|
||||
wantItems: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const target = "http://target.example"
|
||||
|
||||
// TestFlattenCoversEveryResultType is the guard: every result type in the
|
||||
// coverage table must flatten into the expected module without hitting the
|
||||
// "unhandled" fallback. a new scanner that skips both the table and Flatten's
|
||||
// switch trips this loudly.
|
||||
func TestFlattenCoversEveryResultType(t *testing.T) {
|
||||
for _, tc := range coverageCases() {
|
||||
findings := Flatten(target, tc.module, tc.value)
|
||||
|
||||
if len(findings) != tc.wantItems {
|
||||
t.Errorf("module %q: got %d findings, want %d", tc.module, len(findings), tc.wantItems)
|
||||
}
|
||||
for i := 0; i < len(findings); i++ {
|
||||
f := findings[i]
|
||||
if strings.HasSuffix(f.Key, keySep+"unhandled") {
|
||||
t.Errorf("module %q: Flatten has no case, fell through to unhandled (key=%q)", tc.module, f.Key)
|
||||
}
|
||||
if f.Target != target {
|
||||
t.Errorf("module %q: target=%q, want %q", tc.module, f.Target, target)
|
||||
}
|
||||
if f.Module != tc.module {
|
||||
t.Errorf("module %q: finding stamped module=%q, want %q", tc.module, f.Module, tc.module)
|
||||
}
|
||||
if f.Key == "" {
|
||||
t.Errorf("module %q: empty Key", tc.module)
|
||||
}
|
||||
if !strings.HasPrefix(f.Key, tc.module+keySep) {
|
||||
t.Errorf("module %q: Key %q not prefixed with module", tc.module, f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryResultTypeIsInCoverageTable cross-checks the table against the actual
|
||||
// ResultType() registry: if a scanner type exists whose ResultType() isn't in
|
||||
// the table, the coverage guard above would never exercise it. enumerate the
|
||||
// known typed entries and assert each ResultType() string is present.
|
||||
func TestEveryResultTypeIsInCoverageTable(t *testing.T) {
|
||||
covered := make(map[string]struct{})
|
||||
for _, tc := range coverageCases() {
|
||||
if tc.typed == nil {
|
||||
continue
|
||||
}
|
||||
covered[tc.typed.ResultType()] = struct{}{}
|
||||
}
|
||||
|
||||
// the full set of ResultType() strings the scan tree exposes. keep this in
|
||||
// lockstep with the ScanResult implementers; a missing entry means the table
|
||||
// (and very likely Flatten) skipped a scanner.
|
||||
want := []string{
|
||||
"shodan", "sql", "lfi", "jwt", "openapi", "favicon", "cms", "securitytrails",
|
||||
"cors", "redirect", "xss", "crawl", "passive", "probe",
|
||||
"headers", "security_headers", "dirlist", "cloudstorage",
|
||||
"dork", "subdomain_takeover", "framework", "js", "custom-mod",
|
||||
}
|
||||
for _, rt := range want {
|
||||
if _, ok := covered[rt]; !ok {
|
||||
t.Errorf("ResultType %q has no entry in coverageCases; Flatten coverage unverified", rt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenStableKeysAndSeverities pins the keys and severities for a couple
|
||||
// of representative items so a refactor that quietly reshuffles them is caught.
|
||||
func TestFlattenStableKeysAndSeverities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
module string
|
||||
wantKey string
|
||||
wantSev Severity
|
||||
}{
|
||||
{
|
||||
name: "cors honors source severity",
|
||||
value: &scan.CORSResult{Findings: []scan.CORSFinding{{URL: "http://x", OriginTested: "null", AllowOrigin: "null", Severity: "high", Note: "n"}}},
|
||||
module: "cors",
|
||||
wantKey: "cors:http://x:null",
|
||||
wantSev: SeverityHigh,
|
||||
},
|
||||
{
|
||||
name: "public bucket is high",
|
||||
value: scan.CloudStorageResults{{BucketName: "b", IsPublic: true}},
|
||||
module: "cloudstorage",
|
||||
wantKey: "cloudstorage:b",
|
||||
wantSev: SeverityHigh,
|
||||
},
|
||||
{
|
||||
name: "header is recon info",
|
||||
value: scan.HeaderResults{{Name: "Server", Value: "nginx"}},
|
||||
module: "headers",
|
||||
wantKey: "headers:Server",
|
||||
wantSev: SeverityInfo,
|
||||
},
|
||||
{
|
||||
name: "vulnerable takeover is high",
|
||||
value: scan.SubdomainTakeoverResults{{Subdomain: "old.x.com", Vulnerable: true, Service: "GitHub Pages"}},
|
||||
module: "subdomain_takeover",
|
||||
wantKey: "subdomain_takeover:old.x.com",
|
||||
wantSev: SeverityHigh,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
findings := Flatten(target, tt.module, tt.value)
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1", len(findings))
|
||||
}
|
||||
f := findings[0]
|
||||
if f.Key != tt.wantKey {
|
||||
t.Errorf("Key = %q, want %q", f.Key, tt.wantKey)
|
||||
}
|
||||
if f.Severity != tt.wantSev {
|
||||
t.Errorf("Severity = %v, want %v", f.Severity, tt.wantSev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenUnhandledTypeIsLoud asserts the fallback fires for a type Flatten
|
||||
// doesn't know - this is what makes the guard above meaningful.
|
||||
func TestFlattenUnhandledTypeIsLoud(t *testing.T) {
|
||||
type bogus struct{}
|
||||
findings := Flatten(target, "mystery", bogus{})
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1 placeholder", len(findings))
|
||||
}
|
||||
if !strings.HasSuffix(findings[0].Key, keySep+"unhandled") {
|
||||
t.Errorf("unhandled type should key on :unhandled, got %q", findings[0].Key)
|
||||
}
|
||||
if findings[0].Severity != SeverityUnknown {
|
||||
t.Errorf("unhandled severity = %v, want SeverityUnknown", findings[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubdomainTakeoverSkipsSafe confirms a non-vulnerable cname produces no
|
||||
// finding; only the real takeover is a finding.
|
||||
func TestSubdomainTakeoverSkipsSafe(t *testing.T) {
|
||||
value := scan.SubdomainTakeoverResults{
|
||||
{Subdomain: "safe.x.com", Vulnerable: false},
|
||||
{Subdomain: "bad.x.com", Vulnerable: true, Service: "Heroku"},
|
||||
}
|
||||
findings := Flatten(target, "subdomain_takeover", value)
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1 (only the vulnerable one)", len(findings))
|
||||
}
|
||||
if findings[0].Key != "subdomain_takeover:bad.x.com" {
|
||||
t.Errorf("Key = %q, want subdomain_takeover:bad.x.com", findings[0].Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeadProbeIsNotAFinding confirms a host that didn't answer yields nothing.
|
||||
func TestDeadProbeIsNotAFinding(t *testing.T) {
|
||||
findings := Flatten(target, "probe", &scan.ProbeResult{URL: "http://x", Alive: false})
|
||||
if len(findings) != 0 {
|
||||
t.Errorf("dead probe produced %d findings, want 0", len(findings))
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package finding
|
||||
|
||||
import "testing"
|
||||
|
||||
// Line is the -silent wire format; its shape is frozen, so pin it.
|
||||
func TestFindingLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
f Finding
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "high severity",
|
||||
f: Finding{Target: "https://x.com", Module: "sql", Severity: SeverityHigh, Title: "admin panel"},
|
||||
want: "[high] https://x.com sql admin panel",
|
||||
},
|
||||
{
|
||||
name: "info recon",
|
||||
f: Finding{Target: "https://y.com", Module: "headers", Severity: SeverityInfo, Title: "Server"},
|
||||
want: "[info] https://y.com headers Server",
|
||||
},
|
||||
{
|
||||
name: "unknown severity",
|
||||
f: Finding{Target: "z.com", Module: "mystery", Severity: SeverityUnknown, Title: "?"},
|
||||
want: "[unknown] z.com mystery ?",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.f.Line(); got != tt.want {
|
||||
t.Errorf("Line() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package finding
|
||||
|
||||
import "strings"
|
||||
|
||||
// Severity is an ordered severity rank shared by every normalized finding.
|
||||
// the order matters: notify gates on a threshold and diff sorts by it, so the
|
||||
// underlying ints have to compare info < low < medium < high < critical.
|
||||
type Severity int
|
||||
|
||||
// severity ranks, lowest to highest. SeverityUnknown sorts below everything so
|
||||
// an unrecognized scanner string never silently outranks a real critical.
|
||||
const (
|
||||
SeverityUnknown Severity = iota
|
||||
SeverityInfo
|
||||
SeverityLow
|
||||
SeverityMedium
|
||||
SeverityHigh
|
||||
SeverityCritical
|
||||
)
|
||||
|
||||
// severityNames maps each rank to its canonical lowercase string. the wire
|
||||
// format scanners emit ("info"/"low"/...) round-trips through this table.
|
||||
var severityNames = map[Severity]string{
|
||||
SeverityUnknown: "unknown",
|
||||
SeverityInfo: "info",
|
||||
SeverityLow: "low",
|
||||
SeverityMedium: "medium",
|
||||
SeverityHigh: "high",
|
||||
SeverityCritical: "critical",
|
||||
}
|
||||
|
||||
// String renders the canonical lowercase name for the rank.
|
||||
func (s Severity) String() string {
|
||||
if name, ok := severityNames[s]; ok {
|
||||
return name
|
||||
}
|
||||
return severityNames[SeverityUnknown]
|
||||
}
|
||||
|
||||
// ParseSeverity maps a scanner's free-form severity string onto a rank. it's
|
||||
// case/space insensitive and folds the common synonyms ("informational",
|
||||
// "warning", "moderate") so the dozen scanners that each picked their own
|
||||
// spelling all land on the same ladder. an empty or unrecognized value is
|
||||
// SeverityUnknown rather than a guess.
|
||||
func ParseSeverity(raw string) Severity {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "critical":
|
||||
return SeverityCritical
|
||||
case "high":
|
||||
return SeverityHigh
|
||||
case "medium", "moderate", "warning":
|
||||
return SeverityMedium
|
||||
case "low":
|
||||
return SeverityLow
|
||||
case "info", "informational", "information", "none":
|
||||
return SeverityInfo
|
||||
default:
|
||||
return SeverityUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// AtLeast reports whether s is at or above threshold; notify uses it to drop
|
||||
// findings below the configured floor.
|
||||
func (s Severity) AtLeast(threshold Severity) bool {
|
||||
return s >= threshold
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package finding
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSeverity(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want Severity
|
||||
}{
|
||||
{"critical", SeverityCritical},
|
||||
{"CRITICAL", SeverityCritical},
|
||||
{" high ", SeverityHigh},
|
||||
{"medium", SeverityMedium},
|
||||
{"moderate", SeverityMedium},
|
||||
{"warning", SeverityMedium},
|
||||
{"low", SeverityLow},
|
||||
{"info", SeverityInfo},
|
||||
{"informational", SeverityInfo},
|
||||
{"none", SeverityInfo},
|
||||
{"", SeverityUnknown},
|
||||
{"bogus", SeverityUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := ParseSeverity(tt.in); got != tt.want {
|
||||
t.Errorf("ParseSeverity(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityOrdering(t *testing.T) {
|
||||
// the ladder must be strictly increasing for AtLeast/sort to behave.
|
||||
ordered := []Severity{
|
||||
SeverityUnknown, SeverityInfo, SeverityLow,
|
||||
SeverityMedium, SeverityHigh, SeverityCritical,
|
||||
}
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if ordered[i-1] >= ordered[i] {
|
||||
t.Errorf("severity ladder not increasing at %d: %v !< %v", i, ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityAtLeast(t *testing.T) {
|
||||
tests := []struct {
|
||||
sev Severity
|
||||
threshold Severity
|
||||
want bool
|
||||
}{
|
||||
{SeverityHigh, SeverityMedium, true},
|
||||
{SeverityMedium, SeverityMedium, true},
|
||||
{SeverityLow, SeverityMedium, false},
|
||||
{SeverityCritical, SeverityInfo, true},
|
||||
{SeverityUnknown, SeverityInfo, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.sev.AtLeast(tt.threshold); got != tt.want {
|
||||
t.Errorf("%v.AtLeast(%v) = %v, want %v", tt.sev, tt.threshold, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityStringRoundTrip(t *testing.T) {
|
||||
// every named rank renders to a string ParseSeverity maps back to the same
|
||||
// rank, so the wire format is lossless for known severities.
|
||||
for _, sev := range []Severity{
|
||||
SeverityInfo, SeverityLow, SeverityMedium, SeverityHigh, SeverityCritical,
|
||||
} {
|
||||
if got := ParseSeverity(sev.String()); got != sev {
|
||||
t.Errorf("round-trip %v -> %q -> %v", sev, sev.String(), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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())
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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)
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
// Package httpx is the shared http layer every scanner talks through, so a
|
||||
// single Configure call wires proxy, custom headers, cookies and rate limiting
|
||||
// into every outbound request without touching scanner signatures.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// allowed proxy schemes
|
||||
const (
|
||||
schemeHTTP = "http"
|
||||
schemeHTTPS = "https"
|
||||
schemeSOCKS5 = "socks5"
|
||||
)
|
||||
|
||||
// a header is "Key: Value"; this is the separator between the two halves.
|
||||
const headerSep = ": "
|
||||
|
||||
// burst lets the limiter absorb a small spike before pacing kicks in; a burst
|
||||
// equal to the per-second rate keeps the cap honest over any one-second window.
|
||||
const limiterBurstPerRate = 1
|
||||
|
||||
// transport pool tuning. go's default transport caps idle conns per host at 2
|
||||
// and reuse only kicks in once a response body is fully drained, so without
|
||||
// these a high thread count just thrashes the dialer instead of pooling.
|
||||
const (
|
||||
// total idle conns kept warm across every host we hit in a run.
|
||||
maxIdleConns = 512
|
||||
// floor for per-host idle conns so a single-target run still pools even
|
||||
// when the thread count is tiny.
|
||||
minIdleConnsPerHost = 8
|
||||
// how long an idle conn lingers before the pool reaps it.
|
||||
idleConnTimeout = 90 * time.Second
|
||||
// keepalive probe interval for live conns; mirrors go's default dialer so
|
||||
// the socks5 branch doesn't silently lose os-level keepalive.
|
||||
dialKeepAlive = 30 * time.Second
|
||||
// dial timeout for the socks5 branch; matches go's default dialer.
|
||||
dialTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// drainCap bounds how much of an unread body DrainClose will copy before
|
||||
// closing; a body larger than this isn't worth slurping just to reuse the
|
||||
// conn, so we cap the read and let the conn be discarded instead.
|
||||
const drainCap = 16 << 10
|
||||
|
||||
// Options carries the runtime knobs that apply to every outbound request.
|
||||
// RateLimit is requests/sec (0 = unlimited); Headers are "Key: Value" strings.
|
||||
type Options struct {
|
||||
Proxy string
|
||||
Headers []string
|
||||
Cookie string
|
||||
UserAgent string
|
||||
RateLimit int
|
||||
// Threads is the scan worker count; it sizes the per-host idle pool so
|
||||
// concurrent workers hitting one target reuse conns instead of dialing fresh.
|
||||
Threads int
|
||||
}
|
||||
|
||||
// configured holds the package-level transport built once by Configure. nil
|
||||
// means Configure was never called, so Client falls back to a plain client.
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
configured http.RoundTripper
|
||||
)
|
||||
|
||||
// Configure builds the shared transport once at startup from opts. Calling it
|
||||
// again replaces the previous configuration.
|
||||
//
|
||||
//nolint:gocritic // signature is the package's stable startup api; called once.
|
||||
func Configure(opts Options) error {
|
||||
base, err := buildTransport(opts.Proxy, opts.Threads)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headers, err := parseHeaders(opts.Headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var limiter *rate.Limiter
|
||||
if opts.RateLimit > 0 {
|
||||
limiter = rate.NewLimiter(rate.Limit(opts.RateLimit), opts.RateLimit*limiterBurstPerRate)
|
||||
}
|
||||
|
||||
rt := &roundTripper{
|
||||
base: base,
|
||||
headers: headers,
|
||||
cookie: opts.Cookie,
|
||||
userAgent: opts.UserAgent,
|
||||
limiter: limiter,
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
configured = rt
|
||||
mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client returns an http client wired to the configured transport. It works
|
||||
// before Configure is ever called (plain transport) so existing code and tests
|
||||
// behave unchanged. A zero timeout means no timeout, matching http.Client.
|
||||
func Client(timeout time.Duration) *http.Client {
|
||||
mu.RLock()
|
||||
rt := configured
|
||||
mu.RUnlock()
|
||||
|
||||
return &http.Client{Timeout: timeout, Transport: rt}
|
||||
}
|
||||
|
||||
// buildTransport clones the default transport, tunes its pool for the worker
|
||||
// count and applies the proxy. An empty proxy leaves the default behavior
|
||||
// (respects HTTP_PROXY env) intact.
|
||||
func buildTransport(proxyURL string, threads int) (*http.Transport, error) {
|
||||
tr, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// unreachable in practice, but never trust an assertion silently.
|
||||
return nil, fmt.Errorf("default transport is not *http.Transport")
|
||||
}
|
||||
transport := tr.Clone()
|
||||
|
||||
// size the idle pool so every worker can keep its conn warm. per-host idle
|
||||
// must clear the thread count or workers past the cap re-dial each request;
|
||||
// MaxConnsPerHost stays 0 (unbounded) so the limiter, not the pool, paces us.
|
||||
transport.MaxIdleConns = maxIdleConns
|
||||
transport.MaxIdleConnsPerHost = idlePerHost(threads)
|
||||
transport.MaxConnsPerHost = 0
|
||||
transport.IdleConnTimeout = idleConnTimeout
|
||||
transport.ForceAttemptHTTP2 = true
|
||||
|
||||
if proxyURL == "" {
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse proxy url %q: %w", proxyURL, err)
|
||||
}
|
||||
|
||||
switch parsed.Scheme {
|
||||
case schemeHTTP, schemeHTTPS:
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
case schemeSOCKS5:
|
||||
// socks5 needs a custom dialer. proxy.SOCKS5 takes a forward dialer, so
|
||||
// hand it our own net.Dialer with keepalive set - the default
|
||||
// proxy.Direct has none, which would kill os-level conn pooling.
|
||||
fwd := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
|
||||
dialer, err := proxy.SOCKS5("tcp", parsed.Host, nil, fwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("socks5 proxy %q: %w", proxyURL, err)
|
||||
}
|
||||
ctxDialer, ok := dialer.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("socks5 proxy %q: dialer lacks context support", proxyURL)
|
||||
}
|
||||
transport.DialContext = ctxDialer.DialContext
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme %q (want http/https/socks5)", parsed.Scheme)
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
// idlePerHost picks the per-host idle pool size: at least the worker count so
|
||||
// no worker re-dials, never below the floor so a small thread count still pools.
|
||||
func idlePerHost(threads int) int {
|
||||
if threads < minIdleConnsPerHost {
|
||||
return minIdleConnsPerHost
|
||||
}
|
||||
return threads
|
||||
}
|
||||
|
||||
// DrainClose fully reads (up to drainCap) and closes resp.Body. go only returns
|
||||
// a conn to the idle pool when the body is read to EOF, so a caller that only
|
||||
// closes leaks the conn and forces a fresh dial next time. Call this instead of
|
||||
// a bare resp.Body.Close() to keep the pool warm. Safe on a nil response.
|
||||
func DrainClose(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
// the read result is intentionally ignored: we're discarding the body and
|
||||
// about to close it, so a copy error changes nothing we can act on.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, drainCap))
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// parseHeaders splits each "Key: Value" entry on the first ": ". Entries
|
||||
// without the separator are rejected so a typo fails loud instead of silently.
|
||||
// The returned map is always non-nil so callers can range it unconditionally.
|
||||
func parseHeaders(raw []string) (map[string]string, error) {
|
||||
headers := make(map[string]string, len(raw))
|
||||
for i := 0; i < len(raw); i++ {
|
||||
key, value, ok := strings.Cut(raw[i], headerSep)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid header %q (want \"Key: Value\")", raw[i])
|
||||
}
|
||||
headers[key] = value
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// roundTripper paces and decorates each request before delegating to base.
|
||||
type roundTripper struct {
|
||||
base *http.Transport
|
||||
headers map[string]string
|
||||
cookie string
|
||||
userAgent string
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if rt.limiter != nil {
|
||||
if err := rt.limiter.Wait(req.Context()); err != nil {
|
||||
return nil, fmt.Errorf("rate limiter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// only set what the caller hasn't already; a scanner that explicitly sets a
|
||||
// header (e.g. an api key) must win over the global default.
|
||||
for key, value := range rt.headers {
|
||||
if req.Header.Get(key) == "" {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
if rt.cookie != "" && req.Header.Get("Cookie") == "" {
|
||||
req.Header.Set("Cookie", rt.cookie)
|
||||
}
|
||||
if rt.userAgent != "" && req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", rt.userAgent)
|
||||
}
|
||||
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// resetConfig clears the package-level transport so each test starts clean.
|
||||
func resetConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
mu.Lock()
|
||||
configured = nil
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// captureServer records the headers of the last request it served.
|
||||
func captureServer(t *testing.T, seen *http.Header) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*seen = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func get(t *testing.T, client *http.Client, url string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestClientBeforeConfigure(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
var seen http.Header
|
||||
srv := captureServer(t, &seen)
|
||||
|
||||
// a client must work with no Configure call so existing code is unaffected.
|
||||
get(t, Client(5*time.Second), srv.URL)
|
||||
|
||||
if seen == nil {
|
||||
t.Fatal("request never reached the server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureHeadersAndCookie(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts Options
|
||||
wantKey string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "custom header injected",
|
||||
opts: Options{Headers: []string{"X-Test: sif"}},
|
||||
wantKey: "X-Test",
|
||||
wantValue: "sif",
|
||||
},
|
||||
{
|
||||
name: "cookie injected",
|
||||
opts: Options{Cookie: "session=abc"},
|
||||
wantKey: "Cookie",
|
||||
wantValue: "session=abc",
|
||||
},
|
||||
{
|
||||
name: "user agent injected",
|
||||
opts: Options{UserAgent: "sif-scanner"},
|
||||
wantKey: "User-Agent",
|
||||
wantValue: "sif-scanner",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
if err := Configure(tt.opts); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
|
||||
var seen http.Header
|
||||
srv := captureServer(t, &seen)
|
||||
get(t, Client(5*time.Second), srv.URL)
|
||||
|
||||
if got := seen.Get(tt.wantKey); got != tt.wantValue {
|
||||
t.Errorf("header %q = %q, want %q", tt.wantKey, got, tt.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureHeaderDoesNotOverride(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
if err := Configure(Options{Headers: []string{"X-Test: global"}}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
|
||||
var seen http.Header
|
||||
srv := captureServer(t, &seen)
|
||||
|
||||
// a caller that sets the header explicitly must win over the global default.
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Test", "caller")
|
||||
resp, err := Client(5 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := seen.Get("X-Test"); got != "caller" {
|
||||
t.Errorf("X-Test = %q, want caller (caller value must not be overridden)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureInvalidHeader(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
// a header without ": " should fail loud rather than silently dropping.
|
||||
if err := Configure(Options{Headers: []string{"missing-separator"}}); err == nil {
|
||||
t.Fatal("expected error for malformed header, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureInvalidProxy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
proxy string
|
||||
}{
|
||||
{name: "unsupported scheme", proxy: "ftp://localhost:1080"},
|
||||
{name: "malformed url", proxy: "://nope"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetConfig(t)
|
||||
if err := Configure(Options{Proxy: tt.proxy}); err == nil {
|
||||
t.Errorf("expected error for proxy %q, got nil", tt.proxy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimit(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
const ratePerSec = 5
|
||||
if err := Configure(Options{RateLimit: ratePerSec}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
|
||||
var seen http.Header
|
||||
srv := captureServer(t, &seen)
|
||||
client := Client(5 * time.Second)
|
||||
|
||||
// at 5 req/s the limiter starts with a full burst, so the first batch is
|
||||
// immediate and the next request must wait roughly one tick. fire burst+1
|
||||
// requests and assert the extra one forced a measurable delay.
|
||||
const requests = ratePerSec + 1
|
||||
start := time.Now()
|
||||
for i := 0; i < requests; i++ {
|
||||
get(t, client, srv.URL)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// one request beyond the burst should cost about 1/rate; allow slack but
|
||||
// require a non-trivial delay so an unthrottled client fails this.
|
||||
minDelay := time.Second / ratePerSec / 2
|
||||
if elapsed < minDelay {
|
||||
t.Errorf("expected rate limiting to add >= %v of delay, got %v", minDelay, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitUnlimited(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
// RateLimit 0 means no limiter is installed; requests should fly through.
|
||||
if err := Configure(Options{RateLimit: 0}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
|
||||
mu.RLock()
|
||||
rt, ok := configured.(*roundTripper)
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
t.Fatal("configured transport is not *roundTripper")
|
||||
}
|
||||
if rt.limiter != nil {
|
||||
t.Error("expected no limiter when RateLimit is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdlePerHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
threads int
|
||||
want int
|
||||
}{
|
||||
{name: "below floor clamps up", threads: 1, want: minIdleConnsPerHost},
|
||||
{name: "zero clamps up", threads: 0, want: minIdleConnsPerHost},
|
||||
{name: "at floor", threads: minIdleConnsPerHost, want: minIdleConnsPerHost},
|
||||
{name: "above floor passes through", threads: 64, want: 64},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := idlePerHost(tt.threads); got != tt.want {
|
||||
t.Errorf("idlePerHost(%d) = %d, want %d", tt.threads, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTransportTuning(t *testing.T) {
|
||||
const threads = 32
|
||||
tr, err := buildTransport("", threads)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTransport: %v", err)
|
||||
}
|
||||
|
||||
if tr.MaxIdleConns != maxIdleConns {
|
||||
t.Errorf("MaxIdleConns = %d, want %d", tr.MaxIdleConns, maxIdleConns)
|
||||
}
|
||||
if tr.MaxIdleConnsPerHost != threads {
|
||||
t.Errorf("MaxIdleConnsPerHost = %d, want %d", tr.MaxIdleConnsPerHost, threads)
|
||||
}
|
||||
if tr.MaxConnsPerHost != 0 {
|
||||
t.Errorf("MaxConnsPerHost = %d, want 0 (unbounded)", tr.MaxConnsPerHost)
|
||||
}
|
||||
if tr.IdleConnTimeout != idleConnTimeout {
|
||||
t.Errorf("IdleConnTimeout = %v, want %v", tr.IdleConnTimeout, idleConnTimeout)
|
||||
}
|
||||
if !tr.ForceAttemptHTTP2 {
|
||||
t.Error("ForceAttemptHTTP2 = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainClose(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
// serve a body the caller never reads; DrainClose must drain it so the conn
|
||||
// is eligible for reuse rather than abandoned mid-stream.
|
||||
const body = "sif response body that the caller never reads"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
resp, err := Client(5 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
|
||||
DrainClose(resp)
|
||||
|
||||
// after DrainClose the body is closed; a further read must fail.
|
||||
if _, err := resp.Body.Read(make([]byte, 1)); err == nil {
|
||||
t.Error("expected read after DrainClose to fail on a closed body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainCloseNil(t *testing.T) {
|
||||
// a nil response (e.g. an errored request) must not panic.
|
||||
DrainClose(nil)
|
||||
DrainClose(&http.Response{})
|
||||
}
|
||||
|
||||
// countConns wraps a test server with a ConnState hook that tallies how many
|
||||
// distinct tcp conns the server saw. distinct conns == failed reuse.
|
||||
func countConns(t *testing.T) (*httptest.Server, func() int) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
conns = make(map[net.Conn]struct{})
|
||||
)
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// always write a body so reuse depends on the caller draining it.
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
||||
if state != http.StateNew {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
conns[c] = struct{}{}
|
||||
mu.Unlock()
|
||||
}
|
||||
srv.Start()
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv, func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(conns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportReusesConnections(t *testing.T) {
|
||||
resetConfig(t)
|
||||
|
||||
const (
|
||||
threads = 8
|
||||
requests = 30
|
||||
)
|
||||
if err := Configure(Options{Threads: threads}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
|
||||
srv, distinct := countConns(t)
|
||||
|
||||
// fire N sequential requests through the tuned client, draining each body so
|
||||
// the conn returns to the pool. a working pool serves all of them on one conn.
|
||||
client := Client(5 * time.Second)
|
||||
for i := 0; i < requests; i++ {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request %d: %v", i, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request %d: %v", i, err)
|
||||
}
|
||||
DrainClose(resp)
|
||||
}
|
||||
|
||||
// sequential reuse should land on exactly one conn; allow a tiny margin for
|
||||
// the rare race where a conn is reaped between requests.
|
||||
const maxReuseConns = 2
|
||||
if got := distinct(); got > maxReuseConns {
|
||||
t.Errorf("tuned client opened %d conns for %d requests, want <= %d (pool not reusing)",
|
||||
got, requests, maxReuseConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareClientDoesNotReuse(t *testing.T) {
|
||||
srv, distinct := countConns(t)
|
||||
|
||||
// the control: a bare DefaultTransport client whose caller closes but never
|
||||
// drains the body. go can't reuse a half-read conn, so each request dials
|
||||
// fresh - this is exactly the pre-tuning behavior we're fixing.
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: http.DefaultTransport.(*http.Transport).Clone(),
|
||||
}
|
||||
|
||||
const requests = 30
|
||||
for i := 0; i < requests; i++ {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request %d: %v", i, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request %d: %v", i, err)
|
||||
}
|
||||
// close without draining - the leak that kills reuse.
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// most requests should have dialed a fresh conn. don't demand exactly N (the
|
||||
// scheduler occasionally reuses one), just that it's clearly not pooling.
|
||||
const minDistinct = requests / 2
|
||||
if got := distinct(); got < minDistinct {
|
||||
t.Errorf("bare client opened only %d conns for %d requests, want >= %d "+
|
||||
"(expected near-zero reuse without draining)", got, requests, minDistinct)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConnReuse contrasts the tuned, draining client against a bare client
|
||||
// that closes without draining. the reported conns/op metric is the distinct
|
||||
// tcp conns one pass of `requests` opened - tuned≈1, bare≈requests - so the
|
||||
// README can quote real before/after reuse numbers. the conn map is reset per
|
||||
// iteration so the metric stays a per-pass count and the bare path doesn't
|
||||
// accumulate b.N*requests live sockets and exhaust the ephemeral port range.
|
||||
//
|
||||
// run the bare sub-bench with a bounded -benchtime (e.g. -benchtime 5x): its
|
||||
// whole point is that it can't reuse, so a large b.N floods the local port
|
||||
// space with TIME_WAIT sockets. the tuned sub-bench reuses and runs unbounded.
|
||||
func BenchmarkConnReuse(b *testing.B) {
|
||||
const requests = 50
|
||||
|
||||
run := func(b *testing.B, drain bool, client *http.Client) {
|
||||
b.Helper()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
conns = make(map[net.Conn]struct{})
|
||||
)
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
io.WriteString(w, strings.Repeat("x", 256))
|
||||
}))
|
||||
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
||||
if state != http.StateNew {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
conns[c] = struct{}{}
|
||||
mu.Unlock()
|
||||
}
|
||||
srv.Start()
|
||||
defer srv.Close()
|
||||
|
||||
var lastPass int
|
||||
b.ResetTimer()
|
||||
for n := 0; n < b.N; n++ {
|
||||
mu.Lock()
|
||||
conns = make(map[net.Conn]struct{})
|
||||
mu.Unlock()
|
||||
for i := 0; i < requests; i++ {
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
b.Fatalf("do: %v", err)
|
||||
}
|
||||
if drain {
|
||||
DrainClose(resp)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
// close idle conns between passes so the bare client's per-pass
|
||||
// sockets land in TIME_WAIT and free up before the next pass.
|
||||
client.CloseIdleConnections()
|
||||
mu.Lock()
|
||||
lastPass = len(conns)
|
||||
mu.Unlock()
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
// distinct conns for a single pass of `requests`.
|
||||
b.ReportMetric(float64(lastPass), "conns/op")
|
||||
}
|
||||
|
||||
b.Run("tuned-drain", func(b *testing.B) {
|
||||
resetBench()
|
||||
tr, err := buildTransport("", 8)
|
||||
if err != nil {
|
||||
b.Fatalf("buildTransport: %v", err)
|
||||
}
|
||||
run(b, true, &http.Client{Timeout: 5 * time.Second, Transport: tr})
|
||||
})
|
||||
|
||||
b.Run("bare-noDrain", func(b *testing.B) {
|
||||
run(b, false, &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: http.DefaultTransport.(*http.Transport).Clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// resetBench clears the package transport without a *testing.T for benchmarks.
|
||||
func resetBench() {
|
||||
mu.Lock()
|
||||
configured = nil
|
||||
mu.Unlock()
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Logger manages buffered file writers for efficient logging.
|
||||
// File handles are kept open and writes are buffered to minimize I/O overhead.
|
||||
type Logger struct {
|
||||
mu sync.RWMutex
|
||||
writers map[string]*bufio.Writer
|
||||
files map[string]*os.File
|
||||
}
|
||||
|
||||
var defaultLogger = &Logger{
|
||||
writers: make(map[string]*bufio.Writer),
|
||||
files: make(map[string]*os.File),
|
||||
}
|
||||
|
||||
// Init creates the log directory if it doesn't exist.
|
||||
func Init(dir string) error {
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
if err := os.Mkdir(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getWriter returns a buffered writer for the given file path, creating it if needed.
|
||||
func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
|
||||
l.mu.RLock()
|
||||
w, exists := l.writers[path]
|
||||
l.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return w, nil
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if w, exists = l.writers[path]; exists {
|
||||
return w, nil
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w = bufio.NewWriter(f)
|
||||
l.writers[path] = w
|
||||
l.files[path] = f
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// write writes text to the specified log file using buffered I/O.
|
||||
func (l *Logger) write(path, text string) error {
|
||||
w, err := l.getWriter(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
_, err = w.WriteString(text)
|
||||
l.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Flush flushes all buffered writers to disk.
|
||||
func (l *Logger) Flush() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
for _, w := range l.writers {
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close flushes and closes all open file handles.
|
||||
func (l *Logger) Close() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
for path, w := range l.writers {
|
||||
if err := w.Flush(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if err := l.files[path].Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
l.writers = make(map[string]*bufio.Writer)
|
||||
l.files = make(map[string]*os.File)
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// CreateFile initializes a log file for the given URL and writes the header.
|
||||
func CreateFile(logFiles *[]string, url string, dir string) error {
|
||||
sanitizedURL := url
|
||||
if _, after, ok := strings.Cut(url, "://"); ok {
|
||||
sanitizedURL = after
|
||||
}
|
||||
path := filepath.Join(dir, sanitizedURL+".log")
|
||||
|
||||
header := fmt.Sprintf(" _____________\n__________(_)__ __/\n__ ___/_ /__ /_ \n_(__ )_ / _ __/ \n/____/ /_/ /_/ \n\nsif log file for %s\nhttps://sif.sh\n\n", url)
|
||||
|
||||
if err := defaultLogger.write(path, header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*logFiles = append(*logFiles, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write appends text to the log file for the given URL.
|
||||
func Write(url string, dir string, text string) error {
|
||||
path := filepath.Join(dir, url+".log")
|
||||
return defaultLogger.write(path, text)
|
||||
}
|
||||
|
||||
// WriteHeader writes a section header to the log file.
|
||||
func WriteHeader(url string, dir string, scan string) error {
|
||||
return Write(url, dir, fmt.Sprintf("\n\n--------------\nStarting %s\n--------------\n", scan))
|
||||
}
|
||||
|
||||
// Flush flushes all buffered log data to disk.
|
||||
func Flush() error {
|
||||
return defaultLogger.Flush()
|
||||
}
|
||||
|
||||
// Close flushes and closes all log files. Should be called before program exit.
|
||||
func Close() error {
|
||||
return defaultLogger.Close()
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logDir := filepath.Join(tmpDir, "logs")
|
||||
|
||||
if err := Init(logDir); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
||||
t.Fatal("Init did not create log directory")
|
||||
}
|
||||
|
||||
// Second call should be a no-op
|
||||
if err := Init(logDir); err != nil {
|
||||
t.Fatalf("Init failed on existing directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAndFlush(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Write some data
|
||||
if err := Write("test", tmpDir, "hello world\n"); err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
// Flush to ensure data is written
|
||||
if err := Flush(); err != nil {
|
||||
t.Fatalf("Flush failed: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "test.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "hello world\n" {
|
||||
t.Errorf("Expected 'hello world\\n', got %q", content)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Close()
|
||||
}
|
||||
|
||||
func TestWriteHeader(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
if err := WriteHeader("test", tmpDir, "TestScan"); err != nil {
|
||||
t.Fatalf("WriteHeader failed: %v", err)
|
||||
}
|
||||
|
||||
if err := Flush(); err != nil {
|
||||
t.Fatalf("Flush failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "test.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "Starting TestScan") {
|
||||
t.Errorf("Expected header to contain 'Starting TestScan', got %q", content)
|
||||
}
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
func TestCreateFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
var logFiles []string
|
||||
if err := CreateFile(&logFiles, "https://example.com", tmpDir); err != nil {
|
||||
t.Fatalf("CreateFile failed: %v", err)
|
||||
}
|
||||
|
||||
if err := Flush(); err != nil {
|
||||
t.Fatalf("Flush failed: %v", err)
|
||||
}
|
||||
|
||||
if len(logFiles) != 1 {
|
||||
t.Fatalf("Expected 1 log file, got %d", len(logFiles))
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(logFiles[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "sif log file for https://example.com") {
|
||||
t.Errorf("Expected header content, got %q", content)
|
||||
}
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
func TestConcurrentWrites(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numWriters := 10
|
||||
writesPerWriter := 100
|
||||
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < writesPerWriter; j++ {
|
||||
if err := Write("concurrent", tmpDir, "data\n"); err != nil {
|
||||
t.Errorf("Write failed: %v", err)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if err := Flush(); err != nil {
|
||||
t.Fatalf("Flush failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "concurrent.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Count(string(content), "data\n")
|
||||
expected := numWriters * writesPerWriter
|
||||
if lines != expected {
|
||||
t.Errorf("Expected %d lines, got %d", expected, lines)
|
||||
}
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
if err := Write("close_test", tmpDir, "before close\n"); err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
if err := Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify data was flushed on close
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "close_test.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "before close\n" {
|
||||
t.Errorf("Expected 'before close\\n', got %q", content)
|
||||
}
|
||||
|
||||
// Write after close should create new file handle
|
||||
if err := Write("close_test", tmpDir, "after close\n"); err != nil {
|
||||
t.Fatalf("Write after close failed: %v", err)
|
||||
}
|
||||
|
||||
if err := Close(); err != nil {
|
||||
t.Fatalf("Second close failed: %v", err)
|
||||
}
|
||||
|
||||
content, err = os.ReadFile(filepath.Join(tmpDir, "close_test.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "before close\nafter close\n" {
|
||||
t.Errorf("Expected both writes, got %q", content)
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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)
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
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.
|
||||
const MaxBodySize = 5 * 1024 * 1024
|
||||
|
||||
// ErrUnsupportedModuleType signals an executor for a module type that is not
|
||||
// yet implemented. Returning it (rather than an empty result) keeps callers
|
||||
// from mistaking "not implemented" for "scanned, found nothing".
|
||||
var ErrUnsupportedModuleType = errors.New("unsupported module type")
|
||||
|
||||
// httpRequest represents a generated HTTP request.
|
||||
type httpRequest struct {
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Body string
|
||||
Payload string
|
||||
Original string // Original path template
|
||||
}
|
||||
|
||||
// ExecuteHTTPModule runs an HTTP-based module.
|
||||
func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
|
||||
if def.HTTP == nil {
|
||||
return nil, fmt.Errorf("no HTTP configuration")
|
||||
}
|
||||
|
||||
cfg := def.HTTP
|
||||
result := &Result{
|
||||
ModuleID: def.ID,
|
||||
Target: target,
|
||||
Findings: make([]Finding, 0),
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
client := opts.Client
|
||||
if client == nil {
|
||||
client = &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Generate requests based on paths and payloads
|
||||
requests, err := generateHTTPRequests(target, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine thread count
|
||||
threads := cfg.Threads
|
||||
if threads == 0 {
|
||||
threads = opts.Threads
|
||||
}
|
||||
if threads == 0 {
|
||||
threads = 10
|
||||
}
|
||||
|
||||
// Execute requests concurrently
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
resultsChan := make(chan Finding, len(requests))
|
||||
|
||||
// Limit concurrency
|
||||
sem := make(chan struct{}, threads)
|
||||
|
||||
for _, req := range requests {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return result, ctx.Err()
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(r *httpRequest) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
finding, ok := executeHTTPRequest(ctx, client, r, cfg, def.Info.Severity)
|
||||
if ok {
|
||||
resultsChan <- finding
|
||||
}
|
||||
}(req)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
}()
|
||||
|
||||
for finding := range resultsChan {
|
||||
mu.Lock()
|
||||
result.Findings = append(result.Findings, finding)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generateHTTPRequests creates all requests based on paths and payloads.
|
||||
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, "/")
|
||||
|
||||
method := cfg.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
// If no payloads, just use paths directly
|
||||
if len(cfg.Payloads) == 0 {
|
||||
for _, path := range paths {
|
||||
url := substituteVariables(path, target, "")
|
||||
requests = append(requests, &httpRequest{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Headers: cfg.Headers,
|
||||
Body: cfg.Body,
|
||||
Original: path,
|
||||
})
|
||||
}
|
||||
return requests, nil
|
||||
}
|
||||
|
||||
// pitchfork pairs path[i] with payload[i] and stops at the shorter list;
|
||||
// clusterbomb (default) crosses every path with every payload.
|
||||
if strings.EqualFold(cfg.Attack, "pitchfork") {
|
||||
n := len(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 {
|
||||
requests = append(requests, newPayloadRequest(method, target, path, payload, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
func substituteVariables(template, baseURL, payload string) string {
|
||||
result := template
|
||||
result = strings.ReplaceAll(result, "{{BaseURL}}", baseURL)
|
||||
result = strings.ReplaceAll(result, "{{baseurl}}", baseURL)
|
||||
result = strings.ReplaceAll(result, "{{payload}}", payload)
|
||||
result = strings.ReplaceAll(result, "{{Payload}}", payload)
|
||||
return result
|
||||
}
|
||||
|
||||
// executeHTTPRequest executes a single HTTP request and checks matchers.
|
||||
func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest, cfg *HTTPConfig, severity string) (Finding, bool) {
|
||||
var body io.Reader
|
||||
if r.Body != "" {
|
||||
body = strings.NewReader(r.Body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, body)
|
||||
if err != nil {
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
// Set headers
|
||||
for k, v := range r.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; sif/1.0)")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Finding{}, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body with limit
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodySize))
|
||||
if err != nil {
|
||||
return Finding{}, false
|
||||
}
|
||||
bodyStr := string(respBody)
|
||||
|
||||
// Check matchers
|
||||
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: evidence,
|
||||
Extracted: extracted,
|
||||
}, true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
or := strings.EqualFold(condition, "or")
|
||||
for i := range matchers {
|
||||
matched := checkMatcher(&matchers[i], resp, body)
|
||||
if matchers[i].Negative {
|
||||
matched = !matched
|
||||
}
|
||||
if or && matched {
|
||||
return true
|
||||
}
|
||||
if !or && !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
switch m.Type {
|
||||
case "status":
|
||||
for _, status := range m.Status {
|
||||
if resp.StatusCode == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
case "word":
|
||||
return checkWords(getPart(m.Part, resp, body), m.Words, m.Condition)
|
||||
|
||||
case "regex":
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// getPart extracts the relevant part of the response.
|
||||
func getPart(part string, resp *http.Response, body string) string {
|
||||
switch part {
|
||||
case "header", "headers":
|
||||
var sb strings.Builder
|
||||
for k, v := range resp.Header {
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(strings.Join(v, ", "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
case "body":
|
||||
return body
|
||||
case "all", "":
|
||||
var sb strings.Builder
|
||||
for k, v := range resp.Header {
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(strings.Join(v, ", "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(body)
|
||||
return sb.String()
|
||||
default:
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
// checkWords checks if any/all words are found.
|
||||
func checkWords(content string, words []string, condition string) bool {
|
||||
if condition == "or" {
|
||||
for _, word := range words {
|
||||
if strings.Contains(content, word) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Default to AND
|
||||
for _, word := range words {
|
||||
if !strings.Contains(content, word) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// checkRegex checks if any/all regex patterns match.
|
||||
func checkRegex(content string, patterns []string, condition string) bool {
|
||||
if condition == "or" {
|
||||
for _, pattern := range patterns {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if re.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Default to AND
|
||||
for _, pattern := range patterns {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !re.MatchString(content) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// runExtractors extracts data from the response.
|
||||
func runExtractors(extractors []Extractor, resp *http.Response, body string) map[string]string {
|
||||
if len(extractors) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]string)
|
||||
|
||||
for _, e := range extractors {
|
||||
switch e.Type {
|
||||
case "regex":
|
||||
part := getPart(e.Part, resp, body)
|
||||
for _, pattern := range e.Regex {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
matches := re.FindStringSubmatch(part)
|
||||
if len(matches) > e.Group {
|
||||
result[e.Name] = matches[e.Group]
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// truncateEvidence limits evidence length for storage.
|
||||
func truncateEvidence(s string) string {
|
||||
const maxLen = 500
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ExecuteDNSModule runs a DNS-based module (not yet implemented).
|
||||
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
|
||||
// than reporting an empty (but successful-looking) result.
|
||||
func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
|
||||
return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType)
|
||||
}
|
||||
|
||||
// ExecuteTCPModule runs a TCP-based module (not yet implemented).
|
||||
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
|
||||
// than reporting an empty (but successful-looking) result.
|
||||
func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
|
||||
return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType)
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
)
|
||||
|
||||
const testTimeout = 5 * time.Second
|
||||
|
||||
// TestExecuteHTTPModuleMatchAndExtract drives the full executor against a live
|
||||
// httptest server: a request hits a path, a matcher fires, an extractor captures.
|
||||
func TestExecuteHTTPModuleMatchAndExtract(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/admin" {
|
||||
w.Header().Set("X-App", "demo")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`flag{found-it} session=sess-4242`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-hit",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{Severity: "high"},
|
||||
HTTP: &HTTPConfig{
|
||||
Method: "GET",
|
||||
Paths: []string{"{{BaseURL}}/admin", "{{BaseURL}}/missing"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "status", Status: []int{200}},
|
||||
{Type: "word", Part: "body", Words: []string{"flag{found-it}"}},
|
||||
},
|
||||
Extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`session=(\S+)`}, Group: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// route through the shared httpx client so proxy/-H/-rate-limit would apply.
|
||||
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)
|
||||
}
|
||||
|
||||
// only /admin satisfies status+word, /missing returns 404.
|
||||
if len(result.Findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1", len(result.Findings))
|
||||
}
|
||||
f := result.Findings[0]
|
||||
if f.Severity != "high" {
|
||||
t.Errorf("severity = %q, want high (carried from Info)", f.Severity)
|
||||
}
|
||||
if f.Extracted["session"] != "sess-4242" {
|
||||
t.Errorf("extracted session = %q, want sess-4242", f.Extracted["session"])
|
||||
}
|
||||
if f.URL != srv.URL+"/admin" {
|
||||
t.Errorf("finding url = %q, want %q", f.URL, srv.URL+"/admin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModuleNoMatch confirms a module that matches nothing reports
|
||||
// zero findings without erroring.
|
||||
func TestExecuteHTTPModuleNoMatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("nothing interesting"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-miss",
|
||||
Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"never-present"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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) != 0 {
|
||||
t.Fatalf("got %d findings, want 0", len(result.Findings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModulePayloadExpansion verifies payload templates reach the
|
||||
// server and the matching response is captured.
|
||||
func TestExecuteHTTPModulePayloadExpansion(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// only the "boom" payload triggers the vulnerable branch.
|
||||
if r.URL.Query().Get("q") == "boom" {
|
||||
_, _ = w.Write([]byte("error: sql syntax near boom"))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-payload",
|
||||
Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/search?q={{payload}}"},
|
||||
Payloads: []string{"safe", "boom"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"sql syntax"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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 (only boom payload)", len(result.Findings))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Fatal("expected error when HTTP config is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModuleContextCancel pins the cancellation path. The dispatch
|
||||
// loop selects between ctx.Done() and the concurrency semaphore, so a cancelled
|
||||
// context can either short-circuit with ctx.Err() or let the in-flight request
|
||||
// fail on the dead context. Both are correct: the contract is "never hang, never
|
||||
// invent a finding", which is what we assert here rather than forcing one race
|
||||
// winner (that made this test flaky under -count).
|
||||
func TestExecuteHTTPModuleContextCancel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-cancel",
|
||||
Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/a"},
|
||||
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ExecuteHTTPModule(ctx, srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled or nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// no error means the request was dispatched but failed on the dead context;
|
||||
// either way a cancelled scan must not surface findings.
|
||||
if len(result.Findings) != 0 {
|
||||
t.Fatalf("cancelled scan produced %d findings, want 0", len(result.Findings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteDNSModuleUnsupported pins the current behavior: DNS execution is
|
||||
// not implemented and must signal it via ErrUnsupportedModuleType, not by
|
||||
// quietly returning an empty (successful-looking) result.
|
||||
func TestExecuteDNSModuleUnsupported(t *testing.T) {
|
||||
def := &YAMLModule{ID: "dns-mod", Type: TypeDNS, DNS: &DNSConfig{Type: "A"}}
|
||||
result, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{})
|
||||
if result != nil {
|
||||
t.Errorf("result = %v, want nil for unsupported type", result)
|
||||
}
|
||||
if !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTCPModuleUnsupported(t *testing.T) {
|
||||
def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}}
|
||||
result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
|
||||
if result != nil {
|
||||
t.Errorf("result = %v, want nil for unsupported type", result)
|
||||
}
|
||||
if !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each
|
||||
// type to the right executor and propagates the unsupported-type sentinel.
|
||||
func TestWrapperExecuteRoutesByType(t *testing.T) {
|
||||
t.Run("dns routes to unsupported", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{}}
|
||||
w := newYAMLModuleWrapper(def, "d.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tcp routes to unsupported", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}}
|
||||
w := newYAMLModuleWrapper(def, "t.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing http config errors", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "h", Type: TypeHTTP}
|
||||
w := newYAMLModuleWrapper(def, "h.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
|
||||
t.Fatal("expected error for missing http config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type errors", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "z", Type: ModuleType("bogus")}
|
||||
w := newYAMLModuleWrapper(def, "z.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
|
||||
t.Fatal("expected error for unknown module type")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Errorf("short evidence changed: %q", got)
|
||||
}
|
||||
|
||||
long := make([]byte, 600)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
got := truncateEvidence(string(long))
|
||||
// 500 chars of content plus the ellipsis marker.
|
||||
if len(got) != 503 {
|
||||
t.Errorf("truncated len = %d, want 503", len(got))
|
||||
}
|
||||
if got[len(got)-3:] != "..." {
|
||||
t.Errorf("truncated evidence missing ellipsis: %q", got[len(got)-3:])
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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)
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dropalldatabases/sif/internal/output"
|
||||
)
|
||||
|
||||
// Loader handles module discovery and loading.
|
||||
type Loader struct {
|
||||
builtinDir string
|
||||
userDir string
|
||||
loaded int
|
||||
}
|
||||
|
||||
// NewLoader creates a new module loader.
|
||||
// It automatically detects the built-in modules directory and sets up
|
||||
// the user modules directory based on the operating system.
|
||||
func NewLoader() (*Loader, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
|
||||
// Find built-in modules relative to executable
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
execPath = "."
|
||||
}
|
||||
builtinDir := filepath.Join(filepath.Dir(execPath), "modules")
|
||||
|
||||
// Also check current working directory for development
|
||||
if _, err := os.Stat(builtinDir); os.IsNotExist(err) {
|
||||
builtinDir = "modules"
|
||||
}
|
||||
|
||||
// User modules directory based on OS
|
||||
var userDir string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
userDir = filepath.Join(home, "AppData", "Local", "sif", "modules")
|
||||
default:
|
||||
userDir = filepath.Join(home, ".config", "sif", "modules")
|
||||
}
|
||||
|
||||
return &Loader{
|
||||
builtinDir: builtinDir,
|
||||
userDir: userDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadAll discovers and loads all modules from both built-in
|
||||
// and user directories.
|
||||
func (l *Loader) LoadAll() error {
|
||||
// Load built-in modules first
|
||||
if err := l.loadDir(l.builtinDir, false); err != nil {
|
||||
log.Debugf("No built-in modules found: %v", err)
|
||||
}
|
||||
|
||||
// Load user modules (can override built-in)
|
||||
if err := l.loadDir(l.userDir, true); err != nil {
|
||||
// User dir might not exist, that's OK
|
||||
if !os.IsNotExist(err) {
|
||||
log.Debugf("No user modules found: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if l.loaded > 0 {
|
||||
modLog := output.Module("MODULES")
|
||||
modLog.Info("Loaded %d modules", l.loaded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadDir loads modules from a directory.
|
||||
func (l *Loader) loadDir(dir string, userDefined bool) error {
|
||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch filepath.Ext(path) {
|
||||
case ".yaml", ".yml":
|
||||
if err := l.loadYAML(path); err != nil {
|
||||
log.Warnf("Failed to load module %s: %v", path, err)
|
||||
} else {
|
||||
l.loaded++
|
||||
}
|
||||
case ".go":
|
||||
if err := l.loadScript(path); err != nil {
|
||||
log.Debugf("Failed to load script %s: %v", path, err)
|
||||
} else {
|
||||
l.loaded++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loadYAML loads a YAML module definition.
|
||||
func (l *Loader) loadYAML(path string) error {
|
||||
def, err := ParseYAMLModule(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module := newYAMLModuleWrapper(def, path)
|
||||
Register(module)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadScript loads a Go script module.
|
||||
// Implementation will be provided in script.go.
|
||||
func (l *Loader) loadScript(path string) error {
|
||||
// Will be implemented in script.go
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuiltinDir returns the built-in modules directory path.
|
||||
func (l *Loader) BuiltinDir() string {
|
||||
return l.builtinDir
|
||||
}
|
||||
|
||||
// UserDir returns the user modules directory path.
|
||||
func (l *Loader) UserDir() string {
|
||||
return l.userDir
|
||||
}
|
||||
|
||||
// Loaded returns the number of loaded modules.
|
||||
func (l *Loader) Loaded() int {
|
||||
return l.loaded
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeModule drops a yaml file into a temp dir and returns its path.
|
||||
func writeModule(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write module: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestParseYAMLModuleValid(t *testing.T) {
|
||||
const doc = `id: example-http
|
||||
type: http
|
||||
info:
|
||||
name: Example
|
||||
author: azzie
|
||||
severity: medium
|
||||
description: a test module
|
||||
tags: [test, demo]
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/admin"
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
- type: word
|
||||
part: body
|
||||
words: ["admin"]
|
||||
condition: and
|
||||
extractors:
|
||||
- type: regex
|
||||
name: token
|
||||
part: body
|
||||
regex: ["token=(\\w+)"]
|
||||
group: 1
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := writeModule(t, dir, "ok.yaml", doc)
|
||||
|
||||
def, err := ParseYAMLModule(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseYAMLModule: %v", err)
|
||||
}
|
||||
if def.ID != "example-http" {
|
||||
t.Errorf("id = %q, want example-http", def.ID)
|
||||
}
|
||||
if def.Type != TypeHTTP {
|
||||
t.Errorf("type = %q, want http", def.Type)
|
||||
}
|
||||
if def.Info.Severity != "medium" {
|
||||
t.Errorf("severity = %q, want medium", def.Info.Severity)
|
||||
}
|
||||
if def.HTTP == nil {
|
||||
t.Fatal("http config not parsed")
|
||||
}
|
||||
if len(def.HTTP.Matchers) != 2 {
|
||||
t.Errorf("got %d matchers, want 2", len(def.HTTP.Matchers))
|
||||
}
|
||||
if len(def.HTTP.Extractors) != 1 || def.HTTP.Extractors[0].Group != 1 {
|
||||
t.Errorf("extractor not parsed correctly: %+v", def.HTTP.Extractors)
|
||||
}
|
||||
if len(def.Info.Tags) != 2 {
|
||||
t.Errorf("got %d tags, want 2", len(def.Info.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYAMLModuleErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "missing id",
|
||||
content: "type: http\nhttp:\n paths: [\"/\"]\n",
|
||||
},
|
||||
{
|
||||
name: "missing type",
|
||||
content: "id: no-type\nhttp:\n paths: [\"/\"]\n",
|
||||
},
|
||||
{
|
||||
name: "malformed yaml",
|
||||
content: "id: bad\ntype: http\n paths: [unbalanced\n : nope\n",
|
||||
},
|
||||
{
|
||||
// a scalar where a mapping is expected must fail to unmarshal.
|
||||
name: "type mismatch",
|
||||
content: "id: bad-shape\ntype: http\nhttp: \"should-be-a-map\"\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeModule(t, dir, tt.name+".yaml", tt.content)
|
||||
if _, err := ParseYAMLModule(path); err == nil {
|
||||
t.Fatalf("expected error for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYAMLModuleMissingFile(t *testing.T) {
|
||||
if _, err := ParseYAMLModule(filepath.Join(t.TempDir(), "does-not-exist.yaml")); err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLModuleWrapperInfoAndType(t *testing.T) {
|
||||
def := &YAMLModule{
|
||||
ID: "wrap-test",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{
|
||||
Name: "Wrapped",
|
||||
Author: "azzie",
|
||||
Severity: "low",
|
||||
Description: "desc",
|
||||
Tags: []string{"a", "b"},
|
||||
},
|
||||
}
|
||||
w := newYAMLModuleWrapper(def, "wrap.yaml")
|
||||
|
||||
if w.Type() != TypeHTTP {
|
||||
t.Errorf("Type() = %q, want http", w.Type())
|
||||
}
|
||||
info := w.Info()
|
||||
if info.ID != "wrap-test" || info.Name != "Wrapped" || info.Severity != "low" {
|
||||
t.Errorf("Info() mismatch: %+v", info)
|
||||
}
|
||||
if len(info.Tags) != 2 {
|
||||
t.Errorf("Info().Tags = %v, want 2 entries", info.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoaderLoadAll exercises the directory walk: a valid module registers, a
|
||||
// malformed one is skipped without aborting the walk.
|
||||
func TestLoaderLoadAll(t *testing.T) {
|
||||
Clear()
|
||||
t.Cleanup(Clear)
|
||||
|
||||
dir := t.TempDir()
|
||||
writeModule(t, dir, "good.yaml", "id: good-mod\ntype: http\nhttp:\n paths: [\"{{BaseURL}}/\"]\n matchers:\n - type: status\n status: [200]\n")
|
||||
writeModule(t, dir, "bad.yml", "id: bad-mod\n") // missing type -> skipped
|
||||
writeModule(t, dir, "ignore.txt", "not a module")
|
||||
|
||||
l := &Loader{builtinDir: dir, userDir: filepath.Join(dir, "nonexistent-user")}
|
||||
if err := l.LoadAll(); err != nil {
|
||||
t.Fatalf("LoadAll: %v", err)
|
||||
}
|
||||
|
||||
// only the good module loads; the malformed one is logged and skipped.
|
||||
if l.Loaded() != 1 {
|
||||
t.Errorf("Loaded() = %d, want 1", l.Loaded())
|
||||
}
|
||||
if _, ok := Get("good-mod"); !ok {
|
||||
t.Error("good-mod not registered")
|
||||
}
|
||||
if _, ok := Get("bad-mod"); ok {
|
||||
t.Error("bad-mod should not have registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoaderDirs(t *testing.T) {
|
||||
l, err := NewLoader()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoader: %v", err)
|
||||
}
|
||||
if l.BuiltinDir() == "" {
|
||||
t.Error("BuiltinDir is empty")
|
||||
}
|
||||
if l.UserDir() == "" {
|
||||
t.Error("UserDir is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry exercises the package-level registry: register, get, dedupe by
|
||||
// id, filter by tag and type, count and clear.
|
||||
func TestRegistry(t *testing.T) {
|
||||
Clear()
|
||||
t.Cleanup(Clear)
|
||||
|
||||
http1 := newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web", "cve"}}}, "h1")
|
||||
http2 := newYAMLModuleWrapper(&YAMLModule{ID: "h2", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web"}}}, "h2")
|
||||
dns1 := newYAMLModuleWrapper(&YAMLModule{ID: "d1", Type: TypeDNS, Info: YAMLModuleInfo{Tags: []string{"dns"}}}, "d1")
|
||||
|
||||
Register(http1)
|
||||
Register(http2)
|
||||
Register(dns1)
|
||||
|
||||
if Count() != 3 {
|
||||
t.Fatalf("Count() = %d, want 3", Count())
|
||||
}
|
||||
|
||||
got, ok := Get("h1")
|
||||
if !ok || got.Info().ID != "h1" {
|
||||
t.Errorf("Get(h1) = %v, %v", got, ok)
|
||||
}
|
||||
if _, ok := Get("missing"); ok {
|
||||
t.Error("Get(missing) should report not found")
|
||||
}
|
||||
|
||||
if n := len(ByType(TypeHTTP)); n != 2 {
|
||||
t.Errorf("ByType(http) = %d, want 2", n)
|
||||
}
|
||||
if n := len(ByType(TypeDNS)); n != 1 {
|
||||
t.Errorf("ByType(dns) = %d, want 1", n)
|
||||
}
|
||||
if n := len(ByTag("web")); n != 2 {
|
||||
t.Errorf("ByTag(web) = %d, want 2", n)
|
||||
}
|
||||
if n := len(ByTag("cve")); n != 1 {
|
||||
t.Errorf("ByTag(cve) = %d, want 1", n)
|
||||
}
|
||||
if n := len(ByTag("none")); n != 0 {
|
||||
t.Errorf("ByTag(none) = %d, want 0", n)
|
||||
}
|
||||
if n := len(All()); n != 3 {
|
||||
t.Errorf("All() = %d, want 3", n)
|
||||
}
|
||||
|
||||
// re-registering the same id overwrites rather than duplicating.
|
||||
Register(newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP}, "h1-v2"))
|
||||
if Count() != 3 {
|
||||
t.Errorf("Count() after re-register = %d, want 3", Count())
|
||||
}
|
||||
|
||||
Clear()
|
||||
if Count() != 0 {
|
||||
t.Errorf("Count() after Clear = %d, want 0", Count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestResultType pins the ScanResult interface bridge.
|
||||
func TestResultType(t *testing.T) {
|
||||
r := &Result{ModuleID: "abc"}
|
||||
if r.ResultType() != "abc" {
|
||||
t.Errorf("ResultType() = %q, want abc", r.ResultType())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoaderScriptStubNoop confirms the go-script loader is currently a no-op
|
||||
// that registers nothing and returns no error.
|
||||
func TestLoaderScriptStubNoop(t *testing.T) {
|
||||
l := &Loader{}
|
||||
if err := l.loadScript("anything.go"); err != nil {
|
||||
t.Errorf("loadScript stub returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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))
|
||||
}
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeResponse builds a minimal *http.Response for matcher/extractor tests.
|
||||
// it carries no real socket (Body is http.NoBody), so there is nothing to
|
||||
// close; bodyclose is excluded for test files in .golangci.yml. header drives
|
||||
// the header/all parts without a live server; matchers read the body string
|
||||
// argument, not resp.Body.
|
||||
func fakeResponse(t *testing.T, status int, header http.Header) *http.Response {
|
||||
t.Helper()
|
||||
if header == nil {
|
||||
header = http.Header{}
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: header, Body: http.NoBody}
|
||||
}
|
||||
|
||||
func TestCheckMatcherStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
want []int
|
||||
expect bool
|
||||
}{
|
||||
{name: "single match", status: 200, want: []int{200}, expect: true},
|
||||
{name: "one of many", status: 404, want: []int{200, 301, 404}, expect: true},
|
||||
{name: "no match", status: 500, want: []int{200, 404}, expect: false},
|
||||
{name: "empty status list", status: 200, want: nil, expect: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Matcher{Type: "status", Status: tt.want}
|
||||
resp := fakeResponse(t, tt.status, nil)
|
||||
if got := checkMatcher(m, resp, ""); got != tt.expect {
|
||||
t.Errorf("checkMatcher status = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherWord(t *testing.T) {
|
||||
const body = "welcome admin dashboard"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
words []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all present", words: []string{"admin", "dashboard"}, condition: "and", expect: true},
|
||||
{name: "and one missing", words: []string{"admin", "missing"}, condition: "and", expect: false},
|
||||
{name: "default is and", words: []string{"admin", "missing"}, condition: "", expect: false},
|
||||
{name: "or one present", words: []string{"missing", "admin"}, condition: "or", expect: true},
|
||||
{name: "or none present", words: []string{"missing", "absent"}, condition: "or", expect: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Matcher{Type: "word", Part: "body", Words: tt.words, Condition: tt.condition}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if got := checkMatcher(m, resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatcher word = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherRegex(t *testing.T) {
|
||||
const body = "version 1.2.3 build 99"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all match", patterns: []string{`version \d`, `build \d+`}, condition: "and", expect: true},
|
||||
{name: "and one fails", patterns: []string{`version \d`, `nope\d`}, condition: "and", expect: false},
|
||||
{name: "or one matches", patterns: []string{`nope`, `build \d+`}, condition: "or", expect: true},
|
||||
{name: "or none match", patterns: []string{`nope`, `zilch`}, condition: "or", expect: false},
|
||||
// an invalid pattern under AND must fail closed, not panic.
|
||||
{name: "and invalid pattern fails closed", patterns: []string{`version \d`, `(`}, condition: "and", expect: false},
|
||||
// under OR an invalid pattern is skipped, a later valid one can still hit.
|
||||
{name: "or invalid pattern skipped", patterns: []string{`(`, `build \d+`}, condition: "or", expect: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Matcher{Type: "regex", Part: "body", Regex: tt.patterns, Condition: tt.condition}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if got := checkMatcher(m, resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatcher regex = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherHeaderPart(t *testing.T) {
|
||||
header := http.Header{"X-Powered-By": []string{"PHP/8.1"}}
|
||||
resp := fakeResponse(t, 200, header)
|
||||
|
||||
m := &Matcher{Type: "word", Part: "header", Words: []string{"PHP/8.1"}}
|
||||
if !checkMatcher(m, resp, "body-content") {
|
||||
t.Error("expected header-part word matcher to hit on header value")
|
||||
}
|
||||
|
||||
// the same word lives only in the header, so a body-part matcher must miss.
|
||||
mBody := &Matcher{Type: "word", Part: "body", Words: []string{"PHP/8.1"}}
|
||||
if checkMatcher(mBody, resp, "body-content") {
|
||||
t.Error("body-part matcher should not see header-only value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherUnknownType(t *testing.T) {
|
||||
m := &Matcher{Type: "size", Part: "body"}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if checkMatcher(m, resp, "anything") {
|
||||
t.Error("unknown matcher type should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatchers(t *testing.T) {
|
||||
resp := fakeResponse(t, 200, http.Header{"Server": []string{"nginx"}})
|
||||
const body = "secret token here"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
matchers []Matcher
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "empty matchers never match",
|
||||
matchers: nil,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "all matchers pass (AND across matchers)",
|
||||
matchers: []Matcher{
|
||||
{Type: "status", Status: []int{200}},
|
||||
{Type: "word", Part: "body", Words: []string{"secret"}},
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "one matcher fails breaks AND",
|
||||
matchers: []Matcher{
|
||||
{Type: "status", Status: []int{200}},
|
||||
{Type: "word", Part: "body", Words: []string{"absent"}},
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "negative inverts a non-match into a pass",
|
||||
matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true},
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "negative inverts a match into a fail",
|
||||
matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"secret"}, Negative: true},
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkMatchers(tt.matchers, "", resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatchers = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWords(t *testing.T) {
|
||||
const content = "alpha beta gamma"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
words []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all present", words: []string{"alpha", "gamma"}, condition: "and", expect: true},
|
||||
{name: "and missing", words: []string{"alpha", "delta"}, condition: "and", expect: false},
|
||||
{name: "or present", words: []string{"delta", "beta"}, condition: "or", expect: true},
|
||||
{name: "or absent", words: []string{"delta", "epsilon"}, condition: "or", expect: false},
|
||||
{name: "empty under and matches vacuously", words: nil, condition: "and", expect: true},
|
||||
{name: "empty under or matches nothing", words: nil, condition: "or", expect: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkWords(content, tt.words, tt.condition); got != tt.expect {
|
||||
t.Errorf("checkWords = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRegex(t *testing.T) {
|
||||
const content = "id=42 name=root"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all match", patterns: []string{`id=\d+`, `name=\w+`}, condition: "and", expect: true},
|
||||
{name: "and one fails", patterns: []string{`id=\d+`, `zzz`}, condition: "and", expect: false},
|
||||
{name: "or first matches", patterns: []string{`id=\d+`, `zzz`}, condition: "or", expect: true},
|
||||
{name: "or none match", patterns: []string{`xxx`, `zzz`}, condition: "or", expect: false},
|
||||
{name: "and bad regex fails closed", patterns: []string{`(`}, condition: "and", expect: false},
|
||||
{name: "or bad regex skipped then match", patterns: []string{`(`, `name=\w+`}, condition: "or", expect: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkRegex(content, tt.patterns, tt.condition); got != tt.expect {
|
||||
t.Errorf("checkRegex = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPart(t *testing.T) {
|
||||
header := http.Header{"Server": []string{"nginx"}}
|
||||
resp := fakeResponse(t, 200, header)
|
||||
const body = "page body"
|
||||
|
||||
if got := getPart("body", resp, body); got != body {
|
||||
t.Errorf("getPart body = %q, want %q", got, body)
|
||||
}
|
||||
|
||||
headerPart := getPart("header", resp, body)
|
||||
if !strings.Contains(headerPart, "Server") || !strings.Contains(headerPart, "nginx") {
|
||||
t.Errorf("getPart header = %q, want it to include the header", headerPart)
|
||||
}
|
||||
if strings.Contains(headerPart, body) {
|
||||
t.Errorf("getPart header should not include body, got %q", headerPart)
|
||||
}
|
||||
|
||||
all := getPart("all", resp, body)
|
||||
if !strings.Contains(all, "nginx") || !strings.Contains(all, body) {
|
||||
t.Errorf("getPart all = %q, want both header and body", all)
|
||||
}
|
||||
|
||||
// an unrecognised part falls back to the body.
|
||||
if got := getPart("weird", resp, body); got != body {
|
||||
t.Errorf("getPart fallback = %q, want body %q", got, body)
|
||||
}
|
||||
|
||||
// empty part behaves like "all".
|
||||
if got := getPart("", resp, body); !strings.Contains(got, "nginx") || !strings.Contains(got, body) {
|
||||
t.Errorf("getPart empty = %q, want both header and body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExtractors(t *testing.T) {
|
||||
resp := fakeResponse(t, 200, http.Header{"X-Token": []string{"abc123"}})
|
||||
const body = `{"session":"sess-7788","role":"admin"}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extractors []Extractor
|
||||
wantKey string
|
||||
wantVal string
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "no extractors yields nil",
|
||||
extractors: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "regex capture group on body",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
||||
},
|
||||
wantKey: "session",
|
||||
wantVal: "sess-7788",
|
||||
},
|
||||
{
|
||||
name: "group zero is the whole match",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "role", Part: "body", Regex: []string{`role":"admin`}, Group: 0},
|
||||
},
|
||||
wantKey: "role",
|
||||
wantVal: `role":"admin`,
|
||||
},
|
||||
{
|
||||
name: "extract from header part",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "token", Part: "header", Regex: []string{`X-Token: (\S+)`}, Group: 1},
|
||||
},
|
||||
wantKey: "token",
|
||||
wantVal: "abc123",
|
||||
},
|
||||
{
|
||||
name: "first matching pattern wins",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`nomatch(\d+)`, `"session":"([^"]+)"`}, Group: 1},
|
||||
},
|
||||
wantKey: "session",
|
||||
wantVal: "sess-7788",
|
||||
},
|
||||
{
|
||||
name: "group index out of range is skipped",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 5},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "invalid pattern is skipped, no capture",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`(`}, Group: 1},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "unknown extractor type is ignored",
|
||||
extractors: []Extractor{
|
||||
{Type: "bogus", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := runExtractors(tt.extractors, resp, body)
|
||||
if tt.wantNil {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("runExtractors = %v, want empty", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got[tt.wantKey] != tt.wantVal {
|
||||
t.Errorf("runExtractors[%q] = %q, want %q", tt.wantKey, got[tt.wantKey], tt.wantVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
baseURL string
|
||||
payload string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "baseurl both cases",
|
||||
template: "{{BaseURL}}/x and {{baseurl}}/y",
|
||||
baseURL: "http://h",
|
||||
want: "http://h/x and http://h/y",
|
||||
},
|
||||
{
|
||||
name: "payload both cases",
|
||||
template: "q={{payload}}&r={{Payload}}",
|
||||
payload: "<script>",
|
||||
want: "q=<script>&r=<script>",
|
||||
},
|
||||
{
|
||||
name: "combined base and payload",
|
||||
template: "{{BaseURL}}/search?q={{payload}}",
|
||||
baseURL: "http://h",
|
||||
payload: "x",
|
||||
want: "http://h/search?q=x",
|
||||
},
|
||||
{
|
||||
name: "no placeholders untouched",
|
||||
template: "/static/path",
|
||||
baseURL: "http://h",
|
||||
want: "/static/path",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := substituteVariables(tt.template, tt.baseURL, tt.payload); got != tt.want {
|
||||
t.Errorf("substituteVariables = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHTTPRequests(t *testing.T) {
|
||||
t.Run("paths without payloads", func(t *testing.T) {
|
||||
cfg := &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
||||
}
|
||||
// trailing slash on the target must be trimmed before substitution.
|
||||
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))
|
||||
}
|
||||
if got[0].Method != "GET" {
|
||||
t.Errorf("default method = %q, want GET", got[0].Method)
|
||||
}
|
||||
if got[0].URL != "http://h/a" || got[1].URL != "http://h/b" {
|
||||
t.Errorf("urls = %q,%q", got[0].URL, got[1].URL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("payload expansion is path x payload", func(t *testing.T) {
|
||||
cfg := &HTTPConfig{
|
||||
Method: "POST",
|
||||
Paths: []string{"{{BaseURL}}/q?x={{payload}}"},
|
||||
Payloads: []string{"1", "2", "3"},
|
||||
Body: "data={{payload}}",
|
||||
}
|
||||
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))
|
||||
}
|
||||
for i, want := range []string{"1", "2", "3"} {
|
||||
if got[i].Payload != want {
|
||||
t.Errorf("req %d payload = %q, want %q", i, got[i].Payload, want)
|
||||
}
|
||||
if got[i].URL != "http://h/q?x="+want {
|
||||
t.Errorf("req %d url = %q", i, got[i].URL)
|
||||
}
|
||||
if got[i].Body != "data="+want {
|
||||
t.Errorf("req %d body = %q", i, got[i].Body)
|
||||
}
|
||||
if got[i].Method != "POST" {
|
||||
t.Errorf("req %d method = %q, want POST", i, got[i].Method)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple paths times multiple payloads", func(t *testing.T) {
|
||||
cfg := &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
||||
Payloads: []string{"x", "y"},
|
||||
}
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
// Package modules provides the module system infrastructure for SIF.
|
||||
// It defines the core interfaces, types, and utilities for building
|
||||
// and executing security scanning modules.
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ModuleType represents the type of module.
|
||||
type ModuleType string
|
||||
|
||||
const (
|
||||
TypeHTTP ModuleType = "http"
|
||||
TypeDNS ModuleType = "dns"
|
||||
TypeTCP ModuleType = "tcp"
|
||||
TypeScript ModuleType = "script"
|
||||
)
|
||||
|
||||
// Module is the interface all modules implement.
|
||||
// Each module must provide metadata, specify its type, and implement
|
||||
// an Execute method for running the scan against a target.
|
||||
type Module interface {
|
||||
// Info returns the module metadata.
|
||||
Info() Info
|
||||
|
||||
// Type returns the module type (http, dns, tcp, script).
|
||||
Type() ModuleType
|
||||
|
||||
// Execute runs the module against the specified target.
|
||||
Execute(ctx context.Context, target string, opts Options) (*Result, error)
|
||||
}
|
||||
|
||||
// Info contains module metadata.
|
||||
type Info struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Author string `yaml:"author" json:"author"`
|
||||
Severity string `yaml:"severity" json:"severity"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Tags []string `yaml:"tags" json:"tags"`
|
||||
}
|
||||
|
||||
// Options for module execution.
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
Threads int
|
||||
LogDir string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// Result from module execution.
|
||||
type Result struct {
|
||||
ModuleID string `json:"module_id"`
|
||||
Target string `json:"target"`
|
||||
Findings []Finding `json:"findings,omitempty"`
|
||||
}
|
||||
|
||||
// ResultType implements the ScanResult interface from pkg/scan.
|
||||
func (r *Result) ResultType() string {
|
||||
return r.ModuleID
|
||||
}
|
||||
|
||||
// Finding represents a discovered issue.
|
||||
type Finding struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Extracted map[string]string `json:"extracted,omitempty"`
|
||||
}
|
||||
|
||||
// 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, 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"`
|
||||
}
|
||||
|
||||
// Extractor defines data extraction from responses.
|
||||
// Extractors pull specific data from matched responses for reporting.
|
||||
type Extractor struct {
|
||||
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"`
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
registry = make(map[string]Module)
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// Register adds a module to the registry.
|
||||
// If a module with the same ID already exists, it will be overwritten.
|
||||
func Register(m Module) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
registry[m.Info().ID] = m
|
||||
}
|
||||
|
||||
// Get returns a module by ID.
|
||||
// The second return value indicates whether the module was found.
|
||||
func Get(id string) (Module, bool) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
m, ok := registry[id]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// All returns all registered modules.
|
||||
func All() []Module {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
result := make([]Module, 0, len(registry))
|
||||
for _, m := range registry {
|
||||
result = append(result, m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ByTag returns modules matching a tag.
|
||||
func ByTag(tag string) []Module {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
var result []Module
|
||||
for _, m := range registry {
|
||||
for _, t := range m.Info().Tags {
|
||||
if t == tag {
|
||||
result = append(result, m)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ByType returns modules of a specific type.
|
||||
func ByType(t ModuleType) []Module {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
var result []Module
|
||||
for _, m := range registry {
|
||||
if m.Type() == t {
|
||||
result = append(result, m)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Count returns the number of registered modules.
|
||||
func Count() int {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return len(registry)
|
||||
}
|
||||
|
||||
// Clear removes all modules from the registry.
|
||||
// This is primarily useful for testing.
|
||||
func Clear() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
registry = make(map[string]Module)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user