mirror of
https://github.com/epi052/feroxbuster.git
synced 2026-05-27 08:42:04 -03:00
Compare commits
20 Commits
v2.13.1
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f6da2abfc | ||
|
|
b248b2d9b9 | ||
|
|
36a366eb55 | ||
|
|
c9a7abb8f7 | ||
|
|
c597ec2bc1 | ||
|
|
c512669d3a | ||
|
|
100bcbfbc4 | ||
|
|
5543fa5d36 | ||
|
|
38ab434642 | ||
|
|
72ab2d9a58 | ||
|
|
f57087c0f9 | ||
|
|
d0e2419554 | ||
|
|
4390ac0500 | ||
|
|
49c3851a85 | ||
|
|
0881295234 | ||
|
|
e673ae3e76 | ||
|
|
cb55880aaa | ||
|
|
45ee292110 | ||
|
|
a197d1994b | ||
|
|
9e0f47acdf |
@@ -963,33 +963,6 @@
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "auk0x01",
|
||||
"name": "Adnan Ullah Khan (auk0x01)",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/75381620?v=4",
|
||||
"profile": "http://adnanullahkhan.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mzember",
|
||||
"name": "Martin Žember",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/61412285?v=4",
|
||||
"profile": "https://github.com/mzember",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "pg9051",
|
||||
"name": "pg9051",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/202219877?v=4",
|
||||
"profile": "https://github.com/pg9051",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
26
.github/workflows/cicd-to-dockerhub.yml
vendored
26
.github/workflows/cicd-to-dockerhub.yml
vendored
@@ -32,29 +32,3 @@ jobs:
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
- name: Verify pushed image
|
||||
run: |
|
||||
# Wait a moment for the image to be available
|
||||
sleep 5
|
||||
|
||||
# Pull the image we just pushed
|
||||
docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/feroxbuster:latest
|
||||
|
||||
# Get the digest of the pulled image
|
||||
PULLED_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ${{ secrets.DOCKER_HUB_USERNAME }}/feroxbuster:latest | cut -d'@' -f2)
|
||||
PUSHED_DIGEST="${{ steps.docker_build.outputs.digest }}"
|
||||
|
||||
echo "Pushed digest: $PUSHED_DIGEST"
|
||||
echo "Pulled digest: $PULLED_DIGEST"
|
||||
|
||||
# Verify they match
|
||||
if [ "$PULLED_DIGEST" = "$PUSHED_DIGEST" ]; then
|
||||
echo "✓ Verification successful: Pulled image matches pushed image"
|
||||
|
||||
# Test that the binary works
|
||||
docker run --rm ${{ secrets.DOCKER_HUB_USERNAME }}/feroxbuster:latest --version
|
||||
else
|
||||
echo "✗ Verification failed: Digests do not match"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
310
.github/workflows/release.yml
vendored
310
.github/workflows/release.yml
vendored
@@ -1,310 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get previous tag
|
||||
PREV_TAG=$(git describe --abbrev=0 --tags HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
# First release, get all commits
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges)
|
||||
else
|
||||
# Get commits since previous tag
|
||||
CHANGELOG=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
|
||||
fi
|
||||
|
||||
# Create changelog file
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$CHANGELOG"
|
||||
echo ""
|
||||
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${GITHUB_REF_NAME}"
|
||||
} > CHANGELOG.md
|
||||
|
||||
cat CHANGELOG.md
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: CHANGELOG.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
download-and-upload-artifacts:
|
||||
name: Download & Upload Release Assets
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get latest CD Pipeline run
|
||||
id: get_run
|
||||
run: |
|
||||
# Get the latest successful CD Pipeline run for main branch
|
||||
RUN_ID=$(gh run list --workflow="CD Pipeline" --branch=main --status=success --limit=1 --json databaseId --jq='.[0].databaseId')
|
||||
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
run-id: ${{ steps.get_run.outputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts/
|
||||
|
||||
- name: Prepare release assets
|
||||
id: prepare
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
cd artifacts
|
||||
|
||||
# Process Linux x86_64 binary - create tar.gz
|
||||
if [ -d "x86_64-linux-feroxbuster" ]; then
|
||||
tar czf ../release-assets/x86_64-linux-feroxbuster.tar.gz -C x86_64-linux-feroxbuster feroxbuster
|
||||
fi
|
||||
|
||||
# Process Linux x86 binary - create tar.gz
|
||||
if [ -d "x86-linux-feroxbuster" ]; then
|
||||
tar czf ../release-assets/x86-linux-feroxbuster.tar.gz -C x86-linux-feroxbuster feroxbuster
|
||||
fi
|
||||
|
||||
# Process ARM binaries - create tar.gz
|
||||
if [ -d "armv7-linux-feroxbuster" ]; then
|
||||
tar czf ../release-assets/armv7-linux-feroxbuster.tar.gz -C armv7-linux-feroxbuster feroxbuster
|
||||
fi
|
||||
|
||||
if [ -d "aarch64-linux-feroxbuster" ]; then
|
||||
tar czf ../release-assets/aarch64-linux-feroxbuster.tar.gz -C aarch64-linux-feroxbuster feroxbuster
|
||||
fi
|
||||
|
||||
# Copy macOS tar.gz files (already compressed)
|
||||
if [ -f "x86_64-macos-feroxbuster.tar.gz/x86_64-macos-feroxbuster.tar.gz" ]; then
|
||||
cp x86_64-macos-feroxbuster.tar.gz/x86_64-macos-feroxbuster.tar.gz ../release-assets/
|
||||
fi
|
||||
|
||||
if [ -f "aarch64-macos-feroxbuster.tar.gz/aarch64-macos-feroxbuster.tar.gz" ]; then
|
||||
cp aarch64-macos-feroxbuster.tar.gz/aarch64-macos-feroxbuster.tar.gz ../release-assets/
|
||||
fi
|
||||
|
||||
# Copy Windows executables - create zip files
|
||||
if [ -d "x86_64-windows-feroxbuster.exe" ]; then
|
||||
cd x86_64-windows-feroxbuster.exe
|
||||
zip ../../release-assets/x86_64-windows-feroxbuster.zip feroxbuster.exe
|
||||
cd ..
|
||||
fi
|
||||
|
||||
if [ -d "x86-windows-feroxbuster.exe" ]; then
|
||||
cd x86-windows-feroxbuster.exe
|
||||
zip ../../release-assets/x86-windows-feroxbuster.zip feroxbuster.exe
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Copy .deb file
|
||||
if [ -d "feroxbuster_amd64.deb" ]; then
|
||||
cp feroxbuster_amd64.deb/*.deb ../release-assets/ || true
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
# Generate SHA256 checksums
|
||||
cd release-assets
|
||||
sha256sum * > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
|
||||
# Extract specific hashes for homebrew
|
||||
LINUX_HASH=$(grep "x86_64-linux-feroxbuster.tar.gz" SHA256SUMS | awk '{print $1}')
|
||||
MACOS_X64_HASH=$(grep "x86_64-macos-feroxbuster.tar.gz" SHA256SUMS | awk '{print $1}')
|
||||
MACOS_ARM_HASH=$(grep "aarch64-macos-feroxbuster.tar.gz" SHA256SUMS | awk '{print $1}')
|
||||
|
||||
echo "linux_hash=$LINUX_HASH" >> $GITHUB_OUTPUT
|
||||
echo "macos_x64_hash=$MACOS_X64_HASH" >> $GITHUB_OUTPUT
|
||||
echo "macos_arm_hash=$MACOS_ARM_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-assets/*
|
||||
|
||||
publish-crates-io:
|
||||
name: Publish to crates.io
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Publish to crates.io
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
run: cargo publish
|
||||
|
||||
update-homebrew:
|
||||
name: Update Homebrew Taps
|
||||
needs: [create-release, download-and-upload-artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout TGotwig's homebrew-linux-feroxbuster
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: TGotwig/homebrew-linux-feroxbuster
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
path: homebrew-linux
|
||||
|
||||
- name: Update Linux formula
|
||||
run: |
|
||||
cd homebrew-linux
|
||||
VERSION="${{ needs.create-release.outputs.version }}"
|
||||
HASH="${{ needs.download-and-upload-artifacts.outputs.linux_hash }}"
|
||||
|
||||
# Update version and hash in formula
|
||||
sed -i "s|url \"https://github.com/epi052/feroxbuster/releases/download/v[^/]*/x86_64-linux-feroxbuster.tar.gz\"|url \"https://github.com/epi052/feroxbuster/releases/download/v${VERSION}/x86_64-linux-feroxbuster.tar.gz\"|g" feroxbuster.rb
|
||||
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${HASH}\"/g" feroxbuster.rb
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add feroxbuster.rb
|
||||
git commit -m "Update feroxbuster to v${VERSION}" || echo "No changes to commit"
|
||||
git push
|
||||
|
||||
- name: Checkout feroxbuster main repo to get config
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: feroxbuster-src
|
||||
|
||||
- name: Check if config changed
|
||||
id: config_check
|
||||
run: |
|
||||
cd feroxbuster-src
|
||||
CONFIG_HASH=$(sha256sum ferox-config.toml.example | awk '{print $1}')
|
||||
echo "config_hash=$CONFIG_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if config changed since last tag
|
||||
PREV_TAG=$(git describe --abbrev=0 --tags HEAD^ 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
if git diff ${PREV_TAG}..HEAD --quiet -- ferox-config.toml.example; then
|
||||
echo "config_changed=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "config_changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "config_changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Update config hash in homebrew if changed
|
||||
if: steps.config_check.outputs.config_changed == 'true'
|
||||
run: |
|
||||
cd homebrew-linux
|
||||
CONFIG_HASH="${{ steps.config_check.outputs.config_hash }}"
|
||||
|
||||
# Update config hash if it exists in formula
|
||||
if grep -q "ferox-config.toml.example" feroxbuster.rb; then
|
||||
sed -i "s/sha256 \"[^\"]*\" # ferox-config.toml.example/sha256 \"${CONFIG_HASH}\" # ferox-config.toml.example/g" feroxbuster.rb
|
||||
|
||||
git add feroxbuster.rb
|
||||
git commit -m "Update ferox-config.toml.example hash" || echo "No changes to commit"
|
||||
git push
|
||||
fi
|
||||
|
||||
publish-winget:
|
||||
name: Publish to Winget
|
||||
needs: [create-release, download-and-upload-artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal2009/winget-releaser@main
|
||||
with:
|
||||
identifier: epi052.feroxbuster
|
||||
installers-regex: '-windows-feroxbuster\.exe\.zip$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
release-tag: v${{ needs.create-release.outputs.version }}
|
||||
|
||||
publish-snapcraft:
|
||||
name: Publish to Snapcraft
|
||||
needs: [create-release, download-and-upload-artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish to Snapcraft
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }}
|
||||
with:
|
||||
snap: feroxbuster
|
||||
release: stable
|
||||
|
||||
manual-steps-reminder:
|
||||
name: Manual Steps Reminder
|
||||
needs: [create-release, download-and-upload-artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create comment with manual steps
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const version = '${{ needs.create-release.outputs.version }}';
|
||||
const linuxHash = '${{ needs.download-and-upload-artifacts.outputs.linux_hash }}';
|
||||
|
||||
const body = `## 🚀 Release v${version} Published!
|
||||
|
||||
### ✅ Automated Steps Completed
|
||||
- [x] GitHub Release created with changelog
|
||||
- [x] All artifacts uploaded with SHA256 checksums
|
||||
- [x] Published to crates.io
|
||||
- [x] Homebrew tap updated
|
||||
- [x] Winget package published
|
||||
- [x] Snapcraft published to stable
|
||||
|
||||
### 📋 Manual Steps Required
|
||||
|
||||
1. **Kali Linux**
|
||||
- Go to https://bugs.kali.org/login_page.php?return=%2Fmy_view_page.php
|
||||
- Request a tool update
|
||||
|
||||
3. **Announcement** (optional)
|
||||
- Tweet about the release if it's significant!
|
||||
|
||||
2. **Announcement** (optional)
|
||||
- Linux x86_64: \`${linuxHash}\`
|
||||
|
||||
See full checksums in release assets: SHA256SUMS
|
||||
`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
21
.github/workflows/winget.yml
vendored
Normal file
21
.github/workflows/winget.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Publish to Winget
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Tag name of release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal2009/winget-releaser@main
|
||||
with:
|
||||
identifier: epi052.feroxbuster
|
||||
installers-regex: '-windows-feroxbuster\.exe\.zip$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
release-tag: ${{ inputs.tag_name || github.event.release.tag_name || github.ref_name }}
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -946,7 +946,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "feroxbuster"
|
||||
version = "2.13.1"
|
||||
version = "2.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "feroxbuster"
|
||||
version = "2.13.1"
|
||||
version = "2.13.0"
|
||||
authors = ["Ben 'epi' Risher (@epi052)"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
@@ -50,12 +50,4 @@ condition = { env_not_set = ["CI"] }
|
||||
clear = true
|
||||
script = """
|
||||
cargo nextest run --all-features --all-targets --no-fail-fast --run-ignored all --retries 4
|
||||
"""
|
||||
|
||||
# coverage
|
||||
[tasks.coverage]
|
||||
clear = true
|
||||
script = """
|
||||
cargo llvm-cov nextest --all-features --no-fail-fast --retries 4 --html
|
||||
echo "Coverage report generated at target/llvm-cov/html/index.html"
|
||||
"""
|
||||
@@ -352,11 +352,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0x7274"><img src="https://avatars.githubusercontent.com/u/85586890?v=4?s=100" width="100px;" alt="R̝͖̱͖͕̤̰̯͙ͫ͒̀ͮȁ̤͔̝̘̪̻͕̝̖ͧͪͤu̗̠̜̩̗͇͑̀ͣ̃͂̔͂c̫͔͚̲̬̓̂̿͌̿͊̐͗h͚̲̤̟͓̟̥̊ͬͪ̏̍̍ T̟̜̞͉͙̙ͣ́ͪ͗̓̇ͭo͍̰͎̼͓̟̽ͧ̓̉ͬ̐͐b͇̖̳̫̰̗̭͍ͧ̄̄̌̈i̙̪̤̝̟͓̹̋̽͋̀ͧ̒a͕̭̱͎̪̦̤ͤ͊̊̑ͣ̄s̪̯͖̰̯͍ͫ̋͑̄ͭͅͅ"/><br /><sub><b>R̝͖̱͖͕̤̰̯͙ͫ͒̀ͮȁ̤͔̝̘̪̻͕̝̖ͧͪͤu̗̠̜̩̗͇͑̀ͣ̃͂̔͂c̫͔͚̲̬̓̂̿͌̿͊̐͗h͚̲̤̟͓̟̥̊ͬͪ̏̍̍ T̟̜̞͉͙̙ͣ́ͪ͗̓̇ͭo͍̰͎̼͓̟̽ͧ̓̉ͬ̐͐b͇̖̳̫̰̗̭͍ͧ̄̄̌̈i̙̪̤̝̟͓̹̋̽͋̀ͧ̒a͕̭̱͎̪̦̤ͤ͊̊̑ͣ̄s̪̯͖̰̯͍ͫ̋͑̄ͭͅͅ</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3A0x7274" title="Bug reports">🐛</a> <a href="#ideas-0x7274" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=0x7274" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/4FunAndProfit"><img src="https://avatars.githubusercontent.com/u/174417079?v=4?s=100" width="100px;" alt="4FunAndProfit"/><br /><sub><b>4FunAndProfit</b></sub></a><br /><a href="#ideas-4FunAndProfit" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lidorelias3"><img src="https://avatars.githubusercontent.com/u/41958137?v=4?s=100" width="100px;" alt="lidorelias3"/><br /><sub><b>lidorelias3</b></sub></a><br /><a href="#ideas-lidorelias3" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://adnanullahkhan.com"><img src="https://avatars.githubusercontent.com/u/75381620?v=4?s=100" width="100px;" alt="Adnan Ullah Khan (auk0x01)"/><br /><sub><b>Adnan Ullah Khan (auk0x01)</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=auk0x01" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mzember"><img src="https://avatars.githubusercontent.com/u/61412285?v=4?s=100" width="100px;" alt="Martin Žember"/><br /><sub><b>Martin Žember</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Amzember" title="Bug reports">🐛</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/pg9051"><img src="https://avatars.githubusercontent.com/u/202219877?v=4?s=100" width="100px;" alt="pg9051"/><br /><sub><b>pg9051</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=pg9051" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -27,8 +27,8 @@ _feroxbuster() {
|
||||
'--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]:REPLAY_PROXY:_urls' \
|
||||
'*-R+[Status Codes to send through a Replay Proxy when found (default\: --status-codes value)]:REPLAY_CODE:_default' \
|
||||
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default\: --status-codes value)]:REPLAY_CODE:_default' \
|
||||
'-a+[Sets the User-Agent (default\: feroxbuster/2.13.1)]:USER_AGENT:_default' \
|
||||
'--user-agent=[Sets the User-Agent (default\: feroxbuster/2.13.1)]:USER_AGENT:_default' \
|
||||
'-a+[Sets the User-Agent (default\: feroxbuster/2.13.0)]:USER_AGENT:_default' \
|
||||
'--user-agent=[Sets the User-Agent (default\: feroxbuster/2.13.0)]:USER_AGENT:_default' \
|
||||
'*-x+[File extension(s) to search for (ex\: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex\: @ext.txt)]:FILE_EXTENSION:_default' \
|
||||
'*--extensions=[File extension(s) to search for (ex\: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex\: @ext.txt)]:FILE_EXTENSION:_default' \
|
||||
'*-m+[Which HTTP request method(s) should be sent (default\: GET)]:HTTP_METHODS:_default' \
|
||||
@@ -68,7 +68,7 @@ _feroxbuster() {
|
||||
'-L+[Limit total number of concurrent scans (default\: 0, i.e. no limit)]:SCAN_LIMIT:_default' \
|
||||
'--scan-limit=[Limit total number of concurrent scans (default\: 0, i.e. no limit)]:SCAN_LIMIT:_default' \
|
||||
'(-v --verbosity -u --url)--parallel=[Run parallel feroxbuster instances (one child process per url passed via stdin)]:PARALLEL_SCANS:_default' \
|
||||
'--rate-limit=[Limit number of requests per second (per directory) (default\: 0, i.e. no limit)]:RATE_LIMIT:_default' \
|
||||
'(--auto-tune)--rate-limit=[Limit number of requests per second (per directory) (default\: 0, i.e. no limit)]:RATE_LIMIT:_default' \
|
||||
'--response-size-limit=[Limit size of response body to read in bytes (default\: 4MB)]:BYTES:_default' \
|
||||
'--time-limit=[Limit total run time of all scans (ex\: --time-limit 10m)]:TIME_SPEC:_default' \
|
||||
'-w+[Path or URL of the wordlist]:FILE:_files' \
|
||||
|
||||
@@ -33,8 +33,8 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
|
||||
[CompletionResult]::new('--replay-proxy', '--replay-proxy', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
|
||||
[CompletionResult]::new('-R', '-R ', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
|
||||
[CompletionResult]::new('--replay-codes', '--replay-codes', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
|
||||
[CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.13.1)')
|
||||
[CompletionResult]::new('--user-agent', '--user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.13.1)')
|
||||
[CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.13.0)')
|
||||
[CompletionResult]::new('--user-agent', '--user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.13.0)')
|
||||
[CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)')
|
||||
[CompletionResult]::new('--extensions', '--extensions', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)')
|
||||
[CompletionResult]::new('-m', '-m', [CompletionResultType]::ParameterName, 'Which HTTP request method(s) should be sent (default: GET)')
|
||||
|
||||
@@ -30,8 +30,8 @@ set edit:completion:arg-completer[feroxbuster] = {|@words|
|
||||
cand --replay-proxy 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
|
||||
cand -R 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
|
||||
cand --replay-codes 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
|
||||
cand -a 'Sets the User-Agent (default: feroxbuster/2.13.1)'
|
||||
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.13.1)'
|
||||
cand -a 'Sets the User-Agent (default: feroxbuster/2.13.0)'
|
||||
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.13.0)'
|
||||
cand -x 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)'
|
||||
cand --extensions 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)'
|
||||
cand -m 'Which HTTP request method(s) should be sent (default: GET)'
|
||||
|
||||
@@ -6,7 +6,7 @@ complete -c feroxbuster -l data-json -d 'Set -H \'Content-Type: application/json
|
||||
complete -c feroxbuster -s p -l proxy -d 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)' -r -f
|
||||
complete -c feroxbuster -s P -l replay-proxy -d 'Send only unfiltered requests through a Replay Proxy, instead of all requests' -r -f
|
||||
complete -c feroxbuster -s R -l replay-codes -d 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)' -r
|
||||
complete -c feroxbuster -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/2.13.1)' -r
|
||||
complete -c feroxbuster -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/2.13.0)' -r
|
||||
complete -c feroxbuster -s x -l extensions -d 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)' -r
|
||||
complete -c feroxbuster -s m -l methods -d 'Which HTTP request method(s) should be sent (default: GET)' -r
|
||||
complete -c feroxbuster -l data -d 'Request\'s Body; can read data from a file if input starts with an @ (ex: @post.bin)' -r
|
||||
|
||||
@@ -10,9 +10,8 @@ description: |
|
||||
|
||||
This attack is also known as Predictable Resource Location, File Enumeration, Directory Enumeration, and Resource Enumeration.
|
||||
|
||||
confinement: strict
|
||||
grade: stable
|
||||
base: core22
|
||||
|
||||
base: core18
|
||||
|
||||
plugs:
|
||||
etc-feroxbuster:
|
||||
|
||||
@@ -600,10 +600,7 @@ impl<'a> Extractor<'a> {
|
||||
) {
|
||||
log::trace!("enter: extract_links_by_attr");
|
||||
|
||||
let Some(selector) = Selector::parse(html_tag).ok() else {
|
||||
log::warn!("Failed to parse selector for tag: {html_tag}");
|
||||
return;
|
||||
};
|
||||
let selector = Selector::parse(html_tag).unwrap();
|
||||
|
||||
let tags = html
|
||||
.select(&selector)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use console::style;
|
||||
use futures::future;
|
||||
use lazy_static::lazy_static;
|
||||
use scraper::{Html, Selector};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -19,26 +18,9 @@ use crate::{
|
||||
skip_fail,
|
||||
url::FeroxUrl,
|
||||
utils::{ferox_print, fmt_err, logged_request},
|
||||
COMMON_FILE_EXTENSIONS, DEFAULT_BACKUP_EXTENSIONS, DEFAULT_METHOD,
|
||||
DEFAULT_METHOD,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
/// Pre-built HashSet of file extensions for O(1) lookup in directory listing detection
|
||||
/// Combines COMMON_FILE_EXTENSIONS and DEFAULT_BACKUP_EXTENSIONS
|
||||
static ref FILE_EXTENSION_SET: HashSet<&'static str> = {
|
||||
let mut set = HashSet::with_capacity(
|
||||
COMMON_FILE_EXTENSIONS.len() + DEFAULT_BACKUP_EXTENSIONS.len()
|
||||
);
|
||||
for ext in COMMON_FILE_EXTENSIONS.iter() {
|
||||
set.insert(*ext);
|
||||
}
|
||||
for ext in DEFAULT_BACKUP_EXTENSIONS.iter() {
|
||||
set.insert(*ext);
|
||||
}
|
||||
set
|
||||
};
|
||||
}
|
||||
|
||||
/// enum representing the different servers that `parse_html` can detect when directory listing is
|
||||
/// enabled
|
||||
#[derive(Copy, Debug, Clone)]
|
||||
@@ -52,9 +34,6 @@ pub enum DirListingType {
|
||||
/// ASP.NET server, detected by `Directory Listing -- /`
|
||||
AspDotNet,
|
||||
|
||||
/// custom/non-standard directory listing, detected by high-signal heuristics
|
||||
Custom,
|
||||
|
||||
// /// IIS/Azure server, detected by `HOST_NAME - /` (not currently used)
|
||||
// IIS_AZURE,
|
||||
/// variant that represents the absence of directory listing
|
||||
@@ -197,14 +176,16 @@ impl HeuristicTests {
|
||||
let body = ferox_response.text();
|
||||
let html = Html::parse_document(body);
|
||||
|
||||
if let Some(dir_type) = self.detect_directory_listing(&html) {
|
||||
let dirlist_type = self.detect_directory_listing(&html);
|
||||
|
||||
if dirlist_type.is_some() {
|
||||
// folks that run things and step away/rely on logs need to be notified of directory
|
||||
// listing, since they won't see the message on the bar; bastardizing FeroxMessage
|
||||
// for ease of implementation. This could use a bit of polish at some point.
|
||||
|
||||
let msg = format!(
|
||||
"detected directory listing: {} ({:?})",
|
||||
target_url, dir_type
|
||||
target_url,
|
||||
dirlist_type.unwrap()
|
||||
);
|
||||
let ferox_msg = FeroxMessage {
|
||||
kind: "log".to_string(),
|
||||
@@ -222,7 +203,7 @@ impl HeuristicTests {
|
||||
log::info!("{msg}");
|
||||
|
||||
let result = DirListingResult {
|
||||
dir_list_type: Some(dir_type),
|
||||
dir_list_type: dirlist_type,
|
||||
response: ferox_response,
|
||||
};
|
||||
|
||||
@@ -240,11 +221,10 @@ impl HeuristicTests {
|
||||
/// - tomcat/python: `Directory Listing for /`
|
||||
/// - ASP.NET: `Directory Listing -- /`
|
||||
/// - <host> - /: iis, azure, skipping due to loose heuristic
|
||||
/// - custom: detected by combining multiple high-signal heuristics
|
||||
fn detect_directory_listing(&self, html: &Html) -> Option<DirListingType> {
|
||||
log::trace!("enter: detect_directory_listing(html body...)");
|
||||
|
||||
let title_selector = Selector::parse("title").ok()?;
|
||||
let title_selector = Selector::parse("title").expect("couldn't parse title selector");
|
||||
|
||||
for t in html.select(&title_selector) {
|
||||
let title = t.inner_html().to_lowercase();
|
||||
@@ -266,228 +246,10 @@ impl HeuristicTests {
|
||||
}
|
||||
}
|
||||
|
||||
// If no standard title-based detection, try high-signal custom heuristics
|
||||
let has_parent_link = self.has_parent_directory_link(html);
|
||||
let has_table_headers = self.has_directory_table_headers(html);
|
||||
let has_sorting_params = self.has_sorting_query_params(html);
|
||||
let has_link_density = self.has_high_link_density(html);
|
||||
|
||||
let signal_count = [
|
||||
has_parent_link,
|
||||
has_table_headers,
|
||||
has_sorting_params,
|
||||
has_link_density,
|
||||
]
|
||||
.iter()
|
||||
.filter(|&&x| x)
|
||||
.count();
|
||||
|
||||
if signal_count >= 2 {
|
||||
let mut signals = Vec::new();
|
||||
if has_parent_link {
|
||||
signals.push("parent-link");
|
||||
}
|
||||
if has_table_headers {
|
||||
signals.push("table-headers");
|
||||
}
|
||||
if has_sorting_params {
|
||||
signals.push("sorting-params");
|
||||
}
|
||||
if has_link_density {
|
||||
signals.push("link-density");
|
||||
}
|
||||
log::debug!("custom directory listing signals: [{}]", signals.join(", "));
|
||||
log::trace!("exit: detect_directory_listing -> Some(Custom)");
|
||||
return Some(DirListingType::Custom);
|
||||
}
|
||||
|
||||
log::trace!("exit: detect_directory_listing -> None");
|
||||
None
|
||||
}
|
||||
|
||||
/// check if the HTML contains a link to the parent directory
|
||||
///
|
||||
/// returns true if any anchor element has:
|
||||
/// - href equals "../" or ".."
|
||||
/// - visible text contains "parent directory", "to parent", or "up to parent"
|
||||
fn has_parent_directory_link(&self, html: &Html) -> bool {
|
||||
log::trace!("enter: has_parent_directory_link");
|
||||
|
||||
let Some(anchor_selector) = Selector::parse("a").ok() else {
|
||||
log::warn!("failed to parse anchor selector in has_parent_directory_link");
|
||||
return false;
|
||||
};
|
||||
|
||||
for anchor in html.select(&anchor_selector) {
|
||||
if let Some(href) = anchor.value().attr("href") {
|
||||
let href_lower = href.trim().to_lowercase();
|
||||
if href_lower == "../" || href_lower == ".." {
|
||||
log::trace!("exit: has_parent_directory_link -> true (href match)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let text = anchor.text().collect::<String>().to_lowercase();
|
||||
let text_trimmed = text.trim();
|
||||
if text_trimmed.contains("parent directory")
|
||||
|| text_trimmed.contains("to parent")
|
||||
|| text_trimmed.contains("up to parent")
|
||||
{
|
||||
log::trace!("exit: has_parent_directory_link -> true (text match)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: has_parent_directory_link -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// check if the HTML contains table headers typical of directory listings
|
||||
///
|
||||
/// returns true if at least two of the following header categories are present:
|
||||
/// - name headers: "file name", "filename", "name"
|
||||
/// - size headers: "size", "file size"
|
||||
/// - time headers: "date", "last modified", "modified", "last mod"
|
||||
fn has_directory_table_headers(&self, html: &Html) -> bool {
|
||||
log::trace!("enter: has_directory_table_headers");
|
||||
|
||||
let Some(th_selector) = Selector::parse("th").ok() else {
|
||||
log::warn!("failed to parse th selector in has_directory_table_headers");
|
||||
return false;
|
||||
};
|
||||
let Some(td_selector) = Selector::parse("td").ok() else {
|
||||
log::warn!("failed to parse td selector in has_directory_table_headers");
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut headers = Vec::new();
|
||||
|
||||
// try <th> elements first
|
||||
for th in html.select(&th_selector) {
|
||||
let text = th.text().collect::<String>().to_lowercase();
|
||||
headers.push(text.trim().to_string());
|
||||
}
|
||||
|
||||
// fallback: if no <th> elements, try first row of <td> elements
|
||||
if headers.is_empty() {
|
||||
if let Ok(tr_selector) = Selector::parse("tr") {
|
||||
if let Some(first_row) = html.select(&tr_selector).next() {
|
||||
for td in first_row.select(&td_selector) {
|
||||
let text = td.text().collect::<String>().to_lowercase();
|
||||
headers.push(text.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut has_name = false;
|
||||
let mut has_size = false;
|
||||
let mut has_time = false;
|
||||
|
||||
for header in headers {
|
||||
if header == "name" || header.contains("file name") || header.contains("filename") {
|
||||
has_name = true;
|
||||
}
|
||||
if header.contains("size") || header.contains("file size") {
|
||||
has_size = true;
|
||||
}
|
||||
if header.contains("date")
|
||||
|| header.contains("last modified")
|
||||
|| header.contains("modified")
|
||||
|| header.contains("last mod")
|
||||
{
|
||||
has_time = true;
|
||||
}
|
||||
}
|
||||
|
||||
let category_count = [has_name, has_size, has_time]
|
||||
.iter()
|
||||
.filter(|&&x| x)
|
||||
.count();
|
||||
let result = category_count >= 2;
|
||||
|
||||
log::trace!("exit: has_directory_table_headers -> {result}");
|
||||
result
|
||||
}
|
||||
|
||||
/// check if the HTML contains sorting query parameters typical of auto-index pages
|
||||
///
|
||||
/// returns true if any anchor href contains sorting parameters like:
|
||||
/// - ?C=N (name), ?C=S (size), ?C=M (modified), ?C=D (date)
|
||||
/// - optionally combined with &O=A or &O=D (ascending/descending)
|
||||
fn has_sorting_query_params(&self, html: &Html) -> bool {
|
||||
log::trace!("enter: has_sorting_query_params");
|
||||
|
||||
let Some(anchor_selector) = Selector::parse("a").ok() else {
|
||||
log::warn!("failed to parse anchor selector in has_sorting_query_params");
|
||||
return false;
|
||||
};
|
||||
|
||||
for anchor in html.select(&anchor_selector) {
|
||||
if let Some(href) = anchor.value().attr("href") {
|
||||
let href_lower = href.to_lowercase();
|
||||
if href_lower.contains("?c=n")
|
||||
|| href_lower.contains("?c=s")
|
||||
|| href_lower.contains("?c=m")
|
||||
|| href_lower.contains("?c=d")
|
||||
{
|
||||
log::trace!("exit: has_sorting_query_params -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: has_sorting_query_params -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// check if the HTML has a high density of file/directory links
|
||||
///
|
||||
/// returns true if there are at least 3 links that look like files or directories:
|
||||
/// - href ends with '/' (likely subdirectory)
|
||||
/// - href looks like a file (common extensions)
|
||||
fn has_high_link_density(&self, html: &Html) -> bool {
|
||||
log::trace!("enter: has_high_link_density");
|
||||
|
||||
const MIN_LINKS: usize = 3;
|
||||
|
||||
let Some(anchor_selector) = Selector::parse("a").ok() else {
|
||||
log::warn!("failed to parse anchor selector in has_high_link_density");
|
||||
return false;
|
||||
};
|
||||
let mut count = 0;
|
||||
|
||||
for anchor in html.select(&anchor_selector) {
|
||||
if let Some(href) = anchor.value().attr("href") {
|
||||
let href_trimmed = href.trim();
|
||||
|
||||
// skip parent directory links and fragments
|
||||
if href_trimmed == "../" || href_trimmed == ".." || href_trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if it's a directory (ends with /)
|
||||
if href_trimmed.ends_with('/') {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if it looks like a file - extract extension and O(1) lookup
|
||||
let href_lower = href_trimmed.to_lowercase();
|
||||
if let Some(dot_pos) = href_lower.rfind('.') {
|
||||
let extension = &href_lower[dot_pos..];
|
||||
if FILE_EXTENSION_SET.contains(extension) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = count >= MIN_LINKS;
|
||||
log::trace!("exit: has_high_link_density -> {result} (count: {count})");
|
||||
result
|
||||
}
|
||||
|
||||
/// given a target's base url, attempt to automatically detect its 404 response
|
||||
/// pattern(s), and then set filters that will exclude those patterns from future
|
||||
/// responses
|
||||
@@ -898,210 +660,4 @@ mod tests {
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(dirlist_type.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_parent_directory_link` detects parent directory links by href
|
||||
fn has_parent_directory_link_detects_by_href() {
|
||||
let html = r#"<a href="../">Go up</a>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(heuristics.has_parent_directory_link(&parsed));
|
||||
|
||||
let html2 = r#"<a href="..">Go up</a>"#;
|
||||
let parsed2 = Html::parse_document(html2);
|
||||
assert!(heuristics.has_parent_directory_link(&parsed2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_parent_directory_link` detects parent directory links by text
|
||||
fn has_parent_directory_link_detects_by_text() {
|
||||
let html = r#"<a href="/parent">Parent Directory</a>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(heuristics.has_parent_directory_link(&parsed));
|
||||
|
||||
let html2 = r#"<a href="/up">To Parent</a>"#;
|
||||
let parsed2 = Html::parse_document(html2);
|
||||
assert!(heuristics.has_parent_directory_link(&parsed2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_parent_directory_link` returns false when no parent link
|
||||
fn has_parent_directory_link_returns_false_when_absent() {
|
||||
let html = r#"<a href="/about">About</a>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(!heuristics.has_parent_directory_link(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_directory_table_headers` detects table headers with name and size
|
||||
fn has_directory_table_headers_detects_name_and_size() {
|
||||
let html = r#"<table><thead><tr><th>File Name</th><th>Size</th></tr></thead></table>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(heuristics.has_directory_table_headers(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_directory_table_headers` detects table headers with name and date
|
||||
fn has_directory_table_headers_detects_name_and_date() {
|
||||
let html = r#"<table><thead><tr><th>Name</th><th>Last Modified</th></tr></thead></table>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(heuristics.has_directory_table_headers(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_directory_table_headers` returns false with only one category
|
||||
fn has_directory_table_headers_requires_two_categories() {
|
||||
let html = r#"<table><thead><tr><th>Name</th><th>Description</th></tr></thead></table>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(!heuristics.has_directory_table_headers(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_sorting_query_params` detects Apache-style sorting parameters
|
||||
fn has_sorting_query_params_detects_apache_style() {
|
||||
let html = r#"<a href="?C=N&O=A">Name</a><a href="?C=S&O=D">Size</a>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(heuristics.has_sorting_query_params(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_sorting_query_params` returns false when no sorting params
|
||||
fn has_sorting_query_params_returns_false_when_absent() {
|
||||
let html = r#"<a href="/page?q=search">Search</a>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(!heuristics.has_sorting_query_params(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_high_link_density` detects high density of file/directory links
|
||||
fn has_high_link_density_detects_files_and_dirs() {
|
||||
let html = r#"
|
||||
<a href="backup/">backup/</a>
|
||||
<a href="file1.html">file1.html</a>
|
||||
<a href="file2.txt">file2.txt</a>
|
||||
"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(heuristics.has_high_link_density(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_high_link_density` requires at least 3 links
|
||||
fn has_high_link_density_requires_minimum_links() {
|
||||
let html = r#"
|
||||
<a href="backup/">backup/</a>
|
||||
<a href="file.html">file.html</a>
|
||||
"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(!heuristics.has_high_link_density(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `has_high_link_density` ignores parent directory links
|
||||
fn has_high_link_density_ignores_parent_links() {
|
||||
let html = r#"
|
||||
<a href="../">Parent</a>
|
||||
<a href="backup/">backup/</a>
|
||||
<a href="file.html">file.html</a>
|
||||
"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
assert!(!heuristics.has_high_link_density(&parsed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `detect_directory_listing` detects custom listing with 2+ signals
|
||||
fn detect_directory_listing_detects_custom_with_multiple_signals() {
|
||||
// This HTML has parent link, table headers, sorting params, and link density
|
||||
let html = r#"
|
||||
<table><thead><tr>
|
||||
<th><a href="?C=N&O=A">File Name</a></th>
|
||||
<th><a href="?C=S&O=A">Size</a></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td><a href="../">Parent directory/</a></td></tr>
|
||||
<tr><td><a href="backup/">backup/</a></td></tr>
|
||||
<tr><td><a href="pass.html">pass.html</a></td></tr>
|
||||
</tbody></table>
|
||||
"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(matches!(dirlist_type, Some(DirListingType::Custom)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `detect_directory_listing` requires at least 2 signals for custom detection
|
||||
fn detect_directory_listing_requires_two_signals() {
|
||||
// This HTML has only parent link (1 signal)
|
||||
let html = r#"<a href="../">Parent directory/</a>"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(dirlist_type.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `detect_directory_listing` detects Root-Me sample page as custom
|
||||
fn detect_directory_listing_detects_rootme_sample() {
|
||||
// Simplified version of response.html from Root-Me
|
||||
let html = r#"
|
||||
<table id="list">
|
||||
<thead><tr>
|
||||
<th><a href="?C=N&O=A">File Name</a></th>
|
||||
<th><a href="?C=S&O=A">File Size</a></th>
|
||||
<th><a href="?C=M&O=A">Date</a></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td><a href="../">Parent directory/</a></td><td>-</td><td>-</td></tr>
|
||||
<tr><td><a href="backup/">backup/</a></td><td>-</td><td>2021-Dec-10</td></tr>
|
||||
<tr><td><a href="pass.html">pass.html</a></td><td>346 B</td><td>2021-Dec-10</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(matches!(dirlist_type, Some(DirListingType::Custom)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `detect_directory_listing` does not trigger on pages with many random links
|
||||
fn detect_directory_listing_ignores_generic_pages() {
|
||||
let html = r#"
|
||||
<nav>
|
||||
<a href="/about">About</a>
|
||||
<a href="/contact">Contact</a>
|
||||
<a href="/services">Services</a>
|
||||
<a href="/products">Products</a>
|
||||
</nav>
|
||||
"#;
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(dirlist_type.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
178
src/lib.rs
178
src/lib.rs
@@ -54,185 +54,15 @@ pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const DEFAULT_OPEN_FILE_LIMIT: u64 = 8192;
|
||||
|
||||
/// Default set of extensions to Ignore when auto-collecting extensions during scans
|
||||
pub(crate) const DEFAULT_IGNORED_EXTENSIONS: [&str; 43] = [
|
||||
"woff2", "woff", "ttf", "otf", "eot", "tif", "tiff", "ico", "cur", "bmp", "webp", "svg", "png",
|
||||
"jpg", "jpeg", "jfif", "gif", "avif", "apng", "pjpeg", "pjp", "mov", "wav", "mpg", "mpeg",
|
||||
"mp3", "mp4", "m4a", "m4p", "m4v", "ogg", "webm", "ogv", "oga", "flac", "aac", "3gp", "css",
|
||||
"zip", "xls", "xml", "gz", "tgz",
|
||||
pub(crate) const DEFAULT_IGNORED_EXTENSIONS: [&str; 38] = [
|
||||
"tif", "tiff", "ico", "cur", "bmp", "webp", "svg", "png", "jpg", "jpeg", "jfif", "gif", "avif",
|
||||
"apng", "pjpeg", "pjp", "mov", "wav", "mpg", "mpeg", "mp3", "mp4", "m4a", "m4p", "m4v", "ogg",
|
||||
"webm", "ogv", "oga", "flac", "aac", "3gp", "css", "zip", "xls", "xml", "gz", "tgz",
|
||||
];
|
||||
|
||||
/// Default set of extensions to search for when auto-collecting backups during scans
|
||||
pub(crate) const DEFAULT_BACKUP_EXTENSIONS: [&str; 5] = ["~", ".bak", ".bak2", ".old", ".1"];
|
||||
|
||||
/// list of common file extensions for link density detection in directory listings
|
||||
/// based on https://www.computerhope.com/issues/ch001789.htm
|
||||
pub(crate) const COMMON_FILE_EXTENSIONS: [&str; 154] = [
|
||||
// Web & Documents
|
||||
".html",
|
||||
".htm",
|
||||
".php",
|
||||
".asp",
|
||||
".aspx",
|
||||
".jsp",
|
||||
".jspx",
|
||||
".cgi",
|
||||
".pl",
|
||||
".py",
|
||||
".rb",
|
||||
".lua",
|
||||
".txt",
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".odt",
|
||||
".ods",
|
||||
".odp",
|
||||
".rtf",
|
||||
".tex",
|
||||
".md",
|
||||
".csv",
|
||||
// Programming & Scripts
|
||||
".js",
|
||||
".mjs",
|
||||
".ts",
|
||||
".jsx",
|
||||
".tsx",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".java",
|
||||
".class",
|
||||
".jar",
|
||||
".c",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".cs",
|
||||
".vb",
|
||||
".go",
|
||||
".rs",
|
||||
".swift",
|
||||
".kt",
|
||||
".scala",
|
||||
".r",
|
||||
".m",
|
||||
".mm",
|
||||
".f",
|
||||
".f90",
|
||||
".pas",
|
||||
".asm",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".bat",
|
||||
".cmd",
|
||||
".ps1",
|
||||
".psm1",
|
||||
// Data & Config
|
||||
".xml",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".conf",
|
||||
".config",
|
||||
".cfg",
|
||||
".properties",
|
||||
".env",
|
||||
".sql",
|
||||
".db",
|
||||
".sqlite",
|
||||
".mdb",
|
||||
".accdb",
|
||||
// Archives & Compressed
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".tar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".xz",
|
||||
".tgz",
|
||||
".tbz2",
|
||||
".cab",
|
||||
".dmg",
|
||||
".iso",
|
||||
".img",
|
||||
// Executables & Libraries
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".app",
|
||||
".deb",
|
||||
".rpm",
|
||||
".apk",
|
||||
".msi",
|
||||
// Images
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
".svg",
|
||||
".webp",
|
||||
".ico",
|
||||
".tif",
|
||||
".tiff",
|
||||
".psd",
|
||||
".ai",
|
||||
".eps",
|
||||
".raw",
|
||||
".cr2",
|
||||
".nef",
|
||||
// Audio
|
||||
".mp3",
|
||||
".wav",
|
||||
".flac",
|
||||
".aac",
|
||||
".ogg",
|
||||
".wma",
|
||||
".m4a",
|
||||
".opus",
|
||||
".aiff",
|
||||
// Video
|
||||
".mp4",
|
||||
".avi",
|
||||
".mkv",
|
||||
".mov",
|
||||
".wmv",
|
||||
".flv",
|
||||
".webm",
|
||||
".m4v",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
".3gp",
|
||||
".ogv",
|
||||
// Fonts
|
||||
".ttf",
|
||||
".otf",
|
||||
".woff",
|
||||
".woff2",
|
||||
".eot",
|
||||
// Backups & Logs
|
||||
".log",
|
||||
".bak",
|
||||
".tmp",
|
||||
".temp",
|
||||
".swp",
|
||||
".swo",
|
||||
".old",
|
||||
".orig",
|
||||
".backup",
|
||||
];
|
||||
|
||||
/// Default wordlist to use when `-w|--wordlist` isn't specified and not `wordlist` isn't set
|
||||
/// in a [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file.
|
||||
///
|
||||
|
||||
@@ -556,9 +556,9 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
|
||||
let live_targets = {
|
||||
let test = heuristics::HeuristicTests::new(handles.clone());
|
||||
let result = test.connectivity(&targets).await;
|
||||
if let Err(err) = result {
|
||||
if result.is_err() {
|
||||
clean_up(handles, tasks).await?;
|
||||
bail!(fmt_err(&err.to_string()));
|
||||
bail!(fmt_err(&result.unwrap_err().to_string()));
|
||||
}
|
||||
result?
|
||||
};
|
||||
|
||||
@@ -547,6 +547,7 @@ pub fn initialize() -> Command {
|
||||
.long("rate-limit")
|
||||
.value_name("RATE_LIMIT")
|
||||
.num_args(1)
|
||||
.conflicts_with("auto_tune")
|
||||
.help_heading("Scan settings")
|
||||
.help("Limit number of requests per second (per directory) (default: 0, i.e. no limit)")
|
||||
)
|
||||
|
||||
@@ -555,7 +555,7 @@ fn feroxstates_feroxserialize_implementation() {
|
||||
r#""response_size_limit":4194304"#,
|
||||
r#""filters":[{"filter_code":100},{"word_count":200},{"content_length":300},{"line_count":400},{"compiled":".*","raw_string":".*"},{"hash":1,"original_url":"http://localhost:12345/","cutoff":3}]"#,
|
||||
r#""collected_extensions":["php"]"#,
|
||||
r#""dont_collect":["woff2","woff","ttf","otf","eot","tif","tiff","ico","cur","bmp","webp","svg","png","jpg","jpeg","jfif","gif","avif","apng","pjpeg","pjp","mov","wav","mpg","mpeg","mp3","mp4","m4a","m4p","m4v","ogg","webm","ogv","oga","flac","aac","3gp","css","zip","xls","xml","gz","tgz"]"#,
|
||||
r#""dont_collect":["tif","tiff","ico","cur","bmp","webp","svg","png","jpg","jpeg","jfif","gif","avif","apng","pjpeg","pjp","mov","wav","mpg","mpeg","mp3","mp4","m4a","m4p","m4v","ogg","webm","ogv","oga","flac","aac","3gp","css","zip","xls","xml","gz","tgz"]"#,
|
||||
]
|
||||
.iter()
|
||||
{
|
||||
|
||||
@@ -192,20 +192,6 @@ impl LimitHeap {
|
||||
self.move_up();
|
||||
}
|
||||
|
||||
/// clamp all heap values to a maximum limit
|
||||
///
|
||||
/// this is used when --rate-limit is set alongside --auto-tune to ensure
|
||||
/// that no auto-tuning adjustment can exceed the user's specified rate limit.
|
||||
/// only clamps non-zero values to preserve the "unset" marker (0) used during
|
||||
/// heap construction.
|
||||
pub(super) fn clamp_to_max(&mut self, max: i32) {
|
||||
for i in 0..self.inner.len() {
|
||||
if self.inner[i] > 0 && self.inner[i] > max {
|
||||
self.inner[i] = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// iterate over the backing array, filling in each child's value based on the original value
|
||||
pub(super) fn build(&mut self) {
|
||||
// ex: original is 400
|
||||
|
||||
@@ -31,11 +31,6 @@ pub struct PolicyData {
|
||||
|
||||
/// heap of values used for adjusting # of requests/second
|
||||
pub(super) heap: std::sync::RwLock<LimitHeap>,
|
||||
|
||||
/// maximum limit for requests per second; optionally set by --rate-limit
|
||||
/// if not set, the maximum limit during auto-tuning is unbounded and determined
|
||||
/// dynamically based on the observed request rate
|
||||
pub(super) rate_limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// implementation of PolicyData
|
||||
@@ -55,26 +50,11 @@ impl PolicyData {
|
||||
}
|
||||
}
|
||||
|
||||
/// builder for rate limit
|
||||
///
|
||||
/// builder method chosen to not conflict with existing `new` api
|
||||
pub fn with_rate_limit(mut self, rate_limit: usize) -> Self {
|
||||
self.rate_limit = Some(rate_limit);
|
||||
self
|
||||
}
|
||||
|
||||
/// setter for requests / second; populates the underlying heap with values from req/sec seed
|
||||
pub(super) fn set_reqs_sec(&self, reqs_sec: usize) {
|
||||
if let Ok(mut guard) = self.heap.write() {
|
||||
guard.original = reqs_sec as i32;
|
||||
guard.build();
|
||||
|
||||
if let Some(cap) = self.rate_limit {
|
||||
// if a rate limit was set, clamp the heap to that maximum
|
||||
// this method is only called from tune, which implies that auto-tune is enabled
|
||||
guard.clamp_to_max(cap as i32);
|
||||
}
|
||||
|
||||
self.set_limit(guard.inner[0] as usize); // set limit to 1/2 of current request rate
|
||||
self.heap_initialized.store(true, Ordering::Release);
|
||||
} else {
|
||||
|
||||
@@ -80,18 +80,17 @@ impl Requester {
|
||||
pub fn from(scanner: &FeroxScanner, ferox_scan: Arc<FeroxScan>) -> Result<Self> {
|
||||
let limit = scanner.handles.config.rate_limit;
|
||||
|
||||
let mut policy_data = PolicyData::new(
|
||||
scanner.handles.config.requester_policy,
|
||||
scanner.handles.config.timeout,
|
||||
);
|
||||
|
||||
let rate_limiter = if limit > 0 {
|
||||
policy_data = policy_data.with_rate_limit(limit);
|
||||
Some(Self::build_a_bucket(limit)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let policy_data = PolicyData::new(
|
||||
scanner.handles.config.requester_policy,
|
||||
scanner.handles.config.timeout,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
ferox_scan,
|
||||
policy_data,
|
||||
@@ -286,12 +285,7 @@ impl Requester {
|
||||
} // guard is dropped here automatically
|
||||
|
||||
if atomic_load!(self.policy_data.remove_limit) {
|
||||
if let Some(rate_limit) = self.policy_data.rate_limit {
|
||||
self.set_rate_limiter(Some(rate_limit)).await?;
|
||||
} else {
|
||||
self.set_rate_limiter(None).await?;
|
||||
}
|
||||
|
||||
self.set_rate_limiter(None).await?;
|
||||
atomic_store!(self.policy_data.remove_limit, false);
|
||||
|
||||
// reset the auto-tune state machine so it can be re-triggered if needed
|
||||
@@ -356,15 +350,8 @@ impl Requester {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// cap the initial reqs/sec to the user-specified rate limit if it exists
|
||||
// this ensures that the heap is built in such a way that clamping occurs correctly
|
||||
let seed = if let Some(cap) = self.policy_data.rate_limit {
|
||||
reqs_sec.min(cap)
|
||||
} else {
|
||||
reqs_sec
|
||||
};
|
||||
|
||||
self.policy_data.set_reqs_sec(seed);
|
||||
// only initialize if we have a valid req/sec value
|
||||
self.policy_data.set_reqs_sec(reqs_sec);
|
||||
|
||||
// set the flag to indicate that we have triggered the rate limiter
|
||||
// at least once
|
||||
@@ -464,7 +451,7 @@ impl Requester {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if rate limiting should be applied (either via --rate-limit or auto-tune)
|
||||
// auto_tune is true, or rate_limit was set (mutually exclusive to user)
|
||||
// and a rate_limiter has been created
|
||||
// short-circuiting the lock access behind the first boolean check
|
||||
let should_tune =
|
||||
@@ -1457,301 +1444,4 @@ mod tests {
|
||||
"should_enforce_policy should use per-scan requests, not global"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// verify heap values are clamped when rate_limit cap is set
|
||||
async fn heap_values_clamped_to_rate_limit_cap() {
|
||||
let policy_data = PolicyData::new(RequesterPolicy::AutoTune, 7).with_rate_limit(100);
|
||||
|
||||
// Set a high RPS that exceeds the cap
|
||||
policy_data.set_reqs_sec(500);
|
||||
|
||||
// All heap values should be clamped to 100
|
||||
let heap = policy_data.heap.read().unwrap();
|
||||
for i in 0..heap.inner.len() {
|
||||
if heap.inner[i] > 0 {
|
||||
assert!(
|
||||
heap.inner[i] <= 100,
|
||||
"Heap value at index {} is {}, expected <= 100",
|
||||
i,
|
||||
heap.inner[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Root should be 100 (clamped from 250)
|
||||
assert_eq!(heap.inner[0], 100, "Root should be clamped to cap");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// verify auto-tune with cap adjusts down correctly on errors
|
||||
async fn auto_tune_with_cap_adjusts_down_on_errors() {
|
||||
let policy_data = PolicyData::new(RequesterPolicy::AutoTune, 7).with_rate_limit(100);
|
||||
|
||||
// Build heap with cap of 100
|
||||
policy_data.set_reqs_sec(100);
|
||||
|
||||
// Initial limit should be 50 (half of 100)
|
||||
assert_eq!(policy_data.get_limit(), 50);
|
||||
|
||||
// Adjust down (simulating errors)
|
||||
policy_data.adjust_down();
|
||||
|
||||
// Should move to right child, which is 25
|
||||
assert_eq!(policy_data.get_limit(), 25);
|
||||
|
||||
// Adjust down again
|
||||
policy_data.adjust_down();
|
||||
|
||||
// Should continue moving down the tree
|
||||
let new_limit = policy_data.get_limit();
|
||||
assert!(new_limit < 25, "Limit should decrease further");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// verify auto-tune with cap never exceeds cap on upward adjustment
|
||||
async fn auto_tune_with_cap_never_exceeds_cap_on_upward_adjustment() {
|
||||
let policy_data = PolicyData::new(RequesterPolicy::AutoTune, 7).with_rate_limit(100);
|
||||
|
||||
// Build heap with cap of 100
|
||||
policy_data.set_reqs_sec(100);
|
||||
|
||||
// Move to a low value in the tree
|
||||
{
|
||||
let mut heap = policy_data.heap.write().unwrap();
|
||||
heap.move_to(15); // Deep in the tree
|
||||
}
|
||||
|
||||
// Continuously adjust up with streak counter to reach root
|
||||
for _ in 0..10 {
|
||||
policy_data.adjust_up(&3); // Use high streak to move up faster
|
||||
let current_limit = policy_data.get_limit();
|
||||
assert!(
|
||||
current_limit <= 100,
|
||||
"Limit {} exceeded cap of 100",
|
||||
current_limit
|
||||
);
|
||||
}
|
||||
|
||||
// Should be at or near the cap, but heap navigation may not reach exact root
|
||||
let final_limit = policy_data.get_limit();
|
||||
assert!(
|
||||
(50..=100).contains(&final_limit),
|
||||
"Final limit {} should be between 50 and 100",
|
||||
final_limit
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// verify remove_limit with cap sets to cap instead of removing
|
||||
async fn remove_limit_with_cap_sets_to_cap_instead_of_removing() {
|
||||
let mut config = Configuration::new().unwrap_or_default();
|
||||
config.rate_limit = 100;
|
||||
config.auto_tune = true;
|
||||
config.requester_policy = RequesterPolicy::AutoTune;
|
||||
|
||||
let (handles, _) = setup_requester_test(Some(Arc::new(config))).await;
|
||||
let ferox_scan = Arc::new(FeroxScan::default());
|
||||
|
||||
let policy_data = PolicyData::new(RequesterPolicy::AutoTune, 7).with_rate_limit(100);
|
||||
|
||||
let requester = Requester {
|
||||
handles: handles.clone(),
|
||||
seen_links: RwLock::new(HashSet::<String>::new()),
|
||||
tuning_lock: Mutex::new(0),
|
||||
ferox_scan: ferox_scan.clone(),
|
||||
target_url: "http://localhost".to_string(),
|
||||
rate_limiter: RwLock::new(Some(Requester::build_a_bucket(50).unwrap())),
|
||||
policy_data,
|
||||
policy_triggered: AtomicBool::new(true),
|
||||
};
|
||||
|
||||
// Set remove_limit flag
|
||||
atomic_store!(requester.policy_data.remove_limit, true);
|
||||
|
||||
// Call adjust_limit
|
||||
requester
|
||||
.adjust_limit(PolicyTrigger::Errors, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify limiter was set to cap, not removed
|
||||
let limiter = requester.rate_limiter.read().await;
|
||||
assert!(
|
||||
limiter.is_some(),
|
||||
"Limiter should not be removed when cap exists"
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.as_ref().unwrap().max(),
|
||||
100,
|
||||
"Limiter should be set to cap value"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// verify initial limiter set to cap when both rate_limit and auto_tune are present
|
||||
async fn initial_limiter_set_to_cap_when_both_flags_present() {
|
||||
let mut config = Configuration::new().unwrap_or_default();
|
||||
config.rate_limit = 100;
|
||||
config.auto_tune = true;
|
||||
|
||||
let (handles, _) = setup_requester_test(Some(Arc::new(config))).await;
|
||||
let ferox_scan = Arc::new(FeroxScan::default());
|
||||
|
||||
let policy_data = PolicyData::new(RequesterPolicy::AutoTune, 7).with_rate_limit(100);
|
||||
|
||||
// Manually construct requester to verify initialization
|
||||
let requester = Requester {
|
||||
handles: handles.clone(),
|
||||
seen_links: RwLock::new(HashSet::<String>::new()),
|
||||
tuning_lock: Mutex::new(0),
|
||||
ferox_scan: ferox_scan.clone(),
|
||||
target_url: "http://localhost".to_string(),
|
||||
rate_limiter: RwLock::new(Some(Requester::build_a_bucket(100).unwrap())),
|
||||
policy_data,
|
||||
policy_triggered: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
// Verify initial limiter is set
|
||||
let limiter = requester.rate_limiter.read().await;
|
||||
assert!(limiter.is_some(), "Limiter should be initialized");
|
||||
assert_eq!(
|
||||
limiter.as_ref().unwrap().max(),
|
||||
100,
|
||||
"Initial limiter should be set to rate_limit value"
|
||||
);
|
||||
|
||||
// Verify policy_data has the cap
|
||||
assert_eq!(
|
||||
requester.policy_data.rate_limit,
|
||||
Some(100),
|
||||
"PolicyData should have rate_limit set"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// Full lifecycle test: --rate-limit 100 --auto-tune
|
||||
/// Simulates errors triggering reduction, then success allowing increase, never exceeding cap
|
||||
async fn capped_auto_tune_full_lifecycle() {
|
||||
let mut config = Configuration::new().unwrap_or_default();
|
||||
config.rate_limit = 100;
|
||||
config.auto_tune = true;
|
||||
config.requester_policy = RequesterPolicy::AutoTune;
|
||||
config.threads = 50;
|
||||
|
||||
let (handles, _) = setup_requester_test(Some(Arc::new(config))).await;
|
||||
|
||||
// Create a proper Directory scan that will report as active
|
||||
let ferox_scan = FeroxScan::new(
|
||||
"http://localhost",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
true,
|
||||
handles.clone(),
|
||||
);
|
||||
|
||||
// Simulate scan running - need at least 2 req/s for tune() to initialize
|
||||
ferox_scan.set_status(ScanStatus::Running).unwrap();
|
||||
ferox_scan.set_start_time(Instant::now()).unwrap();
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Add enough requests to get RPS >= 2 (100 requests in 0.1s = 1000 req/s)
|
||||
ferox_scan.progress_bar().inc(100);
|
||||
|
||||
let policy_data = PolicyData::new(RequesterPolicy::AutoTune, 7).with_rate_limit(100);
|
||||
|
||||
let requester = Requester {
|
||||
handles: handles.clone(),
|
||||
seen_links: RwLock::new(HashSet::<String>::new()),
|
||||
tuning_lock: Mutex::new(0),
|
||||
ferox_scan: ferox_scan.clone(),
|
||||
target_url: "http://localhost".to_string(),
|
||||
rate_limiter: RwLock::new(Some(Requester::build_a_bucket(100).unwrap())),
|
||||
policy_data,
|
||||
policy_triggered: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
// Step 1: Trigger auto-tune due to errors
|
||||
for _ in 0..50 {
|
||||
ferox_scan.add_error();
|
||||
}
|
||||
|
||||
requester.tune(PolicyTrigger::Errors).await.unwrap();
|
||||
|
||||
// Heap should be initialized now (RPS is high, capped to 100)
|
||||
assert!(
|
||||
requester.policy_data.heap_initialized(),
|
||||
"Heap should be initialized after tune()"
|
||||
);
|
||||
|
||||
let initial_limit = requester.policy_data.get_limit();
|
||||
assert!(
|
||||
initial_limit <= 100,
|
||||
"Initial limit {} should not exceed cap",
|
||||
initial_limit
|
||||
);
|
||||
assert_eq!(
|
||||
initial_limit, 50,
|
||||
"Initial limit should be 50 (half of capped seed 100)"
|
||||
);
|
||||
|
||||
// Step 2: More errors - adjust down
|
||||
// Don't reset policy errors - they're already set to 50 from tune()
|
||||
// Add more scan errors so scan_errors (75) > policy_errors (50)
|
||||
for _ in 0..25 {
|
||||
ferox_scan.add_error();
|
||||
}
|
||||
|
||||
requester
|
||||
.adjust_limit(PolicyTrigger::Errors, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let reduced_limit = requester.policy_data.get_limit();
|
||||
assert!(
|
||||
reduced_limit < initial_limit,
|
||||
"Limit should decrease on errors: {} < {}",
|
||||
reduced_limit,
|
||||
initial_limit
|
||||
);
|
||||
|
||||
// Step 3: Success - adjust up multiple times
|
||||
// Set policy errors higher than scan errors to trigger upward adjustment
|
||||
requester.policy_data.set_errors(PolicyTrigger::Errors, 200);
|
||||
for i in 0..5 {
|
||||
requester
|
||||
.adjust_limit(PolicyTrigger::Errors, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let current_limit = requester.policy_data.get_limit();
|
||||
|
||||
// Should never exceed cap
|
||||
assert!(
|
||||
current_limit <= 100,
|
||||
"Iteration {}: Limit {} exceeded cap of 100",
|
||||
i,
|
||||
current_limit
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Verify limiter stays at cap (not removed)
|
||||
atomic_store!(requester.policy_data.remove_limit, true);
|
||||
requester
|
||||
.adjust_limit(PolicyTrigger::Errors, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let final_limiter = requester.rate_limiter.read().await;
|
||||
assert!(
|
||||
final_limiter.is_some(),
|
||||
"Limiter should not be removed when cap exists"
|
||||
);
|
||||
assert_eq!(
|
||||
final_limiter.as_ref().unwrap().max(),
|
||||
100,
|
||||
"Limiter should be at cap value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ impl PolicyTrigger {
|
||||
PolicyTrigger::Status429 => 1,
|
||||
PolicyTrigger::Errors => 2,
|
||||
PolicyTrigger::TryAdjustUp => {
|
||||
unreachable!("TryAdjustUp should never be used to access the errors array");
|
||||
unreachable!("TryAdjustUp should never be used to access the errors array")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ impl DynamicSemaphore {
|
||||
/// Ok(permit) => println!("Got permit"),
|
||||
/// Err(TryAcquireError::NoPermits) => println!("No permits available"),
|
||||
/// Err(TryAcquireError::Closed) => println!("Semaphore closed"),
|
||||
/// };
|
||||
/// }
|
||||
/// ```
|
||||
pub fn try_acquire(&self) -> Result<DynamicSemaphorePermit<'_>, tokio::sync::TryAcquireError> {
|
||||
// Check if we're already at or over capacity
|
||||
|
||||
@@ -304,99 +304,3 @@ fn scenario_mixed_steady_state() {
|
||||
// With mixed but not extreme errors, should see some adjustments
|
||||
assert!(total > 100, "Should complete significant portion of scan");
|
||||
}
|
||||
|
||||
/// Scenario 5: Capped auto-tune - --rate-limit caps --auto-tune adjustments
|
||||
#[test]
|
||||
fn scenario_capped_auto_tune() {
|
||||
let srv = MockServer::start();
|
||||
let (tmp_dir, file) = setup_tmp_directory(&[], "wordlist").unwrap();
|
||||
let (log_dir, logfile) = setup_tmp_directory(&[], "debug-log").unwrap();
|
||||
|
||||
// Pattern: errors first to trigger rate limiting, then normal responses to allow upward adjustment
|
||||
// The rate limit cap should prevent exceeding the specified limit
|
||||
let mut wordlist = Vec::new();
|
||||
|
||||
// Start with many errors to trigger auto-tune
|
||||
for i in 0..200 {
|
||||
wordlist.push(format!("s403_{:04}", i));
|
||||
}
|
||||
|
||||
// Then many normal responses to allow upward adjustment
|
||||
for i in 0..400 {
|
||||
wordlist.push(format!("normal_{:04}", i));
|
||||
}
|
||||
|
||||
write(&file, wordlist.join("\n")).unwrap();
|
||||
|
||||
let _normal_mock = srv.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path_matches(Regex::new("/normal_.*").unwrap());
|
||||
then.status(200).body("OK");
|
||||
});
|
||||
|
||||
let _error_mock = srv.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path_matches(Regex::new("/s403_.*").unwrap());
|
||||
then.status(403).body("Forbidden");
|
||||
});
|
||||
|
||||
Command::cargo_bin("feroxbuster")
|
||||
.unwrap()
|
||||
.arg("--url")
|
||||
.arg(srv.url("/"))
|
||||
.arg("--wordlist")
|
||||
.arg(file.as_os_str())
|
||||
.arg("--auto-tune")
|
||||
.arg("--rate-limit")
|
||||
.arg("50") // Cap at 50 req/s
|
||||
.arg("--dont-filter")
|
||||
.arg("--threads")
|
||||
.arg("10")
|
||||
.arg("--debug-log")
|
||||
.arg(logfile.as_os_str())
|
||||
.arg("--json")
|
||||
.arg("-vv")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let debug_log = read_to_string(&logfile).unwrap();
|
||||
|
||||
let mut auto_tune_triggered = false;
|
||||
let mut max_rate_seen = 0;
|
||||
|
||||
for line in debug_log.lines() {
|
||||
if let Ok(log) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(msg) = log.get("message").and_then(|m| m.as_str()) {
|
||||
// Check for auto-tune activation
|
||||
if msg.contains("auto-tune:") && msg.contains("enforcing limit") {
|
||||
auto_tune_triggered = true;
|
||||
}
|
||||
|
||||
// Extract rate values from messages like "set rate limit (25/s)" or "scan speed (30/s)"
|
||||
if msg.contains("/s)") {
|
||||
if let Some(start) = msg.rfind('(') {
|
||||
if let Some(end) = msg.rfind("/s)") {
|
||||
if let Ok(rate) = msg[start + 1..end].parse::<usize>() {
|
||||
max_rate_seen = max_rate_seen.max(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teardown_tmp_directory(tmp_dir);
|
||||
teardown_tmp_directory(log_dir);
|
||||
|
||||
assert!(
|
||||
auto_tune_triggered,
|
||||
"Auto-tune should be triggered by errors"
|
||||
);
|
||||
|
||||
assert!(
|
||||
max_rate_seen <= 50,
|
||||
"Auto-tune should never exceed rate-limit cap of 50, but saw {}",
|
||||
max_rate_seen
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user