Compare commits

..

20 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
9f6da2abfc Reduce errors array size from 4 to 3 and make TryAdjustUp unreachable in as_index()
Co-authored-by: epi052 <43392618+epi052@users.noreply.github.com>
2025-11-16 22:11:49 +00:00
copilot-swe-agent[bot]
b248b2d9b9 Initial plan 2025-11-16 22:04:38 +00:00
epi
36a366eb55 fixed a handful of minor correctness issues 2025-11-16 16:25:37 -05:00
epi
c9a7abb8f7 fixed possible deadlock in error path for tune/bail 2025-11-16 09:57:37 -05:00
epi
c597ec2bc1 clippy/fmt 2025-11-16 09:10:10 -05:00
epi
c512669d3a added new test suite for tuning; fixed more tests 2025-11-16 09:05:29 -05:00
epi
100bcbfbc4 fixed more tests 2025-11-16 09:05:01 -05:00
epi
5543fa5d36 fixed req/sec test 2025-11-16 08:17:26 -05:00
epi
38ab434642 touched up a few minor issues in nlp 2025-11-16 08:03:58 -05:00
epi
72ab2d9a58 fixed race condition in progress bar message display; fixed tests 2025-11-16 07:58:34 -05:00
epi
f57087c0f9 updated requester to use new policy data per-trigger errors 2025-11-16 06:46:44 -05:00
epi
d0e2419554 added per-trigger error tracking to policy data 2025-11-16 06:46:12 -05:00
epi
4390ac0500 capped timeout to 30sec; added lock error logging 2025-11-16 06:19:33 -05:00
epi
49c3851a85 added (more) safety/bounds checks to limitheap 2025-11-15 22:27:47 -05:00
epi
0881295234 cleaned up how limitheap is initialized from tune func 2025-11-15 22:20:03 -05:00
epi
e673ae3e76 added new flag releases before returns from should_enforce_policy 2025-11-15 21:56:09 -05:00
epi
cb55880aaa removed minor toctou in should_enforce_policy 2025-11-15 21:53:33 -05:00
epi
45ee292110 removed unnecessary cooldown flag manipulation in cool_down func 2025-11-15 21:51:18 -05:00
epi
a197d1994b ensured limit var is never 0 in build_a_bucket, not just refill 2025-11-15 21:46:47 -05:00
epi
9e0f47acdf fixed requests/sec for small values 2025-11-15 21:41:33 -05:00
25 changed files with 62 additions and 1474 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -946,7 +946,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "feroxbuster"
version = "2.13.1"
version = "2.13.0"
dependencies = [
"anyhow",
"assert_cmd",

View File

@@ -1,6 +1,6 @@
[package]
name = "feroxbuster"
version = "2.13.1"
version = "2.13.0"
authors = ["Ben 'epi' Risher (@epi052)"]
license = "MIT"
edition = "2021"

View File

@@ -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"
"""

View File

@@ -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>

View File

@@ -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' \

View File

@@ -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)')

View File

@@ -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)'

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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());
}
}

View File

@@ -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.
///

View 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?
};

View File

@@ -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)")
)

View File

@@ -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()
{

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"
);
}
}

View File

@@ -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")
}
}
}

View File

@@ -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

View File

@@ -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
);
}