Compare commits

...

783 Commits

Author SHA1 Message Date
epi
2d381e7e05 added logo for chocolatey packaging 2023-03-08 06:20:37 -06:00
epi
7d26f368f5 Merge pull request #808 from epi052/fix-wildcard-directory-redirect-v2
Fix wildcard directory redirect v2
2023-03-08 06:14:27 -06:00
epi
36970896ca Merge pull request #810 from epi052/all-contributors/add-aancw
docs: add aancw as a contributor for code, and infra
2023-03-08 05:54:56 -06:00
epi
39a75f0608 Merge pull request #804 from aancw/scanmanager-banner
Showing banner again after finish scan management menu
2023-03-08 05:54:26 -06:00
allcontributors[bot]
ab8537beeb docs: update .all-contributorsrc [skip ci] 2023-03-08 11:54:12 +00:00
allcontributors[bot]
9e907d37d5 docs: update README.md [skip ci] 2023-03-08 11:54:11 +00:00
epi
19e0a7f48b Merge branch 'main' into fix-wildcard-directory-redirect-v2 2023-03-08 05:50:29 -06:00
epi
5e93da0a65 fixed #809; thorough/smart bypassed mutual exclusion 2023-03-08 05:29:30 -06:00
Aan
2704e33178 Update the code as requested in suggestion 2023-03-08 14:47:39 +07:00
epi
8392f6d26b fixed menu filter display; fixed wildcard filter comparison 2023-03-07 21:14:20 -06:00
epi
ca43a767d2 fixed failing test 2023-03-07 20:15:10 -06:00
epi
291ccedba3 clippy 2023-03-07 18:54:32 -06:00
epi
6d01bc8ec4 added a few tests taht were removed previously 2023-03-07 18:38:10 -06:00
epi
94aafccf8a bumped version 2023-03-07 06:30:53 -06:00
epi
8dd8871ae5 old tests pass 2023-03-07 06:27:24 -06:00
epi
ad0df8ccd3 updated deps 2023-03-07 06:00:00 -06:00
epi
31cdba64e4 fmt 2023-03-06 20:44:24 -06:00
epi
584fc940cd implemented fix for wildcard directories 2023-03-06 20:44:14 -06:00
Aan
43116f9aab Showing banner again after finish scan management menu 2023-03-06 19:49:50 +07:00
Aan
aec083ea58 Showing banner again after finish scan management menu 2023-03-06 19:47:33 +07:00
epi
52d08e504d Merge pull request #801 from epi052/all-contributors/add-Luoooio
docs: add Luoooio as a contributor for ideas
2023-02-28 15:55:12 -06:00
allcontributors[bot]
a254574ce7 docs: update .all-contributorsrc [skip ci] 2023-02-28 21:55:03 +00:00
allcontributors[bot]
6cb7c8e342 docs: update README.md [skip ci] 2023-02-28 21:55:02 +00:00
epi
98670f367f Merge pull request #800 from epi052/all-contributors/add-xaeroborg
docs: add xaeroborg as a contributor for ideas
2023-02-28 15:53:42 -06:00
allcontributors[bot]
68913c9950 docs: update .all-contributorsrc [skip ci] 2023-02-28 21:53:34 +00:00
allcontributors[bot]
5901c75187 docs: update README.md [skip ci] 2023-02-28 21:53:33 +00:00
epi
8499901bfe Merge pull request #799 from epi052/all-contributors/add-pich4ya
docs: add pich4ya as a contributor for ideas
2023-02-28 15:52:00 -06:00
allcontributors[bot]
69dcb38360 docs: update .all-contributorsrc [skip ci] 2023-02-28 21:51:46 +00:00
allcontributors[bot]
eb8b70668d docs: update README.md [skip ci] 2023-02-28 21:51:45 +00:00
epi
f0702794b0 Merge pull request #796 from epi052/751-resume-scan-with-offset
resume scan starts from offset in wordlist
2023-02-28 06:32:17 -06:00
epi
367dcdbd72 fixed hanging test 2023-02-28 06:06:53 -06:00
epi
4b7a25c13b fixed ordering of dir bars / overall bar; fixed overall offset when resuming 2023-02-27 19:25:17 -06:00
epi
339189ff13 resume scan starts from offset in wordlist 2023-02-27 07:26:59 -06:00
epi
ed701c13b0 Merge pull request #794 from epi052/784-content-based-auto-filtering
Content-based auto filtering
2023-02-26 19:50:41 -06:00
epi
e034734df4 Merge branch 'main' into 784-content-based-auto-filtering 2023-02-26 13:21:55 -06:00
epi
73e2558404 fmt 2023-02-26 13:05:37 -06:00
epi
eb7ad68c01 all tests passing 2023-02-26 12:48:02 -06:00
epi
c61688f984 fmt 2023-02-26 07:31:03 -06:00
epi
6c96589ca5 fixed some tests 2023-02-26 07:30:41 -06:00
epi
0437c2baac updated deps harder 2023-02-26 07:30:28 -06:00
epi
0d689942eb updated deps 2023-02-26 06:54:23 -06:00
epi
74a1a8d597 bumped version to 2.8.0 2023-02-26 06:50:31 -06:00
epi
729d88a724 clippy 2023-02-26 06:49:48 -06:00
epi
ad38b56473 finalized new detections; removed wildcard filter and supporting code 2023-02-26 06:39:03 -06:00
epi
655364d9bd removed wildcard test, integrated into 404 detection 2023-02-25 20:58:28 -06:00
epi
ac7f59cd3f updated default status codes to all; adjusted banner entry 2023-02-25 07:28:27 -06:00
epi
0d64d28fe6 removed cruft 2023-02-25 06:28:14 -06:00
epi
89c29600c7 removed cruft 2023-02-25 06:23:39 -06:00
epi
96375e7734 added minhash algo when resp too short for ssdeep 2023-02-25 06:20:30 -06:00
epi
3531b8c74b added gaoya dependency for minhash algo 2023-02-25 06:20:08 -06:00
epi
e8f4438a52 fixed bug in dynamic wildcards; reorded 404-like id strat 2023-02-24 20:09:29 -06:00
epi
02b25dc553 incremental save for testing 2023-02-23 17:21:48 -06:00
epi
551cf065f3 Merge pull request #793 from epi052/all-contributors/add-f3rn0s
docs: add f3rn0s as a contributor for bug
2023-02-16 20:30:41 -06:00
allcontributors[bot]
c81885cf5e docs: update .all-contributorsrc [skip ci] 2023-02-17 02:30:28 +00:00
allcontributors[bot]
6a3d250e3b docs: update README.md [skip ci] 2023-02-17 02:30:27 +00:00
epi
259fbcca74 Merge pull request #790 from epi052/all-contributors/add-joaociocca
docs: add joaociocca as a contributor for bug, and ideas
2023-02-15 20:50:31 -06:00
allcontributors[bot]
f3c9f8ed20 docs: update .all-contributorsrc [skip ci] 2023-02-16 02:49:52 +00:00
allcontributors[bot]
be400ce971 docs: update README.md [skip ci] 2023-02-16 02:49:51 +00:00
epi
b62c76bce3 updated deps 2023-02-15 20:44:07 -06:00
epi
990a471d71 Merge pull request #779 from epi052/fix-some-visuals
Fix some visuals; update deps
2023-02-15 20:39:58 -06:00
epi
ec47d6f934 updated deps 2023-02-15 20:31:54 -06:00
epi
da509bd208 clippy 2023-02-15 19:39:27 -06:00
epi
8568b340a9 clippy 2023-02-15 19:38:23 -06:00
epi
7c9d8f529d tweaked auto-tune behavior to more aggressively move upward 2023-02-15 19:36:16 -06:00
epi
6d47b4b68b fixed a case where the --dont-filter message wasnt shown 2023-02-15 07:04:48 -06:00
epi
be3290572e fallback to body.len when content-length header missing 2023-02-15 06:40:35 -06:00
epi
4f13fd7974 Merge branch 'fix-some-visuals' of github.com:epi052/feroxbuster into fix-some-visuals 2023-02-07 05:33:50 -06:00
epi
57b8117015 fixed stale file reference 2023-02-05 20:33:53 -06:00
epi
7b4900fa07 caught a comparison bug 2023-02-01 18:51:29 -06:00
epi
d1e47b0025 added messaging about state of auto-tune/bail 2023-01-30 16:46:29 -06:00
epi
98612e2256 changed auto-tune emoji to align across different terminals 2023-01-30 16:46:03 -06:00
epi
f08023b813 Update README.md 2023-01-13 05:53:18 -06:00
epi
98254e3cac Update README.md 2023-01-13 05:52:55 -06:00
epi
46cc64325f Update README.md 2023-01-13 05:51:02 -06:00
epi
fc034f0720 Update README.md 2023-01-13 05:49:51 -06:00
epi
ef4a597cb1 Update README.md 2023-01-13 05:48:56 -06:00
epi
bb6b12d168 Merge branch 'main' of github.com:epi052/feroxbuster 2023-01-13 05:32:53 -06:00
epi
21a9de2d39 fixed code coverage workflow 2023-01-13 05:32:48 -06:00
epi
7d8f3b0305 Merge pull request #764 from epi052/all-contributors/add-aidanhall34
docs: add aidanhall34 as a contributor for code, and infra
2023-01-13 05:13:39 -06:00
allcontributors[bot]
6b3fe48b4f docs: update .all-contributorsrc [skip ci] 2023-01-13 11:12:49 +00:00
allcontributors[bot]
7a79000d96 docs: update README.md [skip ci] 2023-01-13 11:12:48 +00:00
epi
d164034d3e Merge pull request #762 from aidanhall34/NA-dockerfile
Fixes #761 | Updated Dockerfile and CONTRIBUTING docs
2023-01-13 05:12:25 -06:00
aidan.hall34
e4dc7da756 Remove edge branch, update alpine 2023-01-12 23:34:03 +00:00
aidan.hall34
6090cefa4f Updated Dockerfile and CONTRIBUTING docs 2023-01-12 14:16:58 +00:00
epi
ec05644854 Merge pull request #753 from epi052/all-contributors/add-duokebei
docs: add duokebei as a contributor for ideas
2022-12-29 20:31:42 -06:00
allcontributors[bot]
567f927884 docs: update .all-contributorsrc [skip ci] 2022-12-30 02:31:17 +00:00
allcontributors[bot]
176a6a6426 docs: update README.md [skip ci] 2022-12-30 02:31:16 +00:00
epi
c99f6146e3 Update README.md 2022-12-29 20:29:56 -06:00
epi
fb34817509 Merge pull request #752 from epi052/all-contributors/add-hakdogpinas
docs: add hakdogpinas as a contributor for ideas
2022-12-29 20:29:26 -06:00
epi
0c8e5d51f0 Update .all-contributorsrc 2022-12-29 20:27:05 -06:00
allcontributors[bot]
ac24e507ac docs: update .all-contributorsrc [skip ci] 2022-12-30 02:24:54 +00:00
allcontributors[bot]
808c749f63 docs: update README.md [skip ci] 2022-12-30 02:24:53 +00:00
epi
b1f5ed507b Merge pull request #750 from epi052/742-748-state-file-bug-fixes
multiple bug fixes / small improvements
2022-12-29 19:57:23 -06:00
epi
79edc42b17 bumped version to 2.7.3 2022-12-29 15:56:56 -06:00
epi
1b223b0867 fixed #716; wordlist entries with leading slash are trimmed 2022-12-29 15:55:50 -06:00
epi
0c6d5193a9 fixed #743; redirects always show full url as Location 2022-12-29 15:43:57 -06:00
epi
c637355796 clippy 2022-12-29 15:32:22 -06:00
epi
a114cc8f85 fixed #748; cancelled scans persist across ctrl+c 2022-12-29 15:28:58 -06:00
epi
c8503faf02 updated dependencies 2022-12-29 07:03:03 -06:00
epi
cbbd642510 Merge pull request #749 from n0kovo/main
Fix incorrect username in Contributors
2022-12-29 06:59:34 -06:00
n0kovo
2c8e9bace9 Fix incorrect username in Contributors 2022-12-29 13:37:41 +01:00
epi
f4fe8c0544 Merge pull request #734 from epi052/all-contributors/add-kmanc
docs: add kmanc as a contributor for bug, and code
2022-12-14 06:09:50 -06:00
allcontributors[bot]
73109483fe docs: update .all-contributorsrc [skip ci] 2022-12-14 12:09:23 +00:00
allcontributors[bot]
aee33012b1 docs: update README.md [skip ci] 2022-12-14 12:09:22 +00:00
epi
eab95e0435 Merge pull request #733 from kmanc/bugfix-no-state-with-time-limit
FIX 732 ensure --no-state is respected even through --time-limit
2022-12-14 06:08:50 -06:00
koins
acb2f42f69 ensure --no-state is respected even through --time-limit 2022-12-13 22:37:27 -08:00
epi
ac20b213ec Merge branch 'main' of github.com:epi052/feroxbuster 2022-11-16 16:53:02 -06:00
epi
201873d7ac bumped version to 2.7.2 2022-11-16 16:52:35 -06:00
epi
9678b8f31c Merge pull request #708 from epi052/all-contributors/add-udoprog
docs: add udoprog as a contributor for code
2022-11-16 16:41:55 -06:00
allcontributors[bot]
20a826fc0f docs: update .all-contributorsrc [skip ci] 2022-11-16 22:41:13 +00:00
allcontributors[bot]
56b78a4e04 docs: update README.md [skip ci] 2022-11-16 22:41:12 +00:00
epi
4b6bf3645d bumped version to 2.7.2 2022-11-16 16:35:34 -06:00
epi
6fd201b717 clippy 2022-11-16 16:21:32 -06:00
epi
5f39d71fe8 updated deps; clippy 2022-11-16 16:20:09 -06:00
epi
c23850208b added link tag to html extraction 2022-11-16 16:01:01 -06:00
epi
d5605efb08 Merge pull request #706 from epi052/689-invalid-uri-during-extraction
fixed invalid uri exception during extraction
2022-11-16 07:44:48 -06:00
epi
5b8d3f5661 removed cruft 2022-11-16 07:42:09 -06:00
epi
ce7f3b79b8 fixed invalid uri exception during extraction 2022-11-16 07:09:02 -06:00
epi
c9c63bebd0 Merge pull request #672 from epi052/661-fix-double-dir-scan
661 fix double dir scan
2022-10-05 05:32:09 -05:00
epi
1f60e06247 turned off builds for all but main 2022-10-05 05:30:00 -05:00
epi
04e3ad69cc allowing a test build to happen 2022-10-04 07:09:40 -05:00
epi
fd5b1f6f25 refined the fix; updated tests and serialization 2022-10-04 07:07:24 -05:00
epi
a9dde3f7e1 normalized directory scan input + search in feroxscans 2022-10-04 05:45:34 -05:00
epi
7a9ee39941 Merge pull request #671 from epi052/update-clap-major
updated clap from 3.x to 4.x
2022-10-03 05:27:10 -05:00
epi
6befae1a93 updated clap from 3.x to 4.x 2022-10-03 05:16:50 -05:00
epi
e6b422b92a Merge pull request #670 from epi052/update-console
updated deps
2022-10-01 13:22:54 -05:00
epi
fb4bfa27fd updated deps 2022-10-01 13:21:41 -05:00
epi
2795ec4e72 fixed typo; closes #660 2022-09-27 06:21:51 -05:00
epi
e9d283bc59 Merge pull request #655 from epi052/update-deps
Update deps
2022-09-20 04:53:36 -05:00
epi
3a128df2fc clippy 2022-09-20 04:52:11 -05:00
epi
38f1b917c4 clippy 2022-09-20 04:49:58 -05:00
epi
afa7d6804c clippy 2022-09-18 05:51:13 -05:00
epi
28c3e25eeb updated deps 2022-09-18 05:50:39 -05:00
epi
55e22467ce clippy issues 2022-09-18 05:50:15 -05:00
epi
bbfaddaedd fixed #644; methods respected from config 2022-09-06 08:02:30 -05:00
epi
53e3420efd updated deps 2022-07-10 16:44:16 -05:00
epi
d390bbc12d Merge branch 'leakybucket-update' 2022-07-10 16:33:22 -05:00
epi
0c3b91855a Merge pull request #604 from udoprog/bump-leaky-bucket
Bump leaky-bucket to 0.12.1
2022-07-10 16:33:07 -05:00
epi
48f5362f5f appeased clippy 2022-07-10 16:30:52 -05:00
John-John Tedro
24514faf9e Bump leaky-bucket to 0.12.1 2022-07-10 18:20:48 +02:00
epi
a424057166 Merge pull request #581 from epi052/all-contributors/add-herrcykel
docs: add herrcykel as a contributor for code
2022-05-17 18:42:47 -05:00
epi
7d483b6edd Merge pull request #580 from herrcykel/patch-1
Remove superfluous if statement
2022-05-17 18:42:19 -05:00
allcontributors[bot]
1a717e878d docs: update .all-contributorsrc [skip ci] 2022-05-17 23:41:59 +00:00
allcontributors[bot]
e35a6dda9f docs: update README.md [skip ci] 2022-05-17 23:41:58 +00:00
O
f3b2193b2f Remove superfluous if statement 2022-05-17 23:31:29 +02:00
epi
07a7ac652e updated deps 2022-05-12 06:15:17 -05:00
epi
f51993cde0 Merge pull request #575 from epi052/all-contributors/add-postmodern
docs: add postmodern as a contributor for ideas
2022-05-12 06:03:16 -05:00
allcontributors[bot]
9093ffb92a docs: update .all-contributorsrc [skip ci] 2022-05-12 11:03:09 +00:00
allcontributors[bot]
d550448229 docs: update README.md [skip ci] 2022-05-12 11:03:08 +00:00
epi
492665154e Merge pull request #574 from epi052/all-contributors/add-DonatoReis
docs: add DonatoReis as a contributor for ideas
2022-05-12 06:02:21 -05:00
allcontributors[bot]
c14e617076 docs: update .all-contributorsrc [skip ci] 2022-05-12 11:02:03 +00:00
allcontributors[bot]
6bb748af17 docs: update README.md [skip ci] 2022-05-12 11:02:03 +00:00
epi
863ea089cc Merge pull request #573 from epi052/all-contributors/add-jhaddix
docs: add jhaddix as a contributor for bug
2022-05-12 06:01:24 -05:00
allcontributors[bot]
ad3091a7db docs: update .all-contributorsrc [skip ci] 2022-05-12 11:00:30 +00:00
allcontributors[bot]
b2bdea71dd docs: update README.md [skip ci] 2022-05-12 11:00:30 +00:00
epi
f478700b86 Merge pull request #564 from epi052/563-fix-leaky-bucket-unwrap-to-none
563 fix leaky bucket unwrap to none
2022-05-11 20:09:46 -05:00
epi
1f2aad5e52 updated deps 2022-05-11 17:29:59 -05:00
epi
3a6a61cc24 updated dependencies 2022-05-11 17:24:09 -05:00
epi
0311a846b3 added secondary wordlist check to main 2022-05-11 17:14:13 -05:00
epi
3066efa848 add https if missing url scheme; check /usr/local/share for wordlist 2022-05-10 06:45:10 -05:00
epi
a8fae65d63 allow extensions with prepended . 2022-05-10 06:44:21 -05:00
epi
970886a68b reverted actions change 2022-05-10 05:51:03 -05:00
epi
494eed81e8 fmt 2022-05-05 19:19:01 -05:00
epi
c8a577b1e7 removed unwrap from limit function 2022-05-05 19:18:29 -05:00
epi
ccb10c1c68 Update README.md 2022-04-15 05:53:58 -05:00
epi
20ab0aade3 Update README.md 2022-04-15 05:52:14 -05:00
epi
02ad0b1d85 Update README.md 2022-04-15 05:49:58 -05:00
epi
09aad922c1 Update README.md 2022-04-15 05:48:49 -05:00
epi
697f947bfa Update README.md 2022-04-15 05:47:42 -05:00
epi
d300d68737 Update README.md 2022-04-15 05:46:39 -05:00
epi
c2c6854db4 Merge pull request #541 from epi052/all-contributors/add-Flangyver
docs: add Flangyver as a contributor for ideas
2022-04-15 05:45:26 -05:00
allcontributors[bot]
63be575d89 docs: update .all-contributorsrc [skip ci] 2022-04-15 10:45:17 +00:00
allcontributors[bot]
0d25fda11e docs: update README.md [skip ci] 2022-04-15 10:45:16 +00:00
epi
b0341c2432 Merge pull request #540 from epi052/all-contributors/add-0xdf223
docs: add 0xdf223 as a contributor for bug, ideas
2022-04-15 05:42:54 -05:00
allcontributors[bot]
62352db152 docs: update .all-contributorsrc [skip ci] 2022-04-15 10:42:41 +00:00
allcontributors[bot]
3090edc49c docs: update README.md [skip ci] 2022-04-15 10:42:40 +00:00
epi
85d686d1aa Merge pull request #539 from epi052/all-contributors/add-ThisLimn0
docs: add ThisLimn0 as a contributor for bug
2022-04-15 05:41:41 -05:00
allcontributors[bot]
17138f4ef7 docs: update .all-contributorsrc [skip ci] 2022-04-15 10:41:28 +00:00
allcontributors[bot]
1d30b7db31 docs: update README.md [skip ci] 2022-04-15 10:41:27 +00:00
epi
4c0d3c91a0 Merge pull request #536 from epi052/535-status-code-filter-overhaul
535 status code filter overhaul
2022-04-15 05:39:55 -05:00
epi
96fc6b232a update 2022-04-14 21:08:33 -05:00
epi
9b306aad34 update 2022-04-14 16:45:14 -05:00
epi
10eee184d0 update 2022-04-14 16:42:25 -05:00
epi
986161f05f update 2022-04-14 16:39:50 -05:00
epi
4a19dbfd7d update 2022-04-14 16:14:23 -05:00
epi
b8ceeaff0f update 2022-04-14 16:07:37 -05:00
epi
d04e58036e update 2022-04-14 13:10:56 -05:00
epi
d1a74207f4 one more again 2022-04-14 08:13:51 -05:00
epi
03a36f0b60 update 2022-04-14 07:54:43 -05:00
epi
f2d9269643 update 2022-04-14 07:44:35 -05:00
epi
bba7cba02e update 2022-04-14 06:46:26 -05:00
epi
fffd1e5c82 update 2022-04-14 06:44:51 -05:00
epi
3c6da0f782 update 2022-04-14 06:40:35 -05:00
epi
5ecd937c0e reverted heuristics tests 2022-04-14 06:25:55 -05:00
epi
9f6221daf6 removed more toolchain actions 2022-04-14 06:18:00 -05:00
epi
af49fd8e62 attempting to remove toolchain action 2022-04-14 06:15:14 -05:00
epi
d1daefd8ba removed allows from ci clippy 2022-04-14 06:12:42 -05:00
epi
3e8255d5b7 reverted ci back to normal 2022-04-14 06:11:38 -05:00
epi
5af18e83d8 ci tweak 2022-04-14 05:48:43 -05:00
epi
d1d0757d56 test troubleshoot 2022-04-14 05:28:20 -05:00
epi
f5f9344a81 test troubleshoot 2022-04-14 05:09:50 -05:00
epi
fd52e39188 reverted build to main only 2022-04-13 20:44:57 -05:00
epi
22377dc9a3 trying again with new ci configs 2022-04-13 20:43:34 -05:00
epi
1cf7dff734 trying again with new ci configs 2022-04-13 20:34:16 -05:00
epi
7c9eb900b7 reverted test action 2022-04-13 20:14:57 -05:00
epi
8480b3cc2c reverted coverage 2022-04-13 20:01:18 -05:00
epi
9d29142046 lint 2022-04-13 19:45:30 -05:00
epi
38c194b222 tweaked coverage action 2022-04-13 19:36:05 -05:00
epi
72dc14bf3d fixed nlp tests 2022-04-13 19:20:24 -05:00
epi
9a7c690c17 changed to SecLists 2022-04-13 17:55:33 -05:00
epi
de4514e381 test build windows 2022-04-13 17:27:15 -05:00
epi
2be8aaf2bf changed -w default when on windows host 2022-04-13 17:19:09 -05:00
epi
4db3a0b056 updated help a bit 2022-04-13 16:40:51 -05:00
epi
a9ef7f180f force-recursion and no-recursion are mutually exclusive 2022-04-13 16:34:52 -05:00
epi
ac7cb5d6b6 lint / tests 2022-04-13 06:25:35 -05:00
epi
f8e18abb48 added filter checking logic to collect-backups requests 2022-04-12 21:09:51 -05:00
epi
1d805aca5a tweaked pre-processing and selection criteria for nlp 2022-04-12 20:55:37 -05:00
epi
fa09266804 handles passed through to termhandler 2022-04-12 20:53:24 -05:00
epi
15592c3dfd added AddHandles command 2022-04-12 20:52:57 -05:00
epi
53c171aeb5 extract-links should also abide by forced recursion into found assets only 2022-04-12 07:15:40 -05:00
epi
5167d24c4b another deb builder fix attempt 2022-04-12 06:49:42 -05:00
epi
81ff62c53d force recursion should only happen on found assets 2022-04-12 06:45:37 -05:00
epi
433f68458e updated test runner to nextest 2022-04-12 06:11:09 -05:00
epi
d32720a90a fixed deb build, maybe 2022-04-12 06:04:35 -05:00
epi
0ceef975e6 updated ci coverage job 2022-04-12 05:52:03 -05:00
epi
6d7d6c4e7b added tests for --force-recursion 2022-04-11 20:14:41 -05:00
epi
5f21953bc1 added --force-recursion option 2022-04-11 20:13:10 -05:00
epi
6906ac0ee8 added force-recursion to example config 2022-04-11 20:12:58 -05:00
epi
cafe766d9e bumped version to 2.7.0 2022-04-11 20:12:38 -05:00
epi
98d0d177df updated parse extensions logic and banner to reflect status code changes 2022-04-11 17:37:36 -05:00
epi
23e10833d0 cicd test 2022-04-11 17:13:01 -05:00
epi
7f51d0f7bf initial stab at updated statuscode behavior 2022-04-11 17:10:53 -05:00
epi
9515a5da99 fixed bug related to methods/responses not being displayed 2022-04-09 11:08:46 -05:00
epi
b417ab41a5 fixed bug related to methods/responses not being displayed 2022-04-09 11:08:16 -05:00
epi
b946565c2f updated shell completions for new version 2022-04-09 06:46:14 -05:00
epi
714b054360 bumped version to 2.6.3 2022-04-09 06:24:48 -05:00
epi
30544eaf7d fixed bug in replay proxy related to #501 2022-04-09 06:18:50 -05:00
epi
99e2d46aa2 Merge pull request #534 from epi052/all-contributors/add-jhaddix
docs: add jhaddix as a contributor for ideas
2022-04-07 07:04:05 -05:00
allcontributors[bot]
82a4f16252 docs: update .all-contributorsrc [skip ci] 2022-04-07 12:02:25 +00:00
allcontributors[bot]
6e0fe1eced docs: update README.md [skip ci] 2022-04-07 12:02:24 +00:00
epi
1ffc93a337 Merge pull request #533 from epi052/527-add-filters-via-scan-menu
add and remove filters via scan management menu
2022-04-06 20:53:20 -05:00
epi
7ab0453eb7 lint 2022-04-06 20:13:07 -05:00
epi
50b29a2b74 added remove filter command to SMM 2022-04-06 19:16:30 -05:00
epi
bf78fea926 Merge pull request #528 from epi052/527-add-filters-via-scan-menu
add filters via scan menu
2022-04-04 19:18:07 -05:00
epi
56267726cc finalized tests 2022-04-04 19:03:51 -05:00
epi
01844cffd8 added filters::utils tests 2022-04-04 18:28:39 -05:00
epi
2abc0b78ee removed unnecessary async 2022-04-04 06:22:38 -05:00
epi
fdb8774bfd removed unnecessary async 2022-04-04 06:17:32 -05:00
epi
8d3cd2471b added docs to new util fns 2022-04-04 06:12:18 -05:00
epi
da22371c87 removed duplicate import 2022-04-04 06:06:24 -05:00
epi
ca494ca801 removed duplicate link-attr extractor 2022-04-04 06:01:01 -05:00
epi
376804aa59 added test for empty filter 2022-04-04 05:59:28 -05:00
epi
56a769c197 upgraded deps 2022-04-04 05:44:23 -05:00
epi
9922eb5124 added clippy to makefile 2022-04-03 20:43:12 -05:00
epi
7a58f8fcf8 clippy and tests 2022-04-03 20:42:55 -05:00
epi
8d1872fb3f initial stab at implementation 2022-04-03 20:20:08 -05:00
epi
e1e59e6e8e Merge pull request #515 from epi052/all-contributors/add-gtjamesa
docs: add gtjamesa as a contributor for bug
2022-03-08 05:50:02 -06:00
allcontributors[bot]
0c731c3639 docs: update .all-contributorsrc [skip ci] 2022-03-08 11:49:34 +00:00
allcontributors[bot]
b456215a00 docs: update README.md [skip ci] 2022-03-08 11:49:33 +00:00
epi
d38c66f290 Merge pull request #514 from epi052/513-fix-collect-backups-root-request
backups requested from proper dir
2022-03-08 05:46:37 -06:00
epi
6ba32d926c fixed 513; backups requested from proper dir 2022-03-08 05:43:40 -06:00
epi
86bcfd8fb1 Merge pull request #512 from epi052/all-contributors/add-ippsec
docs: add ippsec as a contributor for ideas
2022-03-05 08:15:33 -06:00
allcontributors[bot]
4a101e4ae5 docs: update .all-contributorsrc [skip ci] 2022-03-05 14:15:17 +00:00
allcontributors[bot]
1ae1430a11 docs: update README.md [skip ci] 2022-03-05 14:15:16 +00:00
epi
cca3163baf fixed test 2022-03-05 07:09:23 -06:00
epi
c9013edce8 Merge pull request #511 from epi052/all-contributors/add-0dayCTF
docs: add 0dayCTF as a contributor for ideas
2022-03-05 07:02:49 -06:00
allcontributors[bot]
7bdb137fd1 docs: update .all-contributorsrc [skip ci] 2022-03-05 13:02:17 +00:00
allcontributors[bot]
5f0eaf8885 docs: update README.md [skip ci] 2022-03-05 13:02:16 +00:00
epi
2b7002d9cf Merge pull request #494 from epi052/release-2.6.0-multi-feature
2.6.0 release branch
2022-03-05 06:57:33 -06:00
epi
86b17f226d removed lint 2022-03-04 21:47:52 -06:00
epi
cbbf9be6c9 added composite flags 2022-03-04 21:15:43 -06:00
epi
f814c4b223 put back stripped comment 2022-03-04 06:54:17 -06:00
epi
7839118379 added cargo make Makefile.toml 2022-03-04 06:52:45 -06:00
epi
8214a2a357 bumped depenedencies 2022-03-04 06:52:23 -06:00
epi
e06e194f77 fixed flaky test 2022-03-04 06:52:13 -06:00
epi
1628ee86a3 added info log for collect words 2022-03-04 06:51:59 -06:00
epi
53d2076176 removed deprecated clap struct/methods 2022-03-04 06:45:22 -06:00
epi
304750fa3f Merge pull request #507 from epi052/collect-words-feature-2.6
add collect-words feature
2022-03-03 06:55:02 -06:00
epi
6c5c812784 added a few tests 2022-03-03 06:38:37 -06:00
epi
063e7b0420 added --collect-words implementation 2022-03-01 17:55:14 -06:00
epi
eed59e1da5 added nlp module 2022-02-27 13:42:07 -06:00
epi
ca4d8f0c52 replay proxy respects --data now 2022-02-19 17:28:19 -06:00
epi
c1132622cf replay proxy respects --data now 2022-02-19 15:19:09 -06:00
epi
2d5aeb444e added temp workaround for +proxy/-data problem 2022-02-19 15:11:22 -06:00
epi
53238a6e5e Merge pull request #474 from godylockz/Misc-Fixes
Add no-state option, filter queries from links, fix headers
2022-02-19 10:18:50 -06:00
epi
7b7eeeebfa Merge branch 'release-2.6.0-multi-feature' into Misc-Fixes 2022-02-18 08:05:12 -06:00
epi
aed0c41d8f Merge pull request #498 from epi052/489-discover-backups
implemented/tested logic for collecting backups
2022-02-17 20:02:20 -06:00
epi
5edd58a3f4 clippy 2022-02-17 19:46:44 -06:00
epi
44693a3498 added cli/banner/tests etc... 2022-02-17 19:44:29 -06:00
epi
02448e9834 dirlist extraction gated behind -e 2022-02-16 21:25:20 -06:00
epi
368035833c fixed up implementation/removed todo items 2022-02-16 20:44:07 -06:00
epi
d13bce2261 implemented/tested logic for collecting backups 2022-02-16 17:16:12 -06:00
epi
88d451144c Merge pull request #495 from epi052/collect-extensions-feature
implemented --collect-extensions; numerous bugfixes/code improvements
2022-02-15 21:04:49 -06:00
epi
8d639a17e4 removed read_body param from FeroxResponse::from 2022-02-15 21:03:02 -06:00
epi
7f0dcb6b46 lint 2022-02-15 20:51:01 -06:00
epi
3230f9c276 removed client param from logged_request 2022-02-15 20:42:15 -06:00
epi
b21ea9ce32 removed assay 2022-02-15 20:37:20 -06:00
epi
801413105d clippy 2022-02-15 16:35:16 -06:00
epi
3030296d1c added more tests again 2022-02-15 16:34:12 -06:00
epi
88a595fd82 added more tests 2022-02-15 07:14:23 -06:00
epi
9a84c5234f fixed banner tests 2022-02-14 17:32:40 -06:00
epi
d0d99ebed6 tests passing 2022-02-14 06:29:25 -06:00
epi
7194326cd1 moved dirlist detection to heuristics/fixed initial extract async issue 2022-02-13 13:20:13 -06:00
epi
71885e7e56 implemented --collect-extensions; numerous bugfixes/code improvements 2022-02-12 07:01:25 -06:00
epi
13cfbe152e bumped version to 2.6.0 2022-02-12 06:57:42 -06:00
godylockz
8b9d640090 Found a bug for redirect links not extracting links properly 2022-02-05 23:34:16 -05:00
godylockz
007bc4a50d Response URL was not being used in concatenation. 2022-01-30 01:24:53 -05:00
godylockz
71c5b66eb6 Revert, did in wrong place. 2022-01-30 01:07:32 -05:00
godylockz
1498122973 Fix warnings / formatting. 2022-01-29 09:22:23 -05:00
godylockz
c0b4040743 Fix relative pathing 2022-01-29 09:20:44 -05:00
godylockz
3c474920bb Fix per comments 2022-01-24 23:10:21 -05:00
godylockz
079b8b2176 Update src/parser.rs
Co-authored-by: epi <43392618+epi052@users.noreply.github.com>
2022-01-24 22:17:09 -05:00
godylockz
4a678ef65b Shell completions 2022-01-23 14:30:41 -05:00
godylockz
e9fb9642a8 Add no-state option, filter queries from links, fix headers 2022-01-23 14:27:24 -05:00
epi
f25475ae4f updated deps 2022-01-17 07:07:08 -06:00
epi
194eec1867 Merge pull request #466 from epi052/add-redirect-messages-to-normal-reports
Add redirect messages to normal reports
2022-01-17 07:03:10 -06:00
epi
d9088be54e Merge pull request #470 from epi052/all-contributors/add-godylockz
docs: add godylockz as a contributor for ideas, code
2022-01-17 07:02:12 -06:00
allcontributors[bot]
c0ae120016 docs: update .all-contributorsrc [skip ci] 2022-01-17 13:02:00 +00:00
allcontributors[bot]
9d5b339708 docs: update README.md [skip ci] 2022-01-17 13:01:59 +00:00
epi
b7e1876d87 Merge pull request #469 from epi052/all-contributors/add-MD-Levitan
docs: add MD-Levitan as a contributor for ideas, code
2022-01-17 07:00:10 -06:00
allcontributors[bot]
4979946471 docs: update .all-contributorsrc [skip ci] 2022-01-17 12:59:53 +00:00
allcontributors[bot]
ef5d267500 docs: update README.md [skip ci] 2022-01-17 12:59:52 +00:00
epi
a0208449cd Merge pull request #468 from epi052/all-contributors/add-its0x08
docs: add its0x08 as a contributor for ideas
2022-01-17 06:58:57 -06:00
allcontributors[bot]
bef48a8441 docs: update .all-contributorsrc [skip ci] 2022-01-17 12:58:46 +00:00
allcontributors[bot]
1bed84394a docs: update README.md [skip ci] 2022-01-17 12:58:45 +00:00
epi
d66ba9c78a nitpickery 2022-01-17 06:49:57 -06:00
epi
b4e8a63429 updated PR template with docs links 2022-01-17 06:43:03 -06:00
epi
257352e22d fixed reported times when scan limit used 2022-01-17 06:36:28 -06:00
epi
ed521005b2 Merge branch 'main' into add-redirect-messages-to-normal-reports 2022-01-17 06:35:04 -06:00
epi
cbc4da53de Merge pull request #464 from godylockz/ferox-parsehtml
Directory Listing & Web Scraping Links
2022-01-17 06:32:16 -06:00
epi
920ce7ce23 fixed dir listing req/s 2022-01-17 05:24:10 -06:00
godylockz
1ef5bc288a Fix tests back after removing extra prints 2022-01-16 22:34:37 -05:00
epi
97be0731d9 added directory listing message to bars 2022-01-16 21:20:26 -06:00
epi
0b42c6a30e Update .github/pull_request_template.md 2022-01-16 21:06:53 -06:00
epi
537d5c69dc Update CONTRIBUTING.md 2022-01-16 21:05:48 -06:00
epi
194d00c073 Update src/extractor/container.rs 2022-01-16 21:04:23 -06:00
epi
f83195120a Update src/scanner/ferox_scanner.rs 2022-01-16 21:03:43 -06:00
epi
af2a4dbde0 Update src/extractor/container.rs 2022-01-16 21:02:04 -06:00
godylockz
c83a2d8ef2 Fixed review comments 2022-01-16 21:37:43 -05:00
godylockz
ba96b686ea Remove iis heuristic, fix test cases 2022-01-16 21:15:04 -05:00
godylockz
fddade6a11 Fix parsing non-relative urls 2022-01-16 20:53:25 -05:00
godylockz
8b97974728 Cleanup code, make function. 2022-01-16 17:54:47 -05:00
godylockz
ab1861ca2c Use scrapper instead. 2022-01-16 17:00:19 -05:00
godylockz
5f241f6034 Cargo lock changed after build 2022-01-16 14:59:10 -05:00
epi
709a787613 removed some cruft 2022-01-16 07:03:11 -06:00
epi
21f0b95f15 updated tests 2022-01-16 06:56:47 -06:00
epi
36b6e49b87 added redirect messages to normal url reporting 2022-01-16 06:50:36 -06:00
godylockz
327bdd6e03 Merge branch 'main' of github.com:godylockz/feroxbuster into ferox-parsehtml 2022-01-15 23:49:17 -05:00
godylockz
b1e8023462 Fix file/directory detection logic again.... 2022-01-15 23:29:36 -05:00
godylockz
ae3a43db28 Add directory listing stop 2022-01-15 23:21:06 -05:00
godylockz
e8f4bbccf4 Add nginx style directory listing heuristic 2022-01-15 21:42:33 -05:00
epi
7cf834d000 Merge pull request #463 from epi052/112-parser-option-groups
added option groups; updated deps; clippy
2022-01-15 19:09:23 -06:00
epi
1500e651fa clippy / nitpickery 2022-01-15 19:04:53 -06:00
epi
e5048f0c8d fixed multiple -u issue 2022-01-15 18:46:41 -06:00
epi
d109608e2a added option groups; updated deps; clippy 2022-01-15 17:27:07 -06:00
godylockz
3e373d1a50 Sync clippy arguments between contributing and pull request template 2022-01-15 17:25:10 -05:00
godylockz
46b3989df7 Fix description 2022-01-15 17:05:42 -05:00
godylockz
cc9467153b Ran cargo fmt 2022-01-15 16:07:12 -05:00
godylockz
eb4b074454 Passed tests 2022-01-15 16:04:53 -05:00
godylockz
a07c9432f2 Fix issue with no extension assumed directory 2022-01-15 14:32:10 -05:00
godylockz
1f4dbeaf65 Fix traces / debug statements 2022-01-15 12:37:03 -05:00
godylockz
1d45325c23 Add directory listing tests 2022-01-15 10:15:35 -05:00
godylockz
216e0e6595 Update test cases for directory hits +1 2022-01-15 01:31:56 -05:00
godylockz
dfa0664a16 Add testing extractor and formatting 2022-01-15 00:58:27 -05:00
godylockz
54144dba89 Second Cut - All Directory Listing Items Obtained 2022-01-14 23:46:40 -05:00
godylockz
18ad9ca733 First cut at parsing html for links 2022-01-14 21:30:22 -05:00
godylockz
2b58113a2c Merge branch 'ferox-parsehtml' of github.com:godylockz/feroxbuster into ferox-parsehtml 2022-01-13 19:58:48 -05:00
godylockz
5ed43e8dbd Add html5ever 2022-01-13 19:58:42 -05:00
godylockz
82f65e58b7 Add html5ever 2022-01-13 19:56:28 -05:00
epi
b87836fb45 Merge pull request #441 from MD-Levitan/feature_methods
Add support of multiple methods during scan #440
2022-01-13 07:29:42 -06:00
epi
4db5835ea3 Update src/banner/container.rs
cargo fmt
2022-01-13 07:23:18 -06:00
epi
203108fc2d Update src/config/container.rs
use `strip_prefix`
2022-01-13 07:16:36 -06:00
epi
b040113115 Update src/config/container.rs
use `strip_prefix`
2022-01-13 07:16:29 -06:00
epi
6158d36279 Update src/event_handlers/outputs.rs
remove unnecessary `&`
2022-01-13 07:16:04 -06:00
epi
215b76246c Update src/banner/container.rs
handle newline/carriage return
2022-01-13 07:15:12 -06:00
MD-Levitan
729c272964 Fix Progress Bar count 2022-01-13 14:04:30 +03:00
MD-Levitan
43a8b7d7e7 Update scan_manager/tests.rs 2022-01-12 14:16:15 +03:00
MD-Levitan
94055ec504 Fix changes from https://github.com/epi052/feroxbuster/pull/441#pullrequestreview-848554319 2022-01-12 14:08:14 +03:00
MD-Levitan
76a1660329 Merge remote-tracking branch 'upstream/main' into feature_methods 2022-01-11 15:08:23 +03:00
epi
d44ad9ca2d Merge pull request #456 from epi052/all-contributors/add-unkn0wnsyst3m
docs: add unkn0wnsyst3m as a contributor for ideas
2022-01-10 20:15:22 -06:00
allcontributors[bot]
8712dee50c docs: update .all-contributorsrc [skip ci] 2022-01-11 02:15:11 +00:00
allcontributors[bot]
fc47a6e4e5 docs: update README.md [skip ci] 2022-01-11 02:15:10 +00:00
epi
87c23b2cde Merge pull request #455 from epi052/all-contributors/add-7047payloads
docs: add 7047payloads as a contributor for code
2022-01-10 20:14:22 -06:00
epi
e3fed0e6ac Merge pull request #454 from epi052/cookie-flag
add --cookie flag
2022-01-10 20:11:34 -06:00
allcontributors[bot]
d43da01dab docs: update .all-contributorsrc [skip ci] 2022-01-11 02:11:16 +00:00
allcontributors[bot]
47726bc25a docs: update README.md [skip ci] 2022-01-11 02:11:15 +00:00
epi
ec26321e42 updated deps 2022-01-10 20:09:12 -06:00
epi
21c0a7458b bumped version to 2.5.0 2022-01-10 20:04:13 -06:00
epi
9950b1381d completions 2022-01-10 20:01:37 -06:00
epi
06460bf9da cargo fmt; parser help 2022-01-10 20:01:10 -06:00
Citizen 7047
701701eea3 Removed extra vector for storing parsed cookies, using map instead 2022-01-10 21:20:32 +05:30
Citizen 7047
9a53053112 Fixed multiple cookie headers. Using semicolon to seperate each key-value pair 2022-01-10 20:50:35 +05:30
MD-Levitan
ee69c558a7 Add reader from file 2022-01-10 17:46:48 +03:00
Citizen 7047
8f9b757e2d Changed match to if let 2022-01-09 09:25:05 +05:30
Citizen 7047
777e5628a5 Added support for specifying cookies with the -b flag 2022-01-03 18:46:43 +05:30
MD-Levitan
4d49401a96 Init commit for 2021-12-30 19:18:51 +03:00
MD-Levitan
03e0d0092d Fix bugs with HTTP methods.
1. Update Configs
2. Add banner test
3. Change lentgh calculation.
4. Add wildcard for each method.
2021-12-30 15:33:04 +03:00
MD-Levitan
21b29a693e Add param for feroxscanner 2021-12-23 18:10:02 +03:00
epi
857ac22266 Merge pull request #436 from epi052/all-contributors/add-justinsteven
docs: add justinsteven as a contributor for ideas
2021-12-19 14:06:10 -06:00
epi
06ca52a19c Merge branch 'main' into all-contributors/add-justinsteven 2021-12-19 14:05:39 -06:00
epi
a1ab0b92c5 Merge pull request #437 from epi052/all-contributors/add-narkopolo
docs: add narkopolo as a contributor for ideas
2021-12-19 14:02:28 -06:00
allcontributors[bot]
45c2285a65 docs: update .all-contributorsrc [skip ci] 2021-12-19 19:50:15 +00:00
allcontributors[bot]
3edab75966 docs: update README.md [skip ci] 2021-12-19 19:50:14 +00:00
allcontributors[bot]
2d0e64dec6 docs: update .all-contributorsrc [skip ci] 2021-12-19 19:49:48 +00:00
allcontributors[bot]
b8386b7e20 docs: update README.md [skip ci] 2021-12-19 19:49:47 +00:00
epi
46e1d00e41 Merge pull request #430 from epi052/429-add-orig-url-to-json-output
429 add orig url to json output
2021-12-19 13:40:11 -06:00
epi
c0bfca4dbf update dependencies 2021-12-19 13:39:30 -06:00
epi
355b50bdc1 updated menu name in main display 2021-12-19 13:12:05 -06:00
epi
db2822b2cb revised a test 2021-12-19 13:03:44 -06:00
epi
f92116c16d enum variants 2021-12-19 12:58:59 -06:00
epi
1c48f754a2 added some tests 2021-12-19 11:12:51 -06:00
epi
1a26e6a992 removed lint 2021-12-11 20:29:50 -06:00
epi
23653b0cc3 added "add scan" to interactive menu 2021-12-11 20:23:52 -06:00
epi
85c529fd48 updated dependencies 2021-12-07 06:57:51 -06:00
epi
1614780c11 appeased clippy 2021-12-07 06:31:25 -06:00
epi
74121bf9be added original url to json output; updated tests 2021-12-07 06:13:53 -06:00
epi
75810ff697 updated AC badge 2021-10-19 07:47:59 -05:00
epi
e79d51b23d Merge pull request #402 from epi052/all-contributors/add-dsaxton
docs: add dsaxton as a contributor for ideas, code
2021-10-19 07:42:44 -05:00
allcontributors[bot]
4f87aa6ab6 docs: update .all-contributorsrc [skip ci] 2021-10-19 12:42:29 +00:00
allcontributors[bot]
9922999cfb docs: update README.md [skip ci] 2021-10-19 12:42:28 +00:00
epi
a73ad768b6 Merge pull request #401 from epi052/all-contributors/add-cortantief
docs: add cortantief as a contributor for bug, code
2021-10-19 07:40:58 -05:00
allcontributors[bot]
8e22a0881f docs: update .all-contributorsrc [skip ci] 2021-10-19 12:40:38 +00:00
allcontributors[bot]
d1fc7a4969 docs: update README.md [skip ci] 2021-10-19 12:40:37 +00:00
epi
f0252bc375 Merge pull request #360 from epi052/329-dont-scan-enhancements
329 dont scan enhancements
2021-10-15 16:44:11 -05:00
epi
1eca023d6e updated libs 2021-10-15 16:34:35 -05:00
epi
3ae3adf11b merged main 2021-10-15 16:29:11 -05:00
epi
b94edc4e57 Merge pull request #355 from cortantief/main
adding support add_slash and extensions + correcting error on double slashes in the start of a word with extension
2021-10-15 16:25:57 -05:00
epi
8116018b8b reverted the revert 2021-10-15 16:16:13 -05:00
epi
d1d37c135e merged upstream main 2021-10-15 15:46:30 -05:00
epi
1cd1d990de Merge pull request #357 from dsaxton/random-agent
Implement random user agent flag
2021-10-15 15:39:53 -05:00
Daniel Saxton
9c50038a25 Nit 2021-10-14 15:15:32 -05:00
Daniel Saxton
1e3cd3a209 Doc 2021-10-13 20:12:43 -05:00
Daniel Saxton
3536587260 Update banner 2021-10-13 20:11:02 -05:00
Daniel Saxton
19cd5c910a Try a test refactor 2021-10-13 20:08:28 -05:00
Daniel Saxton
b8bfbb09f3 Oops 2021-10-13 16:32:39 -05:00
Daniel Saxton
0a3130934c Ignore 2021-10-13 16:31:48 -05:00
Daniel Saxton
7b3201f2f8 Fix test 2021-10-13 16:14:42 -05:00
Daniel Saxton
521d341e36 Remove unused and update make_request call 2021-10-13 16:03:32 -05:00
epi
3f3b24b26f reverted add-slash change in heuristics 2021-10-13 06:38:26 -05:00
epi
2a4a150598 changed counter type 2021-10-12 20:24:05 -05:00
epi
39b2da9735 changed counter type 2021-10-12 20:22:01 -05:00
epi
87aaa84f1e added user-agent logic 2021-10-12 18:21:05 -05:00
epi
2b7d134ede Merge pull request #399 from eltociear/patch-1
fix typo in parser.rs
2021-10-12 05:55:27 -05:00
Ikko Ashimine
4eebacb077 fix typo in parser.rs
initalize -> initialize
2021-10-12 02:53:29 +09:00
epi
1a6ad39b46 Merge pull request #384 from epi052/all-contributors/add-hunter0x8
docs: add hunter0x8 as a contributor for bug
2021-10-10 14:24:20 -05:00
epi
10b473d920 Merge branch 'main' into all-contributors/add-hunter0x8 2021-10-10 14:24:13 -05:00
epi
1d2dc8bd37 Merge pull request #397 from epi052/all-contributors/add-dnaka91
docs: add dnaka91 as a contributor for infra
2021-10-10 14:21:06 -05:00
allcontributors[bot]
2132ceadd5 docs: update .all-contributorsrc [skip ci] 2021-10-10 19:20:45 +00:00
allcontributors[bot]
15bd50dc24 docs: update README.md [skip ci] 2021-10-10 19:20:44 +00:00
epi
48ca8d510a Merge pull request #396 from epi052/add-contrib-badrequest
updated contributor
2021-10-10 06:07:14 -05:00
epi
9ca48fe877 updated contributor 2021-10-10 06:06:51 -05:00
epi
2f09df921d Merge pull request #395 from epi052/add-contrib-SleepiPanda
updated contributor
2021-10-10 06:03:02 -05:00
epi
93686acb48 updated contributor 2021-10-10 06:02:36 -05:00
epi
294159088c Merge pull request #394 from epi052/add-contributors-hoggard
added henry hoggard
2021-10-10 05:52:28 -05:00
epi
da9bec5a67 added henry hoggard 2021-10-10 05:51:43 -05:00
epi
69cf08bf1f Merge pull request #393 from epi052/all-contributors/add-sicks3c
docs: add sicks3c as a contributor for bug
2021-10-10 05:44:12 -05:00
allcontributors[bot]
035a8c75c0 docs: update .all-contributorsrc [skip ci] 2021-10-10 10:44:04 +00:00
allcontributors[bot]
ac56225405 docs: update README.md [skip ci] 2021-10-10 10:44:03 +00:00
epi
82a7aa458c Merge pull request #392 from epi052/all-contributors/add-BitThr3at
docs: add BitThr3at as a contributor for bug
2021-10-10 05:43:03 -05:00
allcontributors[bot]
c29abe4ec4 docs: update .all-contributorsrc [skip ci] 2021-10-10 10:42:51 +00:00
allcontributors[bot]
bb6146c18f docs: update README.md [skip ci] 2021-10-10 10:42:50 +00:00
epi
882aded16c Merge pull request #391 from epi052/all-contributors/add-moscowchill
docs: add moscowchill as a contributor for bug
2021-10-10 05:41:38 -05:00
allcontributors[bot]
86dc8edd3d docs: update .all-contributorsrc [skip ci] 2021-10-10 10:41:27 +00:00
allcontributors[bot]
0281057944 docs: update README.md [skip ci] 2021-10-10 10:41:26 +00:00
epi
96fa07a5e5 Merge pull request #390 from epi052/all-contributors/add-N0ur5
docs: add N0ur5 as a contributor for ideas
2021-10-10 05:40:27 -05:00
allcontributors[bot]
3ee6641a7d docs: update .all-contributorsrc [skip ci] 2021-10-10 10:40:18 +00:00
allcontributors[bot]
90dd18af2e docs: update README.md [skip ci] 2021-10-10 10:40:17 +00:00
epi
b98ab6d691 Merge pull request #388 from epi052/all-contributors/add-dinosn
docs: add dinosn as a contributor for ideas
2021-10-09 16:31:57 -05:00
allcontributors[bot]
1723847672 docs: update .all-contributorsrc [skip ci] 2021-10-09 21:31:48 +00:00
allcontributors[bot]
125a55f72b docs: update README.md [skip ci] 2021-10-09 21:31:47 +00:00
epi
ba58bd942e Merge pull request #387 from epi052/all-contributors/add-black-A
docs: add black-A as a contributor for ideas
2021-10-09 16:29:49 -05:00
allcontributors[bot]
72fc0b026d docs: update .all-contributorsrc [skip ci] 2021-10-09 21:29:16 +00:00
allcontributors[bot]
5350724e5f docs: update README.md [skip ci] 2021-10-09 21:29:15 +00:00
epi
0b208cd011 Merge pull request #386 from epi052/all-contributors/add-sbrun
docs: add sbrun as a contributor for infra
2021-10-09 16:28:39 -05:00
allcontributors[bot]
82f8f687fd docs: update .all-contributorsrc [skip ci] 2021-10-09 21:27:49 +00:00
allcontributors[bot]
b36c3e0318 docs: update README.md [skip ci] 2021-10-09 21:27:49 +00:00
epi
85473916db Merge pull request #385 from epi052/all-contributors/add-secure-77
docs: add secure-77 as a contributor for bug
2021-10-09 16:27:26 -05:00
allcontributors[bot]
7afb261206 docs: update .all-contributorsrc [skip ci] 2021-10-09 21:27:17 +00:00
allcontributors[bot]
0b2d77605e docs: update README.md [skip ci] 2021-10-09 21:27:16 +00:00
allcontributors[bot]
073291360a docs: update .all-contributorsrc [skip ci] 2021-10-09 21:26:29 +00:00
allcontributors[bot]
98d6fdf536 docs: update README.md [skip ci] 2021-10-09 21:26:28 +00:00
epi
2b6de8e7dc Merge pull request #383 from epi052/all-contributors/add-0xdf
docs: add 0xdf as a contributor for bug
2021-10-09 16:25:39 -05:00
allcontributors[bot]
d0cdf5766b docs: update .all-contributorsrc [skip ci] 2021-10-09 21:25:07 +00:00
allcontributors[bot]
46366291f1 docs: update README.md [skip ci] 2021-10-09 21:25:06 +00:00
epi
b1d33f4f7d updated readme 2021-10-09 16:23:16 -05:00
epi
1e4d3802f8 updated readme 2021-10-09 16:21:58 -05:00
epi
a2bc9ecb49 Merge pull request #382 from epi052/all-contributors/add-Tib3rius
docs: add Tib3rius as a contributor for bug
2021-10-09 16:19:23 -05:00
allcontributors[bot]
4b0b26da02 docs: update .all-contributorsrc [skip ci] 2021-10-09 21:18:40 +00:00
allcontributors[bot]
fe5612ce71 docs: update README.md [skip ci] 2021-10-09 21:18:39 +00:00
epi
ea51805552 fixed badge 2021-10-07 07:20:34 -05:00
epi
2ff4dcde8a Merge pull request #376 from epi052/all-contributors/add-wtwver
docs: add wtwver as a contributor for infra
2021-10-07 07:18:28 -05:00
allcontributors[bot]
9f93c2381a docs: update .all-contributorsrc [skip ci] 2021-10-07 12:18:20 +00:00
allcontributors[bot]
ece220263b docs: update README.md [skip ci] 2021-10-07 12:18:19 +00:00
epi
06312f1f09 Merge pull request #375 from epi052/all-contributors/add-EONRaider
docs: add EONRaider as a contributor for infra
2021-10-07 07:18:07 -05:00
allcontributors[bot]
14023f7e05 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:18:00 +00:00
allcontributors[bot]
cc18dfc7d4 docs: update README.md [skip ci] 2021-10-07 12:17:59 +00:00
epi
99bb0200e5 Merge pull request #374 from epi052/all-contributors/add-craig
docs: add craig as a contributor for infra
2021-10-07 07:17:43 -05:00
allcontributors[bot]
542db19180 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:17:35 +00:00
allcontributors[bot]
d9718d0d6a docs: update README.md [skip ci] 2021-10-07 12:17:34 +00:00
epi
491821f0b2 Merge pull request #373 from epi052/all-contributors/add-noraj
docs: add noraj as a contributor for infra, doc
2021-10-07 07:17:15 -05:00
allcontributors[bot]
6d5235ab0a docs: update .all-contributorsrc [skip ci] 2021-10-07 12:17:08 +00:00
allcontributors[bot]
74b23141e0 docs: update README.md [skip ci] 2021-10-07 12:17:07 +00:00
epi
8f4ffc8e22 Merge pull request #372 from epi052/all-contributors/add-bpsizemore
docs: add bpsizemore as a contributor for code
2021-10-07 07:16:53 -05:00
allcontributors[bot]
7d314c7bac docs: update .all-contributorsrc [skip ci] 2021-10-07 12:16:47 +00:00
allcontributors[bot]
b6c41ae2d3 docs: update README.md [skip ci] 2021-10-07 12:16:46 +00:00
epi
b80c58a073 Merge pull request #371 from epi052/all-contributors/add-bsysop
docs: add bsysop as a contributor for doc
2021-10-07 07:16:36 -05:00
allcontributors[bot]
a035f0eeaf docs: update .all-contributorsrc [skip ci] 2021-10-07 12:16:28 +00:00
allcontributors[bot]
672d17ec27 docs: update README.md [skip ci] 2021-10-07 12:16:28 +00:00
epi
f7d4a3e7b4 Merge pull request #370 from epi052/all-contributors/add-tomtastic
docs: add tomtastic as a contributor for doc
2021-10-07 07:16:13 -05:00
allcontributors[bot]
00b0c3c62d docs: update .all-contributorsrc [skip ci] 2021-10-07 12:16:05 +00:00
allcontributors[bot]
f260a981ca docs: update README.md [skip ci] 2021-10-07 12:16:04 +00:00
epi
f254fe172c Merge pull request #369 from epi052/all-contributors/add-n-thumann
docs: add n-thumann as a contributor for code, doc
2021-10-07 07:15:47 -05:00
allcontributors[bot]
cc1dc94459 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:15:35 +00:00
allcontributors[bot]
e39f6cf16d docs: update README.md [skip ci] 2021-10-07 12:15:34 +00:00
epi
2a406960c4 Merge pull request #368 from epi052/all-contributors/add-mzpqnxow
docs: add mzpqnxow as a contributor for ideas, doc
2021-10-07 07:15:16 -05:00
allcontributors[bot]
c65e2f02b3 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:15:06 +00:00
allcontributors[bot]
831ae011e2 docs: update README.md [skip ci] 2021-10-07 12:15:06 +00:00
epi
32a4db4b46 Merge pull request #367 from epi052/all-contributors/add-evanrichter
docs: add evanrichter as a contributor for code, doc
2021-10-07 07:14:51 -05:00
allcontributors[bot]
a439be0305 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:14:42 +00:00
allcontributors[bot]
36994d208d docs: update README.md [skip ci] 2021-10-07 12:14:41 +00:00
epi
b72c42e1d1 Merge pull request #366 from epi052/all-contributors/add-spikecodes
docs: add spikecodes as a contributor for infra, doc
2021-10-07 07:14:26 -05:00
allcontributors[bot]
b508dcce8d docs: update .all-contributorsrc [skip ci] 2021-10-07 12:14:02 +00:00
allcontributors[bot]
c33d397360 docs: update README.md [skip ci] 2021-10-07 12:14:01 +00:00
epi
449f6bda32 Merge pull request #365 from epi052/all-contributors/add-TGotwig
docs: add TGotwig as a contributor for infra, doc
2021-10-07 07:13:43 -05:00
allcontributors[bot]
092515cf3a docs: update .all-contributorsrc [skip ci] 2021-10-07 12:13:30 +00:00
allcontributors[bot]
e5fe9bb360 docs: update README.md [skip ci] 2021-10-07 12:13:29 +00:00
epi
2e42e3efac Merge pull request #364 from epi052/all-contributors/add-jsav0
docs: add jsav0 as a contributor for infra, doc
2021-10-07 07:13:10 -05:00
allcontributors[bot]
0d1cb25b69 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:12:29 +00:00
allcontributors[bot]
653117bda6 docs: update README.md [skip ci] 2021-10-07 12:12:28 +00:00
epi
5c32fab4cb fixed badge 2021-10-07 06:54:26 -05:00
epi
904c70281a Merge pull request #363 from epi052/all-contributors/add-joohoi
docs: add joohoi as a contributor for doc
2021-10-07 06:48:11 -05:00
allcontributors[bot]
2d5825556f docs: create .all-contributorsrc [skip ci] 2021-10-07 11:45:27 +00:00
allcontributors[bot]
ef7fc7a8a3 docs: update README.md [skip ci] 2021-10-07 11:45:26 +00:00
epi
e48a462471 added confirmed tag to stale-bot 2021-10-07 06:40:40 -05:00
epi
f6047e9819 updated multiple items from new-feature checklist 2021-10-04 06:34:04 -05:00
epi
534cbe8fe1 added a few tests 2021-10-03 14:17:28 -05:00
epi
adb5cd75cc fixed existing tests 2021-10-03 12:16:04 -05:00
epi
3469e2c306 fixed existing tests 2021-10-03 11:32:43 -05:00
epi
6de087ae79 Update cicd-to-dockerhub.yml 2021-10-03 11:26:48 -05:00
epi
07a9fdee41 moved parsing of urls/regexes to config
this means that each url / regex passed to --dont-scan is only parsed once
2021-10-03 09:04:31 -05:00
epi
7b9767107f added regex specific banner title to --dont-scan 2021-10-01 07:16:22 -05:00
epi
5388d40c03 added regex support to --dont-scan 2021-10-01 06:54:30 -05:00
epi
28769b5028 broke absolute path denial into its own function; tests pass 2021-10-01 06:23:44 -05:00
epi
28fa90b093 Merge pull request #359 from wtwver/main
Fix docker build error
2021-09-30 06:12:48 -05:00
wtwver
0b16f368a4 . 2021-09-30 17:25:24 +08:00
Daniel Saxton
c5e59b70f7 Hard code 2021-09-28 15:44:11 -05:00
Daniel Saxton
6756a1da74 Merge remote-tracking branch 'upstream/main' into random-agent 2021-09-28 15:23:29 -05:00
epi
d14de76f9a added redirect 2021-09-27 06:29:40 -05:00
Daniel Saxton
ef3cc05ee3 Merge remote-tracking branch 'upstream/main' into random-agent 2021-09-26 10:19:00 -05:00
Daniel Saxton
efd706cb9b cargo fmt 2021-09-26 10:09:52 -05:00
epi
63baa3ec57 removed github pages workflow 2021-09-26 06:52:46 -05:00
epi
51defffd3b cleaned up docs stuff 2021-09-26 06:52:16 -05:00
epi
439afd2e2a updated to reflect new documentation site 2021-09-26 06:50:56 -05:00
epi
10ae4ee524 updated url for submodule 2021-09-25 18:40:53 -05:00
epi
4af448d7b1 updated url for submodule 2021-09-25 18:35:21 -05:00
epi
adc536bf4b added nojekyll 2021-09-25 18:28:24 -05:00
epi
fde52e95e1 added npm install 2021-09-25 18:24:37 -05:00
epi
e4ae5759ff added node 2021-09-25 17:33:07 -05:00
epi
89eda0e62b added node 2021-09-25 17:31:14 -05:00
epi
00330b053f postcss install 2021-09-25 17:28:15 -05:00
epi
0df1d34ee1 recursive checkout 2021-09-25 16:53:15 -05:00
epi
eddab0de13 cd into docs dir for ci 2021-09-25 16:51:19 -05:00
epi
f9335a7867 removed jekyll theme 2021-09-25 16:48:46 -05:00
epi
bc9779be2a Merge branch 'main' of github.com:epi052/feroxbuster 2021-09-25 16:48:01 -05:00
epi
c4b6fed6ef added new documentation site 2021-09-25 16:47:52 -05:00
epi
3818276c7e Set theme jekyll-theme-midnight 2021-09-24 06:52:17 -05:00
epi
6596759132 Set theme jekyll-theme-dinky 2021-09-24 06:48:23 -05:00
epi
5235208aa8 dynamically generate user agents at build time for inclusion into lib.rs 2021-09-23 21:22:52 -05:00
Daniel Saxton
bce55e77f3 Add arg 2021-09-22 19:41:52 -05:00
Daniel Saxton
8d11bb1800 README 2021-09-22 19:21:07 -05:00
Daniel Saxton
40fccb9761 Bump minor version 2021-09-22 19:14:07 -05:00
Daniel Saxton
28f63aae94 Test 2021-09-22 17:09:11 -05:00
Daniel Saxton
1eaf6fc232 Merge remote-tracking branch 'upstream/main' into random-agent 2021-09-22 17:01:19 -05:00
epi
dd1c824d98 updated shell completions that care about mutual exclusion 2021-09-22 07:30:38 -05:00
epi
42c06c87cc bumped version to 2.3.4 2021-09-22 07:29:14 -05:00
epi
d36379ba1b nitpickery stuff; removed the test i added cuz it's a duplicate 2021-09-22 07:28:57 -05:00
epi
83ba49a486 added test for formatted_urls when both extension and slash are set 2021-09-21 20:49:31 -05:00
Daniel Saxton
0b75e1a548 Implement random user agent flag 2021-09-21 13:35:10 -05:00
epi
867e297284 Set theme jekyll-theme-midnight 2021-09-21 07:25:12 -05:00
epi
63bd89ddc3 Set theme jekyll-theme-leap-day 2021-09-21 07:14:31 -05:00
epi
9f39ee3491 Set theme jekyll-theme-cayman 2021-09-21 07:13:00 -05:00
epi
eabf97b776 Set theme jekyll-theme-merlot 2021-09-21 07:05:39 -05:00
epi
3c3b976a71 Set theme jekyll-theme-hacker 2021-09-21 07:02:59 -05:00
epi
81709b5009 Set theme jekyll-theme-midnight 2021-09-21 07:01:26 -05:00
epi
ffa0c6b390 Set theme jekyll-theme-midnight 2021-09-21 07:00:42 -05:00
epi
141fe74129 removed nojekyll 2021-09-21 06:57:32 -05:00
cortantief
d4a69fa2ec clippy plus cargo fmt 2021-09-20 10:58:53 +02:00
cortantief
9a3754a31d Cargo fmt formating 2021-09-20 10:56:19 +02:00
cortantief
c89453c5c3 Adding support for slash and extensions at the same time.
Support for the extensions and slash where added by using a special condition inside the format function allowing us to treat slash as an extension with a particular format.
We had to modify other place to propagate the changes like in make_wildcard_request for example.
Ofcourse we had to delete the limitation inside the arg parser.
A test was added to ensure the validity of the implementation.
2021-09-20 10:01:09 +02:00
cortantief
be57e620f0 Correcting error when extensions are used with word that start with two slashes
The cause of that error was the fact that the check was made inside a chain of if-else-if conditions, since the extension are tested before the slashes
resulting to not check correctly the word.
2021-09-20 07:02:37 +02:00
epi
45efaa7388 new theme options 2021-09-17 06:29:32 -05:00
epi
15cb5e1619 trying dark theme 2021-09-17 06:20:05 -05:00
epi
fc500a5cd5 flatdoc resolve fix 2021-09-17 06:12:03 -05:00
epi
c84612751c github buttons cahnged to https 2021-09-17 06:09:45 -05:00
epi
c8ecbd4ed6 jquery cahnged to https 2021-09-17 06:08:16 -05:00
epi
fbf79ab7c1 pointing docs to readme 2021-09-17 06:06:55 -05:00
epi
a446192b9a pointing docs to readme 2021-09-17 06:06:22 -05:00
epi
26565be18d removed config file 2021-09-17 06:05:18 -05:00
epi
7c4bc213a3 fixed theme bug 2021-09-17 06:04:52 -05:00
epi
a227dcf726 fixed theme bug 2021-09-17 06:01:13 -05:00
epi
b70c92b1e6 added github pages content 2021-09-17 05:46:49 -05:00
epi
033a57a9e9 Merge branch 'main' of github.com:epi052/feroxbuster 2021-09-16 18:58:14 -05:00
epi
dac74ae040 updated dependencies 2021-09-16 18:58:07 -05:00
epi
d4abb84214 updated readme 2021-09-04 17:04:27 -05:00
epi
5201c300e9 update branch name 2021-09-04 16:41:29 -05:00
epi
ec0e5299ed Merge pull request #345 from EONRaider/cicd-to-dockerhub
Optimize Dockerfile and implement CI/CD to DockerHub
2021-09-04 15:55:17 -05:00
EONRaider
242c35c89f Create cicd-to-dockerhub.yml 2021-09-02 08:29:38 -03:00
EONRaider
f717ee534e Optimize Dockerfile 2021-08-28 14:01:26 -03:00
EONRaider
6b66f39122 Update Dockerfile 2021-08-27 18:08:06 -03:00
epi
4b3e9badbb Merge pull request #336 from epi052/335-fix-wildcard-filter-when-response-is-zero
335 fix wildcard filter when response is zero
2021-08-20 20:44:36 -05:00
epi
c680be558a added test for 0-length wildcard response 2021-08-20 20:09:48 -05:00
epi
8cee7ce247 bumped version; updated dependencies 2021-08-20 19:52:06 -05:00
epi
580aa19681 fixed erroneous reporting of total urls expected 2021-08-20 19:46:46 -05:00
epi
cd220fe471 fixed wildcard filtering when wildcard response len is 0 2021-08-20 19:25:34 -05:00
epi
15b4fd04e5 updated status code defaults to include 500 2021-08-02 19:28:09 -05:00
epi
fceba0b68b updated deps 2021-08-02 19:27:55 -05:00
epi
eef4c9b5ed added 500 to status code defaults 2021-08-02 19:23:20 -05:00
epi
24da4e017c adjusted rlimit imports to ignore windows targets 2021-08-02 05:41:50 -05:00
epi
f3cedf01a5 Merge pull request #321 from epi052/319-separate-log-files-for-parallel
Log to separate files when using --parallel
2021-08-02 05:21:16 -05:00
epi
08ee32595f updated documentation for parallel logging change 2021-08-01 21:03:17 -05:00
epi
4c4d1a2a61 updated documentation for parallel logging change 2021-08-01 19:59:28 -05:00
epi
64b54a6308 fixed up a few todo items 2021-08-01 19:57:28 -05:00
epi
e27b3ee8da added coverage for stdin slugifying 2021-08-01 15:17:59 -05:00
epi
129725cedd added test for --parallel with -o 2021-08-01 14:32:14 -05:00
epi
17886da3df handle dir/outfile case to -o 2021-08-01 14:31:48 -05:00
epi
c8a46b7e5a added prefix param to slugify_filename 2021-08-01 09:23:42 -05:00
epi
f97d103fc6 unique file logging with parallel works 2021-08-01 08:07:12 -05:00
epi
aa2fecc5c1 bumped version to 2.3.2 2021-07-31 14:49:15 -05:00
epi
6f2244e1ff put url slug logic into its own function 2021-07-31 14:48:35 -05:00
epi
a1dc90ba06 updated rlimit lib 2021-07-30 20:01:16 -05:00
epi
32f55ddfb7 Merge branch 'main' of github.com:epi052/feroxbuster 2021-07-30 16:14:32 -05:00
epi
9a65c7f1f5 fixed up code for new clippy checks 2021-07-30 16:14:25 -05:00
epi
0f6bc1c160 updated deps 2021-07-30 07:42:25 -05:00
epi
abef7a236b Update README.md 2021-07-12 16:17:08 -05:00
epi
0cff62dbe2 return 0 when -h/--help is used 2021-07-05 06:33:40 -05:00
epi
a590188e44 Merge pull request #293 from epi052/286-url-blacklist
add --dont-scan option for denying urls
2021-06-18 14:40:16 -07:00
epi
dc3aa11966 added tracing to new extractor fns 2021-06-18 16:32:04 -05:00
epi
57714d243a fixed caching for extraction; much better performance now 2021-06-18 16:18:42 -05:00
epi
1d34a5e99f updated readme 2021-06-18 11:05:57 -05:00
epi
9ab3e5515e added short-circuit to deny check 2021-06-18 06:48:41 -05:00
epi
3abef25c8f added integration tests 2021-06-17 20:34:21 -05:00
epi
454f3a4302 satisfied newest version of clippy 2021-06-17 16:13:18 -05:00
epi
acb9c19f4d added should_deny_url function and unit tests 2021-06-17 14:44:33 -05:00
epi
98f06951bd added banner test 2021-06-15 17:31:24 -05:00
epi
c9e1a7adbe added deny list to banner 2021-06-15 16:59:36 -05:00
epi
c57cf82fce added --dont-scan to options parser 2021-06-15 16:45:23 -05:00
epi
a3bcfaf95c added url_denylist to config 2021-06-15 14:31:01 -05:00
epi
c99afec740 bumped version to 2.3.0 2021-06-15 13:52:45 -05:00
epi
fa9fd65c2f bumped version to 2.3.0 2021-06-15 11:47:57 -05:00
epi
2af87971d5 fixed build script when on cd pipeline 2021-06-15 11:46:20 -05:00
epi
e6753d9474 fixed build script when on cd pipeline 2021-06-15 11:45:35 -05:00
epi
d23717dc6c troubleshooting build script 2021-06-15 11:32:00 -05:00
epi
4debe68ed6 updated pipeline build 2021-06-15 11:28:46 -05:00
epi
e6b78e3986 Merge pull request #292 from epi052/287-always-parse-help-parameter
added small check for help param to always print and exit
2021-06-15 09:22:34 -07:00
epi
7b268cf197 bumped version to 2.2.5 2021-06-15 11:22:14 -05:00
epi
34ff884d52 added tests for new help param catcher 2021-06-15 11:02:43 -05:00
epi
7fef23f888 added small check for help param to always print and exit 2021-06-15 10:28:20 -05:00
epi
7a8d6d0d52 fixed build when config copied on cd 2021-06-15 10:14:59 -05:00
epi
6d4f2a7ed9 Update build.yml 2021-06-15 10:14:16 -05:00
epi
329d04252f config file is dropped to disk when installing via cargo 2021-06-15 08:00:21 -05:00
epi
9b4092ea8c added plusdirs to bash completion script and regenerated it 2021-06-15 06:37:03 -05:00
epi
d942a7705a bumped various lib versions 2021-06-14 20:09:22 -05:00
epi
e3365b42a2 Merge branch 'main' of github.com:epi052/feroxbuster 2021-06-14 20:08:11 -05:00
epi
41689bd742 added verify command 2021-06-14 20:07:03 -05:00
epi
bc487475f0 Update pull_request_template.md 2021-06-14 20:00:19 -05:00
epi
393e775285 satisfied clippy 2021-05-08 16:10:01 -05:00
epi
cf6c02307c bumped version to 2.2.4 2021-05-08 16:01:23 -05:00
epi
88b9bc3a01 Merge pull request #270 from epi052/268-cancel-scan-by-range
updated scan cancel input to support comma and range delimited values
2021-05-08 15:57:28 -05:00
epi
d1f90efb09 bumped lib versions 2021-05-05 06:12:50 -05:00
epi
df4fad07a9 Merge branch 'main' into 268-cancel-scan-by-range 2021-05-05 06:06:19 -05:00
epi
56d533117e updated docs with new cancel scan info 2021-05-05 05:56:54 -05:00
epi
9549e27f19 updated cancel menu footer with description about -f 2021-04-20 11:57:56 -05:00
epi
1677b51c2d reverted workflow file 2021-04-20 11:43:05 -05:00
epi
d4f9442d38 examining codecov env 2021-04-20 11:05:17 -05:00
epi
8191fa1a5e updated scan cancel input to support comma and range delimd values 2021-04-20 07:39:11 -05:00
epi
4811b37aa4 Merge pull request #267 from epi052/restyled/pull-266
check for unzip before continuing
2021-04-15 05:15:59 -05:00
Restyled.io
941cad5844 Restyled by shfmt 2021-04-14 17:37:28 +00:00
Restyled.io
d59af94f62 Restyled by shellharden 2021-04-14 17:37:26 +00:00
Craig
cf403c4d4a check for unzip before continuing 2021-04-14 19:35:24 +02:00
epi
57a2b1cbab bumped ctrlc, tokio, reqwest, tokio-util, and futures 2021-04-14 05:59:35 -05:00
epi
ef195bd653 Merge branch 'main' of github.com:epi052/feroxbuster into main 2021-04-01 06:01:45 -05:00
epi
9b1a24bca3 updated libs; fixed new clippy errors 2021-04-01 06:00:44 -05:00
epi
c6aefbfa97 Merge pull request #249 from noraj/patch-1
add blackarch install description
2021-03-20 14:06:35 -05:00
Alexandre ZANNI
42bad85208 add blackarch install 2021-03-19 16:29:27 +01:00
epi
f5709739fa Merge pull request #247 from epi052/dependabot/cargo/regex-1.4.5
Bump regex from 1.4.4 to 1.4.5
2021-03-17 05:59:12 -05:00
epi
248f56ed7a Merge pull request #246 from epi052/dependabot/cargo/console-0.14.1
Bump console from 0.14.0 to 0.14.1
2021-03-17 05:58:55 -05:00
dependabot[bot]
3de6ed9696 Bump regex from 1.4.4 to 1.4.5
Bumps [regex](https://github.com/rust-lang/regex) from 1.4.4 to 1.4.5.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.4.4...1.4.5)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-15 07:59:58 +00:00
dependabot[bot]
4bad39f4b9 Bump console from 0.14.0 to 0.14.1
Bumps [console](https://github.com/mitsuhiko/console) from 0.14.0 to 0.14.1.
- [Release notes](https://github.com/mitsuhiko/console/releases)
- [Changelog](https://github.com/mitsuhiko/console/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mitsuhiko/console/compare/0.14.0...0.14.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-15 07:59:47 +00:00
epi
9b303d8b5a update dependencies 2021-03-14 07:29:47 -05:00
epi
7e0b003216 Merge branch 'main' of github.com:epi052/feroxbuster into main 2021-03-14 07:22:30 -05:00
epi
dc36a7bf4d updated deendencies 2021-03-14 07:22:16 -05:00
epi
d33632c421 update ko-fi 2021-03-13 07:05:01 -06:00
epi
7dc6a867a5 update ko-fi 2021-03-13 07:04:31 -06:00
epi
b937a0191e Create FUNDING.yml 2021-03-13 06:24:03 -06:00
epi
d57a83956c Merge pull request #237 from epi052/235-add-arm-build
added support for arm builds
2021-03-05 11:10:09 -06:00
epi
71efd78f03 fixed docstrings for Stats 2021-03-05 11:04:00 -06:00
epi
139006d0a7 added aarch64 to the build matrix 2021-03-05 11:03:36 -06:00
epi
b5abb8b6e8 ci build for aarch64 works, restricting to main again 2021-03-05 10:49:13 -06:00
epi
a076a333df updated README for arm installs 2021-03-05 10:31:56 -06:00
epi
461ed0a9ff added aarch64 build to ci 2021-03-05 10:24:57 -06:00
epi
4381569a0f ci build works, restricting to main again 2021-03-05 09:59:48 -06:00
epi
a52bd10340 added arm specific strip command 2021-03-05 09:34:23 -06:00
epi
56a1144865 testing ci build of armv7 2021-03-05 09:21:00 -06:00
epi
23ab009c08 added linker flag for arm builds 2021-03-05 09:14:43 -06:00
epi
fa4e3d5d88 added linker flag for arm builds 2021-03-05 09:13:10 -06:00
epi
ad7a1ffe44 added custom Stats::serialize to accomodated arm builds 2021-03-05 09:05:51 -06:00
epi
0e4f8893f8 added custom Stats::deserialize to accomodated arm builds 2021-03-05 08:25:33 -06:00
epi
8e0b801ec5 bumped version to 2.3.3 2021-03-05 06:55:00 -06:00
epi
97889f917d Merge pull request #234 from epi052/233-ordered-wordlist
changed wordlist read so that ordering is maintained
2021-03-01 16:48:54 -06:00
epi
cedb3ccc8d wordlist order is now maintained 2021-03-01 16:38:59 -06:00
epi
d7cfd8ff60 bumped version to 2.2.2 2021-03-01 16:17:05 -06:00
epi
223e75923d updated 3 libs, added command completion to .deb build 2021-03-01 06:18:47 -06:00
epi
dd9f2f72c0 added Kali install to readme 2021-02-27 07:01:46 -06:00
epi
8ffea2500d Merge pull request #228 from epi052/fix-makefile-for-kali-repos
Fix makefile for kali repos
2021-02-23 07:44:58 -06:00
epi
5ed890e3fd bumped futures to 0.3.13 2021-02-23 07:34:44 -06:00
epi
8fe458263d fixed bash completion location 2021-02-23 07:30:04 -06:00
epi
6de36585a9 Merge pull request #224 from bsysop/patch-1
Update README.md
2021-02-22 17:09:08 -06:00
epi
30538c366c makefile works for kali 2021-02-22 16:48:42 -06:00
bsysop
89a0ac8aa4 Update README.md
FFUF supports SOCKS, it's just not documented yet =]

`ffuf -x socks5://127.0.0.1:1234`
2021-02-22 13:34:41 -03:00
epi
c9a93f2843 changed filter status emoji 2021-02-21 14:46:47 -06:00
epi
bfdb4abdce changed filter status emoji 2021-02-21 14:37:04 -06:00
epi
eb17eeecd3 bumped reqwest version to 0.11.1 2021-02-21 11:49:34 -06:00
epi
c2819ef2e7 Update README.md 2021-02-18 13:24:40 -06:00
epi
030b588448 Merge pull request #222 from epi052/213-add-parallel-option
add --parallel option
2021-02-18 11:37:36 -06:00
epi
4ee143968e updated readme with parallel option 2021-02-18 11:26:15 -06:00
epi
834d681bb9 updated readme with parallel option 2021-02-18 11:25:55 -06:00
epi
fc35bb6764 improved parallel testing 2021-02-18 11:01:11 -06:00
epi
13222bfc7b added Debug impl for LimitHeap 2021-02-18 09:42:39 -06:00
epi
8e2b08ce90 bumped version to 2.2.0 2021-02-18 09:22:55 -06:00
epi
24a44ff253 Merge branch 'main' into 213-add-parallel-option 2021-02-18 09:21:50 -06:00
epi
9e0118fd30 Merge pull request #221 from epi052/123-auto-tune-or-bail
added --auto-tune and --auto-bail
2021-02-18 09:16:07 -06:00
epi
3325af2331 updated branch monitoring for CI builds from master to main 2021-02-18 09:15:13 -06:00
epi
ec102a8093 updated readme 2021-02-18 09:11:57 -06:00
epi
9d72109023 added integration tests for auto-tune policy 2021-02-18 08:15:50 -06:00
epi
f1d6f3d8cb broke utils out into separate files 2021-02-18 07:48:06 -06:00
epi
1e01be712a added another message test; fixed clippy 2021-02-18 07:13:18 -06:00
epi
1a0c914819 added tests for feroxmessage 2021-02-18 07:09:06 -06:00
epi
19d3f46428 unit tests for scanner/utils are complete 2021-02-18 07:01:36 -06:00
epi
6e2e3ff97f added tests for adjust_limit on the Requester 2021-02-17 20:46:00 -06:00
epi
303eed03d7 finished up tests for limitheap 2021-02-17 17:07:26 -06:00
epi
a0754d2e3a finished policy data tests 2021-02-17 17:01:47 -06:00
epi
3d4417d84b added some tests for policy data 2021-02-17 15:34:52 -06:00
epi
6f5de57115 added test for requests_per_second 2021-02-17 15:04:05 -06:00
epi
7e72d52e4a removed GetRuntime dead code 2021-02-17 14:36:10 -06:00
epi
7010b00b00 added tests for stats container 2021-02-17 14:34:46 -06:00
epi
3de31f0393 removed all enforced_ dead code 2021-02-17 13:27:50 -06:00
epi
06fe34f291 fixed all existing tests 2021-02-17 12:59:38 -06:00
epi
d78dbb76b1 removed todos related to tuning 2021-02-17 12:23:49 -06:00
epi
a09493b845 Merge branch 'main' into 123-auto-tune-or-bail 2021-02-17 08:30:58 -06:00
epi
3cb5a9b8fa incremental save 2021-02-17 07:03:04 -06:00
epi
7ad8915d96 updated lockfile 2021-02-15 08:16:14 -06:00
epi
23ec79d897 Merge branch 'master' into 123-auto-tune-or-bail 2021-02-15 08:13:14 -06:00
epi
c4f072e159 incremental save before branch swap 2021-02-15 07:37:50 -06:00
epi
4019c31f9d auto-tune and rate-limit are mutually exclusive 2021-02-15 07:00:25 -06:00
epi
5cb5541eda changed memory ordering of feroxscan errors 2021-02-15 06:58:14 -06:00
epi
71084979f3 remvoed clippy ci errors 2021-02-15 06:48:05 -06:00
epi
96527a1419 fixed clippy error from pipeline 2021-02-15 06:35:50 -06:00
epi
4e0a85e64f removed lint 2021-02-13 20:11:02 -06:00
epi
ed5e1d86cd bumped env_logger to 0.8.3 2021-02-13 20:08:03 -06:00
epi
d8b15da016 added autobail tests for 403/429s 2021-02-13 20:05:33 -06:00
epi
54e290106d added test for autobail; fixed lock contention bug 2021-02-13 19:44:19 -06:00
epi
161f8f0aed added a few tests to scanner/utils 2021-02-13 06:05:16 -06:00
epi
c9e2d302be autobail mostly complete 2021-02-13 05:43:06 -06:00
epi
bd4f6024c6 another test fix 2021-02-12 07:00:42 -06:00
epi
15de46da7b fixed existing tests 2021-02-12 06:26:45 -06:00
epi
4e3b8701a2 incremental save 2021-02-11 20:20:40 -06:00
epi
dabcedcf23 added test for get_base_scan_by_url 2021-02-10 05:39:11 -06:00
epi
52a2a1f961 errors incrementing per-scan properly 2021-02-09 20:17:28 -06:00
epi
0345e03e6a added test for --parallel 2021-02-08 06:44:00 -06:00
epi
873539ac92 fixed up existing tests 2021-02-08 06:16:23 -06:00
epi
9c85f90faf bumped version to 2.1.0; bumped tokio & serde_json to new versions 2021-02-08 06:02:36 -06:00
epi
1643643e77 more nitpickery in main 2021-02-08 05:58:34 -06:00
epi
a7e4cc914b updated example config 2021-02-08 05:51:53 -06:00
epi
6daa2a230a reverted ci change 2021-02-08 05:49:45 -06:00
epi
5486e3c95f cleaned up main 2021-02-08 05:48:43 -06:00
epi
204aa5e226 implemented --parallel logic; banner/config logic/tests added 2021-02-07 14:35:38 -06:00
epi
e2dd01fb95 incremental save 2021-02-06 20:23:27 -06:00
epi
0ebbd89778 updated example config with new options 2021-02-05 05:20:02 -06:00
epi
c8c2f7b4c8 added banner entries for auto-tune/bail 2021-02-04 20:45:34 -06:00
epi
ac75c01fed added banner entries and tests for auto[bail,tune] 2021-02-04 20:32:12 -06:00
epi
a823c6040a added auto-tune and auto-bail to config 2021-02-04 20:24:02 -06:00
epi
05589f3988 broke scanner into sub module 2021-02-04 19:20:31 -06:00
epi
5b8b3f148b bumped version to 2.1.0 2021-02-04 17:14:37 -06:00
112 changed files with 20741 additions and 4100 deletions

564
.all-contributorsrc Normal file
View File

@@ -0,0 +1,564 @@
{
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"contributors": [
{
"login": "joohoi",
"name": "Joona Hoikkala",
"avatar_url": "https://avatars.githubusercontent.com/u/5235109?v=4",
"profile": "https://io.fi",
"contributions": [
"doc"
]
},
{
"login": "jsav0",
"name": "J Savage",
"avatar_url": "https://avatars.githubusercontent.com/u/20546041?v=4",
"profile": "https://github.com/jsav0",
"contributions": [
"infra",
"doc"
]
},
{
"login": "TGotwig",
"name": "Thomas Gotwig",
"avatar_url": "https://avatars.githubusercontent.com/u/30773779?v=4",
"profile": "http://www.tgotwig.dev",
"contributions": [
"infra",
"doc"
]
},
{
"login": "spikecodes",
"name": "Spike",
"avatar_url": "https://avatars.githubusercontent.com/u/19519553?v=4",
"profile": "https://github.com/spikecodes",
"contributions": [
"infra",
"doc"
]
},
{
"login": "evanrichter",
"name": "Evan Richter",
"avatar_url": "https://avatars.githubusercontent.com/u/330292?v=4",
"profile": "https://github.com/evanrichter",
"contributions": [
"code",
"doc"
]
},
{
"login": "mzpqnxow",
"name": "AG",
"avatar_url": "https://avatars.githubusercontent.com/u/8016228?v=4",
"profile": "https://github.com/mzpqnxow",
"contributions": [
"ideas",
"doc"
]
},
{
"login": "n-thumann",
"name": "Nicolas Thumann",
"avatar_url": "https://avatars.githubusercontent.com/u/46975855?v=4",
"profile": "https://n-thumann.de/",
"contributions": [
"code",
"doc"
]
},
{
"login": "tomtastic",
"name": "Tom Matthews",
"avatar_url": "https://avatars.githubusercontent.com/u/302127?v=4",
"profile": "https://github.com/tomtastic",
"contributions": [
"doc"
]
},
{
"login": "bsysop",
"name": "bsysop",
"avatar_url": "https://avatars.githubusercontent.com/u/9998303?v=4",
"profile": "https://github.com/bsysop",
"contributions": [
"doc"
]
},
{
"login": "bpsizemore",
"name": "Brian Sizemore",
"avatar_url": "https://avatars.githubusercontent.com/u/11645898?v=4",
"profile": "http://bpsizemore.me",
"contributions": [
"code"
]
},
{
"login": "noraj",
"name": "Alexandre ZANNI",
"avatar_url": "https://avatars.githubusercontent.com/u/16578570?v=4",
"profile": "https://pwn.by/noraj",
"contributions": [
"infra",
"doc"
]
},
{
"login": "craig",
"name": "Craig",
"avatar_url": "https://avatars.githubusercontent.com/u/99729?v=4",
"profile": "https://github.com/craig",
"contributions": [
"infra"
]
},
{
"login": "EONRaider",
"name": "EONRaider",
"avatar_url": "https://avatars.githubusercontent.com/u/15611424?v=4",
"profile": "https://www.reddit.com/u/EONRaider",
"contributions": [
"infra"
]
},
{
"login": "wtwver",
"name": "wtwver",
"avatar_url": "https://avatars.githubusercontent.com/u/53866088?v=4",
"profile": "https://github.com/wtwver",
"contributions": [
"infra"
]
},
{
"login": "Tib3rius",
"name": "Tib3rius",
"avatar_url": "https://avatars.githubusercontent.com/u/48113936?v=4",
"profile": "https://tib3rius.com",
"contributions": [
"bug"
]
},
{
"login": "0xdf",
"name": "0xdf",
"avatar_url": "https://avatars.githubusercontent.com/u/1489045?v=4",
"profile": "https://github.com/0xdf",
"contributions": [
"bug"
]
},
{
"login": "secure-77",
"name": "secure-77",
"avatar_url": "https://avatars.githubusercontent.com/u/31564517?v=4",
"profile": "http://secure77.de",
"contributions": [
"bug"
]
},
{
"login": "sbrun",
"name": "Sophie Brun",
"avatar_url": "https://avatars.githubusercontent.com/u/7712154?v=4",
"profile": "https://github.com/sbrun",
"contributions": [
"infra"
]
},
{
"login": "black-A",
"name": "black-A",
"avatar_url": "https://avatars.githubusercontent.com/u/30686803?v=4",
"profile": "https://github.com/black-A",
"contributions": [
"ideas"
]
},
{
"login": "dinosn",
"name": "Nicolas Krassas",
"avatar_url": "https://avatars.githubusercontent.com/u/3851678?v=4",
"profile": "https://github.com/dinosn",
"contributions": [
"ideas"
]
},
{
"login": "N0ur5",
"name": "N0ur5",
"avatar_url": "https://avatars.githubusercontent.com/u/24260009?v=4",
"profile": "https://github.com/N0ur5",
"contributions": [
"ideas"
]
},
{
"login": "moscowchill",
"name": "mchill",
"avatar_url": "https://avatars.githubusercontent.com/u/72578879?v=4",
"profile": "https://github.com/moscowchill",
"contributions": [
"bug"
]
},
{
"login": "BitThr3at",
"name": "Naman",
"avatar_url": "https://avatars.githubusercontent.com/u/45028933?v=4",
"profile": "http://BitThr3at.github.io",
"contributions": [
"bug"
]
},
{
"login": "sicks3c",
"name": "Ayoub Elaich",
"avatar_url": "https://avatars.githubusercontent.com/u/32225186?v=4",
"profile": "https://github.com/Sicks3c",
"contributions": [
"bug"
]
},
{
"login": "HenryHoggard",
"name": "Henry",
"avatar_url": "https://avatars.githubusercontent.com/u/1208121?v=4",
"profile": "https://github.com/HenryHoggard",
"contributions": [
"bug"
]
},
{
"login": "SleepiPanda",
"name": "SleepiPanda",
"avatar_url": "https://avatars.githubusercontent.com/u/6428561?v=4",
"profile": "https://github.com/SleepiPanda",
"contributions": [
"bug"
]
},
{
"login": "uBadRequest",
"name": "Bad Requests",
"avatar_url": "https://avatars.githubusercontent.com/u/47282747?v=4",
"profile": "https://github.com/uBadRequest",
"contributions": [
"bug"
]
},
{
"login": "dnaka91",
"name": "Dominik Nakamura",
"avatar_url": "https://avatars.githubusercontent.com/u/36804488?v=4",
"profile": "https://home.dnaka91.rocks",
"contributions": [
"infra"
]
},
{
"login": "hunter0x8",
"name": "Muhammad Ahsan",
"avatar_url": "https://avatars.githubusercontent.com/u/46222314?v=4",
"profile": "https://github.com/hunter0x8",
"contributions": [
"bug"
]
},
{
"login": "cortantief",
"name": "cortantief",
"avatar_url": "https://avatars.githubusercontent.com/u/34527333?v=4",
"profile": "https://github.com/cortantief",
"contributions": [
"bug",
"code"
]
},
{
"login": "dsaxton",
"name": "Daniel Saxton",
"avatar_url": "https://avatars.githubusercontent.com/u/2658661?v=4",
"profile": "https://github.com/dsaxton",
"contributions": [
"ideas",
"code"
]
},
{
"login": "n0kovo",
"name": "n0kovo",
"avatar_url": "https://avatars.githubusercontent.com/u/16690056?v=4",
"profile": "https://github.com/n0kovo",
"contributions": [
"ideas"
]
},
{
"login": "justinsteven",
"name": "Justin Steven",
"avatar_url": "https://avatars.githubusercontent.com/u/1893909?v=4",
"profile": "https://ring0.lol",
"contributions": [
"ideas"
]
},
{
"login": "7047payloads",
"name": "7047payloads",
"avatar_url": "https://avatars.githubusercontent.com/u/95562424?v=4",
"profile": "https://github.com/7047payloads",
"contributions": [
"code"
]
},
{
"login": "unkn0wnsyst3m",
"name": "unkn0wnsyst3m",
"avatar_url": "https://avatars.githubusercontent.com/u/21272239?v=4",
"profile": "https://github.com/unkn0wnsyst3m",
"contributions": [
"ideas"
]
},
{
"login": "its0x08",
"name": "0x08",
"avatar_url": "https://avatars.githubusercontent.com/u/15280042?v=4",
"profile": "https://ironwort.me/",
"contributions": [
"ideas"
]
},
{
"login": "MD-Levitan",
"name": "kusok",
"avatar_url": "https://avatars.githubusercontent.com/u/12116508?v=4",
"profile": "https://github.com/MD-Levitan",
"contributions": [
"ideas",
"code"
]
},
{
"login": "godylockz",
"name": "godylockz",
"avatar_url": "https://avatars.githubusercontent.com/u/81207744?v=4",
"profile": "https://github.com/godylockz",
"contributions": [
"ideas",
"code"
]
},
{
"login": "0dayCTF",
"name": "Ryan Montgomery",
"avatar_url": "https://avatars.githubusercontent.com/u/44453666?v=4",
"profile": "http://ryanmontgomery.me",
"contributions": [
"ideas"
]
},
{
"login": "ippsec",
"name": "ippsec",
"avatar_url": "https://avatars.githubusercontent.com/u/24677271?v=4",
"profile": "https://github.com/IppSec",
"contributions": [
"ideas"
]
},
{
"login": "gtjamesa",
"name": "James",
"avatar_url": "https://avatars.githubusercontent.com/u/2078364?v=4",
"profile": "https://github.com/gtjamesa",
"contributions": [
"bug"
]
},
{
"login": "jhaddix",
"name": "Jason Haddix",
"avatar_url": "https://avatars.githubusercontent.com/u/3488554?v=4",
"profile": "https://twitter.com/Jhaddix",
"contributions": [
"ideas",
"bug"
]
},
{
"login": "ThisLimn0",
"name": "Limn0",
"avatar_url": "https://avatars.githubusercontent.com/u/67125885?v=4",
"profile": "https://github.com/ThisLimn0",
"contributions": [
"bug"
]
},
{
"login": "0xdf223",
"name": "0xdf",
"avatar_url": "https://avatars.githubusercontent.com/u/76954092?v=4",
"profile": "https://github.com/0xdf223",
"contributions": [
"bug",
"ideas"
]
},
{
"login": "Flangyver",
"name": "Flangyver",
"avatar_url": "https://avatars.githubusercontent.com/u/59575870?v=4",
"profile": "https://github.com/Flangyver",
"contributions": [
"ideas"
]
},
{
"login": "DonatoReis",
"name": "PeakyBlinder",
"avatar_url": "https://avatars.githubusercontent.com/u/93531354?v=4",
"profile": "https://github.com/DonatoReis",
"contributions": [
"ideas"
]
},
{
"login": "postmodern",
"name": "Postmodern",
"avatar_url": "https://avatars.githubusercontent.com/u/12671?v=4",
"profile": "https://postmodern.github.io/",
"contributions": [
"ideas"
]
},
{
"login": "herrcykel",
"name": "O",
"avatar_url": "https://avatars.githubusercontent.com/u/1936757?v=4",
"profile": "https://github.com/herrcykel",
"contributions": [
"code"
]
},
{
"login": "udoprog",
"name": "John-John Tedro",
"avatar_url": "https://avatars.githubusercontent.com/u/111092?v=4",
"profile": "http://udoprog.github.io/",
"contributions": [
"code"
]
},
{
"login": "kmanc",
"name": "kmanc",
"avatar_url": "https://avatars.githubusercontent.com/u/14863147?v=4",
"profile": "https://github.com/kmanc",
"contributions": [
"bug",
"code"
]
},
{
"login": "hakdogpinas",
"name": "hakdogpinas",
"avatar_url": "https://avatars.githubusercontent.com/u/71529469?v=4",
"profile": "https://github.com/hakdogpinas",
"contributions": [
"ideas"
]
},
{
"login": "duokebei",
"name": "多可悲",
"avatar_url": "https://avatars.githubusercontent.com/u/75022552?v=4",
"profile": "https://github.com/duokebei",
"contributions": [
"ideas"
]
},
{
"login": "aidanhall34",
"name": "Aidan Hall",
"avatar_url": "https://avatars.githubusercontent.com/u/58670593?v=4",
"profile": "https://blog.ah34.net/",
"contributions": [
"code",
"infra"
]
},
{
"login": "joaociocca",
"name": "João Ciocca",
"avatar_url": "https://avatars.githubusercontent.com/u/6473725?v=4",
"profile": "https://hachyderm.io/@JohnnyCiocca",
"contributions": [
"bug",
"ideas"
]
},
{
"login": "f3rn0s",
"name": "f3rn0s",
"avatar_url": "https://avatars.githubusercontent.com/u/1351279?v=4",
"profile": "https://github.com/f3rn0s",
"contributions": [
"bug"
]
},
{
"login": "pich4ya",
"name": "LongCat",
"avatar_url": "https://avatars.githubusercontent.com/u/2099767?v=4",
"profile": "https://sth.sh",
"contributions": [
"ideas"
]
},
{
"login": "xaeroborg",
"name": "xaeroborg",
"avatar_url": "https://avatars.githubusercontent.com/u/33274680?v=4",
"profile": "https://github.com/xaeroborg",
"contributions": [
"ideas"
]
},
{
"login": "Luoooio",
"name": "Luoooio",
"avatar_url": "https://avatars.githubusercontent.com/u/26653157?v=4",
"profile": "https://github.com/Luoooio",
"contributions": [
"ideas"
]
},
{
"login": "aancw",
"name": "Aan",
"avatar_url": "https://avatars.githubusercontent.com/u/6284204?v=4",
"profile": "https://petruknisme.com",
"contributions": [
"code",
"infra"
]
}
],
"contributorsPerLine": 7,
"projectName": "feroxbuster",
"projectOwner": "epi052",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true,
"commitConvention": "angular"
}

5
.cargo/config Normal file
View File

@@ -0,0 +1,5 @@
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [epi052]
ko_fi: epi052

View File

@@ -7,16 +7,20 @@ Long form explanations of most of the items below can be found in the [CONTRIBUT
- [ ] Your PR description references the associated issue (i.e. fixes #123456) - [ ] Your PR description references the associated issue (i.e. fixes #123456)
- [ ] Code is in its own branch - [ ] Code is in its own branch
- [ ] Branch name is related to the PR contents - [ ] Branch name is related to the PR contents
- [ ] PR targets master - [ ] PR targets main
## Static analysis checks ## Static analysis checks
- [ ] All rust files are formatted using `cargo fmt` - [ ] All rust files are formatted using `cargo fmt`
- [ ] All `clippy` checks pass when running `cargo clippy --all-targets --all-features -- -D warnings -A clippy::deref_addrof` - [ ] All `clippy` checks pass when running `cargo clippy --all-targets --all-features -- -D warnings -A clippy::mutex-atomic`
- [ ] All existing tests pass - [ ] All existing tests pass
## Documentation ## Documentation
- [ ] New code is documented using [doc comments](https://doc.rust-lang.org/stable/rust-by-example/meta/doc.html) - [ ] New code is documented using [doc comments](https://doc.rust-lang.org/stable/rust-by-example/meta/doc.html)
- [ ] Documentation about your PR is included in the README, as needed - [ ] Documentation about your PR is included in the `docs`, as needed. The docs live in a [separate repository](https://epi052.github.io/feroxbuster-docs/docs/). Update the appropriate pages at the links below.
- [ ] update [example config file section](https://epi052.github.io/feroxbuster-docs/docs/configuration/ferox-config-toml/)
- [ ] update [help output section](https://epi052.github.io/feroxbuster-docs/docs/configuration/command-line/)
- [ ] add an [example](https://epi052.github.io/feroxbuster-docs/docs/examples/)
- [ ] update [comparison table](https://epi052.github.io/feroxbuster-docs/docs/compare/)
## Additional Tests ## Additional Tests
- [ ] New code is unit tested - [ ] New code is unit tested

1
.github/stale.yml vendored
View File

@@ -6,6 +6,7 @@ daysUntilClose: 7
exemptLabels: exemptLabels:
- pinned - pinned
- security - security
- confirmed
# Label to use when marking an issue as stale # Label to use when marking an issue as stale
staleLabel: stale staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable

View File

@@ -4,11 +4,13 @@ on: [push]
jobs: jobs:
build-nix: build-nix:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
strategy: strategy:
matrix: matrix:
type: [ubuntu-x64, ubuntu-x86] type: [ubuntu-x64, ubuntu-x86, armv7, aarch64]
include: include:
- type: ubuntu-x64 - type: ubuntu-x64
os: ubuntu-latest os: ubuntu-latest
@@ -22,12 +24,25 @@ jobs:
name: x86-linux-feroxbuster name: x86-linux-feroxbuster
path: target/i686-unknown-linux-musl/release/feroxbuster path: target/i686-unknown-linux-musl/release/feroxbuster
pkg_config_path: /usr/lib/i686-linux-gnu/pkgconfig pkg_config_path: /usr/lib/i686-linux-gnu/pkgconfig
- type: armv7
os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
name: armv7-feroxbuster
path: target/armv7-unknown-linux-gnueabihf/release/feroxbuster
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
- type: aarch64
os: ubuntu-latest
target: aarch64-unknown-linux-gnu
name: aarch64-feroxbuster
path: target/aarch64-unknown-linux-gnu/release/feroxbuster
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install System Dependencies - name: Install System Dependencies
run: | run: |
env
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends libssl-dev pkg-config sudo apt-get install -y --no-install-recommends libssl-dev pkg-config gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu
- uses: actions-rs/toolchain@v1 - uses: actions-rs/toolchain@v1
with: with:
toolchain: stable toolchain: stable
@@ -43,7 +58,7 @@ jobs:
args: --release --target=${{ matrix.target }} args: --release --target=${{ matrix.target }}
- name: Strip symbols from binary - name: Strip symbols from binary
run: | run: |
strip -s ${{ matrix.path }} strip -s ${{ matrix.path }} || arm-linux-gnueabihf-strip -s ${{ matrix.path }} || aarch64-linux-gnu-strip -s ${{ matrix.path }}
- name: Build tar.gz for homebrew installs - name: Build tar.gz for homebrew installs
if: matrix.type == 'ubuntu-x64' if: matrix.type == 'ubuntu-x64'
run: | run: |
@@ -58,20 +73,26 @@ jobs:
name: ${{ matrix.name }}.tar.gz name: ${{ matrix.name }}.tar.gz
path: ${{ matrix.name }}.tar.gz path: ${{ matrix.name }}.tar.gz
build-deb: # build-deb:
needs: [build-nix] # needs: [build-nix]
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- uses: actions/checkout@master # - uses: actions/checkout@master
- name: Deb Build # - name: Install cargo-deb
uses: ebbflow-io/cargo-deb-amd64-ubuntu@1.0 # run: cargo install -f cargo-deb
- name: Upload Deb Artifact # - name: Install musl toolchain
uses: actions/upload-artifact@v2 # run: rustup target add x86_64-unknown-linux-musl
with: # - name: Deb Build
name: feroxbuster_amd64.deb # run: cargo deb --target=x86_64-unknown-linux-musl
path: ./target/x86_64-unknown-linux-musl/debian/* # - name: Upload Deb Artifact
# uses: actions/upload-artifact@v2
# with:
# name: feroxbuster_amd64.deb
# path: ./target/x86_64-unknown-linux-musl/debian/*
build-macos: build-macos:
env:
IN_PIPELINE: true
runs-on: macos-latest runs-on: macos-latest
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
steps: steps:
@@ -102,6 +123,8 @@ jobs:
path: x86_64-macos-feroxbuster.tar.gz path: x86_64-macos-feroxbuster.tar.gz
build-windows: build-windows:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
strategy: strategy:
@@ -134,4 +157,3 @@ jobs:
with: with:
name: ${{ matrix.name }} name: ${{ matrix.name }}
path: ${{ matrix.path }} path: ${{ matrix.path }}

View File

@@ -8,11 +8,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: actions-rs/cargo@v1 - uses: actions-rs/cargo@v1
with: with:
command: check command: check
@@ -22,26 +17,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1 - name: Install latest nextest release
uses: taiki-e/install-action@nextest
- name: Test with latest nextest release
uses: actions-rs/cargo@v1
with: with:
profile: minimal command: nextest
toolchain: stable args: run --all-features --all-targets --retries 10
override: true
- uses: actions-rs/cargo@v1
with:
command: test
fmt: fmt:
name: Rust fmt name: Rust fmt
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add rustfmt
- uses: actions-rs/cargo@v1 - uses: actions-rs/cargo@v1
with: with:
command: fmt command: fmt
@@ -52,13 +40,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add clippy
- uses: actions-rs/cargo@v1 - uses: actions-rs/cargo@v1
with: with:
command: clippy command: clippy
args: --all-targets --all-features -- -D warnings -A clippy::deref_addrof args: --all-targets --all-features -- -D warnings

34
.github/workflows/cicd-to-dockerhub.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: ci-to-dockerhub
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/feroxbuster:latest
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View File

@@ -3,42 +3,22 @@ on: [push]
name: Code Coverage Pipeline name: Code Coverage Pipeline
jobs: jobs:
upload-coverage: coverage:
name: LLVM Coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1 - uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: nightly components: llvm-tools-preview
override: true - name: Install cargo-llvm-cov and cargo-nextest
- uses: actions-rs/cargo@v1 uses: taiki-e/install-action@v2
with: with:
command: clean tool: cargo-nextest,cargo-llvm-cov
- uses: actions-rs/cargo@v1 - name: Generate code coverage
run: cargo llvm-cov nextest --all-features --no-fail-fast --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with: with:
command: test files: lcov.info
args: --all-features --no-fail-fast
env:
CARGO_INCREMENTAL: '0'
RUSTFLAGS: '-Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort'
RUSTDOCFLAGS: '-Cpanic=abort'
- uses: actions-rs/grcov@v0.1
- uses: actions/upload-artifact@v2
with:
name: lcov.info
path: lcov.info
- name: Convert lcov to xml
run: |
curl -O https://raw.githubusercontent.com/epi052/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py
chmod +x lcov_cobertura.py
./lcov_cobertura.py ./lcov.info
- uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
name: codecov-umbrella
fail_ci_if_error: true fail_ci_if_error: true
- uses: actions/upload-artifact@v2
with:
name: coverage.xml
path: ./coverage.xml

5
.gitignore vendored
View File

@@ -9,6 +9,9 @@ target/
# jetbrains metadata folder # jetbrains metadata folder
.idea/ .idea/
# vscode metadata folder
.vscode/
# personal feroxbuster config for testing # personal feroxbuster config for testing
ferox-config.toml ferox-config.toml
@@ -23,7 +26,7 @@ lcov_cobertura.py
.dockerignore .dockerignore
# state file created during tests # state file created during tests
ferox-http* ferox-*.state
# python stuff cuz reasons # python stuff cuz reasons
Pipfile* Pipfile*

View File

@@ -76,35 +76,35 @@ Now that you have a copy of your fork, there is work you will need to do to keep
Do this prior to every time you create a branch for a PR: Do this prior to every time you create a branch for a PR:
1. Make sure you are on the `master` branch 1. Make sure you are on the `main` branch
> ```sh > ```sh
> $ git status > $ git status
> On branch master > On branch main
> Your branch is up-to-date with 'origin/master'. > Your branch is up-to-date with 'origin/main'.
> ``` > ```
> If your aren't on `master`, resolve outstanding files and commits and checkout the `master` branch > If your aren't on `main`, resolve outstanding files and commits and checkout the `main` branch
> ```sh > ```sh
> $ git checkout master > $ git checkout main
> ``` > ```
2. Do a pull with rebase against `upstream` 2. Do a pull with rebase against `upstream`
> ```sh > ```sh
> $ git pull --rebase upstream master > $ git pull --rebase upstream main
> ``` > ```
> This will pull down all of the changes to the official master branch, without making an additional commit in your local repo. > This will pull down all of the changes to the official main branch, without making an additional commit in your local repo.
3. (_Optional_) Force push your updated master branch to your GitHub fork 3. (_Optional_) Force push your updated main branch to your GitHub fork
> ```sh > ```sh
> $ git push origin master --force > $ git push origin main --force
> ``` > ```
> This will overwrite the master branch of your fork. > This will overwrite the main branch of your fork.
### Creating a branch ### Creating a branch
@@ -166,7 +166,7 @@ primarily related to continuous integration and release deployment.
feroxbuster uses the [`clippy`](https://rust-lang.github.io/rust-clippy/) code linter. feroxbuster uses the [`clippy`](https://rust-lang.github.io/rust-clippy/) code linter.
The command that will ultimately be used in the CI pipeline for linting is `cargo clippy --all-targets --all-features -- -D warnings -A clippy::unnecessary_unwrap`. The command that will ultimately be used in the CI pipeline for linting is `cargo clippy --all-targets --all-features -- -D warnings -A clippy::mutex-atomic`.
Before submitting a Pull Request, the above command should be run. Please do not ignore any linting errors in code you write or modify, as they are meant to **help** by ensuring a clean and simple code base. Before submitting a Pull Request, the above command should be run. Please do not ignore any linting errors in code you write or modify, as they are meant to **help** by ensuring a clean and simple code base.
@@ -214,20 +214,20 @@ GitHub has a good guide on how to contribute to open source [here](https://opens
##### Editing via your local fork ##### Editing via your local fork
1. Perform the maintenance step of rebasing `master` 1. Perform the maintenance step of rebasing `main`
2. Ensure you're on the `master` branch using `git status`: 2. Ensure you're on the `main` branch using `git status`:
```sh ```sh
$ git status $ git status
On branch master On branch main
Your branch is up-to-date with 'origin/master'. Your branch is up-to-date with 'origin/main'.
nothing to commit, working directory clean nothing to commit, working directory clean
``` ```
1. If you're not on master or your working directory is not clean, resolve 1. If you're not on main or your working directory is not clean, resolve
any outstanding files/commits and checkout master `git checkout master` any outstanding files/commits and checkout main `git checkout main`
2. Create a branch off of `master` with git: `git checkout -B 2. Create a branch off of `main` with git: `git checkout -B
branch/name-here` branch/name-here`
3. Edit your file(s) locally with the editor of your choice 3. Edit your file(s) locally with the editor of your choice
4. Check your `git status` to see unstaged files 4. Check your `git status` to see unstaged files
@@ -239,8 +239,8 @@ nothing to commit, working directory clean
8. Push your commits to your GitHub Fork: `git push -u origin branch/name-here` 8. Push your commits to your GitHub Fork: `git push -u origin branch/name-here`
9. Once the edits have been committed, you will be prompted to create a pull 9. Once the edits have been committed, you will be prompted to create a pull
request on your fork's GitHub page request on your fork's GitHub page
10. By default, all pull requests should be against the `master` branch 10. By default, all pull requests should be against the `main` branch
11. Submit a pull request from your branch to feroxbuster's `master` branch 11. Submit a pull request from your branch to feroxbuster's `main` branch
12. The title (also called the subject) of your PR should be descriptive of your 12. The title (also called the subject) of your PR should be descriptive of your
changes and succinctly indicate what is being fixed changes and succinctly indicate what is being fixed
- Examples: `Add test cases for Unicode support`; `Correct typo in overview documentation` - Examples: `Add test cases for Unicode support`; `Correct typo in overview documentation`

2141
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,20 @@
[package] [package]
name = "feroxbuster" name = "feroxbuster"
version = "2.0.2" version = "2.9.0"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"] authors = ["Ben 'epi' Risher (@epi052)"]
license = "MIT" license = "MIT"
edition = "2018" edition = "2021"
homepage = "https://github.com/epi052/feroxbuster" homepage = "https://github.com/epi052/feroxbuster"
repository = "https://github.com/epi052/feroxbuster" repository = "https://github.com/epi052/feroxbuster"
description = "A fast, simple, recursive content discovery tool." description = "A fast, simple, recursive content discovery tool."
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
keywords = ["pentest", "enumeration", "url-bruteforce", "content-discovery", "web"] keywords = [
"pentest",
"enumeration",
"url-bruteforce",
"content-discovery",
"web",
]
exclude = [".github/*", "img/*", "check-coverage.sh"] exclude = [".github/*", "img/*", "check-coverage.sh"]
build = "build.rs" build = "build.rs"
@@ -16,40 +22,46 @@ build = "build.rs"
maintenance = { status = "actively-developed" } maintenance = { status = "actively-developed" }
[build-dependencies] [build-dependencies]
clap = "2.33" clap = { version = "4.1.8", features = ["wrap_help", "cargo"] }
regex = "1" clap_complete = "4.1.4"
lazy_static = "1.4" regex = "1.5.5"
lazy_static = "1.4.0"
dirs = "4.0.0"
[dependencies] [dependencies]
futures = { version = "0.3"} scraper = "0.15.0"
tokio = { version = "1.0", features = ["full"] } futures = "0.3.26"
tokio-util = {version = "0.6.3", features = ["codec"]} tokio = { version = "1.26.0", features = ["full"] }
log = "0.4" tokio-util = { version = "0.7.7", features = ["codec"] }
env_logger = "0.8" log = "0.4.17"
reqwest = { version = "0.11", features = ["socks"] } env_logger = "0.10.0"
clap = "2.33" reqwest = { version = "0.11.10", features = ["socks"] }
lazy_static = "1.4" # uses feature unification to add 'serde' to reqwest::Url
toml = "0.5" url = { version = "2.2.2", features = ["serde"] }
serde = { version = "1.0", features = ["derive", "rc"] } serde_regex = "1.1.0"
serde_json = "1.0" clap = { version = "4.1.8", features = ["wrap_help", "cargo"] }
uuid = { version = "0.8", features = ["v4"] } lazy_static = "1.4.0"
toml = "0.7.2"
serde = { version = "1.0.137", features = ["derive", "rc"] }
serde_json = "1.0.94"
uuid = { version = "1.3.0", features = ["v4"] }
indicatif = "0.15" indicatif = "0.15"
console = "0.14" console = "0.15.2"
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
dirs = "3.0" dirs = "4.0.0"
regex = "1" regex = "1.5.5"
crossterm = "0.19" crossterm = "0.26.0"
rlimit = "0.5" rlimit = "0.9.1"
ctrlc = "3.1" ctrlc = "3.2.2"
fuzzyhash = "0.2.1" anyhow = "1.0.69"
anyhow = "1.0" leaky-bucket = "0.12.1"
leaky-bucket = "0.10.0" gaoya = "0.1.2"
[dev-dependencies] [dev-dependencies]
tempfile = "3.1" tempfile = "3.3.0"
httpmock = "0.5.2" httpmock = "0.6.6"
assert_cmd = "1.0.3" assert_cmd = "2.0.4"
predicates = "1.0.7" predicates = "2.1.1"
[profile.release] [profile.release]
lto = true lto = true
@@ -61,6 +73,29 @@ section = "utility"
license-file = ["LICENSE", "4"] license-file = ["LICENSE", "4"]
conf-files = ["/etc/feroxbuster/ferox-config.toml"] conf-files = ["/etc/feroxbuster/ferox-config.toml"]
assets = [ assets = [
["target/release/feroxbuster", "/usr/bin/", "755"], [
["ferox-config.toml.example", "/etc/feroxbuster/ferox-config.toml", "644"], "target/release/feroxbuster",
"/usr/bin/",
"755",
],
[
"ferox-config.toml.example",
"/etc/feroxbuster/ferox-config.toml",
"644",
],
[
"shell_completions/feroxbuster.bash",
"/usr/share/bash-completion/completions/feroxbuster.bash",
"644",
],
[
"shell_completions/feroxbuster.fish",
"/usr/share/fish/completions/feroxbuster.fish",
"644",
],
[
"shell_completions/_feroxbuster",
"/usr/share/zsh/vendor-completions/_feroxbuster",
"644",
],
] ]

View File

@@ -1,14 +1,23 @@
FROM alpine:latest FROM alpine:3.17.1 as build
LABEL maintainer="wfnintr@null.net" LABEL maintainer="wfnintr@null.net"
RUN sed -i -e 's/v[[:digit:]]\..*\//edge\//g' /etc/apk/repositories && apk upgrade --update-cache --available RUN apk upgrade --update-cache --available && apk add --update openssl
# download default wordlists # Download latest release
RUN apk add --no-cache --virtual .depends subversion font-noto-emoji && \ RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip \
svn export https://github.com/danielmiessler/SecLists/trunk/Discovery/Web-Content /usr/share/seclists/Discovery/Web-Content && \ && unzip -d /tmp/ feroxbuster.zip feroxbuster \
apk del .depends && chmod +x /tmp/feroxbuster \
&& wget https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-medium-directories.txt -O /tmp/raft-medium-directories.txt
# install latest release from alpine:3.17.1 as release
RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip && unzip -d /usr/local/bin/ feroxbuster.zip feroxbuster && rm feroxbuster.zip && chmod +x /usr/local/bin/feroxbuster COPY --from=build /tmp/raft-medium-directories.txt /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
COPY --from=build /tmp/feroxbuster /usr/local/bin/feroxbuster
RUN adduser \
--gecos "" \
--disabled-password \
feroxbuster
USER feroxbuster
ENTRYPOINT ["feroxbuster"] ENTRYPOINT ["feroxbuster"]

View File

@@ -6,12 +6,16 @@ datarootdir = $(prefix)/share
datadir = $(datarootdir) datadir = $(datarootdir)
example_config = ferox-config.toml.example example_config = ferox-config.toml.example
config_file = ferox-config.toml config_file = ferox-config.toml
completion_dir = shell_completions
completion_prefix = $(completion_dir)/$(BIN)
BIN=feroxbuster
SHR_SOURCES = $(shell find src -type f -wholename '*src/*.rs') Cargo.toml Cargo.lock SHR_SOURCES = $(shell find src -type f -wholename '*src/*.rs') Cargo.toml Cargo.lock
RELEASE = debug RELEASE = debug
DEBUG ?= 0 DEBUG ?= 0
ifeq (0,$(DEBUG))
ifeq (0, $(DEBUG))
ARGS = --release ARGS = --release
RELEASE = release RELEASE = release
endif endif
@@ -23,54 +27,53 @@ endif
TARGET = target/$(RELEASE) TARGET = target/$(RELEASE)
.PHONY: all clean distclean install uninstall update .PHONY: all clean install uninstall test update
BIN=feroxbuster
DESKTOP=$(APPID).desktop
all: cli all: cli
cli: $(TARGET)/$(BIN) $(TARGET)/$(BIN).1.gz $(SHR_SOURCES) cli: $(TARGET)/$(BIN) $(TARGET)/$(BIN).1.gz $(SHR_SOURCES)
install: all install-cli
verify:
cargo fmt
cargo clippy --all-targets --all-features -- -D warnings -A clippy::mutex-atomic
cargo test
clean: clean:
cargo clean cargo clean
distclean: clean
rm -rf .cargo vendor Cargo.lock vendor.tar
vendor: vendor.tar vendor: vendor.tar
vendor.tar: vendor.tar:
mkdir -p .cargo cargo vendor
cargo vendor | head -n -1 > .cargo/config
echo 'directory = "vendor"' >> .cargo/config
tar pcf vendor.tar vendor tar pcf vendor.tar vendor
rm -rf vendor rm -rf vendor
install-cli: cli install-cli: cli
install -Dm 0755 "$(TARGET)/$(BIN)" "$(DESTDIR)$(bindir)/$(BIN)" install -Dm 0644 "$(completion_prefix).bash" "$(DESTDIR)/usr/share/bash-completion/completions/$(BIN).bash"
install -Dm 0644 "$(completion_prefix).fish" "$(DESTDIR)/usr/share/fish/completions/$(BIN).fish"
install -Dm 0644 "$(completion_dir)/_$(BIN)" "$(DESTDIR)/usr/share/zsh/vendor-completions/_$(BIN)"
install -sDm 0755 "$(TARGET)/$(BIN)" "$(DESTDIR)$(bindir)/$(BIN)"
install -Dm 0644 "$(TARGET)/$(BIN).1.gz" "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz" install -Dm 0644 "$(TARGET)/$(BIN).1.gz" "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz"
install -Dm 0644 "$(example_config)" "/etc/$(BIN)/$(config_File)" install -Dm 0644 "$(example_config)" "$(DESTDIR)/etc/$(BIN)/$(config_file)"
install: all install-cli uninstall:
uninstall-cli:
rm -f "$(DESTDIR)$(bindir)/$(BIN)" rm -f "$(DESTDIR)$(bindir)/$(BIN)"
rm -f "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz" rm -f "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz"
rm -rf "/etc/$(BIN)/" rm -rf "$(DESTDIR)/etc/$(BIN)/"
rm -f "$(DESTDIR)/usr/share/bash-completion/completions/$(BIN).bash"
uninstall: uninstall-cli rm -f "$(DESTDIR)/usr/share/zsh/vendor-completions/_$(BIN)"
rm -f "$(DESTDIR)/usr/share/fish/completions/$(BIN).fish"
update:
cargo update
extract: extract:
ifeq ($(VENDORED),1) ifeq (1, $(VENDORED))
tar pxf vendor.tar tar pxf vendor.tar
endif endif
$(TARGET)/$(BIN): extract $(TARGET)/$(BIN): extract
cargo build --manifest-path Cargo.toml $(ARGS) mkdir -p .cargo debian
touch debian/cargo.config
cp debian/cargo.config .cargo/config.toml
cargo build $(ARGS)
$(TARGET)/$(BIN).1.gz: $(TARGET)/$(BIN) $(TARGET)/$(BIN).1.gz: $(TARGET)/$(BIN)
help2man --no-info $< | gzip -c > $@.partial help2man --no-info $< | gzip -c > $@.partial

32
Makefile.toml Normal file
View File

@@ -0,0 +1,32 @@
# composite tasks
[tasks.upgrade]
dependencies = ["upgrade-deps", "update"]
# cleaning
[tasks.clean-state]
script = """
rm ferox-*.state
"""
# dependency management
[tasks.upgrade-deps]
command = "cargo"
args = ["upgrade", "--exclude", "indicatif"]
[tasks.update]
command = "cargo"
args = ["update"]
# clippy / lint
[tasks.clippy]
clear = true
script = """
cargo clippy --all-targets --all-features -- -D warnings
"""
# tests
[tasks.test]
clear = true
script = """
cargo nextest run --all-features --all-targets --retries 10
"""

1059
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
extern crate clap; use std::fs::{copy, create_dir_all, OpenOptions};
use std::io::{Read, Seek, Write};
use clap::Shell; use clap_complete::{generate_to, shells};
include!("src/parser.rs"); include!("src/parser.rs");
@@ -15,9 +16,65 @@ fn main() {
let mut app = initialize(); let mut app = initialize();
let shells: [Shell; 4] = [Shell::Bash, Shell::Fish, Shell::Zsh, Shell::PowerShell]; generate_to(shells::Bash, &mut app, "feroxbuster", outdir).unwrap();
generate_to(shells::Zsh, &mut app, "feroxbuster", outdir).unwrap();
generate_to(shells::Zsh, &mut app, "feroxbuster", outdir).unwrap();
generate_to(shells::PowerShell, &mut app, "feroxbuster", outdir).unwrap();
generate_to(shells::Elvish, &mut app, "feroxbuster", outdir).unwrap();
for shell in &shells { // 0xdf pointed out an oddity when tab-completing options that expect file paths, the fix we
app.gen_completions("feroxbuster", *shell, outdir); // landed on was to add -o plusdirs to the bash completion script. The following code aims to
// automate that fix and have it present in all future builds
let mut contents = String::new();
let mut bash_file = OpenOptions::new()
.read(true)
.write(true)
.open(format!("{outdir}/feroxbuster.bash"))
.expect("Couldn't open bash completion script");
bash_file
.read_to_string(&mut contents)
.expect("Couldn't read bash completion script");
contents = contents.replace("default feroxbuster", "default -o plusdirs feroxbuster");
bash_file
.rewind()
.expect("Couldn't seek to position 0 in bash completion script");
bash_file
.write_all(contents.as_bytes())
.expect("Couldn't write updated bash completion script to disk");
// hunter0x8 let me know that when installing via cargo, it would be nice if we dropped a
// config file during the build process. The following code will place an example config in
// the user's configuration directory
// - linux: $XDG_CONFIG_HOME or $HOME/.config
// - macOS: $HOME/Library/Application Support
// - windows: {FOLDERID_RoamingAppData}
let mut config_dir = dirs::config_dir().expect("Couldn't resolve user's config directory");
config_dir = config_dir.join("feroxbuster"); // $HOME/.config/feroxbuster
if !config_dir.exists() {
// recursively create the feroxbuster directory and all of its parent components if
// they are missing
if create_dir_all(&config_dir).is_err() {
// only copy the config file when we're not running in the CI/CD pipeline
// which fails with permission denied
eprintln!("Couldn't create one or more directories needed to copy the config file");
return;
}
}
// hard-coding config name here to not rely on the crate we're building, if DEFAULT_CONFIG_NAME
// ever changes, this will need to be updated
let config_file = config_dir.join("ferox-config.toml");
if !config_file.exists() {
// config file doesn't exist, add it to the config directory
if copy("ferox-config.toml.example", config_file).is_err() {
eprintln!("Couldn't copy example config into config directory");
}
} }
} }

0
docs/.nojekyll Normal file
View File

11
docs/index.html Normal file
View File

@@ -0,0 +1,11 @@
<html>
<head>
<meta http-equiv="refresh"
content="0; url=https://epi052.github.io/feroxbuster-docs/">
</head>
<body>
<p>The page has moved to:
<a href="https://epi052.github.io/feroxbuster-docs/">feroxbuster-docs</a></p>
</body>
</html>

View File

@@ -16,23 +16,36 @@
# replay_proxy = "http://127.0.0.1:8081" # replay_proxy = "http://127.0.0.1:8081"
# replay_codes = [200, 302] # replay_codes = [200, 302]
# verbosity = 1 # verbosity = 1
# parallel = 8
# scan_limit = 6 # scan_limit = 6
# rate_limit = 250 # rate_limit = 250
# quiet = true # quiet = true
# silent = true # silent = true
# auto_tune = true
# auto_bail = true
# json = true # json = true
# output = "/targets/ellingson_mineral_company/gibson.txt" # output = "/targets/ellingson_mineral_company/gibson.txt"
# debug_log = "/var/log/find-the-derp.log" # debug_log = "/var/log/find-the-derp.log"
# user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0" # user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
# random_agent = false
# redirects = true # redirects = true
# insecure = true # insecure = true
# collect_words = true
# collect_backups = true
# collect_extensions = true
# extensions = ["php", "html"] # extensions = ["php", "html"]
# dont_collect = ["png", "gif", "jpg", "jpeg"]
# methods = ["GET", "POST"]
# data = [11, 12, 13, 14, 15]
# url_denylist = ["http://dont-scan.me", "https://also-not.me"]
# regex_denylist = ["/deny.*"]
# no_recursion = true # no_recursion = true
# add_slash = true # add_slash = true
# stdin = true # stdin = true
# dont_filter = true # dont_filter = true
# extract_links = true # extract_links = true
# depth = 1 # depth = 1
# force_recursion = true
# filter_size = [5174] # filter_size = [5174]
# filter_regex = ["^ignore me$"] # filter_regex = ["^ignore me$"]
# filter_similar = ["https://somesite.com/soft404"] # filter_similar = ["https://somesite.com/soft404"]

BIN
img/auto-bail-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

BIN
img/auto-tune-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 670 KiB

BIN
img/logo/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -3,59 +3,63 @@
BASE_URL=https://github.com/epi052/feroxbuster/releases/latest/download BASE_URL=https://github.com/epi052/feroxbuster/releases/latest/download
MAC_ZIP=x86_64-macos-feroxbuster.zip MAC_ZIP=x86_64-macos-feroxbuster.zip
MAC_URL="${BASE_URL}/${MAC_ZIP}" MAC_URL="$BASE_URL/$MAC_ZIP"
LIN32_ZIP=x86-linux-feroxbuster.zip LIN32_ZIP=x86-linux-feroxbuster.zip
LIN32_URL="${BASE_URL}/${LIN32_ZIP}" LIN32_URL="$BASE_URL/$LIN32_ZIP"
LIN64_ZIP=x86_64-linux-feroxbuster.zip LIN64_ZIP=x86_64-linux-feroxbuster.zip
LIN64_URL="${BASE_URL}/${LIN64_ZIP}" LIN64_URL="$BASE_URL/$LIN64_ZIP"
EMOJI_URL=https://gist.github.com/epi052/8196b550ea51d0907ad4b93751b1b57d/raw/6112c9f32ae07922983fdc549c54fd3fb9a38e4c/NotoColorEmoji.ttf EMOJI_URL=https://gist.github.com/epi052/8196b550ea51d0907ad4b93751b1b57d/raw/6112c9f32ae07922983fdc549c54fd3fb9a38e4c/NotoColorEmoji.ttf
echo "[+] Installing feroxbuster!" echo "[+] Installing feroxbuster!"
if [[ "$(uname)" == "Darwin" ]]; then which unzip &>/dev/null
echo "[=] Found MacOS, downloading from ${MAC_URL}" if [ "$?" = "0" ]; then
echo "[+] unzip found"
curl -sLO "${MAC_URL}" else
unzip -o "${MAC_ZIP}" > /dev/null echo "[ ] unzip not found, exiting. "
rm "${MAC_ZIP}" exit -1
elif [[ "$(expr substr $(uname -s) 1 5)" == "Linux" ]]; then
if [[ $(getconf LONG_BIT) == 32 ]]; then
echo "[=] Found 32-bit Linux, downloading from ${LIN32_URL}"
curl -sLO "${LIN32_URL}"
unzip -o "${LIN32_ZIP}" > /dev/null
rm "${LIN32_ZIP}"
else
echo "[=] Found 64-bit Linux, downloading from ${LIN64_URL}"
curl -sLO "${LIN64_URL}"
unzip -o "${LIN64_ZIP}" > /dev/null
rm "${LIN64_ZIP}"
fi
if [[ -e ~/.fonts/NotoColorEmoji.ttf ]]; then
echo "[=] Found Noto Emoji Font, skipping install"
else
echo "[=] Installing Noto Emoji Font"
mkdir -p ~/.fonts
pushd ~/.fonts 2>&1 >/dev/null
curl -sLO "${EMOJI_URL}"
fc-cache -f -v >/dev/null
popd 2>&1 >/dev/null
echo "[+] Noto Emoji Font installed"
fi
fi fi
if [[ "$(uname)" == "Darwin" ]]; then
echo "[=] Found MacOS, downloading from $MAC_URL"
curl -sLO "$MAC_URL"
unzip -o "$MAC_ZIP" >/dev/null
rm "$MAC_ZIP"
elif [[ "$(expr substr $(uname -s) 1 5)" == "Linux" ]]; then
if [[ $(getconf LONG_BIT) == 32 ]]; then
echo "[=] Found 32-bit Linux, downloading from $LIN32_URL"
curl -sLO "$LIN32_URL"
unzip -o "$LIN32_ZIP" >/dev/null
rm "$LIN32_ZIP"
else
echo "[=] Found 64-bit Linux, downloading from $LIN64_URL"
curl -sLO "$LIN64_URL"
unzip -o "$LIN64_ZIP" >/dev/null
rm "$LIN64_ZIP"
fi
if [[ -e ~/.fonts/NotoColorEmoji.ttf ]]; then
echo "[=] Found Noto Emoji Font, skipping install"
else
echo "[=] Installing Noto Emoji Font"
mkdir -p ~/.fonts
pushd ~/.fonts 2>&1 >/dev/null
curl -sLO "$EMOJI_URL"
fc-cache -f -v >/dev/null
popd 2>&1 >/dev/null
echo "[+] Noto Emoji Font installed"
fi
fi
chmod +x ./feroxbuster chmod +x ./feroxbuster
echo "[+] Installed feroxbuster version $(./feroxbuster -V)" echo "[+] Installed feroxbuster version $(./feroxbuster -V)"

View File

@@ -15,84 +15,110 @@ _feroxbuster() {
local context curcontext="$curcontext" state line local context curcontext="$curcontext" state line
_arguments "${_arguments_options[@]}" \ _arguments "${_arguments_options[@]}" \
'-w+[Path to the wordlist]' \ '-u+[The target URL (required, unless \[--stdin || --resume-from\] used)]:URL:_urls' \
'--wordlist=[Path to the wordlist]' \ '--url=[The target URL (required, unless \[--stdin || --resume-from\] used)]:URL:_urls' \
'*-u+[The target URL(s) (required, unless --stdin used)]' \ '(-u --url)--resume-from=[State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)]:STATE_FILE:_files' \
'*--url=[The target URL(s) (required, unless --stdin used)]' \ '-p+[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]:PROXY:_urls' \
'-t+[Number of concurrent threads (default: 50)]' \ '--proxy=[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]:PROXY:_urls' \
'--threads=[Number of concurrent threads (default: 50)]' \ '-P+[Send only unfiltered requests through a Replay Proxy, instead of all requests]:REPLAY_PROXY:_urls' \
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \ '--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]:REPLAY_PROXY:_urls' \
'--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \ '*-R+[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]:REPLAY_CODE: ' \
'-T+[Number of seconds before a request times out (default: 7)]' \ '*--replay-codes=[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]:REPLAY_CODE: ' \
'--timeout=[Number of seconds before a request times out (default: 7)]' \ '-a+[Sets the User-Agent (default: feroxbuster/2.9.0)]:USER_AGENT: ' \
'-p+[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]' \ '--user-agent=[Sets the User-Agent (default: feroxbuster/2.9.0)]:USER_AGENT: ' \
'--proxy=[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]' \ '*-x+[File extension(s) to search for (ex: -x php -x pdf js)]:FILE_EXTENSION: ' \
'-P+[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \ '*--extensions=[File extension(s) to search for (ex: -x php -x pdf js)]:FILE_EXTENSION: ' \
'--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \ '*-m+[Which HTTP request method(s) should be sent (default: GET)]:HTTP_METHODS: ' \
'*-R+[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \ '*--methods=[Which HTTP request method(s) should be sent (default: GET)]:HTTP_METHODS: ' \
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \ '--data=[Request'\''s Body; can read data from a file if input starts with an @ (ex: @post.bin)]:DATA: ' \
'*-s+[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \ '*-H+[Specify HTTP headers to be used in each request (ex: -H Header:val -H '\''stuff: things'\'')]:HEADER: ' \
'*--status-codes=[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \ '*--headers=[Specify HTTP headers to be used in each request (ex: -H Header:val -H '\''stuff: things'\'')]:HEADER: ' \
'-o+[Output file to write results to (use w/ --json for JSON entries)]' \ '*-b+[Specify HTTP cookies to be used in each request (ex: -b stuff=things)]:COOKIE: ' \
'--output=[Output file to write results to (use w/ --json for JSON entries)]' \ '*--cookies=[Specify HTTP cookies to be used in each request (ex: -b stuff=things)]:COOKIE: ' \
'(-u --url)--resume-from=[State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)]' \ '*-Q+[Request'\''s URL query parameters (ex: -Q token=stuff -Q secret=key)]:QUERY: ' \
'--debug-log=[Output file to write log entries (use w/ --json for JSON entries)]' \ '*--query=[Request'\''s URL query parameters (ex: -Q token=stuff -Q secret=key)]:QUERY: ' \
'-a+[Sets the User-Agent (default: feroxbuster/VERSION)]' \ '*--dont-scan=[URL(s) or Regex Pattern(s) to exclude from recursion/scans]:URL: ' \
'--user-agent=[Sets the User-Agent (default: feroxbuster/VERSION)]' \ '*-S+[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]:SIZE: ' \
'*-x+[File extension(s) to search for (ex: -x php -x pdf js)]' \ '*--filter-size=[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]:SIZE: ' \
'*--extensions=[File extension(s) to search for (ex: -x php -x pdf js)]' \ '*-X+[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]:REGEX: ' \
'*-H+[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \ '*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]:REGEX: ' \
'*--headers=[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \ '*-W+[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]:WORDS: ' \
'*-Q+[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \ '*--filter-words=[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]:WORDS: ' \
'*--query=[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \ '*-N+[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]:LINES: ' \
'*-S+[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \ '*--filter-lines=[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]:LINES: ' \
'*--filter-size=[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \ '(-s --status-codes)*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
'*-X+[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \ '(-s --status-codes)*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
'*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \ '*--filter-similar-to=[Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)]:UNWANTED_PAGE:_urls' \
'*-W+[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \ '*-s+[Status Codes to include (allow list) (default: All Status Codes)]:STATUS_CODE: ' \
'*--filter-words=[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \ '*--status-codes=[Status Codes to include (allow list) (default: All Status Codes)]:STATUS_CODE: ' \
'*-N+[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \ '-T+[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
'*--filter-lines=[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \ '--timeout=[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
'*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \ '-t+[Number of concurrent threads (default: 50)]:THREADS: ' \
'*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \ '--threads=[Number of concurrent threads (default: 50)]:THREADS: ' \
'*--filter-similar-to=[Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)]' \ '-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]:RECURSION_DEPTH: ' \
'-L+[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \ '--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]:RECURSION_DEPTH: ' \
'--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \ '-L+[Limit total number of concurrent scans (default: 0, i.e. no limit)]:SCAN_LIMIT: ' \
'--rate-limit=[Limit number of requests per second (per directory) (default: 0, i.e. no limit)]' \ '--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]:SCAN_LIMIT: ' \
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]' \ '--parallel=[Run parallel feroxbuster instances (one child process per url passed via stdin)]:PARALLEL_SCANS: ' \
'(--auto-tune)--rate-limit=[Limit number of requests per second (per directory) (default: 0, i.e. no limit)]:RATE_LIMIT: ' \
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]:TIME_SPEC: ' \
'-w+[Path to the wordlist]:FILE:_files' \
'--wordlist=[Path to the wordlist]:FILE:_files' \
'*-I+[File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)]:FILE_EXTENSION: ' \
'*--dont-collect=[File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)]:FILE_EXTENSION: ' \
'-o+[Output file to write results to (use w/ --json for JSON entries)]:FILE:_files' \
'--output=[Output file to write results to (use w/ --json for JSON entries)]:FILE:_files' \
'--debug-log=[Output file to write log entries (use w/ --json for JSON entries)]:FILE:_files' \
'(-u --url)--stdin[Read url(s) from STDIN]' \
'(-p --proxy -k --insecure --burp-replay)--burp[Set --proxy to http://127.0.0.1:8080 and set --insecure to true]' \
'(-P --replay-proxy -k --insecure)--burp-replay[Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true]' \
'(--rate-limit --auto-bail)--smart[Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true]' \
'(--rate-limit --auto-bail)--thorough[Use the same settings as --smart and set --collect-extensions to true]' \
'-A[Use a random User-Agent]' \
'--random-agent[Use a random User-Agent]' \
'-f[Append / to each request'\''s URL]' \
'--add-slash[Append / to each request'\''s URL]' \
'-r[Allow client to follow redirects]' \
'--redirects[Allow client to follow redirects]' \
'-k[Disables TLS certificate validation in the client]' \
'--insecure[Disables TLS certificate validation in the client]' \
'-n[Do not scan recursively]' \
'--no-recursion[Do not scan recursively]' \
'(-n --no-recursion)--force-recursion[Force recursion attempts on all '\''found'\'' endpoints (still respects recursion depth)]' \
'-e[Extract links from response body (html, javascript, etc...); make new requests based on findings]' \
'--extract-links[Extract links from response body (html, javascript, etc...); make new requests based on findings]' \
'(--auto-bail)--auto-tune[Automatically lower scan rate when an excessive amount of errors are encountered]' \
'--auto-bail[Automatically stop scanning when an excessive amount of errors are encountered]' \
'-D[Don'\''t auto-filter wildcard responses]' \
'--dont-filter[Don'\''t auto-filter wildcard responses]' \
'-E[Automatically discover extensions and add them to --extensions (unless they'\''re in --dont-collect)]' \
'--collect-extensions[Automatically discover extensions and add them to --extensions (unless they'\''re in --dont-collect)]' \
'-B[Automatically request likely backup extensions for "found" urls]' \
'--collect-backups[Automatically request likely backup extensions for "found" urls]' \
'-g[Automatically discover important words from within responses and add them to the wordlist]' \
'--collect-words[Automatically discover important words from within responses and add them to the wordlist]' \
'(--silent)*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \ '(--silent)*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
'(--silent)*--verbosity[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \ '(--silent)*--verbosity[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
'(-q --quiet)--silent[Only print URLs + turn off logging (good for piping a list of urls to other commands)]' \ '(-q --quiet)--silent[Only print URLs + turn off logging (good for piping a list of urls to other commands)]' \
'-q[Hide progress bars and banner (good for tmux windows w/ notifications)]' \ '-q[Hide progress bars and banner (good for tmux windows w/ notifications)]' \
'--quiet[Hide progress bars and banner (good for tmux windows w/ notifications)]' \ '--quiet[Hide progress bars and banner (good for tmux windows w/ notifications)]' \
'--json[Emit JSON logs to --output and --debug-log instead of normal text]' \ '--json[Emit JSON logs to --output and --debug-log instead of normal text]' \
'-D[Don'\''t auto-filter wildcard responses]' \ '--no-state[Disable state output file (*.state)]' \
'--dont-filter[Don'\''t auto-filter wildcard responses]' \ '-h[Print help (see more with '\''--help'\'')]' \
'-r[Follow redirects]' \ '--help[Print help (see more with '\''--help'\'')]' \
'--redirects[Follow redirects]' \ '-V[Print version]' \
'-k[Disables TLS certificate validation]' \ '--version[Print version]' \
'--insecure[Disables TLS certificate validation]' \
'-n[Do not scan recursively]' \
'--no-recursion[Do not scan recursively]' \
'(-x --extensions)-f[Append / to each request]' \
'(-x --extensions)--add-slash[Append / to each request]' \
'(-u --url)--stdin[Read url(s) from STDIN]' \
'-e[Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)]' \
'--extract-links[Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)]' \
'-h[Prints help information]' \
'--help[Prints help information]' \
'-V[Prints version information]' \
'--version[Prints version information]' \
&& ret=0 && ret=0
} }
(( $+functions[_feroxbuster_commands] )) || (( $+functions[_feroxbuster_commands] )) ||
_feroxbuster_commands() { _feroxbuster_commands() {
local commands; commands=( local commands; commands=()
)
_describe -t commands 'feroxbuster commands' commands "$@" _describe -t commands 'feroxbuster commands' commands "$@"
} }
_feroxbuster "$@" if [ "$funcstack[1]" = "_feroxbuster" ]; then
_feroxbuster "$@"
else
compdef _feroxbuster feroxbuster
fi

View File

@@ -12,7 +12,8 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
$element = $commandElements[$i] $element = $commandElements[$i]
if ($element -isnot [StringConstantExpressionAst] -or if ($element -isnot [StringConstantExpressionAst] -or
$element.StringConstantType -ne [StringConstantType]::BareWord -or $element.StringConstantType -ne [StringConstantType]::BareWord -or
$element.Value.StartsWith('-')) { $element.Value.StartsWith('-') -or
$element.Value -eq $wordToComplete) {
break break
} }
$element.Value $element.Value
@@ -20,36 +21,29 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
$completions = @(switch ($command) { $completions = @(switch ($command) {
'feroxbuster' { 'feroxbuster' {
[CompletionResult]::new('-w', 'w', [CompletionResultType]::ParameterName, 'Path to the wordlist') [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from] used)')
[CompletionResult]::new('--wordlist', 'wordlist', [CompletionResultType]::ParameterName, 'Path to the wordlist') [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from] used)')
[CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The target URL(s) (required, unless --stdin used)') [CompletionResult]::new('--resume-from', 'resume-from', [CompletionResultType]::ParameterName, 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)')
[CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The target URL(s) (required, unless --stdin used)')
[CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
[CompletionResult]::new('--threads', 'threads', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
[CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
[CompletionResult]::new('--depth', 'depth', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
[CompletionResult]::new('-T', 'T', [CompletionResultType]::ParameterName, 'Number of seconds before a request times out (default: 7)')
[CompletionResult]::new('--timeout', 'timeout', [CompletionResultType]::ParameterName, 'Number of seconds before a request times out (default: 7)')
[CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)') [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)')
[CompletionResult]::new('--proxy', 'proxy', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)') [CompletionResult]::new('--proxy', 'proxy', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)')
[CompletionResult]::new('-P', 'P', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests') [CompletionResult]::new('-P', 'P', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
[CompletionResult]::new('--replay-proxy', 'replay-proxy', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests') [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('-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('--replay-codes', 'replay-codes', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
[CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)') [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.9.0)')
[CompletionResult]::new('--status-codes', 'status-codes', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)') [CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.9.0)')
[CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
[CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
[CompletionResult]::new('--resume-from', 'resume-from', [CompletionResultType]::ParameterName, 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)')
[CompletionResult]::new('--debug-log', 'debug-log', [CompletionResultType]::ParameterName, 'Output file to write log entries (use w/ --json for JSON entries)')
[CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/VERSION)')
[CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/VERSION)')
[CompletionResult]::new('-x', 'x', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)') [CompletionResult]::new('-x', 'x', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)')
[CompletionResult]::new('--extensions', 'extensions', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)') [CompletionResult]::new('--extensions', 'extensions', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)')
[CompletionResult]::new('-H', 'H', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')') [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Which HTTP request method(s) should be sent (default: GET)')
[CompletionResult]::new('--headers', 'headers', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')') [CompletionResult]::new('--methods', 'methods', [CompletionResultType]::ParameterName, 'Which HTTP request method(s) should be sent (default: GET)')
[CompletionResult]::new('-Q', 'Q', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)') [CompletionResult]::new('--data', 'data', [CompletionResultType]::ParameterName, 'Request''s Body; can read data from a file if input starts with an @ (ex: @post.bin)')
[CompletionResult]::new('--query', 'query', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)') [CompletionResult]::new('-H', 'H', [CompletionResultType]::ParameterName, 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')')
[CompletionResult]::new('--headers', 'headers', [CompletionResultType]::ParameterName, 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')')
[CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)')
[CompletionResult]::new('--cookies', 'cookies', [CompletionResultType]::ParameterName, 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)')
[CompletionResult]::new('-Q', 'Q', [CompletionResultType]::ParameterName, 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)')
[CompletionResult]::new('--query', 'query', [CompletionResultType]::ParameterName, 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)')
[CompletionResult]::new('--dont-scan', 'dont-scan', [CompletionResultType]::ParameterName, 'URL(s) or Regex Pattern(s) to exclude from recursion/scans')
[CompletionResult]::new('-S', 'S', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)') [CompletionResult]::new('-S', 'S', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
[CompletionResult]::new('--filter-size', 'filter-size', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)') [CompletionResult]::new('--filter-size', 'filter-size', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
[CompletionResult]::new('-X', 'X', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')') [CompletionResult]::new('-X', 'X', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')')
@@ -61,33 +55,65 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
[CompletionResult]::new('-C', 'C', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)') [CompletionResult]::new('-C', 'C', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
[CompletionResult]::new('--filter-status', 'filter-status', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)') [CompletionResult]::new('--filter-status', 'filter-status', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
[CompletionResult]::new('--filter-similar-to', 'filter-similar-to', [CompletionResultType]::ParameterName, 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)') [CompletionResult]::new('--filter-similar-to', 'filter-similar-to', [CompletionResultType]::ParameterName, 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)')
[CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: All Status Codes)')
[CompletionResult]::new('--status-codes', 'status-codes', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: All Status Codes)')
[CompletionResult]::new('-T', 'T', [CompletionResultType]::ParameterName, 'Number of seconds before a client''s request times out (default: 7)')
[CompletionResult]::new('--timeout', 'timeout', [CompletionResultType]::ParameterName, 'Number of seconds before a client''s request times out (default: 7)')
[CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
[CompletionResult]::new('--threads', 'threads', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
[CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
[CompletionResult]::new('--depth', 'depth', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
[CompletionResult]::new('-L', 'L', [CompletionResultType]::ParameterName, 'Limit total number of concurrent scans (default: 0, i.e. no limit)') [CompletionResult]::new('-L', 'L', [CompletionResultType]::ParameterName, 'Limit total number of concurrent scans (default: 0, i.e. no limit)')
[CompletionResult]::new('--scan-limit', 'scan-limit', [CompletionResultType]::ParameterName, 'Limit total number of concurrent scans (default: 0, i.e. no limit)') [CompletionResult]::new('--scan-limit', 'scan-limit', [CompletionResultType]::ParameterName, 'Limit total number of concurrent scans (default: 0, i.e. no limit)')
[CompletionResult]::new('--parallel', 'parallel', [CompletionResultType]::ParameterName, 'Run parallel feroxbuster instances (one child process per url passed via stdin)')
[CompletionResult]::new('--rate-limit', 'rate-limit', [CompletionResultType]::ParameterName, 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)') [CompletionResult]::new('--rate-limit', 'rate-limit', [CompletionResultType]::ParameterName, 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)')
[CompletionResult]::new('--time-limit', 'time-limit', [CompletionResultType]::ParameterName, 'Limit total run time of all scans (ex: --time-limit 10m)') [CompletionResult]::new('--time-limit', 'time-limit', [CompletionResultType]::ParameterName, 'Limit total run time of all scans (ex: --time-limit 10m)')
[CompletionResult]::new('-w', 'w', [CompletionResultType]::ParameterName, 'Path to the wordlist')
[CompletionResult]::new('--wordlist', 'wordlist', [CompletionResultType]::ParameterName, 'Path to the wordlist')
[CompletionResult]::new('-I', 'I', [CompletionResultType]::ParameterName, 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)')
[CompletionResult]::new('--dont-collect', 'dont-collect', [CompletionResultType]::ParameterName, 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)')
[CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
[CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
[CompletionResult]::new('--debug-log', 'debug-log', [CompletionResultType]::ParameterName, 'Output file to write log entries (use w/ --json for JSON entries)')
[CompletionResult]::new('--stdin', 'stdin', [CompletionResultType]::ParameterName, 'Read url(s) from STDIN')
[CompletionResult]::new('--burp', 'burp', [CompletionResultType]::ParameterName, 'Set --proxy to http://127.0.0.1:8080 and set --insecure to true')
[CompletionResult]::new('--burp-replay', 'burp-replay', [CompletionResultType]::ParameterName, 'Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true')
[CompletionResult]::new('--smart', 'smart', [CompletionResultType]::ParameterName, 'Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true')
[CompletionResult]::new('--thorough', 'thorough', [CompletionResultType]::ParameterName, 'Use the same settings as --smart and set --collect-extensions to true')
[CompletionResult]::new('-A', 'A', [CompletionResultType]::ParameterName, 'Use a random User-Agent')
[CompletionResult]::new('--random-agent', 'random-agent', [CompletionResultType]::ParameterName, 'Use a random User-Agent')
[CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Append / to each request''s URL')
[CompletionResult]::new('--add-slash', 'add-slash', [CompletionResultType]::ParameterName, 'Append / to each request''s URL')
[CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Allow client to follow redirects')
[CompletionResult]::new('--redirects', 'redirects', [CompletionResultType]::ParameterName, 'Allow client to follow redirects')
[CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation in the client')
[CompletionResult]::new('--insecure', 'insecure', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation in the client')
[CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Do not scan recursively')
[CompletionResult]::new('--no-recursion', 'no-recursion', [CompletionResultType]::ParameterName, 'Do not scan recursively')
[CompletionResult]::new('--force-recursion', 'force-recursion', [CompletionResultType]::ParameterName, 'Force recursion attempts on all ''found'' endpoints (still respects recursion depth)')
[CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings')
[CompletionResult]::new('--extract-links', 'extract-links', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings')
[CompletionResult]::new('--auto-tune', 'auto-tune', [CompletionResultType]::ParameterName, 'Automatically lower scan rate when an excessive amount of errors are encountered')
[CompletionResult]::new('--auto-bail', 'auto-bail', [CompletionResultType]::ParameterName, 'Automatically stop scanning when an excessive amount of errors are encountered')
[CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
[CompletionResult]::new('--dont-filter', 'dont-filter', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
[CompletionResult]::new('-E', 'E', [CompletionResultType]::ParameterName, 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)')
[CompletionResult]::new('--collect-extensions', 'collect-extensions', [CompletionResultType]::ParameterName, 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)')
[CompletionResult]::new('-B', 'B', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls')
[CompletionResult]::new('--collect-backups', 'collect-backups', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls')
[CompletionResult]::new('-g', 'g', [CompletionResultType]::ParameterName, 'Automatically discover important words from within responses and add them to the wordlist')
[CompletionResult]::new('--collect-words', 'collect-words', [CompletionResultType]::ParameterName, 'Automatically discover important words from within responses and add them to the wordlist')
[CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)') [CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
[CompletionResult]::new('--verbosity', 'verbosity', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)') [CompletionResult]::new('--verbosity', 'verbosity', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
[CompletionResult]::new('--silent', 'silent', [CompletionResultType]::ParameterName, 'Only print URLs + turn off logging (good for piping a list of urls to other commands)') [CompletionResult]::new('--silent', 'silent', [CompletionResultType]::ParameterName, 'Only print URLs + turn off logging (good for piping a list of urls to other commands)')
[CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)') [CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)')
[CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)') [CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)')
[CompletionResult]::new('--json', 'json', [CompletionResultType]::ParameterName, 'Emit JSON logs to --output and --debug-log instead of normal text') [CompletionResult]::new('--json', 'json', [CompletionResultType]::ParameterName, 'Emit JSON logs to --output and --debug-log instead of normal text')
[CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses') [CompletionResult]::new('--no-state', 'no-state', [CompletionResultType]::ParameterName, 'Disable state output file (*.state)')
[CompletionResult]::new('--dont-filter', 'dont-filter', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses') [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
[CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Follow redirects') [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
[CompletionResult]::new('--redirects', 'redirects', [CompletionResultType]::ParameterName, 'Follow redirects') [CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version')
[CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation') [CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version')
[CompletionResult]::new('--insecure', 'insecure', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation')
[CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Do not scan recursively')
[CompletionResult]::new('--no-recursion', 'no-recursion', [CompletionResultType]::ParameterName, 'Do not scan recursively')
[CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Append / to each request')
[CompletionResult]::new('--add-slash', 'add-slash', [CompletionResultType]::ParameterName, 'Append / to each request')
[CompletionResult]::new('--stdin', 'stdin', [CompletionResultType]::ParameterName, 'Read url(s) from STDIN')
[CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)')
[CompletionResult]::new('--extract-links', 'extract-links', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)')
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Prints help information')
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Prints help information')
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Prints version information')
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Prints version information')
break break
} }
}) })

View File

@@ -1,5 +1,5 @@
_feroxbuster() { _feroxbuster() {
local i cur prev opts cmds local i cur prev opts cmd
COMPREPLY=() COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}" cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}" prev="${COMP_WORDS[COMP_CWORD-1]}"
@@ -8,11 +8,10 @@ _feroxbuster() {
for i in ${COMP_WORDS[@]} for i in ${COMP_WORDS[@]}
do do
case "${i}" in case "${cmd},${i}" in
feroxbuster) ",$1")
cmd="feroxbuster" cmd="feroxbuster"
;; ;;
*) *)
;; ;;
esac esac
@@ -20,90 +19,17 @@ _feroxbuster() {
case "${cmd}" in case "${cmd}" in
feroxbuster) feroxbuster)
opts=" -v -q -D -r -k -n -f -e -h -V -w -u -t -d -T -p -P -R -s -o -a -x -H -Q -S -X -W -N -C -L --verbosity --silent --quiet --json --dont-filter --redirects --insecure --no-recursion --add-slash --stdin --extract-links --help --version --wordlist --url --threads --depth --timeout --proxy --replay-proxy --replay-codes --status-codes --output --resume-from --debug-log --user-agent --extensions --headers --query --filter-size --filter-regex --filter-words --filter-lines --filter-status --filter-similar-to --scan-limit --rate-limit --time-limit " opts="-u -p -P -R -a -A -x -m -H -b -Q -f -S -X -W -N -C -s -T -r -k -t -n -d -e -L -w -D -E -B -g -I -v -q -o -h -V --url --stdin --resume-from --burp --burp-replay --smart --thorough --proxy --replay-proxy --replay-codes --user-agent --random-agent --extensions --methods --data --headers --cookies --query --add-slash --dont-scan --filter-size --filter-regex --filter-words --filter-lines --filter-status --filter-similar-to --status-codes --timeout --redirects --insecure --threads --no-recursion --depth --force-recursion --extract-links --scan-limit --parallel --rate-limit --time-limit --wordlist --auto-tune --auto-bail --dont-filter --collect-extensions --collect-backups --collect-words --dont-collect --verbosity --silent --quiet --json --output --debug-log --no-state --help --version"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
fi fi
case "${prev}" in case "${prev}" in
--wordlist)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-w)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--url) --url)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-u) -u)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--threads)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-t)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--depth)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-d)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--timeout)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-T)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--proxy)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-p)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--replay-proxy)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-P)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--replay-codes)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-R)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--status-codes)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-s)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--output)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -111,7 +37,27 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
--debug-log) --proxy)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-p)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--replay-proxy)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-P)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--replay-codes)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-R)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -119,7 +65,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-a) -a)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -127,7 +73,19 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-x) -x)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--methods)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-m)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--data)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -135,7 +93,15 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-H) -H)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--cookies)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-b)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -143,7 +109,11 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-Q) -Q)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--dont-scan)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -151,7 +121,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-S) -S)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -159,7 +129,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-X) -X)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -167,7 +137,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-W) -W)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -175,7 +145,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-N) -N)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -183,7 +153,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-C) -C)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -191,11 +161,47 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
--status-codes)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-s)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--timeout)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-T)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--threads)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-t)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--depth)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-d)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--scan-limit) --scan-limit)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
-L) -L)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--parallel)
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
@@ -207,6 +213,34 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}")) COMPREPLY=($(compgen -f "${cur}"))
return 0 return 0
;; ;;
--wordlist)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-w)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--dont-collect)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-I)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--output)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--debug-log)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
*) *)
COMPREPLY=() COMPREPLY=()
;; ;;
@@ -214,8 +248,7 @@ _feroxbuster() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
esac esac
} }
complete -F _feroxbuster -o bashdefault -o default feroxbuster complete -F _feroxbuster -o bashdefault -o default -o plusdirs feroxbuster

View File

@@ -0,0 +1,117 @@
use builtin;
use str;
set edit:completion:arg-completer[feroxbuster] = {|@words|
fn spaces {|n|
builtin:repeat $n ' ' | str:join ''
}
fn cand {|text desc|
edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc
}
var command = 'feroxbuster'
for word $words[1..-1] {
if (str:has-prefix $word '-') {
break
}
set command = $command';'$word
}
var completions = [
&'feroxbuster'= {
cand -u 'The target URL (required, unless [--stdin || --resume-from] used)'
cand --url 'The target URL (required, unless [--stdin || --resume-from] used)'
cand --resume-from 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)'
cand -p 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)'
cand --proxy 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)'
cand -P 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
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.9.0)'
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.9.0)'
cand -x 'File extension(s) to search for (ex: -x php -x pdf js)'
cand --extensions 'File extension(s) to search for (ex: -x php -x pdf js)'
cand -m 'Which HTTP request method(s) should be sent (default: GET)'
cand --methods 'Which HTTP request method(s) should be sent (default: GET)'
cand --data 'Request''s Body; can read data from a file if input starts with an @ (ex: @post.bin)'
cand -H 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')'
cand --headers 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')'
cand -b 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)'
cand --cookies 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)'
cand -Q 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)'
cand --query 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)'
cand --dont-scan 'URL(s) or Regex Pattern(s) to exclude from recursion/scans'
cand -S 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
cand --filter-size 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
cand -X 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')'
cand --filter-regex 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')'
cand -W 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
cand --filter-words 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
cand -N 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
cand --filter-lines 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
cand -C 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
cand --filter-status 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
cand --filter-similar-to 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)'
cand -s 'Status Codes to include (allow list) (default: All Status Codes)'
cand --status-codes 'Status Codes to include (allow list) (default: All Status Codes)'
cand -T 'Number of seconds before a client''s request times out (default: 7)'
cand --timeout 'Number of seconds before a client''s request times out (default: 7)'
cand -t 'Number of concurrent threads (default: 50)'
cand --threads 'Number of concurrent threads (default: 50)'
cand -d 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)'
cand --depth 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)'
cand -L 'Limit total number of concurrent scans (default: 0, i.e. no limit)'
cand --scan-limit 'Limit total number of concurrent scans (default: 0, i.e. no limit)'
cand --parallel 'Run parallel feroxbuster instances (one child process per url passed via stdin)'
cand --rate-limit 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)'
cand --time-limit 'Limit total run time of all scans (ex: --time-limit 10m)'
cand -w 'Path to the wordlist'
cand --wordlist 'Path to the wordlist'
cand -I 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)'
cand --dont-collect 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)'
cand -o 'Output file to write results to (use w/ --json for JSON entries)'
cand --output 'Output file to write results to (use w/ --json for JSON entries)'
cand --debug-log 'Output file to write log entries (use w/ --json for JSON entries)'
cand --stdin 'Read url(s) from STDIN'
cand --burp 'Set --proxy to http://127.0.0.1:8080 and set --insecure to true'
cand --burp-replay 'Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true'
cand --smart 'Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true'
cand --thorough 'Use the same settings as --smart and set --collect-extensions to true'
cand -A 'Use a random User-Agent'
cand --random-agent 'Use a random User-Agent'
cand -f 'Append / to each request''s URL'
cand --add-slash 'Append / to each request''s URL'
cand -r 'Allow client to follow redirects'
cand --redirects 'Allow client to follow redirects'
cand -k 'Disables TLS certificate validation in the client'
cand --insecure 'Disables TLS certificate validation in the client'
cand -n 'Do not scan recursively'
cand --no-recursion 'Do not scan recursively'
cand --force-recursion 'Force recursion attempts on all ''found'' endpoints (still respects recursion depth)'
cand -e 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
cand --extract-links 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
cand --auto-tune 'Automatically lower scan rate when an excessive amount of errors are encountered'
cand --auto-bail 'Automatically stop scanning when an excessive amount of errors are encountered'
cand -D 'Don''t auto-filter wildcard responses'
cand --dont-filter 'Don''t auto-filter wildcard responses'
cand -E 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)'
cand --collect-extensions 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)'
cand -B 'Automatically request likely backup extensions for "found" urls'
cand --collect-backups 'Automatically request likely backup extensions for "found" urls'
cand -g 'Automatically discover important words from within responses and add them to the wordlist'
cand --collect-words 'Automatically discover important words from within responses and add them to the wordlist'
cand -v 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)'
cand --verbosity 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)'
cand --silent 'Only print URLs + turn off logging (good for piping a list of urls to other commands)'
cand -q 'Hide progress bars and banner (good for tmux windows w/ notifications)'
cand --quiet 'Hide progress bars and banner (good for tmux windows w/ notifications)'
cand --json 'Emit JSON logs to --output and --debug-log instead of normal text'
cand --no-state 'Disable state output file (*.state)'
cand -h 'Print help (see more with ''--help'')'
cand --help 'Print help (see more with ''--help'')'
cand -V 'Print version'
cand --version 'Print version'
}
]
$completions[$command]
}

View File

@@ -12,7 +12,11 @@ complete -c feroxbuster -n "__fish_use_subcommand" -l resume-from -d 'State file
complete -c feroxbuster -n "__fish_use_subcommand" -l debug-log -d 'Output file to write log entries (use w/ --json for JSON entries)' complete -c feroxbuster -n "__fish_use_subcommand" -l debug-log -d 'Output file to write log entries (use w/ --json for JSON entries)'
complete -c feroxbuster -n "__fish_use_subcommand" -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/VERSION)' complete -c feroxbuster -n "__fish_use_subcommand" -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/VERSION)'
complete -c feroxbuster -n "__fish_use_subcommand" -s x -l extensions -d 'File extension(s) to search for (ex: -x php -x pdf js)' complete -c feroxbuster -n "__fish_use_subcommand" -s x -l extensions -d 'File extension(s) to search for (ex: -x php -x pdf js)'
complete -c feroxbuster -n "__fish_use_subcommand" -s m -l methods -d 'HTTP request method(s) (default: GET)'
complete -c feroxbuster -n "__fish_use_subcommand" -l data -d 'HTTP Body data; can read data from a file if input starts with an @ (ex: @post.bin)'
complete -c feroxbuster -n "__fish_use_subcommand" -l dont-scan -d 'URL(s) or Regex Pattern(s) to exclude from recursion/scans'
complete -c feroxbuster -n "__fish_use_subcommand" -s H -l headers -d 'Specify HTTP headers (ex: -H Header:val \'stuff: things\')' complete -c feroxbuster -n "__fish_use_subcommand" -s H -l headers -d 'Specify HTTP headers (ex: -H Header:val \'stuff: things\')'
complete -c feroxbuster -n "__fish_use_subcommand" -s b -l cookies -d 'Specify HTTP cookies (ex: -b stuff=things)'
complete -c feroxbuster -n "__fish_use_subcommand" -s Q -l query -d 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)' complete -c feroxbuster -n "__fish_use_subcommand" -s Q -l query -d 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)'
complete -c feroxbuster -n "__fish_use_subcommand" -s S -l filter-size -d 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)' complete -c feroxbuster -n "__fish_use_subcommand" -s S -l filter-size -d 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
complete -c feroxbuster -n "__fish_use_subcommand" -s X -l filter-regex -d 'Filter out messages via regular expression matching on the response\'s body (ex: -X \'^ignore me$\')' complete -c feroxbuster -n "__fish_use_subcommand" -s X -l filter-regex -d 'Filter out messages via regular expression matching on the response\'s body (ex: -X \'^ignore me$\')'
@@ -21,13 +25,17 @@ complete -c feroxbuster -n "__fish_use_subcommand" -s N -l filter-lines -d 'Filt
complete -c feroxbuster -n "__fish_use_subcommand" -s C -l filter-status -d 'Filter out status codes (deny list) (ex: -C 200 -C 401)' complete -c feroxbuster -n "__fish_use_subcommand" -s C -l filter-status -d 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
complete -c feroxbuster -n "__fish_use_subcommand" -l filter-similar-to -d 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)' complete -c feroxbuster -n "__fish_use_subcommand" -l filter-similar-to -d 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)'
complete -c feroxbuster -n "__fish_use_subcommand" -s L -l scan-limit -d 'Limit total number of concurrent scans (default: 0, i.e. no limit)' complete -c feroxbuster -n "__fish_use_subcommand" -s L -l scan-limit -d 'Limit total number of concurrent scans (default: 0, i.e. no limit)'
complete -c feroxbuster -n "__fish_use_subcommand" -l parallel -d 'Run parallel feroxbuster instances (one child process per url passed via stdin)'
complete -c feroxbuster -n "__fish_use_subcommand" -l rate-limit -d 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)' complete -c feroxbuster -n "__fish_use_subcommand" -l rate-limit -d 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)'
complete -c feroxbuster -n "__fish_use_subcommand" -l time-limit -d 'Limit total run time of all scans (ex: --time-limit 10m)' complete -c feroxbuster -n "__fish_use_subcommand" -l time-limit -d 'Limit total run time of all scans (ex: --time-limit 10m)'
complete -c feroxbuster -n "__fish_use_subcommand" -s v -l verbosity -d 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v\'s is probably too much)' complete -c feroxbuster -n "__fish_use_subcommand" -s v -l verbosity -d 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v\'s is probably too much)'
complete -c feroxbuster -n "__fish_use_subcommand" -l silent -d 'Only print URLs + turn off logging (good for piping a list of urls to other commands)' complete -c feroxbuster -n "__fish_use_subcommand" -l silent -d 'Only print URLs + turn off logging (good for piping a list of urls to other commands)'
complete -c feroxbuster -n "__fish_use_subcommand" -s q -l quiet -d 'Hide progress bars and banner (good for tmux windows w/ notifications)' complete -c feroxbuster -n "__fish_use_subcommand" -s q -l quiet -d 'Hide progress bars and banner (good for tmux windows w/ notifications)'
complete -c feroxbuster -n "__fish_use_subcommand" -l auto-tune -d 'Automatically lower scan rate when an excessive amount of errors are encountered'
complete -c feroxbuster -n "__fish_use_subcommand" -l auto-bail -d 'Automatically stop scanning when an excessive amount of errors are encountered'
complete -c feroxbuster -n "__fish_use_subcommand" -l json -d 'Emit JSON logs to --output and --debug-log instead of normal text' complete -c feroxbuster -n "__fish_use_subcommand" -l json -d 'Emit JSON logs to --output and --debug-log instead of normal text'
complete -c feroxbuster -n "__fish_use_subcommand" -s D -l dont-filter -d 'Don\'t auto-filter wildcard responses' complete -c feroxbuster -n "__fish_use_subcommand" -s D -l dont-filter -d 'Don\'t auto-filter wildcard responses'
complete -c feroxbuster -n "__fish_use_subcommand" -s A -l random-agent -d 'Use a random User-Agent'
complete -c feroxbuster -n "__fish_use_subcommand" -s r -l redirects -d 'Follow redirects' complete -c feroxbuster -n "__fish_use_subcommand" -s r -l redirects -d 'Follow redirects'
complete -c feroxbuster -n "__fish_use_subcommand" -s k -l insecure -d 'Disables TLS certificate validation' complete -c feroxbuster -n "__fish_use_subcommand" -s k -l insecure -d 'Disables TLS certificate validation'
complete -c feroxbuster -n "__fish_use_subcommand" -s n -l no-recursion -d 'Do not scan recursively' complete -c feroxbuster -n "__fish_use_subcommand" -s n -l no-recursion -d 'Do not scan recursively'

View File

@@ -1,9 +1,9 @@
use super::entry::BannerEntry; use super::entry::BannerEntry;
use crate::event_handlers::Handles;
use crate::{ use crate::{
config::Configuration, config::Configuration,
utils::{make_request, status_colorizer}, event_handlers::Handles,
VERSION, utils::{logged_request, status_colorizer},
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, DEFAULT_STATUS_CODES, VERSION,
}; };
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use console::{style, Emoji}; use console::{style, Emoji};
@@ -50,6 +50,9 @@ pub struct Banner {
/// represents Configuration.user_agent /// represents Configuration.user_agent
user_agent: BannerEntry, user_agent: BannerEntry,
/// represents Configuration.random_agent
random_agent: BannerEntry,
/// represents Configuration.config /// represents Configuration.config
config: BannerEntry, config: BannerEntry,
@@ -95,6 +98,12 @@ pub struct Banner {
/// represents Configuration.extensions /// represents Configuration.extensions
extensions: BannerEntry, extensions: BannerEntry,
/// represents Configuration.methods
methods: BannerEntry,
/// represents Configuration.data
data: BannerEntry,
/// represents Configuration.insecure /// represents Configuration.insecure
insecure: BannerEntry, insecure: BannerEntry,
@@ -125,11 +134,38 @@ pub struct Banner {
/// represents Configuration.rate_limit /// represents Configuration.rate_limit
rate_limit: BannerEntry, rate_limit: BannerEntry,
/// represents Configuration.parallel
parallel: BannerEntry,
/// represents Configuration.auto_tune
auto_tune: BannerEntry,
/// represents Configuration.auto_bail
auto_bail: BannerEntry,
/// represents Configuration.url_denylist
url_denylist: Vec<BannerEntry>,
/// current version of feroxbuster /// current version of feroxbuster
pub(super) version: String, pub(super) version: String,
/// whether or not there is a known new version /// whether or not there is a known new version
pub(super) update_status: UpdateStatus, pub(super) update_status: UpdateStatus,
/// represents Configuration.collect_extensions
collect_extensions: BannerEntry,
/// represents Configuration.dont_collect
dont_collect: BannerEntry,
/// represents Configuration.collect_backups
collect_backups: BannerEntry,
/// represents Configuration.collect_words
collect_words: BannerEntry,
/// represents Configuration.collect_words
force_recursion: BannerEntry,
} }
/// implementation of Banner /// implementation of Banner
@@ -137,6 +173,7 @@ impl Banner {
/// Create a new Banner from a Configuration and live targets /// Create a new Banner from a Configuration and live targets
pub fn new(tgts: &[String], config: &Configuration) -> Self { pub fn new(tgts: &[String], config: &Configuration) -> Self {
let mut targets = Vec::new(); let mut targets = Vec::new();
let mut url_denylist = Vec::new();
let mut code_filters = Vec::new(); let mut code_filters = Vec::new();
let mut replay_codes = Vec::new(); let mut replay_codes = Vec::new();
let mut headers = Vec::new(); let mut headers = Vec::new();
@@ -151,18 +188,47 @@ impl Banner {
targets.push(BannerEntry::new("🎯", "Target Url", target)); targets.push(BannerEntry::new("🎯", "Target Url", target));
} }
let mut codes = vec![]; for denied_url in &config.url_denylist {
for code in &config.status_codes { url_denylist.push(BannerEntry::new(
codes.push(status_colorizer(&code.to_string())) "🚫",
"Don't Scan Url",
denied_url.as_str(),
));
} }
let status_codes =
BannerEntry::new("👌", "Status Codes", &format!("[{}]", codes.join(", "))); for denied_regex in &config.regex_denylist {
url_denylist.push(BannerEntry::new(
"🚫",
"Don't Scan Regex",
denied_regex.as_str(),
));
}
// the +2 is for the 2 experimental status codes we add to the default list manually
let status_codes = if config.status_codes.len() == DEFAULT_STATUS_CODES.len() + 2 {
let all_str = format!(
"{} {} {}{}",
style("All").cyan(),
style("Status").green(),
style("Codes").yellow(),
style("!").red()
);
BannerEntry::new("👌", "Status Codes", &all_str)
} else {
let mut codes = vec![];
for code in &config.status_codes {
codes.push(status_colorizer(&code.to_string()))
}
BannerEntry::new("👌", "Status Codes", &format!("[{}]", codes.join(", ")))
};
for code in &config.filter_status { for code in &config.filter_status {
code_filters.push(status_colorizer(&code.to_string())) code_filters.push(status_colorizer(&code.to_string()))
} }
let filter_status = BannerEntry::new( let filter_status = BannerEntry::new(
"🗑", "💢",
"Status Code Filters", "Status Code Filters",
&format!("[{}]", code_filters.join(", ")), &format!("[{}]", code_filters.join(", ")),
); );
@@ -180,7 +246,7 @@ impl Banner {
headers.push(BannerEntry::new( headers.push(BannerEntry::new(
"🤯", "🤯",
"Header", "Header",
&format!("{}: {}", name, value), &format!("{name}: {value}"),
)); ));
} }
@@ -250,13 +316,18 @@ impl Banner {
&config.scan_limit.to_string(), &config.scan_limit.to_string(),
); );
let force_recursion =
BannerEntry::new("🤘", "Force Recursion", &config.force_recursion.to_string());
let replay_proxy = BannerEntry::new("🎥", "Replay Proxy", &config.replay_proxy); let replay_proxy = BannerEntry::new("🎥", "Replay Proxy", &config.replay_proxy);
let auto_tune = BannerEntry::new("🎶", "Auto Tune", &config.auto_tune.to_string());
let auto_bail = BannerEntry::new("🙅", "Auto Bail", &config.auto_bail.to_string());
let cfg = BannerEntry::new("💉", "Config File", &config.config); let cfg = BannerEntry::new("💉", "Config File", &config.config);
let proxy = BannerEntry::new("💎", "Proxy", &config.proxy); let proxy = BannerEntry::new("💎", "Proxy", &config.proxy);
let threads = BannerEntry::new("🚀", "Threads", &config.threads.to_string()); let threads = BannerEntry::new("🚀", "Threads", &config.threads.to_string());
let wordlist = BannerEntry::new("📖", "Wordlist", &config.wordlist); let wordlist = BannerEntry::new("📖", "Wordlist", &config.wordlist);
let timeout = BannerEntry::new("💥", "Timeout (secs)", &config.timeout.to_string()); let timeout = BannerEntry::new("💥", "Timeout (secs)", &config.timeout.to_string());
let user_agent = BannerEntry::new("🦡", "User-Agent", &config.user_agent); let user_agent = BannerEntry::new("🦡", "User-Agent", &config.user_agent);
let random_agent = BannerEntry::new("🦡", "User-Agent", "Random");
let extract_links = let extract_links =
BannerEntry::new("🔎", "Extract Links", &config.extract_links.to_string()); BannerEntry::new("🔎", "Extract Links", &config.extract_links.to_string());
let json = BannerEntry::new("🧔", "JSON Output", &config.json.to_string()); let json = BannerEntry::new("🧔", "JSON Output", &config.json.to_string());
@@ -267,14 +338,57 @@ impl Banner {
"Extensions", "Extensions",
&format!("[{}]", config.extensions.join(", ")), &format!("[{}]", config.extensions.join(", ")),
); );
let methods = BannerEntry::new(
"🏁",
"HTTP methods",
&format!("[{}]", config.methods.join(", ")),
);
let dont_collect = if config.dont_collect == DEFAULT_IGNORED_EXTENSIONS {
// default has 30+ extensions, just trim it up
BannerEntry::new(
"💸",
"Ignored Extensions",
"[Images, Movies, Audio, etc...]",
)
} else {
BannerEntry::new(
"💸",
"Ignored Extensions",
&format!("[{}]", config.dont_collect.join(", ")),
)
};
let offset = std::cmp::min(config.data.len(), 30);
let data = String::from_utf8(config.data[..offset].to_vec())
.unwrap_or_else(|_err| {
format!(
"{:x?} ...",
&config.data[..std::cmp::min(config.data.len(), 13)]
)
})
.replace('\n', " ")
.replace('\r', "");
let data = BannerEntry::new("💣", "HTTP Body", &data);
let insecure = BannerEntry::new("🔓", "Insecure", &config.insecure.to_string()); let insecure = BannerEntry::new("🔓", "Insecure", &config.insecure.to_string());
let redirects = BannerEntry::new("📍", "Follow Redirects", &config.redirects.to_string()); let redirects = BannerEntry::new("📍", "Follow Redirects", &config.redirects.to_string());
let dont_filter = let dont_filter =
BannerEntry::new("🤪", "Filter Wildcards", &(!config.dont_filter).to_string()); BannerEntry::new("🤪", "Filter Wildcards", &(!config.dont_filter).to_string());
let add_slash = BannerEntry::new("🪓", "Add Slash", &config.add_slash.to_string()); let add_slash = BannerEntry::new("🪓", "Add Slash", &config.add_slash.to_string());
let time_limit = BannerEntry::new("🕖", "Time Limit", &config.time_limit); let time_limit = BannerEntry::new("🕖", "Time Limit", &config.time_limit);
let parallel = BannerEntry::new("🛤", "Parallel Scans", &config.parallel.to_string());
let rate_limit = let rate_limit =
BannerEntry::new("🚧", "Requests per Second", &config.rate_limit.to_string()); BannerEntry::new("🚧", "Requests per Second", &config.rate_limit.to_string());
let collect_extensions = BannerEntry::new(
"💰",
"Collect Extensions",
&config.collect_extensions.to_string(),
);
let collect_backups =
BannerEntry::new("🏦", "Collect Backups", &config.collect_backups.to_string());
let collect_words =
BannerEntry::new("🤑", "Collect Words", &config.collect_words.to_string());
Self { Self {
targets, targets,
@@ -284,6 +398,9 @@ impl Banner {
filter_status, filter_status,
timeout, timeout,
user_agent, user_agent,
random_agent,
auto_bail,
auto_tune,
proxy, proxy,
replay_codes, replay_codes,
replay_proxy, replay_proxy,
@@ -294,11 +411,14 @@ impl Banner {
filter_line_count, filter_line_count,
filter_regex, filter_regex,
extract_links, extract_links,
parallel,
json, json,
queries, queries,
output, output,
debug_log, debug_log,
extensions, extensions,
methods,
data,
insecure, insecure,
dont_filter, dont_filter,
redirects, redirects,
@@ -307,7 +427,13 @@ impl Banner {
no_recursion, no_recursion,
rate_limit, rate_limit,
scan_limit, scan_limit,
force_recursion,
time_limit, time_limit,
url_denylist,
collect_extensions,
collect_backups,
collect_words,
dont_collect,
config: cfg, config: cfg,
version: VERSION.to_string(), version: VERSION.to_string(),
update_status: UpdateStatus::Unknown, update_status: UpdateStatus::Unknown,
@@ -328,7 +454,7 @@ by Ben "epi" Risher {} ver: {}"#,
let top = "───────────────────────────┬──────────────────────"; let top = "───────────────────────────┬──────────────────────";
format!("{}\n{}", artwork, top) format!("{artwork}\n{top}")
} }
/// get a fancy footer for the banner /// get a fancy footer for the banner
@@ -339,10 +465,10 @@ by Ben "epi" Risher {} ver: {}"#,
let instructions = format!( let instructions = format!(
" 🏁 Press [{}] to use the {}", " 🏁 Press [{}] to use the {}",
style("ENTER").yellow(), style("ENTER").yellow(),
style("Scan Cancel Menu").bright().yellow(), style("Scan Management Menu").bright().yellow(),
); );
format!("{}\n{}\n{}", bottom, instructions, addl_section) format!("{bottom}\n{instructions}\n{addl_section}")
} }
/// Makes a request to the given url, expecting to receive a JSON response that contains a field /// Makes a request to the given url, expecting to receive a JSON response that contains a field
@@ -354,15 +480,8 @@ by Ben "epi" Risher {} ver: {}"#,
let api_url = Url::parse(url)?; let api_url = Url::parse(url)?;
let response = make_request( let result = logged_request(&api_url, DEFAULT_METHOD, None, handles.clone()).await?;
&handles.config.client, let body = result.text().await?;
&api_url,
handles.config.output_level,
handles.stats.tx.clone(),
)
.await?;
let body = response.text().await?;
let json_response: Value = serde_json::from_str(&body)?; let json_response: Value = serde_json::from_str(&body)?;
@@ -402,21 +521,31 @@ by Ben "epi" Risher {} ver: {}"#,
// begin with always printed items // begin with always printed items
for target in &self.targets { for target in &self.targets {
writeln!(&mut writer, "{}", target)?; writeln!(&mut writer, "{target}")?;
}
for denied_url in &self.url_denylist {
writeln!(&mut writer, "{denied_url}")?;
} }
writeln!(&mut writer, "{}", self.threads)?; writeln!(&mut writer, "{}", self.threads)?;
writeln!(&mut writer, "{}", self.wordlist)?; writeln!(&mut writer, "{}", self.wordlist)?;
writeln!(&mut writer, "{}", self.status_codes)?;
if !config.filter_status.is_empty() { if config.filter_status.is_empty() {
// exception here for an optional print in the middle of always printed values is due // -C and -s are mutually exclusive, and -s meaning changes when -C is used
// to me wanting the allows and denys to be printed one after the other // so only print one or the other
writeln!(&mut writer, "{}", self.status_codes)?;
} else {
writeln!(&mut writer, "{}", self.filter_status)?; writeln!(&mut writer, "{}", self.filter_status)?;
} }
writeln!(&mut writer, "{}", self.timeout)?; writeln!(&mut writer, "{}", self.timeout)?;
writeln!(&mut writer, "{}", self.user_agent)?;
if config.random_agent {
writeln!(&mut writer, "{}", self.random_agent)?;
} else {
writeln!(&mut writer, "{}", self.user_agent)?;
}
// followed by the maybe printed or variably displayed values // followed by the maybe printed or variably displayed values
if !config.config.is_empty() { if !config.config.is_empty() {
@@ -435,27 +564,27 @@ by Ben "epi" Risher {} ver: {}"#,
} }
for header in &self.headers { for header in &self.headers {
writeln!(&mut writer, "{}", header)?; writeln!(&mut writer, "{header}")?;
} }
for filter in &self.filter_size { for filter in &self.filter_size {
writeln!(&mut writer, "{}", filter)?; writeln!(&mut writer, "{filter}")?;
} }
for filter in &self.filter_similar { for filter in &self.filter_similar {
writeln!(&mut writer, "{}", filter)?; writeln!(&mut writer, "{filter}")?;
} }
for filter in &self.filter_word_count { for filter in &self.filter_word_count {
writeln!(&mut writer, "{}", filter)?; writeln!(&mut writer, "{filter}")?;
} }
for filter in &self.filter_line_count { for filter in &self.filter_line_count {
writeln!(&mut writer, "{}", filter)?; writeln!(&mut writer, "{filter}")?;
} }
for filter in &self.filter_regex { for filter in &self.filter_regex {
writeln!(&mut writer, "{}", filter)?; writeln!(&mut writer, "{filter}")?;
} }
if config.extract_links { if config.extract_links {
@@ -467,7 +596,7 @@ by Ben "epi" Risher {} ver: {}"#,
} }
for query in &self.queries { for query in &self.queries {
writeln!(&mut writer, "{}", query)?; writeln!(&mut writer, "{query}")?;
} }
if !config.output.is_empty() { if !config.output.is_empty() {
@@ -482,10 +611,39 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(&mut writer, "{}", self.extensions)?; writeln!(&mut writer, "{}", self.extensions)?;
} }
if config.collect_extensions {
// dont-collect is active only when collect-extensions is used
writeln!(&mut writer, "{}", self.collect_extensions)?;
writeln!(&mut writer, "{}", self.dont_collect)?;
}
if config.collect_backups {
writeln!(&mut writer, "{}", self.collect_backups)?;
}
if config.collect_words {
writeln!(&mut writer, "{}", self.collect_words)?;
}
if !config.methods.is_empty() {
writeln!(&mut writer, "{}", self.methods)?;
}
if !config.data.is_empty() {
writeln!(&mut writer, "{}", self.data)?;
}
if config.insecure { if config.insecure {
writeln!(&mut writer, "{}", self.insecure)?; writeln!(&mut writer, "{}", self.insecure)?;
} }
if config.auto_bail {
writeln!(&mut writer, "{}", self.auto_bail)?;
}
if config.auto_tune {
writeln!(&mut writer, "{}", self.auto_tune)?;
}
if config.redirects { if config.redirects {
writeln!(&mut writer, "{}", self.redirects)?; writeln!(&mut writer, "{}", self.redirects)?;
} }
@@ -504,10 +662,18 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(&mut writer, "{}", self.no_recursion)?; writeln!(&mut writer, "{}", self.no_recursion)?;
if config.force_recursion {
writeln!(&mut writer, "{}", self.force_recursion)?;
}
if config.scan_limit > 0 { if config.scan_limit > 0 {
writeln!(&mut writer, "{}", self.scan_limit)?; writeln!(&mut writer, "{}", self.scan_limit)?;
} }
if config.parallel > 0 {
writeln!(&mut writer, "{}", self.parallel)?;
}
if config.rate_limit > 0 { if config.rate_limit > 0 {
writeln!(&mut writer, "{}", self.rate_limit)?; writeln!(&mut writer, "{}", self.rate_limit)?;
} }
@@ -522,7 +688,7 @@ by Ben "epi" Risher {} ver: {}"#,
"New Version Available", "New Version Available",
"https://github.com/epi052/feroxbuster/releases/latest", "https://github.com/epi052/feroxbuster/releases/latest",
); );
writeln!(&mut writer, "{}", update)?; writeln!(&mut writer, "{update}")?;
} }
writeln!(&mut writer, "{}", self.footer())?; writeln!(&mut writer, "{}", self.footer())?;

View File

@@ -1,6 +1,6 @@
use super::container::UpdateStatus; use super::container::UpdateStatus;
use super::*; use super::*;
use crate::{config::Configuration, event_handlers::Handles}; use crate::{config::Configuration, event_handlers::Handles, scan_manager::FeroxScans};
use httpmock::Method::GET; use httpmock::Method::GET;
use httpmock::MockServer; use httpmock::MockServer;
use std::{io::stderr, sync::Arc, time::Duration}; use std::{io::stderr, sync::Arc, time::Duration};
@@ -73,8 +73,9 @@ async fn banner_needs_update_returns_up_to_date() {
when.method(GET).path("/latest"); when.method(GET).path("/latest");
then.status(200).body("{\"tag_name\":\"v1.1.0\"}"); then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
}); });
let scans = Arc::new(FeroxScans::default());
let handles = Arc::new(Handles::for_testing(None, None).0); let handles = Arc::new(Handles::for_testing(Some(scans), None).0);
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap()); let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
banner.version = String::from("1.1.0"); banner.version = String::from("1.1.0");
@@ -95,7 +96,9 @@ async fn banner_needs_update_returns_out_of_date() {
then.status(200).body("{\"tag_name\":\"v1.1.0\"}"); then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
}); });
let handles = Arc::new(Handles::for_testing(None, None).0); let scans = Arc::new(FeroxScans::default());
let handles = Arc::new(Handles::for_testing(Some(scans), None).0);
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap()); let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
banner.version = String::from("1.0.1"); banner.version = String::from("1.0.1");

View File

@@ -27,7 +27,8 @@ pub fn initialize(
.user_agent(user_agent) .user_agent(user_agent)
.danger_accept_invalid_certs(insecure) .danger_accept_invalid_certs(insecure)
.default_headers(header_map) .default_headers(header_map)
.redirect(policy); .redirect(policy)
.http1_title_case_headers();
if let Some(some_proxy) = proxy { if let Some(some_proxy) = proxy {
if !some_proxy.is_empty() { if !some_proxy.is_empty() {

View File

@@ -1,15 +1,17 @@
use super::utils::{ use super::utils::{
depth, report_and_exit, save_state, serialized_type, status_codes, threads, timeout, depth, ignored_extensions, methods, report_and_exit, save_state, serialized_type, status_codes,
user_agent, wordlist, OutputLevel, threads, timeout, user_agent, wordlist, OutputLevel, RequesterPolicy,
}; };
use crate::config::determine_output_level; use crate::config::determine_output_level;
use crate::config::utils::determine_requester_policy;
use crate::{ use crate::{
client, parser, scan_manager::resume_scan, traits::FeroxSerialize, utils::fmt_err, client, parser, scan_manager::resume_scan, traits::FeroxSerialize, utils::fmt_err,
DEFAULT_CONFIG_NAME, DEFAULT_CONFIG_NAME,
}; };
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use clap::{value_t, ArgMatches}; use clap::{parser::ValueSource, ArgMatches};
use reqwest::{Client, StatusCode}; use regex::Regex;
use reqwest::{Client, Method, StatusCode, Url};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::HashMap, collections::HashMap,
@@ -20,17 +22,10 @@ use std::{
/// macro helper to abstract away repetitive configuration updates /// macro helper to abstract away repetitive configuration updates
macro_rules! update_config_if_present { macro_rules! update_config_if_present {
($c:expr, $m:ident, $v:expr, $t:ty) => { ($conf_val:expr, $matches:ident, $arg_name:expr, $arg_type:ty) => {
match value_t!($m, $v, $t) { match $matches.get_one::<$arg_type>($arg_name) {
Ok(value) => *$c = value, // Update value Some(value) => *$conf_val = value.to_owned(), // Update value
Err(clap::Error { None => {}
kind: clap::ErrorKind::ArgumentNotFound,
message: _,
info: _,
}) => {
// Do nothing if argument not found
}
Err(e) => e.exit(), // Exit with error on parse error
} }
}; };
} }
@@ -44,6 +39,35 @@ macro_rules! update_if_not_default {
}; };
} }
/// macro helper to abstract away repetitive checks to see if the user has specified a value
/// for a given argument from the commandline or if we just had a default value in the parser
macro_rules! came_from_cli {
($matches:ident, $arg_name:expr) => {
matches!(
$matches.value_source($arg_name),
Some(ValueSource::CommandLine)
)
};
}
/// macro helper to abstract away repetitive if not default: update checks, specifically for
/// values that are number types, i.e. usize, u64, etc
macro_rules! update_config_with_num_type_if_present {
($conf_val:expr, $matches:ident, $arg_name:expr, $arg_type:ty) => {
if let Some(val) = $matches.get_one::<String>($arg_name) {
match val.parse::<$arg_type>() {
Ok(v) => *$conf_val = v,
Err(_) => {
report_and_exit(&format!(
"Invalid value for --{}, must be a positive integer",
$arg_name
));
}
}
}
};
}
/// Represents the final, global configuration of the program. /// Represents the final, global configuration of the program.
/// ///
/// This struct is the combination of the following: /// This struct is the combination of the following:
@@ -124,6 +148,18 @@ pub struct Configuration {
#[serde(skip)] #[serde(skip)]
pub output_level: OutputLevel, pub output_level: OutputLevel,
/// automatically bail at certain error thresholds
#[serde(default)]
pub auto_bail: bool,
/// automatically try to lower request rate in order to reduce errors
#[serde(default)]
pub auto_tune: bool,
/// more easily differentiate between the three requester policies
#[serde(skip)]
pub requester_policy: RequesterPolicy,
/// Store log output as NDJSON /// Store log output as NDJSON
#[serde(default)] #[serde(default)]
pub json: bool, pub json: bool,
@@ -141,6 +177,10 @@ pub struct Configuration {
#[serde(default = "user_agent")] #[serde(default = "user_agent")]
pub user_agent: String, pub user_agent: String,
/// Use random User-Agent
#[serde(default)]
pub random_agent: bool,
/// Follow redirects /// Follow redirects
#[serde(default)] #[serde(default)]
pub redirects: bool, pub redirects: bool,
@@ -153,6 +193,14 @@ pub struct Configuration {
#[serde(default)] #[serde(default)]
pub extensions: Vec<String>, pub extensions: Vec<String>,
/// HTTP requests methods(s) to search for
#[serde(default = "methods")]
pub methods: Vec<String>,
/// HTTP Body data to send during request
#[serde(default)]
pub data: Vec<u8>,
/// HTTP headers to be used in each request /// HTTP headers to be used in each request
#[serde(default)] #[serde(default)]
pub headers: HashMap<String, String>, pub headers: HashMap<String, String>,
@@ -185,6 +233,10 @@ pub struct Configuration {
#[serde(default)] #[serde(default)]
pub scan_limit: usize, pub scan_limit: usize,
/// Number of parallel scans permitted; a limit of 0 means no limit is imposed
#[serde(default)]
pub parallel: usize,
/// Number of requests per second permitted (per directory); a limit of 0 means no limit is imposed /// Number of requests per second permitted (per directory); a limit of 0 means no limit is imposed
#[serde(default)] #[serde(default)]
pub rate_limit: usize, pub rate_limit: usize,
@@ -218,8 +270,6 @@ pub struct Configuration {
pub resume_from: String, pub resume_from: String,
/// Whether or not a scan's current state should be saved when user presses Ctrl+C /// Whether or not a scan's current state should be saved when user presses Ctrl+C
///
/// Not configurable from CLI; can only be set from a config file
#[serde(default = "save_state")] #[serde(default = "save_state")]
pub save_state: bool, pub save_state: bool,
@@ -231,6 +281,34 @@ pub struct Configuration {
/// Filter out response bodies that meet a certain threshold of similarity /// Filter out response bodies that meet a certain threshold of similarity
#[serde(default)] #[serde(default)]
pub filter_similar: Vec<String>, pub filter_similar: Vec<String>,
/// URLs that should never be scanned/recursed into
#[serde(default)]
pub url_denylist: Vec<Url>,
/// URLs that should never be scanned/recursed into based on a regular expression
#[serde(with = "serde_regex", default)]
pub regex_denylist: Vec<Regex>,
/// Automatically discover extensions and add them to --extensions (unless they're in --dont-collect)
#[serde(default)]
pub collect_extensions: bool,
/// don't collect any of these extensions when --collect-extensions is used
#[serde(default = "ignored_extensions")]
pub dont_collect: Vec<String>,
/// Automatically request likely backup extensions on "found" urls
#[serde(default)]
pub collect_backups: bool,
/// Automatically discover important words from within responses and add them to the wordlist
#[serde(default)]
pub collect_words: bool,
/// override recursion logic to always attempt recursion, still respects --depth
#[serde(default)]
pub force_recursion: bool,
} }
impl Default for Configuration { impl Default for Configuration {
@@ -245,6 +323,7 @@ impl Default for Configuration {
let replay_codes = status_codes.clone(); let replay_codes = status_codes.clone();
let kind = serialized_type(); let kind = serialized_type();
let output_level = OutputLevel::Default; let output_level = OutputLevel::Default;
let requester_policy = RequesterPolicy::Default;
Configuration { Configuration {
kind, kind,
@@ -254,7 +333,10 @@ impl Default for Configuration {
replay_codes, replay_codes,
status_codes, status_codes,
replay_client, replay_client,
requester_policy,
dont_filter: false, dont_filter: false,
auto_bail: false,
auto_tune: false,
silent: false, silent: false,
quiet: false, quiet: false,
output_level, output_level,
@@ -263,13 +345,19 @@ impl Default for Configuration {
json: false, json: false,
verbosity: 0, verbosity: 0,
scan_limit: 0, scan_limit: 0,
parallel: 0,
rate_limit: 0, rate_limit: 0,
add_slash: false, add_slash: false,
insecure: false, insecure: false,
redirects: false, redirects: false,
no_recursion: false, no_recursion: false,
extract_links: false, extract_links: false,
random_agent: false,
collect_extensions: false,
collect_backups: false,
collect_words: false,
save_state: true, save_state: true,
force_recursion: false,
proxy: String::new(), proxy: String::new(),
config: String::new(), config: String::new(),
output: String::new(), output: String::new(),
@@ -280,8 +368,12 @@ impl Default for Configuration {
replay_proxy: String::new(), replay_proxy: String::new(),
queries: Vec::new(), queries: Vec::new(),
extensions: Vec::new(), extensions: Vec::new(),
methods: methods(),
data: Vec::new(),
filter_size: Vec::new(), filter_size: Vec::new(),
filter_regex: Vec::new(), filter_regex: Vec::new(),
url_denylist: Vec::new(),
regex_denylist: Vec::new(),
filter_line_count: Vec::new(), filter_line_count: Vec::new(),
filter_word_count: Vec::new(), filter_word_count: Vec::new(),
filter_status: Vec::new(), filter_status: Vec::new(),
@@ -290,6 +382,7 @@ impl Default for Configuration {
depth: depth(), depth: depth(),
threads: threads(), threads: threads(),
wordlist: wordlist(), wordlist: wordlist(),
dont_collect: ignored_extensions(),
} }
} }
} }
@@ -313,10 +406,21 @@ impl Configuration {
/// - **debug_log**: `None` /// - **debug_log**: `None`
/// - **quiet**: `false` /// - **quiet**: `false`
/// - **silent**: `false` /// - **silent**: `false`
/// - **auto_tune**: `false`
/// - **auto_bail**: `false`
/// - **save_state**: `true` /// - **save_state**: `true`
/// - **user_agent**: `feroxbuster/VERSION` /// - **user_agent**: `feroxbuster/VERSION`
/// - **random_agent**: `false`
/// - **insecure**: `false` (don't be insecure, i.e. don't allow invalid certs) /// - **insecure**: `false` (don't be insecure, i.e. don't allow invalid certs)
/// - **extensions**: `None` /// - **extensions**: `None`
/// - **collect_extensions**: `false`
/// - **collect_backups**: `false`
/// - **collect_words**: `false`
/// - **dont_collect**: [`DEFAULT_IGNORED_EXTENSIONS`](constant.DEFAULT_RESPONSE_CODES.html)
/// - **methods**: [`DEFAULT_METHOD`](constant.DEFAULT_METHOD.html)
/// - **data**: `None`
/// - **url_denylist**: `None`
/// - **regex_denylist**: `None`
/// - **filter_size**: `None` /// - **filter_size**: `None`
/// - **filter_similar**: `None` /// - **filter_similar**: `None`
/// - **filter_regex**: `None` /// - **filter_regex**: `None`
@@ -330,8 +434,10 @@ impl Configuration {
/// - **json**: `false` /// - **json**: `false`
/// - **dont_filter**: `false` (auto filter wildcard responses) /// - **dont_filter**: `false` (auto filter wildcard responses)
/// - **depth**: `4` (maximum recursion depth) /// - **depth**: `4` (maximum recursion depth)
/// - **force_recursion**: `false` (still respects recursion depth)
/// - **scan_limit**: `0` (no limit on concurrent scans imposed) /// - **scan_limit**: `0` (no limit on concurrent scans imposed)
/// - **rate_limit**: `0` (no limit on concurrent scans imposed) /// - **parallel**: `0` (no limit on parallel scans imposed)
/// - **rate_limit**: `0` (no limit on requests per second imposed)
/// - **time_limit**: `None` (no limit on length of scan imposed) /// - **time_limit**: `None` (no limit on length of scan imposed)
/// - **replay_proxy**: `None` (no limit on concurrent scans imposed) /// - **replay_proxy**: `None` (no limit on concurrent scans imposed)
/// - **replay_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html) /// - **replay_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
@@ -378,7 +484,7 @@ impl Configuration {
// --resume-from used, need to first read the Configuration from disk, and then // --resume-from used, need to first read the Configuration from disk, and then
// merge the cli_config into the resumed config // merge the cli_config into the resumed config
if let Some(filename) = args.value_of("resume_from") { if let Some(filename) = args.get_one::<String>("resume_from") {
// when resuming a scan, instead of normal configuration loading, we just // when resuming a scan, instead of normal configuration loading, we just
// load the config from disk by calling resume_scan // load the config from disk by calling resume_scan
let mut previous_config = resume_scan(filename); let mut previous_config = resume_scan(filename);
@@ -416,7 +522,7 @@ impl Configuration {
/// Parse all possible versions of the ferox-config.toml file, adhering to the order of /// Parse all possible versions of the ferox-config.toml file, adhering to the order of
/// precedence outlined above /// precedence outlined above
fn parse_config_files(mut config: &mut Self) -> Result<()> { fn parse_config_files(config: &mut Self) -> Result<()> {
// Next, we parse the ferox-config.toml file, if present and set the values // Next, we parse the ferox-config.toml file, if present and set the values
// therein to overwrite our default values. Deserialized defaults are specified // therein to overwrite our default values. Deserialized defaults are specified
// in the Configuration struct so that we don't change anything that isn't // in the Configuration struct so that we don't change anything that isn't
@@ -432,7 +538,7 @@ impl Configuration {
let config_file = PathBuf::new() let config_file = PathBuf::new()
.join("/etc/feroxbuster") .join("/etc/feroxbuster")
.join(DEFAULT_CONFIG_NAME); .join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?; Self::parse_and_merge_config(config_file, config)?;
// merge a config found at ~/.config/feroxbuster/ferox-config.toml // merge a config found at ~/.config/feroxbuster/ferox-config.toml
// config_dir() resolves to one of the following // config_dir() resolves to one of the following
@@ -441,7 +547,7 @@ impl Configuration {
// - windows: {FOLDERID_RoamingAppData} // - windows: {FOLDERID_RoamingAppData}
let config_dir = dirs::config_dir().ok_or_else(|| anyhow!("Couldn't load config"))?; let config_dir = dirs::config_dir().ok_or_else(|| anyhow!("Couldn't load config"))?;
let config_file = config_dir.join("feroxbuster").join(DEFAULT_CONFIG_NAME); let config_file = config_dir.join("feroxbuster").join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?; Self::parse_and_merge_config(config_file, config)?;
// merge a config found in same the directory as feroxbuster executable // merge a config found in same the directory as feroxbuster executable
let exe_path = current_exe()?; let exe_path = current_exe()?;
@@ -449,12 +555,12 @@ impl Configuration {
.parent() .parent()
.ok_or_else(|| anyhow!("Couldn't load config"))?; .ok_or_else(|| anyhow!("Couldn't load config"))?;
let config_file = bin_dir.join(DEFAULT_CONFIG_NAME); let config_file = bin_dir.join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?; Self::parse_and_merge_config(config_file, config)?;
// merge a config found in the user's current working directory // merge a config found in the user's current working directory
let cwd = current_dir()?; let cwd = current_dir()?;
let config_file = cwd.join(DEFAULT_CONFIG_NAME); let config_file = cwd.join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?; Self::parse_and_merge_config(config_file, config)?;
Ok(()) Ok(())
} }
@@ -464,17 +570,21 @@ impl Configuration {
fn parse_cli_args(args: &ArgMatches) -> Self { fn parse_cli_args(args: &ArgMatches) -> Self {
let mut config = Configuration::default(); let mut config = Configuration::default();
update_config_if_present!(&mut config.threads, args, "threads", usize); update_config_with_num_type_if_present!(&mut config.threads, args, "threads", usize);
update_config_if_present!(&mut config.depth, args, "depth", usize); update_config_with_num_type_if_present!(&mut config.parallel, args, "parallel", usize);
update_config_if_present!(&mut config.scan_limit, args, "scan_limit", usize); update_config_with_num_type_if_present!(&mut config.depth, args, "depth", usize);
update_config_if_present!(&mut config.rate_limit, args, "rate_limit", usize); update_config_with_num_type_if_present!(&mut config.scan_limit, args, "scan_limit", usize);
update_config_with_num_type_if_present!(&mut config.rate_limit, args, "rate_limit", usize);
update_config_if_present!(&mut config.wordlist, args, "wordlist", String); update_config_if_present!(&mut config.wordlist, args, "wordlist", String);
update_config_if_present!(&mut config.output, args, "output", String); update_config_if_present!(&mut config.output, args, "output", String);
update_config_if_present!(&mut config.debug_log, args, "debug_log", String); update_config_if_present!(&mut config.debug_log, args, "debug_log", String);
update_config_if_present!(&mut config.time_limit, args, "time_limit", String);
update_config_if_present!(&mut config.resume_from, args, "resume_from", String); update_config_if_present!(&mut config.resume_from, args, "resume_from", String);
if let Some(arg) = args.values_of("status_codes") { if let Ok(Some(inner)) = args.try_get_one::<String>("time_limit") {
config.time_limit = inner.to_owned();
}
if let Some(arg) = args.get_many::<String>("status_codes") {
config.status_codes = arg config.status_codes = arg
.map(|code| { .map(|code| {
StatusCode::from_bytes(code.as_bytes()) StatusCode::from_bytes(code.as_bytes())
@@ -484,7 +594,7 @@ impl Configuration {
.collect(); .collect();
} }
if let Some(arg) = args.values_of("replay_codes") { if let Some(arg) = args.get_many::<String>("replay_codes") {
// replay codes passed in by the user // replay codes passed in by the user
config.replay_codes = arg config.replay_codes = arg
.map(|code| { .map(|code| {
@@ -498,7 +608,7 @@ impl Configuration {
config.replay_codes = config.status_codes.clone(); config.replay_codes = config.status_codes.clone();
} }
if let Some(arg) = args.values_of("filter_status") { if let Some(arg) = args.get_many::<String>("filter_status") {
config.filter_status = arg config.filter_status = arg
.map(|code| { .map(|code| {
StatusCode::from_bytes(code.as_bytes()) StatusCode::from_bytes(code.as_bytes())
@@ -508,19 +618,96 @@ impl Configuration {
.collect(); .collect();
} }
if let Some(arg) = args.values_of("extensions") { if let Some(arg) = args.get_many::<String>("extensions") {
config.extensions = arg.map(|val| val.to_string()).collect(); config.extensions = arg
.map(|val| val.trim_start_matches('.').to_string())
.collect();
} }
if let Some(arg) = args.values_of("filter_regex") { if let Some(arg) = args.get_many::<String>("dont_collect") {
config.dont_collect = arg.map(|val| val.to_string()).collect();
}
if let Some(arg) = args.get_many::<String>("methods") {
config.methods = arg
.map(|val| {
// Check methods if they are correct
Method::from_bytes(val.as_bytes())
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
.as_str()
.to_string()
})
.collect();
}
if let Some(arg) = args.get_one::<String>("data") {
if let Some(stripped) = arg.strip_prefix('@') {
config.data =
std::fs::read(stripped).unwrap_or_else(|e| report_and_exit(&e.to_string()));
} else {
config.data = arg.as_bytes().to_vec();
}
}
if came_from_cli!(args, "stdin") {
config.stdin = true;
} else if let Some(url) = args.get_one::<String>("url") {
config.target_url = url.into();
}
if let Some(arg) = args.get_many::<String>("url_denylist") {
// compile all regular expressions and absolute urls used for --dont-scan
//
// when --dont-scan is used, the should_deny_url function is called at least once per
// url to be scanned. With the addition of regex support, I want to move parsing
// out of should_deny_url and into here, so it's performed once instead of thousands
// of times
for denier in arg {
// could be an absolute url or a regex, need to determine which and populate the
// appropriate vector
match Url::parse(denier.trim_end_matches('/')) {
Ok(absolute) => {
// denier is an absolute url and can be parsed as such
config.url_denylist.push(absolute);
}
Err(err) => {
// there are some expected errors that happen when we try to parse a url
// ex: Url::parse("/login") -> Err("relative URL without a base")
// ex: Url::parse("http:") -> Err("empty host")
//
// these are known errors and are used to determine a valid value to
// --dont-scan, when it's not an absolute url
//
// when expected errors are encountered, we're going to assume
// that the input is a regular expression to be parsed. The possibility
// exists that the user rolled their face across the keyboard and we're
// dealing with the results, in which case we'll report it as an error and
// give up
if err.to_string().contains("relative URL without a base")
|| err.to_string().contains("empty host")
{
let regex = Regex::new(denier)
.unwrap_or_else(|e| report_and_exit(&e.to_string()));
config.regex_denylist.push(regex);
} else {
// unexpected error has occurred; bail
report_and_exit(&err.to_string());
}
}
}
}
}
if let Some(arg) = args.get_many::<String>("filter_regex") {
config.filter_regex = arg.map(|val| val.to_string()).collect(); config.filter_regex = arg.map(|val| val.to_string()).collect();
} }
if let Some(arg) = args.values_of("filter_similar") { if let Some(arg) = args.get_many::<String>("filter_similar") {
config.filter_similar = arg.map(|val| val.to_string()).collect(); config.filter_similar = arg.map(|val| val.to_string()).collect();
} }
if let Some(arg) = args.values_of("filter_size") { if let Some(arg) = args.get_many::<String>("filter_size") {
config.filter_size = arg config.filter_size = arg
.map(|size| { .map(|size| {
size.parse::<u64>() size.parse::<u64>()
@@ -529,7 +716,7 @@ impl Configuration {
.collect(); .collect();
} }
if let Some(arg) = args.values_of("filter_words") { if let Some(arg) = args.get_many::<String>("filter_words") {
config.filter_word_count = arg config.filter_word_count = arg
.map(|size| { .map(|size| {
size.parse::<usize>() size.parse::<usize>()
@@ -538,7 +725,7 @@ impl Configuration {
.collect(); .collect();
} }
if let Some(arg) = args.values_of("filter_lines") { if let Some(arg) = args.get_many::<String>("filter_lines") {
config.filter_line_count = arg config.filter_line_count = arg
.map(|size| { .map(|size| {
size.parse::<usize>() size.parse::<usize>()
@@ -547,7 +734,7 @@ impl Configuration {
.collect(); .collect();
} }
if args.is_present("silent") { if came_from_cli!(args, "silent") {
// the reason this is protected by an if statement: // the reason this is protected by an if statement:
// consider a user specifying silent = true in ferox-config.toml // consider a user specifying silent = true in ferox-config.toml
// if the line below is outside of the if, we'd overwrite true with // if the line below is outside of the if, we'd overwrite true with
@@ -556,41 +743,77 @@ impl Configuration {
config.output_level = OutputLevel::Silent; config.output_level = OutputLevel::Silent;
} }
if args.is_present("quiet") { if came_from_cli!(args, "quiet") {
config.quiet = true; config.quiet = true;
config.output_level = OutputLevel::Quiet; config.output_level = OutputLevel::Quiet;
} }
if args.is_present("dont_filter") { if came_from_cli!(args, "auto_tune")
|| came_from_cli!(args, "smart")
|| came_from_cli!(args, "thorough")
{
config.auto_tune = true;
config.requester_policy = RequesterPolicy::AutoTune;
}
if came_from_cli!(args, "auto_bail") {
config.auto_bail = true;
config.requester_policy = RequesterPolicy::AutoBail;
}
if came_from_cli!(args, "no_state") {
config.save_state = false;
}
if came_from_cli!(args, "dont_filter") {
config.dont_filter = true; config.dont_filter = true;
} }
if args.occurrences_of("verbosity") > 0 { if came_from_cli!(args, "collect_extensions") || came_from_cli!(args, "thorough") {
// occurrences_of returns 0 if none are found; this is protected in config.collect_extensions = true;
// an if block for the same reason as the quiet option
config.verbosity = args.occurrences_of("verbosity") as u8;
} }
if args.is_present("no_recursion") { if came_from_cli!(args, "collect_backups")
|| came_from_cli!(args, "smart")
|| came_from_cli!(args, "thorough")
{
config.collect_backups = true;
}
if came_from_cli!(args, "collect_words")
|| came_from_cli!(args, "smart")
|| came_from_cli!(args, "thorough")
{
config.collect_words = true;
}
if args.get_count("verbosity") > 0 {
// occurrences_of returns 0 if none are found; this is protected in
// an if block for the same reason as the quiet option
config.verbosity = args.get_count("verbosity");
}
if came_from_cli!(args, "no_recursion") {
config.no_recursion = true; config.no_recursion = true;
} }
if args.is_present("add_slash") { if came_from_cli!(args, "add_slash") {
config.add_slash = true; config.add_slash = true;
} }
if args.is_present("extract_links") { if came_from_cli!(args, "extract_links")
|| came_from_cli!(args, "smart")
|| came_from_cli!(args, "thorough")
{
config.extract_links = true; config.extract_links = true;
} }
if args.is_present("json") { if came_from_cli!(args, "json") {
config.json = true; config.json = true;
} }
if args.is_present("stdin") { if came_from_cli!(args, "force_recursion") {
config.stdin = true; config.force_recursion = true;
} else if let Some(url) = args.value_of("url") {
config.target_url = String::from(url);
} }
//// ////
@@ -599,17 +822,32 @@ impl Configuration {
update_config_if_present!(&mut config.proxy, args, "proxy", String); update_config_if_present!(&mut config.proxy, args, "proxy", String);
update_config_if_present!(&mut config.replay_proxy, args, "replay_proxy", String); update_config_if_present!(&mut config.replay_proxy, args, "replay_proxy", String);
update_config_if_present!(&mut config.user_agent, args, "user_agent", String); update_config_if_present!(&mut config.user_agent, args, "user_agent", String);
update_config_if_present!(&mut config.timeout, args, "timeout", u64); update_config_with_num_type_if_present!(&mut config.timeout, args, "timeout", u64);
if args.is_present("redirects") { if came_from_cli!(args, "burp") {
config.proxy = String::from("http://127.0.0.1:8080");
}
if came_from_cli!(args, "burp_replay") {
config.replay_proxy = String::from("http://127.0.0.1:8080");
}
if came_from_cli!(args, "random_agent") {
config.random_agent = true;
}
if came_from_cli!(args, "redirects") {
config.redirects = true; config.redirects = true;
} }
if args.is_present("insecure") { if came_from_cli!(args, "insecure")
|| came_from_cli!(args, "burp")
|| came_from_cli!(args, "burp_replay")
{
config.insecure = true; config.insecure = true;
} }
if let Some(headers) = args.values_of("headers") { if let Some(headers) = args.get_many::<String>("headers") {
for val in headers { for val in headers {
let mut split_val = val.split(':'); let mut split_val = val.split(':');
@@ -623,7 +861,23 @@ impl Configuration {
} }
} }
if let Some(queries) = args.values_of("queries") { if let Some(cookies) = args.get_many::<String>("cookies") {
config.headers.insert(
// we know the header name is always "cookie"
"Cookie".to_string(),
// on splitting, there should be only two elements,
// a key and a value
cookies
.map(|cookie| cookie.split('=').collect::<Vec<&str>>()[..].to_owned())
.filter(|parts| parts.len() == 2)
.map(|parts| format!("{}={}", parts[0].trim(), parts[1].trim()))
// trim the spaces, join with an equals sign
.collect::<Vec<String>>()
.join("; "), // join all the cookies with semicolons for the final header
);
}
if let Some(queries) = args.get_many::<String>("queries") {
for val in queries { for val in queries {
// same basic logic used as reading in the headers HashMap above // same basic logic used as reading in the headers HashMap above
let mut split_val = val.split('='); let mut split_val = val.split('=');
@@ -702,7 +956,7 @@ impl Configuration {
config.config = conf_str; config.config = conf_str;
// update the settings // update the settings
Self::merge_config(&mut config, settings); Self::merge_config(config, settings);
} }
Ok(()) Ok(())
} }
@@ -721,13 +975,31 @@ impl Configuration {
update_if_not_default!(&mut conf.verbosity, new.verbosity, 0); update_if_not_default!(&mut conf.verbosity, new.verbosity, 0);
update_if_not_default!(&mut conf.silent, new.silent, false); update_if_not_default!(&mut conf.silent, new.silent, false);
update_if_not_default!(&mut conf.quiet, new.quiet, false); update_if_not_default!(&mut conf.quiet, new.quiet, false);
// use updated quiet/silent values to determin output level update_if_not_default!(&mut conf.auto_bail, new.auto_bail, false);
update_if_not_default!(&mut conf.auto_tune, new.auto_tune, false);
update_if_not_default!(&mut conf.collect_extensions, new.collect_extensions, false);
update_if_not_default!(&mut conf.collect_backups, new.collect_backups, false);
update_if_not_default!(&mut conf.collect_words, new.collect_words, false);
// use updated quiet/silent values to determine output level; same for requester policy
conf.output_level = determine_output_level(conf.quiet, conf.silent); conf.output_level = determine_output_level(conf.quiet, conf.silent);
conf.requester_policy = determine_requester_policy(conf.auto_tune, conf.auto_bail);
update_if_not_default!(&mut conf.output, new.output, ""); update_if_not_default!(&mut conf.output, new.output, "");
update_if_not_default!(&mut conf.redirects, new.redirects, false); update_if_not_default!(&mut conf.redirects, new.redirects, false);
update_if_not_default!(&mut conf.insecure, new.insecure, false); update_if_not_default!(&mut conf.insecure, new.insecure, false);
update_if_not_default!(&mut conf.force_recursion, new.force_recursion, false);
update_if_not_default!(&mut conf.extract_links, new.extract_links, false); update_if_not_default!(&mut conf.extract_links, new.extract_links, false);
update_if_not_default!(&mut conf.extensions, new.extensions, Vec::<String>::new()); update_if_not_default!(&mut conf.extensions, new.extensions, Vec::<String>::new());
update_if_not_default!(&mut conf.methods, new.methods, methods());
update_if_not_default!(&mut conf.data, new.data, Vec::<u8>::new());
update_if_not_default!(&mut conf.url_denylist, new.url_denylist, Vec::<Url>::new());
if !new.regex_denylist.is_empty() {
// cant use the update_if_not_default macro due to the following error
//
// binary operation `!=` cannot be applied to type `Vec<regex::Regex>`
//
// if we get a non-empty list of regex in the new config, override the old
conf.regex_denylist = new.regex_denylist;
}
update_if_not_default!(&mut conf.headers, new.headers, HashMap::new()); update_if_not_default!(&mut conf.headers, new.headers, HashMap::new());
update_if_not_default!(&mut conf.queries, new.queries, Vec::new()); update_if_not_default!(&mut conf.queries, new.queries, Vec::new());
update_if_not_default!(&mut conf.no_recursion, new.no_recursion, false); update_if_not_default!(&mut conf.no_recursion, new.no_recursion, false);
@@ -761,6 +1033,7 @@ impl Configuration {
); );
update_if_not_default!(&mut conf.dont_filter, new.dont_filter, false); update_if_not_default!(&mut conf.dont_filter, new.dont_filter, false);
update_if_not_default!(&mut conf.scan_limit, new.scan_limit, 0); update_if_not_default!(&mut conf.scan_limit, new.scan_limit, 0);
update_if_not_default!(&mut conf.parallel, new.parallel, 0);
update_if_not_default!(&mut conf.rate_limit, new.rate_limit, 0); update_if_not_default!(&mut conf.rate_limit, new.rate_limit, 0);
update_if_not_default!(&mut conf.replay_proxy, new.replay_proxy, ""); update_if_not_default!(&mut conf.replay_proxy, new.replay_proxy, "");
update_if_not_default!(&mut conf.debug_log, new.debug_log, ""); update_if_not_default!(&mut conf.debug_log, new.debug_log, "");
@@ -769,6 +1042,7 @@ impl Configuration {
update_if_not_default!(&mut conf.timeout, new.timeout, timeout()); update_if_not_default!(&mut conf.timeout, new.timeout, timeout());
update_if_not_default!(&mut conf.user_agent, new.user_agent, user_agent()); update_if_not_default!(&mut conf.user_agent, new.user_agent, user_agent());
update_if_not_default!(&mut conf.random_agent, new.random_agent, false);
update_if_not_default!(&mut conf.threads, new.threads, threads()); update_if_not_default!(&mut conf.threads, new.threads, threads());
update_if_not_default!(&mut conf.depth, new.depth, depth()); update_if_not_default!(&mut conf.depth, new.depth, depth());
update_if_not_default!(&mut conf.wordlist, new.wordlist, wordlist()); update_if_not_default!(&mut conf.wordlist, new.wordlist, wordlist());
@@ -776,6 +1050,11 @@ impl Configuration {
// status_codes() is the default for replay_codes, if they're not provided // status_codes() is the default for replay_codes, if they're not provided
update_if_not_default!(&mut conf.replay_codes, new.replay_codes, status_codes()); update_if_not_default!(&mut conf.replay_codes, new.replay_codes, status_codes());
update_if_not_default!(&mut conf.save_state, new.save_state, save_state()); update_if_not_default!(&mut conf.save_state, new.save_state, save_state());
update_if_not_default!(
&mut conf.dont_collect,
new.dont_collect,
ignored_extensions()
);
} }
/// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values /// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values
@@ -783,7 +1062,17 @@ impl Configuration {
/// uses serde to deserialize the toml into a `Configuration` struct /// uses serde to deserialize the toml into a `Configuration` struct
pub(super) fn parse_config(config_file: PathBuf) -> Result<Self> { pub(super) fn parse_config(config_file: PathBuf) -> Result<Self> {
let content = read_to_string(config_file)?; let content = read_to_string(config_file)?;
let config: Self = toml::from_str(content.as_str())?; let mut config: Self = toml::from_str(content.as_str())?;
if !config.extensions.is_empty() {
// remove leading periods, if any are found
config.extensions = config
.extensions
.iter()
.map(|ext| ext.trim_start_matches('.').to_string())
.collect();
}
Ok(config) Ok(config)
} }
} }

View File

@@ -6,4 +6,4 @@ mod utils;
mod tests; mod tests;
pub use self::container::Configuration; pub use self::container::Configuration;
pub use self::utils::{determine_output_level, OutputLevel}; pub use self::utils::{determine_output_level, OutputLevel, RequesterPolicy};

View File

@@ -1,6 +1,8 @@
use super::utils::*; use super::utils::*;
use super::*; use super::*;
use crate::{traits::FeroxSerialize, DEFAULT_CONFIG_NAME}; use crate::{traits::FeroxSerialize, DEFAULT_CONFIG_NAME};
use regex::Regex;
use reqwest::Url;
use std::{collections::HashMap, fs::write}; use std::{collections::HashMap, fs::write};
use tempfile::TempDir; use tempfile::TempDir;
@@ -16,8 +18,11 @@ fn setup_config_test() -> Configuration {
replay_proxy = "http://127.0.0.1:8081" replay_proxy = "http://127.0.0.1:8081"
quiet = true quiet = true
silent = true silent = true
auto_tune = true
auto_bail = true
verbosity = 1 verbosity = 1
scan_limit = 6 scan_limit = 6
parallel = 14
rate_limit = 250 rate_limit = 250
time_limit = "10m" time_limit = "10m"
output = "/some/otherpath" output = "/some/otherpath"
@@ -25,7 +30,15 @@ fn setup_config_test() -> Configuration {
resume_from = "/some/state/file" resume_from = "/some/state/file"
redirects = true redirects = true
insecure = true insecure = true
collect_backups = true
collect_extensions = true
collect_words = true
extensions = ["html", "php", "js"] extensions = ["html", "php", "js"]
dont_collect = ["png", "gif", "jpg", "jpeg"]
methods = ["GET", "PUT", "DELETE"]
data = [31, 32, 33, 34]
url_denylist = ["http://dont-scan.me", "https://also-not.me"]
regex_denylist = ["/deny.*"]
headers = {stuff = "things", mostuff = "mothings"} headers = {stuff = "things", mostuff = "mothings"}
queries = [["name","value"], ["rick", "astley"]] queries = [["name","value"], ["rick", "astley"]]
no_recursion = true no_recursion = true
@@ -36,6 +49,7 @@ fn setup_config_test() -> Configuration {
json = true json = true
save_state = false save_state = false
depth = 1 depth = 1
force_recursion = true
filter_size = [4120] filter_size = [4120]
filter_regex = ["^ignore me$"] filter_regex = ["^ignore me$"]
filter_similar = ["https://somesite.com/soft404"] filter_similar = ["https://somesite.com/soft404"]
@@ -69,20 +83,34 @@ fn default_configuration() {
assert_eq!(config.timeout, timeout()); assert_eq!(config.timeout, timeout());
assert_eq!(config.verbosity, 0); assert_eq!(config.verbosity, 0);
assert_eq!(config.scan_limit, 0); assert_eq!(config.scan_limit, 0);
assert_eq!(config.silent, false); assert!(!config.silent);
assert_eq!(config.quiet, false); assert!(!config.quiet);
assert_eq!(config.dont_filter, false); assert_eq!(config.output_level, OutputLevel::Default);
assert_eq!(config.no_recursion, false); assert!(!config.dont_filter);
assert_eq!(config.json, false); assert!(!config.auto_tune);
assert_eq!(config.save_state, true); assert!(!config.auto_bail);
assert_eq!(config.stdin, false); assert_eq!(config.requester_policy, RequesterPolicy::Default);
assert_eq!(config.add_slash, false); assert!(!config.no_recursion);
assert_eq!(config.redirects, false); assert!(!config.random_agent);
assert_eq!(config.extract_links, false); assert!(!config.json);
assert_eq!(config.insecure, false); assert!(config.save_state);
assert!(!config.stdin);
assert!(!config.add_slash);
assert!(!config.force_recursion);
assert!(!config.redirects);
assert!(!config.extract_links);
assert!(!config.insecure);
assert!(!config.collect_extensions);
assert!(!config.collect_backups);
assert!(!config.collect_words);
assert!(config.regex_denylist.is_empty());
assert_eq!(config.queries, Vec::new()); assert_eq!(config.queries, Vec::new());
assert_eq!(config.extensions, Vec::<String>::new());
assert_eq!(config.filter_size, Vec::<u64>::new()); assert_eq!(config.filter_size, Vec::<u64>::new());
assert_eq!(config.extensions, Vec::<String>::new());
assert_eq!(config.methods, vec!["GET"]);
assert_eq!(config.data, Vec::<u8>::new());
assert_eq!(config.url_denylist, Vec::<Url>::new());
assert_eq!(config.dont_collect, ignored_extensions());
assert_eq!(config.filter_regex, Vec::<String>::new()); assert_eq!(config.filter_regex, Vec::<String>::new());
assert_eq!(config.filter_similar, Vec::<String>::new()); assert_eq!(config.filter_similar, Vec::<String>::new());
assert_eq!(config.filter_word_count, Vec::<usize>::new()); assert_eq!(config.filter_word_count, Vec::<usize>::new());
@@ -140,6 +168,13 @@ fn config_reads_scan_limit() {
assert_eq!(config.scan_limit, 6); assert_eq!(config.scan_limit, 6);
} }
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_parallel() {
let config = setup_config_test();
assert_eq!(config.parallel, 14);
}
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_rate_limit() { fn config_reads_rate_limit() {
@@ -172,21 +207,42 @@ fn config_reads_replay_proxy() {
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_silent() { fn config_reads_silent() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.silent, true); assert!(config.silent);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_force_recursion() {
let config = setup_config_test();
assert!(config.force_recursion);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_quiet() { fn config_reads_quiet() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.quiet, true); assert!(config.quiet);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_json() { fn config_reads_json() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.json, true); assert!(config.json);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_auto_bail() {
let config = setup_config_test();
assert!(config.auto_bail);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_auto_tune() {
let config = setup_config_test();
assert!(config.auto_tune);
} }
#[test] #[test]
@@ -207,49 +263,70 @@ fn config_reads_output() {
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_redirects() { fn config_reads_redirects() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.redirects, true); assert!(config.redirects);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_insecure() { fn config_reads_insecure() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.insecure, true); assert!(config.insecure);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_no_recursion() { fn config_reads_no_recursion() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.no_recursion, true); assert!(config.no_recursion);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_stdin() { fn config_reads_stdin() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.stdin, true); assert!(config.stdin);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_dont_filter() { fn config_reads_dont_filter() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.dont_filter, true); assert!(config.dont_filter);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_add_slash() { fn config_reads_add_slash() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.add_slash, true); assert!(config.add_slash);
} }
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_extract_links() { fn config_reads_extract_links() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.extract_links, true); assert!(config.extract_links);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_collect_extensions() {
let config = setup_config_test();
assert!(config.collect_extensions);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_collect_backups() {
let config = setup_config_test();
assert!(config.collect_backups);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_collect_words() {
let config = setup_config_test();
assert!(config.collect_words);
} }
#[test] #[test]
@@ -259,6 +336,50 @@ fn config_reads_extensions() {
assert_eq!(config.extensions, vec!["html", "php", "js"]); assert_eq!(config.extensions, vec!["html", "php", "js"]);
} }
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_dont_collect() {
let config = setup_config_test();
assert_eq!(config.dont_collect, vec!["png", "gif", "jpg", "jpeg"]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_methods() {
let config = setup_config_test();
assert_eq!(config.methods, vec!["GET", "PUT", "DELETE"]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_data() {
let config = setup_config_test();
assert_eq!(config.data, vec![31, 32, 33, 34]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_regex_denylist() {
let config = setup_config_test();
assert_eq!(
config.regex_denylist[0].as_str(),
Regex::new("/deny.*").unwrap().as_str()
);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_url_denylist() {
let config = setup_config_test();
assert_eq!(
config.url_denylist,
vec![
Url::parse("http://dont-scan.me").unwrap(),
Url::parse("https://also-not.me").unwrap(),
]
);
}
#[test] #[test]
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_filter_regex() { fn config_reads_filter_regex() {
@@ -305,7 +426,7 @@ fn config_reads_filter_status() {
/// parse the test config and see that the value parsed is correct /// parse the test config and see that the value parsed is correct
fn config_reads_save_state() { fn config_reads_save_state() {
let config = setup_config_test(); let config = setup_config_test();
assert_eq!(config.save_state, false); assert!(!config.save_state);
} }
#[test] #[test]
@@ -336,12 +457,19 @@ fn config_reads_headers() {
/// parse the test config and see that the values parsed are correct /// parse the test config and see that the values parsed are correct
fn config_reads_queries() { fn config_reads_queries() {
let config = setup_config_test(); let config = setup_config_test();
let mut queries = vec![]; let queries = vec![
queries.push(("name".to_string(), "value".to_string())); ("name".to_string(), "value".to_string()),
queries.push(("rick".to_string(), "astley".to_string())); ("rick".to_string(), "astley".to_string()),
];
assert_eq!(config.queries, queries); assert_eq!(config.queries, queries);
} }
#[test]
fn config_default_not_random_agent() {
let config = setup_config_test();
assert!(!config.random_agent);
}
#[test] #[test]
#[should_panic] #[should_panic]
/// test that an error message is printed and panic is called when report_and_exit is called /// test that an error message is printed and panic is called when report_and_exit is called
@@ -354,7 +482,7 @@ fn config_report_and_exit_works() {
fn as_str_returns_string_with_newline() { fn as_str_returns_string_with_newline() {
let config = Configuration::new().unwrap(); let config = Configuration::new().unwrap();
let config_str = config.as_str(); let config_str = config.as_str();
println!("{}", config_str); println!("{config_str}");
assert!(config_str.starts_with("Configuration {")); assert!(config_str.starts_with("Configuration {"));
assert!(config_str.ends_with("}\n")); assert!(config_str.ends_with("}\n"));
assert!(config_str.contains("replay_codes:")); assert!(config_str.contains("replay_codes:"));

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
utils::{module_colorizer, status_colorizer}, utils::{module_colorizer, status_colorizer},
DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION, DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION,
}; };
#[cfg(not(test))] #[cfg(not(test))]
use std::process::exit; use std::process::exit;
@@ -49,6 +49,23 @@ pub(super) fn status_codes() -> Vec<u16> {
DEFAULT_STATUS_CODES DEFAULT_STATUS_CODES
.iter() .iter()
.map(|code| code.as_u16()) .map(|code| code.as_u16())
// add experimental codes not found in reqwest
// - 103 - EARLY_HINTS
// - 425 - TOO_EARLY
.chain([103, 425])
.collect()
}
/// default HTTP Method
pub(super) fn methods() -> Vec<String> {
vec![DEFAULT_METHOD.to_owned()]
}
/// default extensions to ignore while auto-collecting
pub(super) fn ignored_extensions() -> Vec<String> {
DEFAULT_IGNORED_EXTENSIONS
.iter()
.map(|s| s.to_string())
.collect() .collect()
} }
@@ -59,7 +76,7 @@ pub(super) fn wordlist() -> String {
/// default user-agent /// default user-agent
pub(super) fn user_agent() -> String { pub(super) fn user_agent() -> String {
format!("feroxbuster/{}", VERSION) format!("feroxbuster/{VERSION}")
} }
/// default recursion depth /// default recursion depth
@@ -68,7 +85,7 @@ pub(super) fn depth() -> usize {
} }
/// enum representing the three possible states for informational output (not logging verbosity) /// enum representing the three possible states for informational output (not logging verbosity)
#[derive(Debug, Copy, Clone, PartialEq)] #[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum OutputLevel { pub enum OutputLevel {
/// normal scan, no --quiet|--silent /// normal scan, no --quiet|--silent
Default, Default,
@@ -102,6 +119,41 @@ pub fn determine_output_level(quiet: bool, silent: bool) -> OutputLevel {
} }
} }
/// represents actions the Requester should take in certain situations
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum RequesterPolicy {
/// automatically try to lower request rate in order to reduce errors
AutoTune,
/// automatically bail at certain error thresholds
AutoBail,
/// just let that junk run super natural
Default,
}
/// default implementation for RequesterPolicy
impl Default for RequesterPolicy {
/// Default as default
fn default() -> Self {
Self::Default
}
}
/// given the current settings for quiet and silent, determine output_level (DRY helper)
pub fn determine_requester_policy(auto_tune: bool, auto_bail: bool) -> RequesterPolicy {
if auto_tune && auto_bail {
// user COULD have both as true in config file, take the more aggressive of the two
RequesterPolicy::AutoBail
} else if auto_tune {
RequesterPolicy::AutoTune
} else if auto_bail {
RequesterPolicy::AutoBail
} else {
RequesterPolicy::Default
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -122,6 +174,22 @@ mod tests {
assert_eq!(level, OutputLevel::Quiet); assert_eq!(level, OutputLevel::Quiet);
} }
#[test]
/// test determine_requester_policy returns higher of the two levels if both given values are true
fn determine_requester_policy_returns_correct_results() {
let mut level = determine_requester_policy(true, true);
assert_eq!(level, RequesterPolicy::AutoBail);
level = determine_requester_policy(false, true);
assert_eq!(level, RequesterPolicy::AutoBail);
level = determine_requester_policy(false, false);
assert_eq!(level, RequesterPolicy::Default);
level = determine_requester_policy(true, false);
assert_eq!(level, RequesterPolicy::AutoTune);
}
#[test] #[test]
#[should_panic] #[should_panic]
/// report_and_exit should panic/exit when called /// report_and_exit should panic/exit when called

View File

@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use reqwest::StatusCode; use reqwest::StatusCode;
@@ -6,6 +5,8 @@ use tokio::sync::oneshot::Sender;
use crate::response::FeroxResponse; use crate::response::FeroxResponse;
use crate::{ use crate::{
event_handlers::Handles,
message::FeroxMessage,
statistics::{StatError, StatField}, statistics::{StatError, StatField},
traits::FeroxFilter, traits::FeroxFilter,
}; };
@@ -23,13 +24,18 @@ pub enum Command {
AddStatus(StatusCode), AddStatus(StatusCode),
/// Create the progress bar (`BarType::Total`) that is updated from the stats thread /// Create the progress bar (`BarType::Total`) that is updated from the stats thread
CreateBar, ///
/// the u64 value is the offset at which to start the progress bar (can be 0)
CreateBar(u64),
/// Update a `Stats` field that corresponds to the given `StatField` by the given `usize` value /// Add to a `Stats` field that corresponds to the given `StatField` by the given `usize` value
UpdateUsizeField(StatField, usize), AddToUsizeField(StatField, usize),
/// Subtract from a `Stats` field that corresponds to the given `StatField` by the given `usize` value
SubtractFromUsizeField(StatField, usize),
/// Update a `Stats` field that corresponds to the given `StatField` by the given `f64` value /// Update a `Stats` field that corresponds to the given `StatField` by the given `f64` value
UpdateF64Field(StatField, f64), AddToF64Field(StatField, f64),
/// Save a `Stats` object to disk using `reporter::get_cached_file_handle` /// Save a `Stats` object to disk using `reporter::get_cached_file_handle`
Save, Save,
@@ -40,17 +46,23 @@ pub enum Command {
/// Add a `FeroxFilter` implementor to `FilterHandler`'s instance of `FeroxFilters` /// Add a `FeroxFilter` implementor to `FilterHandler`'s instance of `FeroxFilters`
AddFilter(Box<dyn FeroxFilter>), AddFilter(Box<dyn FeroxFilter>),
/// Remove a set of `FeroxFilter` implementors from `FeroxFilters` by index
RemoveFilters(Vec<usize>),
/// Send a `FeroxResponse` to the output handler for reporting /// Send a `FeroxResponse` to the output handler for reporting
Report(Box<FeroxResponse>), Report(Box<FeroxResponse>),
/// Send a group of urls to be scanned (only used for the urls passed in explicitly by the user) /// Send a group of urls to be scanned (only used for the urls passed in explicitly by the user)
ScanInitialUrls(Vec<String>), ScanInitialUrls(Vec<String>),
/// Send a single url to be scanned (presumably added from the interactive menu)
ScanNewUrl(String),
/// Determine whether or not recursion is appropriate, given a FeroxResponse, if so start a scan /// Determine whether or not recursion is appropriate, given a FeroxResponse, if so start a scan
TryRecursion(Box<FeroxResponse>), TryRecursion(Box<FeroxResponse>),
/// Send a pointer to the wordlist to the recursion handler /// Send a pointer to the wordlist to the recursion handler
UpdateWordlist(Arc<HashSet<String>>), UpdateWordlist(Arc<Vec<String>>),
/// Instruct the ScanHandler to join on all known scans, use sender to notify main when done /// Instruct the ScanHandler to join on all known scans, use sender to notify main when done
JoinTasks(Sender<bool>), JoinTasks(Sender<bool>),
@@ -61,6 +73,16 @@ pub enum Command {
/// Just receive a sender and reply, used for slowing down the main thread /// Just receive a sender and reply, used for slowing down the main thread
Sync(Sender<bool>), Sync(Sender<bool>),
/// Notify event handler that a new extension has been seen
AddDiscoveredExtension(String),
/// Write an arbitrary string to disk
WriteToDisk(Box<FeroxMessage>),
/// Break out of the (infinite) mpsc receive loop /// Break out of the (infinite) mpsc receive loop
Exit, Exit,
/// Give a handler access to an Arc<Handles> instance after the handler has
/// already been initialized
AddHandles(Arc<Handles>),
} }

View File

@@ -6,6 +6,7 @@ use crate::Joiner;
#[cfg(test)] #[cfg(test)]
use crate::{filters::FeroxFilters, statistics::Stats, Command}; use crate::{filters::FeroxFilters, statistics::Stats, Command};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::collections::HashSet;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
#[cfg(test)] #[cfg(test)]
use tokio::sync::mpsc::{self, UnboundedReceiver}; use tokio::sync::mpsc::{self, UnboundedReceiver};
@@ -56,6 +57,9 @@ pub struct Handles {
/// Handle for recursion /// Handle for recursion
pub scans: RwLock<Option<ScanHandle>>, pub scans: RwLock<Option<ScanHandle>>,
/// Pointer to the list of words generated from reading in the wordlist
pub wordlist: Arc<Vec<String>>,
} }
/// implementation of Handles /// implementation of Handles
@@ -66,6 +70,7 @@ impl Handles {
filters: FiltersHandle, filters: FiltersHandle,
output: TermOutHandle, output: TermOutHandle,
config: Arc<Configuration>, config: Arc<Configuration>,
wordlist: Arc<Vec<String>>,
) -> Self { ) -> Self {
Self { Self {
stats, stats,
@@ -73,6 +78,7 @@ impl Handles {
output, output,
config, config,
scans: RwLock::new(None), scans: RwLock::new(None),
wordlist,
} }
} }
@@ -85,15 +91,16 @@ impl Handles {
let configuration = config.unwrap_or_else(|| Arc::new(Configuration::new().unwrap())); let configuration = config.unwrap_or_else(|| Arc::new(Configuration::new().unwrap()));
let (tx, rx) = mpsc::unbounded_channel::<Command>(); let (tx, rx) = mpsc::unbounded_channel::<Command>();
let terminal_handle = TermOutHandle::new(tx.clone(), tx.clone()); let terminal_handle = TermOutHandle::new(tx.clone(), tx.clone());
let stats_handle = StatsHandle::new( let stats_handle = StatsHandle::new(Arc::new(Stats::new(configuration.json)), tx.clone());
Arc::new(Stats::new(
configuration.extensions.len(),
configuration.json,
)),
tx.clone(),
);
let filters_handle = FiltersHandle::new(Arc::new(FeroxFilters::default()), tx.clone()); let filters_handle = FiltersHandle::new(Arc::new(FeroxFilters::default()), tx.clone());
let handles = Self::new(stats_handle, filters_handle, terminal_handle, configuration); let wordlist = Arc::new(vec![String::from("this_is_a_test")]);
let handles = Self::new(
stats_handle,
filters_handle,
terminal_handle,
configuration,
wordlist,
);
if let Some(sh) = scanned_urls { if let Some(sh) = scanned_urls {
let scan_handle = ScanHandle::new(sh, tx); let scan_handle = ScanHandle::new(sh, tx);
handles.set_scan_handle(scan_handle); handles.set_scan_handle(scan_handle);
@@ -122,6 +129,46 @@ impl Handles {
bail!("Could not get underlying CommandSender object") bail!("Could not get underlying CommandSender object")
} }
/// wrapper to reach into `FeroxScans` and yank out the length of `collected_extensions`
pub fn num_collected_extensions(&self) -> usize {
if !self.config.collect_extensions {
// if --collect-extensions wasn't used, simply return 0 and forego unlocking
return 0;
}
self.collected_extensions().len()
}
/// wrapper to reach into `FeroxScans` and yank out the length of `collected_extensions`
pub fn collected_extensions(&self) -> HashSet<String> {
if let Ok(scans) = self.ferox_scans() {
if let Ok(extensions) = scans.collected_extensions.read() {
return extensions.clone();
}
}
HashSet::new()
}
/// number of words in the wordlist, multiplied by `expected_num_requests_multiplier`
pub fn expected_num_requests_per_dir(&self) -> usize {
let num_words = self.wordlist.len();
let multiplier = self.expected_num_requests_multiplier();
multiplier * num_words
}
/// number of extensions plus the number of request method types plus any dynamically collected
/// extensions
pub fn expected_num_requests_multiplier(&self) -> usize {
let multiplier = self.config.extensions.len()
+ self.config.methods.len()
+ self.num_collected_extensions();
// methods should always have at least 1 member, likely making this .max call unneeded
// but leaving it for 'just in case' reasons
multiplier.max(1)
}
/// Helper to easily get the (locked) underlying FeroxScans object /// Helper to easily get the (locked) underlying FeroxScans object
pub fn ferox_scans(&self) -> Result<Arc<FeroxScans>> { pub fn ferox_scans(&self) -> Result<Arc<FeroxScans>> {
if let Ok(guard) = self.scans.read().as_ref() { if let Ok(guard) = self.scans.read().as_ref() {

View File

@@ -1,4 +1,5 @@
use super::*; use super::*;
use crate::filters::EmptyFilter;
use crate::{filters::FeroxFilters, CommandSender, FeroxChannel, Joiner}; use crate::{filters::FeroxFilters, CommandSender, FeroxChannel, Joiner};
use anyhow::Result; use anyhow::Result;
use std::sync::Arc; use std::sync::Arc;
@@ -84,8 +85,12 @@ impl FiltersHandler {
while let Some(command) = self.receiver.recv().await { while let Some(command) = self.receiver.recv().await {
match command { match command {
Command::AddFilter(filter) => { Command::AddFilter(filter) => {
self.data.push(filter)?; if filter.as_any().downcast_ref::<EmptyFilter>().is_none() {
// don't add an empty filter
self.data.push(filter)?;
}
} }
Command::RemoveFilters(mut indices) => self.data.remove(&mut indices),
Command::Sync(sender) => { Command::Sync(sender) => {
log::debug!("filters: {:?}", self); log::debug!("filters: {:?}", self);
sender.send(true).unwrap_or_default(); sender.send(true).unwrap_or_default();
@@ -99,3 +104,41 @@ impl FiltersHandler {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::filters::WordsFilter;
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn empty_filter_skipped() {
let data = Arc::new(FeroxFilters::default());
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
let mut handler = FiltersHandler::new(data.clone(), rx);
let event_handle = FiltersHandle::new(data, tx);
let _task = tokio::spawn(async move { handler.start().await });
event_handle
.send(Command::AddFilter(Box::new(EmptyFilter {})))
.unwrap();
let (tx, rx) = oneshot::channel::<bool>();
event_handle.send(Command::Sync(tx)).unwrap();
rx.await.unwrap();
assert!(event_handle.data.filters.read().unwrap().is_empty());
event_handle
.send(Command::AddFilter(Box::new(WordsFilter { word_count: 1 })))
.unwrap();
let (tx, rx) = oneshot::channel::<bool>();
event_handle.send(Command::Sync(tx)).unwrap();
rx.await.unwrap();
assert_eq!(event_handle.data.filters.read().unwrap().len(), 1);
}
}

View File

@@ -4,6 +4,7 @@ use crate::{
scan_manager::{FeroxState, PAUSE_SCAN}, scan_manager::{FeroxState, PAUSE_SCAN},
scanner::RESPONSES, scanner::RESPONSES,
statistics::StatError, statistics::StatError,
utils::slugify_filename,
utils::{open_file, write_to}, utils::{open_file, write_to},
SLEEP_DURATION, SLEEP_DURATION,
}; };
@@ -17,7 +18,6 @@ use std::{
}, },
thread::sleep, thread::sleep,
time::Duration, time::Duration,
time::{SystemTime, UNIX_EPOCH},
}; };
/// Atomic boolean flag, used to determine whether or not the terminal input handler should exit /// Atomic boolean flag, used to determine whether or not the terminal input handler should exit
@@ -33,7 +33,7 @@ pub struct TermInputHandler {
/// ///
/// kicks off the following handlers related to terminal input: /// kicks off the following handlers related to terminal input:
/// ctrl+c handler that saves scan state to disk /// ctrl+c handler that saves scan state to disk
/// enter handler that listens for enter during scans to drop into interactive scan cancel menu /// enter handler that listens for enter during scans to drop into interactive scan management menu
impl TermInputHandler { impl TermInputHandler {
/// Create new event handler /// Create new event handler
pub fn new(handles: Arc<Handles>) -> Self { pub fn new(handles: Arc<Handles>) -> Self {
@@ -77,22 +77,14 @@ impl TermInputHandler {
pub fn sigint_handler(handles: Arc<Handles>) -> Result<()> { pub fn sigint_handler(handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: sigint_handler({:?})", handles); log::trace!("enter: sigint_handler({:?})", handles);
let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let filename = if !handles.config.target_url.is_empty() {
let slug = if !handles.config.target_url.is_empty() {
// target url populated // target url populated
handles slugify_filename(&handles.config.target_url, "ferox", "state")
.config
.target_url
.replace("://", "_")
.replace("/", "_")
.replace(".", "_")
} else { } else {
// stdin used // stdin used
"stdin".to_string() slugify_filename("stdin", "ferox", "state")
}; };
let filename = format!("ferox-{}-{}.state", slug, ts);
let warning = format!( let warning = format!(
"🚨 Caught {} 🚨 saving scan state to {} ...", "🚨 Caught {} 🚨 saving scan state to {} ...",
style("ctrl+c").yellow(), style("ctrl+c").yellow(),
@@ -106,12 +98,16 @@ impl TermInputHandler {
handles.config.clone(), handles.config.clone(),
&RESPONSES, &RESPONSES,
handles.stats.data.clone(), handles.stats.data.clone(),
handles.filters.data.clone(),
); );
let state_file = open_file(&filename); // User didn't set the --no-state flag (so saved_state is still the default true)
if handles.config.save_state {
let state_file = open_file(&filename);
let mut buffered_file = state_file?; let mut buffered_file = state_file?;
write_to(&state, &mut buffered_file, true)?; write_to(&state, &mut buffered_file, true)?;
}
log::trace!("exit: sigint_handler (end of program)"); log::trace!("exit: sigint_handler (end of program)");
std::process::exit(1); std::process::exit(1);

View File

@@ -1,20 +1,33 @@
use super::Command::UpdateUsizeField; use super::Command::AddToUsizeField;
use super::*; use super::*;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use futures::future::{BoxFuture, FutureExt};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use crate::{ use crate::{
config::Configuration, config::Configuration,
progress::PROGRESS_PRINTER, progress::PROGRESS_PRINTER,
response::FeroxResponse,
scanner::RESPONSES, scanner::RESPONSES,
send_command, skip_fail, send_command, skip_fail,
statistics::StatField::ResourcesDiscovered, statistics::StatField::{ResourcesDiscovered, TotalExpected},
traits::FeroxSerialize, traits::FeroxSerialize,
utils::{ferox_print, fmt_err, make_request, open_file, write_to}, utils::{ferox_print, fmt_err, make_request, open_file, write_to},
CommandReceiver, CommandSender, Joiner, CommandReceiver, CommandSender, Joiner,
}; };
use std::sync::Arc; use std::sync::Arc;
use url::Url;
#[derive(Debug, Copy, Clone)]
/// Simple enum for semantic clarity around calling expectations for `process_response`
enum ProcessResponseCall {
/// call should allow recursion
Recursive,
/// call should not allow recursion
NotRecursive,
}
#[derive(Debug)] #[derive(Debug)]
/// Container for terminal output transmitter /// Container for terminal output transmitter
@@ -90,6 +103,12 @@ impl FileOutHandler {
Command::Report(response) => { Command::Report(response) => {
skip_fail!(write_to(&*response, &mut file, self.config.json)); skip_fail!(write_to(&*response, &mut file, self.config.json));
} }
Command::WriteToDisk(message) => {
// todo consider making report accept dyn FeroxSerialize; would mean adding
// as_any/box_eq/PartialEq to the trait and then adding them to the
// implementing structs
skip_fail!(write_to(&*message, &mut file, self.config.json));
}
Command::Exit => { Command::Exit => {
break; break;
} }
@@ -124,6 +143,9 @@ pub struct TermOutHandler {
/// pointer to "global" configuration struct /// pointer to "global" configuration struct
config: Arc<Configuration>, config: Arc<Configuration>,
/// handles instance
handles: Option<Arc<Handles>>,
} }
/// implementation of TermOutHandler /// implementation of TermOutHandler
@@ -139,8 +161,9 @@ impl TermOutHandler {
Self { Self {
receiver, receiver,
tx_file, tx_file,
config,
file_task, file_task,
config,
handles: None,
} }
} }
@@ -185,57 +208,16 @@ impl TermOutHandler {
while let Some(command) = self.receiver.recv().await { while let Some(command) = self.receiver.recv().await {
match command { match command {
Command::Report(mut resp) => { Command::Report(resp) => {
let contains_sentry = self.process_response(tx_stats.clone(), resp, ProcessResponseCall::Recursive)
self.config.status_codes.contains(&resp.status().as_u16()); .await?;
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
let should_process_response = contains_sentry && unknown_sentry;
if should_process_response {
// print to stdout
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
send_command!(tx_stats, UpdateUsizeField(ResourcesDiscovered, 1));
if self.file_task.is_some() {
// -o used, need to send the report to be written out to disk
self.tx_file
.send(Command::Report(resp.clone()))
.with_context(|| {
fmt_err(&format!("Could not send {} to file handler", resp))
})?;
}
}
log::trace!("report complete: {}", resp.url());
if self.config.replay_client.is_some() && should_process_response {
// replay proxy specified/client created and this response's status code is one that
// should be replayed
make_request(
self.config.replay_client.as_ref().unwrap(),
&resp.url(),
self.config.output_level,
tx_stats.clone(),
)
.await
.with_context(|| "Could not replay request through replay proxy")?;
}
if should_process_response {
// add response to RESPONSES for serialization in case of ctrl+c
// placed all by its lonesome like this so that RESPONSES can take ownership
// of the FeroxResponse
// before ownership is transferred, there's no real reason to keep the body anymore
// so we can free that piece of data, reducing memory usage
resp.drop_text();
RESPONSES.insert(*resp);
}
} }
Command::Sync(sender) => { Command::Sync(sender) => {
sender.send(true).unwrap_or_default(); sender.send(true).unwrap_or_default();
} }
Command::AddHandles(handles) => {
self.handles = Some(handles);
}
Command::Exit => { Command::Exit => {
if self.file_task.is_some() && self.tx_file.send(Command::Exit).is_ok() { if self.file_task.is_some() && self.tx_file.send(Command::Exit).is_ok() {
self.file_task.as_mut().unwrap().await??; // wait for death self.file_task.as_mut().unwrap().await??; // wait for death
@@ -248,11 +230,198 @@ impl TermOutHandler {
log::trace!("exit: start"); log::trace!("exit: start");
Ok(()) Ok(())
} }
/// upon receiving a `FeroxResponse` from the mpsc, handle printing, sending to the replay
/// proxy, checking for backups of the `FeroxResponse`'s url, and tracking the response.
fn process_response(
&self,
tx_stats: CommandSender,
mut resp: Box<FeroxResponse>,
call_type: ProcessResponseCall,
) -> BoxFuture<'_, Result<()>> {
log::trace!("enter: process_response({:?}, {:?})", resp, call_type);
async move {
let should_filter = self
.handles
.as_ref()
.unwrap()
.filters
.data
.should_filter_response(&resp, tx_stats.clone());
let contains_sentry = if !self.config.filter_status.is_empty() {
// -C was used, meaning -s was not and we should ignore the defaults
// https://github.com/epi052/feroxbuster/issues/535
// -C indicates that we should filter that status code, but allow all others
!self.config.filter_status.contains(&resp.status().as_u16())
} else {
// -C wasn't used, so, we defer to checking the -s values
self.config.status_codes.contains(&resp.status().as_u16())
};
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
let should_process_response = contains_sentry && unknown_sentry && !should_filter;
if should_process_response {
// print to stdout
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
send_command!(tx_stats, AddToUsizeField(ResourcesDiscovered, 1));
if self.file_task.is_some() {
// -o used, need to send the report to be written out to disk
self.tx_file
.send(Command::Report(resp.clone()))
.with_context(|| {
fmt_err(&format!("Could not send {resp} to file handler"))
})?;
}
}
log::trace!("report complete: {}", resp.url());
if self.config.replay_client.is_some() && should_process_response {
// replay proxy specified/client created and this response's status code is one that
// should be replayed; not using logged_request due to replay proxy client
let data = if self.config.data.is_empty() {
None
} else {
Some(self.config.data.as_slice())
};
make_request(
self.config.replay_client.as_ref().unwrap(),
resp.url(),
resp.method().as_str(),
data,
self.config.output_level,
&self.config,
tx_stats.clone(),
)
.await
.with_context(|| "Could not replay request through replay proxy")?;
}
if self.config.collect_backups
&& should_process_response
&& matches!(call_type, ProcessResponseCall::Recursive)
{
// --collect-backups was used; the response is one we care about, and the function
// call came from the loop in `.start` (i.e. recursive was specified)
let backup_urls = self.generate_backup_urls(&resp).await;
// need to manually adjust stats
send_command!(tx_stats, AddToUsizeField(TotalExpected, backup_urls.len()));
for backup_url in &backup_urls {
let backup_response = make_request(
&self.config.client,
backup_url,
resp.method().as_str(),
None,
self.config.output_level,
&self.config,
tx_stats.clone(),
)
.await
.with_context(|| {
format!("Could not request backup of {}", resp.url().as_str())
})?;
let ferox_response = FeroxResponse::from(
backup_response,
resp.url().as_str(),
resp.method().as_str(),
resp.output_level,
)
.await;
self.process_response(
tx_stats.clone(),
Box::new(ferox_response),
ProcessResponseCall::NotRecursive,
)
.await?;
}
}
if should_process_response {
// add response to RESPONSES for serialization in case of ctrl+c
// placed all by its lonesome like this so that RESPONSES can take ownership
// of the FeroxResponse
// before ownership is transferred, there's no real reason to keep the body anymore
// so we can free that piece of data, reducing memory usage
resp.drop_text();
RESPONSES.insert(*resp);
}
log::trace!("exit: process_response");
Ok(())
}
.boxed()
}
/// internal helper to stay DRY
fn add_new_url_to_vec(&self, url: &Url, new_name: &str, urls: &mut Vec<Url>) {
if let Ok(joined) = url.join(new_name) {
urls.push(joined);
}
}
/// given a `FeroxResponse`, generate either 6 or 7 urls that are likely backups of the
/// original.
///
/// example:
/// original: LICENSE.txt
/// backups:
/// - LICENSE.txt~
/// - LICENSE.txt.bak
/// - LICENSE.txt.bak2
/// - LICENSE.txt.old
/// - LICENSE.txt.1
/// - LICENSE.bak
/// - .LICENSE.txt.swp
async fn generate_backup_urls(&self, response: &FeroxResponse) -> Vec<Url> {
log::trace!("enter: generate_backup_urls({:?})", response);
let mut urls = vec![];
let url = response.url();
// confirmed safe: see src/response.rs for comments
let filename = url.path_segments().unwrap().last().unwrap();
if !filename.is_empty() {
// append rules
for suffix in ["~", ".bak", ".bak2", ".old", ".1"] {
self.add_new_url_to_vec(url, &format!("{filename}{suffix}"), &mut urls);
}
// vim swap rule
self.add_new_url_to_vec(url, &format!(".{filename}.swp"), &mut urls);
// replace original extension rule
let parts: Vec<_> = filename
.split('.')
// keep things like /.bash_history out of results
.filter(|part| !part.is_empty())
.collect();
if parts.len() > 1 {
// filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
self.add_new_url_to_vec(url, &format!("{}.bak", parts.first().unwrap()), &mut urls);
}
}
log::trace!("exit: generate_backup_urls -> {:?}", urls);
urls
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::event_handlers::Command;
#[test] #[test]
/// try to hit struct field coverage of FileOutHandler /// try to hit struct field coverage of FileOutHandler
@@ -263,7 +432,7 @@ mod tests {
config, config,
receiver: rx, receiver: rx,
}; };
println!("{:?}", foh); println!("{foh:?}");
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -272,15 +441,149 @@ mod tests {
let (tx, rx) = mpsc::unbounded_channel::<Command>(); let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>(); let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap()); let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler { let toh = TermOutHandler {
config, config,
file_task: None, file_task: None,
receiver: rx, receiver: rx,
tx_file, tx_file,
handles: Some(handles),
}; };
println!("{:?}", toh); println!("{toh:?}");
tx.send(Command::Exit).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// when the feroxresponse's url contains an extension, there should be 7 urls returned
async fn generate_backup_urls_creates_correct_urls_when_extension_present() {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
handles: Some(handles),
};
let expected: Vec<_> = vec![
"derp.php~",
"derp.php.bak",
"derp.php.bak2",
"derp.php.old",
"derp.php.1",
".derp.php.swp",
"derp.bak",
];
let mut fr = FeroxResponse::default();
fr.set_url("http://localhost/derp.php");
let urls = toh.generate_backup_urls(&fr).await;
let paths: Vec<_> = urls
.iter()
.map(|url| url.path_segments().unwrap().last().unwrap())
.collect();
assert_eq!(urls.len(), 7);
for path in paths {
assert!(expected.contains(&path));
}
tx.send(Command::Exit).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// when the feroxresponse's url doesn't contain an extension, there should be 6 urls returned
async fn generate_backup_urls_creates_correct_urls_when_extension_not_present() {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
handles: Some(handles),
};
let expected: Vec<_> = vec![
"derp~",
"derp.bak",
"derp.bak2",
"derp.old",
"derp.1",
".derp.swp",
];
let mut fr = FeroxResponse::default();
fr.set_url("http://localhost/derp");
let urls = toh.generate_backup_urls(&fr).await;
let paths: Vec<_> = urls
.iter()
.map(|url| url.path_segments().unwrap().last().unwrap())
.collect();
assert_eq!(urls.len(), 6);
for path in paths {
assert!(expected.contains(&path));
}
tx.send(Command::Exit).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test to ensure that backups are requested from the directory in which they were found
/// re: issue #513
async fn generate_backup_urls_creates_correct_urls_when_not_at_root() {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
handles: Some(handles),
};
let expected: Vec<_> = vec![
"http://localhost/wordpress/derp.php~",
"http://localhost/wordpress/derp.php.bak",
"http://localhost/wordpress/derp.php.bak2",
"http://localhost/wordpress/derp.php.old",
"http://localhost/wordpress/derp.php.1",
"http://localhost/wordpress/.derp.php.swp",
"http://localhost/wordpress/derp.bak",
];
let mut fr = FeroxResponse::default();
fr.set_url("http://localhost/wordpress/derp.php");
let urls = toh.generate_backup_urls(&fr).await;
let url_strs: Vec<_> = urls.iter().map(|url| url.as_str()).collect();
assert_eq!(urls.len(), 7);
for url_str in url_strs {
assert!(expected.contains(&url_str));
}
tx.send(Command::Exit).unwrap(); tx.send(Command::Exit).unwrap();
} }
} }

View File

@@ -1,20 +1,23 @@
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use tokio::sync::{mpsc, Semaphore}; use tokio::sync::{mpsc, Semaphore};
use crate::response::FeroxResponse;
use crate::url::FeroxUrl;
use crate::{ use crate::{
response::FeroxResponse,
scan_manager::{FeroxScan, FeroxScans, ScanOrder}, scan_manager::{FeroxScan, FeroxScans, ScanOrder},
scanner::FeroxScanner, scanner::{FeroxScanner, RESPONSES},
statistics::StatField::TotalScans, statistics::StatField::TotalScans,
CommandReceiver, CommandSender, FeroxChannel, Joiner, url::FeroxUrl,
utils::should_deny_url,
CommandReceiver, CommandSender, FeroxChannel, Joiner, SLEEP_DURATION,
}; };
use super::command::Command::UpdateUsizeField; use super::command::Command::AddToUsizeField;
use super::*; use super::*;
use crate::statistics::StatField;
use reqwest::Url;
use tokio::time::Duration;
#[derive(Debug)] #[derive(Debug)]
/// Container for recursion transmitter and FeroxScans object /// Container for recursion transmitter and FeroxScans object
@@ -53,7 +56,7 @@ pub struct ScanHandler {
receiver: CommandReceiver, receiver: CommandReceiver,
/// wordlist (re)used for each scan /// wordlist (re)used for each scan
wordlist: std::sync::Mutex<Option<Arc<HashSet<String>>>>, wordlist: std::sync::Mutex<Option<Arc<Vec<String>>>>,
/// group of scans that need to be joined /// group of scans that need to be joined
tasks: Vec<Arc<FeroxScan>>, tasks: Vec<Arc<FeroxScan>>,
@@ -104,7 +107,7 @@ impl ScanHandler {
} }
/// Set the wordlist /// Set the wordlist
fn wordlist(&self, wordlist: Arc<HashSet<String>>) { fn wordlist(&self, wordlist: Arc<Vec<String>>) {
if let Ok(mut guard) = self.wordlist.lock() { if let Ok(mut guard) = self.wordlist.lock() {
if guard.is_none() { if guard.is_none() {
let _ = std::mem::replace(&mut *guard, Some(wordlist)); let _ = std::mem::replace(&mut *guard, Some(wordlist));
@@ -144,6 +147,15 @@ impl ScanHandler {
Command::ScanInitialUrls(targets) => { Command::ScanInitialUrls(targets) => {
self.ordered_scan_url(targets, ScanOrder::Initial).await?; self.ordered_scan_url(targets, ScanOrder::Initial).await?;
} }
Command::ScanNewUrl(target) => {
// added as part of interactive menu ability (2.4.1) to add a new scan.
// we don't have a way of knowing if they're adding a new url entirely (i.e.
// new base url), or simply adding a new sub-directory found some other way.
// Since we can't know, we'll start a scan as though we received the scan
// from -u | --stdin
self.ordered_scan_url(vec![target], ScanOrder::Initial)
.await?;
}
Command::UpdateWordlist(wordlist) => { Command::UpdateWordlist(wordlist) => {
self.wordlist(wordlist); self.wordlist(wordlist);
} }
@@ -153,9 +165,7 @@ impl ScanHandler {
tokio::spawn(async move { tokio::spawn(async move {
while ferox_scans.has_active_scans() { while ferox_scans.has_active_scans() {
for scan in ferox_scans.get_active_scans() { tokio::time::sleep(Duration::from_millis(SLEEP_DURATION + 250)).await;
scan.join().await;
}
} }
limiter_clone.close(); limiter_clone.close();
sender.send(true).expect("oneshot channel failed"); sender.send(true).expect("oneshot channel failed");
@@ -167,6 +177,23 @@ impl ScanHandler {
Command::Sync(sender) => { Command::Sync(sender) => {
sender.send(true).unwrap_or_default(); sender.send(true).unwrap_or_default();
} }
Command::AddDiscoveredExtension(new_extension) => {
// if --collect-extensions was used, AND the new extension isn't in
// the --dont-collect list AND it's also not in the --extensions list, AND
// we actually added a new extension (i.e. wasn't previously known), add
// it to FeroxScans.collected_extensions
if self.handles.config.collect_extensions
&& !self.handles.config.dont_collect.contains(&new_extension)
&& !self.handles.config.extensions.contains(&new_extension)
&& self.data.add_discovered_extension(new_extension)
{
self.update_all_bar_lengths()?;
self.handles
.stats
.send(Command::AddToUsizeField(StatField::ExtensionsCollected, 1))
.unwrap_or_default();
}
}
_ => {} // no other commands needed for RecursionHandler _ => {} // no other commands needed for RecursionHandler
} }
} }
@@ -175,11 +202,107 @@ impl ScanHandler {
Ok(()) Ok(())
} }
/// update all current and future bar lengths
///
/// updating all bar lengths correctly requires a few different actions on our part.
/// - get the current number of requests expected per scan (dynamic when --collect-extensions
/// is used)
/// - update the overall progress bar via the statistics handler (total expected)
/// - update the expected per scan value tracked in the statistics handler
/// - update progress bars on each FeroxScan (type::directory) that are running/not-started
/// - update progress bar length on FeroxScans (this is used when creating new a FeroxScan and
/// determines the new scan's progress bar length)
fn update_all_bar_lengths(&self) -> Result<()> {
log::trace!("enter: update_all_bar_lengths");
// current number of requests expected per scan
// ExpectedPerScan and TotalExpected are a += action, so we need the wordlist length to
// update them while the other updates use expected_num_requests_per_dir
let num_words = self.get_wordlist(0)?.len();
let current_expectation = self.handles.expected_num_requests_per_dir() as u64;
// used in the calculation of bar width down below, see explanation there
let divisor = self.handles.expected_num_requests_multiplier() as u64 - 1;
// add another `wordlist.len` to the expected per scan tracker in the statistics handler
self.handles
.stats
.send(AddToUsizeField(StatField::ExpectedPerScan, num_words))?;
// since we're adding extensions in the middle of scans (potentially), we need to take
// current number of requests into account, new_total will be used as an accumulator
// used to increment the overall progress bar
let mut new_total = 0;
if let Ok(ferox_scans) = self.handles.ferox_scans() {
// update progress bar length on FeroxScans, which used when creating a new FeroxScan's
// progress bar and should mirror the expected_per_scan field on Statistics
ferox_scans.set_bar_length(current_expectation);
if let Ok(scans_guard) = ferox_scans.scans.read() {
// update progress bars on each FeroxScan where its scan type is directory and
// scan status is either running or not-started
for scan in scans_guard.iter() {
if scan.is_active() {
// current number of words left in the 'to-scan' bin, for example:
//
// say we have a 2000 word wordlist, have `-x js` on the command line, and
// just found `php` as a new extension
//
// that puts our state at:
// - wordlist length: 2000
// - total expected: 4000 (original length * 2 for -x js)
//
// let's assume the current scan has sent 3000 requests so far
// that means to get the number of `words` left to send, we need to take
// the difference of 4000 and 3000 and then divide that by the current
// multiplier (2 in the example)
//
// (4000 - 3000) / 2 => 500 words left to send
//
// the remaining 500 words will be sent as 3 variations (word, word.js,
// word.php). So, we would then need to increment the bar by 500 to
// reflect the dynamism of adding extensions mid-scan.
let bar = scan.progress_bar();
// (4000 - 3000) / 2 => 500 words left to send
let length = bar.length();
let num_words_left = (length - bar.position()) / divisor;
// accumulate each bar's increment value for incrementing the total bar
new_total += num_words_left;
bar.inc_length(num_words_left);
}
}
}
// add the total number of newly expected requests to the overall progress bar
// via the statistics handler
self.handles.stats.send(AddToUsizeField(
StatField::TotalExpected,
new_total as usize,
))?;
}
log::trace!("exit: update_all_bar_lengths");
Ok(())
}
/// Helper to easily get the (locked) underlying wordlist /// Helper to easily get the (locked) underlying wordlist
pub fn get_wordlist(&self) -> Result<Arc<HashSet<String>>> { pub fn get_wordlist(&self, offset: usize) -> Result<Arc<Vec<String>>> {
if let Ok(guard) = self.wordlist.lock().as_ref() { if let Ok(guard) = self.wordlist.lock().as_ref() {
if let Some(list) = guard.as_ref() { if let Some(list) = guard.as_ref() {
return Ok(list.clone()); return if offset > 0 {
// the offset could be off a bit, so we'll adjust it backwards by 10%
// of the overall wordlist size to ensure we don't miss any words
// (hopefully)
let adjusted_offset = offset - ((offset as f64 * 0.10) as usize);
Ok(Arc::new(list[adjusted_offset..].to_vec()))
} else {
Ok(list.clone())
};
} }
} }
@@ -189,6 +312,8 @@ impl ScanHandler {
/// wrapper around scanning a url to stay DRY /// wrapper around scanning a url to stay DRY
async fn ordered_scan_url(&mut self, targets: Vec<String>, order: ScanOrder) -> Result<()> { async fn ordered_scan_url(&mut self, targets: Vec<String>, order: ScanOrder) -> Result<()> {
log::trace!("enter: ordered_scan_url({:?}, {:?})", targets, order); log::trace!("enter: ordered_scan_url({:?}, {:?})", targets, order);
let should_test_deny = !self.handles.config.url_denylist.is_empty()
|| !self.handles.config.regex_denylist.is_empty();
for target in targets { for target in targets {
if self.data.contains(&target) && matches!(order, ScanOrder::Latest) { if self.data.contains(&target) && matches!(order, ScanOrder::Latest) {
@@ -205,7 +330,14 @@ impl ScanHandler {
self.data.add_directory_scan(&target, order).1 // add the new target; return FeroxScan self.data.add_directory_scan(&target, order).1 // add the new target; return FeroxScan
}; };
let list = self.get_wordlist()?; if should_test_deny && should_deny_url(&Url::parse(&target)?, self.handles.clone())? {
// response was caught by a user-provided deny list
// checking this last, since it's most susceptible to longer runtimes due to what
// input is received
continue;
}
let list = self.get_wordlist(scan.requests() as usize)?;
log::info!("scan handler received {} - beginning scan", target); log::info!("scan handler received {} - beginning scan", target);
@@ -231,7 +363,7 @@ impl ScanHandler {
} }
}); });
self.handles.stats.send(UpdateUsizeField(TotalScans, 1))?; self.handles.stats.send(AddToUsizeField(TotalScans, 1))?;
scan.set_task(task).await?; scan.set_task(task).await?;
@@ -245,6 +377,11 @@ impl ScanHandler {
async fn try_recursion(&mut self, response: Box<FeroxResponse>) -> Result<()> { async fn try_recursion(&mut self, response: Box<FeroxResponse>) -> Result<()> {
log::trace!("enter: try_recursion({:?})", response,); log::trace!("enter: try_recursion({:?})", response,);
if !self.handles.config.force_recursion && !response.is_directory() {
// not a directory and --force-recursion wasn't used, quick exit
return Ok(());
}
let mut base_depth = 1_usize; let mut base_depth = 1_usize;
for (base_url, base_url_depth) in &self.depths { for (base_url, base_url_depth) in &self.depths {
@@ -258,9 +395,56 @@ impl ScanHandler {
return Ok(()); return Ok(());
} }
if !response.is_directory() { if let Ok(responses) = RESPONSES.responses.read() {
// not a directory for maybe_wild in responses.iter() {
return Ok(()); if !maybe_wild.wildcard() || !maybe_wild.is_directory() {
// if the stored response isn't a wildcard, skip it
// if the stored response isn't a directory, skip it
// we're only interested in preventing recursion into wildcard directories
continue;
}
if maybe_wild.method() != response.method() {
// methods don't match, skip it
continue;
}
// methods match and is a directory wildcard
// need to check the wildcard's parent directory
// for equality with the incoming response's parent directory
//
// if the parent directories match, we need to prevent recursion
// into the wildcard directory
match (
maybe_wild.url().path_segments(),
response.url().path_segments(),
) {
// both urls must have path segments
(Some(mut maybe_wild_segments), Some(mut response_segments)) => {
match (
maybe_wild_segments.nth_back(1),
response_segments.nth_back(1),
) {
// both urls must have at least 2 path segments, the next to last being the parent
(Some(maybe_wild_parent), Some(response_parent)) => {
if maybe_wild_parent == response_parent {
// the parent directories match, so we need to prevent recursion
return Ok(());
}
}
_ => {
// we couldn't get the parent directory, so we'll skip this
continue;
}
}
}
_ => {
// we couldn't get the path segments, so we'll skip this
continue;
}
}
}
} }
let targets = vec![response.url().to_string()]; let targets = vec![response.url().to_string()];

View File

@@ -89,6 +89,7 @@ impl StatsHandler {
} }
Command::AddStatus(status) => { Command::AddStatus(status) => {
self.stats.add_status_code(status); self.stats.add_status_code(status);
self.increment_bar(); self.increment_bar();
} }
Command::AddRequest => { Command::AddRequest => {
@@ -99,16 +100,24 @@ impl StatsHandler {
self.stats self.stats
.save(start.elapsed().as_secs_f64(), output_file)?; .save(start.elapsed().as_secs_f64(), output_file)?;
} }
Command::UpdateUsizeField(field, value) => { Command::AddToUsizeField(field, value) => {
self.stats.update_usize_field(field, value); self.stats.update_usize_field(field, value);
if matches!(field, StatField::TotalScans) { if matches!(field, StatField::TotalScans | StatField::TotalExpected) {
self.bar.set_length(self.stats.total_expected() as u64); self.bar.set_length(self.stats.total_expected() as u64);
} }
} }
Command::UpdateF64Field(field, value) => self.stats.update_f64_field(field, value), Command::SubtractFromUsizeField(field, value) => {
Command::CreateBar => { self.stats.subtract_from_usize_field(field, value);
if matches!(field, StatField::TotalExpected) {
self.bar.set_length(self.stats.total_expected() as u64);
}
}
Command::AddToF64Field(field, value) => self.stats.update_f64_field(field, value),
Command::CreateBar(offset) => {
self.bar = add_bar("", self.stats.total_expected() as u64, BarType::Total); self.bar = add_bar("", self.stats.total_expected() as u64, BarType::Total);
self.bar.set_position(offset);
} }
Command::LoadStats(filename) => { Command::LoadStats(filename) => {
self.stats.merge_from(&filename)?; self.stats.merge_from(&filename)?;
@@ -147,7 +156,7 @@ impl StatsHandler {
pub fn initialize(config: Arc<Configuration>) -> (Joiner, StatsHandle) { pub fn initialize(config: Arc<Configuration>) -> (Joiner, StatsHandle) {
log::trace!("enter: initialize"); log::trace!("enter: initialize");
let data = Arc::new(Stats::new(config.extensions.len(), config.json)); let data = Arc::new(Stats::new(config.json));
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel(); let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
let mut handler = StatsHandler::new(data.clone(), rx); let mut handler = StatsHandler::new(data.clone(), rx);

View File

@@ -13,14 +13,22 @@ pub(super) const LINKFINDER_REGEX: &str = r#"(?:"|')(((?:[a-zA-Z]{1,10}://|//)[^
pub(super) const ROBOTS_TXT_REGEX: &str = pub(super) const ROBOTS_TXT_REGEX: &str =
r#"(?m)^ *(Allow|Disallow): *(?P<url_path>[a-zA-Z0-9._/?#@!&'()+,;%=-]+?)$"#; // multi-line (?m) r#"(?m)^ *(Allow|Disallow): *(?P<url_path>[a-zA-Z0-9._/?#@!&'()+,;%=-]+?)$"#; // multi-line (?m)
/// Regular expression to filter bad characters from extracted url paths
///
/// ref: https://www.rfc-editor.org/rfc/rfc3986#section-2
pub(super) const URL_CHARS_REGEX: &str = r#"["<>\\^`{|} ]"#;
/// Which type of extraction should be performed /// Which type of extraction should be performed
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
pub enum ExtractionTarget { pub enum ExtractionTarget {
/// Examine a response body and extract links /// Examine a response body and extract javascript and html links (multiple tags)
ResponseBody, ResponseBody,
/// Examine robots.txt (specifically) and extract links /// Examine robots.txt (specifically) and extract links
RobotsTxt, RobotsTxt,
/// Extract all <a> tags from a page
DirectoryListing,
} }
/// responsible for building an `Extractor` /// responsible for building an `Extractor`
@@ -28,7 +36,7 @@ pub struct ExtractorBuilder<'a> {
/// Response from which to extract links /// Response from which to extract links
response: Option<&'a FeroxResponse>, response: Option<&'a FeroxResponse>,
/// Response from which to extract links /// URL of where to extract links
url: String, url: String,
/// Handles object to house the underlying mpsc transmitters /// Handles object to house the underlying mpsc transmitters
@@ -76,9 +84,9 @@ impl<'a> ExtractorBuilder<'a> {
self self
} }
/// finalize configuration of ExtratorBuilder and return an Extractor /// finalize configuration of `ExtractorBuilder` and return an `Extractor`
/// ///
/// requires either with_url or with_response to have been used in the build process /// requires either `with_url` or `with_response` to have been used in the build process
pub fn build(&self) -> Result<Extractor<'a>> { pub fn build(&self) -> Result<Extractor<'a>> {
if (self.url.is_empty() && self.response.is_none()) || self.handles.is_none() { if (self.url.is_empty() && self.response.is_none()) || self.handles.is_none() {
bail!("Extractor requires a URL or a FeroxResponse be specified as well as a Handles object") bail!("Extractor requires a URL or a FeroxResponse be specified as well as a Handles object")
@@ -87,6 +95,7 @@ impl<'a> ExtractorBuilder<'a> {
Ok(Extractor { Ok(Extractor {
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(), links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(), robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
url_regex: Regex::new(URL_CHARS_REGEX).unwrap(),
response: if self.response.is_some() { response: if self.response.is_some() {
Some(self.response.unwrap()) Some(self.response.unwrap())
} else { } else {

View File

@@ -2,8 +2,7 @@ use super::*;
use crate::{ use crate::{
client, client,
event_handlers::{ event_handlers::{
Command, Command::{AddError, AddToUsizeField},
Command::{AddError, UpdateUsizeField},
Handles, Handles,
}, },
scan_manager::ScanOrder, scan_manager::ScanOrder,
@@ -12,12 +11,13 @@ use crate::{
StatField::{LinksExtracted, TotalExpected}, StatField::{LinksExtracted, TotalExpected},
}, },
url::FeroxUrl, url::FeroxUrl,
utils::make_request, utils::{logged_request, make_request, send_try_recursion_command, should_deny_url},
ExtractionResult, DEFAULT_METHOD,
}; };
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use reqwest::{StatusCode, Url}; use reqwest::{Client, StatusCode, Url};
use std::collections::HashSet; use scraper::{Html, Selector};
use tokio::sync::oneshot; use std::{borrow::Cow, collections::HashSet};
/// Whether an active scan is recursive or not /// Whether an active scan is recursive or not
#[derive(Debug)] #[derive(Debug)]
@@ -38,10 +38,13 @@ pub struct Extractor<'a> {
/// `ROBOTS_TXT_REGEX` as a regex::Regex type /// `ROBOTS_TXT_REGEX` as a regex::Regex type
pub(super) robots_regex: Regex, pub(super) robots_regex: Regex,
/// regex to validate a url
pub(super) url_regex: Regex,
/// Response from which to extract links /// Response from which to extract links
pub(super) response: Option<&'a FeroxResponse>, pub(super) response: Option<&'a FeroxResponse>,
/// Response from which to extract links /// URL of where to extract links
pub(super) url: String, pub(super) url: String,
/// Handles object to house the underlying mpsc transmitters /// Handles object to house the underlying mpsc transmitters
@@ -53,12 +56,77 @@ pub struct Extractor<'a> {
/// Extractor implementation /// Extractor implementation
impl<'a> Extractor<'a> { impl<'a> Extractor<'a> {
/// business logic that handles getting links from a normal http body response /// perform extraction from the given target and return any links found
pub async fn extract(&self) -> Result<()> { pub async fn extract(&self) -> Result<ExtractionResult> {
let links = match self.target { log::trace!(
ExtractionTarget::ResponseBody => self.extract_from_body().await?, "enter: extract({:?}) (this fn has no associated trace exit msg)",
ExtractionTarget::RobotsTxt => self.extract_from_robots().await?, self.target
}; );
match self.target {
ExtractionTarget::ResponseBody => Ok(self.extract_from_body().await?),
ExtractionTarget::RobotsTxt => Ok(self.extract_from_robots().await?),
ExtractionTarget::DirectoryListing => Ok(self.extract_from_dir_listing().await?),
}
}
/// wrapper around logic that performs the following:
/// - parses `url_to_parse`
/// - bails if the parsed url doesn't belong to the original host/domain
/// - otherwise, calls `add_all_sub_paths` with the parsed result
fn parse_url_and_add_subpaths(
&self,
url_to_parse: &str,
original_url: &Url,
links: &mut HashSet<String>,
) -> Result<()> {
log::trace!("enter: parse_url_and_add_subpaths({:?})", links);
match Url::parse(url_to_parse) {
Ok(absolute) => {
if absolute.domain() != original_url.domain()
|| absolute.host() != original_url.host()
{
// domains/ips are not the same, don't scan things that aren't part of the original
// target url
bail!("parsed url does not belong to original domain/host");
}
if self.add_all_sub_paths(absolute.path(), links).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", absolute, links);
}
}
Err(e) => {
// this is the expected error that happens when we try to parse a url fragment
// ex: Url::parse("/login") -> Err("relative URL without a base")
// while this is technically an error, these are good results for us
if e.to_string().contains("relative URL without a base") {
if self.add_all_sub_paths(url_to_parse, links).is_err() {
log::warn!(
"could not add sub-paths from {} to {:?}",
url_to_parse,
links
);
}
} else {
// unexpected error has occurred
log::warn!("Could not parse given url: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
}
}
}
log::trace!("exit: parse_url_and_add_subpaths");
Ok(())
}
/// given a set of links from a normal http body response, task the request handler to make
/// the requests
pub async fn request_links(&mut self, links: HashSet<String>) -> Result<()> {
log::trace!("enter: request_links({:?})", links);
if links.is_empty() {
return Ok(());
}
let recursive = if self.handles.config.no_recursion { let recursive = if self.handles.config.no_recursion {
RecursionStatus::NotRecursive RecursionStatus::NotRecursive
@@ -67,6 +135,7 @@ impl<'a> Extractor<'a> {
}; };
let scanned_urls = self.handles.ferox_scans()?; let scanned_urls = self.handles.ferox_scans()?;
self.update_stats(links.len())?;
for link in links { for link in links {
let mut resp = match self.request_link(&link).await { let mut resp = match self.request_link(&link).await {
@@ -84,11 +153,15 @@ impl<'a> Extractor<'a> {
continue; continue;
} }
if resp.is_file() { // request and report assumed file
// very likely a file, simply request and report if resp.is_file() || !resp.is_directory() {
log::debug!("Extracted file: {}", resp); log::debug!("Extracted File: {}", resp);
scanned_urls.add_file_scan(&resp.url().to_string(), ScanOrder::Latest); scanned_urls.add_file_scan(resp.url().as_str(), ScanOrder::Latest);
if self.handles.config.collect_extensions {
resp.parse_extension(self.handles.clone())?;
}
if let Err(e) = resp.send_report(self.handles.output.tx.clone()) { if let Err(e) = resp.send_report(self.handles.output.tx.clone()) {
log::warn!("Could not send FeroxResponse to output handler: {}", e); log::warn!("Could not send FeroxResponse to output handler: {}", e);
@@ -114,18 +187,47 @@ impl<'a> Extractor<'a> {
resp.set_url(&format!("{}/", resp.url())); resp.set_url(&format!("{}/", resp.url()));
} }
self.handles if self.handles.config.filter_status.is_empty() {
.send_scan_command(Command::TryRecursion(Box::new(resp)))?; // -C wasn't used, so -s is the only 'filter' left to account for
let (tx, rx) = oneshot::channel::<bool>(); if self
self.handles.send_scan_command(Command::Sync(tx))?; .handles
rx.await?; .config
.status_codes
.contains(&resp.status().as_u16())
{
send_try_recursion_command(self.handles.clone(), resp).await?;
}
} else {
// -C was used, that means the filters above would have removed
// those responses, and anything else should be let through
send_try_recursion_command(self.handles.clone(), resp).await?;
}
} }
} }
log::trace!("exit: request_links");
Ok(()) Ok(())
} }
/// Given a `reqwest::Response`, perform the following actions /// wrapper around link extraction via html attributes
/// - parse the response's text for links using the linkfinder regex fn extract_all_links_from_html_tags(
&self,
resp_url: &Url,
links: &mut HashSet<String>,
html: &Html,
) {
self.extract_links_by_attr(resp_url, links, html, "a", "href");
self.extract_links_by_attr(resp_url, links, html, "img", "src");
self.extract_links_by_attr(resp_url, links, html, "form", "action");
self.extract_links_by_attr(resp_url, links, html, "script", "src");
self.extract_links_by_attr(resp_url, links, html, "iframe", "src");
self.extract_links_by_attr(resp_url, links, html, "div", "src");
self.extract_links_by_attr(resp_url, links, html, "frame", "src");
self.extract_links_by_attr(resp_url, links, html, "embed", "src");
self.extract_links_by_attr(resp_url, links, html, "link", "href");
}
/// Given the body of a `reqwest::Response`, perform the following actions
/// - parse the body for links using the linkfinder regex
/// - for every link found take its url path and parse each sub-path /// - for every link found take its url path and parse each sub-path
/// - example: Response contains a link fragment `homepage/assets/img/icons/handshake.svg` /// - example: Response contains a link fragment `homepage/assets/img/icons/handshake.svg`
/// with a base url of http://localhost, the following urls would be returned: /// with a base url of http://localhost, the following urls would be returned:
@@ -134,73 +236,90 @@ impl<'a> Extractor<'a> {
/// - homepage/assets/img/ /// - homepage/assets/img/
/// - homepage/assets/ /// - homepage/assets/
/// - homepage/ /// - homepage/
pub(super) async fn extract_from_body(&self) -> Result<HashSet<String>> { fn extract_all_links_from_javascript(
log::trace!("enter: get_links"); &self,
response_body: &str,
response_url: &Url,
links: &mut HashSet<String>,
) {
log::trace!(
"enter: extract_all_links_from_javascript(html body..., {}, {:?})",
response_url.as_str(),
links
);
let mut links = HashSet::<String>::new(); for capture in self.links_regex.captures_iter(response_body) {
let body = self.response.unwrap().text();
for capture in self.links_regex.captures_iter(&body) {
// remove single & double quotes from both ends of the capture // remove single & double quotes from both ends of the capture
// capture[0] is the entire match, additional capture groups start at [1] // capture[0] is the entire match, additional capture groups start at [1]
let link = capture[0].trim_matches(|c| c == '\'' || c == '"'); let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
match Url::parse(link) { if self
Ok(absolute) => { .parse_url_and_add_subpaths(link, response_url, links)
if absolute.domain() != self.response.unwrap().url().domain() .is_err()
|| absolute.host() != self.response.unwrap().url().host() {
{ // purposely not logging the error here, due to the frequency with which it gets hit
// domains/ips are not the same, don't scan things that aren't part of the original
// target url
continue;
}
if self.add_all_sub_paths(absolute.path(), &mut links).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", absolute, links);
}
}
Err(e) => {
// this is the expected error that happens when we try to parse a url fragment
// ex: Url::parse("/login") -> Err("relative URL without a base")
// while this is technically an error, these are good results for us
if e.to_string().contains("relative URL without a base") {
if self.add_all_sub_paths(link, &mut links).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", link, links);
}
} else {
// unexpected error has occurred
log::warn!("Could not parse given url: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
}
}
} }
} }
self.update_stats(links.len())?; log::trace!("exit: extract_all_links_from_javascript");
log::trace!("exit: get_links -> {:?}", links);
Ok(links)
} }
/// take a url fragment like homepage/assets/img/icons/handshake.svg and /// take a url fragment like homepage/assets/img/icons/handshake.svg and
/// incrementally add /// incrementally add
/// - homepage/assets/img/icons/ /// - homepage/assets/img/icons/
/// - homepage/assets/img/ /// - homepage/assets/img/
/// - homepage/assets/ /// - homepage/assets/
/// - homepage/ /// - homepage/
fn add_all_sub_paths(&self, url_path: &str, mut links: &mut HashSet<String>) -> Result<()> { fn add_all_sub_paths(&self, url_path: &str, links: &mut HashSet<String>) -> Result<()> {
log::trace!("enter: add_all_sub_paths({}, {:?})", url_path, links); log::trace!("enter: add_all_sub_paths({}, {:?})", url_path, links);
for sub_path in self.get_sub_paths_from_path(url_path) { for sub_path in self.get_sub_paths_from_path(url_path) {
self.add_link_to_set_of_links(&sub_path, &mut links)?; self.add_link_to_set_of_links(&sub_path, links)?;
} }
log::trace!("exit: add_all_sub_paths"); log::trace!("exit: add_all_sub_paths");
Ok(()) Ok(())
} }
/// given a url path, trim whitespace, remove slashes, and queries/fragments; return the
/// normalized string
pub(super) fn normalize_url_path(&self, path: &str) -> String {
log::trace!("enter: normalize_url_path({})", path);
// remove whitespace and leading '/'
let path_str: String = path
.trim()
.trim_start_matches('/')
.chars()
.filter(|char| !char.is_whitespace())
.collect();
// snippets from rfc-3986:
//
// foo://example.com:8042/over/there?name=ferret#nose
// \_/ \______________/\_________/ \_________/ \__/
// | | | | |
// scheme authority path query fragment
//
// The path component is terminated
// by the first question mark ("?") or number sign ("#") character, or
// by the end of the URI.
//
// The query component is indicated by the first question
// mark ("?") character and terminated by a number sign ("#") character
// or by the end of the URI.
let (path_str, _discarded) = path_str
.split_once('?')
// if there isn't a '?', try to remove a fragment
.unwrap_or_else(|| {
// if there isn't a '#', return (original, empty)
path_str.split_once('#').unwrap_or((&path_str, ""))
});
log::trace!("exit: normalize_url_path -> {}", path_str);
path_str.into()
}
/// Iterate over a given path, return a list of every sub-path found /// Iterate over a given path, return a list of every sub-path found
/// ///
/// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg` /// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg`
@@ -214,8 +333,14 @@ impl<'a> Extractor<'a> {
log::trace!("enter: get_sub_paths_from_path({})", path); log::trace!("enter: get_sub_paths_from_path({})", path);
let mut paths = vec![]; let mut paths = vec![];
let normalized_path = self.normalize_url_path(path);
// filter out any empty strings caused by .split // filter out any empty strings caused by .split
let mut parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let mut parts: Vec<Cow<_>> = normalized_path
.split('/')
.map(|s| self.url_regex.replace_all(s, ""))
.filter(|s| !s.is_empty())
.collect();
let length = parts.len(); let length = parts.len();
@@ -237,7 +362,7 @@ impl<'a> Extractor<'a> {
// this isn't the last index of the parts array // this isn't the last index of the parts array
// ex: /buried/misc/stupidfile.php // ex: /buried/misc/stupidfile.php
// this block skips the file but sees all parent folders // this block skips the file but sees all parent folders
possible_path = format!("{}/", possible_path); possible_path = format!("{possible_path}/");
} }
paths.push(possible_path); // good sub-path found paths.push(possible_path); // good sub-path found
@@ -248,7 +373,7 @@ impl<'a> Extractor<'a> {
paths paths
} }
/// simple helper to stay DRY, trys to join a url + fragment and add it to the `links` HashSet /// simple helper to stay DRY, tries to join a url + fragment and add it to the `links` HashSet
pub(super) fn add_link_to_set_of_links( pub(super) fn add_link_to_set_of_links(
&self, &self,
link: &str, link: &str,
@@ -257,7 +382,9 @@ impl<'a> Extractor<'a> {
log::trace!("enter: add_link_to_set_of_links({}, {:?})", link, links); log::trace!("enter: add_link_to_set_of_links({}, {:?})", link, links);
let old_url = match self.target { let old_url = match self.target {
ExtractionTarget::ResponseBody => self.response.unwrap().url().clone(), ExtractionTarget::ResponseBody | ExtractionTarget::DirectoryListing => {
self.response.unwrap().url().clone()
}
ExtractionTarget::RobotsTxt => match Url::parse(&self.url) { ExtractionTarget::RobotsTxt => match Url::parse(&self.url) {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
@@ -267,8 +394,19 @@ impl<'a> Extractor<'a> {
}; };
let new_url = old_url let new_url = old_url
.join(&link) .join(link)
.with_context(|| format!("Could not join {} with {}", old_url, link))?; .with_context(|| format!("Could not join {old_url} with {link}"))?;
if old_url.domain() != new_url.domain() || old_url.host() != new_url.host() {
// domains/ips are not the same, don't scan things that aren't part of the original
// target url
log::debug!(
"Skipping {} because it's not part of the original target",
new_url
);
log::trace!("exit: add_link_to_set_of_links");
return Ok(());
}
links.insert(new_url.to_string()); links.insert(new_url.to_string());
@@ -278,41 +416,49 @@ impl<'a> Extractor<'a> {
} }
/// Wrapper around link extraction logic /// Wrapper around link extraction logic
/// currently used in two places:
/// - links from response bodies
/// - links from robots.txt responses
///
/// general steps taken:
/// - create a new Url object based on cli options/args /// - create a new Url object based on cli options/args
/// - check if the new Url has already been seen/scanned -> None /// - check if the new Url has already been seen/scanned -> None
/// - make a request to the new Url ? -> Some(response) : None /// - make a request to the new Url ? -> Some(response) : None
pub(super) async fn request_link(&self, url: &str) -> Result<FeroxResponse> { pub(super) async fn request_link(&self, url: &str) -> Result<FeroxResponse> {
log::trace!("enter: request_link({})", url); log::trace!("enter: request_link({})", url);
let ferox_url = FeroxUrl::from_string(&url, self.handles.clone()); let ferox_url = FeroxUrl::from_string(url, self.handles.clone());
// create a url based on the given command line options // create a url based on the given command line options
let new_url = ferox_url.format(&"", None)?; let new_url = ferox_url.format("", None)?;
let scanned_urls = self.handles.ferox_scans()?; let scanned_urls = self.handles.ferox_scans()?;
if scanned_urls.get_scan_by_url(&new_url.to_string()).is_some() { if scanned_urls.get_scan_by_url(new_url.as_ref()).is_some() {
//we've seen the url before and don't need to scan again //we've seen the url before and don't need to scan again
log::trace!("exit: request_link -> None"); log::trace!("exit: request_link -> None");
bail!("previously seen url"); bail!("previously seen url");
} }
// make the request and store the response if (!self.handles.config.url_denylist.is_empty()
let new_response = make_request( || !self.handles.config.regex_denylist.is_empty())
&self.handles.config.client, && should_deny_url(&new_url, self.handles.clone())?
&new_url, {
self.handles.config.output_level, // can't allow a denied url to be requested
self.handles.stats.tx.clone(), bail!(
) "prevented request to {} due to {:?} || {:?}",
.await?; url,
self.handles.config.url_denylist,
self.handles.config.regex_denylist,
);
}
let new_ferox_response = // make the request and store the response
FeroxResponse::from(new_response, true, self.handles.config.output_level).await; let new_response =
logged_request(&new_url, DEFAULT_METHOD, None, self.handles.clone()).await?;
let new_ferox_response = FeroxResponse::from(
new_response,
url,
DEFAULT_METHOD,
self.handles.config.output_level,
)
.await;
log::trace!("exit: request_link -> {:?}", new_ferox_response); log::trace!("exit: request_link -> {:?}", new_ferox_response);
@@ -327,87 +473,185 @@ impl<'a> Extractor<'a> {
/// http://localhost/stuff/things /// http://localhost/stuff/things
/// this function requests: /// this function requests:
/// http://localhost/robots.txt /// http://localhost/robots.txt
pub(super) async fn extract_from_robots(&self) -> Result<HashSet<String>> { pub(super) async fn extract_from_robots(&self) -> Result<ExtractionResult> {
log::trace!("enter: extract_robots_txt"); log::trace!("enter: extract_robots_txt");
let mut links: HashSet<String> = HashSet::new(); let mut result: HashSet<_> = ExtractionResult::new();
let response = self.request_robots_txt().await?; // request
let response = self.make_extract_request("/robots.txt").await?;
let body = response.text();
for capture in self.robots_regex.captures_iter(response.text()) { for capture in self.robots_regex.captures_iter(body) {
if let Some(new_path) = capture.name("url_path") { if let Some(new_path) = capture.name("url_path") {
let mut new_url = Url::parse(&self.url)?; let mut new_url = Url::parse(&self.url)?;
new_url.set_path(new_path.as_str()); new_url.set_path(new_path.as_str());
if self.add_all_sub_paths(&new_url.path(), &mut links).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", new_url, links); if self.add_all_sub_paths(new_url.path(), &mut result).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", new_url, result);
} }
} }
} }
self.update_stats(links.len())?; log::trace!("exit: extract_robots_txt -> {:?}", result);
Ok(result)
log::trace!("exit: extract_robots_txt -> {:?}", links);
Ok(links)
} }
/// helper function that simply requests /robots.txt on the given url's base url /// outer-most wrapper for parsing html response bodies in search of additional content.
/// performs the following high-level steps:
/// - requests the page, if necessary
/// - checks the page to see if directory listing is enabled and sucks up all the links, if so
/// - uses the linkfinder regex to grab links from embedded javascript/javascript files
/// - extracts many different types of link sources from the html itself
pub(super) async fn extract_from_body(&self) -> Result<ExtractionResult> {
log::trace!("enter: extract_from_body");
let mut result = ExtractionResult::new();
let response = self.response.unwrap();
let resp_url = response.url();
let body = response.text();
let html = Html::parse_document(body);
// extract links from html tags/attributes and embedded javascript
self.extract_all_links_from_html_tags(resp_url, &mut result, &html);
self.extract_all_links_from_javascript(body, resp_url, &mut result);
log::trace!("exit: extract_from_body -> {:?}", result);
Ok(result)
}
/// parses html response bodies in search of <a> tags.
///
/// the assumption is that directory listing is turned on and this extraction target simply
/// scoops up all the links for the given directory. The test to detect a directory listing
/// is located in `HeuristicTests`
pub async fn extract_from_dir_listing(&self) -> Result<ExtractionResult> {
log::trace!("enter: extract_from_dir_listing");
let mut result = ExtractionResult::new();
let response = self.response.unwrap();
let html = Html::parse_document(response.text());
self.extract_links_by_attr(response.url(), &mut result, &html, "a", "href");
log::trace!("exit: extract_from_dir_listing -> {:?}", result);
Ok(result)
}
/// simple helper to get html links by tag/attribute and add it to the `links` HashSet
fn extract_links_by_attr(
&self,
resp_url: &Url,
links: &mut HashSet<String>,
html: &Html,
html_tag: &str,
html_attr: &str,
) {
log::trace!("enter: extract_links_by_attr");
let selector = Selector::parse(html_tag).unwrap();
let tags = html
.select(&selector)
.filter(|a| a.value().attrs().any(|attr| attr.0 == html_attr));
for tag in tags {
if let Some(link) = tag.value().attr(html_attr) {
log::debug!("Parsed link \"{}\" from {}", link, resp_url.as_str());
if self
.parse_url_and_add_subpaths(link, resp_url, links)
.is_err()
{
log::debug!("link didn't belong to the target domain/host: {}", link);
}
}
}
log::trace!("exit: extract_links_by_attr");
}
/// helper function that simply requests at <location> on the given url's base url
/// ///
/// example: /// example:
/// http://localhost/api/users -> http://localhost/robots.txt /// http://localhost/api/users -> http://localhost/<location>
/// pub(super) async fn make_extract_request(&self, location: &str) -> Result<FeroxResponse> {
/// The length of the given path has no effect on what's requested; it's always log::trace!("enter: make_extract_request");
/// base url + /robots.txt
pub(super) async fn request_robots_txt(&self) -> Result<FeroxResponse> {
log::trace!("enter: get_robots_file");
// more often than not, domain/robots.txt will redirect to www.domain/robots.txt or something // need late binding here to avoid 'creates a temporary which is freed...' in the
// similar; to account for that, create a client that will follow redirects, regardless of // `let ... if` below to avoid cloning the client out of config
// what the user specified for the scanning client. Other than redirects, it will respect let mut client = Client::new();
// all other user specified settings
let follow_redirects = true;
let proxy = if self.handles.config.proxy.is_empty() { if location == "/robots.txt" {
None // more often than not, domain/robots.txt will redirect to www.domain/robots.txt or something
// similar; to account for that, create a client that will follow redirects, regardless of
// what the user specified for the scanning client. Other than redirects, it will respect
// all other user specified settings
let follow_redirects = true;
let proxy = if self.handles.config.proxy.is_empty() {
None
} else {
Some(self.handles.config.proxy.as_str())
};
client = client::initialize(
self.handles.config.timeout,
&self.handles.config.user_agent,
follow_redirects,
self.handles.config.insecure,
&self.handles.config.headers,
proxy,
)?;
}
let client = if location != "/robots.txt" {
&self.handles.config.client
} else { } else {
Some(self.handles.config.proxy.as_str()) &client
}; };
let client = client::initialize(
self.handles.config.timeout,
&self.handles.config.user_agent,
follow_redirects,
self.handles.config.insecure,
&self.handles.config.headers,
proxy,
)?;
let mut url = Url::parse(&self.url)?; let mut url = Url::parse(&self.url)?;
url.set_path("/robots.txt"); // overwrite existing path with /robots.txt url.set_path(location); // overwrite existing path
// purposefully not using logged_request here due to using the special client
let response = make_request( let response = make_request(
&client, client,
&url, &url,
DEFAULT_METHOD,
None,
self.handles.config.output_level, self.handles.config.output_level,
&self.handles.config,
self.handles.stats.tx.clone(), self.handles.stats.tx.clone(),
) )
.await?; .await?;
let ferox_response =
FeroxResponse::from(response, true, self.handles.config.output_level).await;
log::trace!("exit: get_robots_file -> {}", ferox_response); let ferox_response = FeroxResponse::from(
return Ok(ferox_response); response,
&self.url,
DEFAULT_METHOD,
self.handles.config.output_level,
)
.await;
// note: don't call parse_extension here. If we call it here, it gets called on robots.txt
log::trace!("exit: make_extract_request -> {}", ferox_response);
Ok(ferox_response)
} }
/// update total number of links extracted and expected responses /// update total number of links extracted and expected responses
fn update_stats(&self, num_links: usize) -> Result<()> { fn update_stats(&self, num_links: usize) -> Result<()> {
let multiplier = self.handles.config.extensions.len().max(1); let multiplier = self.handles.expected_num_requests_multiplier();
self.handles self.handles
.stats .stats
.send(UpdateUsizeField(LinksExtracted, num_links))?; .send(AddToUsizeField(LinksExtracted, num_links))?;
self.handles self.handles
.stats .stats
.send(UpdateUsizeField(TotalExpected, num_links * multiplier))?; .send(AddToUsizeField(TotalExpected, num_links * multiplier))?;
Ok(()) Ok(())
} }

View File

@@ -1,9 +1,10 @@
use super::builder::{LINKFINDER_REGEX, ROBOTS_TXT_REGEX}; use super::builder::{LINKFINDER_REGEX, ROBOTS_TXT_REGEX, URL_CHARS_REGEX};
use super::*; use super::*;
use crate::config::{Configuration, OutputLevel}; use crate::config::{Configuration, OutputLevel};
use crate::scan_manager::ScanOrder; use crate::scan_manager::ScanOrder;
use crate::{ use crate::{
event_handlers::Handles, scan_manager::FeroxScans, utils::make_request, Command, FeroxChannel, event_handlers::Handles, scan_manager::FeroxScans, utils::make_request, Command, FeroxChannel,
DEFAULT_METHOD,
}; };
use anyhow::Result; use anyhow::Result;
use httpmock::{Method::GET, MockServer}; use httpmock::{Method::GET, MockServer};
@@ -19,6 +20,9 @@ lazy_static! {
/// Extractor for testing response bodies /// Extractor for testing response bodies
static ref BODY_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::ResponseBody, Arc::new(FeroxScans::default())); static ref BODY_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::ResponseBody, Arc::new(FeroxScans::default()));
/// Extractor for testing paring html
static ref PARSEHTML_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::DirectoryListing, Arc::new(FeroxScans::default()));
/// FeroxResponse for Extractor /// FeroxResponse for Extractor
static ref RESPONSE: FeroxResponse = get_test_response(); static ref RESPONSE: FeroxResponse = get_test_response();
} }
@@ -41,6 +45,9 @@ fn setup_extractor(target: ExtractionTarget, scanned_urls: Arc<FeroxScans>) -> E
ExtractionTarget::RobotsTxt => builder ExtractionTarget::RobotsTxt => builder
.url("http://localhost") .url("http://localhost")
.target(ExtractionTarget::RobotsTxt), .target(ExtractionTarget::RobotsTxt),
ExtractionTarget::DirectoryListing => builder
.url("http://localhost")
.target(ExtractionTarget::DirectoryListing),
}; };
let config = Arc::new(Configuration::new().unwrap()); let config = Arc::new(Configuration::new().unwrap());
@@ -54,8 +61,8 @@ fn setup_extractor(target: ExtractionTarget, scanned_urls: Arc<FeroxScans>) -> E
/// in the expected array /// in the expected array
fn extractor_get_sub_paths_from_path_with_multiple_paths() { fn extractor_get_sub_paths_from_path_with_multiple_paths() {
let path = "homepage/assets/img/icons/handshake.svg"; let path = "homepage/assets/img/icons/handshake.svg";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(&path); let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(&path); let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec![ let expected = vec![
"homepage/", "homepage/",
"homepage/assets/", "homepage/assets/",
@@ -67,8 +74,8 @@ fn extractor_get_sub_paths_from_path_with_multiple_paths() {
assert_eq!(r_paths.len(), expected.len()); assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len()); assert_eq!(b_paths.len(), expected.len());
for expected_path in expected { for expected_path in expected {
assert_eq!(r_paths.contains(&expected_path.to_string()), true); assert!(r_paths.contains(&expected_path.to_string()));
assert_eq!(b_paths.contains(&expected_path.to_string()), true); assert!(b_paths.contains(&expected_path.to_string()));
} }
} }
@@ -78,15 +85,15 @@ fn extractor_get_sub_paths_from_path_with_multiple_paths() {
/// returned /// returned
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() { fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
let path = "/homepage/assets/"; let path = "/homepage/assets/";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(&path); let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(&path); let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec!["homepage/", "homepage/assets"]; let expected = vec!["homepage/", "homepage/assets"];
assert_eq!(r_paths.len(), expected.len()); assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len()); assert_eq!(b_paths.len(), expected.len());
for expected_path in expected { for expected_path in expected {
assert_eq!(r_paths.contains(&expected_path.to_string()), true); assert!(r_paths.contains(&expected_path.to_string()));
assert_eq!(b_paths.contains(&expected_path.to_string()), true); assert!(b_paths.contains(&expected_path.to_string()));
} }
} }
@@ -95,15 +102,15 @@ fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
/// included /// included
fn extractor_get_sub_paths_from_path_with_only_a_word() { fn extractor_get_sub_paths_from_path_with_only_a_word() {
let path = "homepage"; let path = "homepage";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(&path); let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(&path); let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec!["homepage"]; let expected = vec!["homepage"];
assert_eq!(r_paths.len(), expected.len()); assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len()); assert_eq!(b_paths.len(), expected.len());
for expected_path in expected { for expected_path in expected {
assert_eq!(r_paths.contains(&expected_path.to_string()), true); assert!(r_paths.contains(&expected_path.to_string()));
assert_eq!(b_paths.contains(&expected_path.to_string()), true); assert!(b_paths.contains(&expected_path.to_string()));
} }
} }
@@ -111,15 +118,15 @@ fn extractor_get_sub_paths_from_path_with_only_a_word() {
/// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed /// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed
fn extractor_get_sub_paths_from_path_with_an_absolute_word() { fn extractor_get_sub_paths_from_path_with_an_absolute_word() {
let path = "/homepage"; let path = "/homepage";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(&path); let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(&path); let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec!["homepage"]; let expected = vec!["homepage"];
assert_eq!(r_paths.len(), expected.len()); assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len()); assert_eq!(b_paths.len(), expected.len());
for expected_path in expected { for expected_path in expected {
assert_eq!(r_paths.contains(&expected_path.to_string()), true); assert!(r_paths.contains(&expected_path.to_string()));
assert_eq!(b_paths.contains(&expected_path.to_string()), true); assert!(b_paths.contains(&expected_path.to_string()));
} }
} }
@@ -188,7 +195,6 @@ fn extractor_add_link_to_set_of_links_happy_path() {
fn extractor_add_link_to_set_of_links_with_non_base_url() { fn extractor_add_link_to_set_of_links_with_non_base_url() {
let mut links = HashSet::<String>::new(); let mut links = HashSet::<String>::new();
let link = "\\\\\\\\"; let link = "\\\\\\\\";
assert_eq!(links.len(), 0); assert_eq!(links.len(), 0);
assert!(ROBOTS_EXT assert!(ROBOTS_EXT
.add_link_to_set_of_links(link, &mut links) .add_link_to_set_of_links(link, &mut links)
@@ -199,6 +205,34 @@ fn extractor_add_link_to_set_of_links_with_non_base_url() {
assert!(links.is_empty()); assert!(links.is_empty());
} }
#[test]
/// test for filtering queries and fragments
fn normalize_url_path_filters_queries_and_fragments() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let extractor = ExtractorBuilder::default()
.url("doesnt matter")
.target(ExtractionTarget::RobotsTxt)
.handles(handles)
.build()
.unwrap();
let test_strings = [
"over/there?name=ferret#nose",
"over/there?name=ferret",
"over/there#nose",
"over/there",
"over/there?name#nose",
"over/there?name",
" over/there?name=ferret#nose ",
"over/there?name=ferret ",
" over/there#nose",
];
test_strings.iter().for_each(|&ts| {
let normed = extractor.normalize_url_path(ts);
assert_eq!(normed, "over/there");
});
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// use make_request to generate a Response, and use the Response to test get_links; /// use make_request to generate a Response, and use the Response to test get_links;
/// the response will contain an absolute path to a domain that is not part of the scanned /// the response will contain an absolute path to a domain that is not part of the scanned
@@ -211,24 +245,35 @@ async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain()
let mock = srv.mock(|when, then| { let mock = srv.mock(|when, then| {
when.method(GET).path("/some-path"); when.method(GET).path("/some-path");
then.status(200).body( then.status(200).body(
"\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"", "\"http://definitely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"",
); );
}); });
let client = Client::new(); let client = Client::new();
let url = Url::parse(&srv.url("/some-path")).unwrap(); let url = Url::parse(&srv.url("/some-path")).unwrap();
let config = Configuration::new().unwrap();
let response = make_request(&client, &url, OutputLevel::Default, tx_stats.clone()) let response = make_request(
.await &client,
.unwrap(); &url,
DEFAULT_METHOD,
None,
OutputLevel::Default,
&config,
tx_stats.clone(),
)
.await
.unwrap();
let (handles, _rx) = Handles::for_testing(None, None); let (handles, _rx) = Handles::for_testing(None, None);
let handles = Arc::new(handles); let handles = Arc::new(handles);
let ferox_response = FeroxResponse::from(response, true, OutputLevel::Default).await; let ferox_response =
FeroxResponse::from(response, &srv.url(""), DEFAULT_METHOD, OutputLevel::Default).await;
let extractor = Extractor { let extractor = Extractor {
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(), links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(), robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
url_regex: Regex::new(URL_CHARS_REGEX).unwrap(),
response: Some(&ferox_response), response: Some(&ferox_response),
url: String::new(), url: String::new(),
target: ExtractionTarget::ResponseBody, target: ExtractionTarget::ResponseBody,
@@ -257,16 +302,17 @@ async fn request_robots_txt_without_proxy() -> Result<()> {
let extractor = Extractor { let extractor = Extractor {
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(), links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(), robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
url_regex: Regex::new(URL_CHARS_REGEX).unwrap(),
response: None, response: None,
url: srv.url("/api/users/stuff/things"), url: srv.url("/api/users/stuff/things"),
target: ExtractionTarget::RobotsTxt, target: ExtractionTarget::RobotsTxt,
handles, handles,
}; };
let resp = extractor.request_robots_txt().await?; let resp = extractor.make_extract_request("/robots.txt").await?;
assert!(matches!(resp.status(), &StatusCode::OK)); assert!(matches!(resp.status(), &StatusCode::OK));
println!("{}", resp); println!("{resp}");
assert_eq!(resp.content_length(), 14); assert_eq!(resp.content_length(), 14);
assert_eq!(mock.hits(), 1); assert_eq!(mock.hits(), 1);
Ok(()) Ok(())
@@ -296,7 +342,7 @@ async fn request_robots_txt_with_proxy() -> Result<()> {
.handles(handles) .handles(handles)
.build()?; .build()?;
let resp = extractor.request_robots_txt().await?; let resp = extractor.make_extract_request("/robots.txt").await?;
assert!(matches!(resp.status(), &StatusCode::OK)); assert!(matches!(resp.status(), &StatusCode::OK));
assert_eq!(resp.content_length(), 19); assert_eq!(resp.content_length(), 19);

View File

@@ -1,27 +1,30 @@
use std::sync::Mutex; use std::sync::RwLock;
use anyhow::Result; use anyhow::Result;
use serde::{ser::SerializeSeq, Serialize, Serializer};
use crate::response::FeroxResponse; use crate::response::FeroxResponse;
use super::{
FeroxFilter, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
WildcardFilter, WordsFilter,
};
use crate::{ use crate::{
event_handlers::Command::UpdateUsizeField, statistics::StatField::WildcardsFiltered, event_handlers::Command::AddToUsizeField, statistics::StatField::WildcardsFiltered,
CommandSender, CommandSender,
}; };
use super::{FeroxFilter, WildcardFilter};
/// Container around a collection of `FeroxFilters`s /// Container around a collection of `FeroxFilters`s
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct FeroxFilters { pub struct FeroxFilters {
/// collection of `FeroxFilters` /// collection of `FeroxFilters`
pub filters: Mutex<Vec<Box<dyn FeroxFilter>>>, pub filters: RwLock<Vec<Box<dyn FeroxFilter>>>,
} }
/// implementation of FeroxFilter collection /// implementation of FeroxFilter collection
impl FeroxFilters { impl FeroxFilters {
/// add a single FeroxFilter to the collection /// add a single FeroxFilter to the collection
pub fn push(&self, filter: Box<dyn FeroxFilter>) -> Result<()> { pub fn push(&self, filter: Box<dyn FeroxFilter>) -> Result<()> {
if let Ok(mut guard) = self.filters.lock() { if let Ok(mut guard) = self.filters.write() {
if guard.contains(&filter) { if guard.contains(&filter) {
return Ok(()); return Ok(());
} }
@@ -31,6 +34,37 @@ impl FeroxFilters {
Ok(()) Ok(())
} }
/// remove items from the underlying collection by their index
///
/// note: indexes passed in should be index-to-remove+1. This is built for the scan mgt menu
/// so indexes aren't 0-based whehn the user enters them.
///
pub fn remove(&self, indices: &mut [usize]) {
// since we're removing by index, indices must be sorted and then reversed.
// this allows us to iterate over the collection from the rear, allowing any shifting
// of the vector to happen on sections that we no longer care about, as we're moving
// in the opposite direction
indices.sort_unstable();
indices.reverse();
if let Ok(mut guard) = self.filters.write() {
for index in indices {
// numbering of the menu starts at 1, so we'll need to reduce the index by 1
// to account for that. if they've provided 0 as an offset, we'll set the
// result to a gigantic number and skip it in the loop with a bounds check
let reduced_idx = index.checked_sub(1).unwrap_or(usize::MAX);
// check if number provided is out of range
if reduced_idx >= guard.len() {
// usize can't be negative, just need to handle exceeding bounds
continue;
}
guard.remove(reduced_idx);
}
}
}
/// Simple helper to stay DRY; determines whether or not a given `FeroxResponse` should be reported /// Simple helper to stay DRY; determines whether or not a given `FeroxResponse` should be reported
/// to the user or not. /// to the user or not.
pub fn should_filter_response( pub fn should_filter_response(
@@ -38,13 +72,14 @@ impl FeroxFilters {
response: &FeroxResponse, response: &FeroxResponse,
tx_stats: CommandSender, tx_stats: CommandSender,
) -> bool { ) -> bool {
if let Ok(filters) = self.filters.lock() { if let Ok(filters) = self.filters.read() {
for filter in filters.iter() { for filter in filters.iter() {
// wildcard.should_filter goes here // wildcard.should_filter goes here
if filter.should_filter_response(&response) { if filter.should_filter_response(response) {
log::debug!("filtering response due to: {:?}", filter);
if filter.as_any().downcast_ref::<WildcardFilter>().is_some() { if filter.as_any().downcast_ref::<WildcardFilter>().is_some() {
tx_stats tx_stats
.send(UpdateUsizeField(WildcardsFiltered, 1)) .send(AddToUsizeField(WildcardsFiltered, 1))
.unwrap_or_default(); .unwrap_or_default();
} }
return true; return true;
@@ -54,3 +89,43 @@ impl FeroxFilters {
false false
} }
} }
impl Serialize for FeroxFilters {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Ok(guard) = self.filters.read() {
let mut seq = serializer.serialize_seq(Some(guard.len()))?;
for filter in &*guard {
if let Some(line_filter) = filter.as_any().downcast_ref::<LinesFilter>() {
seq.serialize_element(line_filter).unwrap_or_default();
} else if let Some(word_filter) = filter.as_any().downcast_ref::<WordsFilter>() {
seq.serialize_element(word_filter).unwrap_or_default();
} else if let Some(size_filter) = filter.as_any().downcast_ref::<SizeFilter>() {
seq.serialize_element(size_filter).unwrap_or_default();
} else if let Some(wildcard_filter) =
filter.as_any().downcast_ref::<WildcardFilter>()
{
seq.serialize_element(wildcard_filter).unwrap_or_default();
} else if let Some(status_filter) =
filter.as_any().downcast_ref::<StatusCodeFilter>()
{
seq.serialize_element(status_filter).unwrap_or_default();
} else if let Some(regex_filter) = filter.as_any().downcast_ref::<RegexFilter>() {
seq.serialize_element(regex_filter).unwrap_or_default();
} else if let Some(similarity_filter) =
filter.as_any().downcast_ref::<SimilarityFilter>()
{
seq.serialize_element(similarity_filter).unwrap_or_default();
}
}
seq.end()
} else {
// if for some reason we can't unlock the mutex, just write an empty list
let seq = serializer.serialize_seq(Some(0))?;
seq.end()
}
}
}

22
src/filters/empty.rs Normal file
View File

@@ -0,0 +1,22 @@
use super::*;
/// Dummy filter for internal shenanigans
#[derive(Default, Debug, PartialEq, Eq)]
pub struct EmptyFilter {}
impl FeroxFilter for EmptyFilter {
/// `EmptyFilter` always returns false
fn should_filter_response(&self, _response: &FeroxResponse) -> bool {
false
}
/// Compare one EmptyFilter to another
fn box_eq(&self, other: &dyn Any) -> bool {
other.downcast_ref::<Self>().map_or(false, |a| self == a)
}
/// Return self as Any for dynamic dispatch purposes
fn as_any(&self) -> &dyn Any {
self
}
}

View File

@@ -1,18 +1,10 @@
use super::{ use super::{
LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter, WordsFilter, utils::create_similarity_filter, LinesFilter, RegexFilter, SizeFilter, StatusCodeFilter,
}; WordsFilter,
use crate::{
event_handlers::Handles,
response::FeroxResponse,
skip_fail,
utils::{fmt_err, make_request},
Command::AddFilter,
SIMILARITY_THRESHOLD,
}; };
use crate::{event_handlers::Handles, skip_fail, utils::fmt_err, Command::AddFilter};
use anyhow::Result; use anyhow::Result;
use fuzzyhash::FuzzyHash;
use regex::Regex; use regex::Regex;
use reqwest::Url;
use std::sync::Arc; use std::sync::Arc;
/// add all user-supplied filters to the (already started) filters handler /// add all user-supplied filters to the (already started) filters handler
@@ -56,7 +48,7 @@ pub async fn initialize(handles: Arc<Handles>) -> Result<()> {
// add any regex filters to filters handler's FeroxFilters (-X|--filter-regex) // add any regex filters to filters handler's FeroxFilters (-X|--filter-regex)
for regex_filter in &handles.config.filter_regex { for regex_filter in &handles.config.filter_regex {
let raw = regex_filter; let raw = regex_filter;
let compiled = skip_fail!(Regex::new(&raw)); let compiled = skip_fail!(Regex::new(raw));
let filter = RegexFilter { let filter = RegexFilter {
raw_string: raw.to_owned(), raw_string: raw.to_owned(),
@@ -68,30 +60,7 @@ pub async fn initialize(handles: Arc<Handles>) -> Result<()> {
// add any similarity filters to filters handler's FeroxFilters (--filter-similar-to) // add any similarity filters to filters handler's FeroxFilters (--filter-similar-to)
for similarity_filter in &handles.config.filter_similar { for similarity_filter in &handles.config.filter_similar {
// url as-is based on input, ignores user-specified url manipulation options (add-slash etc) let filter = skip_fail!(create_similarity_filter(similarity_filter, handles.clone()).await);
let url = skip_fail!(Url::parse(&similarity_filter));
// attempt to request the given url
let resp = skip_fail!(
make_request(
&handles.config.client,
&url,
handles.config.output_level,
handles.stats.tx.clone()
)
.await
);
// if successful, create a filter based on the response's body
let fr = FeroxResponse::from(resp, true, handles.config.output_level).await;
// hash the response body and store the resulting hash in the filter object
let hash = FuzzyHash::new(&fr.text()).to_string();
let filter = SimilarityFilter {
text: hash,
threshold: SIMILARITY_THRESHOLD,
};
let boxed_filter = Box::new(filter); let boxed_filter = Box::new(filter);
skip_fail!(handles.filters.send(AddFilter(boxed_filter))); skip_fail!(handles.filters.send(AddFilter(boxed_filter)));

View File

@@ -2,7 +2,7 @@ use super::*;
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of lines /// Simple implementor of FeroxFilter; used to filter out responses based on the number of lines
/// in a Response body; specified using -N|--filter-lines /// in a Response body; specified using -N|--filter-lines
#[derive(Default, Debug, PartialEq)] #[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinesFilter { pub struct LinesFilter {
/// Number of lines in a Response's body that should be filtered /// Number of lines in a Response's body that should be filtered
pub line_count: usize, pub line_count: usize,

View File

@@ -1,21 +1,23 @@
//! contains all of feroxbuster's filters //! contains all of feroxbuster's filters
use serde::{Deserialize, Serialize};
use std::any::Any; use std::any::Any;
use std::fmt::Debug; use std::fmt::Debug;
use crate::response::FeroxResponse; use crate::response::FeroxResponse;
use crate::traits::{FeroxFilter, FeroxSerialize}; use crate::traits::FeroxFilter;
pub use self::container::FeroxFilters; pub use self::container::FeroxFilters;
pub(crate) use self::empty::EmptyFilter;
pub use self::init::initialize; pub use self::init::initialize;
pub use self::lines::LinesFilter; pub use self::lines::LinesFilter;
pub use self::regex::RegexFilter; pub use self::regex::RegexFilter;
pub use self::similarity::SimilarityFilter; pub use self::similarity::{SimilarityFilter, SIM_HASHER};
pub use self::size::SizeFilter; pub use self::size::SizeFilter;
pub use self::status_code::StatusCodeFilter; pub use self::status_code::StatusCodeFilter;
pub(crate) use self::utils::{create_similarity_filter, filter_lookup};
pub use self::wildcard::WildcardFilter; pub use self::wildcard::WildcardFilter;
pub use self::words::WordsFilter; pub use self::words::WordsFilter;
mod wildcard;
mod status_code; mod status_code;
mod words; mod words;
mod lines; mod lines;
@@ -26,3 +28,6 @@ mod container;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod init; mod init;
mod utils;
mod wildcard;
mod empty;

View File

@@ -3,15 +3,25 @@ use ::regex::Regex;
/// Simple implementor of FeroxFilter; used to filter out responses based on a given regular /// Simple implementor of FeroxFilter; used to filter out responses based on a given regular
/// expression; specified using -X|--filter-regex /// expression; specified using -X|--filter-regex
#[derive(Debug)] #[derive(Debug, Serialize, Deserialize)]
pub struct RegexFilter { pub struct RegexFilter {
/// Regular expression to be applied to the response body for filtering, compiled /// Regular expression to be applied to the response body for filtering, compiled
#[serde(with = "serde_regex")]
pub compiled: Regex, pub compiled: Regex,
/// Regular expression as passed in on the command line, not compiled /// Regular expression as passed in on the command line, not compiled
pub raw_string: String, pub raw_string: String,
} }
impl Default for RegexFilter {
fn default() -> Self {
Self {
compiled: Regex::new("").unwrap(),
raw_string: String::new(),
}
}
}
/// implementation of FeroxFilter for RegexFilter /// implementation of FeroxFilter for RegexFilter
impl FeroxFilter for RegexFilter { impl FeroxFilter for RegexFilter {
/// Check `expression` against the response body, if the expression matches, the response /// Check `expression` against the response body, if the expression matches, the response

View File

@@ -1,15 +1,29 @@
use super::*; use super::*;
use fuzzyhash::FuzzyHash; use crate::nlp::preprocess;
use gaoya::simhash::{SimHash, SimHashBits, SimSipHasher64};
use lazy_static::lazy_static;
lazy_static! {
/// single instance of the sip hasher used in similarity filtering
pub static ref SIM_HASHER: SimHash<SimSipHasher64, u64, 64> =
SimHash::<SimSipHasher64, u64, 64>::new(SimSipHasher64::new(1, 2));
}
/// maximum hamming distance allowed between two signatures
///
/// ref: https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/33026.pdf
/// section: 4.1 Choice of Parameters
const MAX_HAMMING_DISTANCE: usize = 3;
/// Simple implementor of FeroxFilter; used to filter out responses based on the similarity of a /// Simple implementor of FeroxFilter; used to filter out responses based on the similarity of a
/// Response body with a known response; specified using --filter-similar-to /// Response body with a known response; specified using --filter-similar-to
#[derive(Default, Debug, PartialEq)] #[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SimilarityFilter { pub struct SimilarityFilter {
/// Response's body to be used for comparison for similarity /// Hash of Response's body to be used during similarity comparison
pub text: String, pub hash: u64,
/// Percentage of similarity at which a page is determined to be a near-duplicate of another /// Url originally requested for the similarity filter
pub threshold: u32, pub original_url: String,
} }
/// implementation of FeroxFilter for SimilarityFilter /// implementation of FeroxFilter for SimilarityFilter
@@ -17,20 +31,15 @@ impl FeroxFilter for SimilarityFilter {
/// Check `FeroxResponse::text` against what was requested from the site passed in via /// Check `FeroxResponse::text` against what was requested from the site passed in via
/// --filter-similar-to /// --filter-similar-to
fn should_filter_response(&self, response: &FeroxResponse) -> bool { fn should_filter_response(&self, response: &FeroxResponse) -> bool {
let other = FuzzyHash::new(&response.text()); let other = SIM_HASHER.create_signature(preprocess(response.text()).iter());
self.hash.hamming_distance(&other) <= MAX_HAMMING_DISTANCE
if let Ok(result) = FuzzyHash::compare(&self.text, &other.to_string()) {
return result >= self.threshold;
}
// couldn't hash the response, don't filter
log::warn!("Could not hash body from {}", response.as_str());
false
} }
/// Compare one SimilarityFilter to another /// Compare one SimilarityFilter to another
fn box_eq(&self, other: &dyn Any) -> bool { fn box_eq(&self, other: &dyn Any) -> bool {
other.downcast_ref::<Self>().map_or(false, |a| self == a) other
.downcast_ref::<Self>()
.map_or(false, |a| self.hash == a.hash)
} }
/// Return self as Any for dynamic dispatch purposes /// Return self as Any for dynamic dispatch purposes

View File

@@ -2,7 +2,7 @@ use super::*;
/// Simple implementor of FeroxFilter; used to filter out responses based on the length of a /// Simple implementor of FeroxFilter; used to filter out responses based on the length of a
/// Response body; specified using -S|--filter-size /// Response body; specified using -S|--filter-size
#[derive(Default, Debug, PartialEq)] #[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SizeFilter { pub struct SizeFilter {
/// Overall length of a Response's body that should be filtered /// Overall length of a Response's body that should be filtered
pub content_length: u64, pub content_length: u64,

View File

@@ -2,7 +2,7 @@ use super::*;
/// Simple implementor of FeroxFilter; used to filter out status codes specified using /// Simple implementor of FeroxFilter; used to filter out status codes specified using
/// -C|--filter-status /// -C|--filter-status
#[derive(Default, Debug, PartialEq)] #[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusCodeFilter { pub struct StatusCodeFilter {
/// Status code that should not be displayed to the user /// Status code that should not be displayed to the user
pub filter_code: u16, pub filter_code: u16,

View File

@@ -1,26 +1,38 @@
use super::*; use super::*;
use ::fuzzyhash::FuzzyHash; use crate::nlp::preprocess;
use crate::DEFAULT_METHOD;
use ::regex::Regex; use ::regex::Regex;
#[test] #[test]
/// simply test the default values for wildcardfilter, expect 0, 0 /// simply test the default values for wildcardfilter
fn wildcard_filter_default() { fn wildcard_filter_default() {
let wcf = WildcardFilter::default(); let wcf = WildcardFilter::default();
assert_eq!(wcf.size, u64::MAX); assert_eq!(wcf.content_length, None);
assert_eq!(wcf.dynamic, u64::MAX); assert_eq!(wcf.line_count, None);
assert_eq!(wcf.word_count, None);
assert_eq!(wcf.method, DEFAULT_METHOD.to_string());
assert_eq!(wcf.status_code, 0);
assert!(!wcf.dont_filter);
} }
#[test] #[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value /// just a simple test to increase code coverage by hitting as_any and the inner value
fn wildcard_filter_as_any() { fn wildcard_filter_as_any() {
let filter = WildcardFilter::default(); let mut filter = WildcardFilter::default();
let filter2 = WildcardFilter::default(); let filter2 = WildcardFilter::default();
assert!(filter.box_eq(filter2.as_any())); assert!(filter.box_eq(filter2.as_any()));
assert_eq!( assert_eq!(
*filter.as_any().downcast_ref::<WildcardFilter>().unwrap(), *filter.as_any().downcast_ref::<WildcardFilter>().unwrap(),
filter filter2
);
filter.content_length = Some(1);
assert_ne!(
*filter.as_any().downcast_ref::<WildcardFilter>().unwrap(),
filter2
); );
} }
@@ -111,16 +123,40 @@ fn regex_filter_as_any() {
#[test] #[test]
/// test should_filter on WilcardFilter where static logic matches /// test should_filter on WilcardFilter where static logic matches
fn wildcard_should_filter_when_static_wildcard_found() { fn wildcard_should_filter_when_static_wildcard_found() {
let body =
"pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus faucibus";
let mut resp = FeroxResponse::default(); let mut resp = FeroxResponse::default();
resp.set_wildcard(true); resp.set_wildcard(true);
resp.set_url("http://localhost"); resp.set_url("http://localhost");
resp.set_text( resp.set_text(body);
"pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus faucibus",
);
let filter = WildcardFilter { let filter = WildcardFilter {
size: 83, content_length: Some(body.len() as u64),
dynamic: 0, line_count: Some(1),
word_count: Some(10),
method: DEFAULT_METHOD.to_string(),
status_code: 200,
dont_filter: false,
};
assert!(filter.should_filter_response(&resp));
}
#[test]
/// test should_filter on WilcardFilter where static logic matches but response length is 0
fn wildcard_should_filter_when_static_wildcard_len_is_zero() {
let mut resp = FeroxResponse::default();
resp.set_wildcard(true);
resp.set_url("http://localhost");
// default WildcardFilter is used in the code that executes when response.content_length() == 0
let filter = WildcardFilter {
content_length: Some(0),
line_count: Some(0),
word_count: Some(0),
method: DEFAULT_METHOD.to_string(),
status_code: 200,
dont_filter: false, dont_filter: false,
}; };
@@ -136,16 +172,16 @@ fn wildcard_should_filter_when_dynamic_wildcard_found() {
resp.set_text("pellentesque diam volutpat commodo sed egestas egestas fringilla"); resp.set_text("pellentesque diam volutpat commodo sed egestas egestas fringilla");
let filter = WildcardFilter { let filter = WildcardFilter {
size: 0, content_length: None,
dynamic: 59, // content-length - 5 (len('stuff')) line_count: None,
word_count: Some(8),
method: DEFAULT_METHOD.to_string(),
status_code: 200,
dont_filter: false, dont_filter: false,
}; };
println!("resp: {:?}: filter: {:?}", resp, filter);
assert!(filter.should_filter_response(&resp)); assert!(filter.should_filter_response(&resp));
} }
#[test] #[test]
/// test should_filter on RegexFilter where regex matches body /// test should_filter on RegexFilter where regex matches body
fn regexfilter_should_filter_when_regex_matches_on_response_body() { fn regexfilter_should_filter_when_regex_matches_on_response_body() {
@@ -171,23 +207,23 @@ fn similarity_filter_is_accurate() {
resp.set_text("sitting"); resp.set_text("sitting");
let mut filter = SimilarityFilter { let mut filter = SimilarityFilter {
text: FuzzyHash::new("kitten").to_string(), hash: SIM_HASHER.create_signature(["kitten"].iter()),
threshold: 95, original_url: "".to_string(),
}; };
// kitten/sitting is 57% similar, so a threshold of 95 should not be filtered // kitten/sitting is 57% similar, so a threshold of 95 should not be filtered
assert!(!filter.should_filter_response(&resp)); assert!(!filter.should_filter_response(&resp));
resp.set_text(""); resp.set_text("");
filter.text = String::new(); filter.hash = SIM_HASHER.create_signature([""].iter());
filter.threshold = 100;
// two empty strings are the same, however ssdeep doesn't accept empty strings, expect false // two empty strings are the same
assert!(!filter.should_filter_response(&resp)); assert!(!filter.should_filter_response(&resp));
resp.set_text("some data to hash for the purposes of running a test"); resp.set_text("some data hash purposes running test");
filter.text = FuzzyHash::new("some data to hash for the purposes of running a te").to_string(); filter.hash = SIM_HASHER.create_signature(
filter.threshold = 17; preprocess("some data to hash for the purposes of running a test").iter(),
);
assert!(filter.should_filter_response(&resp)); assert!(filter.should_filter_response(&resp));
} }
@@ -196,20 +232,55 @@ fn similarity_filter_is_accurate() {
/// just a simple test to increase code coverage by hitting as_any and the inner value /// just a simple test to increase code coverage by hitting as_any and the inner value
fn similarity_filter_as_any() { fn similarity_filter_as_any() {
let filter = SimilarityFilter { let filter = SimilarityFilter {
text: String::from("stuff"), hash: 1,
threshold: 95, original_url: "".to_string(),
}; };
let filter2 = SimilarityFilter { let filter2 = SimilarityFilter {
text: String::from("stuff"), hash: 1,
threshold: 95, original_url: "".to_string(),
}; };
assert!(filter.box_eq(filter2.as_any())); assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.text, "stuff");
assert_eq!( assert_eq!(
*filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(), *filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
filter filter
); );
} }
#[test]
/// test correctness of FeroxFilters::remove
fn remove_function_works_as_expected() {
let data = FeroxFilters::default();
assert!(data.filters.read().unwrap().is_empty());
(0..8).for_each(|i| {
data.push(Box::new(WordsFilter { word_count: i })).unwrap();
});
// remove removes index-1 from the vec, zero is skipped, and out-of-bounds indices are skipped
data.remove(&mut [0]);
assert_eq!(data.filters.read().unwrap().len(), 8);
data.remove(&mut [10000]);
assert_eq!(data.filters.read().unwrap().len(), 8);
// removing 0, 2, 4
data.remove(&mut [1, 3, 5]);
assert_eq!(data.filters.read().unwrap().len(), 5);
let expected = vec![
WordsFilter { word_count: 1 },
WordsFilter { word_count: 3 },
WordsFilter { word_count: 5 },
WordsFilter { word_count: 6 },
WordsFilter { word_count: 7 },
];
for filter in data.filters.read().unwrap().iter() {
let downcast = filter.as_any().downcast_ref::<WordsFilter>().unwrap();
assert!(expected.contains(downcast));
}
}

200
src/filters/utils.rs Normal file
View File

@@ -0,0 +1,200 @@
use super::FeroxFilter;
use super::SimilarityFilter;
use crate::event_handlers::Handles;
use crate::filters::similarity::SIM_HASHER;
use crate::nlp::preprocess;
use crate::response::FeroxResponse;
use crate::utils::logged_request;
use crate::DEFAULT_METHOD;
use anyhow::Result;
use regex::Regex;
use reqwest::Url;
use std::sync::Arc;
/// wrapper around logic necessary to create a SimilarityFilter
///
/// - parses given url
/// - makes request to the parsed url
/// - gathers extensions from the url, if configured to do so
/// - computes hash of response body
/// - creates filter with hash
pub(crate) async fn create_similarity_filter(
similarity_filter: &str,
handles: Arc<Handles>,
) -> Result<SimilarityFilter> {
// url as-is based on input, ignores user-specified url manipulation options (add-slash etc)
let url = Url::parse(similarity_filter)?;
// attempt to request the given url
let resp = logged_request(&url, DEFAULT_METHOD, None, handles.clone()).await?;
// if successful, create a filter based on the response's body
let mut fr = FeroxResponse::from(
resp,
similarity_filter,
DEFAULT_METHOD,
handles.config.output_level,
)
.await;
if handles.config.collect_extensions {
fr.parse_extension(handles.clone())?;
}
let hash = SIM_HASHER.create_signature(preprocess(fr.text()).iter());
Ok(SimilarityFilter {
hash,
original_url: similarity_filter.to_string(),
})
}
/// used in conjunction with the Scan Management Menu
///
/// when a user uses the n[ew-filter] command in the menu, the two params are passed here for
/// processing.
///
/// an example command may be `new-filter lines 40`. `lines` and `40` are passed here as &str's
///
/// once here, the type and value are used to create an appropriate FeroxFilter. If anything
/// goes wrong during creation, None is returned.
pub(crate) fn filter_lookup(filter_type: &str, filter_value: &str) -> Option<Box<dyn FeroxFilter>> {
match filter_type {
"status" => {
if let Ok(parsed) = filter_value.parse() {
return Some(Box::new(super::StatusCodeFilter {
filter_code: parsed,
}));
}
}
"lines" => {
if let Ok(parsed) = filter_value.parse() {
return Some(Box::new(super::LinesFilter { line_count: parsed }));
}
}
"size" => {
if let Ok(parsed) = filter_value.parse() {
return Some(Box::new(super::SizeFilter {
content_length: parsed,
}));
}
}
"words" => {
if let Ok(parsed) = filter_value.parse() {
return Some(Box::new(super::WordsFilter { word_count: parsed }));
}
}
"regex" => {
if let Ok(parsed) = Regex::new(filter_value) {
return Some(Box::new(super::RegexFilter {
compiled: parsed,
raw_string: filter_value.to_string(),
}));
}
}
"similarity" => {
return Some(Box::new(SimilarityFilter {
hash: 0,
original_url: filter_value.to_string(),
}));
}
_ => (),
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Configuration;
use crate::filters::{LinesFilter, RegexFilter, SizeFilter, StatusCodeFilter, WordsFilter};
use crate::scan_manager::FeroxScans;
use httpmock::Method::GET;
use httpmock::MockServer;
#[test]
/// filter_lookup returns correct filters
fn filter_lookup_returns_correct_filters() {
let filter = filter_lookup("status", "200").unwrap();
assert_eq!(
filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
&StatusCodeFilter { filter_code: 200 }
);
let filter = filter_lookup("lines", "10").unwrap();
assert_eq!(
filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
&LinesFilter { line_count: 10 }
);
let filter = filter_lookup("size", "20").unwrap();
assert_eq!(
filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
&SizeFilter { content_length: 20 }
);
let filter = filter_lookup("words", "30").unwrap();
assert_eq!(
filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
&WordsFilter { word_count: 30 }
);
let filter = filter_lookup("regex", "stuff.*").unwrap();
let compiled = Regex::new("stuff.*").unwrap();
let raw_string = String::from("stuff.*");
assert_eq!(
filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
&RegexFilter {
compiled,
raw_string
}
);
let filter = filter_lookup("similarity", "http://localhost").unwrap();
assert_eq!(
filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
&SimilarityFilter {
hash: 0,
original_url: "http://localhost".to_string()
}
);
assert!(filter_lookup("non-existent", "").is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// ensure create_similarity_filter correctness of return value and side-effects
async fn create_similarity_filter_is_correct() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/");
then.status(200).body("this is a test");
});
let scans = FeroxScans::default();
let config = Configuration {
collect_extensions: true,
..Default::default()
};
let (test_handles, _) = Handles::for_testing(Some(Arc::new(scans)), Some(Arc::new(config)));
let handles = Arc::new(test_handles);
let filter = create_similarity_filter(&srv.url("/"), handles.clone())
.await
.unwrap();
assert_eq!(mock.hits(), 1);
assert_eq!(
filter,
SimilarityFilter {
hash: 14897447612059286329,
original_url: srv.url("/")
}
);
}
}

View File

@@ -1,25 +1,29 @@
use console::style;
use super::*; use super::*;
use crate::url::FeroxUrl; use crate::utils::create_report_string;
use crate::{config::OutputLevel, DEFAULT_METHOD};
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses /// Data holder for all relevant data needed when auto-filtering out wildcard responses
/// #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
/// `dynamic` is the size of the response that will later be combined with the length
/// of the path of the url requested and used to determine interesting pages from custom
/// 404s where the requested url is reflected back in the response
///
/// `size` is size of the response that should be included with filters passed via runtime
/// configuration and any static wildcard lengths.
#[derive(Debug, Clone, PartialEq)]
pub struct WildcardFilter { pub struct WildcardFilter {
/// size of the response that will later be combined with the length of the path of the url /// The content-length of this response, if known
/// requested pub content_length: Option<u64>,
pub dynamic: u64,
/// size of the response that should be included with filters passed via runtime configuration /// The number of lines contained in the body of this response, if known
pub size: u64, pub line_count: Option<usize>,
/// The number of words contained in the body of this response, if known
pub word_count: Option<usize>,
/// method used in request that should be included with filters passed via runtime configuration
pub method: String,
/// the status code returned in the response
pub status_code: u16,
/// whether or not the user passed -D on the command line /// whether or not the user passed -D on the command line
pub(super) dont_filter: bool, pub dont_filter: bool,
} }
/// implementation of WildcardFilter /// implementation of WildcardFilter
@@ -33,21 +37,23 @@ impl WildcardFilter {
} }
} }
/// implement default that populates both values with u64::MAX /// implement default that populates `method` with its default value
impl Default for WildcardFilter { impl Default for WildcardFilter {
/// populate both values with u64::MAX
fn default() -> Self { fn default() -> Self {
Self { Self {
content_length: None,
line_count: None,
word_count: None,
method: DEFAULT_METHOD.to_string(),
status_code: 0,
dont_filter: false, dont_filter: false,
size: u64::MAX,
dynamic: u64::MAX,
} }
} }
} }
/// implementation of FeroxFilter for WildcardFilter /// implementation of FeroxFilter for WildcardFilter
impl FeroxFilter for WildcardFilter { impl FeroxFilter for WildcardFilter {
/// Examine size, dynamic, and content_len to determine whether or not the response received /// Examine size/words/lines and method to determine whether or not the response received
/// is a wildcard response and therefore should be filtered out /// is a wildcard response and therefore should be filtered out
fn should_filter_response(&self, response: &FeroxResponse) -> bool { fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response); log::trace!("enter: should_filter_response({:?} {})", self, response);
@@ -60,30 +66,78 @@ impl FeroxFilter for WildcardFilter {
return false; return false;
} }
if self.size != u64::MAX && self.size == response.content_length() { if self.method != response.method().as_str() {
// static wildcard size found during testing // method's don't match, so this response should not be filtered out
// size isn't default, size equals response length, and auto-filter is on log::trace!("exit: should_filter_response -> false");
log::debug!("static wildcard: filtered out {}", response.url()); return false;
log::trace!("exit: should_filter_response -> true");
return true;
} }
if self.dynamic != u64::MAX { if self.status_code != response.status().as_u16() {
// dynamic wildcard offset found during testing // status codes don't match, so this response should not be filtered out
log::trace!("exit: should_filter_response -> false");
return false;
}
// I'm about to manually split this url path instead of using reqwest::Url's // methods and status codes match at this point, just need to check the other fields
// builtin parsing. The reason is that they call .split() on the url path
// except that I don't want an empty string taking up the last index in the
// event that the url ends with a forward slash. It's ugly enough to be split
// into its own function for readability.
let url_len = FeroxUrl::path_length_of_url(&response.url());
if url_len + self.dynamic == response.content_length() { match (self.content_length, self.word_count, self.line_count) {
log::debug!("dynamic wildcard: filtered out {}", response.url()); (Some(cl), Some(wc), Some(lc)) => {
log::trace!("exit: should_filter_response -> true"); if cl == response.content_length()
return true; && wc == response.word_count()
&& lc == response.line_count()
{
log::debug!("filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
(Some(cl), Some(wc), None) => {
if cl == response.content_length() && wc == response.word_count() {
log::debug!("filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
(Some(cl), None, Some(lc)) => {
if cl == response.content_length() && lc == response.line_count() {
log::debug!("filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
(None, Some(wc), Some(lc)) => {
if wc == response.word_count() && lc == response.line_count() {
log::debug!("filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
(Some(cl), None, None) => {
if cl == response.content_length() {
log::debug!("filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
(None, Some(wc), None) => {
if wc == response.word_count() {
log::debug!("filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
(None, None, Some(lc)) => {
if lc == response.line_count() {
log::debug!("filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
(None, None, None) => {
unreachable!("wildcard filter without any filters set");
} }
} }
log::trace!("exit: should_filter_response -> false"); log::trace!("exit: should_filter_response -> false");
false false
} }
@@ -98,3 +152,29 @@ impl FeroxFilter for WildcardFilter {
self self
} }
} }
impl std::fmt::Display for WildcardFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = create_report_string(
self.status_code.to_string().as_str(),
self.method.as_str(),
&self
.line_count
.map_or_else(|| "-".to_string(), |x| x.to_string()),
&self
.word_count
.map_or_else(|| "-".to_string(), |x| x.to_string()),
&self
.content_length
.map_or_else(|| "-".to_string(), |x| x.to_string()),
&format!(
"{} found {}-like response and created new filter; toggle off with {}",
style("Auto-filtering").bright().green(),
style("404").red(),
style("--dont-filter").yellow()
),
OutputLevel::Default,
);
write!(f, "{}", msg)
}
}

View File

@@ -2,7 +2,7 @@ use super::*;
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of words /// Simple implementor of FeroxFilter; used to filter out responses based on the number of words
/// in a Response body; specified using -W|--filter-words /// in a Response body; specified using -W|--filter-words
#[derive(Default, Debug, PartialEq)] #[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WordsFilter { pub struct WordsFilter {
/// Number of words in a Response's body that should be filtered /// Number of words in a Response's body that should be filtered
pub word_count: usize, pub word_count: usize,

View File

@@ -1,37 +1,62 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use console::style; use scraper::{Html, Selector};
use uuid::Uuid; use uuid::Uuid;
use crate::filters::{SimilarityFilter, WildcardFilter, SIM_HASHER};
use crate::message::FeroxMessage;
use crate::nlp::preprocess;
use crate::scanner::RESPONSES;
use crate::{ use crate::{
config::OutputLevel, config::OutputLevel,
event_handlers::{Command, Handles}, event_handlers::{Command, Handles},
filters::WildcardFilter,
progress::PROGRESS_PRINTER, progress::PROGRESS_PRINTER,
response::FeroxResponse, response::FeroxResponse,
skip_fail, skip_fail,
url::FeroxUrl, url::FeroxUrl,
utils::{ferox_print, fmt_err, make_request, status_colorizer}, utils::{ferox_print, fmt_err, logged_request},
DEFAULT_METHOD,
}; };
/// length of a standard UUID, used when determining wildcard responses /// enum representing the different servers that `parse_html` can detect when directory listing is
const UUID_LENGTH: u64 = 32; /// enabled
#[derive(Copy, Debug, Clone)]
pub enum DirListingType {
/// apache server, detected by `Index of /`
Apache,
/// wrapper around ugly string formatting /// tomcat/python server, detected by `Directory Listing for /`
macro_rules! format_template { TomCatOrPython,
($template:expr, $length:expr) => {
format!( /// ASP.NET server, detected by `Directory Listing -- /`
$template, AspDotNet,
status_colorizer("WLD"),
"-", // /// IIS/Azure server, detected by `HOST_NAME - /` (not currently used)
"-", // IIS_AZURE,
"-", /// variant that represents the absence of directory listing
style("auto-filtering").yellow(), None,
style($length).cyan(), }
style("--dont-filter").yellow()
) /// Wrapper around the results of running a directory listing detection against a target web page
}; #[derive(Debug, Clone)]
pub struct DirListingResult {
/// type of server where directory listing was detected
/// i.e. https://portswigger.net/kb/issues/00600100_directory-listing
pub dir_list_type: Option<DirListingType>,
/// the `FeroxResponse` generated during detection
pub response: FeroxResponse,
}
/// wrapper around the results of running a wildcard detection against a target web page
#[derive(Copy, Debug, Clone)]
pub enum WildcardResult {
/// variant that represents a wildcard directory
WildcardDirectory(usize),
/// variant that represents the presence of a 404-like response
FourOhFourLike(usize),
} }
/// container for heuristics related info /// container for heuristics related info
@@ -57,7 +82,7 @@ impl HeuristicTests {
let mut ids = vec![]; let mut ids = vec![];
for _ in 0..length { for _ in 0..length {
ids.push(Uuid::new_v4().to_simple().to_string()); ids.push(Uuid::new_v4().as_simple().to_string());
} }
let unique_id = ids.join(""); let unique_id = ids.join("");
@@ -66,142 +91,6 @@ impl HeuristicTests {
unique_id unique_id
} }
/// wrapper for sending a filter to the filters event handler
fn send_filter(&self, filter: WildcardFilter) -> Result<()> {
self.handles
.filters
.send(Command::AddFilter(Box::new(filter)))
}
/// Tests the given url to see if it issues a wildcard response
///
/// In the event that url returns a wildcard response, a
/// [WildcardFilter](struct.WildcardFilter.html) is created and sent to the filters event
/// handler.
///
/// Returns the number of times to increment the caller's progress bar
pub async fn wildcard(&self, target_url: &str) -> Result<u64> {
log::trace!("enter: wildcard_test({:?})", target_url);
if self.handles.config.dont_filter {
// early return, dont_filter scans don't need tested
log::trace!("exit: wildcard_test -> 0");
return Ok(0);
}
let ferox_url = FeroxUrl::from_string(target_url, self.handles.clone());
let ferox_response = self.make_wildcard_request(&ferox_url, 1).await?;
// found a wildcard response
let mut wildcard = WildcardFilter::new(self.handles.config.dont_filter);
let wc_length = ferox_response.content_length();
if wc_length == 0 {
log::trace!("exit: wildcard_test -> 1");
self.send_filter(wildcard)?;
return Ok(1);
}
// content length of wildcard is non-zero, perform additional tests:
// make a second request, with a known-sized (64) longer request
let resp_two = self.make_wildcard_request(&ferox_url, 3).await?;
let wc2_length = resp_two.content_length();
if wc2_length == wc_length + (UUID_LENGTH * 2) {
// second length is what we'd expect to see if the requested url is
// reflected in the response along with some static content; aka custom 404
let url_len = ferox_url.path_length()?;
wildcard.dynamic = wc_length - url_len;
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let msg = format_template!("{} {:>9} {:>9} {:>9} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n", wildcard.dynamic);
ferox_print(&msg, &PROGRESS_PRINTER);
}
} else if wc_length == wc2_length {
wildcard.size = wc_length;
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let msg = format_template!("{} {:>9} {:>9} {:>9} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n", wildcard.size);
ferox_print(&msg, &PROGRESS_PRINTER);
}
}
self.send_filter(wildcard)?;
log::trace!("exit: wildcard_test");
Ok(2)
}
/// Generates a uuid and appends it to the given target url. The reasoning is that the randomly
/// generated unique string should not exist on and be served by the target web server.
///
/// Once the unique url is created, the request is sent to the server. If the server responds
/// back with a valid status code, the response is considered to be a wildcard response. If that
/// wildcard response has a 3xx status code, that redirection location is displayed to the user.
async fn make_wildcard_request(
&self,
target: &FeroxUrl,
length: usize,
) -> Result<FeroxResponse> {
log::trace!("enter: make_wildcard_request({}, {})", target, length);
let unique_str = self.unique_string(length);
let nonexistent_url = target.format(&unique_str, None)?;
let response = make_request(
&self.handles.config.client,
&nonexistent_url.to_owned(),
self.handles.config.output_level,
self.handles.stats.tx.clone(),
)
.await?;
if self
.handles
.config
.status_codes
.contains(&response.status().as_u16())
{
// found a wildcard response
let mut ferox_response =
FeroxResponse::from(response, true, self.handles.config.output_level).await;
ferox_response.set_wildcard(true);
if self
.handles
.filters
.data
.should_filter_response(&ferox_response, self.handles.stats.tx.clone())
{
bail!("filtered response")
}
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let boxed = Box::new(ferox_response.clone());
self.handles.output.send(Command::Report(boxed))?;
}
log::trace!("exit: make_wildcard_request -> {}", ferox_response);
return Ok(ferox_response);
}
log::trace!("exit: make_wildcard_request -> Err");
bail!("uninteresting status code")
}
/// Simply tries to connect to all given sites before starting to scan /// Simply tries to connect to all given sites before starting to scan
/// ///
/// In the event that no sites can be reached, the program will exit. /// In the event that no sites can be reached, the program will exit.
@@ -213,15 +102,10 @@ impl HeuristicTests {
let mut good_urls = vec![]; let mut good_urls = vec![];
for target_url in target_urls { for target_url in target_urls {
let url = FeroxUrl::from_string(&target_url, self.handles.clone()); let url = FeroxUrl::from_string(target_url, self.handles.clone());
let request = skip_fail!(url.format("", None)); let request = skip_fail!(url.format("", None));
let result = make_request(
&self.handles.config.client, let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await;
&request,
self.handles.config.output_level,
self.handles.stats.tx.clone(),
)
.await;
match result { match result {
Ok(_) => { Ok(_) => {
@@ -234,12 +118,12 @@ impl HeuristicTests {
) { ) {
if e.to_string().contains(":SSL") { if e.to_string().contains(":SSL") {
ferox_print( ferox_print(
&format!("Could not connect to {} due to SSL errors (run with -k to ignore), skipping...", target_url), &format!("Could not connect to {target_url} due to SSL errors (run with -k to ignore), skipping..."),
&PROGRESS_PRINTER, &PROGRESS_PRINTER,
); );
} else { } else {
ferox_print( ferox_print(
&format!("Could not connect to {}, skipping...", target_url), &format!("Could not connect to {target_url}, skipping..."),
&PROGRESS_PRINTER, &PROGRESS_PRINTER,
); );
} }
@@ -256,6 +140,373 @@ impl HeuristicTests {
log::trace!("exit: connectivity_test -> {:?}", good_urls); log::trace!("exit: connectivity_test -> {:?}", good_urls);
Ok(good_urls) Ok(good_urls)
} }
/// heuristic designed to detect when a server has directory listing enabled
pub async fn directory_listing(&self, target_url: &str) -> Result<Option<DirListingResult>> {
log::trace!("enter: directory_listing({})", target_url);
let tgt = if !target_url.ends_with('/') {
// if left unchanged, this function would be called against redirects that point to
// valid directories for most, if not all, directories beyond the initial urls.
// so, instead of `directory_listing("http://localhost") -> None` we get
// `directory_listing("http://localhost/") -> Some(DirListingResult)` if there is
// directory listing beyond the redirect
format!("{target_url}/")
} else {
target_url.to_string()
};
let url = FeroxUrl::from_string(&tgt, self.handles.clone());
let request = url.format("", None)?;
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await?;
let ferox_response = FeroxResponse::from(
result,
&url.target,
DEFAULT_METHOD,
self.handles.config.output_level,
)
.await;
let body = ferox_response.text();
let html = Html::parse_document(body);
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,
dirlist_type.unwrap()
);
let ferox_msg = FeroxMessage {
kind: "log".to_string(),
message: msg.clone(),
level: "MSG".to_string(),
time_offset: 0.0,
module: "feroxbuster::heuristics".to_string(),
};
self.handles
.output
.tx_file
.send(Command::WriteToDisk(Box::new(ferox_msg)))
.unwrap_or_default();
log::info!("{}", msg);
let result = DirListingResult {
dir_list_type: dirlist_type,
response: ferox_response,
};
log::trace!("exit: directory_listing -> {:?}", result);
return Ok(Some(result));
}
log::trace!("exit: directory_listing -> None");
Ok(None)
}
/// Directory listing heuristic detection, uses <title> tag to make its determination. When
/// the inner html of <title> matches one of the following, a `DirListingType` is returned.
/// - apache: `Index of /`
/// - tomcat/python: `Directory Listing for /`
/// - ASP.NET: `Directory Listing -- /`
/// - <host> - /: iis, azure, skipping due to loose heuristic
fn detect_directory_listing(&self, html: &Html) -> Option<DirListingType> {
log::trace!("enter: detect_directory_listing(html body...)");
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();
let dirlist_type = if title.contains("directory listing for /") {
Some(DirListingType::TomCatOrPython)
} else if title.contains("index of /") {
Some(DirListingType::Apache)
} else if title.contains("directory listing -- /") {
Some(DirListingType::AspDotNet)
} else {
// IIS_AZURE purposely skipped for now
None
};
if dirlist_type.is_some() {
log::trace!("exit: detect_directory_listing -> {:?}", dirlist_type);
return dirlist_type;
}
}
log::trace!("exit: detect_directory_listing -> None");
None
}
/// 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
pub async fn detect_404_like_responses(
&self,
target_url: &str,
) -> Result<Option<WildcardResult>> {
log::trace!("enter: detect_404_like_responses({:?})", target_url);
if self.handles.config.dont_filter {
// early return, dont_filter scans don't need tested
log::trace!("exit: detect_404_like_responses -> dont_filter is true");
return Ok(None);
}
let mut req_counter = 0;
let data = if self.handles.config.data.is_empty() {
None
} else {
Some(self.handles.config.data.as_slice())
};
// To take care of slash when needed
let slash = if self.handles.config.add_slash {
Some("/")
} else {
None
};
// 4 is due to the array in the nested for loop below
let mut responses = Vec::with_capacity(4);
// for every method, attempt to id its 404 response
//
// a good example of one where the GET/POST differ is on hackthebox:
// - http://prd.m.rendering-api.interface.htb/api
for method in self.handles.config.methods.iter() {
for (prefix, length) in [("", 1), ("", 3), (".htaccess", 1), ("admin", 1)] {
let path = format!("{prefix}{}", self.unique_string(length));
let ferox_url = FeroxUrl::from_string(target_url, self.handles.clone());
let nonexistent_url = ferox_url.format(&path, slash)?;
// example requests:
// - http://localhost/2fc1077836ad43ab98b7a31c2ca28fea
// - http://localhost/92969beae6bf4beb855d1622406d87e395c87387a9ad432e8a11245002b709b03cf609d471004154b83bcc1c6ec49f6f
// - http://localhost/.htaccessa005a2131e68449aa26e99029c914c09
// - http://localhost/adminf1d2541e73c44dcb9d1fb7d93334b280
let response =
logged_request(&nonexistent_url, method, data, self.handles.clone()).await;
req_counter += 1;
// continue to next on error
let response = skip_fail!(response);
if !self
.handles
.config
.status_codes
.contains(&response.status().as_u16())
{
// if the response code isn't one that's accepted via -s values, then skip to the next
//
// the default value for -s is all status codes, so unless the user says otherwise
// this won't fire
continue;
}
let ferox_response = FeroxResponse::from(
response,
&ferox_url.target,
method,
self.handles.config.output_level,
)
.await;
responses.push(ferox_response);
}
if responses.len() < 2 {
// don't have enough responses to make a determination, continue to next method
responses.clear();
continue;
}
// Command::AddFilter, &str (bytes/words/lines), usize (i.e. length associated with the type)
let Some(filter) = self.examine_404_like_responses(&responses) else {
// no match was found during analysis of responses
responses.clear();
continue;
};
// report to the user, if appropriate
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
// sentry value to control whether or not to print the filter
// used because we only want to print the same filter once
let mut print_sentry = true;
if let Ok(filters) = self.handles.filters.data.filters.read() {
for other in filters.iter() {
if let Some(other_wildcard) =
other.as_any().downcast_ref::<WildcardFilter>()
{
if &*filter == other_wildcard {
print_sentry = false;
break;
}
}
}
}
if print_sentry {
ferox_print(&format!("{}", filter), &PROGRESS_PRINTER);
}
}
// create the new filter
self.handles.filters.send(Command::AddFilter(filter))?;
// if we're here, we've detected a 404-like response pattern, and we're already filtering for size/word/line
//
// in addition, we'll create a similarity filter as a fallback
let hash = SIM_HASHER.create_signature(preprocess(responses[0].text()).iter());
let sim_filter = SimilarityFilter {
hash,
original_url: responses[0].url().to_string(),
};
self.handles
.filters
.send(Command::AddFilter(Box::new(sim_filter)))?;
if responses[0].is_directory() {
// response is either a 3XX with a Location header that matches url + '/'
// or it's a 2XX that ends with a '/'
// or it's a 403 that ends with a '/'
// set the wildcard flag to true, so we can check it when preventing
// recursion in event_handlers/scans.rs
responses[0].set_wildcard(true);
// add the response to the global list of responses
RESPONSES.insert(responses[0].clone());
// function-internal magic number, indicates that we've detected a wildcard directory
req_counter += 100;
}
// reset the responses for the next method, if it exists
responses.clear();
}
log::trace!("exit: detect_404_like_responses");
let retval = if req_counter > 100 {
WildcardResult::WildcardDirectory(req_counter)
} else {
WildcardResult::FourOhFourLike(req_counter)
};
Ok(Some(retval))
}
/// for all responses, examine chars/words/lines
/// if all responses respective lengths match each other, we can assume
/// that will remain true for subsequent non-existent urls
///
/// values are examined from most to least specific (content length, word count, line count)
fn examine_404_like_responses(
&self,
responses: &[FeroxResponse],
) -> Option<Box<WildcardFilter>> {
let mut size_sentry = true;
let mut word_sentry = true;
let mut line_sentry = true;
let method = responses[0].method();
let status_code = responses[0].status();
let content_length = responses[0].content_length();
let word_count = responses[0].word_count();
let line_count = responses[0].line_count();
for response in &responses[1..] {
// if any of the responses differ in length, that particular
// response length type is no longer a candidate for filtering
if response.content_length() != content_length {
size_sentry = false;
}
if response.word_count() != word_count {
word_sentry = false;
}
if response.line_count() != line_count {
line_sentry = false;
}
}
if !size_sentry && !word_sentry && !line_sentry {
// none of the response lengths match, so we can't filter on any of them
return None;
}
let mut wildcard = WildcardFilter {
content_length: None,
line_count: None,
word_count: None,
method: method.to_string(),
status_code: status_code.as_u16(),
dont_filter: self.handles.config.dont_filter,
};
match (size_sentry, word_sentry, line_sentry) {
(true, true, true) => {
// all three types of length match, so we can't filter on any of them
wildcard.content_length = Some(content_length);
wildcard.word_count = Some(word_count);
wildcard.line_count = Some(line_count);
}
(true, true, false) => {
// content length and word count match, so we can filter on either
wildcard.content_length = Some(content_length);
wildcard.word_count = Some(word_count);
}
(true, false, true) => {
// content length and line count match, so we can filter on either
wildcard.content_length = Some(content_length);
wildcard.line_count = Some(line_count);
}
(false, true, true) => {
// word count and line count match, so we can filter on either
wildcard.word_count = Some(word_count);
wildcard.line_count = Some(line_count);
}
(true, false, false) => {
// content length matches, so we can filter on that
wildcard.content_length = Some(content_length);
}
(false, true, false) => {
// word count matches, so we can filter on that
wildcard.word_count = Some(word_count);
}
(false, false, true) => {
// line count matches, so we can filter on that
wildcard.line_count = Some(line_count);
}
(false, false, false) => {
// none of the length types match, so we can't filter on any of them
unreachable!("no wildcard size matches; handled by the if statement above");
}
};
Some(Box::new(wildcard))
}
} }
#[cfg(test)] #[cfg(test)]
@@ -271,4 +522,51 @@ mod tests {
assert_eq!(tester.unique_string(i).len(), i * 32); assert_eq!(tester.unique_string(i).len(), i * 32);
} }
} }
#[test]
/// `detect_directory_listing` correctly identifies tomcat/python instances
fn detect_directory_listing_finds_tomcat_python() {
let html = "<title>directory listing for /</title>";
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.unwrap(),
DirListingType::TomCatOrPython
));
}
#[test]
/// `detect_directory_listing` correctly identifies apache instances
fn detect_directory_listing_finds_apache() {
let html = "<title>index of /</title>";
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.unwrap(), DirListingType::Apache));
}
#[test]
/// `detect_directory_listing` correctly identifies ASP.NET instances
fn detect_directory_listing_finds_asp_dot_net() {
let html = "<title>directory listing -- /</title>";
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.unwrap(), DirListingType::AspDotNet));
}
#[test]
/// `detect_directory_listing` returns None when heuristic doesn't match
fn detect_directory_listing_returns_none_as_default() {
let html = "<title>derp listing -- /</title>";
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

@@ -1,5 +1,8 @@
#![deny(clippy::all)]
#![allow(clippy::mutex_atomic)]
use anyhow::Result; use anyhow::Result;
use reqwest::StatusCode; use reqwest::StatusCode;
use std::collections::HashSet;
use tokio::{ use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender}, sync::mpsc::{UnboundedReceiver, UnboundedSender},
task::JoinHandle, task::JoinHandle,
@@ -26,6 +29,7 @@ mod macros;
mod url; mod url;
mod response; mod response;
mod message; mod message;
mod nlp;
/// Alias for tokio::sync::mpsc::UnboundedSender<Command> /// Alias for tokio::sync::mpsc::UnboundedSender<Command>
pub(crate) type CommandSender = UnboundedSender<Command>; pub(crate) type CommandSender = UnboundedSender<Command>;
@@ -39,53 +43,136 @@ pub(crate) type Joiner = JoinHandle<Result<()>>;
/// Generic mpsc::unbounded_channel type to tidy up some code /// Generic mpsc::unbounded_channel type to tidy up some code
pub(crate) type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>); pub(crate) type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>);
/// Wrapper around the results of performing any kind of extraction against a target web page
pub(crate) type ExtractionResult = HashSet<String>;
/// Version pulled from Cargo.toml at compile time /// Version pulled from Cargo.toml at compile time
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Maximum number of file descriptors that can be opened during a scan /// Maximum number of file descriptors that can be opened during a scan
pub const DEFAULT_OPEN_FILE_LIMIT: usize = 8192; pub const DEFAULT_OPEN_FILE_LIMIT: u64 = 8192;
/// Default value used to determine near-duplicate web pages (equivalent to 95%) /// Default set of extensions to Ignore when auto-collecting extensions during scans
pub const SIMILARITY_THRESHOLD: u32 = 95; 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 wordlist to use when `-w|--wordlist` isn't specified and not `wordlist` isn't set /// 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. /// in a [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file.
/// ///
/// defaults to kali's default install location: /// defaults to kali's default install location on linux:
/// - `/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt` /// - `/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt`
///
/// and to the current directory on windows
/// - `.\seclists\Discovery\Web-Content\raft-medium-directories.txt`
#[cfg(not(target_os = "windows"))]
pub const DEFAULT_WORDLIST: &str = pub const DEFAULT_WORDLIST: &str =
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt"; "/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
#[cfg(target_os = "windows")]
pub const DEFAULT_WORDLIST: &str =
".\\SecLists\\Discovery\\Web-Content\\raft-medium-directories.txt";
pub const SECONDARY_WORDLIST: &str =
"/usr/local/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
/// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan /// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan
pub(crate) static SLEEP_DURATION: u64 = 500; pub(crate) const SLEEP_DURATION: u64 = 500;
/// Default list of status codes to report /// The percentage of requests as errors it takes to be deemed too high
/// pub const HIGH_ERROR_RATIO: f64 = 0.90;
/// * 200 Ok
/// * 204 No Content /// Default list of status codes to report (all of them)
/// * 301 Moved Permanently pub const DEFAULT_STATUS_CODES: [StatusCode; 60] = [
/// * 302 Found // all 1XX response codes
/// * 307 Temporary Redirect StatusCode::CONTINUE,
/// * 308 Permanent Redirect StatusCode::SWITCHING_PROTOCOLS,
/// * 401 Unauthorized StatusCode::PROCESSING,
/// * 403 Forbidden // all 2XX response codes
/// * 405 Method Not Allowed
pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
StatusCode::OK, StatusCode::OK,
StatusCode::CREATED,
StatusCode::ACCEPTED,
StatusCode::NON_AUTHORITATIVE_INFORMATION,
StatusCode::NO_CONTENT, StatusCode::NO_CONTENT,
StatusCode::RESET_CONTENT,
StatusCode::PARTIAL_CONTENT,
StatusCode::MULTI_STATUS,
StatusCode::ALREADY_REPORTED,
StatusCode::IM_USED,
// all 3XX response codes
StatusCode::MULTIPLE_CHOICES,
StatusCode::MOVED_PERMANENTLY, StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND, StatusCode::FOUND,
StatusCode::SEE_OTHER,
StatusCode::NOT_MODIFIED,
StatusCode::USE_PROXY,
StatusCode::TEMPORARY_REDIRECT, StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT, StatusCode::PERMANENT_REDIRECT,
// all 4XX response codes
StatusCode::BAD_REQUEST,
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
StatusCode::PAYMENT_REQUIRED,
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
StatusCode::NOT_FOUND,
StatusCode::METHOD_NOT_ALLOWED, StatusCode::METHOD_NOT_ALLOWED,
StatusCode::NOT_ACCEPTABLE,
StatusCode::PROXY_AUTHENTICATION_REQUIRED,
StatusCode::REQUEST_TIMEOUT,
StatusCode::CONFLICT,
StatusCode::GONE,
StatusCode::LENGTH_REQUIRED,
StatusCode::PRECONDITION_FAILED,
StatusCode::PAYLOAD_TOO_LARGE,
StatusCode::URI_TOO_LONG,
StatusCode::UNSUPPORTED_MEDIA_TYPE,
StatusCode::RANGE_NOT_SATISFIABLE,
StatusCode::EXPECTATION_FAILED,
StatusCode::IM_A_TEAPOT,
StatusCode::MISDIRECTED_REQUEST,
StatusCode::UNPROCESSABLE_ENTITY,
StatusCode::LOCKED,
StatusCode::FAILED_DEPENDENCY,
StatusCode::UPGRADE_REQUIRED,
StatusCode::PRECONDITION_REQUIRED,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
// all 5XX response codes
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::NOT_IMPLEMENTED,
StatusCode::BAD_GATEWAY,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::GATEWAY_TIMEOUT,
StatusCode::HTTP_VERSION_NOT_SUPPORTED,
StatusCode::VARIANT_ALSO_NEGOTIATES,
StatusCode::INSUFFICIENT_STORAGE,
StatusCode::LOOP_DETECTED,
StatusCode::NOT_EXTENDED,
StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
]; ];
/// Default method for requests
pub(crate) const DEFAULT_METHOD: &str = "GET";
/// Default filename for config file settings /// Default filename for config file settings
/// ///
/// Expected location is in the same directory as the feroxbuster binary. /// Expected location is in the same directory as the feroxbuster binary.
pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml"; pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml";
/// User agents to select from when random agent is being used
pub const USER_AGENTS: [&str; 12] = [
"Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36",
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Windows Phone 10.0; Android 6.0.1; Microsoft; RM-1152) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Mobile Safari/537.36 Edge/15.15254",
"Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
"Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1",
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
"Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)",
];
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -65,7 +65,7 @@ pub fn initialize(config: Arc<Configuration>) -> Result<()> {
kind: "log".to_string(), kind: "log".to_string(),
}; };
PROGRESS_PRINTER.println(&log_entry.as_str()); PROGRESS_PRINTER.println(log_entry.as_str());
if let Some(buffered_file) = file.clone() { if let Some(buffered_file) = file.clone() {
if let Ok(mut unlocked) = buffered_file.write() { if let Ok(mut unlocked) = buffered_file.write() {

View File

@@ -1,53 +1,80 @@
use std::io::stdin;
use std::{ use std::{
collections::HashSet, env::args,
fs::File, fs::{create_dir, remove_file, File},
io::{stderr, BufRead, BufReader}, io::{stderr, BufRead, BufReader},
ops::Index,
path::Path,
process::Command,
sync::{atomic::Ordering, Arc}, sync::{atomic::Ordering, Arc},
}; };
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use futures::StreamExt; use futures::StreamExt;
use tokio::{io, sync::oneshot}; use tokio::{
io,
sync::{oneshot, Semaphore},
};
use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::codec::{FramedRead, LinesCodec};
use feroxbuster::{ use feroxbuster::{
banner::{Banner, UPDATE_URL}, banner::{Banner, UPDATE_URL},
config::{Configuration, OutputLevel}, config::{Configuration, OutputLevel},
event_handlers::{ event_handlers::{
Command::{CreateBar, Exit, JoinTasks, LoadStats, ScanInitialUrls, UpdateWordlist}, Command::{
AddHandles, CreateBar, Exit, JoinTasks, LoadStats, ScanInitialUrls, UpdateWordlist,
},
FiltersHandler, Handles, ScanHandler, StatsHandler, Tasks, TermInputHandler, FiltersHandler, Handles, ScanHandler, StatsHandler, Tasks, TermInputHandler,
TermOutHandler, SCAN_COMPLETE, TermOutHandler, SCAN_COMPLETE,
}, },
filters, heuristics, logger, filters, heuristics, logger,
progress::{PROGRESS_BAR, PROGRESS_PRINTER}, progress::{PROGRESS_BAR, PROGRESS_PRINTER},
scan_manager::{self}, scan_manager::{self, ScanType},
scanner, scanner,
utils::fmt_err, utils::{fmt_err, slugify_filename},
SECONDARY_WORDLIST,
}; };
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
use feroxbuster::{utils::set_open_file_limit, DEFAULT_OPEN_FILE_LIMIT}; use feroxbuster::{utils::set_open_file_limit, DEFAULT_OPEN_FILE_LIMIT};
use lazy_static::lazy_static;
use regex::Regex;
/// Create a HashSet of Strings from the given wordlist then stores it inside an Arc lazy_static! {
fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<HashSet<String>>> { /// Limits the number of parallel scans active at any given time when using --parallel
static ref PARALLEL_LIMITER: Semaphore = Semaphore::new(0);
}
/// Create a Vec of Strings from the given wordlist then stores it inside an Arc
fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<Vec<String>>> {
log::trace!("enter: get_unique_words_from_wordlist({})", path); log::trace!("enter: get_unique_words_from_wordlist({})", path);
let mut trimmed_word = false;
let file = File::open(&path).with_context(|| format!("Could not open {}", path))?; let file = File::open(path).with_context(|| format!("Could not open {path}"))?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut words = HashSet::new(); // this empty string ensures that we call Requester::request with the base url, i.e.
// `http://localhost/` instead of going straight into `http://localhost/WORD.EXT`.
// for vanilla scans, it doesn't matter all that much, but it can be a significant difference
// when `-e` is used, depending on the content at the base url.
let mut words = vec![String::from("")];
for line in reader.lines() { for line in reader.lines() {
let result = match line { line.map(|result| {
Ok(read_line) => read_line, if !result.starts_with('#') && !result.is_empty() {
Err(_) => continue, if result.starts_with('/') {
}; words.push(result.trim_start_matches('/').to_string());
trimmed_word = true;
} else {
words.push(result);
}
}
})
.ok();
}
if result.starts_with('#') || result.is_empty() { if trimmed_word {
continue; log::warn!("Some words in the wordlist started with a leading forward-slash; those words were trimmed (i.e. /word -> word)");
}
words.insert(result);
} }
log::trace!( log::trace!(
@@ -61,25 +88,12 @@ fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<HashSet<String>>> {
/// Determine whether it's a single url scan or urls are coming from stdin, then scan as needed /// Determine whether it's a single url scan or urls are coming from stdin, then scan as needed
async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> { async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: scan({:?}, {:?})", targets, handles); log::trace!("enter: scan({:?}, {:?})", targets, handles);
// cloning an Arc is cheap (it's basically a pointer into the heap)
// so that will allow for cheap/safe sharing of a single wordlist across multi-target scans
// as well as additional directories found as part of recursion
let words = {
let words_handles = handles.clone();
tokio::spawn(async move { get_unique_words_from_wordlist(&words_handles.config.wordlist) })
.await??
};
if words.len() == 0 {
bail!("Did not find any words in {}", handles.config.wordlist);
}
let scanned_urls = handles.ferox_scans()?; let scanned_urls = handles.ferox_scans()?;
handles.send_scan_command(UpdateWordlist(words.clone()))?; handles.send_scan_command(UpdateWordlist(handles.wordlist.clone()))?;
scanner::initialize(words.len(), handles.clone()).await?; scanner::initialize(handles.wordlist.len(), handles.clone()).await?;
// at this point, the stat thread's progress bar can be created; things that needed to happen // at this point, the stat thread's progress bar can be created; things that needed to happen
// first: // first:
@@ -88,8 +102,20 @@ async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> {
// having been set, makes it so the progress bar doesn't flash as full before anything has // having been set, makes it so the progress bar doesn't flash as full before anything has
// even happened // even happened
if matches!(handles.config.output_level, OutputLevel::Default) { if matches!(handles.config.output_level, OutputLevel::Default) {
let mut total_offset = 0;
if let Ok(guard) = handles.scans.read() {
if let Some(handle) = &*guard {
if let Ok(scans) = handle.data.scans.read() {
for scan in scans.iter() {
total_offset += scan.requests_made_so_far();
}
}
}
}
// only create the bar if no --silent|--quiet // only create the bar if no --silent|--quiet
handles.stats.send(CreateBar)?; handles.stats.send(CreateBar(total_offset))?;
// blocks until the bar is created / avoids race condition in first two bars // blocks until the bar is created / avoids race condition in first two bars
handles.stats.sync().await?; handles.stats.sync().await?;
@@ -98,7 +124,7 @@ async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> {
if handles.config.resumed { if handles.config.resumed {
// display what has already been completed // display what has already been completed
scanned_urls.print_known_responses(); scanned_urls.print_known_responses();
scanned_urls.print_completed_bars(words.len())?; scanned_urls.print_completed_bars(handles.wordlist.len())?;
} }
log::debug!("sending {:?} to be scanned as initial targets", targets); log::debug!("sending {:?} to be scanned as initial targets", targets);
@@ -133,8 +159,8 @@ async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
for scan in scans.iter() { for scan in scans.iter() {
// ferox_scans gets deserialized scans added to it at program start if --resume-from // ferox_scans gets deserialized scans added to it at program start if --resume-from
// is used, so scans that aren't marked complete still need to be scanned // is used, so scans that aren't marked complete still need to be scanned
if scan.is_complete() { if scan.is_complete() || matches!(scan.scan_type, ScanType::File) {
// this one's already done, ignore it // this one's already done, or it's not a directory, ignore it
continue; continue;
} }
@@ -145,6 +171,33 @@ async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
targets.push(handles.config.target_url.clone()); targets.push(handles.config.target_url.clone());
} }
// remove footgun that arises if a --dont-scan value matches on a base url
for target in targets.iter_mut() {
for denier in &handles.config.regex_denylist {
if denier.is_match(target) {
bail!(
"The regex '{}' matches {}; the scan will never start",
denier,
target
);
}
}
for denier in &handles.config.url_denylist {
if denier.as_str().trim_end_matches('/') == target.trim_end_matches('/') {
bail!(
"The url '{}' matches {}; the scan will never start",
denier,
target
);
}
}
if !target.starts_with("http") && !target.starts_with("https") {
// --url hackerone.com
*target = format!("https://{target}");
}
}
log::trace!("exit: get_targets -> {:?}", targets); log::trace!("exit: get_targets -> {:?}", targets);
Ok(targets) Ok(targets)
@@ -166,6 +219,30 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
PROGRESS_BAR.join().unwrap(); PROGRESS_BAR.join().unwrap();
}); });
// cloning an Arc is cheap (it's basically a pointer into the heap)
// so that will allow for cheap/safe sharing of a single wordlist across multi-target scans
// as well as additional directories found as part of recursion
let words = match get_unique_words_from_wordlist(&config.wordlist) {
Ok(w) => w,
Err(err) => {
let secondary = Path::new(SECONDARY_WORDLIST);
if secondary.exists() {
eprintln!("Found wordlist in secondary location");
get_unique_words_from_wordlist(SECONDARY_WORDLIST)?
} else {
return Err(err);
}
}
};
if words.len() <= 1 {
// the check is now <= 1 due to the initial empty string added in 2.6.0
// 1 -> empty wordlist
// 0 -> error
bail!("Did not find any words in {}", config.wordlist);
}
// spawn all event handlers, expect back a JoinHandle and a *Handle to the specific event // spawn all event handlers, expect back a JoinHandle and a *Handle to the specific event
let (stats_task, stats_handle) = StatsHandler::initialize(config.clone()); let (stats_task, stats_handle) = StatsHandler::initialize(config.clone());
let (filters_task, filters_handle) = FiltersHandler::initialize(); let (filters_task, filters_handle) = FiltersHandler::initialize();
@@ -178,11 +255,13 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
filters_handle, filters_handle,
out_handle, out_handle,
config.clone(), config.clone(),
words,
)); ));
let (scan_task, scan_handle) = ScanHandler::initialize(handles.clone()); let (scan_task, scan_handle) = ScanHandler::initialize(handles.clone());
handles.set_scan_handle(scan_handle); // must be done after Handles initialization handles.set_scan_handle(scan_handle); // must be done after Handles initialization
handles.output.send(AddHandles(handles.clone()))?;
filters::initialize(handles.clone()).await?; // send user-supplied filters to the handler filters::initialize(handles.clone()).await?; // send user-supplied filters to the handler
@@ -210,7 +289,7 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
let from_here = config.resume_from.clone(); let from_here = config.resume_from.clone();
// populate FeroxScans object with previously seen scans // populate FeroxScans object with previously seen scans
scanned_urls.add_serialized_scans(&from_here)?; scanned_urls.add_serialized_scans(&from_here, handles.clone())?;
// populate Stats object with previously known statistics // populate Stats object with previously known statistics
handles.stats.send(LoadStats(from_here))?; handles.stats.send(LoadStats(from_here))?;
@@ -222,10 +301,136 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
Err(e) => { Err(e) => {
// should only happen in the event that there was an error reading from stdin // should only happen in the event that there was an error reading from stdin
clean_up(handles, tasks).await?; clean_up(handles, tasks).await?;
bail!("Could not get determine initial targets: {}", e); bail!("Could not determine initial targets: {}", e);
} }
}; };
// --parallel branch
if config.parallel > 0 {
log::trace!("enter: parallel branch");
PARALLEL_LIMITER.add_permits(config.parallel);
let invocation = args();
let para_regex =
Regex::new("--stdin|-q|--quiet|--silent|--verbosity|-v|-vv|-vvv|-vvvv").unwrap();
// remove stdin since only the original process will process targets
// remove quiet and silent so we can force silent later to normalize output
let mut original = invocation
.filter(|s| !para_regex.is_match(s))
.collect::<Vec<String>>();
original.push("--silent".to_string()); // only output modifier allowed
// we need remove --parallel from command line so we don't hit this branch over and over
// but we must remove --parallel N manually; the filter above never sees --parallel and the
// value passed to it at the same time, so can't filter them out in one pass
// unwrap is fine, as it has to be in the args for us to be in this code branch
let parallel_index = original.iter().position(|s| *s == "--parallel").unwrap();
// remove --parallel
original.remove(parallel_index);
// remove N passed to --parallel (it's the same index again since everything shifts
// from removing --parallel)
original.remove(parallel_index);
// to log unique files to a shared folder, we need to first check for the presence
// of -o|--output.
let out_dir = if !config.output.is_empty() {
// -o|--output was used, so we'll attempt to create a directory to store the files
let output_path = Path::new(&handles.config.output);
// this only returns None if the path terminates in `..`. Since I don't want to
// hand-hold to that degree, we'll unwrap and fail if the output path ends in `..`
let base_name = output_path.file_name().unwrap();
let new_folder = slugify_filename(&base_name.to_string_lossy(), "", "logs");
let final_path = output_path.with_file_name(new_folder);
// create the directory or fail silently, assuming the reason for failure is that
// the path exists already
create_dir(&final_path).unwrap_or(());
final_path.to_string_lossy().to_string()
} else {
String::new()
};
// unvalidated targets fresh from stdin, just spawn children and let them do all checks
for target in targets {
// add the current target to the provided command
let mut cloned = original.clone();
if !out_dir.is_empty() {
// output directory value is not empty, need to join output directory with
// unique scan filename
// unwrap is ok, we already know -o was used
let out_idx = original
.iter()
.position(|s| *s == "--output" || *s == "-o")
.unwrap();
let filename = slugify_filename(&target, "ferox", "log");
let full_path = Path::new(&out_dir)
.join(filename)
.to_string_lossy()
.to_string();
// a +1 to the index is fine here, as clap has already validated that
// -o|--output has a value associated with it
cloned[out_idx + 1] = full_path;
}
cloned.push("-u".to_string());
cloned.push(target);
let bin = cloned.index(0).to_owned(); // user's path to feroxbuster
let args = cloned.index(1..).to_vec(); // and args
let permit = PARALLEL_LIMITER.acquire().await?;
log::debug!("parallel exec: {} {}", bin, args.join(" "));
tokio::task::spawn_blocking(move || {
let result = Command::new(bin)
.args(&args)
.spawn()
.expect("failed to spawn a child process")
.wait()
.expect("child process errored during execution");
drop(permit);
result
});
}
// the output handler creates an empty file to which it will try to write, because
// this happens before we enter the --parallel branch, we need to remove that file
// if it's empty
let output = handles.config.output.to_owned();
clean_up(handles, tasks).await?;
let file = Path::new(&output);
if file.exists() {
// expectation is that this is always true for the first ferox process
if file.metadata()?.len() == 0 {
// empty file, attempt to remove it
remove_file(file)?;
}
}
log::trace!("exit: parallel branch && wrapped main");
return Ok(());
}
if matches!(config.output_level, OutputLevel::Default) { if matches!(config.output_level, OutputLevel::Default) {
// only print banner if output level is default (no banner on --quiet|--silent) // only print banner if output level is default (no banner on --quiet|--silent)
let std_stderr = stderr(); // std::io::stderr let std_stderr = stderr(); // std::io::stderr
@@ -246,7 +451,7 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
// The TermOutHandler spawns a FileOutHandler, so errors in the FileOutHandler never bubble // The TermOutHandler spawns a FileOutHandler, so errors in the FileOutHandler never bubble
// up due to the TermOutHandler never awaiting the result of FileOutHandler::start (that's // up due to the TermOutHandler never awaiting the result of FileOutHandler::start (that's
// done later here in main). Ping checks that the tx/rx connection to the file handler works // done later here in main). sync checks that the tx/rx connection to the file handler works
if send_to_file && handles.output.sync(send_to_file).await.is_err() { if send_to_file && handles.output.sync(send_to_file).await.is_err() {
// output file specified and file handler could not initialize // output file specified and file handler could not initialize
clean_up(handles, tasks).await?; clean_up(handles, tasks).await?;
@@ -276,7 +481,7 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
clean_up(handles, tasks).await?; clean_up(handles, tasks).await?;
bail!(fmt_err(&format!("Failed while scanning: {}", e))); bail!(fmt_err(&format!("Failed while scanning: {e}")));
} }
} }
@@ -341,9 +546,39 @@ fn main() -> Result<()> {
.enable_all() .enable_all()
.build() .build()
{ {
let future = wrapped_main(config); let future = wrapped_main(config.clone());
if let Err(e) = runtime.block_on(future) { if let Err(e) = runtime.block_on(future) {
eprintln!("{}", e); eprintln!("{e}");
// the code below is to facilitate testing tests/test_banner entries. Since it's an
// integration test, normal test detection (cfg!(test), etc...) won't work. So, in
// the tests themselves, we pass
// `--wordlist /definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676`
// and look for that here to print the banner.
//
// this change became a necessity once we moved wordlist parsing out of `scan` and into
// `wrapped_main`.
if e.to_string()
.contains("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
{
// support the handful of tests that use `--stdin`
let targets: Vec<_> = if config.stdin {
stdin().lock().lines().map(|tgt| tgt.unwrap()).collect()
} else {
vec!["http://localhost".to_string()]
};
// print the banner to stderr
let std_stderr = stderr(); // std::io::stderr
let banner = Banner::new(&targets, &config);
if !config.quiet && !config.silent {
banner.print_to(std_stderr, config).unwrap();
}
}
// if we've encountered an error before clean_up can be called (i.e. a wordlist error)
// we need to at least spin-down the progress bar
PROGRESS_PRINTER.finish();
}; };
} }

View File

@@ -1,11 +1,11 @@
use anyhow::Context; use anyhow::Context;
use console::{style, Color}; use console::{style, Color};
use serde::{Deserialize, Serialize};
use crate::traits::FeroxSerialize; use crate::traits::FeroxSerialize;
use crate::utils::fmt_err; use crate::utils::fmt_err;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default, Debug)]
/// Representation of a log entry, can be represented as a human readable string or JSON /// Representation of a log entry, can be represented as a human readable string or JSON
pub struct FeroxMessage { pub struct FeroxMessage {
#[serde(rename = "type")] #[serde(rename = "type")]
@@ -38,7 +38,7 @@ impl FeroxSerialize for FeroxMessage {
"DEBUG" => ("DBG", Color::Yellow), "DEBUG" => ("DBG", Color::Yellow),
"TRACE" => ("TRC", Color::Magenta), "TRACE" => ("TRC", Color::Magenta),
"WILDCARD" => ("WLD", Color::Cyan), "WILDCARD" => ("WLD", Color::Cyan),
_ => ("UNK", Color::White), _ => ("MSG", Color::White),
}; };
format!( format!(
@@ -118,4 +118,31 @@ mod tests {
assert_eq!(json.level, message.level); assert_eq!(json.level, message.level);
assert_eq!(json.kind, message.kind); assert_eq!(json.kind, message.kind);
} }
#[test]
/// test defaults for coverage
fn message_defaults() {
let msg = FeroxMessage::default();
assert_eq!(msg.level, String::new());
assert_eq!(msg.kind, String::new());
assert_eq!(msg.message, String::new());
assert_eq!(msg.module, String::new());
assert!(msg.time_offset < 0.1);
}
#[test]
/// ensure WILDCARD messages serialize to WLD and anything not known to UNK
fn message_as_str_edges() {
let mut msg = FeroxMessage {
message: "message".to_string(),
module: "utils".to_string(),
time_offset: 1.0,
level: "WILDCARD".to_string(),
kind: "log".to_string(),
};
assert!(console::strip_ansi_codes(&msg.as_str()).starts_with("WLD"));
msg.level = "UNKNOWN".to_string();
assert!(console::strip_ansi_codes(&msg.as_str()).starts_with("MSG"));
}
} }

334
src/nlp/constants.rs Normal file
View File

@@ -0,0 +1,334 @@
use lazy_static::lazy_static;
use regex::Regex;
lazy_static! {
/// regular expression to match on words with numbers, underscores, and hyphens
pub(super) static ref BOUNDED_WORD_REGEX: Regex = Regex::new(r"\b[a-zA-Z0-9_-]+\b").unwrap();
}
/// collection of stop words from spaCy with small modifications
pub(super) static STOP_WORDS: [&str; 323] = [
"'d",
"'ll",
"'m",
"'re",
"'s",
"'ve",
"a",
"about",
"above",
"across",
"after",
"afterwards",
"again",
"against",
"almost",
"alone",
"along",
"already",
"also",
"although",
"always",
"am",
"among",
"amongst",
"amount",
"an",
"and",
"another",
"any",
"anyhow",
"anyone",
"anything",
"anyway",
"anywhere",
"are",
"around",
"as",
"at",
"back",
"be",
"became",
"because",
"become",
"becomes",
"becoming",
"been",
"before",
"beforehand",
"behind",
"being",
"below",
"beside",
"besides",
"between",
"beyond",
"both",
"bottom",
"but",
"by",
"ca",
"call",
"can",
"cannot",
"could",
"did",
"do",
"does",
"doing",
"done",
"down",
"due",
"during",
"each",
"eight",
"either",
"eleven",
"else",
"elsewhere",
"empty",
"enough",
"even",
"ever",
"every",
"everyone",
"everything",
"everywhere",
"except",
"few",
"fifteen",
"fifty",
"first",
"five",
"for",
"former",
"formerly",
"forty",
"four",
"from",
"front",
"full",
"further",
"get",
"got",
"give",
"go",
"had",
"has",
"have",
"he",
"hence",
"her",
"here",
"hereafter",
"hereby",
"herein",
"hereupon",
"hers",
"herself",
"him",
"himself",
"his",
"how",
"however",
"hundred",
"i",
"if",
"in",
"indeed",
"into",
"is",
"it",
"its",
"itself",
"just",
"keep",
"last",
"latter",
"latterly",
"least",
"less",
"made",
"make",
"many",
"may",
"me",
"meanwhile",
"might",
"mine",
"more",
"moreover",
"most",
"mostly",
"move",
"much",
"must",
"my",
"myself",
"n't",
"name",
"namely",
"neither",
"never",
"nevertheless",
"next",
"nine",
"no",
"nobody",
"none",
"noone",
"nor",
"not",
"nothing",
"now",
"nowhere",
"n\u{2018}t",
"n\u{2019}t",
"of",
"off",
"often",
"on",
"once",
"one",
"only",
"onto",
"or",
"other",
"others",
"otherwise",
"our",
"ours",
"ourselves",
"out",
"over",
"own",
"part",
"per",
"perhaps",
"please",
"put",
"quite",
"rather",
"re",
"really",
"regarding",
"same",
"say",
"see",
"seem",
"seemed",
"seeming",
"seems",
"serious",
"several",
"she",
"should",
"side",
"since",
"six",
"sixty",
"so",
"some",
"somehow",
"someone",
"something",
"sometime",
"sometimes",
"somewhere",
"still",
"such",
"take",
"ten",
"than",
"that",
"the",
"their",
"them",
"themselves",
"then",
"thence",
"there",
"thereafter",
"thereby",
"therefore",
"therein",
"thereupon",
"these",
"they",
"third",
"this",
"those",
"though",
"three",
"through",
"throughout",
"thru",
"thus",
"to",
"together",
"too",
"toward",
"towards",
"twelve",
"twenty",
"two",
"under",
"unless",
"until",
"up",
"upon",
"used",
"using",
"various",
"very",
"via",
"was",
"we",
"well",
"were",
"what",
"whatever",
"when",
"whence",
"whenever",
"where",
"whereafter",
"whereas",
"whereby",
"wherein",
"whereupon",
"wherever",
"whether",
"which",
"while",
"whither",
"who",
"whoever",
"whole",
"whom",
"whose",
"why",
"will",
"with",
"within",
"without",
"would",
"yet",
"you",
"your",
"yours",
"yourself",
"yourselves",
"\u{2018}d",
"\u{2018}ll",
"\u{2018}m",
"\u{2018}re",
"\u{2018}s",
"\u{2018}ve",
"\u{2019}d",
"\u{2019}ll",
"\u{2019}m",
"\u{2019}re",
"\u{2019}s",
"\u{2019}ve",
];

223
src/nlp/document.rs Normal file
View File

@@ -0,0 +1,223 @@
use super::term::{Term, TermMetaData};
use super::utils::preprocess;
use scraper::{Html, Node, Selector};
use std::collections::HashMap;
/// data container representing a single document, in the nlp sense
#[derive(Debug, Default)]
pub(crate) struct Document {
/// collection of `Term`s and their associated metadata
terms: HashMap<Term, TermMetaData>,
/// number of terms contained within the document
number_of_terms: usize,
}
impl Document {
/// create a new `Document` from the given string
pub(super) fn new(text: &str) -> Self {
let mut document = Self::default();
let processed = preprocess(text);
document.number_of_terms += processed.len();
for normalized in processed {
if normalized.len() >= 2 {
document.add_term(&normalized)
}
}
document
}
/// add a `Term` to the document if it's not already tracked, otherwise increment the number
/// of times the term has been seen
fn add_term(&mut self, word: &str) {
let term = Term::new(word);
let metadata = self.terms.entry(term).or_insert_with(TermMetaData::new);
*metadata.count_mut() += 1;
}
/// create a new `Document` from the given HTML string
pub(crate) fn from_html(raw_html: &str) -> Self {
let selector = Selector::parse("body").unwrap();
let html = Html::parse_document(raw_html);
let text = html
.select(&selector)
.next()
.unwrap()
.descendants()
.filter_map(|node| {
if !node.value().is_text() && !node.value().is_comment() {
return None;
}
// have a Text||Comment node, trim whitespace to test for all whitespace stuff
let trimmed = if node.value().is_text() {
node.value().as_text().unwrap().text.trim()
} else {
node.value().as_comment().unwrap().comment.trim()
};
if trimmed.is_empty() {
return None;
}
// found a non-empty Text||Comment node, need to check its parent to determine if
// it's a <script>||<style> tag. We're assuming text within a script||style tag is
// uninteresting
let parent = node.parent().unwrap().value();
if !parent.is_element() {
return None;
}
// parent is an Element node, see if it's a <script> or <style>
if let Node::Element(element) = parent {
if element.name() == "script" || element.name() == "style" {
return None;
}
// at this point, we have a non-empty Text element with a non-script|style parent;
// now we can return the trimmed up string
return Some(format!("{trimmed} "));
}
// not an Element node
None
})
.collect::<String>();
// call `new` to push the parsed html through the pre-processing pipeline and process all
// the words
Self::new(&text)
}
/// Log normalized weighting scheme for term frequency
pub(super) fn term_frequency(&self, term: &Term) -> f32 {
if let Some(metadata) = self.terms.get(term) {
metadata.count() as f32 / self.number_of_terms() as f32
} else {
0.0
}
}
/// immutable reference to the collection of terms and their metadata
pub(super) fn terms(&self) -> &HashMap<Term, TermMetaData> {
&self.terms
}
/// number of terms the current document knows about
fn number_of_terms(&self) -> usize {
self.number_of_terms
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// `Document::new` should preprocess text and generate a hashmap of `Term, TermMetadata`
fn nlp_document_creation_from_text() {
let doc = Document::new("The air quality in Singapore got worse on Wednesday.");
let expected_terms = ["air", "quality", "singapore", "worse", "wednesday"];
for expected in expected_terms {
let term = Term::new(expected);
assert!(doc.terms().contains_key(&term));
assert_eq!(doc.number_of_terms, 5);
assert_eq!(doc.terms().get(&term).unwrap().count(), 1);
// since term frequencies aren't calculated on `new`, document frequency is zero in
// addition to the empty term_frequencies slice
let empty: &[f32] = &[];
assert_eq!(doc.terms().get(&term).unwrap().term_frequencies(), empty);
assert_eq!(doc.terms().get(&term).unwrap().document_frequency(), 0);
}
}
#[test]
/// `Document::new` should preprocess html and generate a hashmap of `Term, TermMetadata`
fn nlp_document_creation_from_html() {
let empty = Document::from_html("<html></html>");
assert_eq!(empty.number_of_terms, 0);
let other_empty = Document::from_html("<html><body><p></p></body></html>");
assert_eq!(other_empty.number_of_terms, 0);
let third_empty = Document::from_html("<!DOCTYPE html><html><!DOCTYPE html><p></p></html>");
assert_eq!(third_empty.number_of_terms, 0);
// p tag for is_text check and comment for is_comment
let doc = Document::from_html(
"<html><body><p>The air quality in Singapore.</p><!--got worse on Wednesday--></body></html>",
);
let expected_terms = ["air", "quality", "singapore", "worse", "wednesday"];
for expected in expected_terms {
let term = Term::new(expected);
assert_eq!(doc.number_of_terms, 5);
assert!(doc.terms().contains_key(&term));
assert_eq!(doc.terms().get(&term).unwrap().count(), 1);
// since term frequencies aren't calculated on `new`, document frequency is zero in
// addition to the empty term_frequencies slice
let empty: &[f32] = &[];
assert_eq!(doc.terms().get(&term).unwrap().term_frequencies(), empty);
assert_eq!(doc.terms().get(&term).unwrap().document_frequency(), 0);
}
}
#[test]
/// simple check of the `term_frequency` function's return value
fn term_frequency_validation() {
let doc = Document::new("The air quality in Singapore got worse on Wednesday. Air Jordan.");
let air_freq = doc.term_frequency(&Term::new("air"));
let abs_diff = (air_freq - 0.2857143).abs();
assert!(abs_diff <= f32::EPSILON);
let non_existent = doc.term_frequency(&Term::new("derpatronic"));
assert_eq!(non_existent, 0.0);
}
#[test]
/// test accessors for correctness
fn document_accessor_test() {
let doc = Document::new("The air quality in Singapore got worse on Wednesday.");
let keys = doc.terms().keys().map(|key| key.raw()).collect::<Vec<_>>();
let expected = ["air", "quality", "singapore", "worse", "wednesday"];
assert_eq!(doc.number_of_terms(), 5);
for key in keys {
assert!(expected.contains(&key));
}
}
#[test]
/// ensure words in script/style tags aren't processed
fn document_creation_skips_script_and_style_tags() {
let html = "<body><script>The air quality</script><style>in Singapore</style><p>got worse on Wednesday.</p></body>";
let doc = Document::from_html(html);
let keys = doc.terms().keys().map(|key| key.raw()).collect::<Vec<_>>();
let expected = ["worse", "wednesday"];
assert_eq!(doc.number_of_terms(), 2);
for key in keys {
assert!(expected.contains(&key));
}
}
}

11
src/nlp/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
//! small stand-alone tf-idf library, specifically designed for use in feroxbuster
mod constants;
mod document;
mod model;
mod term;
mod utils;
pub(crate) use self::document::Document;
pub(crate) use self::model::TfIdf;
pub(crate) use self::utils::preprocess;

185
src/nlp/model.rs Normal file
View File

@@ -0,0 +1,185 @@
use super::document::Document;
use super::term::{Term, TermMetaData};
use super::utils::{inverse_document_frequency, tf_idf_score};
use std::borrow::{Borrow, BorrowMut};
use std::collections::HashMap;
/// data container for the TF-IDF model
#[derive(Debug, Default)]
pub(crate) struct TfIdf {
/// collection of `Term`s and their associated metadata
terms: HashMap<Term, TermMetaData>,
/// number of documents processed by the model
num_documents: usize,
}
impl TfIdf {
/// create an empty TF-IDF model; must be populated with `add_document` prior to use
pub(crate) fn new() -> Self {
Self::default()
}
/// accessor method for the collection of `Term`s and `TermMetaData`
fn terms(&self) -> &HashMap<Term, TermMetaData> {
self.terms.borrow()
}
/// accessor method for the number of `Document`s the model has processed
pub(crate) fn num_documents(&self) -> usize {
self.num_documents
}
/// add a `Document` to the model
pub(crate) fn add_document(&mut self, document: Document) {
// increment number of docs seen, since we don't preserve the document itself; this needs
// to happen before calls to `self.inverse_document_frequency`, as it relies on the count
// being up to date
self.num_documents += 1;
for (term, doc_metadata) in document.terms().iter() {
// an incoming `Term` from a `Document` only has a valid `count` for that particular
// document; need to get the term frequency while both are known/valid
let term_frequency = document.term_frequency(term);
let metadata = self
.terms
.entry(term.clone())
.or_insert_with(|| doc_metadata.to_owned());
metadata.term_frequencies_mut().push(term_frequency);
}
}
/// (re)-calculate tf-idf scores for all terms, given the current number of documents
///
/// # Notes
///
/// old tf-idf scores are removed during calculations to keep new `Term`s at the same relative
/// level as new ones WRT corpus size
pub(crate) fn calculate_tf_idf_scores(&mut self) {
for metadata in self.terms.borrow_mut().values_mut() {
let num_frequencies = metadata.term_frequencies().len();
let mut to_add = Vec::with_capacity(num_frequencies);
for frequency in metadata.term_frequencies() {
let idf = inverse_document_frequency(
self.num_documents as f32,
metadata.document_frequency() as f32,
);
let score = tf_idf_score(*frequency, idf);
to_add.push(score);
}
let average: f32 = to_add.iter().sum::<f32>() / to_add.len() as f32;
*metadata.tf_idf_score_mut() = average;
}
}
/// select all terms with a non-zero tf-idf score
pub(crate) fn all_words(&self) -> Vec<String> {
self.terms()
.iter()
.filter(|(_, metadata)| metadata.tf_idf_score() > 0.0)
.map(|(term, _)| term.raw().to_owned())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// helper for this test suite
fn get_score(word: &str, model: &TfIdf) -> f32 {
model.terms().get(&Term::new(word)).unwrap().tf_idf_score()
}
#[test]
/// given the example data at https://remykarem.github.io/tfidf-demo/, ensure the model
/// produces the same results
fn model_generates_expected_tf_idf_scores() {
let one = "Air quality in the sunny island improved gradually throughout Wednesday.";
let two =
"Air quality in Singapore on Wednesday continued to get worse as haze hit the island.";
let three = "The air quality in Singapore is monitored through a network of air monitoring stations located in different parts of the island";
let four = "The air quality in Singapore got worse on Wednesday.";
let docs = [one, two, three, four];
let mut model = TfIdf::new();
for doc in docs.iter() {
let d = Document::new(doc);
model.add_document(d);
}
assert_eq!(model.terms().len(), 19);
model.calculate_tf_idf_scores();
assert_eq!(get_score("quality", &model), 0.0);
assert_eq!(get_score("air", &model), 0.0);
assert_eq!(get_score("wednesday", &model), 0.018906077);
assert_eq!(get_score("island", &model), 0.014047348);
assert_eq!(get_score("singapore", &model), 0.016427131);
assert_eq!(get_score("sunny", &model), 0.08600858);
assert_eq!(get_score("monitoring", &model), 0.05017167);
assert_eq!(get_score("stations", &model), 0.05017167);
assert_eq!(get_score("parts", &model), 0.05017167);
assert_eq!(get_score("haze", &model), 0.06689556);
assert_eq!(get_score("hit", &model), 0.06689556);
assert_eq!(get_score("worse", &model), 0.04682689);
}
#[test]
/// given the example data at https://remykarem.github.io/tfidf-demo/, ensure the model
/// produces the same results
fn select_n_words_grabs_correct_words() {
let one = "Air quality in the sunny island improved gradually throughout Wednesday.";
let two =
"Air quality in Singapore on Wednesday continued to get worse as haze hit the island.";
let three = "The air quality in Singapore is monitored through a network of air monitoring stations located in different parts of the island";
let four = "The air quality in Singapore got worse on Wednesday.";
let docs = [one, two, three, four];
let mut model = TfIdf::new();
for doc in docs.iter() {
let d = Document::new(doc);
model.add_document(d);
}
assert_eq!(model.num_documents(), 4);
model.calculate_tf_idf_scores();
let non_zero_words = model.all_words();
[
"gradually",
"network",
"hit",
"located",
"continued",
"island",
"worse",
"monitored",
"monitoring",
"haze",
"different",
"stations",
"sunny",
"singapore",
"improved",
"parts",
"wednesday",
]
.iter()
.for_each(|word| {
assert!(non_zero_words.contains(&word.to_string()));
});
}
}

105
src/nlp/term.rs Normal file
View File

@@ -0,0 +1,105 @@
use std::borrow::BorrowMut;
/// single word term for text processing
#[derive(Debug, Hash, Eq, PartialEq, Default, Clone)]
pub(crate) struct Term {
/// underlying string that the term represents
raw: String,
}
impl Term {
/// given a word, create a new `Term`
pub(super) fn new(word: &str) -> Self {
Self {
raw: word.to_owned(),
}
}
/// return a reference to the underlying string
pub(super) fn raw(&self) -> &str {
&self.raw
}
}
/// metadata to be associated with a `Term`
#[derive(Debug, Clone, Default)]
pub(super) struct TermMetaData {
/// number of times the associated `Term` was seen in a single document
count: u32,
/// collection of term frequencies for the associated `Term`
term_frequencies: Vec<f32>,
/// tf-idf score for the associated `Term`
tf_idf_score: f32,
}
impl TermMetaData {
/// create a new metadata container
pub(super) fn new() -> Self {
Self::default()
}
/// number of times a `Term` has appeared in any `Document` within the corpus
pub(super) fn document_frequency(&self) -> usize {
self.term_frequencies().len()
}
/// mutable reference to the collection of term frequencies
pub(super) fn term_frequencies_mut(&mut self) -> &mut Vec<f32> {
self.term_frequencies.borrow_mut()
}
/// immutable reference to the collection of term frequencies
pub(super) fn term_frequencies(&self) -> &[f32] {
&self.term_frequencies
}
/// mutable reference to the number of times a `Term` was seen in a particular `Document`
pub(super) fn count_mut(&mut self) -> &mut u32 {
self.count.borrow_mut()
}
/// number of times a `Term` was seen in a particular `Document`
pub(super) fn count(&self) -> u32 {
self.count
}
/// mutable reference to the term's tf-idf score
pub(super) fn tf_idf_score_mut(&mut self) -> &mut f32 {
self.tf_idf_score.borrow_mut()
}
/// immutable reference to the term's tf-idf score
pub(super) fn tf_idf_score(&self) -> f32 {
self.tf_idf_score
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// test accessors for correctness
fn nlp_term_accessor_test() {
let term = Term::new("stuff");
assert_eq!(term.raw(), "stuff");
}
#[test]
/// test accessors for correctness
fn nlp_term_metadata_accessor_test() {
let mut metadata = TermMetaData::new();
*metadata.count_mut() += 1;
assert_eq!(metadata.count(), 1);
metadata.term_frequencies_mut().push(1.0);
assert_eq!(metadata.document_frequency(), 1);
assert_eq!(metadata.term_frequencies().first().unwrap(), &1.0);
*metadata.tf_idf_score_mut() = 1.0_f32;
assert_eq!(metadata.tf_idf_score(), 1.0);
}
}

158
src/nlp/utils.rs Normal file
View File

@@ -0,0 +1,158 @@
use super::constants::{BOUNDED_WORD_REGEX, STOP_WORDS};
use regex::Captures;
use std::borrow::Cow;
/// pre-processing pipeline wrapper that removes punctuation, normalizes word case (utf-8 included)
/// to lowercase, and remove stop words
pub(crate) fn preprocess(text: &str) -> Vec<String> {
let text = remove_punctuation(text);
let text = normalize_case(text);
let text = remove_stop_words(&text);
text.split_whitespace()
.map(|word| word.to_string())
.collect::<Vec<_>>()
}
/// optimized version of `str::to_lowercase`
fn normalize_case<'a, S: Into<Cow<'a, str>>>(input: S) -> Cow<'a, str> {
let input = input.into();
let first = input.find(char::is_uppercase);
if let Some(first_idx) = first {
let mut output = String::from(&input[..first_idx]);
output.reserve(input.len() - first_idx);
for c in input[first_idx..].chars() {
if c.is_uppercase() {
output.push(c.to_lowercase().next().unwrap())
} else {
output.push(c)
}
}
Cow::Owned(output)
} else {
input
}
}
/// replace ascii and some utf-8 punctuation characters with ' ' (space) in the given string
fn remove_punctuation(text: &str) -> String {
text.replace(
[
'!', '\\', '"', '#', '$', '%', '&', '(', ')', '*', '+', ':', ';', '<', '=', '>', '?',
'@', '[', ']', '^', '{', '}', '|', '~', ',', '\'', '“', '”', '', '', '', '', '/',
'', '—', '.',
],
" ",
)
}
/// remove stop words from the given string
fn remove_stop_words(text: &str) -> String {
BOUNDED_WORD_REGEX
.replace_all(text, |caps: &Captures| {
let word = &caps[0];
if !STOP_WORDS.contains(&word) {
word.to_owned()
} else {
String::new()
}
})
.into()
}
/// calculate inverse document frequency
pub(super) fn inverse_document_frequency(num_docs: f32, doc_frequency: f32) -> f32 {
f32::log10(num_docs / doc_frequency)
}
/// calculate term frequency-inverse document frequency (tf-idf)
pub(super) fn tf_idf_score(term_frequency: f32, idf: f32) -> f32 {
term_frequency * idf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// ensure all expected punctuation characters are removed
fn test_remove_punctuation() {
let tester = "!\\\"#$%&()*+/:;<=>?@[]^{}|~,.'“”’‘–—\n";
// the `" \n"` is because of the things like / getting replaced with a space
assert_eq!(
remove_punctuation(tester),
" \n "
);
}
#[test]
/// ensure uppercase characters are swapped to lowercase
fn test_normalize_case() {
let tester = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
assert_eq!(normalize_case(tester), "abcdefghijklmnopqrstuvwxyz");
}
#[test]
/// ensure all stop words are removed from the list of stopwords ... intestuous
fn test_remove_stopwords() {
let all_words = STOP_WORDS
.iter()
.map(|&word| word.to_string())
.collect::<Vec<_>>()
.join(" ");
let removed = remove_stop_words(&all_words).replace(' ', "");
// the remaining chars are from the contraction-based stop words
assert_eq!(removed, "'d'll'm''s'ven'tntntdllmsvedllmsve");
}
#[test]
/// ensure preprocess
fn test_preprocess_results() {
let tester = "WHY are Y'all YELLing?";
assert_eq!(&preprocess(tester), &["y", "all", "yelling"]);
}
#[test]
/// ensure our calculations conform to the example provided at the link below
///
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
///
/// Consider a document containing 100 words wherein the word cat appears 3 times.
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
/// million documents and the word cat appears in one thousand of these. Then, the inverse
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
fn idf_returns_expected_value() {
let num_docs = 10_000_000_f32;
let num_occurrences = 1_000_f32;
let abs_diff = (inverse_document_frequency(num_docs, num_occurrences) - 4.0).abs();
assert!(abs_diff <= f32::EPSILON);
}
#[test]
/// ensure our calculations conform to the example provided at the link below
///
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
///
/// Consider a document containing 100 words wherein the word cat appears 3 times.
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
/// million documents and the word cat appears in one thousand of these. Then, the inverse
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
fn tf_idf_returns_expected_value() {
let term_freq = 0.03_f32;
let num_docs = 10_000_000_f32;
let num_occurrences = 1_000_f32;
let idf = inverse_document_frequency(num_docs, num_occurrences);
let abs_diff = (tf_idf_score(term_freq, idf) - 0.12).abs();
assert!(abs_diff <= f32::EPSILON);
}
}

View File

@@ -1,6 +1,11 @@
use clap::{App, Arg, ArgGroup}; use clap::ArgAction;
use clap::{
crate_authors, crate_description, crate_name, crate_version, Arg, ArgGroup, Command, ValueHint,
};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex; use regex::Regex;
use std::env;
use std::process;
lazy_static! { lazy_static! {
/// Regex used to validate values passed to --time-limit /// Regex used to validate values passed to --time-limit
@@ -12,349 +17,636 @@ lazy_static! {
/// - 1d /// - 1d
pub static ref TIMESPEC_REGEX: Regex = pub static ref TIMESPEC_REGEX: Regex =
Regex::new(r"^(?i)(?P<n>\d+)(?P<m>[smdh])$").expect("Could not compile regex"); Regex::new(r"^(?i)(?P<n>\d+)(?P<m>[smdh])$").expect("Could not compile regex");
/// help string for user agent, your guess is as good as mine as to why this is required...
static ref DEFAULT_USER_AGENT: String = format!(
"Sets the User-Agent (default: feroxbuster/{})",
crate_version!()
);
} }
/// Create and return an instance of [clap::App](https://docs.rs/clap/latest/clap/struct.App.html), i.e. the Command Line Interface's configuration /// Create and return an instance of [clap::App](https://docs.rs/clap/latest/clap/struct.App.html), i.e. the Command Line Interface's configuration
pub fn initialize() -> App<'static, 'static> { pub fn initialize() -> Command {
App::new("feroxbuster") let app = Command::new(crate_name!())
.version(env!("CARGO_PKG_VERSION")) .version(crate_version!())
.author("Ben 'epi' Risher (@epi052)") .author(crate_authors!())
.about("A fast, simple, recursive content discovery tool written in Rust") .about(crate_description!());
/////////////////////////////////////////////////////////////////////
// group - target selection
/////////////////////////////////////////////////////////////////////
let app = app
.arg( .arg(
Arg::with_name("wordlist") Arg::new("url")
.short("w") .short('u')
.long("wordlist")
.value_name("FILE")
.help("Path to the wordlist")
.takes_value(true),
)
.arg(
Arg::with_name("url")
.short("u")
.long("url") .long("url")
.required_unless_one(&["stdin", "resume_from"]) .required_unless_present_any(["stdin", "resume_from"])
.help_heading("Target selection")
.value_name("URL") .value_name("URL")
.multiple(true) .use_value_delimiter(true)
.use_delimiter(true) .value_hint(ValueHint::Url)
.help("The target URL(s) (required, unless --stdin used)"), .help("The target URL (required, unless [--stdin || --resume-from] used)"),
) )
.arg( .arg(
Arg::with_name("threads") Arg::new("stdin")
.short("t") .long("stdin")
.long("threads") .help_heading("Target selection")
.value_name("THREADS") .num_args(0)
.takes_value(true) .help("Read url(s) from STDIN")
.help("Number of concurrent threads (default: 50)"), .conflicts_with("url")
) )
.arg( .arg(
Arg::with_name("depth") Arg::new("resume_from")
.short("d") .long("resume-from")
.long("depth") .value_hint(ValueHint::FilePath)
.value_name("RECURSION_DEPTH") .value_name("STATE_FILE")
.takes_value(true) .help_heading("Target selection")
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"), .help("State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)")
.conflicts_with("url")
.num_args(1),
);
/////////////////////////////////////////////////////////////////////
// group - composite settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("burp")
.long("burp")
.num_args(0)
.help_heading("Composite settings")
.conflicts_with_all(["proxy", "insecure", "burp_replay"])
.help("Set --proxy to http://127.0.0.1:8080 and set --insecure to true"),
) )
.arg( .arg(
Arg::with_name("timeout") Arg::new("burp_replay")
.short("T") .long("burp-replay")
.long("timeout") .num_args(0)
.value_name("SECONDS") .help_heading("Composite settings")
.takes_value(true) .conflicts_with_all(["replay_proxy", "insecure"])
.help("Number of seconds before a request times out (default: 7)"), .help("Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true"),
) )
.arg( .arg(
Arg::with_name("verbosity") Arg::new("smart")
.short("v") .long("smart")
.long("verbosity") .num_args(0)
.takes_value(false) .help_heading("Composite settings")
.multiple(true) .conflicts_with_all(["rate_limit", "auto_bail"])
.conflicts_with("silent") .help("Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true"),
.help("Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably too much)"), ).arg(
) Arg::new("thorough")
.long("thorough")
.num_args(0)
.help_heading("Composite settings")
.conflicts_with_all(["rate_limit", "auto_bail"])
.help("Use the same settings as --smart and set --collect-extensions to true"),
);
/////////////////////////////////////////////////////////////////////
// group - proxy settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg( .arg(
Arg::with_name("proxy") Arg::new("proxy")
.short("p") .short('p')
.long("proxy") .long("proxy")
.takes_value(true) .num_args(1)
.value_name("PROXY") .value_name("PROXY")
.value_hint(ValueHint::Url)
.help_heading("Proxy settings")
.help( .help(
"Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)", "Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)",
), ),
) )
.arg( .arg(
Arg::with_name("replay_proxy") Arg::new("replay_proxy")
.short("P") .short('P')
.long("replay-proxy") .long("replay-proxy")
.takes_value(true) .num_args(1)
.value_hint(ValueHint::Url)
.value_name("REPLAY_PROXY") .value_name("REPLAY_PROXY")
.help_heading("Proxy settings")
.help( .help(
"Send only unfiltered requests through a Replay Proxy, instead of all requests", "Send only unfiltered requests through a Replay Proxy, instead of all requests",
), ),
) )
.arg( .arg(
Arg::with_name("replay_codes") Arg::new("replay_codes")
.short("R") .short('R')
.long("replay-codes") .long("replay-codes")
.value_name("REPLAY_CODE") .value_name("REPLAY_CODE")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.requires("replay_proxy") .requires("replay_proxy")
.help_heading("Proxy settings")
.help( .help(
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)", "Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
), ),
) );
/////////////////////////////////////////////////////////////////////
// group - request settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg( .arg(
Arg::with_name("status_codes") Arg::new("user_agent")
.short("s") .short('a')
.long("status-codes")
.value_name("STATUS_CODE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.help(
"Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)",
),
)
.arg(
Arg::with_name("silent")
.long("silent")
.takes_value(false)
.conflicts_with("quiet")
.help("Only print URLs + turn off logging (good for piping a list of urls to other commands)")
)
.arg(
Arg::with_name("quiet")
.short("q")
.long("quiet")
.takes_value(false)
.help("Hide progress bars and banner (good for tmux windows w/ notifications)")
)
.arg(
Arg::with_name("json")
.long("json")
.takes_value(false)
.requires("output_files")
.help("Emit JSON logs to --output and --debug-log instead of normal text")
)
.arg(
Arg::with_name("dont_filter")
.short("D")
.long("dont-filter")
.takes_value(false)
.help("Don't auto-filter wildcard responses")
)
.arg(
Arg::with_name("output")
.short("o")
.long("output")
.value_name("FILE")
.help("Output file to write results to (use w/ --json for JSON entries)")
.takes_value(true),
)
.arg(
Arg::with_name("resume_from")
.long("resume-from")
.value_name("STATE_FILE")
.help("State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)")
.conflicts_with("url")
.takes_value(true),
)
.arg(
Arg::with_name("debug_log")
.long("debug-log")
.value_name("FILE")
.help("Output file to write log entries (use w/ --json for JSON entries)")
.takes_value(true),
)
.arg(
Arg::with_name("user_agent")
.short("a")
.long("user-agent") .long("user-agent")
.value_name("USER_AGENT") .value_name("USER_AGENT")
.takes_value(true) .num_args(1)
.help( .help_heading("Request settings")
"Sets the User-Agent (default: feroxbuster/VERSION)" .help(&**DEFAULT_USER_AGENT),
),
) )
.arg( .arg(
Arg::with_name("redirects") Arg::new("random_agent")
.short("r") .short('A')
.long("redirects") .long("random-agent")
.takes_value(false) .num_args(0)
.help("Follow redirects") .help_heading("Request settings")
.help("Use a random User-Agent"),
) )
.arg( .arg(
Arg::with_name("insecure") Arg::new("extensions")
.short("k") .short('x')
.long("insecure")
.takes_value(false)
.help("Disables TLS certificate validation")
)
.arg(
Arg::with_name("extensions")
.short("x")
.long("extensions") .long("extensions")
.value_name("FILE_EXTENSION") .value_name("FILE_EXTENSION")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.help_heading("Request settings")
.help( .help(
"File extension(s) to search for (ex: -x php -x pdf js)", "File extension(s) to search for (ex: -x php -x pdf js)",
), ),
) )
.arg( .arg(
Arg::with_name("headers") Arg::new("methods")
.short("H") .short('m')
.long("methods")
.value_name("HTTP_METHODS")
.num_args(1..)
.action(ArgAction::Append)
.use_value_delimiter(true)
.help_heading("Request settings")
.help(
"Which HTTP request method(s) should be sent (default: GET)",
),
)
.arg(
Arg::new("data")
.long("data")
.value_name("DATA")
.num_args(1)
.help_heading("Request settings")
.help(
"Request's Body; can read data from a file if input starts with an @ (ex: @post.bin)",
),
)
.arg(
Arg::new("headers")
.short('H')
.long("headers") .long("headers")
.value_name("HEADER") .value_name("HEADER")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .help_heading("Request settings")
.use_value_delimiter(true)
.help( .help(
"Specify HTTP headers (ex: -H Header:val 'stuff: things')", "Specify HTTP headers to be used in each request (ex: -H Header:val -H 'stuff: things')",
), ),
) )
.arg( .arg(
Arg::with_name("queries") Arg::new("cookies")
.short("Q") .short('b')
.long("cookies")
.value_name("COOKIE")
.num_args(1..)
.action(ArgAction::Append)
.use_value_delimiter(true)
.help_heading("Request settings")
.help(
"Specify HTTP cookies to be used in each request (ex: -b stuff=things)",
),
)
.arg(
Arg::new("queries")
.short('Q')
.long("query") .long("query")
.value_name("QUERY") .value_name("QUERY")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.help_heading("Request settings")
.help( .help(
"Specify URL query parameters (ex: -Q token=stuff -Q secret=key)", "Request's URL query parameters (ex: -Q token=stuff -Q secret=key)",
), ),
) )
.arg( .arg(
Arg::with_name("no_recursion") Arg::new("add_slash")
.short("n") .short('f')
.long("no-recursion")
.takes_value(false)
.help("Do not scan recursively")
)
.arg(
Arg::with_name("add_slash")
.short("f")
.long("add-slash") .long("add-slash")
.takes_value(false) .help_heading("Request settings")
.conflicts_with("extensions") .num_args(0)
.help("Append / to each request") .help("Append / to each request's URL")
) );
/////////////////////////////////////////////////////////////////////
// group - request filters
/////////////////////////////////////////////////////////////////////
let app = app.arg(
Arg::new("url_denylist")
.long("dont-scan")
.value_name("URL")
.num_args(1..)
.action(ArgAction::Append)
.use_value_delimiter(true)
.help_heading("Request filters")
.help("URL(s) or Regex Pattern(s) to exclude from recursion/scans"),
);
/////////////////////////////////////////////////////////////////////
// group - response filters
/////////////////////////////////////////////////////////////////////
let app = app
.arg( .arg(
Arg::with_name("stdin") Arg::new("filter_size")
.long("stdin") .short('S')
.takes_value(false)
.help("Read url(s) from STDIN")
.conflicts_with("url")
)
.arg(
Arg::with_name("filter_size")
.short("S")
.long("filter-size") .long("filter-size")
.value_name("SIZE") .value_name("SIZE")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.help_heading("Response filters")
.help( .help(
"Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)", "Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)",
), ),
) )
.arg( .arg(
Arg::with_name("filter_regex") Arg::new("filter_regex")
.short("X") .short('X')
.long("filter-regex") .long("filter-regex")
.value_name("REGEX") .value_name("REGEX")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.help_heading("Response filters")
.help( .help(
"Filter out messages via regular expression matching on the response's body (ex: -X '^ignore me$')", "Filter out messages via regular expression matching on the response's body (ex: -X '^ignore me$')",
), ),
) )
.arg( .arg(
Arg::with_name("filter_words") Arg::new("filter_words")
.short("W") .short('W')
.long("filter-words") .long("filter-words")
.value_name("WORDS") .value_name("WORDS")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.help_heading("Response filters")
.help( .help(
"Filter out messages of a particular word count (ex: -W 312 -W 91,82)", "Filter out messages of a particular word count (ex: -W 312 -W 91,82)",
), ),
) )
.arg( .arg(
Arg::with_name("filter_lines") Arg::new("filter_lines")
.short("N") .short('N')
.long("filter-lines") .long("filter-lines")
.value_name("LINES") .value_name("LINES")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.help_heading("Response filters")
.help( .help(
"Filter out messages of a particular line count (ex: -N 20 -N 31,30)", "Filter out messages of a particular line count (ex: -N 20 -N 31,30)",
), ),
) )
.arg( .arg(
Arg::with_name("filter_status") Arg::new("filter_status")
.short("C") .short('C')
.long("filter-status") .long("filter-status")
.value_name("STATUS_CODE") .value_name("STATUS_CODE")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .use_value_delimiter(true)
.conflicts_with("status_codes")
.help_heading("Response filters")
.help( .help(
"Filter out status codes (deny list) (ex: -C 200 -C 401)", "Filter out status codes (deny list) (ex: -C 200 -C 401)",
), ),
) )
.arg( .arg(
Arg::with_name("filter_similar") Arg::new("filter_similar")
.long("filter-similar-to") .long("filter-similar-to")
.value_name("UNWANTED_PAGE") .value_name("UNWANTED_PAGE")
.takes_value(true) .num_args(1..)
.multiple(true) .action(ArgAction::Append)
.use_delimiter(true) .value_hint(ValueHint::Url)
.use_value_delimiter(true)
.help_heading("Response filters")
.help( .help(
"Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)", "Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)",
), ),
) )
.arg( .arg(
Arg::with_name("extract_links") Arg::new("status_codes")
.short("e") .short('s')
.long("extract-links") .long("status-codes")
.takes_value(false) .value_name("STATUS_CODE")
.help("Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)") .num_args(1..)
.action(ArgAction::Append)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Status Codes to include (allow list) (default: All Status Codes)",
),
);
/////////////////////////////////////////////////////////////////////
// group - client settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("timeout")
.short('T')
.long("timeout")
.value_name("SECONDS")
.num_args(1)
.help_heading("Client settings")
.help("Number of seconds before a client's request times out (default: 7)"),
) )
.arg( .arg(
Arg::with_name("scan_limit") Arg::new("redirects")
.short("L") .short('r')
.long("redirects")
.num_args(0)
.help_heading("Client settings")
.help("Allow client to follow redirects"),
)
.arg(
Arg::new("insecure")
.short('k')
.long("insecure")
.num_args(0)
.help_heading("Client settings")
.help("Disables TLS certificate validation in the client"),
);
/////////////////////////////////////////////////////////////////////
// group - scan settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("threads")
.short('t')
.long("threads")
.value_name("THREADS")
.num_args(1)
.help_heading("Scan settings")
.help("Number of concurrent threads (default: 50)"),
)
.arg(
Arg::new("no_recursion")
.short('n')
.long("no-recursion")
.num_args(0)
.help_heading("Scan settings")
.help("Do not scan recursively"),
)
.arg(
Arg::new("depth")
.short('d')
.long("depth")
.value_name("RECURSION_DEPTH")
.num_args(1)
.help_heading("Scan settings")
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"),
).arg(
Arg::new("force_recursion")
.long("force-recursion")
.num_args(0)
.conflicts_with("no_recursion")
.help_heading("Scan settings")
.help("Force recursion attempts on all 'found' endpoints (still respects recursion depth)"),
).arg(
Arg::new("extract_links")
.short('e')
.long("extract-links")
.num_args(0)
.help_heading("Scan settings")
.help("Extract links from response body (html, javascript, etc...); make new requests based on findings")
)
.arg(
Arg::new("scan_limit")
.short('L')
.long("scan-limit") .long("scan-limit")
.value_name("SCAN_LIMIT") .value_name("SCAN_LIMIT")
.takes_value(true) .num_args(1)
.help_heading("Scan settings")
.help("Limit total number of concurrent scans (default: 0, i.e. no limit)") .help("Limit total number of concurrent scans (default: 0, i.e. no limit)")
) )
.arg( .arg(
Arg::with_name("rate_limit") Arg::new("parallel")
.long("parallel")
.value_name("PARALLEL_SCANS")
.num_args(1)
.requires("stdin")
.help_heading("Scan settings")
.help("Run parallel feroxbuster instances (one child process per url passed via stdin)")
)
.arg(
Arg::new("rate_limit")
.long("rate-limit") .long("rate-limit")
.value_name("RATE_LIMIT") .value_name("RATE_LIMIT")
.takes_value(true) .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)") .help("Limit number of requests per second (per directory) (default: 0, i.e. no limit)")
) )
.arg( .arg(
Arg::with_name("time_limit") Arg::new("time_limit")
.long("time-limit") .long("time-limit")
.value_name("TIME_SPEC") .value_name("TIME_SPEC")
.takes_value(true) .num_args(1)
.validator(valid_time_spec) .value_parser(valid_time_spec)
.help_heading("Scan settings")
.help("Limit total run time of all scans (ex: --time-limit 10m)") .help("Limit total run time of all scans (ex: --time-limit 10m)")
) )
.group(ArgGroup::with_name("output_files") .arg(
.args(&["debug_log", "output"]) Arg::new("wordlist")
.multiple(true) .short('w')
.long("wordlist")
.value_hint(ValueHint::FilePath)
.value_name("FILE")
.help("Path to the wordlist")
.help_heading("Scan settings")
.num_args(1),
).arg(
Arg::new("auto_tune")
.long("auto-tune")
.num_args(0)
.conflicts_with("auto_bail")
.help_heading("Scan settings")
.help("Automatically lower scan rate when an excessive amount of errors are encountered")
) )
.after_help(r#"NOTE: .arg(
Arg::new("auto_bail")
.long("auto-bail")
.num_args(0)
.help_heading("Scan settings")
.help("Automatically stop scanning when an excessive amount of errors are encountered")
).arg(
Arg::new("dont_filter")
.short('D')
.long("dont-filter")
.num_args(0)
.help_heading("Scan settings")
.help("Don't auto-filter wildcard responses")
).arg(
Arg::new("collect_extensions")
.short('E')
.long("collect-extensions")
.num_args(0)
.help_heading("Dynamic collection settings")
.help("Automatically discover extensions and add them to --extensions (unless they're in --dont-collect)")
).arg(
Arg::new("collect_backups")
.short('B')
.long("collect-backups")
.num_args(0)
.help_heading("Dynamic collection settings")
.help("Automatically request likely backup extensions for \"found\" urls")
).arg(
Arg::new("collect_words")
.short('g')
.long("collect-words")
.num_args(0)
.help_heading("Dynamic collection settings")
.help("Automatically discover important words from within responses and add them to the wordlist")
).arg(
Arg::new("dont_collect")
.short('I')
.long("dont-collect")
.value_name("FILE_EXTENSION")
.num_args(1..)
.action(ArgAction::Append)
.use_value_delimiter(true)
.help_heading("Dynamic collection settings")
.help(
"File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)",
),
);
/////////////////////////////////////////////////////////////////////
// group - output settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("verbosity")
.short('v')
.long("verbosity")
.num_args(0)
.action(ArgAction::Count)
.conflicts_with("silent")
.help_heading("Output settings")
.help("Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably too much)"),
).arg(
Arg::new("silent")
.long("silent")
.num_args(0)
.conflicts_with("quiet")
.help_heading("Output settings")
.help("Only print URLs + turn off logging (good for piping a list of urls to other commands)")
)
.arg(
Arg::new("quiet")
.short('q')
.long("quiet")
.num_args(0)
.help_heading("Output settings")
.help("Hide progress bars and banner (good for tmux windows w/ notifications)")
)
.arg(
Arg::new("json")
.long("json")
.num_args(0)
.requires("output_files")
.help_heading("Output settings")
.help("Emit JSON logs to --output and --debug-log instead of normal text")
).arg(
Arg::new("output")
.short('o')
.long("output")
.value_hint(ValueHint::FilePath)
.value_name("FILE")
.help_heading("Output settings")
.help("Output file to write results to (use w/ --json for JSON entries)")
.num_args(1),
)
.arg(
Arg::new("debug_log")
.long("debug-log")
.value_name("FILE")
.value_hint(ValueHint::FilePath)
.help_heading("Output settings")
.help("Output file to write log entries (use w/ --json for JSON entries)")
.num_args(1),
)
.arg(
Arg::new("no_state")
.long("no-state")
.num_args(0)
.help_heading("Output settings")
.help("Disable state output file (*.state)")
);
/////////////////////////////////////////////////////////////////////
// group - miscellaneous
/////////////////////////////////////////////////////////////////////
let mut app = app
.group(
ArgGroup::new("output_files")
.args(["debug_log", "output"])
.multiple(true),
)
.after_long_help(EPILOGUE);
/////////////////////////////////////////////////////////////////////
// end parser
/////////////////////////////////////////////////////////////////////
for arg in env::args() {
// secure-77 noticed that when an incorrect flag/option is used, the short help message is printed
// which is fine, but if you add -h|--help, it still errors out on the bad flag/option,
// never showing the full help message. This code addresses that behavior
if arg == "--help" {
app.print_long_help().unwrap();
println!(); // just a newline to mirror original --help output
process::exit(0);
} else if arg == "-h" {
// same for -h, just shorter
app.print_help().unwrap();
println!();
process::exit(0);
}
}
app
}
/// Validate that a string is formatted as a number followed by s, m, h, or d (10d, 30s, etc...)
fn valid_time_spec(time_spec: &str) -> Result<String, String> {
match TIMESPEC_REGEX.is_match(time_spec) {
true => Ok(time_spec.to_string()),
false => {
let msg = format!(
"Expected a non-negative, whole number followed by s, m, h, or d (case insensitive); received {time_spec}"
);
Err(msg)
}
}
}
const EPILOGUE: &str = r#"NOTE:
Options that take multiple values are very flexible. Consider the following ways of specifying Options that take multiple values are very flexible. Consider the following ways of specifying
extensions: extensions:
./feroxbuster -u http://127.1 -x pdf -x js,html -x php txt json,docx ./feroxbuster -u http://127.1 -x pdf -x js,html -x php txt json,docx
@@ -375,7 +667,7 @@ EXAMPLES:
cat targets | ./feroxbuster --stdin --silent -s 200 301 302 --redirects -x js | fff -s 200 -o js-files cat targets | ./feroxbuster --stdin --silent -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
Proxy traffic through Burp Proxy traffic through Burp
./feroxbuster -u http://127.1 --insecure --proxy http://127.0.0.1:8080 ./feroxbuster -u http://127.1 --burp
Proxy traffic through a SOCKS proxy Proxy traffic through a SOCKS proxy
./feroxbuster -u http://127.1 --proxy socks5://127.0.0.1:9050 ./feroxbuster -u http://127.1 --proxy socks5://127.0.0.1:9050
@@ -387,30 +679,28 @@ EXAMPLES:
./feroxbuster -u http://127.1 --extract-links ./feroxbuster -u http://127.1 --extract-links
Ludicrous speed... go! Ludicrous speed... go!
./feroxbuster -u http://127.1 -t 200 ./feroxbuster -u http://127.1 --threads 200
"#)
} Limit to a total of 60 active requests at any given time (threads * scan limit)
./feroxbuster -u http://127.1 --threads 30 --scan-limit 2
/// Validate that a string is formatted as a number followed by s, m, h, or d (10d, 30s, etc...)
fn valid_time_spec(time_spec: String) -> Result<(), String> { Send all 200/302 responses to a proxy (only proxy requests/responses you care about)
match TIMESPEC_REGEX.is_match(&time_spec) { ./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
true => Ok(()),
false => { Abort or reduce scan speed to individual directory scans when too many errors have occurred
let msg = format!( ./feroxbuster -u http://127.1 --auto-bail
"Expected a non-negative, whole number followed by s, m, h, or d (case insensitive); received {}", ./feroxbuster -u http://127.1 --auto-tune
time_spec
); Examples and demonstrations of all features
Err(msg) https://epi052.github.io/feroxbuster-docs/docs/examples/
} "#;
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
/// initalize parser, expect a clap::App returned /// initialize parser, expect a clap::App returned
fn parser_initialize_gives_defaults() { fn parser_initialize_gives_defaults() {
let app = initialize(); let app = initialize();
assert_eq!(app.get_name(), "feroxbuster"); assert_eq!(app.get_name(), "feroxbuster");
@@ -423,29 +713,29 @@ mod tests {
/// that i didn't hose up the regex. Going to consolidate them into a single test /// that i didn't hose up the regex. Going to consolidate them into a single test
fn validate_valid_time_spec_validation() { fn validate_valid_time_spec_validation() {
let float_rejected = "1.4m"; let float_rejected = "1.4m";
assert!(valid_time_spec(float_rejected.into()).is_err()); assert!(valid_time_spec(float_rejected).is_err());
let negative_rejected = "-1m"; let negative_rejected = "-1m";
assert!(valid_time_spec(negative_rejected.into()).is_err()); assert!(valid_time_spec(negative_rejected).is_err());
let only_number_rejected = "1"; let only_number_rejected = "1";
assert!(valid_time_spec(only_number_rejected.into()).is_err()); assert!(valid_time_spec(only_number_rejected).is_err());
let only_measurement_rejected = "m"; let only_measurement_rejected = "m";
assert!(valid_time_spec(only_measurement_rejected.into()).is_err()); assert!(valid_time_spec(only_measurement_rejected).is_err());
for accepted_measurement in &["s", "m", "h", "d", "S", "M", "H", "D"] { for accepted_measurement in &["s", "m", "h", "d", "S", "M", "H", "D"] {
// all upper/lowercase should be good // all upper/lowercase should be good
assert!(valid_time_spec(format!("1{}", *accepted_measurement)).is_ok()); assert!(valid_time_spec(&format!("1{}", *accepted_measurement)).is_ok());
} }
let leading_space_rejected = " 14m"; let leading_space_rejected = " 14m";
assert!(valid_time_spec(leading_space_rejected.into()).is_err()); assert!(valid_time_spec(leading_space_rejected).is_err());
let trailing_space_rejected = "14m "; let trailing_space_rejected = "14m ";
assert!(valid_time_spec(trailing_space_rejected.into()).is_err()); assert!(valid_time_spec(trailing_space_rejected).is_err());
let space_between_rejected = "1 4m"; let space_between_rejected = "1 4m";
assert!(valid_time_spec(space_between_rejected.into()).is_err()); assert!(valid_time_spec(space_between_rejected).is_err());
} }
} }

View File

@@ -35,10 +35,11 @@ pub fn add_bar(prefix: &str, length: u64, bar_type: BarType) -> ProgressBar {
style = match bar_type { style = match bar_type {
BarType::Hidden => style.template(""), BarType::Hidden => style.template(""),
BarType::Default => style BarType::Default => style.template(
.template("[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {per_sec:7} {prefix}"), "[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {per_sec:7} {prefix} {msg}",
),
BarType::Message => style.template(&format!( BarType::Message => style.template(&format!(
"[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}}", "[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}} {{msg}}",
"-" "-"
)), )),
BarType::Total => { BarType::Total => {
@@ -51,7 +52,7 @@ pub fn add_bar(prefix: &str, length: u64, bar_type: BarType) -> ProgressBar {
progress_bar.set_style(style); progress_bar.set_style(style);
progress_bar.set_prefix(&prefix); progress_bar.set_prefix(prefix);
progress_bar progress_bar
} }

View File

@@ -7,9 +7,10 @@ use std::{
}; };
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use console::style;
use reqwest::{ use reqwest::{
header::{HeaderMap, HeaderName, HeaderValue}, header::{HeaderMap, HeaderName, HeaderValue},
Response, StatusCode, Url, Method, Response, StatusCode, Url,
}; };
use serde::ser::SerializeStruct; use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -30,9 +31,15 @@ pub struct FeroxResponse {
/// The final `Url` of this `FeroxResponse` /// The final `Url` of this `FeroxResponse`
url: Url, url: Url,
/// The original url from which the final `Url` was derived
original_url: String,
/// The `StatusCode` of this `FeroxResponse` /// The `StatusCode` of this `FeroxResponse`
status: StatusCode, status: StatusCode,
/// The HTTP Request `Method` of this `FeroxResponse`
method: Method,
/// The full response text /// The full response text
text: String, text: String,
@@ -53,6 +60,9 @@ pub struct FeroxResponse {
/// whether the user passed --quiet|--silent on the command line /// whether the user passed --quiet|--silent on the command line
pub(crate) output_level: OutputLevel, pub(crate) output_level: OutputLevel,
/// Url's file extension, if one exists
pub(crate) extension: Option<String>,
} }
/// implement Default trait for FeroxResponse /// implement Default trait for FeroxResponse
@@ -61,7 +71,9 @@ impl Default for FeroxResponse {
fn default() -> Self { fn default() -> Self {
Self { Self {
url: Url::parse("http://localhost").unwrap(), url: Url::parse("http://localhost").unwrap(),
original_url: "".to_string(),
status: Default::default(), status: Default::default(),
method: Method::default(),
text: "".to_string(), text: "".to_string(),
content_length: 0, content_length: 0,
line_count: 0, line_count: 0,
@@ -69,6 +81,7 @@ impl Default for FeroxResponse {
headers: Default::default(), headers: Default::default(),
wildcard: false, wildcard: false,
output_level: Default::default(), output_level: Default::default(),
extension: None,
} }
} }
} }
@@ -79,8 +92,9 @@ impl fmt::Display for FeroxResponse {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!( write!(
f, f,
"FeroxResponse {{ url: {}, status: {}, content-length: {} }}", "FeroxResponse {{ url: {}, method: {}, status: {}, content-length: {} }}",
self.url(), self.url(),
self.method(),
self.status(), self.status(),
self.content_length() self.content_length()
) )
@@ -94,6 +108,11 @@ impl FeroxResponse {
&self.status &self.status
} }
/// Get the `Method` of this `FeroxResponse`
pub fn method(&self) -> &Method {
&self.method
}
/// Get the `wildcard` of this `FeroxResponse` /// Get the `wildcard` of this `FeroxResponse`
pub fn wildcard(&self) -> bool { pub fn wildcard(&self) -> bool {
self.wildcard self.wildcard
@@ -121,7 +140,7 @@ impl FeroxResponse {
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs /// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
pub fn set_url(&mut self, url: &str) { pub fn set_url(&mut self, url: &str) {
match Url::parse(&url) { match Url::parse(url) {
Ok(url) => { Ok(url) => {
self.url = url; self.url = url;
} }
@@ -186,34 +205,40 @@ impl FeroxResponse {
} }
/// Create a new `FeroxResponse` from the given `Response` /// Create a new `FeroxResponse` from the given `Response`
pub async fn from(response: Response, read_body: bool, output_level: OutputLevel) -> Self { pub async fn from(
response: Response,
original_url: &str,
method: &str,
output_level: OutputLevel,
) -> Self {
let url = response.url().clone(); let url = response.url().clone();
let status = response.status(); let status = response.status();
let headers = response.headers().clone(); let headers = response.headers().clone();
let content_length = response.content_length().unwrap_or(0); let content_length = response.content_length().unwrap_or(0);
let text = if read_body { // .text() consumes the response, must be called last
// .text() consumes the response, must be called last let text = response
// additionally, --extract-links is currently the only place we use the body of the .text()
// response, so we forego the processing if not performing extraction .await
match response.text().await { .with_context(|| "Could not parse body from response")
// await the response's body .unwrap_or_default();
Ok(text) => text,
Err(e) => { // in the event that the content_length was 0, we can try to get the length
log::warn!("Could not parse body from response: {}", e); // of the body we just parsed. At worst, it's still 0; at best we've accounted
String::new() // for sites that reply without a content-length header and yet still have
} // contents in the body.
} //
} else { // thanks to twitter use @f3rn0s for pointing out the possibility
String::new() let content_length = content_length.max(text.len() as u64);
};
let line_count = text.lines().count(); let line_count = text.lines().count();
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum(); let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
FeroxResponse { FeroxResponse {
url, url,
original_url: original_url.to_string(),
status, status,
method: Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET),
content_length, content_length,
text, text,
headers, headers,
@@ -221,9 +246,67 @@ impl FeroxResponse {
word_count, word_count,
output_level, output_level,
wildcard: false, wildcard: false,
extension: None,
} }
} }
/// if --collect-extensions is used, examine the response's url and grab the file's extension
/// if one is available to be grabbed. If an extension is found, send it to the ScanHandler
/// for further processing
pub(crate) fn parse_extension(&mut self, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: parse_extension");
if !handles.config.collect_extensions {
// early return, --collect-extensions not used
return Ok(());
}
// path_segments:
// Return None for cannot-be-a-base URLs.
// When Some is returned, the iterator always contains at least one string
// (which may be empty).
//
// meaning: the two unwraps here are fine, the worst outcome is an empty string
let filename = self.url.path_segments().unwrap().last().unwrap();
if !filename.is_empty() {
// non-empty string, try to get extension
let parts: Vec<_> = filename
.split('.')
// keep things like /.bash_history from becoming an extension
.filter(|part| !part.is_empty())
.collect();
if parts.len() > 1 {
// filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
self.extension = Some(parts.last().unwrap().to_string())
}
}
if let Some(extension) = &self.extension {
if handles
.config
.status_codes
.contains(&self.status().as_u16()) // in -s list
// or -C was used, and -s should be all responses that aren't filtered
|| !handles.config.filter_status.is_empty()
{
// only add extensions to those responses that pass our checks; filtered out
// status codes are handled by should_filter, but we need to still check against
// the allow list for what we want to keep
#[cfg(test)]
handles
.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))
.unwrap_or_default();
#[cfg(not(test))]
handles.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))?;
}
}
log::trace!("exit: parse_extension");
Ok(())
}
/// Helper function that determines if the configured maximum recursion depth has been reached /// Helper function that determines if the configured maximum recursion depth has been reached
/// ///
/// Essentially looks at the Url path and determines how many directories are present in the /// Essentially looks at the Url path and determines how many directories are present in the
@@ -326,53 +409,81 @@ impl FeroxSerialize for FeroxResponse {
let words = self.word_count().to_string(); let words = self.word_count().to_string();
let chars = self.content_length().to_string(); let chars = self.content_length().to_string();
let status = self.status().as_str(); let status = self.status().as_str();
let method = self.method().as_str();
let wild_status = status_colorizer("WLD"); let wild_status = status_colorizer("WLD");
let mut url_with_redirect = match (
self.status().is_redirection(),
self.headers().get("Location").is_some(),
) {
(true, true) => {
// redirect with Location header, show where it goes if possible
let loc = self
.headers()
.get("Location")
.unwrap() // known Some() already
.to_str()
.unwrap_or("Unknown")
.to_string();
let loc = if loc.starts_with('/') {
if let Ok(joined) = self.url().join(&loc) {
joined.to_string()
} else {
loc
}
} else {
loc
};
// prettify the redirect target
let loc = style(loc).yellow();
format!("{} => {loc}", self.url())
}
_ => {
// no redirect, just use the normal url
self.url().to_string()
}
};
if self.wildcard && matches!(self.output_level, OutputLevel::Default | OutputLevel::Quiet) { if self.wildcard && matches!(self.output_level, OutputLevel::Default | OutputLevel::Quiet) {
// --silent was not used and response is a wildcard, special messages abound when // --silent was not used and response is a wildcard, special messages abound when
// this is the case... // this is the case...
// create the base message // create the base message
let mut message = format!( let mut message = format!(
"{} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n", "{} {:>8} {:>8}l {:>8}w {:>8}c Got {} for {}\n",
wild_status, wild_status,
method,
lines, lines,
words, words,
chars, chars,
status_colorizer(&status), status_colorizer(status),
self.url(), self.url(),
FeroxUrl::path_length_of_url(&self.url)
); );
if self.status().is_redirection() { if self.status().is_redirection() {
// when it's a redirect, show where it goes, if possible // initial wildcard messages are wordy enough, put the redirect by itself
if let Some(next_loc) = self.headers().get("Location") { url_with_redirect = format!(
let next_loc_str = next_loc.to_str().unwrap_or("Unknown"); "{} {:>9} {:>9} {:>9} {}\n",
wild_status, "-", "-", "-", url_with_redirect
);
let redirect_msg = format!( // base message + redirection message (either empty string or redir msg)
"{} {:>9} {:>9} {:>9} {} redirects to => {}\n", message.push_str(&url_with_redirect);
wild_status,
"-",
"-",
"-",
self.url(),
next_loc_str
);
message.push_str(&redirect_msg);
}
} }
// base message + redirection message (if appropriate)
message message
} else { } else {
// not a wildcard, just create a normal entry // not a wildcard, just create a normal entry
utils::create_report_string( utils::create_report_string(
self.status.as_str(), self.status.as_str(),
method,
&lines, &lines,
&words, &words,
&chars, &chars,
self.url().as_str(), &url_with_redirect,
self.output_level, self.output_level,
) )
} }
@@ -423,7 +534,7 @@ impl Serialize for FeroxResponse {
S: Serializer, S: Serializer,
{ {
let mut headers = HashMap::new(); let mut headers = HashMap::new();
let mut state = serializer.serialize_struct("FeroxResponse", 7)?; let mut state = serializer.serialize_struct("FeroxResponse", 8)?;
// need to convert the HeaderMap to a HashMap in order to pass it to the serializer // need to convert the HeaderMap to a HashMap in order to pass it to the serializer
for (key, value) in &self.headers { for (key, value) in &self.headers {
@@ -434,13 +545,19 @@ impl Serialize for FeroxResponse {
state.serialize_field("type", "response")?; state.serialize_field("type", "response")?;
state.serialize_field("url", self.url.as_str())?; state.serialize_field("url", self.url.as_str())?;
state.serialize_field("original_url", self.original_url.as_str())?;
state.serialize_field("path", self.url.path())?; state.serialize_field("path", self.url.path())?;
state.serialize_field("wildcard", &self.wildcard)?; state.serialize_field("wildcard", &self.wildcard)?;
state.serialize_field("status", &self.status.as_u16())?; state.serialize_field("status", &self.status.as_u16())?;
state.serialize_field("method", &self.method.as_str())?;
state.serialize_field("content_length", &self.content_length)?; state.serialize_field("content_length", &self.content_length)?;
state.serialize_field("line_count", &self.line_count)?; state.serialize_field("line_count", &self.line_count)?;
state.serialize_field("word_count", &self.word_count)?; state.serialize_field("word_count", &self.word_count)?;
state.serialize_field("headers", &headers)?; state.serialize_field("headers", &headers)?;
state.serialize_field(
"extension",
self.extension.as_ref().unwrap_or(&String::new()),
)?;
state.end() state.end()
} }
@@ -455,7 +572,9 @@ impl<'de> Deserialize<'de> for FeroxResponse {
{ {
let mut response = Self { let mut response = Self {
url: Url::parse("http://localhost").unwrap(), url: Url::parse("http://localhost").unwrap(),
original_url: String::new(),
status: StatusCode::OK, status: StatusCode::OK,
method: Method::GET,
text: String::new(), text: String::new(),
content_length: 0, content_length: 0,
headers: HeaderMap::new(), headers: HeaderMap::new(),
@@ -463,6 +582,7 @@ impl<'de> Deserialize<'de> for FeroxResponse {
output_level: Default::default(), output_level: Default::default(),
line_count: 0, line_count: 0,
word_count: 0, word_count: 0,
extension: None,
}; };
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?; let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
@@ -476,6 +596,11 @@ impl<'de> Deserialize<'de> for FeroxResponse {
} }
} }
} }
"original_url" => {
if let Some(og_url) = value.as_str() {
response.original_url = String::from(og_url);
}
}
"status" => { "status" => {
if let Some(num) = value.as_u64() { if let Some(num) = value.as_u64() {
if let Ok(smaller) = u16::try_from(num) { if let Ok(smaller) = u16::try_from(num) {
@@ -485,6 +610,11 @@ impl<'de> Deserialize<'de> for FeroxResponse {
} }
} }
} }
"method" => {
if let Some(method) = value.as_str() {
response.method = Method::from_bytes(method.as_bytes()).unwrap_or_default();
}
}
"content_length" => { "content_length" => {
if let Some(num) = value.as_u64() { if let Some(num) = value.as_u64() {
response.content_length = num; response.content_length = num;
@@ -521,6 +651,11 @@ impl<'de> Deserialize<'de> for FeroxResponse {
response.wildcard = result; response.wildcard = result;
} }
} }
"extension" => {
if let Some(result) = value.as_str() {
response.extension = Some(result.to_string());
}
}
_ => {} _ => {}
} }
} }
@@ -532,6 +667,8 @@ impl<'de> Deserialize<'de> for FeroxResponse {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::Configuration;
use std::default::Default;
#[test] #[test]
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false /// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
@@ -540,14 +677,7 @@ mod tests {
let url = Url::parse("http://localhost").unwrap(); let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse { let response = FeroxResponse {
url, url,
status: Default::default(), ..Default::default()
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
}; };
let result = response.reached_max_depth(0, 0, handles); let result = response.reached_max_depth(0, 0, handles);
assert!(!result); assert!(!result);
@@ -561,14 +691,7 @@ mod tests {
let url = Url::parse("http://localhost/one/two").unwrap(); let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse { let response = FeroxResponse {
url, url,
status: Default::default(), ..Default::default()
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
}; };
let result = response.reached_max_depth(0, 2, handles); let result = response.reached_max_depth(0, 2, handles);
@@ -582,14 +705,7 @@ mod tests {
let url = Url::parse("http://localhost").unwrap(); let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse { let response = FeroxResponse {
url, url,
status: Default::default(), ..Default::default()
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
}; };
let result = response.reached_max_depth(0, 2, handles); let result = response.reached_max_depth(0, 2, handles);
@@ -603,14 +719,7 @@ mod tests {
let url = Url::parse("http://localhost/one/two").unwrap(); let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse { let response = FeroxResponse {
url, url,
status: Default::default(), ..Default::default()
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
}; };
let result = response.reached_max_depth(2, 2, handles); let result = response.reached_max_depth(2, 2, handles);
@@ -624,17 +733,71 @@ mod tests {
let url = Url::parse("http://localhost/one/two/three").unwrap(); let url = Url::parse("http://localhost/one/two/three").unwrap();
let response = FeroxResponse { let response = FeroxResponse {
url, url,
status: Default::default(), ..Default::default()
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
}; };
let result = response.reached_max_depth(0, 2, handles); let result = response.reached_max_depth(0, 2, handles);
assert!(result); assert!(result);
} }
#[test]
/// simple case of a single extension gets parsed correctly and stored on the `FeroxResponse`
fn parse_extension_finds_simple_extension() {
let config = Configuration {
collect_extensions: true,
..Default::default()
};
let (handles, _) = Handles::for_testing(None, Some(Arc::new(config)));
let url = Url::parse("http://localhost/derp.js").unwrap();
let mut response = FeroxResponse {
url,
..Default::default()
};
response.parse_extension(Arc::new(handles)).unwrap();
assert_eq!(response.extension, Some(String::from("js")));
}
#[test]
/// hidden files shouldn't be parsed as extensions, i.e. `/.bash_history`
fn parse_extension_ignores_hidden_files() {
let config = Configuration {
collect_extensions: true,
..Default::default()
};
let (handles, _) = Handles::for_testing(None, Some(Arc::new(config)));
let url = Url::parse("http://localhost/.bash_history").unwrap();
let mut response = FeroxResponse {
url,
..Default::default()
};
response.parse_extension(Arc::new(handles)).unwrap();
assert_eq!(response.extension, None);
}
#[test]
/// `parse_extension` should return immediately if `--collect-extensions` isn't used
fn parse_extension_early_returns_based_on_config() {
let (handles, _) = Handles::for_testing(None, None);
let url = Url::parse("http://localhost/derp.js").unwrap();
let mut response = FeroxResponse {
url,
..Default::default()
};
response.parse_extension(Arc::new(handles)).unwrap();
assert_eq!(response.extension, None);
}
} }

View File

@@ -1,27 +1,53 @@
use crate::filters::filter_lookup;
use crate::progress::PROGRESS_BAR; use crate::progress::PROGRESS_BAR;
use crate::traits::FeroxFilter;
use console::{measure_text_width, pad_str, style, Alignment, Term}; use console::{measure_text_width, pad_str, style, Alignment, Term};
use indicatif::ProgressDrawTarget; use indicatif::ProgressDrawTarget;
use regex::Regex;
/// Data container for a command entered by the user interactively
#[derive(Debug)]
pub enum MenuCmd {
/// user wants to add a url to be scanned
AddUrl(String),
/// user wants to cancel one or more active scans
Cancel(Vec<usize>, bool),
/// user wants to create a new filter
AddFilter(Box<dyn FeroxFilter>),
/// user wants to remove one or more active filters
RemoveFilter(Vec<usize>),
}
/// Data container for a command result to be used internally by the ferox_scanner
#[derive(Debug)]
pub enum MenuCmdResult {
/// Url to be added to the scan queue
Url(String),
/// Number of scans that were actually cancelled, can be 0
NumCancelled(usize),
/// Filter to be added to current list of `FeroxFilters`
Filter(Box<dyn FeroxFilter>),
}
/// Interactive scan cancellation menu /// Interactive scan cancellation menu
#[derive(Debug)] #[derive(Debug)]
pub(super) struct Menu { pub(super) struct Menu {
/// character to use as visual separator of lines
separator: String,
/// name of menu
name: String,
/// header: name surrounded by separators /// header: name surrounded by separators
header: String, header: String,
/// instructions
instructions: String,
/// footer: instructions surrounded by separators /// footer: instructions surrounded by separators
footer: String, footer: String,
/// unicode line border, matched to longest displayed line
border: String,
/// target for output /// target for output
term: Term, pub(super) term: Term,
} }
/// Implementation of Menu /// Implementation of Menu
@@ -30,34 +56,73 @@ impl Menu {
pub(super) fn new() -> Self { pub(super) fn new() -> Self {
let separator = "".to_string(); let separator = "".to_string();
let instructions = format!(
"Enter a {} list of indexes to {} (ex: 2,3)",
style("comma-separated").yellow(),
style("cancel").red(),
);
let name = format!( let name = format!(
"{} {} {}", "{} {} {}",
"💀", "💀",
style("Scan Cancel Menu").bright().yellow(), style("Scan Management Menu").bright().yellow(),
"💀" "💀"
); );
let longest = measure_text_width(&instructions).max(measure_text_width(&name)); let add_cmd = format!(
" {}[{}] NEW_URL (ex: {} http://localhost)\n",
style("a").green(),
style("dd").green(),
style("add").green()
);
let canx_cmd = format!(
" {}[{}] [-f] SCAN_ID[-SCAN_ID[,...]] (ex: {} 1-4,8,9-13 or {} -f 3)\n",
style("c").red(),
style("ancel").red(),
style("cancel").red(),
style("c").red(),
);
let new_filter_cmd = format!(
" {}[{}] FILTER_TYPE FILTER_VALUE (ex: {} lines 40)\n",
style("n").green(),
style("ew-filter").green(),
style("n").green(),
);
let valid_filters = format!(
" FILTER_TYPEs: {}, {}, {}, {}, {}, {}\n",
style("status").yellow(),
style("lines").yellow(),
style("size").yellow(),
style("words").yellow(),
style("regex").yellow(),
style("similarity").yellow()
);
let rm_filter_cmd = format!(
" {}[{}] FILTER_ID[-FILTER_ID[,...]] (ex: {} 1-4,8,9-13 or {} 3)",
style("r").red(),
style("m-filter").red(),
style("rm-filter").red(),
style("r").red(),
);
let mut commands = format!("{}:\n", style("Commands").bright().blue());
commands.push_str(&add_cmd);
commands.push_str(&canx_cmd);
commands.push_str(&new_filter_cmd);
commands.push_str(&valid_filters);
commands.push_str(&rm_filter_cmd);
let longest = measure_text_width(&canx_cmd).max(measure_text_width(&name));
let border = separator.repeat(longest); let border = separator.repeat(longest);
let padded_name = pad_str(&name, longest, Alignment::Center, None); let padded_name = pad_str(&name, longest, Alignment::Center, None);
let header = format!("{}\n{}\n{}", border, padded_name, border); let header = format!("{border}\n{padded_name}\n{border}");
let footer = format!("{}\n{}\n{}", border, instructions, border); let footer = format!("{commands}\n{border}");
Self { Self {
separator,
name,
header, header,
instructions,
footer, footer,
border,
term: Term::stderr(), term: Term::stderr(),
} }
} }
@@ -67,6 +132,11 @@ impl Menu {
self.println(&self.header); self.println(&self.header);
} }
/// print menu unicode border line
pub(super) fn print_border(&self) {
self.println(&self.border);
}
/// print menu footer /// print menu footer
pub(super) fn print_footer(&self) { pub(super) fn print_footer(&self) {
self.println(&self.footer); self.println(&self.footer);
@@ -93,33 +163,134 @@ impl Menu {
self.term.write_line(msg).unwrap_or_default(); self.term.write_line(msg).unwrap_or_default();
} }
/// split a string into vec of usizes /// Helper for parsing a usize from a str
pub(super) fn split_to_nums(&self, line: &str) -> Vec<usize> { fn str_to_usize(&self, value: &str) -> usize {
line.split(',') if value.is_empty() {
.map(|s| { return 0;
s.trim().to_string().parse::<usize>().unwrap_or_else(|e| { }
self.println(&format!("Found non-numeric input: {}", e));
0 value
}) .trim()
.to_string()
.parse::<usize>()
.unwrap_or_else(|e| {
self.println(&format!("Found non-numeric input: {e}: {value:?}"));
0
}) })
.filter(|m| *m != 0)
.collect()
} }
/// get comma-separated list of scan indexes from the user /// split a comma delimited string into vec of usizes
pub(super) fn get_scans_from_user(&self) -> Option<Vec<usize>> { pub(super) fn split_to_nums(&self, line: &str) -> Vec<usize> {
if let Ok(line) = self.term.read_line() { let mut nums = Vec::new();
Some(self.split_to_nums(&line)) let values = line.split(',');
} else {
None for mut value in values {
value = value.trim();
if value.contains('-') {
// range of two values, needs further processing
let range: Vec<usize> = value
.split('-')
.map(|s| self.str_to_usize(s))
.filter(|m| *m != 0)
.collect();
if range.len() != 2 {
// expecting [1, 4] or similar, if a 0 was used, we'd be left with a vec of size 1
self.println(&format!("Found invalid range of scans: {value}"));
continue;
}
(range[0]..=range[1]).for_each(|n| {
// iterate from lower to upper bound and add all interim values, skipping
// any already known
if !nums.contains(&n) {
nums.push(n)
}
});
} else {
let value = self.str_to_usize(value);
if value != 0 && !nums.contains(&value) {
// the zeroth scan is always skipped, skip already known values
nums.push(value);
}
}
}
nums
}
/// get input from the user and translate it to a `MenuCmd`
pub(super) fn get_command_input_from_user(&self, line: &str) -> Option<MenuCmd> {
let line = line.trim(); // normalize input if there are leading spaces
match line.chars().next().unwrap_or('_').to_ascii_lowercase() {
'c' => {
// cancel command; start by determining if -f was used
let force = line.contains("-f");
// then remove c[ancel] from the command so it can be passed to the number
// splitter
let re = Regex::new(r"^[cC][ancelANCEL]*").unwrap();
let line = line.replace("-f", "");
let line = re.replace(&line, "").to_string();
Some(MenuCmd::Cancel(self.split_to_nums(&line), force))
}
'a' => {
// add command
// similar to cancel, we need to remove the a[dd] substring, the rest should be
// a url
let re = Regex::new(r"^[aA][dD]*").unwrap();
let line = re.replace(line, "").to_string().trim().to_string();
Some(MenuCmd::AddUrl(line))
}
'n' => {
// new filter command
let mut line = line.split_whitespace();
line.next(); // 'n' or 'new-filter'
if let Some(filter_type) = line.next() {
// have a string in the filter_type position
if let Some(filter_value) = line.next() {
// have a string in the filter_value position
if let Some(result) = filter_lookup(filter_type, filter_value) {
// lookup was successful, return the new filter
return Some(MenuCmd::AddFilter(result));
}
}
}
None
}
'r' => {
// remove filter command
// remove r[m-filter] from the command so it can be passed to the number
// splitter
let re = Regex::new(r"^[rR][mfilterMFILTER-]*").unwrap();
// we don't respect a -f or lack thereof in this command, but in case the user
// doesn't realize / thinks its the same as cancel -f, just remove it
let line = line.replace("-f", "");
let line = re.replace(&line, "").to_string();
let indices = self.split_to_nums(&line);
Some(MenuCmd::RemoveFilter(indices))
}
_ => {
// invalid input
None
}
} }
} }
/// Given a url, confirm with user that we should cancel /// Given a url, confirm with user that we should cancel
pub(super) fn confirm_cancellation(&self, url: &str) -> char { pub(super) fn confirm_cancellation(&self, url: &str) -> char {
self.println(&format!( self.println(&format!(
"You sure you wanna cancel this scan: {}? [Y/n]", "You sure you wanna cancel this scan: {url}? [Y/n]"
url
)); ));
self.term.read_char().unwrap_or('n') self.term.read_char().unwrap_or('n')

View File

@@ -9,6 +9,7 @@ mod state;
mod tests; mod tests;
pub(self) use menu::Menu; pub(self) use menu::Menu;
pub use menu::{MenuCmd, MenuCmdResult};
pub use order::ScanOrder; pub use order::ScanOrder;
pub use response_container::FeroxResponses; pub use response_container::FeroxResponses;
pub use scan::{FeroxScan, ScanStatus, ScanType}; pub use scan::{FeroxScan, ScanStatus, ScanType};

View File

@@ -45,7 +45,7 @@ impl FeroxResponses {
pub fn contains(&self, other: &FeroxResponse) -> bool { pub fn contains(&self, other: &FeroxResponse) -> bool {
if let Ok(responses) = self.responses.read() { if let Ok(responses) = self.responses.read() {
for response in responses.iter() { for response in responses.iter() {
if response.url() == other.url() { if response.url() == other.url() && response.method() == other.method() {
return true; return true;
} }
} }

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::{ use crate::{
config::OutputLevel, config::OutputLevel,
progress::{add_bar, BarType}, progress::{add_bar, BarType},
scanner::PolicyTrigger,
}; };
use anyhow::Result; use anyhow::Result;
use console::style; use console::style;
@@ -12,8 +13,10 @@ use std::{
collections::HashMap, collections::HashMap,
fmt, fmt,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Instant,
}; };
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::{sync, task::JoinHandle}; use tokio::{sync, task::JoinHandle};
use uuid::Uuid; use uuid::Uuid;
@@ -29,17 +32,26 @@ pub struct FeroxScan {
/// The URL that to be scanned /// The URL that to be scanned
pub(super) url: String, pub(super) url: String,
/// A url used solely for comparison to other URLs
pub(super) normalized_url: String,
/// The type of scan /// The type of scan
pub(super) scan_type: ScanType, pub scan_type: ScanType,
/// The order in which the scan was received /// The order in which the scan was received
pub(super) scan_order: ScanOrder, pub(crate) scan_order: ScanOrder,
/// Number of requests to populate the progress bar with /// Number of requests to populate the progress bar with
pub(super) num_requests: u64, pub(super) num_requests: u64,
/// Number of requests made so far, only used during deserialization
///
/// serialization: saves self.requests() to this field
/// deserialization: sets self.requests_made_so_far to this field
pub(super) requests_made_so_far: u64,
/// Status of this scan /// Status of this scan
pub(super) status: Mutex<ScanStatus>, pub status: Mutex<ScanStatus>,
/// The spawned tokio task performing this scan (uses tokio::sync::Mutex) /// The spawned tokio task performing this scan (uses tokio::sync::Mutex)
pub(super) task: sync::Mutex<Option<JoinHandle<()>>>, pub(super) task: sync::Mutex<Option<JoinHandle<()>>>,
@@ -49,24 +61,42 @@ pub struct FeroxScan {
/// whether or not the user passed --silent|--quiet on the command line /// whether or not the user passed --silent|--quiet on the command line
pub(super) output_level: OutputLevel, pub(super) output_level: OutputLevel,
/// tracker for overall number of 403s seen by the FeroxScan instance
pub(super) status_403s: AtomicUsize,
/// tracker for overall number of 429s seen by the FeroxScan instance
pub(super) status_429s: AtomicUsize,
/// tracker for total number of errors encountered by the FeroxScan instance
pub(super) errors: AtomicUsize,
/// tracker for the time at which this scan was started
pub(super) start_time: Instant,
} }
/// Default implementation for FeroxScan /// Default implementation for FeroxScan
impl Default for FeroxScan { impl Default for FeroxScan {
/// Create a default FeroxScan, populates ID with a new UUID /// Create a default FeroxScan, populates ID with a new UUID
fn default() -> Self { fn default() -> Self {
let new_id = Uuid::new_v4().to_simple().to_string(); let new_id = Uuid::new_v4().as_simple().to_string();
FeroxScan { FeroxScan {
id: new_id, id: new_id,
task: sync::Mutex::new(None), // tokio mutex task: sync::Mutex::new(None), // tokio mutex
status: Mutex::new(ScanStatus::default()), status: Mutex::new(ScanStatus::default()),
num_requests: 0, num_requests: 0,
requests_made_so_far: 0,
scan_order: ScanOrder::Latest, scan_order: ScanOrder::Latest,
url: String::new(), url: String::new(),
normalized_url: String::new(),
progress_bar: Mutex::new(None), progress_bar: Mutex::new(None),
scan_type: ScanType::File, scan_type: ScanType::File,
output_level: Default::default(), output_level: Default::default(),
errors: Default::default(),
status_429s: Default::default(),
status_403s: Default::default(),
start_time: Instant::now(),
} }
} }
} }
@@ -75,16 +105,22 @@ impl Default for FeroxScan {
impl FeroxScan { impl FeroxScan {
/// Stop a currently running scan /// Stop a currently running scan
pub async fn abort(&self) -> Result<()> { pub async fn abort(&self) -> Result<()> {
let mut guard = self.task.lock().await; log::trace!("enter: abort");
if guard.is_some() { match self.task.try_lock() {
if let Some(task) = std::mem::replace(&mut *guard, None) { Ok(mut guard) => {
task.abort(); if let Some(task) = std::mem::replace(&mut *guard, None) {
self.set_status(ScanStatus::Cancelled)?; log::trace!("aborting {:?}", self);
self.stop_progress_bar(); task.abort();
self.set_status(ScanStatus::Cancelled)?;
self.stop_progress_bar();
}
}
Err(e) => {
log::warn!("Could not acquire lock to abort scan (we're already waiting for its results): {:?} {}", self, e);
} }
} }
log::trace!("exit: abort");
Ok(()) Ok(())
} }
@@ -93,6 +129,11 @@ impl FeroxScan {
&self.url &self.url
} }
/// getter for number of requests made during previously saved scans (i.e. --resume-from used)
pub fn requests_made_so_far(&self) -> u64 {
self.requests_made_so_far
}
/// small wrapper to set the JoinHandle /// small wrapper to set the JoinHandle
pub async fn set_task(&self, task: JoinHandle<()>) -> Result<()> { pub async fn set_task(&self, task: JoinHandle<()>) -> Result<()> {
let mut guard = self.task.lock().await; let mut guard = self.task.lock().await;
@@ -133,7 +174,10 @@ impl FeroxScan {
let pb = add_bar(&self.url, self.num_requests, bar_type); let pb = add_bar(&self.url, self.num_requests, bar_type);
pb.reset_elapsed(); pb.reset_elapsed();
pb.set_position(self.requests_made_so_far);
let _ = std::mem::replace(&mut *guard, Some(pb.clone())); let _ = std::mem::replace(&mut *guard, Some(pb.clone()));
pb pb
} }
} }
@@ -165,6 +209,7 @@ impl FeroxScan {
) -> Arc<Self> { ) -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
url: url.to_string(), url: url.to_string(),
normalized_url: format!("{}/", url.trim_end_matches('/')),
scan_type, scan_type,
scan_order, scan_order,
num_requests, num_requests,
@@ -202,6 +247,14 @@ impl FeroxScan {
false false
} }
/// small wrapper to inspect ScanStatus and see if it's Cancelled
pub fn is_cancelled(&self) -> bool {
if let Ok(guard) = self.status.lock() {
return matches!(*guard, ScanStatus::Cancelled);
}
false
}
/// await a task's completion, similar to a thread's join; perform necessary bookkeeping /// await a task's completion, similar to a thread's join; perform necessary bookkeeping
pub async fn join(&self) { pub async fn join(&self) {
log::trace!("enter join({:?})", self); log::trace!("enter join({:?})", self);
@@ -217,6 +270,62 @@ impl FeroxScan {
log::trace!("exit join({:?})", self); log::trace!("exit join({:?})", self);
} }
/// increment the value in question by 1
pub(crate) fn add_403(&self) {
self.status_403s.fetch_add(1, Ordering::Relaxed);
}
/// increment the value in question by 1
pub(crate) fn add_429(&self) {
self.status_429s.fetch_add(1, Ordering::Relaxed);
}
/// increment the value in question by 1
pub(crate) fn add_error(&self) {
self.errors.fetch_add(1, Ordering::Relaxed);
}
/// simple wrapper to call the appropriate getter based on the given PolicyTrigger
pub fn num_errors(&self, trigger: PolicyTrigger) -> usize {
match trigger {
PolicyTrigger::Status403 => self.status_403s(),
PolicyTrigger::Status429 => self.status_429s(),
PolicyTrigger::Errors => self.errors(),
PolicyTrigger::TryAdjustUp => 0,
}
}
/// return the number of errors seen by this scan
fn errors(&self) -> usize {
self.errors.load(Ordering::Relaxed)
}
/// return the number of 403s seen by this scan
fn status_403s(&self) -> usize {
self.status_403s.load(Ordering::Relaxed)
}
/// return the number of 429s seen by this scan
fn status_429s(&self) -> usize {
self.status_429s.load(Ordering::Relaxed)
}
/// return the number of requests per second performed by this scan's scanner
pub fn requests_per_second(&self) -> u64 {
if !self.is_active() {
return 0;
}
let reqs = self.requests();
let seconds = self.start_time.elapsed().as_secs();
reqs.checked_div(seconds).unwrap_or(0)
}
/// return the number of requests performed by this scan's scanner
pub fn requests(&self) -> u64 {
self.progress_bar().position()
}
} }
/// Display implementation /// Display implementation
@@ -251,13 +360,15 @@ impl Serialize for FeroxScan {
where where
S: Serializer, S: Serializer,
{ {
let mut state = serializer.serialize_struct("FeroxScan", 4)?; let mut state = serializer.serialize_struct("FeroxScan", 6)?;
state.serialize_field("id", &self.id)?; state.serialize_field("id", &self.id)?;
state.serialize_field("url", &self.url)?; state.serialize_field("url", &self.url)?;
state.serialize_field("normalized_url", &self.normalized_url)?;
state.serialize_field("scan_type", &self.scan_type)?; state.serialize_field("scan_type", &self.scan_type)?;
state.serialize_field("status", &self.status)?; state.serialize_field("status", &self.status)?;
state.serialize_field("num_requests", &self.num_requests)?; state.serialize_field("num_requests", &self.num_requests)?;
state.serialize_field("requests_made_so_far", &self.requests())?;
state.end() state.end()
} }
@@ -306,11 +417,21 @@ impl<'de> Deserialize<'de> for FeroxScan {
scan.url = url.to_string(); scan.url = url.to_string();
} }
} }
"normalized_url" => {
if let Some(normalized_url) = value.as_str() {
scan.normalized_url = normalized_url.to_string();
}
}
"num_requests" => { "num_requests" => {
if let Some(num_requests) = value.as_u64() { if let Some(num_requests) = value.as_u64() {
scan.num_requests = num_requests; scan.num_requests = num_requests;
} }
} }
"requests_made_so_far" => {
if let Some(requests_made_so_far) = value.as_u64() {
scan.requests_made_so_far = requests_made_so_far;
}
}
_ => {} _ => {}
} }
} }
@@ -360,3 +481,70 @@ impl Default for ScanStatus {
Self::NotStarted Self::NotStarted
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::thread::sleep;
use tokio::time::Duration;
#[test]
/// ensure that num_errors returns the correct values for the given PolicyTrigger
///
/// covers tests for add_[403,429,error] and the related getters in addition to num_errors
fn num_errors_returns_correct_values() {
let scan = FeroxScan::new(
"http://localhost",
ScanType::Directory,
ScanOrder::Latest,
1000,
OutputLevel::Default,
None,
);
scan.add_error();
scan.add_403();
scan.add_403();
scan.add_429();
scan.add_429();
scan.add_429();
assert_eq!(scan.num_errors(PolicyTrigger::Errors), 1);
assert_eq!(scan.num_errors(PolicyTrigger::Status403), 2);
assert_eq!(scan.num_errors(PolicyTrigger::Status429), 3);
}
#[test]
/// ensure that requests_per_second returns the correct values
fn requests_per_second_returns_correct_values() {
let scan = FeroxScan {
id: "".to_string(),
url: "".to_string(),
normalized_url: String::from("/"),
scan_type: ScanType::Directory,
scan_order: ScanOrder::Initial,
num_requests: 0,
requests_made_so_far: 0,
status: Mutex::new(ScanStatus::Running),
task: Default::default(),
progress_bar: Mutex::new(None),
output_level: Default::default(),
status_403s: Default::default(),
status_429s: Default::default(),
errors: Default::default(),
start_time: Instant::now(),
};
let pb = scan.progress_bar();
pb.set_position(100);
sleep(Duration::new(1, 0));
let req_sec = scan.requests_per_second();
assert_eq!(req_sec, 100);
scan.finish().unwrap();
assert_eq!(scan.requests_per_second(), 0);
}
}

View File

@@ -1,16 +1,28 @@
use super::scan::ScanType; use super::scan::ScanType;
use super::*; use super::*;
use crate::event_handlers::Handles;
use crate::filters::{
EmptyFilter, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
WildcardFilter, WordsFilter,
};
use crate::traits::FeroxFilter;
use crate::Command::AddFilter;
use crate::{ use crate::{
banner::Banner,
config::OutputLevel, config::OutputLevel,
progress::PROGRESS_PRINTER, progress::PROGRESS_PRINTER,
progress::{add_bar, BarType}, progress::{add_bar, BarType},
scan_manager::{MenuCmd, MenuCmdResult},
scanner::RESPONSES, scanner::RESPONSES,
traits::FeroxSerialize, traits::FeroxSerialize,
SLEEP_DURATION, Command, SLEEP_DURATION,
}; };
use anyhow::Result; use anyhow::Result;
use console::style;
use reqwest::StatusCode;
use serde::{ser::SerializeSeq, Serialize, Serializer}; use serde::{ser::SerializeSeq, Serialize, Serializer};
use std::{ use std::{
collections::HashSet,
convert::TryInto, convert::TryInto,
fs::File, fs::File,
io::BufReader, io::BufReader,
@@ -45,6 +57,9 @@ pub struct FeroxScans {
/// whether or not the user passed --silent|--quiet on the command line /// whether or not the user passed --silent|--quiet on the command line
output_level: OutputLevel, output_level: OutputLevel,
/// vector of extensions discovered and collected during scans
pub(crate) collected_extensions: RwLock<HashSet<String>>,
} }
/// Serialize implementation for FeroxScans /// Serialize implementation for FeroxScans
@@ -56,17 +71,20 @@ impl Serialize for FeroxScans {
where where
S: Serializer, S: Serializer,
{ {
if let Ok(scans) = self.scans.read() { match self.scans.read() {
let mut seq = serializer.serialize_seq(Some(scans.len()))?; Ok(scans) => {
for scan in scans.iter() { let mut seq = serializer.serialize_seq(Some(scans.len() + 1))?;
seq.serialize_element(&*scan).unwrap_or_default();
}
seq.end() for scan in scans.iter() {
} else { seq.serialize_element(scan).unwrap_or_default();
// if for some reason we can't unlock the RwLock, just write an empty list }
let seq = serializer.serialize_seq(Some(0))?; seq.end()
seq.end() }
Err(_) => {
// if for some reason we can't unlock the RwLock, just write an empty list
let seq = serializer.serialize_seq(Some(0))?;
seq.end()
}
} }
} }
} }
@@ -107,8 +125,8 @@ impl FeroxScans {
sentry sentry
} }
/// load serialized FeroxScan(s) into this FeroxScans /// load serialized FeroxScan(s) and any previously collected extensions into this FeroxScans
pub fn add_serialized_scans(&self, filename: &str) -> Result<()> { pub fn add_serialized_scans(&self, filename: &str, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: add_serialized_scans({})", filename); log::trace!("enter: add_serialized_scans({})", filename);
let file = File::open(filename)?; let file = File::open(filename)?;
@@ -120,18 +138,83 @@ impl FeroxScans {
for scan in arr_scans { for scan in arr_scans {
let mut deser_scan: FeroxScan = let mut deser_scan: FeroxScan =
serde_json::from_value(scan.clone()).unwrap_or_default(); serde_json::from_value(scan.clone()).unwrap_or_default();
if deser_scan.is_cancelled() {
// if the scan was cancelled by the user, mark it as complete. This will
// prevent the scan from being resumed as well as prevent the wordlist
// from requesting it again
if let Ok(mut guard) = deser_scan.status.lock() {
*guard = ScanStatus::Complete;
}
}
// FeroxScans gets -q value from config as usual; the FeroxScans themselves // FeroxScans gets -q value from config as usual; the FeroxScans themselves
// rely on that value being passed in. If the user starts a scan without -q // rely on that value being passed in. If the user starts a scan without -q
// and resumes the scan but adds -q, FeroxScan will not have the proper value // and resumes the scan but adds -q, FeroxScan will not have the proper value
// without the line below // without the line below
deser_scan.output_level = self.output_level; deser_scan.output_level = self.output_level;
log::debug!("added: {}", deser_scan);
self.insert(Arc::new(deser_scan)); self.insert(Arc::new(deser_scan));
} }
} }
} }
if let Some(extensions) = state.get("collected_extensions") {
if let Some(arr_exts) = extensions.as_array() {
if let Ok(mut guard) = self.collected_extensions.write() {
for ext in arr_exts {
let deser_ext: String =
serde_json::from_value(ext.clone()).unwrap_or_default();
guard.insert(deser_ext);
}
}
}
}
if let Some(filters) = state.get("filters") {
if let Some(arr_filters) = filters.as_array() {
for filter in arr_filters {
let final_filter: Box<dyn FeroxFilter> = if let Ok(deserialized) =
serde_json::from_value::<RegexFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<WordsFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<WildcardFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<SizeFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<LinesFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<SimilarityFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<StatusCodeFilter>(filter.clone())
{
Box::new(deserialized)
} else {
Box::new(EmptyFilter {})
};
handles
.filters
.send(AddFilter(final_filter))
.unwrap_or_default();
}
}
}
log::trace!("exit: add_serialized_scans"); log::trace!("exit: add_serialized_scans");
Ok(()) Ok(())
} }
@@ -140,8 +223,10 @@ impl FeroxScans {
/// on the given URL /// on the given URL
pub fn contains(&self, url: &str) -> bool { pub fn contains(&self, url: &str) -> bool {
if let Ok(scans) = self.scans.read() { if let Ok(scans) = self.scans.read() {
let normalized = format!("{}/", url.trim_end_matches('/'));
for scan in scans.iter() { for scan in scans.iter() {
if scan.url == url { if scan.normalized_url == normalized {
return true; return true;
} }
} }
@@ -152,8 +237,10 @@ impl FeroxScans {
/// Find and return a `FeroxScan` based on the given URL /// Find and return a `FeroxScan` based on the given URL
pub fn get_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> { pub fn get_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
if let Ok(guard) = self.scans.read() { if let Ok(guard) = self.scans.read() {
let normalized = format!("{}/", url.trim_end_matches('/'));
for scan in guard.iter() { for scan in guard.iter() {
if scan.url == url { if scan.normalized_url == normalized {
return Some(scan.clone()); return Some(scan.clone());
} }
} }
@@ -161,6 +248,63 @@ impl FeroxScans {
None None
} }
pub fn get_base_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
log::trace!("enter: get_base_scan_by_url({})", url);
// rmatch_indices returns tuples in index, match form, i.e. (10, "/")
// with the furthest-right match in the first position in the vector
let matches: Vec<_> = url.rmatch_indices('/').collect();
// iterate from the furthest right matching index and check the given url from the
// start to the furthest-right '/' character. compare that slice to the urls associated
// with directory scans and return the first match, since it should be the 'deepest'
// match.
// Example:
// url: http://shmocalhost/src/release/examples/stuff.php
// scans:
// http://shmocalhost/src/statistics
// http://shmocalhost/src/banner
// http://shmocalhost/src/release
// http://shmocalhost/src/release/examples
//
// returns: http://shmocalhost/src/release/examples
if let Ok(guard) = self.scans.read() {
for (idx, _) in &matches {
for scan in guard.iter() {
let slice = url.index(0..*idx);
if slice == scan.url || format!("{slice}/").as_str() == scan.url {
log::trace!("enter: get_base_scan_by_url -> {}", scan);
return Some(scan.clone());
}
}
}
}
log::trace!("enter: get_base_scan_by_url -> None");
None
}
/// add one to either 403 or 429 tracker in the scan related to the given url
pub fn increment_status_code(&self, url: &str, code: StatusCode) {
if let Some(scan) = self.get_base_scan_by_url(url) {
match code {
StatusCode::TOO_MANY_REQUESTS => {
scan.add_429();
}
StatusCode::FORBIDDEN => {
scan.add_403();
}
_ => {}
}
}
}
/// add one to either 403 or 429 tracker in the scan related to the given url
pub fn increment_error(&self, url: &str) {
if let Some(scan) = self.get_base_scan_by_url(url) {
scan.add_error();
}
}
/// Print all FeroxScans of type Directory /// Print all FeroxScans of type Directory
/// ///
/// Example: /// Example:
@@ -178,6 +322,8 @@ impl FeroxScans {
.clone() .clone()
}; };
let mut printed = 0;
for (i, scan) in scans.iter().enumerate() { for (i, scan) in scans.iter().enumerate() {
if matches!(scan.scan_order, ScanOrder::Initial) || scan.task.try_lock().is_err() { if matches!(scan.scan_order, ScanOrder::Initial) || scan.task.try_lock().is_err() {
// original target passed in via either -u or --stdin // original target passed in via either -u or --stdin
@@ -185,18 +331,29 @@ impl FeroxScans {
} }
if matches!(scan.scan_type, ScanType::Directory) { if matches!(scan.scan_type, ScanType::Directory) {
if printed == 0 {
self.menu
.println(&format!("{}:", style("Scans").bright().blue()));
}
// we're only interested in displaying directory scans, as those are // we're only interested in displaying directory scans, as those are
// the only ones that make sense to be stopped // the only ones that make sense to be stopped
let scan_msg = format!("{:3}: {}", i, scan); let scan_msg = format!("{i:3}: {scan}");
self.menu.println(&scan_msg); self.menu.println(&scan_msg);
printed += 1;
} }
} }
if printed > 0 {
self.menu.print_border();
}
} }
/// Given a list of indexes, cancel their associated FeroxScans /// Given a list of indexes, cancel their associated FeroxScans
async fn cancel_scans(&self, indexes: Vec<usize>) { async fn cancel_scans(&self, indexes: Vec<usize>, force: bool) -> usize {
let menu_pause_duration = Duration::from_millis(SLEEP_DURATION); let menu_pause_duration = Duration::from_millis(SLEEP_DURATION);
let mut num_cancelled = 0_usize;
for num in indexes { for num in indexes {
let selected = match self.scans.read() { let selected = match self.scans.read() {
Ok(u_scans) => { Ok(u_scans) => {
@@ -204,7 +361,7 @@ impl FeroxScans {
if num >= u_scans.len() { if num >= u_scans.len() {
// usize can't be negative, just need to handle exceeding bounds // usize can't be negative, just need to handle exceeding bounds
self.menu self.menu
.println(&format!("The number {} is not a valid choice.", num)); .println(&format!("The number {num} is not a valid choice."));
sleep(menu_pause_duration); sleep(menu_pause_duration);
continue; continue;
} }
@@ -213,36 +370,96 @@ impl FeroxScans {
Err(..) => continue, Err(..) => continue,
}; };
let input = self.menu.confirm_cancellation(&selected.url); let input = if force {
'y'
} else {
self.menu.confirm_cancellation(&selected.url)
};
if input == 'y' || input == '\n' { if input == 'y' || input == '\n' {
self.menu.println(&format!("Stopping {}...", selected.url)); self.menu.println(&format!("Stopping {}...", selected.url));
selected selected
.abort() .abort()
.await .await
.unwrap_or_else(|e| log::warn!("Could not cancel task: {}", e)); .unwrap_or_else(|e| log::warn!("Could not cancel task: {}", e));
let pb = selected.progress_bar();
num_cancelled += pb.length() as usize - pb.position() as usize
} else { } else {
self.menu.println("Ok, doing nothing..."); self.menu.println("Ok, doing nothing...");
} }
sleep(menu_pause_duration); sleep(menu_pause_duration);
} }
num_cancelled
}
fn display_filters(&self, handles: Arc<Handles>) {
let mut printed = 0;
if let Ok(guard) = handles.filters.data.filters.read() {
for (i, filter) in guard.iter().enumerate() {
if i == 0 {
self.menu
.println(&format!("{}:", style("Filters").bright().blue()));
}
let filter_msg = format!("{:3}: {}", i + 1, filter);
self.menu.println(&filter_msg);
printed += 1;
}
if printed > 0 {
self.menu.print_border();
}
}
} }
/// CLI menu that allows for interactive cancellation of recursed-into directories /// CLI menu that allows for interactive cancellation of recursed-into directories
async fn interactive_menu(&self) { async fn interactive_menu(&self, handles: Arc<Handles>) -> Option<MenuCmdResult> {
self.menu.hide_progress_bars(); self.menu.hide_progress_bars();
self.menu.clear_screen(); self.menu.clear_screen();
self.menu.print_header(); self.menu.print_header();
self.display_scans().await; self.display_scans().await;
self.display_filters(handles.clone());
self.menu.print_footer(); self.menu.print_footer();
if let Some(input) = self.menu.get_scans_from_user() { let menu_cmd = if let Ok(line) = self.menu.term.read_line() {
self.cancel_scans(input).await self.menu.get_command_input_from_user(&line)
} else {
None
};
let result = match menu_cmd {
Some(MenuCmd::Cancel(indices, should_force)) => {
// cancel the things
let num_cancelled = self.cancel_scans(indices, should_force).await;
Some(MenuCmdResult::NumCancelled(num_cancelled))
}
Some(MenuCmd::AddUrl(url)) => Some(MenuCmdResult::Url(url)),
Some(MenuCmd::AddFilter(filter)) => Some(MenuCmdResult::Filter(filter)),
Some(MenuCmd::RemoveFilter(indices)) => {
handles
.filters
.send(Command::RemoveFilters(indices))
.unwrap_or_default();
None
}
None => None,
}; };
self.menu.clear_screen(); self.menu.clear_screen();
let banner = Banner::new(&[handles.config.target_url.clone()], &handles.config);
banner
.print_to(&self.menu.term, handles.config.clone())
.unwrap_or_default();
self.menu.show_progress_bars(); self.menu.show_progress_bars();
result
} }
/// prints all known responses that the scanner has already seen /// prints all known responses that the scanner has already seen
@@ -290,18 +507,23 @@ impl FeroxScans {
/// ///
/// When the value stored in `PAUSE_SCAN` becomes `false`, the function returns, exiting the busy /// When the value stored in `PAUSE_SCAN` becomes `false`, the function returns, exiting the busy
/// loop /// loop
pub async fn pause(&self, get_user_input: bool) { pub async fn pause(
&self,
get_user_input: bool,
handles: Arc<Handles>,
) -> Option<MenuCmdResult> {
// function uses tokio::time, not std // function uses tokio::time, not std
// local testing showed a pretty slow increase (less than linear) in CPU usage as # of // local testing showed a pretty slow increase (less than linear) in CPU usage as # of
// concurrent scans rose when SLEEP_DURATION was set to 500, using that as the default for now // concurrent scans rose when SLEEP_DURATION was set to 500, using that as the default for now
let mut interval = time::interval(time::Duration::from_millis(SLEEP_DURATION)); let mut interval = time::interval(time::Duration::from_millis(SLEEP_DURATION));
let mut command_result = None;
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 0 { if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 0 {
INTERACTIVE_BARRIER.fetch_add(1, Ordering::Relaxed); INTERACTIVE_BARRIER.fetch_add(1, Ordering::Relaxed);
if get_user_input { if get_user_input {
self.interactive_menu().await; command_result = self.interactive_menu(handles).await;
PAUSE_SCAN.store(false, Ordering::Relaxed); PAUSE_SCAN.store(false, Ordering::Relaxed);
self.print_known_responses(); self.print_known_responses();
} }
@@ -318,8 +540,8 @@ impl FeroxScans {
INTERACTIVE_BARRIER.fetch_sub(1, Ordering::Relaxed); INTERACTIVE_BARRIER.fetch_sub(1, Ordering::Relaxed);
} }
log::trace!("exit: pause_scan"); log::trace!("exit: pause_scan -> {:?}", command_result);
return; return command_result;
} }
} }
} }
@@ -356,7 +578,7 @@ impl FeroxScans {
OutputLevel::Silent => BarType::Hidden, OutputLevel::Silent => BarType::Hidden,
}; };
let progress_bar = add_bar(&url, bar_length, bar_type); let progress_bar = add_bar(url, bar_length, bar_type);
progress_bar.reset_elapsed(); progress_bar.reset_elapsed();
@@ -366,7 +588,7 @@ impl FeroxScans {
}; };
let ferox_scan = FeroxScan::new( let ferox_scan = FeroxScan::new(
&url, url,
scan_type, scan_type,
scan_order, scan_order,
bar_length, bar_length,
@@ -387,7 +609,8 @@ impl FeroxScans {
/// ///
/// Also return a reference to the new `FeroxScan` /// Also return a reference to the new `FeroxScan`
pub fn add_directory_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) { pub fn add_directory_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
self.add_scan(&url, ScanType::Directory, scan_order) let normalized = format!("{}/", url.trim_end_matches('/'));
self.add_scan(&normalized, ScanType::Directory, scan_order)
} }
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a File Scan /// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a File Scan
@@ -396,7 +619,7 @@ impl FeroxScans {
/// ///
/// Also return a reference to the new `FeroxScan` /// Also return a reference to the new `FeroxScan`
pub fn add_file_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) { pub fn add_file_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
self.add_scan(&url, ScanType::File, scan_order) self.add_scan(url, ScanType::File, scan_order)
} }
/// small helper to determine whether any scans are active or not /// small helper to determine whether any scans are active or not
@@ -425,4 +648,67 @@ impl FeroxScans {
} }
scans scans
} }
/// given an extension, add it to `collected_extensions` if all constraints are met
/// returns `true` if an extension was added, `false` otherwise
pub fn add_discovered_extension(&self, extension: String) -> bool {
log::trace!("enter: add_discovered_extension({})", extension);
let mut extension_added = false;
// note: the filter by --dont-collect happens in the event handler, since it has access
// to a Handles object form which it can check the config value. additionally, the check
// against --extensions is performed there for the same reason
if let Ok(extensions) = self.collected_extensions.read() {
// quicker to allow most to read and return and then reopen for write if necessary
if extensions.contains(&extension) {
return extension_added;
}
}
if let Ok(mut extensions) = self.collected_extensions.write() {
log::info!("discovered new extension: {}", extension);
extensions.insert(extension);
extension_added = true;
}
log::trace!("exit: add_discovered_extension -> {}", extension_added);
extension_added
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// unknown extension should be added to collected_extensions
fn unknown_extension_is_added_to_collected_extensions() {
let scans = FeroxScans::new(OutputLevel::Default);
assert_eq!(0, scans.collected_extensions.read().unwrap().len());
let added = scans.add_discovered_extension(String::from("js"));
assert!(added);
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
}
#[test]
/// known extension should not be added to collected_extensions
fn known_extension_is_added_to_collected_extensions() {
let scans = FeroxScans::new(OutputLevel::Default);
scans
.collected_extensions
.write()
.unwrap()
.insert(String::from("js"));
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
let added = scans.add_discovered_extension(String::from("js"));
assert!(!added);
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
}
} }

View File

@@ -1,7 +1,9 @@
use super::*; use super::*;
use crate::filters::FeroxFilters;
use crate::{config::Configuration, statistics::Stats, traits::FeroxSerialize, utils::fmt_err}; use crate::{config::Configuration, statistics::Stats, traits::FeroxSerialize, utils::fmt_err};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::Serialize; use serde::Serialize;
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
/// Data container for (de)?serialization of multiple items /// Data container for (de)?serialization of multiple items
@@ -18,6 +20,12 @@ pub struct FeroxState {
/// Gathered statistics /// Gathered statistics
statistics: Arc<Stats>, statistics: Arc<Stats>,
/// collected extensions
collected_extensions: HashSet<String>,
/// runtime filters, as they may differ from original config
filters: Arc<FeroxFilters>,
} }
/// implementation of FeroxState /// implementation of FeroxState
@@ -28,12 +36,20 @@ impl FeroxState {
config: Arc<Configuration>, config: Arc<Configuration>,
responses: &'static FeroxResponses, responses: &'static FeroxResponses,
statistics: Arc<Stats>, statistics: Arc<Stats>,
filters: Arc<FeroxFilters>,
) -> Self { ) -> Self {
let collected_extensions = match scans.collected_extensions.read() {
Ok(extensions) => extensions.clone(),
Err(_) => HashSet::new(),
};
Self { Self {
scans, scans,
config, config,
responses, responses,
statistics, statistics,
collected_extensions,
filters,
} }
} }
} }
@@ -42,12 +58,12 @@ impl FeroxState {
impl FeroxSerialize for FeroxState { impl FeroxSerialize for FeroxState {
/// Simply return debug format of FeroxState to satisfy as_str /// Simply return debug format of FeroxState to satisfy as_str
fn as_str(&self) -> String { fn as_str(&self) -> String {
format!("{:?}", self) format!("{self:?}")
} }
/// Simple call to produce a JSON string using the given FeroxState /// Simple call to produce a JSON string using the given FeroxState
fn as_json(&self) -> Result<String> { fn as_json(&self) -> Result<String> {
Ok(serde_json::to_string(&self) serde_json::to_string(&self)
.with_context(|| fmt_err("Could not convert scan's running state to JSON"))?) .with_context(|| fmt_err("Could not convert scan's running state to JSON"))
} }
} }

View File

@@ -1,4 +1,8 @@
use super::*; use super::*;
use crate::filters::{
FeroxFilters, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
WordsFilter,
};
use crate::{ use crate::{
config::{Configuration, OutputLevel}, config::{Configuration, OutputLevel},
event_handlers::Handles, event_handlers::Handles,
@@ -10,8 +14,10 @@ use crate::{
}; };
use indicatif::ProgressBar; use indicatif::ProgressBar;
use predicates::prelude::*; use predicates::prelude::*;
use regex::Regex;
use std::sync::{atomic::Ordering, Arc}; use std::sync::{atomic::Ordering, Arc};
use std::thread::sleep; use std::thread::sleep;
use std::time::Instant;
use tokio::time::{self, Duration}; use tokio::time::{self, Duration};
#[test] #[test]
@@ -30,6 +36,7 @@ fn default_scantype_is_file() {
async fn scanner_pause_scan_with_finished_spinner() { async fn scanner_pause_scan_with_finished_spinner() {
let now = time::Instant::now(); let now = time::Instant::now();
let urls = FeroxScans::default(); let urls = FeroxScans::default();
let handles = Arc::new(Handles::for_testing(None, None).0);
PAUSE_SCAN.store(true, Ordering::Relaxed); PAUSE_SCAN.store(true, Ordering::Relaxed);
@@ -40,7 +47,7 @@ async fn scanner_pause_scan_with_finished_spinner() {
PAUSE_SCAN.store(false, Ordering::Relaxed); PAUSE_SCAN.store(false, Ordering::Relaxed);
}); });
urls.pause(false).await; urls.pause(false, handles).await;
assert!(now.elapsed() > expected); assert!(now.elapsed() > expected);
} }
@@ -51,7 +58,7 @@ fn add_url_to_list_of_scanned_urls_with_unknown_url() {
let urls = FeroxScans::default(); let urls = FeroxScans::default();
let url = "http://unknown_url"; let url = "http://unknown_url";
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest); let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
assert_eq!(result, true); assert!(result);
} }
#[test] #[test]
@@ -70,11 +77,11 @@ fn add_url_to_list_of_scanned_urls_with_known_url() {
Some(pb), Some(pb),
); );
assert_eq!(urls.insert(scan), true); assert!(urls.insert(scan));
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest); let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
assert_eq!(result, false); assert!(!result);
} }
#[test] #[test]
@@ -92,27 +99,23 @@ fn stop_progress_bar_stops_bar() {
Some(pb), Some(pb),
); );
assert_eq!( assert!(!scan
scan.progress_bar .progress_bar
.lock() .lock()
.unwrap() .unwrap()
.as_ref() .as_ref()
.unwrap() .unwrap()
.is_finished(), .is_finished());
false
);
scan.stop_progress_bar(); scan.stop_progress_bar();
assert_eq!( assert!(scan
scan.progress_bar .progress_bar
.lock() .lock()
.unwrap() .unwrap()
.as_ref() .as_ref()
.unwrap() .unwrap()
.is_finished(), .is_finished());
true
);
} }
#[test] #[test]
@@ -130,11 +133,11 @@ fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
None, None,
); );
assert_eq!(urls.insert(scan), true); assert!(urls.insert(scan));
let (result, _scan) = urls.add_scan(url, ScanType::File, ScanOrder::Latest); let (result, _scan) = urls.add_scan(url, ScanType::File, ScanOrder::Latest);
assert_eq!(result, false); assert!(!result);
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -170,8 +173,8 @@ async fn call_display_scans() {
.await .await
.unwrap(); .unwrap();
assert_eq!(urls.insert(scan), true); assert!(urls.insert(scan));
assert_eq!(urls.insert(scan_two), true); assert!(urls.insert(scan_two));
urls.display_scans().await; urls.display_scans().await;
} }
@@ -221,7 +224,7 @@ fn ferox_scan_get_progress_bar_when_none_is_set() {
/// given a JSON entry representing a FeroxScan, test that it deserializes into the proper type /// given a JSON entry representing a FeroxScan, test that it deserializes into the proper type
/// with the right attributes /// with the right attributes
fn ferox_scan_deserialize() { fn ferox_scan_deserialize() {
let fs_json = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Directory","status":"Complete"}"#; let fs_json = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Directory","status":"Complete","requests_made_so_far":500}"#;
let fs_json_two = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"Cancelled"}"#; let fs_json_two = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"Cancelled"}"#;
let fs_json_three = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"","num_requests":42}"#; let fs_json_three = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"","num_requests":42}"#;
@@ -243,9 +246,13 @@ fn ferox_scan_deserialize() {
ScanType::File => {} ScanType::File => {}
} }
match *fs.progress_bar.lock().unwrap() { match fs.progress_bar.lock() {
None => {} Ok(guard) => {
Some(_) => { if guard.is_some() {
panic!();
}
}
Err(_) => {
panic!(); panic!();
} }
} }
@@ -274,7 +281,7 @@ fn ferox_scan_serialize() {
None, None,
); );
let fs_json = format!( let fs_json = format!(
r#"{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}"#, r#"{{"id":"{}","url":"https://spiritanimal.com","normalized_url":"https://spiritanimal.com/","scan_type":"Directory","status":"NotStarted","num_requests":0,"requests_made_so_far":0}}"#,
fs.id fs.id
); );
assert_eq!(fs_json, serde_json::to_string(&*fs).unwrap()); assert_eq!(fs_json, serde_json::to_string(&*fs).unwrap());
@@ -293,7 +300,7 @@ fn ferox_scans_serialize() {
); );
let ferox_scans = FeroxScans::default(); let ferox_scans = FeroxScans::default();
let ferox_scans_json = format!( let ferox_scans_json = format!(
r#"[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}]"#, r#"[{{"id":"{}","url":"https://spiritanimal.com","normalized_url":"https://spiritanimal.com/","scan_type":"Directory","status":"NotStarted","num_requests":0,"requests_made_so_far":0}}]"#,
ferox_scan.id ferox_scan.id
); );
ferox_scans.scans.write().unwrap().push(ferox_scan); ferox_scans.scans.write().unwrap().push(ferox_scan);
@@ -306,7 +313,7 @@ fn ferox_scans_serialize() {
#[test] #[test]
/// given a FeroxResponses, test that it serializes into the proper JSON entry /// given a FeroxResponses, test that it serializes into the proper JSON entry
fn ferox_responses_serialize() { fn ferox_responses_serialize() {
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#; let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap(); let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
let responses = FeroxResponses::default(); let responses = FeroxResponses::default();
@@ -314,7 +321,7 @@ fn ferox_responses_serialize() {
// responses has a response now // responses has a response now
// serialized should be a list of responses // serialized should be a list of responses
let expected = format!("[{}]", json_response); let expected = format!("[{json_response}]");
let serialized = serde_json::to_string(&responses).unwrap(); let serialized = serde_json::to_string(&responses).unwrap();
assert_eq!(expected, serialized); assert_eq!(expected, serialized);
@@ -324,12 +331,12 @@ fn ferox_responses_serialize() {
/// given a FeroxResponse, test that it serializes into the proper JSON entry /// given a FeroxResponse, test that it serializes into the proper JSON entry
fn ferox_response_serialize_and_deserialize() { fn ferox_response_serialize_and_deserialize() {
// deserialize // deserialize
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#; let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap(); let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
assert_eq!(response.url().as_str(), "https://nerdcore.com/css"); assert_eq!(response.url().as_str(), "https://nerdcore.com/css");
assert_eq!(response.url().path(), "/css"); assert_eq!(response.url().path(), "/css");
assert_eq!(response.wildcard(), true); assert!(response.wildcard());
assert_eq!(response.status().as_u16(), 301); assert_eq!(response.status().as_u16(), 301);
assert_eq!(response.content_length(), 173); assert_eq!(response.content_length(), 173);
assert_eq!(response.line_count(), 10); assert_eq!(response.line_count(), 10);
@@ -354,20 +361,59 @@ fn feroxstates_feroxserialize_implementation() {
); );
let ferox_scans = FeroxScans::default(); let ferox_scans = FeroxScans::default();
let saved_id = ferox_scan.id.clone(); let saved_id = ferox_scan.id.clone();
ferox_scans.insert(ferox_scan); ferox_scans.insert(ferox_scan);
let config = Configuration::new().unwrap(); ferox_scans
let stats = Arc::new(Stats::new(config.extensions.len(), config.json)); .collected_extensions
.write()
.unwrap()
.insert(String::from("php"));
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#; let mut config = Configuration::new().unwrap();
config.collect_extensions = true;
let stats = Arc::new(Stats::new(config.json));
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap(); let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
RESPONSES.insert(response); RESPONSES.insert(response);
let filters = FeroxFilters::default();
filters
.push(Box::new(StatusCodeFilter { filter_code: 100 }))
.unwrap();
filters
.push(Box::new(WordsFilter { word_count: 200 }))
.unwrap();
filters
.push(Box::new(SizeFilter {
content_length: 300,
}))
.unwrap();
filters
.push(Box::new(LinesFilter { line_count: 400 }))
.unwrap();
filters
.push(Box::new(RegexFilter {
raw_string: ".*".to_string(),
compiled: Regex::new(".*").unwrap(),
}))
.unwrap();
filters
.push(Box::new(SimilarityFilter {
hash: 1,
original_url: "http://localhost:12345/".to_string(),
}))
.unwrap();
let ferox_state = FeroxState::new( let ferox_state = FeroxState::new(
Arc::new(ferox_scans), Arc::new(ferox_scans),
Arc::new(Configuration::new().unwrap()), Arc::new(config),
&RESPONSES, &RESPONSES,
stats, stats,
Arc::new(filters),
); );
let expected_strs = predicates::str::contains("scans: FeroxScans").and( let expected_strs = predicates::str::contains("scans: FeroxScans").and(
@@ -375,18 +421,97 @@ fn feroxstates_feroxserialize_implementation() {
.and(predicate::str::contains("responses: FeroxResponses")) .and(predicate::str::contains("responses: FeroxResponses"))
.and(predicate::str::contains("nerdcore.com")) .and(predicate::str::contains("nerdcore.com"))
.and(predicate::str::contains("/css")) .and(predicate::str::contains("/css"))
.and(predicate::str::contains("https://spiritanimal.com")), .and(predicate::str::contains("https://spiritanimal.com"))
.and(predicate::str::contains("php")),
); );
assert!(expected_strs.eval(&ferox_state.as_str())); assert!(expected_strs.eval(&ferox_state.as_str()));
let json_state = ferox_state.as_json().unwrap(); let json_state = ferox_state.as_json().unwrap();
let expected = format!(
r#"{{"scans":[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}],"config":{{"type":"configuration","wordlist":"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt","config":"","proxy":"","replay_proxy":"","target_url":"","status_codes":[200,204,301,302,307,308,401,403,405],"replay_codes":[200,204,301,302,307,308,401,403,405],"filter_status":[],"threads":50,"timeout":7,"verbosity":0,"silent":false,"quiet":false,"json":false,"output":"","debug_log":"","user_agent":"feroxbuster/{}","redirects":false,"insecure":false,"extensions":[],"headers":{{}},"queries":[],"no_recursion":false,"extract_links":false,"add_slash":false,"stdin":false,"depth":4,"scan_limit":0,"rate_limit":0,"filter_size":[],"filter_line_count":[],"filter_word_count":[],"filter_regex":[],"dont_filter":false,"resumed":false,"resume_from":"","save_state":false,"time_limit":"","filter_similar":[]}},"responses":[{{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{{"server":"nginx/1.16.1"}}}}]"#, println!("echo '{json_state}'|jq"); // for debugging, if the test fails, can see what's going on
saved_id, VERSION
); for expected in [
println!("{}\n{}", expected, json_state); r#""scans""#,
assert!(predicates::str::contains(expected).eval(&json_state)); &format!(r#""id":"{saved_id}""#),
r#""url":"https://spiritanimal.com""#,
r#""scan_type":"Directory""#,
r#""status":"NotStarted""#,
r#""num_requests":0"#,
r#""config""#,
r#""type":"configuration""#,
r#""wordlist":"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt""#,
r#""config""#,
r#""proxy":"""#,
r#""replay_proxy":"""#,
r#""target_url":"""#,
r#""status_codes":[100,101,102,200,201,202,203,204,205,206,207,208,226,300,301,302,303,304,305,307,308,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,421,422,423,424,426,428,429,431,451,500,501,502,503,504,505,506,507,508,510,511,103,425]"#,
r#""replay_codes":[100,101,102,200,201,202,203,204,205,206,207,208,226,300,301,302,303,304,305,307,308,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,421,422,423,424,426,428,429,431,451,500,501,502,503,504,505,506,507,508,510,511,103,425]"#,
r#""filter_status":[]"#,
r#""threads":50"#,
r#""timeout":7"#,
r#""verbosity":0"#,
r#""silent":false"#,
r#""quiet":false"#,
r#""auto_bail":false"#,
r#""auto_tune":false"#,
r#""force_recursion":false"#,
r#""json":false"#,
r#""output":"""#,
r#""debug_log":"""#,
&format!(r#""user_agent":"feroxbuster/{VERSION}""#),
r#""random_agent":false"#,
r#""redirects":false"#,
r#""insecure":false"#,
r#""extensions":[]"#,
r#""methods":["GET"],"#,
r#""data":[]"#,
r#""headers""#,
r#""queries":[]"#,
r#""no_recursion":false"#,
r#""extract_links":false"#,
r#""add_slash":false"#,
r#""stdin":false"#,
r#""depth":4"#,
r#""scan_limit":0"#,
r#""parallel":0"#,
r#""rate_limit":0"#,
r#""filter_size":[]"#,
r#""filter_line_count":[]"#,
r#""filter_word_count":[]"#,
r#""filter_regex":[]"#,
r#""dont_filter":false"#,
r#""resumed":false"#,
r#""resume_from":"""#,
r#""save_state":false"#,
r#""time_limit":"""#,
r#""filter_similar":[]"#,
r#""url_denylist":[]"#,
r#""responses""#,
r#""type":"response""#,
r#""url":"https://nerdcore.com/css""#,
r#""path":"/css""#,
r#""wildcard":true"#,
r#""status":301"#,
r#""method":"GET""#,
r#""content_length":173"#,
r#""line_count":10"#,
r#""word_count":16"#,
r#""headers""#,
r#""server":"nginx/1.16.1"#,
r#""collect_extensions":true"#,
r#""collect_backups":false"#,
r#""collect_words":false"#,
r#""filters":[{"filter_code":100},{"word_count":200},{"content_length":300},{"line_count":400},{"compiled":".*","raw_string":".*"},{"hash":1,"original_url":"http://localhost:12345/"}]"#,
r#""collected_extensions":["php"]"#,
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()
{
assert!(
predicates::str::contains(*expected).eval(&json_state)
);
}
} }
#[should_panic] #[should_panic]
@@ -434,35 +559,41 @@ fn feroxscan_display() {
let scan = FeroxScan { let scan = FeroxScan {
id: "".to_string(), id: "".to_string(),
url: String::from("http://localhost"), url: String::from("http://localhost"),
normalized_url: String::from("http://localhost/"),
scan_order: ScanOrder::Latest, scan_order: ScanOrder::Latest,
scan_type: Default::default(), scan_type: Default::default(),
num_requests: 0, num_requests: 0,
requests_made_so_far: 0,
start_time: Instant::now(),
output_level: OutputLevel::Default, output_level: OutputLevel::Default,
status_403s: Default::default(),
status_429s: Default::default(),
status: Default::default(), status: Default::default(),
task: tokio::sync::Mutex::new(None), task: tokio::sync::Mutex::new(None),
progress_bar: std::sync::Mutex::new(None), progress_bar: std::sync::Mutex::new(None),
errors: Default::default(),
}; };
let not_started = format!("{}", scan); let not_started = format!("{scan}");
assert!(predicate::str::contains("not started") assert!(predicate::str::contains("not started")
.and(predicate::str::contains("localhost")) .and(predicate::str::contains("localhost"))
.eval(&not_started)); .eval(&not_started));
scan.set_status(ScanStatus::Complete).unwrap(); scan.set_status(ScanStatus::Complete).unwrap();
let complete = format!("{}", scan); let complete = format!("{scan}");
assert!(predicate::str::contains("complete") assert!(predicate::str::contains("complete")
.and(predicate::str::contains("localhost")) .and(predicate::str::contains("localhost"))
.eval(&complete)); .eval(&complete));
scan.set_status(ScanStatus::Cancelled).unwrap(); scan.set_status(ScanStatus::Cancelled).unwrap();
let cancelled = format!("{}", scan); let cancelled = format!("{scan}");
assert!(predicate::str::contains("cancelled") assert!(predicate::str::contains("cancelled")
.and(predicate::str::contains("localhost")) .and(predicate::str::contains("localhost"))
.eval(&cancelled)); .eval(&cancelled));
scan.set_status(ScanStatus::Running).unwrap(); scan.set_status(ScanStatus::Running).unwrap();
let running = format!("{}", scan); let running = format!("{scan}");
assert!(predicate::str::contains("running") assert!(predicate::str::contains("running")
.and(predicate::str::contains("localhost")) .and(predicate::str::contains("localhost"))
.eval(&running)); .eval(&running));
@@ -474,15 +605,21 @@ async fn ferox_scan_abort() {
let scan = FeroxScan { let scan = FeroxScan {
id: "".to_string(), id: "".to_string(),
url: String::from("http://localhost"), url: String::from("http://localhost"),
normalized_url: String::from("http://localhost/"),
scan_order: ScanOrder::Latest, scan_order: ScanOrder::Latest,
scan_type: Default::default(), scan_type: Default::default(),
num_requests: 0, num_requests: 0,
requests_made_so_far: 0,
start_time: Instant::now(),
output_level: OutputLevel::Default, output_level: OutputLevel::Default,
status_403s: Default::default(),
status_429s: Default::default(),
status: std::sync::Mutex::new(ScanStatus::Running), status: std::sync::Mutex::new(ScanStatus::Running),
task: tokio::sync::Mutex::new(Some(tokio::spawn(async move { task: tokio::sync::Mutex::new(Some(tokio::spawn(async move {
sleep(Duration::from_millis(SLEEP_DURATION * 2)); sleep(Duration::from_millis(SLEEP_DURATION * 2));
}))), }))),
progress_bar: std::sync::Mutex::new(None), progress_bar: std::sync::Mutex::new(None),
errors: Default::default(),
}; };
scan.abort().await.unwrap(); scan.abort().await.unwrap();
@@ -500,6 +637,11 @@ async fn ferox_scan_abort() {
/// and their correctness can be verified easily manually; just calling for now /// and their correctness can be verified easily manually; just calling for now
fn menu_print_header_and_footer() { fn menu_print_header_and_footer() {
let menu = Menu::new(); let menu = Menu::new();
let menu_cmd_1 = MenuCmd::AddUrl(String::from("http://localhost"));
let menu_cmd_2 = MenuCmd::Cancel(vec![0], false);
let menu_cmd_res_1 = MenuCmdResult::Url(String::from("http://localhost"));
let menu_cmd_res_2 = MenuCmdResult::NumCancelled(2);
println!("{menu_cmd_1:?}{menu_cmd_2:?}{menu_cmd_res_1:?}{menu_cmd_res_2:?}");
menu.clear_screen(); menu.clear_screen();
menu.print_header(); menu.print_header();
menu.print_footer(); menu.print_footer();
@@ -507,12 +649,134 @@ fn menu_print_header_and_footer() {
menu.show_progress_bars(); menu.show_progress_bars();
} }
/// ensure command parsing from user input results int he correct MenuCmd returned
#[test]
fn menu_get_command_input_from_user_returns_cancel() {
let menu = Menu::new();
for (idx, cmd) in ["cancel", "Cancel", "c", "C"].iter().enumerate() {
let force = idx % 2 == 0;
let full_cmd = if force {
format!("{cmd} -f {idx}\n")
} else {
format!("{cmd} {idx}\n")
};
let result = menu.get_command_input_from_user(&full_cmd).unwrap();
assert!(matches!(result, MenuCmd::Cancel(_, _)));
if let MenuCmd::Cancel(canx_list, ret_force) = result {
if idx == 0 {
assert!(canx_list.is_empty());
} else {
assert_eq!(canx_list, vec![idx]);
}
assert_eq!(force, ret_force);
}
}
}
/// ensure command parsing from user input results int he correct MenuCmd returned
#[test]
fn menu_get_command_input_from_user_returns_add() {
let menu = Menu::new();
for cmd in ["add", "Addd", "a", "A", "None"] {
let test_url = "http://happyfuntimes.commmm";
let full_cmd = format!("{cmd} {test_url}\n");
if cmd != "None" {
let result = menu.get_command_input_from_user(&full_cmd).unwrap();
assert!(matches!(result, MenuCmd::AddUrl(_)));
if let MenuCmd::AddUrl(url) = result {
assert_eq!(url, test_url);
}
} else {
assert!(menu.get_command_input_from_user(&full_cmd).is_none());
};
}
}
#[test] #[test]
/// ensure spaces are trimmed and numbers are returned from split_to_nums /// ensure spaces are trimmed and numbers are returned from split_to_nums
fn split_to_nums_is_correct() { fn split_to_nums_is_correct() {
let menu = Menu::new(); let menu = Menu::new();
let nums = menu.split_to_nums("1, 3, 4"); let nums = menu.split_to_nums("1, 3, 4, 7 - 12, 10-10, 10-11, 9-12, 12-6, -1, 4-");
assert_eq!(nums, vec![1, 3, 4]); assert_eq!(nums, vec![1, 3, 4, 7, 8, 9, 10, 11, 12]);
assert_eq!(menu.split_to_nums("9-12"), vec![9, 10, 11, 12]);
assert!(menu.split_to_nums("-12").is_empty());
assert!(menu.split_to_nums("12-").is_empty());
assert!(menu.split_to_nums("\n").is_empty());
}
#[test]
/// given a deep url, find the correct scan
fn get_base_scan_by_url_finds_correct_scan() {
let urls = FeroxScans::default();
let url = "http://localhost";
let url1 = "http://localhost/stuff";
let url2 = "http://shlocalhost/stuff/things";
let url3 = "http://shlocalhost/stuff/things/mostuff";
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
let (_, scan1) = urls.add_scan(url1, ScanType::Directory, ScanOrder::Latest);
let (_, scan2) = urls.add_scan(url2, ScanType::Directory, ScanOrder::Latest);
let (_, scan3) = urls.add_scan(url3, ScanType::Directory, ScanOrder::Latest);
assert_eq!(
urls.get_base_scan_by_url("http://localhost/things.php")
.unwrap()
.id,
scan.id
);
assert_eq!(
urls.get_base_scan_by_url("http://localhost/stuff/things.php")
.unwrap()
.id,
scan1.id
);
assert_eq!(
urls.get_base_scan_by_url("http://shlocalhost/stuff/things/mostuff.php")
.unwrap()
.id,
scan2.id
);
assert_eq!(
urls.get_base_scan_by_url("http://shlocalhost/stuff/things/mostuff/mothings.php")
.unwrap()
.id,
scan3.id
);
}
#[test]
/// given a shallow url without a trailing slash, find the correct scan
fn get_base_scan_by_url_finds_correct_scan_without_trailing_slash() {
let urls = FeroxScans::default();
let url = "http://localhost";
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
assert_eq!(
urls.get_base_scan_by_url("http://localhost/BKPMiherrortBPKcw")
.unwrap()
.id,
scan.id
);
}
#[test]
/// given a shallow url with a trailing slash, find the correct scan
fn get_base_scan_by_url_finds_correct_scan_with_trailing_slash() {
let urls = FeroxScans::default();
let url = "http://127.0.0.1:41971/";
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
assert_eq!(
urls.get_base_scan_by_url("http://127.0.0.1:41971/BKPMiherrortBPKcw")
.unwrap()
.id,
scan.id
);
} }

View File

@@ -41,7 +41,7 @@ pub async fn start_max_time_thread(handles: Arc<Handles>) {
log::trace!("exit: start_max_time_thread"); log::trace!("exit: start_max_time_thread");
#[cfg(test)] #[cfg(test)]
panic!(handles); panic!("{handles:?}");
#[cfg(not(test))] #[cfg(not(test))]
let _ = TermInputHandler::sigint_handler(handles.clone()); let _ = TermInputHandler::sigint_handler(handles.clone());
} }

View File

@@ -1,352 +0,0 @@
use std::{
cmp::max, collections::HashSet, convert::TryInto, ops::Deref, sync::atomic::Ordering,
sync::Arc, time::Instant,
};
use anyhow::{bail, Result};
use futures::{stream, StreamExt};
use lazy_static::lazy_static;
use leaky_bucket::LeakyBucket;
use tokio::sync::{oneshot, Semaphore};
use crate::{
event_handlers::{
Command::{self, AddError, UpdateF64Field, UpdateUsizeField},
Handles,
},
extractor::{
ExtractionTarget::{ResponseBody, RobotsTxt},
ExtractorBuilder,
},
heuristics,
response::FeroxResponse,
scan_manager::{FeroxResponses, ScanOrder, ScanStatus, PAUSE_SCAN},
statistics::{
StatError::Other,
StatField::{DirScanTimes, ExpectedPerScan},
},
url::FeroxUrl,
utils::{fmt_err, make_request},
};
use tokio::time::Duration;
lazy_static! {
/// Vector of FeroxResponse objects
pub static ref RESPONSES: FeroxResponses = FeroxResponses::default();
// todo consider removing this
}
/// Makes multiple requests based on the presence of extensions
struct Requester {
/// handles to handlers and config
handles: Arc<Handles>,
/// url that will be scanned
target_url: String,
/// limits requests per second if present
rate_limiter: Option<LeakyBucket>,
}
/// Requester implementation
impl Requester {
/// given a FeroxScanner, create a Requester
pub fn from(scanner: &FeroxScanner) -> Result<Self> {
let limit = scanner.handles.config.rate_limit;
let refill = max(limit / 10, 1); // minimum of 1 per second
let tokens = max(limit / 2, 1);
let interval = if refill == 1 { 1000 } else { 100 }; // 1 second if refill is 1
let rate_limiter = if limit > 0 {
let bucket = LeakyBucket::builder()
.refill_interval(Duration::from_millis(interval)) // add tokens every 0.1s
.refill_amount(refill) // ex: 100 req/s -> 10 tokens per 0.1s
.tokens(tokens) // reduce initial burst, 2 is arbitrary, but felt good
.max(limit)
.build()?;
Some(bucket)
} else {
None
};
Ok(Self {
rate_limiter,
handles: scanner.handles.clone(),
target_url: scanner.target_url.to_owned(),
})
}
/// limit the number of requests per second
pub async fn limit(&self) -> Result<()> {
self.rate_limiter.as_ref().unwrap().acquire_one().await?;
Ok(())
}
/// Wrapper for [make_request](fn.make_request.html)
///
/// Attempts recursion when appropriate and sends Responses to the output handler for processing
async fn request(&self, word: &str) -> Result<()> {
log::trace!("enter: request({})", word);
let urls =
FeroxUrl::from_string(&self.target_url, self.handles.clone()).formatted_urls(word)?;
for url in urls {
if self.rate_limiter.is_some() {
// found a rate limiter, limit that junk!
if let Err(e) = self.limit().await {
log::warn!("Could not rate limit scan: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
}
}
let response = make_request(
&self.handles.config.client,
&url,
self.handles.config.output_level,
self.handles.stats.tx.clone(),
)
.await?;
// response came back without error, convert it to FeroxResponse
let ferox_response =
FeroxResponse::from(response, true, self.handles.config.output_level).await;
// do recursion if appropriate
if !self.handles.config.no_recursion {
self.handles
.send_scan_command(Command::TryRecursion(Box::new(ferox_response.clone())))?;
let (tx, rx) = oneshot::channel::<bool>();
self.handles.send_scan_command(Command::Sync(tx))?;
rx.await?;
}
// purposefully doing recursion before filtering. the thought process is that
// even though this particular url is filtered, subsequent urls may not
if self
.handles
.filters
.data
.should_filter_response(&ferox_response, self.handles.stats.tx.clone())
{
continue;
}
if self.handles.config.extract_links && !ferox_response.status().is_redirection() {
let extractor = ExtractorBuilder::default()
.target(ResponseBody)
.response(&ferox_response)
.handles(self.handles.clone())
.build()?;
extractor.extract().await?;
}
// everything else should be reported
if let Err(e) = ferox_response.send_report(self.handles.output.tx.clone()) {
log::warn!("Could not send FeroxResponse to output handler: {}", e);
}
}
log::trace!("exit: request");
Ok(())
}
}
/// handles the main muscle movement of scanning a url
pub struct FeroxScanner {
/// handles to handlers and config
handles: Arc<Handles>,
/// url that will be scanned
target_url: String,
/// whether or not this scanner is targeting an initial target specified by the user or one
/// found via recursion
order: ScanOrder,
/// wordlist that's already been read from disk
wordlist: Arc<HashSet<String>>,
/// limiter that restricts the number of active FeroxScanners
scan_limiter: Arc<Semaphore>,
}
/// FeroxScanner implementation
impl FeroxScanner {
/// create a new FeroxScanner
pub fn new(
target_url: &str,
order: ScanOrder,
wordlist: Arc<HashSet<String>>,
scan_limiter: Arc<Semaphore>,
handles: Arc<Handles>,
) -> Self {
Self {
order,
handles,
wordlist,
scan_limiter,
target_url: target_url.to_string(),
}
}
/// Scan a given url using a given wordlist
///
/// This is the primary entrypoint for the scanner
pub async fn scan_url(&self) -> Result<()> {
log::trace!("enter: scan_url");
log::info!("Starting scan against: {}", self.target_url);
let scan_timer = Instant::now();
if matches!(self.order, ScanOrder::Initial) && self.handles.config.extract_links {
// only grab robots.txt on the initial scan_url calls. all fresh dirs will be passed
// to try_recursion
let extractor = ExtractorBuilder::default()
.url(&self.target_url)
.handles(self.handles.clone())
.target(RobotsTxt)
.build()?;
let _ = extractor.extract().await;
}
let scanned_urls = self.handles.ferox_scans()?;
let ferox_scan = match scanned_urls.get_scan_by_url(&self.target_url) {
Some(scan) => {
scan.set_status(ScanStatus::Running)?;
scan
}
None => {
let msg = format!(
"Could not find FeroxScan associated with {}; this shouldn't happen... exiting",
self.target_url
);
bail!(fmt_err(&msg))
}
};
let progress_bar = ferox_scan.progress_bar();
// When acquire is called and the semaphore has remaining permits, the function immediately
// returns a permit. However, if no remaining permits are available, acquire (asynchronously)
// waits until an outstanding permit is dropped, at which point, the freed permit is assigned
// to the caller.
let _permit = self.scan_limiter.acquire().await;
// Arc clones to be passed around to the various scans
let looping_words = self.wordlist.clone();
{
let test = heuristics::HeuristicTests::new(self.handles.clone());
if let Ok(num_reqs) = test.wildcard(&self.target_url).await {
progress_bar.inc(num_reqs);
}
}
let requester = Arc::new(Requester::from(self)?);
let increment_len = (self.handles.config.extensions.len() + 1) as u64;
// producer tasks (mp of mpsc); responsible for making requests
let producers = stream::iter(looping_words.deref().to_owned())
.map(|word| {
let pb = progress_bar.clone(); // progress bar is an Arc around internal state
let scanned_urls_clone = scanned_urls.clone();
let requester_clone = requester.clone();
(
tokio::spawn(async move {
if PAUSE_SCAN.load(Ordering::Acquire) {
// for every word in the wordlist, check to see if PAUSE_SCAN is set to true
// when true; enter a busy loop that only exits by setting PAUSE_SCAN back
// to false
scanned_urls_clone.pause(true).await;
}
requester_clone.request(&word).await
}),
pb,
)
})
.for_each_concurrent(self.handles.config.threads, |(resp, bar)| async move {
match resp.await {
Ok(_) => {
bar.inc(increment_len);
}
Err(e) => {
log::warn!("error awaiting a response: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
}
}
});
// await tx tasks
log::trace!("awaiting scan producers");
producers.await;
log::trace!("done awaiting scan producers");
self.handles.stats.send(UpdateF64Field(
DirScanTimes,
scan_timer.elapsed().as_secs_f64(),
))?;
ferox_scan.finish()?;
log::trace!("exit: scan_url");
Ok(())
}
}
/// Perform steps necessary to run scans that only need to be performed once (warming up the
/// engine, as it were)
pub async fn initialize(num_words: usize, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: initialize({}, {:?})", num_words, handles);
// number of requests only needs to be calculated once, and then can be reused
let num_reqs_expected: u64 = if handles.config.extensions.is_empty() {
num_words.try_into()?
} else {
let total = num_words * (handles.config.extensions.len() + 1);
total.try_into()?
};
{
// no real reason to keep the arc around beyond this call
let scans = handles.ferox_scans()?;
scans.set_bar_length(num_reqs_expected);
}
// tell Stats object about the number of expected requests
handles.stats.send(UpdateUsizeField(
ExpectedPerScan,
num_reqs_expected as usize,
))?;
log::trace!("exit: initialize");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::OutputLevel;
use crate::scan_manager::FeroxScans;
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[should_panic]
/// try to hit struct field coverage of FileOutHandler
async fn get_scan_by_url_bails_on_unfound_url() {
let sem = Semaphore::new(10);
let urls = FeroxScans::new(OutputLevel::Default);
let scanner = FeroxScanner::new(
"http://localhost",
ScanOrder::Initial,
Arc::new(Default::default()),
Arc::new(sem),
Arc::new(Handles::for_testing(Some(Arc::new(urls)), None).0),
);
scanner.scan_url().await.unwrap();
}
}

View File

@@ -0,0 +1,377 @@
use std::fmt::Write as _;
use std::sync::atomic::AtomicBool;
use std::{ops::Deref, sync::atomic::Ordering, sync::Arc, time::Instant};
use anyhow::{bail, Result};
use console::style;
use futures::{stream, StreamExt};
use indicatif::ProgressBar;
use lazy_static::lazy_static;
use tokio::sync::Semaphore;
use crate::filters::{create_similarity_filter, EmptyFilter, SimilarityFilter};
use crate::heuristics::WildcardResult;
use crate::Command::AddFilter;
use crate::{
event_handlers::{
Command::{AddError, AddToF64Field, AddToUsizeField, SubtractFromUsizeField},
Handles,
},
extractor::{ExtractionTarget, ExtractorBuilder},
heuristics,
scan_manager::{FeroxResponses, FeroxScans, MenuCmdResult, ScanOrder, ScanStatus, PAUSE_SCAN},
scanner::requester::TF_IDF,
statistics::{
StatError::Other,
StatField::{DirScanTimes, TotalExpected},
},
utils::fmt_err,
Command,
};
use super::requester::Requester;
lazy_static! {
/// Vector of FeroxResponse objects
pub static ref RESPONSES: FeroxResponses = FeroxResponses::default();
// todo consider removing this
}
/// check to see if `pause_flag` is set to true. when true; enter a busy loop that only exits
/// by setting PAUSE_SCAN back to false
async fn check_for_user_input(
pause_flag: &AtomicBool,
scanned_urls: Arc<FeroxScans>,
handles: Arc<Handles>,
) {
log::trace!(
"enter: check_for_user_input({:?}, SCANNED_URLS, HANDLES)",
pause_flag
);
// todo write a test or two for this function at some point...
if pause_flag.load(Ordering::Acquire) {
match scanned_urls.pause(true, handles.clone()).await {
Some(MenuCmdResult::Url(url)) => {
// user wants to add a new url to be scanned, need to send
// it over to the event handler for processing
handles
.send_scan_command(Command::ScanNewUrl(url))
.unwrap_or_else(|e| log::warn!("Could not add scan to scan queue: {}", e))
}
Some(MenuCmdResult::NumCancelled(num_canx)) => {
if num_canx > 0 {
handles
.stats
.send(SubtractFromUsizeField(TotalExpected, num_canx))
.unwrap_or_else(|e| log::warn!("Could not update overall scan bar: {}", e));
}
}
Some(MenuCmdResult::Filter(mut filter)) => {
let url = if let Some(SimilarityFilter { original_url, .. }) =
filter.as_any().downcast_ref::<SimilarityFilter>()
{
original_url.to_owned()
} else {
String::new()
};
if !url.is_empty() {
// filter was a SimilarityFilter and now we have a url to request.
//
// The reason for this janky structure is that `filter.as_any().downcast_ref`
// isn't Send so we can't call create_similarity_filter(...).await, within
// the if let Some ipso-facto, janky code /shrug
let real_filter = create_similarity_filter(&url, handles.clone())
.await
.unwrap_or_default();
if real_filter.original_url.is_empty() {
// failed to create filter
filter = Box::new(EmptyFilter {});
} else {
filter = Box::new(real_filter)
}
}
handles
.filters
.send(AddFilter(filter))
.unwrap_or_else(|e| log::warn!("Could not add new filter: {}", e));
}
_ => {}
}
}
log::trace!("exit: check_for_user_input");
}
/// handles the main muscle movement of scanning a url
pub struct FeroxScanner {
/// handles to handlers and config
pub(super) handles: Arc<Handles>,
/// url that will be scanned
pub(super) target_url: String,
/// whether or not this scanner is targeting an initial target specified by the user or one
/// found via recursion
order: ScanOrder,
/// wordlist that's already been read from disk
wordlist: Arc<Vec<String>>,
/// limiter that restricts the number of active FeroxScanners
scan_limiter: Arc<Semaphore>,
}
/// FeroxScanner implementation
impl FeroxScanner {
/// create a new FeroxScanner
pub fn new(
target_url: &str,
order: ScanOrder,
wordlist: Arc<Vec<String>>,
scan_limiter: Arc<Semaphore>,
handles: Arc<Handles>,
) -> Self {
Self {
order,
handles,
wordlist,
scan_limiter,
target_url: target_url.to_string(),
}
}
/// produces and awaits tasks (mp of mpsc); responsible for making requests
async fn stream_requests(
&self,
looping_words: Arc<Vec<String>>,
progress_bar: ProgressBar,
scanned_urls: Arc<FeroxScans>,
requester: Arc<Requester>,
) {
log::trace!("enter: stream_requests(params too verbose to print)");
let producers = stream::iter(looping_words.deref().to_owned())
.map(|word| {
let pb = progress_bar.clone(); // progress bar is an Arc around internal state
let scanned_urls_clone = scanned_urls.clone();
let requester_clone = requester.clone();
let handles_clone = self.handles.clone();
(
tokio::spawn(async move {
// for every word in the wordlist, check to see if user has pressed enter
// in order to go into the interactive menu
check_for_user_input(&PAUSE_SCAN, scanned_urls_clone, handles_clone).await;
// after checking for user input, send the request
requester_clone
.request(&word)
.await
.unwrap_or_else(|e| log::warn!("Requester encountered an error: {}", e))
}),
pb,
)
})
.for_each_concurrent(self.handles.config.threads, |(resp, bar)| async move {
match resp.await {
Ok(_) => {
let increment_len = self.handles.expected_num_requests_multiplier() as u64;
bar.inc(increment_len);
}
Err(e) => {
log::warn!("error awaiting a response: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
std::process::exit(1);
}
}
});
// await tx tasks
log::trace!("awaiting scan producers");
producers.await;
log::trace!("done awaiting scan producers");
log::trace!("exit: stream_requests");
}
/// Scan a given url using a given wordlist
///
/// This is the primary entrypoint for the scanner
pub async fn scan_url(&self) -> Result<()> {
log::trace!("enter: scan_url");
log::info!("Starting scan against: {}", self.target_url);
let mut scan_timer = Instant::now();
if self.handles.config.extract_links && matches!(self.order, ScanOrder::Initial) {
// check for robots.txt (cannot be in sub-directories, so limited to Initial)
let mut extractor = ExtractorBuilder::default()
.target(ExtractionTarget::RobotsTxt)
.url(&self.target_url)
.handles(self.handles.clone())
.build()?;
let result = extractor.extract().await?;
extractor.request_links(result).await?;
}
let scanned_urls = self.handles.ferox_scans()?;
let ferox_scan = match scanned_urls.get_scan_by_url(&self.target_url) {
Some(scan) => {
scan.set_status(ScanStatus::Running)?;
scan
}
None => {
let msg = format!(
"Could not find FeroxScan associated with {}; this shouldn't happen... exiting",
self.target_url
);
bail!(fmt_err(&msg))
}
};
let progress_bar = ferox_scan.progress_bar();
// When acquire is called and the semaphore has remaining permits, the function immediately
// returns a permit. However, if no remaining permits are available, acquire (asynchronously)
// waits until an outstanding permit is dropped, at which point, the freed permit is assigned
// to the caller.
let _permit = self.scan_limiter.acquire().await;
if self.handles.config.scan_limit > 0 {
scan_timer = Instant::now();
progress_bar.reset();
}
{
// heuristics test block:
let test = heuristics::HeuristicTests::new(self.handles.clone());
if let Ok(dirlist_result) = test.directory_listing(&self.target_url).await {
if dirlist_result.is_some() {
let dirlist_result = dirlist_result.unwrap();
// at this point, we have a DirListingType, and it's not the None variant
// which means we found directory listing based on the heuristic; now we need
// to process the links that are available if --extract-links was used
if self.handles.config.extract_links {
let mut extractor = ExtractorBuilder::default()
.response(&dirlist_result.response)
.target(ExtractionTarget::DirectoryListing)
.url(&self.target_url)
.handles(self.handles.clone())
.build()?;
let result = extractor.extract_from_dir_listing().await?;
extractor.request_links(result).await?;
log::trace!("exit: scan_url -> Directory listing heuristic");
self.handles.stats.send(AddToF64Field(
DirScanTimes,
scan_timer.elapsed().as_secs_f64(),
))?;
self.handles.stats.send(SubtractFromUsizeField(
TotalExpected,
progress_bar.length() as usize,
))?;
}
let mut message = format!("=> {}", style("Directory listing").blue().bright());
if !self.handles.config.extract_links {
write!(message, " (add {} to scan)", style("-e").bright().yellow())?;
}
progress_bar.reset_eta();
progress_bar.finish_with_message(&message);
ferox_scan.finish()?;
return Ok(()); // nothing left to do if we found a dir listing
}
}
// now that we haven't found a directory listing, we'll attempt to derive whatever
// the server is using to respond to resources that don't exist (could be a
// traditional 404, or a custom response)
//
// `detect_404_like_responses` will make the requests that the wildcard test used to
// perform pre-2.8 in addition to new detection techniques, superseding the old
// wildcard test
let num_reqs_made = test.detect_404_like_responses(&self.target_url).await?;
match num_reqs_made {
Some(WildcardResult::WildcardDirectory(num_reqs)) => {
let message = format!(
"=> {} dir! {} recursion",
style("Wildcard").blue().bright(),
style("stopped").red()
);
progress_bar.set_message(&message);
progress_bar.inc(num_reqs as u64);
}
Some(WildcardResult::FourOhFourLike(num_reqs)) => {
progress_bar.inc(num_reqs as u64);
}
_ => {}
}
}
// Arc clones to be passed around to the various scans
let looping_words = self.wordlist.clone();
let requester = Arc::new(Requester::from(self, ferox_scan.clone())?);
self.stream_requests(
looping_words.clone(),
progress_bar.clone(),
scanned_urls.clone(),
requester.clone(),
)
.await;
if self.handles.config.collect_words {
let new_words = TF_IDF.read().unwrap().all_words();
let new_words_len = new_words.len();
let cur_length = progress_bar.length();
let new_length = cur_length + new_words_len as u64;
progress_bar.set_length(new_length);
self.handles
.stats
.send(AddToUsizeField(TotalExpected, new_words.len()))
.unwrap_or_default();
log::info!(
"requesting {} collected words: {:?}...",
new_words_len,
&new_words[..new_words_len.min(3)]
);
self.stream_requests(
Arc::new(new_words),
progress_bar.clone(),
scanned_urls.clone(),
requester.clone(),
)
.await;
}
self.handles.stats.send(AddToF64Field(
DirScanTimes,
scan_timer.elapsed().as_secs_f64(),
))?;
ferox_scan.finish()?;
log::trace!("exit: scan_url");
Ok(())
}
}

29
src/scanner/init.rs Normal file
View File

@@ -0,0 +1,29 @@
use crate::{
event_handlers::{Command::AddToUsizeField, Handles},
statistics::StatField::ExpectedPerScan,
};
use anyhow::Result;
use std::{convert::TryInto, sync::Arc};
/// Perform steps necessary to run scans that only need to be performed once (warming up the
/// engine, as it were)
pub async fn initialize(num_words: usize, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: initialize({}, {:?})", num_words, handles);
// number of requests only needs to be calculated once, and then can be reused
let num_reqs_expected: u64 = handles.expected_num_requests_per_dir().try_into()?;
{
// no real reason to keep the arc around beyond this call
let scans = handles.ferox_scans()?;
scans.set_bar_length(num_reqs_expected);
}
// tell Stats object about the number of expected requests
handles
.stats
.send(AddToUsizeField(ExpectedPerScan, num_reqs_expected as usize))?;
log::trace!("exit: initialize");
Ok(())
}

171
src/scanner/limit_heap.rs Normal file
View File

@@ -0,0 +1,171 @@
use std::fmt::{Debug, Formatter, Result};
/// bespoke variation on an array-backed max-heap
///
/// 255 possible values generated from the initial requests/second
///
/// when no additional errors are encountered, the left child is taken (increasing req/sec)
/// if errors have increased since the last interval, the right child is taken (decreasing req/sec)
///
/// formula for each child:
/// - left: (|parent - current|) / 2 + current
/// - right: current - ((|parent - current|) / 2)
pub(super) struct LimitHeap {
/// backing array, 255 nodes == height of 7 ( 2^(h+1) -1 nodes )
pub(super) inner: [i32; 255],
/// original # of requests / second
pub(super) original: i32,
/// current position w/in the backing array
pub(super) current: usize,
}
/// default implementation of a LimitHeap
impl Default for LimitHeap {
/// zero-initialize the backing array
fn default() -> Self {
Self {
inner: [0; 255],
original: 0,
current: 0,
}
}
}
/// Debug implementation of a LimitHeap
impl Debug for LimitHeap {
/// return debug representation that conforms to <32 elements in array
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
let msg = format!(
"LimitHeap {{ original: {}, current: {}, inner: [{}...] }}",
self.original, self.current, self.inner[0]
);
write!(f, "{msg}")
}
}
/// implementation of a LimitHeap
impl LimitHeap {
/// move to right child, return node's index from which the move was requested
pub(super) fn move_right(&mut self) -> usize {
if self.has_children() {
let tmp = self.current;
self.current = self.current * 2 + 2;
return tmp;
}
self.current
}
/// move to left child, return node's index from which the move was requested
pub(super) fn move_left(&mut self) -> usize {
if self.has_children() {
let tmp = self.current;
self.current = self.current * 2 + 1;
return tmp;
}
self.current
}
/// move to parent, return node's index from which the move was requested
pub(super) fn move_up(&mut self) -> usize {
if self.has_parent() {
let tmp = self.current;
self.current = (self.current - 1) / 2;
return tmp;
}
self.current
}
/// move directly to the given index
pub(super) fn move_to(&mut self, index: usize) {
self.current = index;
}
/// get the current node's value
pub(super) fn value(&self) -> i32 {
self.inner[self.current]
}
/// set the current node's value
pub(super) fn set_value(&mut self, value: i32) {
self.inner[self.current] = value;
}
/// check that this node has a parent (true for all except root)
pub(super) fn has_parent(&self) -> bool {
self.current > 0
}
/// get node's parent's value or self.original if at the root
pub(super) fn parent_value(&mut self) -> i32 {
if self.has_parent() {
let current = self.move_up();
let val = self.value();
self.move_to(current);
return val;
}
self.original
}
/// check if the current node has children
pub(super) fn has_children(&self) -> bool {
// inner structure is a complete tree, just check for the right child
self.current * 2 + 2 <= self.inner.len()
}
/// get current node's right child's value
fn right_child_value(&mut self) -> i32 {
let tmp = self.move_right();
let val = self.value();
self.move_to(tmp);
val
}
/// set current node's left child's value
fn set_left_child(&mut self) {
let parent = self.parent_value();
let current = self.value();
let value = ((parent - current).abs() / 2) + current;
self.move_left();
self.set_value(value);
self.move_up();
}
/// set current node's right child's value
fn set_right_child(&mut self) {
let parent = self.parent_value();
let current = self.value();
let value = current - ((parent - current).abs() / 2);
self.move_right();
self.set_value(value);
self.move_up();
}
/// 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
// arr[0] == 200
// arr[1] (left child) == 300
// arr[2] (right child) == 100
let root = self.original / 2;
self.inner[0] = root; // set root node to half of the original value
self.inner[1] = ((self.original - root).abs() / 2) + root;
self.inner[2] = root - ((self.original - root).abs() / 2);
// start with index 1 and fill in each child below that node
for i in 1..self.inner.len() {
self.move_to(i);
if self.has_children() && self.right_child_value() == 0 {
// this node has an unset child since the rchild is 0
self.set_left_child();
self.set_right_child();
}
}
self.move_to(0); // reset current index to the root of the tree
}
}

12
src/scanner/mod.rs Normal file
View File

@@ -0,0 +1,12 @@
mod ferox_scanner;
mod utils;
mod init;
#[cfg(test)]
mod tests;
mod limit_heap;
mod policy_data;
mod requester;
pub use self::ferox_scanner::{FeroxScanner, RESPONSES};
pub use self::init::initialize;
pub use self::utils::PolicyTrigger;

308
src/scanner/policy_data.rs Normal file
View File

@@ -0,0 +1,308 @@
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use crate::{atomic_load, atomic_store, config::RequesterPolicy};
use super::limit_heap::LimitHeap;
/// data regarding policy and metadata about last enforced trigger etc...
#[derive(Default, Debug)]
pub struct PolicyData {
/// how to handle exceptional cases such as too many errors / 403s / 429s etc
pub(super) policy: RequesterPolicy,
/// whether or not we're in the middle of a cooldown period
pub(super) cooling_down: AtomicBool,
/// length of time to pause tuning after making an adjustment
pub(super) wait_time: u64,
/// rate limit (at last interval)
limit: AtomicUsize,
/// number of errors (at last interval)
pub(super) errors: AtomicUsize,
/// whether or not the owning Requester should remove the rate_limiter, happens when a scan
/// has been limited and moves back up to the point of its original scan speed
pub(super) remove_limit: AtomicBool,
/// heap of values used for adjusting # of requests/second
pub(super) heap: std::sync::RwLock<LimitHeap>,
}
/// implementation of PolicyData
impl PolicyData {
/// given a RequesterPolicy, create a new PolicyData
pub fn new(policy: RequesterPolicy, timeout: u64) -> Self {
// can use this as a tweak for how aggressively adjustments should be made when tuning
let wait_time = ((timeout as f64 / 2.0) * 1000.0) as u64;
Self {
policy,
wait_time,
..Default::default()
}
}
/// 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();
self.set_limit(guard.inner[0] as usize); // set limit to 1/2 of current request rate
}
}
/// setter for errors
pub(super) fn set_errors(&self, errors: usize) {
atomic_store!(self.errors, errors);
}
/// setter for limit
fn set_limit(&self, limit: usize) {
atomic_store!(self.limit, limit);
}
/// getter for limit
pub(super) fn get_limit(&self) -> usize {
atomic_load!(self.limit)
}
/// adjust the rate of requests per second up (increase rate)
pub(super) fn adjust_up(&self, streak_counter: &usize) {
if let Ok(mut heap) = self.heap.try_write() {
if *streak_counter > 2 {
// streak of 3 upward moves in a row, traverse the tree upward instead of to a
// higher-valued branch lower in the tree
let current = heap.value();
heap.move_up();
heap.move_up();
if current > heap.value() {
// the tree's structure makes it so that sometimes 2 moves up results in a
// value greater than the current node's and other times we need to move 3 up
// to arrive at a greater value
if heap.has_parent() && heap.parent_value() > current {
// all nodes except 0th node (root)
heap.move_up();
}
}
} else if heap.has_children() {
// streak not at 3, just check that we can move down, and do so
heap.move_left();
} else {
// tree bottomed out, need to move back up the tree a bit
let current = heap.value();
heap.move_up();
heap.move_up();
if current > heap.value() {
heap.move_up();
}
}
if !heap.has_parent() {
// been here enough that we can try resuming the scan to its original
// speed (no limiting at all)
atomic_store!(self.remove_limit, true);
}
self.set_limit(heap.value() as usize);
}
}
/// adjust the rate of requests per second down (decrease rate)
pub(super) fn adjust_down(&self) {
if let Ok(mut heap) = self.heap.try_write() {
if heap.has_children() {
heap.move_right();
self.set_limit(heap.value() as usize);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// PolicyData builds and sets correct values for the inner heap when set_reqs_sec is called
fn set_reqs_sec_builds_heap_and_sets_initial_value() {
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
assert_eq!(pd.wait_time, 3500);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
assert_eq!(pd.heap.read().unwrap().original, 400);
assert_eq!(pd.heap.read().unwrap().current, 0);
assert_eq!(pd.heap.read().unwrap().inner[0], 200);
assert_eq!(pd.heap.read().unwrap().inner[1], 300);
assert_eq!(pd.heap.read().unwrap().inner[2], 100);
}
#[test]
/// PolicyData setters/getters tests for code coverage / sanity
fn policy_data_getters_and_setters() {
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_errors(20);
assert_eq!(pd.errors.load(Ordering::Relaxed), 20);
pd.set_limit(200);
assert_eq!(pd.get_limit(), 200);
}
#[test]
/// PolicyData adjust_down sets the limit to the correct value
fn policy_data_adjust_down_simple() {
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
pd.adjust_down();
assert_eq!(pd.get_limit(), 100);
}
#[test]
/// PolicyData adjust_down sets the limit to the correct value when no child nodes are present
fn policy_data_adjust_down_no_children() {
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
let mut guard = pd.heap.write().unwrap();
guard.move_to(250);
guard.set_value(27);
pd.set_limit(guard.value() as usize);
drop(guard);
pd.adjust_down();
assert_eq!(pd.get_limit(), 27);
}
#[test]
/// PolicyData adjust_up sets the limit to the correct value
fn policy_data_adjust_up_simple() {
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
pd.adjust_up(&0);
assert_eq!(pd.get_limit(), 300);
}
#[test]
/// PolicyData adjust_up sets the limit to the correct value
fn policy_data_adjust_up_with_streak_and_2_moves() {
// original: 400
// [200, 300, 100, 350, 250, 150, 50, 375, 325, 275, 225, 175, 125, 75, 25, ...]
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
// 2 moves
pd.heap.write().unwrap().move_to(9);
assert_eq!(pd.heap.read().unwrap().value(), 275);
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 300);
assert_eq!(pd.limit.load(Ordering::Relaxed), 300);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
/// PolicyData adjust_up sets the limit to the correct value
fn policy_data_adjust_up_with_streak_and_2_moves_to_arrive_at_root() {
// original: 400
// [200, 300, 100, 350, 250, 150, 50, 375, 325, 275, 225, 175, 125, 75, 25, ...]
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
pd.heap.write().unwrap().move_to(4);
assert_eq!(pd.heap.read().unwrap().value(), 250);
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 200);
assert_eq!(pd.limit.load(Ordering::Relaxed), 200);
assert!(pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
/// PolicyData adjust_up sets the limit to the correct value
fn policy_data_adjust_up_with_streak_and_2_moves_to_find_less_than_current() {
// original: 400
// [200, 300, 100, 350, 250, 150, 50, 375, 325, 275, 225, 175, 125, 75, 25, ...]
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
pd.heap.write().unwrap().move_to(15);
assert_eq!(pd.heap.read().unwrap().value(), 387);
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 350);
assert_eq!(pd.limit.load(Ordering::Relaxed), 350);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
/// PolicyData adjust_up sets the limit to the correct value
fn policy_data_adjust_up_with_streak_and_3_moves() {
// original: 400
// [200, 300, 100, 350, 250, 150, 50, 375, 325, 275, 225, 175, 125, 75, 25, ...]
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
pd.heap.write().unwrap().move_to(19);
assert_eq!(pd.heap.read().unwrap().value(), 287);
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 300);
assert_eq!(pd.limit.load(Ordering::Relaxed), 300);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
/// PolicyData adjust_up sets the limit to the correct value
fn policy_data_adjust_up_with_no_children_2_moves() {
// original: 400
// [200, 300, 100, 350, 250, 150, 50, 375, 325, 275, 225, 175, 125, 75, 25, ...]
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
pd.heap.write().unwrap().move_to(241);
assert_eq!(pd.heap.read().unwrap().value(), 41);
pd.adjust_up(&0);
assert_eq!(pd.heap.read().unwrap().value(), 43);
assert_eq!(pd.limit.load(Ordering::Relaxed), 43);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
/// PolicyData adjust_up sets the limit to the correct value
fn policy_data_adjust_up_with_no_children_3_moves() {
// original: 400
// [200, 300, 100, 350, 250, 150, 50, 375, 325, 275, 225, 175, 125, 75, 25, ...]
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
assert_eq!(pd.get_limit(), 200);
pd.heap.write().unwrap().move_to(240);
assert_eq!(pd.heap.read().unwrap().value(), 45);
pd.adjust_up(&0);
assert_eq!(pd.heap.read().unwrap().value(), 37);
assert_eq!(pd.limit.load(Ordering::Relaxed), 37);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
/// hit some of the out of the way corners of limitheap for coverage
fn increase_limit_heap_coverage_by_hitting_edge_cases() {
let pd = PolicyData::new(RequesterPolicy::AutoBail, 7);
pd.set_reqs_sec(400);
println!("{:?}", pd.heap.read().unwrap()); // debug derivation
pd.heap.write().unwrap().move_to(240);
assert_eq!(pd.heap.write().unwrap().move_right(), 240);
assert_eq!(pd.heap.write().unwrap().move_left(), 240);
pd.heap.write().unwrap().move_to(0);
assert_eq!(pd.heap.write().unwrap().move_up(), 0);
assert_eq!(pd.heap.write().unwrap().parent_value(), 400);
}
}

1177
src/scanner/requester.rs Normal file

File diff suppressed because it is too large Load Diff

28
src/scanner/tests.rs Normal file
View File

@@ -0,0 +1,28 @@
use std::sync::Arc;
use tokio::sync::Semaphore;
use crate::{
config::OutputLevel,
event_handlers::Handles,
scan_manager::{FeroxScans, ScanOrder},
};
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[should_panic]
/// try to hit struct field coverage of FileOutHandler
async fn get_scan_by_url_bails_on_unfound_url() {
let sem = Semaphore::new(10);
let urls = FeroxScans::new(OutputLevel::Default);
let scanner = FeroxScanner::new(
"http://localhost",
ScanOrder::Initial,
Arc::new(Default::default()),
Arc::new(sem),
Arc::new(Handles::for_testing(Some(Arc::new(urls)), None).0),
);
scanner.scan_url().await.unwrap();
}

15
src/scanner/utils.rs Normal file
View File

@@ -0,0 +1,15 @@
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
/// represents different situations where different criteria can trigger auto-tune/bail behavior
pub enum PolicyTrigger {
/// excessive 403 trigger
Status403,
/// excessive 429 trigger
Status429,
/// excessive general errors
Errors,
/// dummy error for upward rate adjustment
TryAdjustUp,
}

View File

@@ -1,4 +1,6 @@
use std::{ use std::{
collections::HashMap,
convert::TryFrom,
fs::File, fs::File,
io::BufReader, io::BufReader,
sync::{ sync::{
@@ -9,7 +11,8 @@ use std::{
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use reqwest::StatusCode; use reqwest::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use crate::{ use crate::{
traits::FeroxSerialize, traits::FeroxSerialize,
@@ -19,9 +22,8 @@ use crate::{
use super::{error::StatError, field::StatField}; use super::{error::StatError, field::StatField};
/// Data collection of statistics related to a scan /// Data collection of statistics related to a scan
#[derive(Default, Deserialize, Debug, Serialize)] #[derive(Default, Debug)]
pub struct Stats { pub struct Stats {
#[serde(rename = "type")]
/// Name of this type of struct, used for serialization, i.e. `{"type":"statistics"}` /// Name of this type of struct, used for serialization, i.e. `{"type":"statistics"}`
kind: String, kind: String,
@@ -29,7 +31,7 @@ pub struct Stats {
timeouts: AtomicUsize, timeouts: AtomicUsize,
/// tracker for total number of requests sent by the client /// tracker for total number of requests sent by the client
requests: AtomicUsize, pub(crate) requests: AtomicUsize,
/// tracker for total number of requests expected to send if the scan runs to completion /// tracker for total number of requests expected to send if the scan runs to completion
/// ///
@@ -42,7 +44,7 @@ pub struct Stats {
total_expected: AtomicUsize, total_expected: AtomicUsize,
/// tracker for total number of errors encountered by the client /// tracker for total number of errors encountered by the client
errors: AtomicUsize, pub(crate) errors: AtomicUsize,
/// tracker for overall number of 2xx status codes seen by the client /// tracker for overall number of 2xx status codes seen by the client
successes: AtomicUsize, successes: AtomicUsize,
@@ -58,7 +60,7 @@ pub struct Stats {
/// tracker for number of scans performed, this directly equates to number of directories /// tracker for number of scans performed, this directly equates to number of directories
/// recursed into and affects the total number of expected requests /// recursed into and affects the total number of expected requests
total_scans: AtomicUsize, pub(crate) total_scans: AtomicUsize,
/// tracker for initial number of requested targets /// tracker for initial number of requested targets
initial_targets: AtomicUsize, initial_targets: AtomicUsize,
@@ -67,6 +69,10 @@ pub struct Stats {
/// response bodies and robots.txt as of v1.11.0 /// response bodies and robots.txt as of v1.11.0
links_extracted: AtomicUsize, links_extracted: AtomicUsize,
/// tracker for number of extensions discovered when `--collect-extensions` is used; sources
/// are response bodies
extensions_collected: AtomicUsize,
/// tracker for overall number of 200s seen by the client /// tracker for overall number of 200s seen by the client
status_200s: AtomicUsize, status_200s: AtomicUsize,
@@ -80,10 +86,10 @@ pub struct Stats {
status_401s: AtomicUsize, status_401s: AtomicUsize,
/// tracker for overall number of 403s seen by the client /// tracker for overall number of 403s seen by the client
status_403s: AtomicUsize, pub(crate) status_403s: AtomicUsize,
/// tracker for overall number of 429s seen by the client /// tracker for overall number of 429s seen by the client
status_429s: AtomicUsize, pub(crate) status_429s: AtomicUsize,
/// tracker for overall number of 500s seen by the client /// tracker for overall number of 500s seen by the client
status_500s: AtomicUsize, status_500s: AtomicUsize,
@@ -124,12 +130,7 @@ pub struct Stats {
/// tracker for total runtime /// tracker for total runtime
total_runtime: Mutex<Vec<f64>>, total_runtime: Mutex<Vec<f64>>,
/// tracker for the number of extensions the user specified
#[serde(skip)]
num_extensions: usize,
/// tracker for whether to use json during serialization or not /// tracker for whether to use json during serialization or not
#[serde(skip)]
json: bool, json: bool,
} }
@@ -147,13 +148,318 @@ impl FeroxSerialize for Stats {
} }
} }
/// Serialize implementation for Stats
impl Serialize for Stats {
/// Function that handles serialization of Stats
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("Stats", 32)?;
state.serialize_field("type", &self.kind)?;
state.serialize_field("timeouts", &atomic_load!(self.timeouts))?;
state.serialize_field("requests", &atomic_load!(self.requests))?;
state.serialize_field("expected_per_scan", &atomic_load!(self.expected_per_scan))?;
state.serialize_field("total_expected", &atomic_load!(self.total_expected))?;
state.serialize_field("errors", &atomic_load!(self.errors))?;
state.serialize_field("successes", &atomic_load!(self.successes))?;
state.serialize_field("redirects", &atomic_load!(self.redirects))?;
state.serialize_field("client_errors", &atomic_load!(self.client_errors))?;
state.serialize_field("server_errors", &atomic_load!(self.server_errors))?;
state.serialize_field("total_scans", &atomic_load!(self.total_scans))?;
state.serialize_field("initial_targets", &atomic_load!(self.initial_targets))?;
state.serialize_field("links_extracted", &atomic_load!(self.links_extracted))?;
state.serialize_field(
"extensions_collected",
&atomic_load!(self.extensions_collected),
)?;
state.serialize_field("status_200s", &atomic_load!(self.status_200s))?;
state.serialize_field("status_301s", &atomic_load!(self.status_301s))?;
state.serialize_field("status_302s", &atomic_load!(self.status_302s))?;
state.serialize_field("status_401s", &atomic_load!(self.status_401s))?;
state.serialize_field("status_403s", &atomic_load!(self.status_403s))?;
state.serialize_field("status_429s", &atomic_load!(self.status_429s))?;
state.serialize_field("status_500s", &atomic_load!(self.status_500s))?;
state.serialize_field("status_503s", &atomic_load!(self.status_503s))?;
state.serialize_field("status_504s", &atomic_load!(self.status_504s))?;
state.serialize_field("status_508s", &atomic_load!(self.status_508s))?;
state.serialize_field("wildcards_filtered", &atomic_load!(self.wildcards_filtered))?;
state.serialize_field("responses_filtered", &atomic_load!(self.responses_filtered))?;
state.serialize_field(
"resources_discovered",
&atomic_load!(self.resources_discovered),
)?;
state.serialize_field("url_format_errors", &atomic_load!(self.url_format_errors))?;
state.serialize_field("redirection_errors", &atomic_load!(self.redirection_errors))?;
state.serialize_field("connection_errors", &atomic_load!(self.connection_errors))?;
state.serialize_field("request_errors", &atomic_load!(self.request_errors))?;
state.serialize_field("directory_scan_times", &self.directory_scan_times)?;
state.serialize_field("total_runtime", &self.total_runtime)?;
state.end()
}
}
/// Deserialize implementation for Stats
impl<'a> Deserialize<'a> for Stats {
/// Deserialize a Stats object from a serde_json::Value
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'a>,
{
let stats = Self::new(false);
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"timeouts" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.timeouts, parsed);
}
}
}
"requests" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.requests, parsed);
}
}
}
"expected_per_scan" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.expected_per_scan, parsed);
}
}
}
"total_expected" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.total_expected, parsed);
}
}
}
"errors" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.errors, parsed);
}
}
}
"successes" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.successes, parsed);
}
}
}
"redirects" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.redirects, parsed);
}
}
}
"client_errors" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.client_errors, parsed);
}
}
}
"server_errors" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.server_errors, parsed);
}
}
}
"total_scans" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.total_scans, parsed);
}
}
}
"initial_targets" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.initial_targets, parsed);
}
}
}
"links_extracted" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.links_extracted, parsed);
}
}
}
"extensions_collected" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.extensions_collected, parsed);
}
}
}
"status_200s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_200s, parsed);
}
}
}
"status_301s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_301s, parsed);
}
}
}
"status_302s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_302s, parsed);
}
}
}
"status_401s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_401s, parsed);
}
}
}
"status_403s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_403s, parsed);
}
}
}
"status_429s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_429s, parsed);
}
}
}
"status_500s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_500s, parsed);
}
}
}
"status_503s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_503s, parsed);
}
}
}
"status_504s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_504s, parsed);
}
}
}
"status_508s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.status_508s, parsed);
}
}
}
"wildcards_filtered" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.wildcards_filtered, parsed);
}
}
}
"responses_filtered" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.responses_filtered, parsed);
}
}
}
"resources_discovered" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.resources_discovered, parsed);
}
}
}
"url_format_errors" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.url_format_errors, parsed);
}
}
}
"redirection_errors" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.redirection_errors, parsed);
}
}
}
"connection_errors" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.connection_errors, parsed);
}
}
}
"request_errors" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.request_errors, parsed);
}
}
}
"directory_scan_times" => {
if let Some(arr) = value.as_array() {
for val in arr {
if let Some(parsed) = val.as_f64() {
if let Ok(mut guard) = stats.directory_scan_times.lock() {
guard.push(parsed)
}
}
}
}
}
"total_runtime" => {
if let Some(arr) = value.as_array() {
for val in arr {
if let Some(parsed) = val.as_f64() {
if let Ok(mut guard) = stats.total_runtime.lock() {
guard.push(parsed)
}
}
}
}
}
_ => {}
}
}
Ok(stats)
}
}
/// implementation of statistics data collection struct /// implementation of statistics data collection struct
impl Stats { impl Stats {
/// Small wrapper for default to set `kind` to "statistics" and `total_runtime` to have at least /// Small wrapper for default to set `kind` to "statistics" and `total_runtime` to have at least
/// one value /// one value
pub fn new(num_extensions: usize, is_json: bool) -> Self { pub fn new(is_json: bool) -> Self {
Self { Self {
num_extensions,
json: is_json, json: is_json,
kind: String::from("statistics"), kind: String::from("statistics"),
total_runtime: Mutex::new(vec![0.0]), total_runtime: Mutex::new(vec![0.0]),
@@ -176,6 +482,16 @@ impl Stats {
atomic_load!(self.errors) atomic_load!(self.errors)
} }
/// public getter for status_403s
pub fn status_403s(&self) -> usize {
atomic_load!(self.status_403s)
}
/// public getter for status_429s
pub fn status_429s(&self) -> usize {
atomic_load!(self.status_429s)
}
/// public getter for total_expected /// public getter for total_expected
pub fn total_expected(&self) -> usize { pub fn total_expected(&self) -> usize {
atomic_load!(self.total_expected) atomic_load!(self.total_expected)
@@ -222,10 +538,6 @@ impl Stats {
StatError::Timeout => { StatError::Timeout => {
atomic_increment!(self.timeouts); atomic_increment!(self.timeouts);
} }
StatError::Status403 => {
atomic_increment!(self.status_403s);
atomic_increment!(self.client_errors);
}
StatError::UrlFormat => { StatError::UrlFormat => {
atomic_increment!(self.url_format_errors); atomic_increment!(self.url_format_errors);
} }
@@ -238,9 +550,7 @@ impl Stats {
StatError::Request => { StatError::Request => {
atomic_increment!(self.request_errors); atomic_increment!(self.request_errors);
} }
StatError::Other => { _ => {} // no need to hit Other as we always increment self.errors anyway
atomic_increment!(self.errors);
}
} }
} }
@@ -248,7 +558,7 @@ impl Stats {
/// ///
/// Implies incrementing: /// Implies incrementing:
/// - requests /// - requests
/// - status_403s (when code is 403) /// - appropriate status_* codes
/// - errors (when code is [45]xx) /// - errors (when code is [45]xx)
pub fn add_status_code(&self, status: StatusCode) { pub fn add_status_code(&self, status: StatusCode) {
self.add_request(); self.add_request();
@@ -264,9 +574,6 @@ impl Stats {
} }
match status { match status {
StatusCode::FORBIDDEN => {
atomic_increment!(self.status_403s);
}
StatusCode::OK => { StatusCode::OK => {
atomic_increment!(self.status_200s); atomic_increment!(self.status_200s);
} }
@@ -279,6 +586,9 @@ impl Stats {
StatusCode::UNAUTHORIZED => { StatusCode::UNAUTHORIZED => {
atomic_increment!(self.status_401s); atomic_increment!(self.status_401s);
} }
StatusCode::FORBIDDEN => {
atomic_increment!(self.status_403s);
}
StatusCode::TOO_MANY_REQUESTS => { StatusCode::TOO_MANY_REQUESTS => {
atomic_increment!(self.status_429s); atomic_increment!(self.status_429s);
} }
@@ -307,6 +617,13 @@ impl Stats {
} }
} }
/// subtract a value from the given field
pub fn subtract_from_usize_field(&self, field: StatField, value: usize) {
if let StatField::TotalExpected = field {
self.total_expected.fetch_sub(value, Ordering::Relaxed);
}
}
/// Update a `Stats` field of type usize /// Update a `Stats` field of type usize
pub fn update_usize_field(&self, field: StatField, value: usize) { pub fn update_usize_field(&self, field: StatField, value: usize) {
match field { match field {
@@ -314,12 +631,10 @@ impl Stats {
atomic_increment!(self.expected_per_scan, value); atomic_increment!(self.expected_per_scan, value);
} }
StatField::TotalScans => { StatField::TotalScans => {
let multiplier = self.num_extensions.max(1);
atomic_increment!(self.total_scans, value); atomic_increment!(self.total_scans, value);
atomic_increment!( atomic_increment!(
self.total_expected, self.total_expected,
value * self.expected_per_scan.load(Ordering::Relaxed) * multiplier value * self.expected_per_scan.load(Ordering::Relaxed)
); );
} }
StatField::TotalExpected => { StatField::TotalExpected => {
@@ -328,6 +643,9 @@ impl Stats {
StatField::LinksExtracted => { StatField::LinksExtracted => {
atomic_increment!(self.links_extracted, value); atomic_increment!(self.links_extracted, value);
} }
StatField::ExtensionsCollected => {
atomic_increment!(self.extensions_collected, value);
}
StatField::WildcardsFiltered => { StatField::WildcardsFiltered => {
atomic_increment!(self.wildcards_filtered, value); atomic_increment!(self.wildcards_filtered, value);
atomic_increment!(self.responses_filtered, value); atomic_increment!(self.responses_filtered, value);
@@ -349,8 +667,8 @@ impl Stats {
/// ///
/// This is only ever called when resuming a scan from disk /// This is only ever called when resuming a scan from disk
pub fn merge_from(&self, filename: &str) -> Result<()> { pub fn merge_from(&self, filename: &str) -> Result<()> {
let file = File::open(filename) let file =
.with_context(|| fmt_err(&format!("Could not open {}", filename)))?; File::open(filename).with_context(|| fmt_err(&format!("Could not open {filename}")))?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
let state: serde_json::Value = serde_json::from_reader(reader)?; let state: serde_json::Value = serde_json::from_reader(reader)?;
@@ -364,6 +682,10 @@ impl Stats {
atomic_increment!(self.client_errors, atomic_load!(d_stats.client_errors)); atomic_increment!(self.client_errors, atomic_load!(d_stats.client_errors));
atomic_increment!(self.server_errors, atomic_load!(d_stats.server_errors)); atomic_increment!(self.server_errors, atomic_load!(d_stats.server_errors));
atomic_increment!(self.links_extracted, atomic_load!(d_stats.links_extracted)); atomic_increment!(self.links_extracted, atomic_load!(d_stats.links_extracted));
atomic_increment!(
self.extensions_collected,
atomic_load!(d_stats.extensions_collected)
);
atomic_increment!(self.status_200s, atomic_load!(d_stats.status_200s)); atomic_increment!(self.status_200s, atomic_load!(d_stats.status_200s));
atomic_increment!(self.status_301s, atomic_load!(d_stats.status_301s)); atomic_increment!(self.status_301s, atomic_load!(d_stats.status_301s));
atomic_increment!(self.status_302s, atomic_load!(d_stats.status_302s)); atomic_increment!(self.status_302s, atomic_load!(d_stats.status_302s));
@@ -435,30 +757,6 @@ mod tests {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// when sent StatCommand::AddRequest, stats object should reflect the change
///
/// incrementing a 403 (tracked in status_403s) should also increment:
/// - errors
/// - requests
/// - client_errors
async fn statistics_handler_increments_403() {
let (task, handle) = setup_stats_test();
let err = Command::AddError(StatError::Status403);
let err2 = Command::AddError(StatError::Status403);
handle.tx.send(err).unwrap_or_default();
handle.tx.send(err2).unwrap_or_default();
teardown_stats_test(handle.tx.clone(), task).await;
assert_eq!(handle.data.errors.load(Ordering::Relaxed), 2);
assert_eq!(handle.data.requests.load(Ordering::Relaxed), 2);
assert_eq!(handle.data.status_403s.load(Ordering::Relaxed), 2);
assert_eq!(handle.data.client_errors.load(Ordering::Relaxed), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// when sent StatCommand::AddRequest, stats object should reflect the change /// when sent StatCommand::AddRequest, stats object should reflect the change
/// ///
@@ -510,7 +808,7 @@ mod tests {
/// - errors /// - errors
fn stats_increments_timeouts() { fn stats_increments_timeouts() {
let config = Configuration::new().unwrap(); let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json); let stats = Stats::new(config.json);
stats.add_error(StatError::Timeout); stats.add_error(StatError::Timeout);
stats.add_error(StatError::Timeout); stats.add_error(StatError::Timeout);
@@ -528,7 +826,7 @@ mod tests {
/// - responses_filtered /// - responses_filtered
fn stats_increments_wildcards() { fn stats_increments_wildcards() {
let config = Configuration::new().unwrap(); let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json); let stats = Stats::new(config.json);
assert_eq!(stats.responses_filtered.load(Ordering::Relaxed), 0); assert_eq!(stats.responses_filtered.load(Ordering::Relaxed), 0);
assert_eq!(stats.wildcards_filtered.load(Ordering::Relaxed), 0); assert_eq!(stats.wildcards_filtered.load(Ordering::Relaxed), 0);
@@ -544,7 +842,7 @@ mod tests {
/// when Stats::update_usize_field receives StatField::ResponsesFiltered, it should increment /// when Stats::update_usize_field receives StatField::ResponsesFiltered, it should increment
fn stats_increments_responses_filtered() { fn stats_increments_responses_filtered() {
let config = Configuration::new().unwrap(); let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json); let stats = Stats::new(config.json);
assert_eq!(stats.responses_filtered.load(Ordering::Relaxed), 0); assert_eq!(stats.responses_filtered.load(Ordering::Relaxed), 0);
@@ -558,16 +856,16 @@ mod tests {
#[test] #[test]
/// Stats::merge_from should properly increment expected fields and ignore others /// Stats::merge_from should properly increment expected fields and ignore others
fn stats_merge_from_alters_correct_fields() { fn stats_merge_from_alters_correct_fields() {
let contents = r#"{"statistics":{"type":"statistics","timeouts":1,"requests":9207,"expected_per_scan":707,"total_expected":9191,"errors":3,"successes":720,"redirects":13,"client_errors":8474,"server_errors":2,"total_scans":13,"initial_targets":1,"links_extracted":51,"status_403s":3,"status_200s":720,"status_301s":12,"status_302s":1,"status_401s":4,"status_429s":2,"status_500s":5,"status_503s":9,"status_504s":6,"status_508s":7,"wildcards_filtered":707,"responses_filtered":707,"resources_discovered":27,"directory_scan_times":[2.211973078,1.989015505,1.898675839,3.9714468910000003,4.938152838,5.256073528,6.021986595,6.065740734,6.42633762,7.095142125,7.336982137,5.319785619,4.843649778],"total_runtime":[11.556575456000001],"url_format_errors":17,"redirection_errors":12,"connection_errors":21,"request_errors":4}}"#; let contents = r#"{"statistics":{"type":"statistics","timeouts":1,"requests":9207,"expected_per_scan":707,"total_expected":9191,"errors":3,"successes":720,"redirects":13,"client_errors":8474,"server_errors":2,"total_scans":13,"initial_targets":1,"links_extracted":51,"extensions_collected":4,"status_403s":3,"status_200s":720,"status_301s":12,"status_302s":1,"status_401s":4,"status_429s":2,"status_500s":5,"status_503s":9,"status_504s":6,"status_508s":7,"wildcards_filtered":707,"responses_filtered":707,"resources_discovered":27,"directory_scan_times":[2.211973078,1.989015505,1.898675839,3.9714468910000003,4.938152838,5.256073528,6.021986595,6.065740734,6.42633762,7.095142125,7.336982137,5.319785619,4.843649778],"total_runtime":[11.556575456000001],"url_format_errors":17,"redirection_errors":12,"connection_errors":21,"request_errors":4}}"#;
let config = Configuration::new().unwrap(); let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json); let stats = Stats::new(config.json);
let tfile = NamedTempFile::new().unwrap(); let tfile = NamedTempFile::new().unwrap();
write(&tfile, contents).unwrap(); write(&tfile, contents).unwrap();
stats.merge_from(tfile.path().to_str().unwrap()).unwrap(); stats.merge_from(tfile.path().to_str().unwrap()).unwrap();
// as of 1.11.1; all Stats fields are accounted for whether they're updated in merge_from // as of 2.1.0; all Stats fields are accounted for whether they're updated in merge_from
// or not // or not
assert_eq!(atomic_load!(stats.timeouts), 1); assert_eq!(atomic_load!(stats.timeouts), 1);
assert_eq!(atomic_load!(stats.requests), 9207); assert_eq!(atomic_load!(stats.requests), 9207);
@@ -581,6 +879,7 @@ mod tests {
assert_eq!(atomic_load!(stats.total_scans), 0); // not updated in merge_from assert_eq!(atomic_load!(stats.total_scans), 0); // not updated in merge_from
assert_eq!(atomic_load!(stats.initial_targets), 0); // not updated in merge_from assert_eq!(atomic_load!(stats.initial_targets), 0); // not updated in merge_from
assert_eq!(atomic_load!(stats.links_extracted), 51); assert_eq!(atomic_load!(stats.links_extracted), 51);
assert_eq!(atomic_load!(stats.extensions_collected), 4);
assert_eq!(atomic_load!(stats.status_200s), 720); assert_eq!(atomic_load!(stats.status_200s), 720);
assert_eq!(atomic_load!(stats.status_301s), 12); assert_eq!(atomic_load!(stats.status_301s), 12);
assert_eq!(atomic_load!(stats.status_302s), 1); assert_eq!(atomic_load!(stats.status_302s), 1);
@@ -611,10 +910,28 @@ mod tests {
/// ensure update runtime overwrites the default 0th entry /// ensure update runtime overwrites the default 0th entry
fn update_runtime_works() { fn update_runtime_works() {
let config = Configuration::new().unwrap(); let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json); let stats = Stats::new(config.json);
assert!((stats.total_runtime.lock().unwrap()[0] - 0.0).abs() < f64::EPSILON); assert!((stats.total_runtime.lock().unwrap()[0] - 0.0).abs() < f64::EPSILON);
stats.update_runtime(20.2); stats.update_runtime(20.2);
assert!((stats.total_runtime.lock().unwrap()[0] - 20.2).abs() < f64::EPSILON); assert!((stats.total_runtime.lock().unwrap()[0] - 20.2).abs() < f64::EPSILON);
} }
#[test]
/// ensure status_403s returns the correct value
fn status_403s_returns_correct_value() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.json);
stats.status_403s.store(12, Ordering::Relaxed);
assert_eq!(stats.status_403s(), 12);
}
#[test]
/// ensure status_403s returns the correct value
fn status_429s_returns_correct_value() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.json);
stats.status_429s.store(141, Ordering::Relaxed);
assert_eq!(stats.status_429s(), 141);
}
} }

View File

@@ -1,9 +1,6 @@
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
/// Enum variants used to inform the `StatCommand` protocol what `Stats` fields should be updated /// Enum variants used to inform the `StatCommand` protocol what `Stats` fields should be updated
pub enum StatError { pub enum StatError {
/// Represents a 403 response code
Status403,
/// Represents a timeout error /// Represents a timeout error
Timeout, Timeout,

View File

@@ -13,6 +13,9 @@ pub enum StatField {
/// Translates to `links_extracted` /// Translates to `links_extracted`
LinksExtracted, LinksExtracted,
/// Translates to `extensions_collected`
ExtensionsCollected,
/// Translates to `total_expected` /// Translates to `total_expected`
TotalExpected, TotalExpected,

View File

@@ -18,6 +18,20 @@ macro_rules! atomic_increment {
#[macro_export] #[macro_export]
macro_rules! atomic_load { macro_rules! atomic_load {
($metric:expr) => { ($metric:expr) => {
$metric.load(Ordering::Relaxed); $metric.load(Ordering::Relaxed)
};
($metric:expr, $ordering:expr) => {
$metric.load($ordering)
};
}
/// Wrapper around `Atomic*.store` to save me from writing Ordering::Relaxed a bajillion times
#[macro_export]
macro_rules! atomic_store {
($metric:expr, $value:expr) => {
$metric.store($value, Ordering::Relaxed);
};
($metric:expr, $value:expr, $ordering:expr) => {
$metric.store($value, $ordering);
}; };
} }

View File

@@ -41,7 +41,7 @@ async fn statistics_handler_exits() -> Result<()> {
/// Stats::save should write contents of Stats to disk /// Stats::save should write contents of Stats to disk
fn save_writes_stats_object_to_disk() { fn save_writes_stats_object_to_disk() {
let config = Configuration::new().unwrap(); let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json); let stats = Stats::new(config.json);
stats.add_request(); stats.add_request();
stats.add_request(); stats.add_request();
@@ -55,10 +55,7 @@ fn save_writes_stats_object_to_disk() {
stats.add_status_code(StatusCode::OK); stats.add_status_code(StatusCode::OK);
stats.add_status_code(StatusCode::OK); stats.add_status_code(StatusCode::OK);
let outfile = NamedTempFile::new().unwrap(); let outfile = NamedTempFile::new().unwrap();
if stats if stats.save(174.33, outfile.path().to_str().unwrap()).is_ok() {}
.save(174.33, &outfile.path().to_str().unwrap())
.is_ok()
{}
assert!(stats.as_json().unwrap().contains("statistics")); assert!(stats.as_json().unwrap().contains("statistics"));
assert!(stats.as_json().unwrap().contains("11")); // requests made assert!(stats.as_json().unwrap().contains("11")); // requests made

View File

@@ -1,9 +1,15 @@
//! collection of all traits used //! collection of all traits used
use crate::filters::{
LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter, WildcardFilter,
WordsFilter,
};
use crate::response::FeroxResponse; use crate::response::FeroxResponse;
use crate::utils::status_colorizer;
use anyhow::Result; use anyhow::Result;
use crossterm::style::{style, Stylize};
use serde::Serialize; use serde::Serialize;
use std::any::Any; use std::any::Any;
use std::fmt::Debug; use std::fmt::{self, Debug, Display, Formatter};
// references: // references:
// https://dev.to/magnusstrale/rust-trait-objects-in-a-vector-non-trivial-4co5 // https://dev.to/magnusstrale/rust-trait-objects-in-a-vector-non-trivial-4co5
@@ -22,6 +28,68 @@ pub trait FeroxFilter: Debug + Send + Sync {
fn as_any(&self) -> &dyn Any; fn as_any(&self) -> &dyn Any;
} }
impl Display for dyn FeroxFilter {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
if let Some(filter) = self.as_any().downcast_ref::<LinesFilter>() {
write!(f, "Line count: {}", style(filter.line_count).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<WordsFilter>() {
write!(f, "Word count: {}", style(filter.word_count).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<SizeFilter>() {
write!(f, "Response size: {}", style(filter.content_length).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<RegexFilter>() {
write!(f, "Regex: {}", style(&filter.raw_string).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<WildcardFilter>() {
let mut msg = format!(
"{} requests with {} responses ",
style(&filter.method).cyan(),
status_colorizer(&filter.status_code.to_string())
);
match (filter.content_length, filter.word_count, filter.line_count) {
(None, None, None) => {
unreachable!("wildcard filter without any filters set");
}
(None, None, Some(lc)) => {
msg.push_str(&format!("containing {} lines", lc));
}
(None, Some(wc), None) => {
msg.push_str(&format!("containing {} words", wc));
}
(None, Some(wc), Some(lc)) => {
msg.push_str(&format!("containing {} words and {} lines", wc, lc));
}
(Some(cl), None, None) => {
msg.push_str(&format!("containing {} bytes", cl));
}
(Some(cl), None, Some(lc)) => {
msg.push_str(&format!("containing {} bytes and {} lines", cl, lc));
}
(Some(cl), Some(wc), None) => {
msg.push_str(&format!("containing {} bytes and {} words", cl, wc));
}
(Some(cl), Some(wc), Some(lc)) => {
msg.push_str(&format!(
"containing {} bytes, {} words, and {} lines",
cl, wc, lc
));
}
}
write!(f, "{}", msg)
} else if let Some(filter) = self.as_any().downcast_ref::<StatusCodeFilter>() {
write!(f, "Status code: {}", style(filter.filter_code).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<SimilarityFilter>() {
write!(
f,
"Pages similar to: {}",
style(&filter.original_url).cyan()
)
} else {
write!(f, "Filter: {self:?}")
}
}
}
/// implementation of PartialEq, necessary long-form due to "trait cannot be made into an object" /// implementation of PartialEq, necessary long-form due to "trait cannot be made into an object"
/// error when attempting to derive PartialEq on the trait itself /// error when attempting to derive PartialEq on the trait itself
impl PartialEq for Box<dyn FeroxFilter> { impl PartialEq for Box<dyn FeroxFilter> {

View File

@@ -1,13 +1,14 @@
use crate::{event_handlers::Handles, statistics::StatError::UrlFormat, Command::AddError}; use crate::{event_handlers::Handles, statistics::StatError::UrlFormat, Command::AddError};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use reqwest::Url; use reqwest::Url;
use std::{convert::TryInto, fmt, sync::Arc}; use std::collections::HashSet;
use std::{fmt, sync::Arc};
/// abstraction around target urls; collects all Url related shenanigans in one place /// abstraction around target urls; collects all Url related shenanigans in one place
#[derive(Debug)] #[derive(Debug)]
pub struct FeroxUrl { pub struct FeroxUrl {
/// string representation of the target url /// string representation of the target url
target: String, pub target: String,
/// Handles object for grabbing config values /// Handles object for grabbing config values
handles: Arc<Handles>, handles: Arc<Handles>,
@@ -37,25 +38,40 @@ impl FeroxUrl {
/// ///
/// If any extensions were passed to the program, each extension will add a /// If any extensions were passed to the program, each extension will add a
/// (base_url + word + ext) Url to the vector /// (base_url + word + ext) Url to the vector
pub fn formatted_urls(&self, word: &str) -> Result<Vec<Url>> { pub fn formatted_urls(
&self,
word: &str,
collected_extensions: HashSet<String>,
) -> Result<Vec<Url>> {
log::trace!("enter: formatted_urls({})", word); log::trace!("enter: formatted_urls({})", word);
let mut urls = vec![]; let mut urls = vec![];
match self.format(word, None) { let slash = if self.handles.config.add_slash {
Some("/")
} else {
None
};
match self.format(word, slash) {
// default request, i.e. no extension // default request, i.e. no extension
Ok(url) => urls.push(url), Ok(url) => urls.push(url),
Err(_) => self.handles.stats.send(AddError(UrlFormat))?, Err(_) => self.handles.stats.send(AddError(UrlFormat))?,
} }
for ext in self.handles.config.extensions.iter() { for ext in self
.handles
.config
.extensions
.iter()
.chain(collected_extensions.iter())
{
match self.format(word, Some(ext)) { match self.format(word, Some(ext)) {
// any extensions passed in // any extensions passed in
Ok(url) => urls.push(url), Ok(url) => urls.push(url),
Err(_) => self.handles.stats.send(AddError(UrlFormat))?, Err(_) => self.handles.stats.send(AddError(UrlFormat))?,
} }
} }
log::trace!("exit: formatted_urls -> {:?}", urls); log::trace!("exit: formatted_urls -> {:?}", urls);
Ok(urls) Ok(urls)
} }
@@ -66,7 +82,7 @@ impl FeroxUrl {
pub fn format(&self, word: &str, extension: Option<&str>) -> Result<Url> { pub fn format(&self, word: &str, extension: Option<&str>) -> Result<Url> {
log::trace!("enter: format({}, {:?})", word, extension); log::trace!("enter: format({}, {:?})", word, extension);
if Url::parse(&word).is_ok() { if Url::parse(word).is_ok() {
// when a full url is passed in as a word to be joined to a base url using // when a full url is passed in as a word to be joined to a base url using
// reqwest::Url::join, the result is that the word (url) completely overwrites the base // reqwest::Url::join, the result is that the word (url) completely overwrites the base
// url, potentially resulting in requests to places that aren't actually the target // url, potentially resulting in requests to places that aren't actually the target
@@ -74,7 +90,7 @@ impl FeroxUrl {
// //
// in order to resolve the issue, we check if the word from the wordlist is a parsable URL // in order to resolve the issue, we check if the word from the wordlist is a parsable URL
// and if so, don't do any further processing // and if so, don't do any further processing
let message = format!("word ({}) from wordlist is a URL, skipping...", word); let message = format!("word ({word}) from wordlist is a URL, skipping...");
log::warn!("{}", message); log::warn!("{}", message);
log::trace!("exit: format -> Err({})", message); log::trace!("exit: format -> Err({})", message);
bail!(message); bail!(message);
@@ -97,13 +113,25 @@ impl FeroxUrl {
self.target.to_string() self.target.to_string()
}; };
// extensions and slashes are mutually exclusive cases // As of version 2.3.4, extensions and trailing slashes are no longer mutually exclusive.
let word = if extension.is_some() { // Trailing slashes are now treated as just another extension, which is pretty clever.
format!("{}.{}", word, extension.unwrap()) //
} else if self.handles.config.add_slash && !word.ends_with('/') { // In addition to the change above, @cortantief ID'd a bug here that incorrectly handled
// -f used, and word doesn't already end with a / // 2 leading forward slashes when extensions were used. This block addresses the bugfix.
format!("{}/", word) let mut word = if let Some(ext) = extension {
} else if word.starts_with("//") { // We handle the special case of forward slash
// That allow us to treat it as an extension with a particular format
if ext == "/" {
format!("{word}/")
} else {
format!("{word}.{ext}")
}
} else {
String::from(word)
};
// We check separately if the current word begins with 2 forward slashes
if word.starts_with("//") {
// bug ID'd by @Sicks3c, when a wordlist contains words that begin with 2 forward slashes // bug ID'd by @Sicks3c, when a wordlist contains words that begin with 2 forward slashes
// i.e. //1_40_0/static/js, it gets joined onto the base url in a surprising way // i.e. //1_40_0/static/js, it gets joined onto the base url in a surprising way
// ex: https://localhost/ + //1_40_0/static/js -> https://1_40_0/static/js // ex: https://localhost/ + //1_40_0/static/js -> https://1_40_0/static/js
@@ -111,9 +139,7 @@ impl FeroxUrl {
// and simply removes prefixed forward slashes if there are two of them. Additionally, // and simply removes prefixed forward slashes if there are two of them. Additionally,
// trim_start_matches will trim the pattern until it's gone, so even if there are more than // trim_start_matches will trim the pattern until it's gone, so even if there are more than
// 2 /'s, they'll still be trimmed // 2 /'s, they'll still be trimmed
word.trim_start_matches('/').to_string() word = word.trim_start_matches('/').to_string();
} else {
String::from(word)
}; };
let base_url = Url::parse(&url)?; let base_url = Url::parse(&url)?;
@@ -131,47 +157,6 @@ impl FeroxUrl {
} }
} }
/// Gets the length of a url's path
pub fn path_length(&self) -> Result<u64> {
let parsed = Url::parse(&self.target)?;
Ok(FeroxUrl::path_length_of_url(&parsed))
}
/// Gets the length of a url's path
///
/// example: http://localhost/stuff -> 5
pub fn path_length_of_url(url: &Url) -> u64 {
log::trace!("enter: get_path_length({})", url);
let path = url.path();
let segments = if let Some(split) = path.strip_prefix('/') {
split.split_terminator('/')
} else {
log::trace!("exit: get_path_length -> 0");
return 0;
};
if let Some(last) = segments.last() {
// failure on conversion should be very unlikely. While a usize can absolutely overflow a
// u64, the generally accepted maximum for the length of a url is ~2000. so the value we're
// putting into the u64 should never realistically be anywhere close to producing an
// overflow.
// usize max: 18,446,744,073,709,551,615
// u64 max: 9,223,372,036,854,775,807
let url_len: u64 = last
.len()
.try_into()
.expect("Failed usize -> u64 conversion");
log::trace!("exit: get_path_length -> {}", url_len);
return url_len;
}
log::trace!("exit: get_path_length -> 0");
0
}
/// Simple helper to abstract away adding a forward-slash to a url if not present /// Simple helper to abstract away adding a forward-slash to a url if not present
/// ///
/// used mostly for deduplication purposes and url state tracking /// used mostly for deduplication purposes and url state tracking
@@ -239,7 +224,7 @@ mod tests {
fn formatted_urls_no_extension_returns_base_url_with_word() { fn formatted_urls_no_extension_returns_base_url_with_word() {
let handles = Arc::new(Handles::for_testing(None, None).0); let handles = Arc::new(Handles::for_testing(None, None).0);
let url = FeroxUrl::from_string("http://localhost", handles); let url = FeroxUrl::from_string("http://localhost", handles);
let urls = url.formatted_urls("turbo").unwrap(); let urls = url.formatted_urls("turbo", HashSet::new()).unwrap();
assert_eq!(urls, [Url::parse("http://localhost/turbo").unwrap()]) assert_eq!(urls, [Url::parse("http://localhost/turbo").unwrap()])
} }
@@ -253,7 +238,7 @@ mod tests {
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0); let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
let url = FeroxUrl::from_string("http://localhost", handles); let url = FeroxUrl::from_string("http://localhost", handles);
let urls = url.formatted_urls("turbo").unwrap(); let urls = url.formatted_urls("turbo", HashSet::new()).unwrap();
assert_eq!( assert_eq!(
urls, urls,
@@ -300,7 +285,7 @@ mod tests {
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0); let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
let url = FeroxUrl::from_string("http://localhost", handles); let url = FeroxUrl::from_string("http://localhost", handles);
let urls = url.formatted_urls("turbo").unwrap(); let urls = url.formatted_urls("turbo", HashSet::new()).unwrap();
assert_eq!(urls, expected[i]); assert_eq!(urls, expected[i]);
} }
} }
@@ -451,6 +436,20 @@ mod tests {
); );
} }
#[test]
/// word with two prepended slashes and extensions doesn't discard the entire domain
fn format_url_word_with_two_prepended_slashes_and_extensions() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = FeroxUrl::from_string("http://localhost", handles);
for ext in ["rocks", "fun"] {
let to_check = format!("http://localhost/upload/ferox.{ext}");
assert_eq!(
url.format("//upload/ferox", Some(ext)).unwrap(),
reqwest::Url::parse(&to_check[..]).unwrap()
);
}
}
#[test] #[test]
/// word that is a fully formed url, should return an error /// word that is a fully formed url, should return an error
fn format_url_word_that_is_a_url() { fn format_url_word_that_is_a_url() {
@@ -460,4 +459,33 @@ mod tests {
assert!(formatted.is_err()); assert!(formatted.is_err());
} }
#[test]
/// sending url + word with both an extension and add-slash should get back
/// two urls, one with '/' appended to the word, and the other with the extension
/// appended
fn formatted_urls_with_postslash_and_extensions() {
let config = Configuration {
add_slash: true,
extensions: vec!["rocks".to_string(), "fun".to_string()],
..Default::default()
};
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
let url = FeroxUrl::from_string("http://localhost", handles);
match url.formatted_urls("ferox", HashSet::new()) {
Ok(urls) => {
// 3 = One for the main word + slash and for the two extensions
assert_eq!(urls.len(), 3);
assert_eq!(
urls,
[
Url::parse("http://localhost/ferox/").unwrap(),
Url::parse("http://localhost/ferox.rocks").unwrap(),
Url::parse("http://localhost/ferox.fun").unwrap(),
]
)
}
Err(err) => panic!("{}", err.to_string()),
}
}
} }

Some files were not shown because too many files have changed in this diff Show More