Compare commits

...

728 Commits

Author SHA1 Message Date
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
cd085282ff Update build.yml
change conditional build from master to main branch
2021-02-17 07:57:03 -06:00
epi
468ff8c3a9 Update README.md 2021-02-17 07:49:36 -06:00
epi
a991693584 Merge pull request #220 from bpsizemore/219-sslerrors
Added SSL specific error to "could not connect message" fixes #219
2021-02-17 07:42:18 -06:00
epi
fc6724b4f0 fixed clippy errors; bumped version to 2.0.2 2021-02-17 07:33:27 -06:00
epi
3cb5a9b8fa incremental save 2021-02-17 07:03:04 -06:00
Brian Sizemore
16613077df cargo fmt 2021-02-16 23:20:03 -06:00
Brian Sizemore
b844985528 Added specific error message when unable to connect to host due to SSL errors and test to validate it's working as expected. 2021-02-16 23:01:14 -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
8849db197e updated lock file 2021-02-15 08:08:27 -06:00
epi
ec1a20cd0a bumped version to 2.0.1 2021-02-15 07:41:44 -06:00
epi
6c3e41fc3d updated gitignore 2021-02-15 07:40:36 -06:00
epi
cb8f2c8d34 fixed requests/second display bug 2021-02-15 07:40:11 -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
epi
a9c3ba3c00 updated lock file 2021-02-04 17:12:03 -06:00
epi
c9d1ed599d Merge pull request #188 from epi052/2.0.0-overhaul
2.0.0 internal overhaul
2021-02-04 07:33:13 -06:00
epi
0d024e2b79 reverted ci build change 2021-02-04 07:07:49 -06:00
epi
d97355207c removed lint 2021-02-04 06:46:37 -06:00
epi
19fbbb88b4 fixed trace message in check_for_updates 2021-02-03 14:45:04 -06:00
epi
910dfbc1b7 moved FeroxSerialize out of lib.rs 2021-02-03 10:39:54 -06:00
epi
f329bbc91f Merge pull request #210 from epi052/2.0.0-add-silent-option
2.0.0 add silent option
2021-02-03 10:32:39 -06:00
epi
3a1a1fcd0a removed lint 2021-02-03 10:25:55 -06:00
epi
dfa60099c3 removed lint 2021-02-03 10:18:05 -06:00
epi
bed8c75cd5 added silent/quiet stuff in readme 2021-02-03 10:00:19 -06:00
epi
ba5b1bcbca two todo items wrt test are done 2021-02-03 09:45:40 -06:00
epi
aecb971e11 added integration test to ensure silent/quiet dont show banner 2021-02-03 09:37:16 -06:00
epi
86ef6d705d another coverage test 2021-02-03 09:33:11 -06:00
epi
f15bc742fc another coverage test 2021-02-03 08:26:40 -06:00
epi
49ac9ec1e0 another coverage test 2021-02-03 08:09:52 -06:00
epi
b8bea4ce6a attempt for coverage increase 2021-02-03 07:56:29 -06:00
epi
923c59faac silent and quiet are gtg 2021-02-03 07:18:19 -06:00
epi
b58f84d48f most things appear to work properly, except for resume-from 2021-02-02 21:06:12 -06:00
epi
45d5d73cd6 feroxresponse accepts output_level instead of quiet 2021-02-02 20:31:53 -06:00
epi
766fe567a5 initial work on adding --silent done 2021-02-02 19:35:17 -06:00
epi
50477c8449 added gif 2021-02-02 17:32:21 -06:00
epi
3e6a7d1c03 Merge pull request #209 from epi052/2.0.0-add-rate-limiting
2.0.0 add rate limiting
2021-02-02 15:47:40 -06:00
epi
ab8ebff847 enforced min of 1 req/sec; added integration test 2021-02-02 15:30:30 -06:00
epi
9459246bc9 added banner test 2021-02-02 15:09:53 -06:00
epi
0c126c11f8 added gif for rate limit, fixed banner 2021-02-02 15:07:16 -06:00
epi
688b514285 implemented rate limiting 2021-02-02 14:57:11 -06:00
epi
c9e928ee53 added rate_limiter to struct; fixed serialization test 2021-02-02 13:37:40 -06:00
epi
360b379a82 added leaky-bucket dependency 2021-02-02 13:19:35 -06:00
epi
fdbb403d27 added new BannerEntry 2021-02-02 13:03:46 -06:00
epi
7abf5a50cb updated config 2021-02-02 12:19:53 -06:00
epi
6a2a3b2e97 added arg to parser 2021-02-02 12:19:43 -06:00
epi
075a209517 added example value to config 2021-02-02 12:19:21 -06:00
epi
1c471dc14d Merge pull request #208 from epi052/2.0.0-scanner-rewrite
scanner rewrite
2021-02-02 11:20:40 -06:00
epi
7bb1d810f6 rewrote scanner 2021-02-02 11:07:05 -06:00
epi
2133bf5edd broke out config into a sub-module 2021-02-01 16:08:34 -06:00
epi
9190bc7f3e moved progressbar and printer to progress.rs 2021-02-01 15:55:46 -06:00
epi
d86b6be62d extractor no longer needs config ref 2021-02-01 15:09:30 -06:00
epi
295da11ef5 adjusted tests for new pub level of feroxscan 2021-02-01 08:56:26 -06:00
epi
cc6960e940 fixed issue where --quiet + --resume-from werent displaying correct info 2021-02-01 08:51:10 -06:00
epi
0c6d6c70bb removed CONFIG from progress; CONFIG completely gone 2021-02-01 06:39:11 -06:00
epi
227f8d660a bumped predicates/tokio-util; renamed modules; removed CONFIG from utils 2021-02-01 06:11:11 -06:00
epi
6caed557af removed CONFIG global from wildcard 2021-01-31 20:32:28 -06:00
epi
a78c6c2d4a removed CONFIG global from wildcard 2021-01-31 20:31:12 -06:00
epi
5676bf7914 removed CONFIG global from statistics 2021-01-31 20:14:59 -06:00
epi
35d61147f1 Merge pull request #204 from epi052/2.0.0-rewrite-scan_manager
scan_manager successfully moved to sub-module
2021-01-31 19:20:30 -06:00
epi
f38d7c88a2 scan_manager done 2021-01-31 19:16:02 -06:00
epi
1b0ca51e31 refactored FeroxResponses 2021-01-31 17:47:53 -06:00
epi
82d261919b scan_manager successfully moved to sub-module 2021-01-31 12:52:02 -06:00
epi
9fa3d4ac42 allowing builds for this branch to test releases off the pipeline 2021-01-31 08:57:48 -06:00
epi
83c88ae30d allowing builds for this branch to test releases off the pipeline 2021-01-31 08:57:15 -06:00
epi
662521af10 removed SCAN_LIMIT global from scanner 2021-01-31 08:19:56 -06:00
epi
4efd31e444 Merge branch 'master' into 2.0.0-overhaul 2021-01-30 20:49:45 -06:00
epi
43fab73d71 moved FeroxMessage from lib.rs 2021-01-30 20:43:31 -06:00
epi
a5cfbe72c0 fixed tests related to feroxresponse move 2021-01-30 20:27:31 -06:00
epi
d09a875d4d moved FeroxResponse to its own file 2021-01-30 14:03:49 -06:00
epi
050c4f0892 removed FeroxError 2021-01-30 13:35:58 -06:00
epi
cd89a29df0 all Config tests now use ::new to avoid saving state files 2021-01-30 13:31:43 -06:00
epi
323be9e1ed Merge pull request #203 from epi052/2.0.0-inputs-handler
added terminal input event handlers
2021-01-30 13:25:16 -06:00
epi
cc59a85609 clean up todo items in inputs 2021-01-30 09:59:38 -06:00
epi
004a045da2 added terminal input event handlers 2021-01-30 08:26:33 -06:00
epi
950fda2214 nitpickery 2021-01-30 06:27:06 -06:00
epi
7e6cfa0075 most CONFIG removed from scanner 2021-01-30 06:08:17 -06:00
epi
f60532501f removed CONFIG from banner 2021-01-30 05:46:34 -06:00
epi
19728f2cbd removed CONFIG from outputs and scans 2021-01-29 21:05:07 -06:00
epi
186fd79dba removed CONFIG global from logger 2021-01-29 20:04:59 -06:00
epi
a6e5fc9982 fixed todo items 2021-01-29 20:00:47 -06:00
epi
3349fb275b started CONFIGURATION removal; created FeroxUrl 2021-01-29 19:18:02 -06:00
Ben "epi" Risher
6e92e5e2d5 added Makefile for builds 2021-01-29 16:04:09 -06:00
Ben "epi" Risher
3060f73ce3 New upstream version 0.20210129 2021-01-29 11:33:17 -06:00
epi
cd52647800 cleaned up config a bit 2021-01-28 15:02:37 -06:00
epi
ece32bf4f3 pulled macros out of utils 2021-01-28 14:29:03 -06:00
epi
5d230a365c Merge pull request #202 from epi052/2.0.0-rewrite-heuristics
rewrote heuristics
2021-01-28 11:46:01 -06:00
epi
bc36dca3cd added docstrings for struct in heuristics 2021-01-28 11:43:10 -06:00
epi
9cecf0c0d4 rewrote heuristics 2021-01-28 11:26:23 -06:00
epi
a2ba088d45 Merge pull request #201 from epi052/2.0.0-rewrite-client
rewrote client; updated tests
2021-01-27 20:51:01 -06:00
epi
85c4d5ce59 rewrote client; updated tests 2021-01-27 20:38:27 -06:00
epi
41fdc6a95a Merge pull request #194 from epi052/2.0.0-filter-handler
2.0.0 filter handler
2021-01-27 20:21:05 -06:00
epi
26019677a4 reverted build to master only 2021-01-27 20:18:56 -06:00
epi
06c4217785 removed some lint from Handles and ScanHandle 2021-01-27 19:45:47 -06:00
epi
033751221b increased filters test coverage 2021-01-27 19:35:24 -06:00
epi
50d5d98316 updated lcov converter to point at my fork 2021-01-27 18:31:36 -06:00
epi
1ec6a3fff5 fixed tests for real this time 2021-01-27 17:41:04 -06:00
epi
eef8fa62a0 removed todo/clippy 2021-01-27 07:38:00 -06:00
epi
1511be8d0e tests passing; initial check of coverage 2021-01-27 07:36:48 -06:00
epi
d9c99913d3 all todo done; wildcard filter default changed to u64::MAX 2021-01-26 06:55:17 -06:00
epi
f6eae256a4 fixed filtering and depth limiting 2021-01-24 17:38:40 -06:00
epi
e33816e9da filters now sent to the filter handler; still not acted upon 2021-01-24 15:03:43 -06:00
epi
8353978b5a lint and wrappers 2021-01-24 13:51:59 -06:00
epi
d9c64aa238 moved scan resume logic into FeroxScans + random lint 2021-01-24 13:31:49 -06:00
epi
9aafca90ee non-utf8 lines in wordlist skipped instead of erroring 2021-01-24 09:35:42 -06:00
epi
907943ad01 many todo items complete, many more to go 2021-01-24 09:19:32 -06:00
epi
60a31ce96c old tests back to passing 2021-01-24 07:37:38 -06:00
epi
37a4debf65 fixed race conditions between main and scan handler 2021-01-23 14:08:52 -06:00
epi
33be7d4da3 new scan handler works 2021-01-22 09:01:10 -06:00
epi
63b9d4d93b fixed statistics tests 2021-01-19 16:13:52 -06:00
epi
06dcb1e193 clippy satisfied 2021-01-19 08:35:56 -06:00
epi
1fbda3f91c Merge pull request #196 from tomtastic/patch-1
tiny typo
2021-01-19 08:15:22 -06:00
Tom Matthews
90b0068752 tiny typo 2021-01-19 11:20:46 +00:00
epi
4d8d96c1b7 Update README.md 2021-01-18 07:33:20 -06:00
epi
a9483aef2d Update README.md 2021-01-18 06:56:12 -06:00
epi
5fbf554282 work in progress, incremental save 2021-01-17 13:46:54 -06:00
epi
4ff943fe9f Merge pull request #193 from epi052/2.0.0-extractor
2.0.0 extractor
2021-01-16 14:21:45 -06:00
epi
f313527b46 more nitpickery 2021-01-16 14:17:44 -06:00
epi
d65294c4e2 added docstring to builder 2021-01-16 14:14:18 -06:00
epi
947f1b8a33 clippy satisfied 2021-01-16 11:52:05 -06:00
epi
6b87fb7e0e nitpickery 2021-01-16 11:50:23 -06:00
epi
96b9152c3a removed filters helpers 2021-01-16 09:46:39 -06:00
epi
9a9ab99914 updated extractor tests to hit a few edges 2021-01-16 09:30:42 -06:00
epi
414e71be50 clippy satisfied 2021-01-16 08:11:20 -06:00
epi
269ae86201 extractor restructure mostly done 2021-01-16 08:07:38 -06:00
epi
f03af8056b bumped version to 1.12.3 2021-01-15 10:31:17 -06:00
epi
9d760a0712 fixed banner entry that looked wonky 2021-01-15 10:30:33 -06:00
epi
4b2af18ae2 Merge branch 'master' into 2.0.0-extractor 2021-01-15 07:15:07 -06:00
epi
db25ddfcf3 Merge pull request #192 from epi052/190-fix-double-fslash
fixed url parsing issue when word starts with 2 or more /
2021-01-15 07:04:40 -06:00
epi
02fb4a9cf6 fixed url parsing issue when word starts with 2 or more / 2021-01-15 06:56:44 -06:00
epi
18727c70a3 Merge pull request #189 from epi052/2.0.0-restructure-banner
restructured banner
2021-01-14 15:49:39 -06:00
epi
2fd369b011 broke all traits out into their own module 2021-01-14 15:43:46 -06:00
epi
46eabd25bb restructured banner 2021-01-14 15:00:37 -06:00
epi
4b08a3a36f Merge branch '2.0-overall' into 2.0.0-overhaul 2021-01-14 12:32:19 -06:00
epi
df28827c5d Merge pull request #187 from epi052/2.0-statistics-restructure
2.0 statistics restructure
2021-01-14 12:21:37 -06:00
epi
e7b3c9f7c0 fixed Formatter issue 2021-01-14 11:36:03 -06:00
epi
c301d54083 cleaned up banner code 2021-01-14 11:28:15 -06:00
epi
70a5eed2ee tidied up banner a lot 2021-01-13 21:02:11 -06:00
epi
218be60bc2 fixed closure error 2021-01-13 19:03:35 -06:00
epi
5299fb0aa8 bumped fuzzyhash version to 0.2.1 2021-01-13 16:44:41 -06:00
epi
4b1f1afabc more error handling for statistics 2021-01-13 15:58:39 -06:00
epi
6832cbcdd8 nitpickery and started incorporating anyhow to error handling 2021-01-13 15:52:24 -06:00
epi
6b05fba068 restructured statistics; created event_handlers module 2021-01-13 07:27:37 -06:00
epi
e867898a31 Merge branch 'master' into 2.0-overall 2021-01-13 05:51:50 -06:00
epi
5374d785ae Merge pull request #185 from epi052/2.0-restructure-filters
Restructure filters
2021-01-12 20:24:36 -06:00
epi
3bda77b21b fixed regression with stats bar 2021-01-12 20:17:27 -06:00
epi
5054f6673e bumped version to 1.12.1 2021-01-12 20:08:56 -06:00
epi
dfc0c2ba7f added another test for 403 recursion 2021-01-12 20:06:42 -06:00
epi
d22d8aea51 added test for 403 recursion 2021-01-12 19:23:30 -06:00
epi
1f57f82358 added 403 as a valid recursion target 2021-01-12 14:56:27 -06:00
epi
2efe3bc5b6 fixed shared lib error 2021-01-12 14:35:31 -06:00
epi
03282ed4af Merge branch '2.0-restructure-filters' into 2.0-overall 2021-01-12 12:36:11 -06:00
epi
e2576c8602 bumped version to 2.0.0 2021-01-12 12:33:55 -06:00
epi
5d2b10f859 removed unnecessary file 2021-01-12 12:32:49 -06:00
epi
9bed9930e8 broke filters out into a submodule 2021-01-12 12:31:59 -06:00
epi
eec54343c5 disabled ignored tests 2021-01-11 20:59:02 -06:00
epi
825b36f5da Merge pull request #178 from epi052/107-cancel-scans-interactively
107 cancel scans interactively
2021-01-11 20:47:27 -06:00
epi
97bbbc57e0 added readme image 2021-01-11 20:42:14 -06:00
epi
4869541688 added documentation for scan cancel menu 2021-01-11 20:40:54 -06:00
epi
eb34a1b2b3 removed lint from review 2021-01-11 20:26:42 -06:00
epi
bceafecfa6 fixed stats::save test 2021-01-11 14:59:06 -06:00
epi
5d6d7bbeaa added a few extra tests 2021-01-11 10:19:14 -06:00
epi
c57e4716ae added a few extra tests 2021-01-11 10:17:41 -06:00
epi
4f5786ddeb fixed hanging extractor test 2021-01-11 07:37:27 -06:00
epi
6c5e6d6784 found hanging test that will need fixed 2021-01-10 17:25:33 -06:00
epi
5acbdb4461 finalized menu implementation 2021-01-10 17:22:14 -06:00
epi
adaf8bc098 fixed bug where overall scan bar moved too fast 2021-01-10 10:19:27 -06:00
epi
78f2babf27 added check for pre-existing font file 2021-01-10 10:18:34 -06:00
epi
c6b919b4fd good solution to bars flashing implemented 2021-01-10 08:00:20 -06:00
epi
5b23ce2a24 updated display_scans test 2021-01-09 20:47:21 -06:00
epi
42e3bd22fd added status cases to feroxscan deserialize test 2021-01-09 20:26:42 -06:00
epi
a2b0991da9 added abort test 2021-01-09 20:24:28 -06:00
epi
f2c80b42ed added test for feroxscan display trait 2021-01-09 19:26:51 -06:00
epi
7ea74c8ace cleaned up reporter.rs 2021-01-09 17:01:03 -06:00
epi
8cc39fd10f removed lint 2021-01-09 16:57:56 -06:00
epi
29ad28d3f8 added ignored tests 2021-01-09 16:56:14 -06:00
epi
f6c68614bc added ignored tests 2021-01-09 16:44:52 -06:00
epi
0f9e801cb9 reasonably close to being done; checkpoint 2021-01-09 15:55:08 -06:00
epi
710663ec59 working solution, but think it can be better 2021-01-08 06:26:29 -06:00
epi
dd89705e50 update reqwest to 0.11; bumped version to 1.11.2 2021-01-06 06:42:00 -06:00
epi
8d5e3455f1 merged in master 2021-01-05 20:37:23 -06:00
epi
b11a5eceeb Merge branch 'master' into 107-cancel-scans-interactively 2021-01-05 20:36:54 -06:00
epi
8a3922ee89 merged in master and tokio1.0 branch 2020-12-30 14:52:55 -06:00
epi
ece65450cc Merge branch 'master' into 107-cancel-scans-interactively 2020-12-30 14:05:07 -06:00
epi
704ca02698 using reqwest master branch from github until new version with tokio 1.0 support is released 2020-12-30 14:04:59 -06:00
epi
25c267eb7f prepared for tokio 1.0; crates depending on tokio 0.2 need to update before proceeding 2020-12-24 07:18:15 -06:00
dependabot[bot]
3db0b1b771 Update tokio requirement from 0.2 to 1.0
Updates the requirements on [tokio](https://github.com/tokio-rs/tokio) to permit the latest version.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-0.2.0...tokio-1.0.0)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-24 07:04:43 +00:00
126 changed files with 28751 additions and 8443 deletions

404
.all-contributorsrc Normal file
View File

@@ -0,0 +1,404 @@
{
"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": "narkopolo",
"name": "narkopolo",
"avatar_url": "https://avatars.githubusercontent.com/u/16690056?v=4",
"profile": "https://github.com/narkopolo",
"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"
]
}
],
"contributorsPerLine": 7,
"projectName": "feroxbuster",
"projectOwner": "epi052",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true
}

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)
- [ ] Code is in its own branch
- [ ] Branch name is related to the PR contents
- [ ] PR targets master
- [ ] PR targets main
## Static analysis checks
- [ ] 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
## Documentation
- [ ] 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
- [ ] New code is unit tested

1
.github/stale.yml vendored
View File

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

View File

@@ -4,11 +4,13 @@ on: [push]
jobs:
build-nix:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/main'
strategy:
matrix:
type: [ubuntu-x64, ubuntu-x86]
type: [ubuntu-x64, ubuntu-x86, armv7, aarch64]
include:
- type: ubuntu-x64
os: ubuntu-latest
@@ -22,12 +24,25 @@ jobs:
name: x86-linux-feroxbuster
path: target/i686-unknown-linux-musl/release/feroxbuster
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:
- uses: actions/checkout@v2
- name: Install System Dependencies
run: |
env
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
with:
toolchain: stable
@@ -43,7 +58,7 @@ jobs:
args: --release --target=${{ matrix.target }}
- name: Strip symbols from binary
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
if: matrix.type == 'ubuntu-x64'
run: |
@@ -72,8 +87,10 @@ jobs:
path: ./target/x86_64-unknown-linux-musl/debian/*
build-macos:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
@@ -102,8 +119,10 @@ jobs:
path: x86_64-macos-feroxbuster.tar.gz
build-windows:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/main'
strategy:
matrix:
type: [windows-x64, windows-x86]
@@ -134,4 +153,3 @@ jobs:
with:
name: ${{ matrix.name }}
path: ${{ matrix.path }}

View File

@@ -61,4 +61,4 @@ jobs:
- uses: actions-rs/cargo@v1
with:
command: clippy
args: --all-targets --all-features -- -D warnings -A clippy::deref_addrof
args: --all-targets --all-features -- -D warnings -A clippy::deref_addrof -A clippy::mutex-atomic

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

@@ -23,9 +23,13 @@ jobs:
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/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py
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
@@ -34,3 +38,7 @@ jobs:
file: ./coverage.xml
name: codecov-umbrella
fail_ci_if_error: true
- uses: actions/upload-artifact@v2
with:
name: coverage.xml
path: ./coverage.xml

13
.gitignore vendored
View File

@@ -3,16 +3,15 @@
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# jetbrains metadata folder
.idea/
# vscode metadata folder
.vscode/
# personal feroxbuster config for testing
ferox-config.toml
@@ -25,3 +24,9 @@ lcov_cobertura.py
# dockerignore file that makes it so i can work on the docker config without copying a 4GB manifest or w/e it is
.dockerignore
# state file created during tests
ferox-*.state
# python stuff cuz reasons
Pipfile*

View File

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

2768
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "feroxbuster"
version = "1.11.1"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
version = "2.6.2"
authors = ["Ben 'epi' Risher (@epi052)"]
license = "MIT"
edition = "2018"
edition = "2021"
homepage = "https://github.com/epi052/feroxbuster"
repository = "https://github.com/epi052/feroxbuster"
description = "A fast, simple, recursive content discovery tool."
@@ -16,38 +16,46 @@ build = "build.rs"
maintenance = { status = "actively-developed" }
[build-dependencies]
clap = "2.33"
regex = "1"
lazy_static = "1.4"
clap = { version = "3.1.8", features = ["wrap_help", "cargo"] }
clap_complete = "3.1.1"
regex = "1.5.5"
lazy_static = "1.4.0"
dirs = "4.0.0"
[dependencies]
futures = { version = "0.3"}
tokio = { version = "0.2", features = ["full"] }
tokio-util = {version = "0.3", features = ["codec"]}
log = "0.4"
env_logger = "0.8"
reqwest = { version = "0.10", features = ["socks"] }
clap = "2.33"
lazy_static = "1.4"
toml = "0.5"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
uuid = { version = "0.8", features = ["v4"] }
scraper = "0.12.0"
futures = "0.3.21"
tokio = { version = "1.17.0", features = ["full"] }
tokio-util = { version = "0.7.1", features = ["codec"] }
log = "0.4.16"
env_logger = "0.9.0"
reqwest = { version = "0.11.10", features = ["socks"] }
# uses feature unification to add 'serde' to reqwest::Url
url = { version = "2.2.2", features = ["serde"] }
serde_regex = "1.1.0"
clap = { version = "3.1.8", features = ["wrap_help", "cargo"] }
lazy_static = "1.4.0"
toml = "0.5.8"
serde = { version = "1.0.136", features = ["derive", "rc"] }
serde_json = "1.0.79"
uuid = { version = "0.8.2", features = ["v4"] }
indicatif = "0.15"
console = "0.14"
openssl = { version = "0.10", features = ["vendored"] }
dirs = "3.0"
regex = "1"
crossterm = "0.19"
rlimit = "0.5"
ctrlc = "3.1"
fuzzyhash = "0.2"
console = "0.15.0"
openssl = { version = "0.10.38", features = ["vendored"] }
dirs = "4.0.0"
regex = "1.5.5"
crossterm = "0.23.2"
rlimit = "0.8.3"
ctrlc = "3.2.1"
fuzzyhash = "0.2.1"
anyhow = "1.0.56"
leaky-bucket = "0.10.0" # todo: upgrade, will take a little work/thought since api changed
[dev-dependencies]
tempfile = "3.1"
httpmock = "0.5.2"
assert_cmd = "1.0.1"
predicates = "1.0.5"
tempfile = "3.3.0"
httpmock = "0.6.6"
assert_cmd = "2.0.4"
predicates = "2.1.1"
[profile.release]
lto = true
@@ -61,4 +69,7 @@ conf-files = ["/etc/feroxbuster/ferox-config.toml"]
assets = [
["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,28 @@
FROM alpine:latest
# Image: alpine:3.14.2
FROM alpine@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a as build
LABEL maintainer="wfnintr@null.net"
RUN sed -i -e 's/v[[:digit:]]\..*\//edge\//g' /etc/apk/repositories && apk upgrade --update-cache --available
RUN sed -i -e 's/v[[:digit:]]\..*\//edge\//g' /etc/apk/repositories \
&& apk upgrade --update-cache --available && apk add --update openssl
# download default wordlists
RUN apk add --no-cache --virtual .depends subversion font-noto-emoji && \
svn export https://github.com/danielmiessler/SecLists/trunk/Discovery/Web-Content /usr/share/seclists/Discovery/Web-Content && \
apk del .depends
# install latest 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
# Download latest release
RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip \
&& unzip -d /tmp/ feroxbuster.zip feroxbuster \
&& 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
# Image: alpine:3.14.2
FROM alpine@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a as release
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"]

79
Makefile Normal file
View File

@@ -0,0 +1,79 @@
default_prefix = /usr/local
prefix ?= $(default_prefix)
exec_prefix = $(prefix)
bindir = $(exec_prefix)/bin
datarootdir = $(prefix)/share
datadir = $(datarootdir)
example_config = ferox-config.toml.example
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
RELEASE = debug
DEBUG ?= 0
ifeq (0, $(DEBUG))
ARGS = --release
RELEASE = release
endif
VENDORED ?= 0
ifeq (1,$(VENDORED))
ARGS += --frozen
endif
TARGET = target/$(RELEASE)
.PHONY: all clean install uninstall test update
all: cli
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:
cargo clean
vendor: vendor.tar
vendor.tar:
cargo vendor
tar pcf vendor.tar vendor
rm -rf vendor
install-cli: cli
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 "$(example_config)" "$(DESTDIR)/etc/$(BIN)/$(config_file)"
uninstall:
rm -f "$(DESTDIR)$(bindir)/$(BIN)"
rm -f "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz"
rm -rf "$(DESTDIR)/etc/$(BIN)/"
rm -f "$(DESTDIR)/usr/share/bash-completion/completions/$(BIN).bash"
rm -f "$(DESTDIR)/usr/share/zsh/vendor-completions/_$(BIN)"
rm -f "$(DESTDIR)/usr/share/fish/completions/$(BIN).fish"
extract:
ifeq (1, $(VENDORED))
tar pxf vendor.tar
endif
$(TARGET)/$(BIN): extract
mkdir -p .cargo
cp debian/cargo.config .cargo/config.toml
cargo build $(ARGS)
$(TARGET)/$(BIN).1.gz: $(TARGET)/$(BIN)
help2man --no-info $< | gzip -c > $@.partial
mv $@.partial $@

25
Makefile.toml Normal file
View File

@@ -0,0 +1,25 @@
# 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", "leaky-bucket"]
[tasks.update]
command = "cargo"
args = ["update"]
# clippy / lint
[tasks.clippy]
clear = true
script = """
cargo clippy --all-targets --all-features -- -D warnings
"""

931
README.md
View File

@@ -8,7 +8,7 @@
<p align="center">
<a href="https://github.com/epi052/feroxbuster/actions?query=workflow%3A%22CI+Pipeline%22">
<img src="https://img.shields.io/github/workflow/status/epi052/feroxbuster/CI%20Pipeline/master?logo=github">
<img src="https://img.shields.io/github/workflow/status/epi052/feroxbuster/CI%20Pipeline/main?logo=github">
</a>
<a href="https://github.com/epi052/feroxbuster/releases">
@@ -30,6 +30,14 @@
<a href="https://codecov.io/gh/epi052/feroxbuster">
<img src="https://codecov.io/gh/epi052/feroxbuster/branch/master/graph/badge.svg" />
</a>
<!--
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section
[![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
<a href="https://github.com/epi052/feroxbuster/graphs/contributors">
<img src="https://img.shields.io/badge/all_contributors-31-orange.svg" />
</a>
</p>
![demo](img/demo.gif)
@@ -37,12 +45,21 @@
<p align="center">
🦀
<a href="https://github.com/epi052/feroxbuster/releases">Releases</a> ✨
<a href="#-example-usage">Example Usage</a> ✨
<a href="https://github.com/epi052/feroxbuster/blob/master/CONTRIBUTING.md">Contributing</a> ✨
<a href="https://docs.rs/feroxbuster/latest/feroxbuster/">Documentation</a>
<a href="https://epi052.github.io/feroxbuster-docs/docs/examples/">Example Usage</a> ✨
<a href="https://github.com/epi052/feroxbuster/blob/main/CONTRIBUTING.md">Contributing</a> ✨
<a href="https://epi052.github.io/feroxbuster-docs/docs/">Documentation</a>
🦀
</p>
---
<h1><p align="center">✨🎉👉 <a href="https://epi052.github.io/feroxbuster-docs/docs/">NEW DOCUMENTATION SITE</a> 👈🎉✨</p></h1>
## 🚀 Documentation has **moved** 🚀
Instead of having a 1300 line `README.md` (sorry...), feroxbuster's documentation has moved to GitHub Pages. The move to hosting documentation on Pages should make it a LOT easier to find the information you're looking for, whatever that may be. Please check it out for anything you need beyond a quick-start. The new documentation can be found [here](https://epi052.github.io/feroxbuster-docs/docs/).
## 😕 What the heck is a ferox anyway?
Ferox is short for Ferric Oxide. Ferric Oxide, simply put, is rust. The name rustbuster was taken, so I decided on a
@@ -62,59 +79,21 @@ credentials, internal network addressing, etc...
This attack is also known as Predictable Resource Location, File Enumeration, Directory Enumeration, and Resource
Enumeration.
📖 Table of Contents
-----------------
## ⏳ Quick Start
- [Installation](#-installation)
- [Download a Release](#download-a-release)
- [Snap Install](#snap-install)
- [Homebrew on MacOS and Linux](#homebrew-on-macos-and-linux)
- [Cargo Install](#cargo-install)
- [apt Install](#apt-install)
- [AUR Install](#aur-install)
- [Docker Install](#docker-install)
- [Configuration](#%EF%B8%8F-configuration)
- [Default Values](#default-values)
- [Threads and Connection Limits At A High-Level](#threads-and-connection-limits-at-a-high-level)
- [ferox-config.toml](#ferox-configtoml)
- [Command Line Parsing](#command-line-parsing)
- [Scan's Display Explained](#-scans-display-explained)
- [Discovered Resource](#discovered-resource)
- [Overall Scan Progress Bar](#overall-scan-progress-bar)
- [Directory Scan Progress Bar](#directory-scan-progress-bar)
- [Example Usage](#-example-usage)
- [Multiple Values](#multiple-values)
- [Include Headers](#include-headers)
- [IPv6, Non-recursive scan with INFO logging enabled](#ipv6-non-recursive-scan-with-info-level-logging-enabled)
- [Read urls from STDIN; pipe only resulting urls out to another tool](#read-urls-from-stdin-pipe-only-resulting-urls-out-to-another-tool)
- [Proxy traffic through Burp](#proxy-traffic-through-burp)
- [Proxy traffic through a SOCKS proxy (including DNS lookups)](#proxy-traffic-through-a-socks-proxy-including-dns-lookups)
- [Pass auth token via query parameter](#pass-auth-token-via-query-parameter)
- [Extract Links from Response Body (new in `v1.1.0`)](#extract-links-from-response-body-new-in-v110)
- [Limit Total Number of Concurrent Scans (new in `v1.2.0`)](#limit-total-number-of-concurrent-scans-new-in-v120)
- [Filter Response by Status Code (new in `v1.3.0`)](#filter-response-by-status-code--new-in-v130)
- [Pause an Active Scan (new in `v1.4.0`)](#pause-an-active-scan-new-in-v140)
- [Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)](#replay-responses-to-a-proxy-based-on-status-code-new-in-v150)
- [Filter Response by Word Count & Line Count (new in `v1.6.0`)](#filter-response-by-word-count--line-count--new-in-v160)
- [Filter Response Using a Regular Expression (new in `v1.8.0`)](#filter-response-using-a-regular-expression-new-in-v180)
- [Stop and Resume Scans (save scan's state to disk) (new in `v1.9.0`)](#stop-and-resume-scans---resume-from-file-new-in-v190)
- [Enforce a Time Limit on Your Scan (new in `v1.10.0`)](#enforce-a-time-limit-on-your-scan-new-in-v1100)
- [Extract Links from robots.txt (New in `v1.10.2`)](#extract-links-from-robotstxt-new-in-v1102)
- [Filter Response by Similarity to A Given Page (fuzzy filter) (new in `v1.11.0`)](#filter-response-by-similarity-to-a-given-page-fuzzy-filter-new-in-v1110)
- [Comparison w/ Similar Tools](#-comparison-w-similar-tools)
- [Common Problems/Issues (FAQ)](#-common-problemsissues-faq)
- [No file descriptors available](#no-file-descriptors-available)
- [Progress bars print one line at a time](#progress-bars-print-one-line-at-a-time)
- [What do each of the numbers beside the URL mean?](#what-do-each-of-the-numbers-beside-the-url-mean)
- [Connection closed before message completed](#connection-closed-before-message-completed)
- [SSL Error routines:tls_process_server_certificate:certificate verify failed](#ssl-error-routinestls_process_server_certificatecertificate-verify-failed)
This section will cover the minimum amount of information to get up and running with feroxbuster. Please refer the the [documentation](https://epi052.github.io/feroxbuster-docs/docs/), as it's much more comprehensive.
## 💿 Installation
### 💿 Installation
### Download a Release
There are quite a few other [installation methods](https://epi052.github.io/feroxbuster-docs/docs/installation/), but these snippets should cover the majority of users.
Releases for multiple architectures can be found in the [Releases](https://github.com/epi052/feroxbuster/releases)
section. The latest release for each of the following systems can be downloaded and executed as shown below.
#### Kali
If you're using kali, this is the preferred install method. Installing from the repos adds a [**ferox-config.toml**](https://epi052.github.io/feroxbuster-docs/docs/configuration/ferox-config-toml/) in `/etc/feroxbuster/`, adds command completion for bash, fish, and zsh, includes a man page entry, and installs `feroxbuster` itself.
```
sudo apt update && sudo apt install -y feroxbuster
```
#### Linux (32 and 64-bit) & MacOS
@@ -122,13 +101,6 @@ section. The latest release for each of the following systems can be downloaded
curl -sL https://raw.githubusercontent.com/epi052/feroxbuster/master/install-nix.sh | bash
```
#### Windows x86
```
https://github.com/epi052/feroxbuster/releases/latest/download/x86-windows-feroxbuster.exe.zip
Expand-Archive .\feroxbuster.zip
.\feroxbuster\feroxbuster.exe -V
```
#### Windows x86_64
@@ -138,359 +110,14 @@ Expand-Archive .\feroxbuster.zip
.\feroxbuster\feroxbuster.exe -V
```
### Snap Install
#### All others
Install using `snap`
```
sudo snap install feroxbuster
```
The only gotcha here is that the snap package can only read wordlists from a few specific locations. There are a few
possible solutions, of which two are shown below.
If the wordlist is on the same partition as your home directory, it can be hard-linked into `~/snap/feroxbuster/common`
```
ln /path/to/the/wordlist ~/snap/feroxbuster/common
./feroxbuster -u http://localhost -w ~/snap/feroxbuster/common/wordlist
```
If the wordlist is on a separate partition, hard-linking won't work. You'll need to copy it into the snap directory.
```
cp /path/to/the/wordlist ~/snap/feroxbuster/common
./feroxbuster -u http://localhost -w ~/snap/feroxbuster/common/wordlist
```
### Homebrew on MacOS and Linux
Install using Homebrew via tap
🍏 [MacOS](https://github.com/TGotwig/homebrew-feroxbuster/blob/main/feroxbuster.rb)
```shell
brew tap tgotwig/feroxbuster
brew install feroxbuster
```
🐧 [Linux](https://github.com/TGotwig/homebrew-linux-feroxbuster/blob/main/feroxbuster.rb)
```shell
brew tap tgotwig/linux-feroxbuster
brew install feroxbuster
```
### Cargo Install
`feroxbuster` is published on crates.io, making it easy to install if you already have rust installed on your system.
```
cargo install feroxbuster
```
### apt Install
Download `feroxbuster_amd64.deb` from the [Releases](https://github.com/epi052/feroxbuster/releases) section. After
that, use your favorite package manager to install the `.deb`.
```
wget -sLO https://github.com/epi052/feroxbuster/releases/latest/download/feroxbuster_amd64.deb.zip
unzip feroxbuster_amd64.deb.zip
sudo apt install ./feroxbuster_amd64.deb
```
### AUR Install
Install `feroxbuster-git` on Arch Linux with your AUR helper of choice:
```
yay -S feroxbuster-git
```
### Docker Install
> The following steps assume you have docker installed / setup
First, clone the repository.
```
git clone https://github.com/epi052/feroxbuster.git
cd feroxbuster
```
Next, build the image.
```
sudo docker build -t feroxbuster .
```
After that, you should be able to use `docker run` to perform scans with `feroxbuster`.
#### Basic usage
```
sudo docker run --init -it feroxbuster -u http://example.com -x js,html
```
#### Piping from stdin and proxying all requests through socks5 proxy
```
cat targets.txt | sudo docker run --net=host --init -i feroxbuster --stdin -x js,html --proxy socks5://127.0.0.1:9050
```
#### Mount a volume to pass in `ferox-config.toml`
You've got some options available if you want to pass in a config file. [`ferox-buster.toml`](#ferox-configtoml) can
live in multiple locations and still be valid, so it's up to you how you'd like to pass it in. Below are a few valid
examples:
```
sudo docker run --init -v $(pwd)/ferox-config.toml:/etc/feroxbuster/ferox-config.toml -it feroxbuster -u http://example.com
```
```
sudo docker run --init -v ~/.config/feroxbuster:/root/.config/feroxbuster -it feroxbuster -u http://example.com
```
Note: If you are on a SELinux enforced system, you will need to pass the `:Z` attribute also.
```
docker run --init -v (pwd)/ferox-config.toml:/etc/feroxbuster/ferox-config.toml:Z -it feroxbuster -u http://example.com
```
#### Define an alias for simplicity
```
alias feroxbuster="sudo docker run --init -v ~/.config/feroxbuster:/root/.config/feroxbuster -i feroxbuster"
```
## ⚙️ Configuration
### Default Values
Configuration begins with with the following built-in default values baked into the binary:
- timeout: `7` seconds
- follow redirects: `false`
- wordlist: `/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt`
- threads: `50`
- verbosity: `0` (no logging enabled)
- scan_limit: `0` (no limit imposed on concurrent scans)
- status_codes: `200 204 301 302 307 308 401 403 405`
- user_agent: `feroxbuster/VERSION`
- recursion depth: `4`
- auto-filter wildcards - `true`
- output: `stdout`
- save_state: `true` (create a state file in cwd when `Ctrl+C` is received)
### Threads and Connection Limits At A High-Level
This section explains how the `-t` and `-L` options work together to determine the overall aggressiveness of a scan. The
combination of the two values set by these options determines how hard your target will get hit and to some extent also
determines how many resources will be consumed on your local machine.
#### A Note on Green Threads
`feroxbuster` uses so-called [green threads](https://en.wikipedia.org/wiki/Green_threads) as opposed to traditional
kernel/OS threads. This means (at a high-level) that the threads are implemented entirely in userspace, within a single
running process. As a result, a scan with 30 green threads will appear to the OS to be a single process with no
additional light-weight processes associated with it as far as the kernel is concerned. As such, there will not be any
impact to process (`nproc`) limits when specifying larger values for `-t`. However, these threads will still consume
file descriptors, so you will need to ensure that you have a suitable `nlimit` set when scaling up the amount of
threads. More detailed documentation on setting appropriate `nlimit` values can be found in
the [No File Descriptors Available](#no-file-descriptors-available) section of the FAQ
#### Threads and Connection Limits: The Implementation
* Threads: The `-t` option specifies the maximum amount of active threads *per-directory* during a scan
* Connection Limits: The `-L` option specifies the maximum amount of active connections per thread
#### Threads and Connection Limits: Examples
To truly have only 30 active requests to a site at any given time, `-t 30 -L 1` is necessary. Using `-t 30 -L 2` will
result in a maximum of 60 total requests being processed at any given time for that site. And so on. For a conversation
on this, please see [Issue #126](https://github.com/epi052/feroxbuster/issues/126) which may provide more (or less)
clarity :wink:
### ferox-config.toml
After setting built-in default values, any values defined in a `ferox-config.toml` config file will override the
built-in defaults.
`feroxbuster` searches for `ferox-config.toml` in the following locations (in the order shown):
- `/etc/feroxbuster/` (global)
- `CONFIG_DIR/ferxobuster/` (per-user)
- The same directory as the `feroxbuster` executable (per-user)
- The user's current working directory (per-target)
> `CONFIG_DIR` is defined as the following:
> - Linux: `$XDG_CONFIG_HOME` or `$HOME/.config` i.e. `/home/bob/.config`
> - MacOs: `$HOME/Library/Application Support` i.e. `/Users/bob/Library/Application Support`
> - Windows: `{FOLDERID_RoamingAppData}` i.e. `C:\Users\Bob\AppData\Roaming`
If more than one valid configuration file is found, each one overwrites the values found previously.
If no configuration file is found, nothing happens at this stage.
As an example, let's say that we prefer to use a different wordlist as our default when scanning; we can set
the `wordlist` value in the config file to override the baked-in default.
Notes of interest:
- it's ok to only specify values you want to change without specifying anything else
- variable names in `ferox-config.toml` must match their command-line counterpart
```toml
# ferox-config.toml
wordlist = "/wordlists/jhaddix/all.txt"
```
A pre-made configuration file with examples of all available settings can be found in `ferox-config.toml.example`.
```toml
# ferox-config.toml
# Example configuration for feroxbuster
#
# If you wish to provide persistent settings to feroxbuster, rename this file to ferox-config.toml and make sure
# it resides in the same directory as the feroxbuster binary.
#
# After that, uncomment any line to override the default value provided by the binary itself.
#
# Any setting used here can be overridden by the corresponding command line option/argument
#
# wordlist = "/wordlists/jhaddix/all.txt"
# status_codes = [200, 500]
# filter_status = [301]
# threads = 1
# timeout = 5
# proxy = "http://127.0.0.1:8080"
# replay_proxy = "http://127.0.0.1:8081"
# replay_codes = [200, 302]
# verbosity = 1
# scan_limit = 6
# quiet = true
# json = true
# output = "/targets/ellingson_mineral_company/gibson.txt"
# 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"
# redirects = true
# insecure = true
# extensions = ["php", "html"]
# no_recursion = true
# add_slash = true
# stdin = true
# dont_filter = true
# extract_links = true
# depth = 1
# filter_size = [5174]
# filter_regex = ["^ignore me$"]
# filter_similar = ["https://somesite.com/soft404"]
# filter_word_count = [993]
# filter_line_count = [35, 36]
# queries = [["name","value"], ["rick", "astley"]]
# save_state = false
# time_limit = 10m
# headers can be specified on multiple lines or as an inline table
#
# inline example
# headers = {"stuff" = "things"}
#
# multi-line example
# note: if multi-line is used, all key/value pairs under it belong to the headers table until the next table
# is found or the end of the file is reached
#
# [headers]
# stuff = "things"
# more = "headers"
```
### Command Line Parsing
Finally, after parsing the available config file, any options/arguments given on the commandline will override any
values that were set as a built-in or config-file value.
```
USAGE:
feroxbuster [FLAGS] [OPTIONS] --url <URL>...
FLAGS:
-f, --add-slash Append / to each request
-D, --dont-filter Don't auto-filter wildcard responses
-e, --extract-links Extract links from response body (html, javascript, etc...); make new requests based on
findings (default: false)
-h, --help Prints help information
-k, --insecure Disables TLS certificate validation
--json Emit JSON logs to --output and --debug-log instead of normal text
-n, --no-recursion Do not scan recursively
-q, --quiet Only print URLs; Don't print status codes, response size, running config, etc...
-r, --redirects Follow redirects
--stdin Read url(s) from STDIN
-V, --version Prints version information
-v, --verbosity Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably
too much)
OPTIONS:
--debug-log <FILE> Output file to write log entries (use w/ --json for JSON entries)
-d, --depth <RECURSION_DEPTH> Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)
-x, --extensions <FILE_EXTENSION>... File extension(s) to search for (ex: -x php -x pdf js)
-N, --filter-lines <LINES>... Filter out messages of a particular line count (ex: -N 20 -N 31,30)
-X, --filter-regex <REGEX>... Filter out messages via regular expression matching on the response's body
(ex: -X '^ignore me$')
-S, --filter-size <SIZE>... Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)
-C, --filter-status <STATUS_CODE>... Filter out status codes (deny list) (ex: -C 200 -C 401)
-W, --filter-words <WORDS>... Filter out messages of a particular word count (ex: -W 312 -W 91,82)
-H, --headers <HEADER>... Specify HTTP headers (ex: -H Header:val 'stuff: things')
-o, --output <FILE> Output file to write results to (use w/ --json for JSON entries)
-p, --proxy <PROXY> Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)
-Q, --query <QUERY>... Specify URL query parameters (ex: -Q token=stuff -Q secret=key)
-R, --replay-codes <REPLAY_CODE>... Status Codes to send through a Replay Proxy when found (default: --status-
codes value)
-P, --replay-proxy <REPLAY_PROXY> Send only unfiltered requests through a Replay Proxy, instead of all
requests
--resume-from <STATE_FILE> State file from which to resume a partially complete scan (ex. --resume-from
ferox-1606586780.state)
-L, --scan-limit <SCAN_LIMIT> Limit total number of concurrent scans (default: 0, i.e. no limit)
-s, --status-codes <STATUS_CODE>... Status Codes to include (allow list) (default: 200 204 301 302 307 308 401
403 405)
-t, --threads <THREADS> Number of concurrent threads (default: 50)
--time-limit <TIME_SPEC> Limit total run time of all scans (ex: --time-limit 10m)
-T, --timeout <SECONDS> Number of seconds before a request times out (default: 7)
-u, --url <URL>... The target URL(s) (required, unless --stdin used)
-a, --user-agent <USER_AGENT> Sets the User-Agent (default: feroxbuster/VERSION)
-w, --wordlist <FILE> Path to the wordlist
```
## 📊 Scan's Display Explained
`feroxbuster` attempts to be intuitive and easy to understand, however, if you are wondering about any of the scan's
output and what it means, this is the section for you!
### Discovered Resource
When `feroxbuster` finds a response that you haven't filtered out, it's reported above the progress bars and looks similar to what's pictured below.
The number of lines, words, and bytes shown here can be used to [filter those responses](#filter-response-by-word-count--line-count--new-in-v160)
![response-bar-explained](img/response-bar-explained.png)
### Overall Scan Progress Bar
The top progress bar, colored yellow, tracks the overall scan status. Its fields are described in the image below.
![total-bar-explained](img/total-bar-explained.png)
### Directory Scan Progress Bar
All other progress bars, colored cyan, represent a scan of one particular directory and will look similar to what's below.
![dir-scan-bar-explained](img/dir-scan-bar-explained.png)
Please refer the the [documentation](https://epi052.github.io/feroxbuster-docs/docs/).
## 🧰 Example Usage
Here are a few brief examples to get you started. Please note, feroxbuster can do a **lot more** than what's listed below. As a result, there are **many more** examples, with **demonstration gifs** that highlight specific features, in the [documentation](https://epi052.github.io/feroxbuster-docs/docs/).
### Multiple Values
Options that take multiple values are very flexible. Consider the following ways of specifying extensions:
@@ -519,7 +146,7 @@ same goes for urls, headers, status codes, queries, and size filters.
### Read urls from STDIN; pipe only resulting urls out to another tool
```
cat targets | ./feroxbuster --stdin --quiet -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
@@ -540,411 +167,79 @@ cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | f
./feroxbuster -u http://127.1 --query token=0123456789ABCDEF
```
### Extract Links from Response Body (New in `v1.1.0`)
Search through the body of valid responses (html, javascript, etc...) for additional endpoints to scan. This turns
`feroxbuster` into a hybrid that looks for both linked and unlinked content.
Example request/response with `--extract-links` enabled:
- Make request to `http://example.com/index.html`
- Receive, and read in, the `body` of the response
- Search the `body` for absolute and relative links (i.e. `homepage/assets/img/icons/handshake.svg`)
- Add the following directories for recursive scanning:
- `http://example.com/homepage`
- `http://example.com/homepage/assets`
- `http://example.com/homepage/assets/img`
- `http://example.com/homepage/assets/img/icons`
- Make a single request to `http://example.com/homepage/assets/img/icons/handshake.svg`
```
./feroxbuster -u http://127.1 --extract-links
```
Here's a comparison of a wordlist-only scan vs `--extract-links`
using [Feline](https://www.hackthebox.eu/home/machines/profile/274) from Hack the Box:
Wordlist only
![normal-scan-cmp-extract](img/normal-scan-cmp-extract.gif)
With `--extract-links`
![extract-scan-cmp-normal](img/extract-scan-cmp-normal.gif)
### Limit Total Number of Concurrent Scans (new in `v1.2.0`)
Limit the number of scans permitted to run at any given time. Recursion will still identify new directories, but newly
discovered directories can only begin scanning when the total number of active scans drops below the value passed to
`--scan-limit`.
```
./feroxbuster -u http://127.1 --scan-limit 2
```
![limit-demo](img/limit-demo.gif)
### Filter Response by Status Code (new in `v1.3.0`)
Version 1.3.0 included an overhaul to the filtering system which will allow for a wide array of filters to be added with
minimal effort. The first such filter is a Status Code Filter. As responses come back from the scanned server, each one
is checked against a list of known filters and either displayed or not according to which filters are set.
```
./feroxbuster -u http://127.1 --filter-status 301
```
### Pause an Active Scan (new in `v1.4.0`)
Scans can be paused and resumed by pressing the ENTER key (shown below)
![pause-resume-demo](img/pause-resume-demo.gif)
### Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)
The `--replay-proxy` and `--replay-codes` options were added as a way to only send a select few responses to a proxy.
This is in stark contrast to `--proxy` which proxies EVERY request.
Imagine you only care about proxying responses that have either the status code `200` or `302` (or you just don't want
to clutter up your Burp history). These two options will allow you to fine-tune what gets proxied and what doesn't.
```
./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
```
Of note: this means that for every response that matches your replay criteria, you'll end up sending the request that
generated that response a second time. Depending on the target and your engagement terms (if any), it may not make sense
from a traffic generated perspective.
![replay-proxy-demo](img/replay-proxy-demo.gif)
### Filter Response by Word Count & Line Count (new in `v1.6.0`)
In addition to filtering on the size of a response, version 1.6.0 added the ability to filter out responses based on the
number of lines and/or words contained within the response body. This change drove a change to the information displayed
to the user as well. This section will detail the new information and how to make use of it with the new filters
provided.
Example output:
```
200 10l 212w 38437c https://example-site.com/index.html
```
There are five columns of output above:
- column 1: status code - can be filtered with `-C|--filter-status`
- column 2: number of lines - can be filtered with `-N|--filter-lines`
- column 3: number of words - can be filtered with `-W|--filter-words`
- column 4: number of bytes (overall size) - can be filtered with `-S|--filter-size`
- column 5: url to discovered resource
### Filter Response Using a Regular Expression (new in `v1.8.0`)
Version 1.3.0 included an overhaul to the filtering system which will allow for a wide array of filters to be added with
minimal effort. The latest addition is a Regular Expression Filter. As responses come back from the scanned server,
the **body** of the response is checked against the filter's regular expression. If the expression is found in the body,
then that response is filtered out.
**NOTE: Using regular expressions to filter large responses or many regular expressions may negatively impact
performance.**
```
./feroxbuster -u http://127.1 --filter-regex '[aA]ccess [dD]enied.?' --output results.txt --json
```
### Stop and Resume Scans (`--resume-from FILE`) (new in `v1.9.0`)
Version 1.9.0 adds a few features that allow for completely stopping a scan, and resuming that same scan from a file on
disk.
A simple `Ctrl+C` during a scan will create a file that contains information about the scan that was cancelled.
![save-state](img/save-state.png)
```json
// example snippet of state file
{
"scans": [
{
"id": "057016a14769414aac9a7a62707598cb",
"url": "https://localhost.com",
"scan_type": "Directory",
"complete": true
},
{
"id": "400b2323a16f43468a04ffcbbeba34c6",
"url": "https://localhost.com/css",
"scan_type": "Directory",
"complete": false
}
],
"config": {
"wordlist": "/wordlists/seclists/Discovery/Web-Content/common.txt",
"...": "..."
},
"responses": [
{
"type": "response",
"url": "https://localhost.com/Login",
"path": "/Login",
"wildcard": false,
"status": 302,
"content_length": 0,
"line_count": 0,
"word_count": 0,
"headers": {
"content-length": "0",
"server": "nginx/1.16.1"
}
}
]
},
```
Based on the example image above, the same scan can be resumed by
using `feroxbuster --resume-from ferox-http_localhost-1606947491.state`. Directories that were already complete are not
rescanned, however partially complete scans are started from the beginning.
![resumed-scan](img/resumed-scan.gif)
In order to prevent state file creation when `Ctrl+C` is pressed, you can simply add the entry below to
your `ferox-config.toml`.
```toml
# ferox-config.toml
save_state = false
```
### Enforce a Time Limit on Your Scan (new in `v1.10.0`)
Version 1.10.0 adds the ability to set a maximum runtime, or time limit, on your scan. The usage is pretty simple: a
number followed directly by a single character representing seconds, minutes, hours, or days. `feroxbuster` refers to
this combination as a time_spec.
Examples of possible time_specs:
- `30s` - 30 seconds
- `20m` - 20 minutes
- `1h` - 1 hour
- `1d` - 1 day (why??)
A valid time_spec can be passed to `--time-limit` in order to force a shutdown after the given time has elapsed.
![time-limit](img/time-limit.gif)
### Extract Links from robots.txt (New in `v1.10.2`)
In addition to [extracting links from the response body](#extract-links-from-response-body-new-in-v110), using
`--extract-links` makes a request to `/robots.txt` and examines all `Allow` and `Disallow` entries. Directory entries
are added to the scan queue, while file entries are requested and then reported if appropriate.
### Filter Response by Similarity to A Given Page (fuzzy filter) (new in `v1.11.0`)
Version 1.11.0 adds the ability to specify an example page for filtering pages that are similar to the given example.
For example, consider a site that attempts to redirect new users to a `/register` endpoint. The `/register` page has a
CSRF token that alters the page's response slightly with each new request (sometimes affecting overall length). This
means that a simple line/word/char filter won't be able to filter all responses. In order to filter those redirects out,
one could use a command like this:
```
./feroxbuster -u https://somesite.xyz --filter-similar-to https://somesite.xyz/register
```
`--filter-similar-to` requests the page passed to it via CLI (`https://somesite.xyz/register`), after which it hashes
the response body using the [SSDeep algorithm](https://ssdeep-project.github.io/ssdeep/index.html). All subsequent
pages are hashed and compared to the original request's hash. If the comparison of the two hashes meets a certain
percentage of similarity (currently 95%), then that request will be filtered out.
SSDeep was selected as it does a good job of identifying near-duplicate pages once content-length reaches a certain
size, while remaining performant. Other algorithms were tested but resulted in huge performance hits (orders of
magnitude slower on requests/second).
**NOTE**
- SSDeep/`--filter-similar-to` does not do well at detecting similarity of very small responses
- The lack of accuracy with very small responses is considered a fair trade-off for not negatively impacting performance
- Using a bunch of `--filter-similar-to` values **may** negatively impact performance
## 🧐 Comparison w/ Similar Tools
There are quite a few similar tools for forced browsing/content discovery. Burp Suite Pro, Dirb, Dirbuster, etc...
However, in my opinion, there are two that set the standard: [gobuster](https://github.com/OJ/gobuster) and
[ffuf](https://github.com/ffuf/ffuf). Both are mature, feature-rich, and all-around incredible tools to use.
So, why would you ever want to use feroxbuster over ffuf/gobuster? In most cases, you probably won't. ffuf in particular
can do the vast majority of things that feroxbuster can, while still offering boatloads more functionality. Here are a
few of the use-cases in which feroxbuster may be a better fit:
- You want a **simple** tool usage experience
- You want to be able to run your content discovery as part of some crazy 12 command unix **pipeline extravaganza**
- You want to scan through a **SOCKS** proxy
- You want **auto-filtering** of Wildcard responses by default
- You want an integrated **link extractor/robots.txt parser** to increase discovered endpoints
- You want **recursion** along with some other thing mentioned above (ffuf also does recursion)
- You want a **configuration file** option for overriding built-in default values for your scans
| | feroxbuster | gobuster | ffuf |
|------------------------------------------------------------------------------|---|---|---|
| fast | ✔ | ✔ | ✔ |
| easy to use | ✔ | ✔ | |
| allows recursion | ✔ | | ✔ |
| can specify query parameters | ✔ | | ✔ |
| SOCKS proxy support | ✔ | | |
| multiple target scan (via stdin or multiple -u) | ✔ | | ✔ |
| configuration file for default value override | ✔ | | ✔ |
| can accept urls via STDIN as part of a pipeline | ✔ | | ✔ |
| can accept wordlists via STDIN | | ✔ | ✔ |
| filter based on response size, wordcount, and linecount | ✔ | | ✔ |
| auto-filter wildcard responses | ✔ | | ✔ |
| performs other scans (vhost, dns, etc) | | ✔ | ✔ |
| time delay / rate limiting | | ✔ | ✔ |
| extracts links from response body to increase scan coverage (`v1.1.0`) | ✔ | | |
| limit number of concurrent recursive scans (`v1.2.0`) | ✔ | | |
| filter out responses by status code (`v1.3.0`) | ✔ | ✔ | ✔ |
| interactive pause and resume of active scan (`v1.4.0`) | ✔ | | |
| replay only matched requests to a proxy (`v1.5.0`) | ✔ | | ✔ |
| filter out responses by line & word count (`v1.6.0`) | ✔ | | ✔ |
| json output (ffuf supports other formats as well) (`v1.7.0`) | ✔ | | ✔ |
| filter out responses by regular expression (`v1.8.0`) | ✔ | | ✔ |
| save scan's state to disk (can pick up where it left off) (`v1.9.0`) | ✔ | | |
| maximum run time limit (`v1.10.0`) | ✔ | | ✔ |
| use robots.txt to increase scan coverage (`v1.10.2`) | ✔ | | |
| use example page's response to fuzzily filter similar pages (`v1.11.0`) | ✔ | | |
| **huge** number of other options | | | ✔ |
Of note, there's another written-in-rust content discovery tool, [rustbuster](https://github.com/phra/rustbuster). I
came across rustbuster when I was naming my tool (😢). I don't have any experience using it, but it appears to be able
to do POST requests with an HTTP body, has SOCKS support, and has an 8.3 shortname scanner (in addition to vhost dns,
directory, etc...). In short, it definitely looks interesting and may be what you're looking for as it has some
capability I haven't seen in similar tools.
## 🤯 Common Problems/Issues (FAQ)
### No file descriptors available
Why do I get a bunch of `No file descriptors available (os error 24)` errors?
---
There are a few potential causes of this error. The simplest is that your operating system sets an open file limit that
is aggressively low. Through personal testing, I've found that `4096` is a reasonable open file limit (this will vary
based on your exact setup).
There are quite a few options to solve this particular problem, of which a handful are shown below.
#### Increase the Number of Open Files
We'll start by increasing the number of open files the OS allows. On my Kali install, the default was `1024`, and I know
some MacOS installs use `256` 😕.
##### Edit `/etc/security/limits.conf`
One option to up the limit is to edit `/etc/security/limits.conf` so that it includes the two lines below.
- `*` represents all users
- `hard` and `soft` indicate the hard and soft limits for the OS
- `nofile` is the number of open files option.
```
/etc/security/limits.conf
-------------------------
...
* soft nofile 4096
* hard nofile 8192
...
```
##### Use `ulimit` directly
A faster option, that is **not** persistent, is to simply use the `ulimit` command to change the setting.
```
ulimit -n 4096
```
#### Additional Tweaks (may not be needed)
If you still find yourself hitting the file limit with the above changes, there are a few additional tweaks that may
help.
> This section was shamelessly stolen from this [stackoverflow answer](https://stackoverflow.com/a/3923785). More information is included in that post and is recommended reading if you end up needing to use this section.
✨ Special thanks to HTB user [@sparkla](https://www.hackthebox.eu/home/users/profile/221599) for their help with
identifying these additional tweaks ✨
##### Increase the ephemeral port range, and decrease the tcp_fin_timeout.
The ephermal port range defines the maximum number of outbound sockets a host can create from a particular I.P. address.
The fin_timeout defines the minimum time these sockets will stay in TIME_WAIT state (unusable after being used once).
Usual system defaults are
- `net.ipv4.ip_local_port_range = 32768 61000`
- `net.ipv4.tcp_fin_timeout = 60`
This basically means your system cannot consistently guarantee more than `(61000 - 32768) / 60 = 470` sockets per
second.
```
sudo sysctl net.ipv4.ip_local_port_range="15000 61000"
sudo sysctl net.ipv4.tcp_fin_timeout=30
```
##### Allow socket reuse while in a `TIME_WAIT` status
This allows fast cycling of sockets in time_wait state and re-using them. Make sure to read
post [Coping with the TCP TIME-WAIT](https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux) from Vincent
Bernat to understand the implications.
```
sudo sysctl net.ipv4.tcp_tw_reuse=1
```
### Progress bars print one line at a time
`feroxbuster` needs a terminal width of at least the size of what's being printed in order to do progress bar printing
correctly. If your width is too small, you may see output like what's shown below.
![small-term](img/small-term.png)
If you can, simply make the terminal wider and rerun. If you're unable to make your terminal wider consider using `-q`
to suppress the progress bars.
### What do each of the numbers beside the URL mean?
Please refer to [this section](#filter-response-by-word-count--line-count--new-in-v160) where each number's meaning and
how to use it to filter responses is discussed.
### Connection closed before message completed
The error in question can be boiled down to 'networking stuff'. `feroxbuster`
uses [reqwest](https://docs.rs/reqwest/latest/) which uses [hyper](https://docs.rs/hyper/latest/hyper/) to make requests
to the server. [This issue report](https://github.com/hyperium/hyper/issues/2136#issuecomment-589345238) to the hyper
project explains what is happening (quoted below to save you a click). This isn't a bug so much as it's a
target-specific tuning issue. When lowering the `-t` value, the error doesn't occur (or happens much less frequently).
This isn't a bug. Simply slow down the scan. A `-t` value of 50 was chosen as a sane default that's still quite fast out
of the box. However, network related errors may occur when the client and/or server become over-saturated.
The [Threads and Connection Limits At A High-Level](#threads-and-connection-limits-at-a-high-level) section details how
to accomplish per-target tuning.
> This is just due to the racy nature of networking.
>
> hyper has a connection pool of idle connections, and it selected one to send your request. Most of the time, hyper will receive the server's FIN and drop the dead connection from its pool. But occasionally, a connection will be selected from the pool and written to at the same time the server is deciding to close the connection. Since hyper already wrote some of the request, it can't really retry it automatically on a new connection, since the server may have acted already.
### SSL Error routines:tls_process_server_certificate:certificate verify failed
In the event you see an error similar to
![self-signed](img/insecure.png)
```
error trying to connect: error:1416F086:SSL routines:tls_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:1913: (self signed certificate)
```
You just need to add the `-k|--insecure` flag to your command.
`feroxbuster` rejects self-signed certs and other "insecure" certificates/site configurations by default. You can choose
to scan these services anyway by telling `feroxbuster` to ignore insecure server certs.
## 🚀 Documentation has **moved** 🚀
For realsies, there used to be over 1300 lines in this README, but it's all been moved to the [new documentation site](https://epi052.github.io/feroxbuster-docs/docs/). Go check it out!
<h1><p align="center">✨🎉👉 <a href="https://epi052.github.io/feroxbuster-docs/docs/">DOCUMENTATION</a> 👈🎉✨</p></h1>
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://io.fi"><img src="https://avatars.githubusercontent.com/u/5235109?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joona Hoikkala</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=joohoi" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/jsav0"><img src="https://avatars.githubusercontent.com/u/20546041?v=4?s=100" width="100px;" alt=""/><br /><sub><b>J Savage</b></sub></a><br /><a href="#infra-jsav0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=jsav0" title="Documentation">📖</a></td>
<td align="center"><a href="http://www.tgotwig.dev"><img src="https://avatars.githubusercontent.com/u/30773779?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thomas Gotwig</b></sub></a><br /><a href="#infra-TGotwig" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=TGotwig" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/spikecodes"><img src="https://avatars.githubusercontent.com/u/19519553?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Spike</b></sub></a><br /><a href="#infra-spikecodes" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=spikecodes" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/evanrichter"><img src="https://avatars.githubusercontent.com/u/330292?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Evan Richter</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=evanrichter" title="Code">💻</a> <a href="https://github.com/epi052/feroxbuster/commits?author=evanrichter" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mzpqnxow"><img src="https://avatars.githubusercontent.com/u/8016228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>AG</b></sub></a><br /><a href="#ideas-mzpqnxow" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=mzpqnxow" title="Documentation">📖</a></td>
<td align="center"><a href="https://n-thumann.de/"><img src="https://avatars.githubusercontent.com/u/46975855?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolas Thumann</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=n-thumann" title="Code">💻</a> <a href="https://github.com/epi052/feroxbuster/commits?author=n-thumann" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/tomtastic"><img src="https://avatars.githubusercontent.com/u/302127?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tom Matthews</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=tomtastic" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/bsysop"><img src="https://avatars.githubusercontent.com/u/9998303?v=4?s=100" width="100px;" alt=""/><br /><sub><b>bsysop</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=bsysop" title="Documentation">📖</a></td>
<td align="center"><a href="http://bpsizemore.me"><img src="https://avatars.githubusercontent.com/u/11645898?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Brian Sizemore</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=bpsizemore" title="Code">💻</a></td>
<td align="center"><a href="https://pwn.by/noraj"><img src="https://avatars.githubusercontent.com/u/16578570?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexandre ZANNI</b></sub></a><br /><a href="#infra-noraj" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=noraj" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/craig"><img src="https://avatars.githubusercontent.com/u/99729?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Craig</b></sub></a><br /><a href="#infra-craig" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://www.reddit.com/u/EONRaider"><img src="https://avatars.githubusercontent.com/u/15611424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>EONRaider</b></sub></a><br /><a href="#infra-EONRaider" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/wtwver"><img src="https://avatars.githubusercontent.com/u/53866088?v=4?s=100" width="100px;" alt=""/><br /><sub><b>wtwver</b></sub></a><br /><a href="#infra-wtwver" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://tib3rius.com"><img src="https://avatars.githubusercontent.com/u/48113936?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tib3rius</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ATib3rius" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/0xdf"><img src="https://avatars.githubusercontent.com/u/1489045?v=4?s=100" width="100px;" alt=""/><br /><sub><b>0xdf</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3A0xdf" title="Bug reports">🐛</a></td>
<td align="center"><a href="http://secure77.de"><img src="https://avatars.githubusercontent.com/u/31564517?v=4?s=100" width="100px;" alt=""/><br /><sub><b>secure-77</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asecure-77" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/sbrun"><img src="https://avatars.githubusercontent.com/u/7712154?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sophie Brun</b></sub></a><br /><a href="#infra-sbrun" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/black-A"><img src="https://avatars.githubusercontent.com/u/30686803?v=4?s=100" width="100px;" alt=""/><br /><sub><b>black-A</b></sub></a><br /><a href="#ideas-black-A" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/dinosn"><img src="https://avatars.githubusercontent.com/u/3851678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolas Krassas</b></sub></a><br /><a href="#ideas-dinosn" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/N0ur5"><img src="https://avatars.githubusercontent.com/u/24260009?v=4?s=100" width="100px;" alt=""/><br /><sub><b>N0ur5</b></sub></a><br /><a href="#ideas-N0ur5" title="Ideas, Planning, & Feedback">🤔</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/moscowchill"><img src="https://avatars.githubusercontent.com/u/72578879?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mchill</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Amoscowchill" title="Bug reports">🐛</a></td>
<td align="center"><a href="http://BitThr3at.github.io"><img src="https://avatars.githubusercontent.com/u/45028933?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Naman</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ABitThr3at" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/Sicks3c"><img src="https://avatars.githubusercontent.com/u/32225186?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ayoub Elaich</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asicks3c" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/HenryHoggard"><img src="https://avatars.githubusercontent.com/u/1208121?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Henry</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AHenryHoggard" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/SleepiPanda"><img src="https://avatars.githubusercontent.com/u/6428561?v=4?s=100" width="100px;" alt=""/><br /><sub><b>SleepiPanda</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ASleepiPanda" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/uBadRequest"><img src="https://avatars.githubusercontent.com/u/47282747?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bad Requests</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AuBadRequest" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://home.dnaka91.rocks"><img src="https://avatars.githubusercontent.com/u/36804488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dominik Nakamura</b></sub></a><br /><a href="#infra-dnaka91" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/hunter0x8"><img src="https://avatars.githubusercontent.com/u/46222314?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Muhammad Ahsan</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Ahunter0x8" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/cortantief"><img src="https://avatars.githubusercontent.com/u/34527333?v=4?s=100" width="100px;" alt=""/><br /><sub><b>cortantief</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Acortantief" title="Bug reports">🐛</a> <a href="https://github.com/epi052/feroxbuster/commits?author=cortantief" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/dsaxton"><img src="https://avatars.githubusercontent.com/u/2658661?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Saxton</b></sub></a><br /><a href="#ideas-dsaxton" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=dsaxton" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/narkopolo"><img src="https://avatars.githubusercontent.com/u/16690056?v=4?s=100" width="100px;" alt=""/><br /><sub><b>narkopolo</b></sub></a><br /><a href="#ideas-narkopolo" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://ring0.lol"><img src="https://avatars.githubusercontent.com/u/1893909?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Justin Steven</b></sub></a><br /><a href="#ideas-justinsteven" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/7047payloads"><img src="https://avatars.githubusercontent.com/u/95562424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>7047payloads</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=7047payloads" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/unkn0wnsyst3m"><img src="https://avatars.githubusercontent.com/u/21272239?v=4?s=100" width="100px;" alt=""/><br /><sub><b>unkn0wnsyst3m</b></sub></a><br /><a href="#ideas-unkn0wnsyst3m" title="Ideas, Planning, & Feedback">🤔</a></td>
</tr>
<tr>
<td align="center"><a href="https://ironwort.me/"><img src="https://avatars.githubusercontent.com/u/15280042?v=4?s=100" width="100px;" alt=""/><br /><sub><b>0x08</b></sub></a><br /><a href="#ideas-its0x08" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/MD-Levitan"><img src="https://avatars.githubusercontent.com/u/12116508?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kusok</b></sub></a><br /><a href="#ideas-MD-Levitan" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=MD-Levitan" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/godylockz"><img src="https://avatars.githubusercontent.com/u/81207744?v=4?s=100" width="100px;" alt=""/><br /><sub><b>godylockz</b></sub></a><br /><a href="#ideas-godylockz" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=godylockz" title="Code">💻</a></td>
<td align="center"><a href="http://ryanmontgomery.me"><img src="https://avatars.githubusercontent.com/u/44453666?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ryan Montgomery</b></sub></a><br /><a href="#ideas-0dayCTF" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/IppSec"><img src="https://avatars.githubusercontent.com/u/24677271?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ippsec</b></sub></a><br /><a href="#ideas-ippsec" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/gtjamesa"><img src="https://avatars.githubusercontent.com/u/2078364?v=4?s=100" width="100px;" alt=""/><br /><sub><b>James</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Agtjamesa" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://twitter.com/Jhaddix"><img src="https://avatars.githubusercontent.com/u/3488554?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jason Haddix</b></sub></a><br /><a href="#ideas-jhaddix" title="Ideas, Planning, & Feedback">🤔</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!

View File

@@ -1,6 +1,7 @@
extern crate clap;
use std::fs::{copy, create_dir_all, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use clap::Shell;
use clap_complete::{generate_to, shells};
include!("src/parser.rs");
@@ -15,9 +16,69 @@ fn main() {
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 {
app.gen_completions("feroxbuster", *shell, outdir);
// 0xdf pointed out an oddity when tab-completing options that expect file paths, the fix we
// 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!("{}/feroxbuster.bash", outdir))
.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
.seek(SeekFrom::Start(0))
.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 !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,15 +16,29 @@
# replay_proxy = "http://127.0.0.1:8081"
# replay_codes = [200, 302]
# verbosity = 1
# parallel = 8
# scan_limit = 6
# rate_limit = 250
# quiet = true
# silent = true
# auto_tune = true
# auto_bail = true
# json = true
# output = "/targets/ellingson_mineral_company/gibson.txt"
# 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"
# random_agent = false
# redirects = true
# insecure = true
# collect_words = true
# collect_backups = true
# collect_extensions = true
# 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
# add_slash = true
# stdin = true

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

BIN
img/cancel-menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
img/cancel-scan.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

BIN
img/rate-limit-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

View File

@@ -3,54 +3,63 @@
BASE_URL=https://github.com/epi052/feroxbuster/releases/latest/download
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_URL="${BASE_URL}/${LIN32_ZIP}"
LIN32_URL="$BASE_URL/$LIN32_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
echo "[+] Installing feroxbuster!"
which unzip &>/dev/null
if [ "$?" = "0" ]; then
echo "[+] unzip found"
else
echo "[ ] unzip not found, exiting. "
exit -1
fi
if [[ "$(uname)" == "Darwin" ]]; then
echo "[=] Found MacOS, downloading from ${MAC_URL}"
echo "[=] Found MacOS, downloading from $MAC_URL"
curl -sLO "${MAC_URL}"
unzip -o "${MAC_ZIP}" > /dev/null
rm "${MAC_ZIP}"
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}"
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 "$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
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}"
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
echo "[+] Installed feroxbuster version $(./feroxbuster -V)"

View File

@@ -15,82 +15,105 @@ _feroxbuster() {
local context curcontext="$curcontext" state line
_arguments "${_arguments_options[@]}" \
'-w+[Path to the wordlist]' \
'--wordlist=[Path to the wordlist]' \
'*-u+[The target URL(s) (required, unless --stdin used)]' \
'*--url=[The target URL(s) (required, unless --stdin used)]' \
'-t+[Number of concurrent threads (default: 50)]' \
'--threads=[Number of concurrent threads (default: 50)]' \
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \
'--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \
'-T+[Number of seconds before a request times out (default: 7)]' \
'--timeout=[Number of seconds before a request times out (default: 7)]' \
'-p+[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]' \
'--proxy=[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]' \
'-P+[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \
'--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \
'*-R+[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \
'*-s+[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \
'*--status-codes=[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \
'-o+[Output file to write results to (use w/ --json for JSON entries)]' \
'--output=[Output file to write results to (use w/ --json for JSON entries)]' \
'(-u --url)--resume-from=[State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)]' \
'--debug-log=[Output file to write log entries (use w/ --json for JSON entries)]' \
'-a+[Sets the User-Agent (default: feroxbuster/VERSION)]' \
'--user-agent=[Sets the User-Agent (default: feroxbuster/VERSION)]' \
'*-x+[File extension(s) to search for (ex: -x php -x pdf js)]' \
'*--extensions=[File extension(s) to search for (ex: -x php -x pdf js)]' \
'*-H+[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \
'*--headers=[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \
'*-Q+[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \
'*--query=[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \
'*-S+[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \
'*--filter-size=[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \
'*-X+[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \
'*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \
'*-W+[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \
'*--filter-words=[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \
'*-N+[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \
'*--filter-lines=[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \
'*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \
'*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \
'*--filter-similar-to=[Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)]' \
'-L+[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \
'--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]' \
'*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
'*--verbosity[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
'-q[Only print URLs; Don'\''t print status codes, response size, running config, etc...]' \
'--quiet[Only print URLs; Don'\''t print status codes, response size, running config, etc...]' \
'--json[Emit JSON logs to --output and --debug-log instead of normal text]' \
'-D[Don'\''t auto-filter wildcard responses]' \
'--dont-filter[Don'\''t auto-filter wildcard responses]' \
'-r[Follow redirects]' \
'--redirects[Follow redirects]' \
'-k[Disables TLS certificate validation]' \
'--insecure[Disables TLS certificate validation]' \
'-u+[The target URL (required, unless \[--stdin || --resume-from\] used)]:URL:_urls' \
'--url=[The target URL (required, unless \[--stdin || --resume-from\] used)]:URL:_urls' \
'(-u --url)--resume-from=[State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)]:STATE_FILE:_files' \
'-p+[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]:PROXY:_urls' \
'--proxy=[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]:PROXY:_urls' \
'-P+[Send only unfiltered requests through a Replay Proxy, instead of all requests]:REPLAY_PROXY:_urls' \
'--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]:REPLAY_PROXY:_urls' \
'*-R+[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]:REPLAY_CODE: ' \
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]:REPLAY_CODE: ' \
'-a+[Sets the User-Agent (default: feroxbuster/2.6.2)]:USER_AGENT: ' \
'--user-agent=[Sets the User-Agent (default: feroxbuster/2.6.2)]:USER_AGENT: ' \
'*-x+[File extension(s) to search for (ex: -x php -x pdf js)]:FILE_EXTENSION: ' \
'*--extensions=[File extension(s) to search for (ex: -x php -x pdf js)]:FILE_EXTENSION: ' \
'*-m+[Which HTTP request method(s) should be sent (default: GET)]:HTTP_METHODS: ' \
'*--methods=[Which HTTP request method(s) should be sent (default: GET)]:HTTP_METHODS: ' \
'--data=[Request'\''s Body; can read data from a file if input starts with an @ (ex: @post.bin)]:DATA: ' \
'*-H+[Specify HTTP headers to be used in each request (ex: -H Header:val -H '\''stuff: things'\'')]:HEADER: ' \
'*--headers=[Specify HTTP headers to be used in each request (ex: -H Header:val -H '\''stuff: things'\'')]:HEADER: ' \
'*-b+[Specify HTTP cookies to be used in each request (ex: -b stuff=things)]:COOKIE: ' \
'*--cookies=[Specify HTTP cookies to be used in each request (ex: -b stuff=things)]:COOKIE: ' \
'*-Q+[Request'\''s URL query parameters (ex: -Q token=stuff -Q secret=key)]:QUERY: ' \
'*--query=[Request'\''s URL query parameters (ex: -Q token=stuff -Q secret=key)]:QUERY: ' \
'*--dont-scan=[URL(s) or Regex Pattern(s) to exclude from recursion/scans]:URL: ' \
'*-S+[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]:SIZE: ' \
'*--filter-size=[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]:SIZE: ' \
'*-X+[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]:REGEX: ' \
'*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]:REGEX: ' \
'*-W+[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]:WORDS: ' \
'*--filter-words=[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]:WORDS: ' \
'*-N+[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]:LINES: ' \
'*--filter-lines=[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]:LINES: ' \
'*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
'*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
'*--filter-similar-to=[Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)]:UNWANTED_PAGE:_urls' \
'*-s+[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]:STATUS_CODE: ' \
'*--status-codes=[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]:STATUS_CODE: ' \
'-T+[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
'--timeout=[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
'-t+[Number of concurrent threads (default: 50)]:THREADS: ' \
'--threads=[Number of concurrent threads (default: 50)]:THREADS: ' \
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]:RECURSION_DEPTH: ' \
'--depth=[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)]:SCAN_LIMIT: ' \
'--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]:SCAN_LIMIT: ' \
'--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' \
'-h[Print help information]' \
'--help[Print help information]' \
'-V[Print version information]' \
'--version[Print version information]' \
'(-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]' \
'--smart[Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true]' \
'--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]' \
'(-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]' \
'-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)*--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[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]' \
'--no-state[Disable state output file (*.state)]' \
&& ret=0
}
(( $+functions[_feroxbuster_commands] )) ||
_feroxbuster_commands() {
local commands; commands=(
)
local commands; commands=()
_describe -t commands 'feroxbuster commands' commands "$@"
}
_feroxbuster "$@"
_feroxbuster "$@"

View File

@@ -12,7 +12,8 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
$element = $commandElements[$i]
if ($element -isnot [StringConstantExpressionAst] -or
$element.StringConstantType -ne [StringConstantType]::BareWord -or
$element.Value.StartsWith('-')) {
$element.Value.StartsWith('-') -or
$element.Value -eq $wordToComplete) {
break
}
$element.Value
@@ -20,36 +21,29 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
$completions = @(switch ($command) {
'feroxbuster' {
[CompletionResult]::new('-w', 'w', [CompletionResultType]::ParameterName, 'Path to the wordlist')
[CompletionResult]::new('--wordlist', 'wordlist', [CompletionResultType]::ParameterName, 'Path to the wordlist')
[CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The target URL(s) (required, unless --stdin used)')
[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('-u', 'u', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from] used)')
[CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from] 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('-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('-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('-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('-s', 's', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)')
[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('-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('-a', 'a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.6.2)')
[CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.6.2)')
[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('-H', 'H', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')')
[CompletionResult]::new('--headers', 'headers', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')')
[CompletionResult]::new('-Q', 'Q', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)')
[CompletionResult]::new('--query', 'query', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)')
[CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Which HTTP request method(s) should be sent (default: GET)')
[CompletionResult]::new('--methods', 'methods', [CompletionResultType]::ParameterName, 'Which HTTP request method(s) should be sent (default: GET)')
[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('-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('--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$'')')
@@ -61,31 +55,64 @@ 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('--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('-s', 's', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)')
[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('-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('--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('--time-limit', 'time-limit', [CompletionResultType]::ParameterName, 'Limit total run time of all scans (ex: --time-limit 10m)')
[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('-q', 'q', [CompletionResultType]::ParameterName, 'Only print URLs; Don''t print status codes, response size, running config, etc...')
[CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Only print URLs; Don''t print status codes, response size, running config, etc...')
[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('--dont-filter', 'dont-filter', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
[CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Follow redirects')
[CompletionResult]::new('--redirects', 'redirects', [CompletionResultType]::ParameterName, 'Follow redirects')
[CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation')
[CompletionResult]::new('--insecure', 'insecure', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation')
[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('-h', 'h', [CompletionResultType]::ParameterName, 'Print help information')
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help information')
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information')
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information')
[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('-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')
[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('--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('-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('--json', 'json', [CompletionResultType]::ParameterName, 'Emit JSON logs to --output and --debug-log instead of normal text')
[CompletionResult]::new('--no-state', 'no-state', [CompletionResultType]::ParameterName, 'Disable state output file (*.state)')
break
}
})

View File

@@ -9,10 +9,9 @@ _feroxbuster() {
for i in ${COMP_WORDS[@]}
do
case "${i}" in
feroxbuster)
"$1")
cmd="feroxbuster"
;;
*)
;;
esac
@@ -20,90 +19,17 @@ _feroxbuster() {
case "${cmd}" in
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 --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 --time-limit "
opts="-h -V -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 --help --version --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 --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"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--wordlist)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-w)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--url)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-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)
-u)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -111,7 +37,27 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
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}"))
return 0
;;
@@ -119,7 +65,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-a)
-a)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -127,7 +73,19 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
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}"))
return 0
;;
@@ -135,7 +93,15 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-H)
-H)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--cookies)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-b)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -143,7 +109,11 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-Q)
-Q)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--dont-scan)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -151,7 +121,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-S)
-S)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -159,7 +129,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-X)
-X)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -167,7 +137,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-W)
-W)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -175,7 +145,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-N)
-N)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -183,7 +153,7 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-C)
-C)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -191,11 +161,51 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
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)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-L)
-L)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--parallel)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--rate-limit)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@@ -203,6 +213,34 @@ _feroxbuster() {
COMPREPLY=($(compgen -f "${cur}"))
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=()
;;
@@ -210,8 +248,7 @@ _feroxbuster() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
esac
}
complete -F _feroxbuster -o bashdefault -o default feroxbuster
complete -F _feroxbuster -o bashdefault -o default -o plusdirs feroxbuster

View File

@@ -0,0 +1,116 @@
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.6.2)'
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.6.2)'
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: 200 204 301 302 307 308 401 403 405)'
cand --status-codes 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)'
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 -h 'Print help information'
cand --help 'Print help information'
cand -V 'Print version information'
cand --version 'Print version information'
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 -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)'
}
]
$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" -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 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 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 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$\')'
@@ -21,11 +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" -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" -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 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 q -l quiet -d 'Only print URLs; Don\'t print status codes, response size, running config, etc...'
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" -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" -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 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'

View File

@@ -1,753 +0,0 @@
use crate::{
config::{Configuration, CONFIGURATION},
statistics::StatCommand,
utils::{make_request, status_colorizer},
};
use console::{style, Emoji};
use reqwest::{Client, Url};
use serde_json::Value;
use std::io::Write;
use tokio::sync::mpsc::UnboundedSender;
/// macro helper to abstract away repetitive string formatting
macro_rules! format_banner_entry_helper {
// \u{0020} -> unicode space
// \u{2502} -> vertical box drawing character, i.e. │
($rune:expr, $name:expr, $value:expr, $indent:expr, $col_width:expr) => {
format!(
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<col_w$}\u{2502}\u{0020}{}",
$rune,
$name,
$value,
indent = $indent,
col_w = $col_width
)
};
($rune:expr, $name:expr, $value:expr, $value2:expr, $indent:expr, $col_width:expr) => {
format!(
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<col_w$}\u{2502}\u{0020}{}:\u{0020}{}",
$rune,
$name,
$value,
$value2,
indent = $indent,
col_w = $col_width
)
};
}
/// macro that wraps another macro helper to abstract away repetitive string formatting
macro_rules! format_banner_entry {
// 4 -> unicode emoji padding width
// 22 -> column width (when unicode rune is 4 bytes wide, 23 when it's 3)
// hardcoded since macros don't allow let statements
($rune:expr, $name:expr, $value:expr) => {
format_banner_entry_helper!($rune, $name, $value, 3, 22)
};
($rune:expr, $name:expr, $value1:expr, $value2:expr) => {
format_banner_entry_helper!($rune, $name, $value1, $value2, 3, 22)
};
}
/// Url used to query github's api; specifically used to look for the latest tagged release name
const UPDATE_URL: &str = "https://api.github.com/repos/epi052/feroxbuster/releases/latest";
/// Simple enum to hold three different update states
#[derive(Debug)]
enum UpdateStatus {
/// this version and latest release are the same
UpToDate,
/// this version and latest release are not the same
OutOfDate,
/// some error occurred during version check
Unknown,
}
/// Makes a request to the given url, expecting to receive a JSON response that contains a field
/// named `tag_name` that holds a value representing the latest tagged release of this tool.
///
/// ex: v1.1.0
///
/// Returns `UpdateStatus`
async fn needs_update(
client: &Client,
url: &str,
bin_version: &str,
tx_stats: UnboundedSender<StatCommand>,
) -> UpdateStatus {
log::trace!("enter: needs_update({:?}, {}, {:?})", client, url, tx_stats);
let unknown = UpdateStatus::Unknown;
let api_url = match Url::parse(url) {
Ok(url) => url,
Err(e) => {
log::error!("{}", e);
log::trace!("exit: needs_update -> {:?}", unknown);
return unknown;
}
};
if let Ok(response) = make_request(&client, &api_url, tx_stats.clone()).await {
let body = response.text().await.unwrap_or_default();
let json_response: Value = serde_json::from_str(&body).unwrap_or_default();
if json_response.is_null() {
// unwrap_or_default above should result in a null value for the json_response variable
log::error!("Could not parse JSON from response body");
log::trace!("exit: needs_update -> {:?}", unknown);
return unknown;
}
let latest_version = match json_response["tag_name"].as_str() {
Some(tag) => tag.trim_start_matches('v'),
None => {
log::error!("Could not get version field from JSON response");
log::debug!("{}", json_response);
log::trace!("exit: needs_update -> {:?}", unknown);
return unknown;
}
};
// if we've gotten this far, we have a string in the form of X.X.X where X is a number
// all that's left is to compare the current version with the version found above
return if latest_version == bin_version {
// there's really only two possible outcomes if we accept that the tag conforms to
// the X.X.X pattern:
// 1. the version strings match, meaning we're up to date
// 2. the version strings do not match, meaning we're out of date
//
// except for developers working on this code, nobody should ever be in a situation
// where they have a version greater than the latest tagged release
log::trace!("exit: needs_update -> UpdateStatus::UpToDate");
UpdateStatus::UpToDate
} else {
log::trace!("exit: needs_update -> UpdateStatus::OutOfDate");
UpdateStatus::OutOfDate
};
}
log::trace!("exit: needs_update -> {:?}", unknown);
unknown
}
/// Simple wrapper for emoji or fallback when terminal doesn't support emoji
fn format_emoji(emoji: &str) -> String {
let width = console::measure_text_width(emoji);
let pad_len = width * width;
let pad = format!("{:<pad_len$}", "\u{0020}", pad_len = pad_len);
Emoji(emoji, &pad).to_string()
}
/// Prints the banner to stdout.
///
/// Only prints those settings which are either always present, or passed in by the user.
pub async fn initialize<W>(
targets: &[String],
config: &Configuration,
version: &str,
mut writer: W,
tx_stats: UnboundedSender<StatCommand>,
) where
W: Write,
{
let artwork = format!(
r#"
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher {} ver: {}"#,
Emoji("🤓", &format!("{:<2}", "\u{0020}")),
version
);
let status = needs_update(&CONFIGURATION.client, UPDATE_URL, version, tx_stats).await;
let top = "───────────────────────────┬──────────────────────";
let addl_section = "──────────────────────────────────────────────────";
let bottom = "───────────────────────────┴──────────────────────";
writeln!(&mut writer, "{}", artwork).unwrap_or_default();
writeln!(&mut writer, "{}", top).unwrap_or_default();
// begin with always printed items
for target in targets {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🎯"), "Target Url", target)
)
.unwrap_or_default(); // 🎯
}
let mut codes = vec![];
for code in &config.status_codes {
codes.push(status_colorizer(&code.to_string()))
}
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🚀"), "Threads", config.threads)
)
.unwrap_or_default(); // 🚀
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("📖"), "Wordlist", config.wordlist)
)
.unwrap_or_default(); // 📖
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji("🆗"),
"Status Codes",
format!("[{}]", codes.join(", "))
)
)
.unwrap_or_default(); // 🆗
if !config.filter_status.is_empty() {
// exception here for optional print due to me wanting the allows and denys to be printed
// one after the other
let mut code_filters = vec![];
for code in &config.filter_status {
code_filters.push(status_colorizer(&code.to_string()))
}
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji("🗑"),
"Status Code Filters",
format!("[{}]", code_filters.join(", "))
)
)
.unwrap_or_default(); // 🗑
}
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💥"), "Timeout (secs)", config.timeout)
)
.unwrap_or_default(); // 💥
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🦡"), "User-Agent", config.user_agent)
)
.unwrap_or_default(); // 🦡
// followed by the maybe printed or variably displayed values
if !config.config.is_empty() {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💉"), "Config File", config.config)
)
.unwrap_or_default(); // 💉
}
if !config.proxy.is_empty() {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💎"), "Proxy", config.proxy)
)
.unwrap_or_default(); // 💎
}
if !config.replay_proxy.is_empty() {
// i include replay codes logic here because in config.rs, replay codes are set to the
// value in status codes, meaning it's never empty
let mut replay_codes = vec![];
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🎥"), "Replay Proxy", config.replay_proxy)
)
.unwrap_or_default(); // 🎥
for code in &config.replay_codes {
replay_codes.push(status_colorizer(&code.to_string()))
}
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji("📼"),
"Replay Proxy Codes",
format!("[{}]", replay_codes.join(", "))
)
)
.unwrap_or_default(); // 📼
}
if !config.headers.is_empty() {
for (name, value) in &config.headers {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🤯"), "Header", name, value)
)
.unwrap_or_default(); // 🤯
}
}
if !config.filter_size.is_empty() {
for filter in &config.filter_size {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💢"), "Size Filter", filter)
)
.unwrap_or_default(); // 💢
}
}
if !config.filter_similar.is_empty() {
for filter in &config.filter_similar {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💢"), "Similarity Filter", filter)
)
.unwrap_or_default(); // 💢
}
}
for filter in &config.filter_word_count {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💢"), "Word Count Filter", filter)
)
.unwrap_or_default(); // 💢
}
for filter in &config.filter_line_count {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💢"), "Line Count Filter", filter)
)
.unwrap_or_default(); // 💢
}
for filter in &config.filter_regex {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💢"), "Regex Filter", filter)
)
.unwrap_or_default(); // 💢
}
if config.extract_links {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🔎"), "Extract Links", config.extract_links)
)
.unwrap_or_default(); // 🔎
}
if config.json {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🧔"), "JSON Output", config.json)
)
.unwrap_or_default(); // 🧔
}
if !config.queries.is_empty() {
for query in &config.queries {
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji("🤔"),
"Query Parameter",
format!("{}={}", query.0, query.1)
)
)
.unwrap_or_default(); // 🤔
}
}
if !config.output.is_empty() {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("💾"), "Output File", config.output)
)
.unwrap_or_default(); // 💾
}
if !config.debug_log.is_empty() {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🪲"), "Debugging Log", config.debug_log)
)
.unwrap_or_default(); // 🪲
}
if !config.extensions.is_empty() {
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji("💲"),
"Extensions",
format!("[{}]", config.extensions.join(", "))
)
)
.unwrap_or_default(); // 💲
}
if config.insecure {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🔓"), "Insecure", config.insecure)
)
.unwrap_or_default(); // 🔓
}
if config.redirects {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("📍"), "Follow Redirects", config.redirects)
)
.unwrap_or_default(); // 📍
}
if config.dont_filter {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🤪"), "Filter Wildcards", !config.dont_filter)
)
.unwrap_or_default(); // 🤪
}
let volume = ["🔈", "🔉", "🔊", "📢"];
if let 1..=4 = config.verbosity {
//speaker medium volume (increasing with verbosity to loudspeaker)
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji(volume[config.verbosity as usize - 1]),
"Verbosity",
config.verbosity
)
)
.unwrap_or_default();
}
if config.add_slash {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🪓"), "Add Slash", config.add_slash)
)
.unwrap_or_default(); // 🪓
}
if !config.no_recursion {
if config.depth == 0 {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🔃"), "Recursion Depth", "INFINITE")
)
.unwrap_or_default(); // 🔃
} else {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🔃"), "Recursion Depth", config.depth)
)
.unwrap_or_default(); // 🔃
}
} else {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🚫"), "Do Not Recurse", config.no_recursion)
)
.unwrap_or_default(); // 🚫
}
if CONFIGURATION.scan_limit > 0 {
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji("🦥"),
"Concurrent Scan Limit",
config.scan_limit
)
)
.unwrap_or_default(); // 🦥
}
if !CONFIGURATION.time_limit.is_empty() {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🕖"), "Time Limit", config.time_limit)
)
.unwrap_or_default(); // 🕖
}
if matches!(status, UpdateStatus::OutOfDate) {
writeln!(
&mut writer,
"{}",
format_banner_entry!(
format_emoji("🎉"),
"New Version Available",
"https://github.com/epi052/feroxbuster/releases/latest"
)
)
.unwrap_or_default(); // 🎉
}
writeln!(&mut writer, "{}", bottom).unwrap_or_default();
// ⏯
writeln!(
&mut writer,
" {} Press [{}] to {}|{} your scan",
format_emoji(""),
style("ENTER").yellow(),
style("pause").red(),
style("resume").green()
)
.unwrap_or_default();
writeln!(&mut writer, "{}", addl_section).unwrap_or_default();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FeroxChannel, VERSION};
use httpmock::Method::GET;
use httpmock::MockServer;
use std::fs::read_to_string;
use std::io::stderr;
use std::time::Duration;
use tempfile::NamedTempFile;
use tokio::sync::mpsc;
#[tokio::test(core_threads = 1)]
/// test to hit no execution of targets for loop in banner
async fn banner_intialize_without_targets() {
let config = Configuration::default();
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
initialize(&[], &config, VERSION, stderr(), tx).await;
}
#[tokio::test(core_threads = 1)]
/// test to hit no execution of statuscode for loop in banner
async fn banner_intialize_without_status_codes() {
let config = Configuration {
status_codes: vec![],
..Default::default()
};
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
initialize(
&[String::from("http://localhost")],
&config,
VERSION,
stderr(),
tx,
)
.await;
}
#[tokio::test(core_threads = 1)]
/// test to hit an empty config file
async fn banner_intialize_without_config_file() {
let config = Configuration {
config: String::new(),
..Default::default()
};
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
initialize(
&[String::from("http://localhost")],
&config,
VERSION,
stderr(),
tx,
)
.await;
}
#[tokio::test(core_threads = 1)]
/// test to hit an empty config file
async fn banner_intialize_without_queries() {
let config = Configuration {
queries: vec![(String::new(), String::new())],
..Default::default()
};
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
initialize(
&[String::from("http://localhost")],
&config,
VERSION,
stderr(),
tx,
)
.await;
}
#[tokio::test(core_threads = 1)]
/// test to show that a new version is available for download
async fn banner_intialize_with_mismatched_version() {
let config = Configuration::default();
let file = NamedTempFile::new().unwrap();
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
initialize(
&[String::from("http://localhost")],
&config,
"mismatched-version",
&file,
tx,
)
.await;
let contents = read_to_string(file.path()).unwrap();
println!("contents: {}", contents);
assert!(contents.contains("New Version Available"));
assert!(contents.contains("https://github.com/epi052/feroxbuster/releases/latest"));
}
#[tokio::test(core_threads = 1)]
/// test that
async fn banner_needs_update_returns_unknown_with_bad_url() {
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let result = needs_update(&CONFIGURATION.client, &"", VERSION, tx).await;
assert!(matches!(result, UpdateStatus::Unknown));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url to needs_update
async fn banner_needs_update_returns_up_to_date() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
});
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.1.0", tx).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::UpToDate));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url to needs_update that returns a newer version than current
async fn banner_needs_update_returns_out_of_date() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
});
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1", tx).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::OutOfDate));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url that times out
async fn banner_needs_update_returns_unknown_on_timeout() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200)
.body("{\"tag_name\":\"v1.1.0\"}")
.delay(Duration::from_secs(8));
});
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1", tx).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url with bad json response
async fn banner_needs_update_returns_unknown_on_bad_json_response() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("not json");
});
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1", tx).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url with json response that lacks the tag_name field
async fn banner_needs_update_returns_unknown_on_json_without_correct_tag() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200)
.body("{\"no tag_name\": \"doesn't exist\"}");
});
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1", tx).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
}

674
src/banner/container.rs Normal file
View File

@@ -0,0 +1,674 @@
use super::entry::BannerEntry;
use crate::{
config::Configuration,
event_handlers::Handles,
utils::{logged_request, status_colorizer},
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, VERSION,
};
use anyhow::{bail, Result};
use console::{style, Emoji};
use reqwest::Url;
use serde_json::Value;
use std::{io::Write, sync::Arc};
/// Url used to query github's api; specifically used to look for the latest tagged release name
pub const UPDATE_URL: &str = "https://api.github.com/repos/epi052/feroxbuster/releases/latest";
/// Simple enum to hold three different update states
#[derive(Debug)]
pub(super) enum UpdateStatus {
/// this version and latest release are the same
UpToDate,
/// this version and latest release are not the same
OutOfDate,
/// some error occurred during version check
Unknown,
}
/// Banner object, contains multiple BannerEntry's and knows how to display itself
pub struct Banner {
/// all live targets
targets: Vec<BannerEntry>,
/// represents Configuration.status_codes
status_codes: BannerEntry,
/// represents Configuration.filter_status
filter_status: BannerEntry,
/// represents Configuration.threads
threads: BannerEntry,
/// represents Configuration.wordlist
wordlist: BannerEntry,
/// represents Configuration.timeout
timeout: BannerEntry,
/// represents Configuration.user_agent
user_agent: BannerEntry,
/// represents Configuration.random_agent
random_agent: BannerEntry,
/// represents Configuration.config
config: BannerEntry,
/// represents Configuration.proxy
proxy: BannerEntry,
/// represents Configuration.replay_proxy
replay_proxy: BannerEntry,
/// represents Configuration.replay_codes
replay_codes: BannerEntry,
/// represents Configuration.headers
headers: Vec<BannerEntry>,
/// represents Configuration.filter_size
filter_size: Vec<BannerEntry>,
/// represents Configuration.filter_similar
filter_similar: Vec<BannerEntry>,
/// represents Configuration.filter_word_count
filter_word_count: Vec<BannerEntry>,
/// represents Configuration.filter_line_count
filter_line_count: Vec<BannerEntry>,
/// represents Configuration.filter_regex
filter_regex: Vec<BannerEntry>,
/// represents Configuration.extract_links
extract_links: BannerEntry,
/// represents Configuration.json
json: BannerEntry,
/// represents Configuration.output
output: BannerEntry,
/// represents Configuration.debug_log
debug_log: BannerEntry,
/// represents Configuration.extensions
extensions: BannerEntry,
/// represents Configuration.methods
methods: BannerEntry,
/// represents Configuration.data
data: BannerEntry,
/// represents Configuration.insecure
insecure: BannerEntry,
/// represents Configuration.redirects
redirects: BannerEntry,
/// represents Configuration.dont_filter
dont_filter: BannerEntry,
/// represents Configuration.queries
queries: Vec<BannerEntry>,
/// represents Configuration.verbosity
verbosity: BannerEntry,
/// represents Configuration.add_slash
add_slash: BannerEntry,
/// represents Configuration.no_recursion
no_recursion: BannerEntry,
/// represents Configuration.scan_limit
scan_limit: BannerEntry,
/// represents Configuration.time_limit
time_limit: BannerEntry,
/// represents Configuration.rate_limit
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
pub(super) version: String,
/// whether or not there is a known new version
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,
}
/// implementation of Banner
impl Banner {
/// Create a new Banner from a Configuration and live targets
pub fn new(tgts: &[String], config: &Configuration) -> Self {
let mut targets = Vec::new();
let mut url_denylist = Vec::new();
let mut code_filters = Vec::new();
let mut replay_codes = Vec::new();
let mut headers = Vec::new();
let mut filter_size = Vec::new();
let mut filter_similar = Vec::new();
let mut filter_word_count = Vec::new();
let mut filter_line_count = Vec::new();
let mut filter_regex = Vec::new();
let mut queries = Vec::new();
for target in tgts {
targets.push(BannerEntry::new("🎯", "Target Url", target));
}
for denied_url in &config.url_denylist {
url_denylist.push(BannerEntry::new(
"🚫",
"Don't Scan Url",
denied_url.as_str(),
));
}
for denied_regex in &config.regex_denylist {
url_denylist.push(BannerEntry::new(
"🚫",
"Don't Scan Regex",
denied_regex.as_str(),
));
}
let mut codes = vec![];
for code in &config.status_codes {
codes.push(status_colorizer(&code.to_string()))
}
let status_codes =
BannerEntry::new("👌", "Status Codes", &format!("[{}]", codes.join(", ")));
for code in &config.filter_status {
code_filters.push(status_colorizer(&code.to_string()))
}
let filter_status = BannerEntry::new(
"💢",
"Status Code Filters",
&format!("[{}]", code_filters.join(", ")),
);
for code in &config.replay_codes {
replay_codes.push(status_colorizer(&code.to_string()))
}
let replay_codes = BannerEntry::new(
"📼",
"Replay Proxy Codes",
&format!("[{}]", replay_codes.join(", ")),
);
for (name, value) in &config.headers {
headers.push(BannerEntry::new(
"🤯",
"Header",
&format!("{}: {}", name, value),
));
}
for filter in &config.filter_size {
filter_size.push(BannerEntry::new("💢", "Size Filter", &filter.to_string()));
}
for filter in &config.filter_similar {
filter_similar.push(BannerEntry::new("💢", "Similarity Filter", filter));
}
for filter in &config.filter_word_count {
filter_word_count.push(BannerEntry::new(
"💢",
"Word Count Filter",
&filter.to_string(),
));
}
for filter in &config.filter_line_count {
filter_line_count.push(BannerEntry::new(
"💢",
"Line Count Filter",
&filter.to_string(),
));
}
for filter in &config.filter_regex {
filter_regex.push(BannerEntry::new("💢", "Regex Filter", filter));
}
for query in &config.queries {
queries.push(BannerEntry::new(
"🤔",
"Query Parameter",
&format!("{}={}", query.0, query.1),
));
}
let volume = ["🔈", "🔉", "🔊", "📢"];
let verbosity = if let 1..=4 = config.verbosity {
//speaker medium volume (increasing with verbosity to loudspeaker)
BannerEntry::new(
volume[config.verbosity as usize - 1],
"Verbosity",
&config.verbosity.to_string(),
)
} else {
BannerEntry::default()
};
let no_recursion = if !config.no_recursion {
let depth = if config.depth == 0 {
"INFINITE".to_string()
} else {
config.depth.to_string()
};
BannerEntry::new("🔃", "Recursion Depth", &depth)
} else {
BannerEntry::new("🚫", "Do Not Recurse", &config.no_recursion.to_string())
};
let scan_limit = BannerEntry::new(
"🦥",
"Concurrent Scan Limit",
&config.scan_limit.to_string(),
);
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 proxy = BannerEntry::new("💎", "Proxy", &config.proxy);
let threads = BannerEntry::new("🚀", "Threads", &config.threads.to_string());
let wordlist = BannerEntry::new("📖", "Wordlist", &config.wordlist);
let timeout = BannerEntry::new("💥", "Timeout (secs)", &config.timeout.to_string());
let user_agent = BannerEntry::new("🦡", "User-Agent", &config.user_agent);
let random_agent = BannerEntry::new("🦡", "User-Agent", "Random");
let extract_links =
BannerEntry::new("🔎", "Extract Links", &config.extract_links.to_string());
let json = BannerEntry::new("🧔", "JSON Output", &config.json.to_string());
let output = BannerEntry::new("💾", "Output File", &config.output);
let debug_log = BannerEntry::new("🪲", "Debugging Log", &config.debug_log);
let extensions = BannerEntry::new(
"💲",
"Extensions",
&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 redirects = BannerEntry::new("📍", "Follow Redirects", &config.redirects.to_string());
let dont_filter =
BannerEntry::new("🤪", "Filter Wildcards", &(!config.dont_filter).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 parallel = BannerEntry::new("🛤", "Parallel Scans", &config.parallel.to_string());
let rate_limit =
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 {
targets,
status_codes,
threads,
wordlist,
filter_status,
timeout,
user_agent,
random_agent,
auto_bail,
auto_tune,
proxy,
replay_codes,
replay_proxy,
headers,
filter_size,
filter_similar,
filter_word_count,
filter_line_count,
filter_regex,
extract_links,
parallel,
json,
queries,
output,
debug_log,
extensions,
methods,
data,
insecure,
dont_filter,
redirects,
verbosity,
add_slash,
no_recursion,
rate_limit,
scan_limit,
time_limit,
url_denylist,
collect_extensions,
collect_backups,
collect_words,
dont_collect,
config: cfg,
version: VERSION.to_string(),
update_status: UpdateStatus::Unknown,
}
}
/// get a fancy header for the banner
fn header(&self) -> String {
let artwork = format!(
r#"
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher {} ver: {}"#,
Emoji("🤓", &format!("{:<2}", "\u{0020}")),
self.version
);
let top = "───────────────────────────┬──────────────────────";
format!("{}\n{}", artwork, top)
}
/// get a fancy footer for the banner
fn footer(&self) -> String {
let addl_section = "──────────────────────────────────────────────────";
let bottom = "───────────────────────────┴──────────────────────";
let instructions = format!(
" 🏁 Press [{}] to use the {}",
style("ENTER").yellow(),
style("Scan Management Menu").bright().yellow(),
);
format!("{}\n{}\n{}", bottom, instructions, addl_section)
}
/// Makes a request to the given url, expecting to receive a JSON response that contains a field
/// named `tag_name` that holds a value representing the latest tagged release of this tool.
///
/// ex: v1.1.0
pub async fn check_for_updates(&mut self, url: &str, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: needs_update({}, {:?})", url, handles);
let api_url = Url::parse(url)?;
let result = logged_request(&api_url, DEFAULT_METHOD, None, handles.clone()).await?;
let body = result.text().await?;
let json_response: Value = serde_json::from_str(&body)?;
let latest_version = match json_response["tag_name"].as_str() {
Some(tag) => tag.trim_start_matches('v'),
None => {
bail!("JSON has no tag_name: {}", json_response);
}
};
// if we've gotten this far, we have a string in the form of X.X.X where X is a number
// all that's left is to compare the current version with the version found above
if latest_version == self.version {
// there's really only two possible outcomes if we accept that the tag conforms to
// the X.X.X pattern:
// 1. the version strings match, meaning we're up to date
// 2. the version strings do not match, meaning we're out of date
//
// except for developers working on this code, nobody should ever be in a situation
// where they have a version greater than the latest tagged release
self.update_status = UpdateStatus::UpToDate;
} else {
self.update_status = UpdateStatus::OutOfDate;
}
log::trace!("exit: check_for_updates -> {:?}", self.update_status);
Ok(())
}
/// display the banner on Write writer
pub fn print_to<W>(&self, mut writer: W, config: Arc<Configuration>) -> Result<()>
where
W: Write,
{
writeln!(&mut writer, "{}", self.header())?;
// begin with always printed items
for target in &self.targets {
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.wordlist)?;
writeln!(&mut writer, "{}", self.status_codes)?;
if !config.filter_status.is_empty() {
// exception here for an optional print in the middle of always printed values is due
// to me wanting the allows and denys to be printed one after the other
writeln!(&mut writer, "{}", self.filter_status)?;
}
writeln!(&mut writer, "{}", self.timeout)?;
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
if !config.config.is_empty() {
writeln!(&mut writer, "{}", self.config)?;
}
if !config.proxy.is_empty() {
writeln!(&mut writer, "{}", self.proxy)?;
}
if !config.replay_proxy.is_empty() {
// i include replay codes logic here because in config.rs, replay codes are set to the
// value in status codes, meaning it's never empty
writeln!(&mut writer, "{}", self.replay_proxy)?;
writeln!(&mut writer, "{}", self.replay_codes)?;
}
for header in &self.headers {
writeln!(&mut writer, "{}", header)?;
}
for filter in &self.filter_size {
writeln!(&mut writer, "{}", filter)?;
}
for filter in &self.filter_similar {
writeln!(&mut writer, "{}", filter)?;
}
for filter in &self.filter_word_count {
writeln!(&mut writer, "{}", filter)?;
}
for filter in &self.filter_line_count {
writeln!(&mut writer, "{}", filter)?;
}
for filter in &self.filter_regex {
writeln!(&mut writer, "{}", filter)?;
}
if config.extract_links {
writeln!(&mut writer, "{}", self.extract_links)?;
}
if config.json {
writeln!(&mut writer, "{}", self.json)?;
}
for query in &self.queries {
writeln!(&mut writer, "{}", query)?;
}
if !config.output.is_empty() {
writeln!(&mut writer, "{}", self.output)?;
}
if !config.debug_log.is_empty() {
writeln!(&mut writer, "{}", self.debug_log)?;
}
if !config.extensions.is_empty() {
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 {
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 {
writeln!(&mut writer, "{}", self.redirects)?;
}
if config.dont_filter {
writeln!(&mut writer, "{}", self.dont_filter)?;
}
if let 1..=4 = config.verbosity {
writeln!(&mut writer, "{}", self.verbosity)?;
}
if config.add_slash {
writeln!(&mut writer, "{}", self.add_slash)?;
}
writeln!(&mut writer, "{}", self.no_recursion)?;
if config.scan_limit > 0 {
writeln!(&mut writer, "{}", self.scan_limit)?;
}
if config.parallel > 0 {
writeln!(&mut writer, "{}", self.parallel)?;
}
if config.rate_limit > 0 {
writeln!(&mut writer, "{}", self.rate_limit)?;
}
if !config.time_limit.is_empty() {
writeln!(&mut writer, "{}", self.time_limit)?;
}
if matches!(self.update_status, UpdateStatus::OutOfDate) {
let update = BannerEntry::new(
"🎉",
"New Version Available",
"https://github.com/epi052/feroxbuster/releases/latest",
);
writeln!(&mut writer, "{}", update)?;
}
writeln!(&mut writer, "{}", self.footer())?;
Ok(())
}
}

57
src/banner/entry.rs Normal file
View File

@@ -0,0 +1,57 @@
use console::{measure_text_width, Emoji};
use std::fmt;
/// Initial visual indentation size used in formatting banner entries
const INDENT: usize = 3;
/// Column width used in formatting banner entries
const COL_WIDTH: usize = 22;
/// Represents a single line on the banner
#[derive(Default)]
pub(super) struct BannerEntry {
/// emoji used in the banner entry
emoji: String,
/// title used in the banner entry
title: String,
/// value passed in via config/cli/defaults
value: String,
}
/// implementation of a banner entry
impl BannerEntry {
/// Create a new banner entry from given fields
pub fn new(emoji: &str, title: &str, value: &str) -> Self {
BannerEntry {
emoji: emoji.to_string(),
title: title.to_string(),
value: value.to_string(),
}
}
/// Simple wrapper for emoji or fallback when terminal doesn't support emoji
fn format_emoji(&self) -> String {
let width = measure_text_width(&self.emoji);
let pad_len = width * width;
let pad = format!("{:<pad_len$}", "\u{0020}", pad_len = pad_len);
Emoji(&self.emoji, &pad).to_string()
}
}
/// Display implementation for a banner entry
impl fmt::Display for BannerEntry {
/// Display formatter for the given banner entry
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<width$}\u{2502}\u{0020}{}",
self.format_emoji(),
self.title,
self.value,
indent = INDENT,
width = COL_WIDTH
)
}
}

8
src/banner/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
//! all logic related to building/printing the banner seen when scans start
mod container;
mod entry;
#[cfg(test)]
mod tests;
pub use self::container::{Banner, UPDATE_URL};

174
src/banner/tests.rs Normal file
View File

@@ -0,0 +1,174 @@
use super::container::UpdateStatus;
use super::*;
use crate::{config::Configuration, event_handlers::Handles, scan_manager::FeroxScans};
use httpmock::Method::GET;
use httpmock::MockServer;
use std::{io::stderr, sync::Arc, time::Duration};
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test to hit no execution of targets for loop in banner
async fn banner_intialize_without_targets() {
let config = Configuration::new().unwrap();
let banner = Banner::new(&[], &config);
banner.print_to(stderr(), Arc::new(config)).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test to hit no execution of statuscode for loop in banner
async fn banner_intialize_without_status_codes() {
let config = Configuration {
status_codes: vec![],
..Default::default()
};
let banner = Banner::new(&[String::from("http://localhost")], &config);
banner.print_to(stderr(), Arc::new(config)).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test to hit an empty config file
async fn banner_intialize_without_config_file() {
let config = Configuration {
config: String::new(),
..Default::default()
};
let banner = Banner::new(&[String::from("http://localhost")], &config);
banner.print_to(stderr(), Arc::new(config)).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test to hit an empty queries
async fn banner_intialize_without_queries() {
let config = Configuration {
queries: vec![(String::new(), String::new())],
..Default::default()
};
let banner = Banner::new(&[String::from("http://localhost")], &config);
banner.print_to(stderr(), Arc::new(config)).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test that
async fn banner_needs_update_returns_unknown_with_bad_url() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let mut banner = Banner::new(
&[String::from("http://localhost")],
&Configuration::new().unwrap(),
);
let _ = banner.check_for_updates("", handles).await;
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test return value of good url to needs_update
async fn banner_needs_update_returns_up_to_date() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("{\"tag_name\":\"v1.1.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());
banner.version = String::from("1.1.0");
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(banner.update_status, UpdateStatus::UpToDate));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test return value of good url to needs_update that returns a newer version than current
async fn banner_needs_update_returns_out_of_date() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("{\"tag_name\":\"v1.1.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());
banner.version = String::from("1.0.1");
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(banner.update_status, UpdateStatus::OutOfDate));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test return value of good url that times out
async fn banner_needs_update_returns_unknown_on_timeout() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200)
.body("{\"tag_name\":\"v1.1.0\"}")
.delay(Duration::from_secs(8));
});
let handles = Arc::new(Handles::for_testing(None, None).0);
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test return value of good url with bad json response
async fn banner_needs_update_returns_unknown_on_bad_json_response() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("not json");
});
let handles = Arc::new(Handles::for_testing(None, None).0);
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test return value of good url with json response that lacks the tag_name field
async fn banner_needs_update_returns_unknown_on_json_without_correct_tag() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200)
.body("{\"no tag_name\": \"doesn't exist\"}");
});
let handles = Arc::new(Handles::for_testing(None, None).0);
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
banner.version = String::from("1.0.1");
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
assert_eq!(mock.hits(), 1);
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
}

View File

@@ -1,10 +1,8 @@
use crate::utils::{module_colorizer, status_colorizer};
use anyhow::Result;
use reqwest::header::HeaderMap;
use reqwest::{redirect::Policy, Client, Proxy};
use std::collections::HashMap;
use std::convert::TryInto;
#[cfg(not(test))]
use std::process::exit;
use std::time::Duration;
/// Create and return an instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
@@ -15,68 +13,32 @@ pub fn initialize(
insecure: bool,
headers: &HashMap<String, String>,
proxy: Option<&str>,
) -> Client {
) -> Result<Client> {
let policy = if redirects {
Policy::limited(10)
} else {
Policy::none()
};
// try_into returns infallible as its error, unwrap is safe here
let header_map: HeaderMap = headers.try_into().unwrap();
let header_map: HeaderMap = headers.try_into()?;
let client = Client::builder()
.timeout(Duration::new(timeout, 0))
.user_agent(user_agent)
.danger_accept_invalid_certs(insecure)
.default_headers(header_map)
.redirect(policy);
.redirect(policy)
.http1_title_case_headers();
let client = match proxy {
// a proxy is specified, need to add it to the client
Some(some_proxy) => {
if !some_proxy.is_empty() {
// it's not an empty string
match Proxy::all(some_proxy) {
Ok(proxy_obj) => client.proxy(proxy_obj),
Err(e) => {
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
module_colorizer("Client::initialize"),
e
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
}
} else {
client // Some("") was used?
}
}
// no proxy specified
None => client,
};
match client.build() {
Ok(client) => client,
Err(e) => {
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
module_colorizer("Client::build"),
e
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
if let Some(some_proxy) = proxy {
if !some_proxy.is_empty() {
// it's not an empty string; set the proxy
let proxy_obj = Proxy::all(some_proxy)?;
return Ok(client.proxy(proxy_obj).build()?);
}
}
Ok(client.build()?)
}
#[cfg(test)]
@@ -88,7 +50,7 @@ mod tests {
/// create client with a bad proxy, expect panic
fn client_with_bad_proxy() {
let headers = HashMap::new();
initialize(0, "stuff", true, false, &headers, Some("not a valid proxy"));
initialize(0, "stuff", true, false, &headers, Some("not a valid proxy")).unwrap();
}
#[test]
@@ -96,6 +58,6 @@ mod tests {
fn client_with_good_proxy() {
let headers = HashMap::new();
let proxy = "http://127.0.0.1:8080";
initialize(0, "stuff", true, true, &headers, Some(proxy));
initialize(0, "stuff", true, true, &headers, Some(proxy)).unwrap();
}
}

File diff suppressed because it is too large Load Diff

9
src/config/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! all logic related to instantiating a running configuration
mod container;
mod utils;
#[cfg(test)]
mod tests;
pub use self::container::Configuration;
pub use self::utils::{determine_output_level, OutputLevel, RequesterPolicy};

497
src/config/tests.rs Normal file
View File

@@ -0,0 +1,497 @@
use super::utils::*;
use super::*;
use crate::{traits::FeroxSerialize, DEFAULT_CONFIG_NAME};
use regex::Regex;
use reqwest::Url;
use std::{collections::HashMap, fs::write};
use tempfile::TempDir;
/// creates a dummy configuration file for testing
fn setup_config_test() -> Configuration {
let data = r#"
wordlist = "/some/path"
status_codes = [201, 301, 401]
replay_codes = [201, 301]
threads = 40
timeout = 5
proxy = "http://127.0.0.1:8080"
replay_proxy = "http://127.0.0.1:8081"
quiet = true
silent = true
auto_tune = true
auto_bail = true
verbosity = 1
scan_limit = 6
parallel = 14
rate_limit = 250
time_limit = "10m"
output = "/some/otherpath"
debug_log = "/yet/anotherpath"
resume_from = "/some/state/file"
redirects = true
insecure = true
collect_backups = true
collect_extensions = true
collect_words = true
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"}
queries = [["name","value"], ["rick", "astley"]]
no_recursion = true
add_slash = true
stdin = true
dont_filter = true
extract_links = true
json = true
save_state = false
depth = 1
filter_size = [4120]
filter_regex = ["^ignore me$"]
filter_similar = ["https://somesite.com/soft404"]
filter_word_count = [994, 992]
filter_line_count = [34]
filter_status = [201]
"#;
let tmp_dir = TempDir::new().unwrap();
let file = tmp_dir.path().join(DEFAULT_CONFIG_NAME);
write(&file, data).unwrap();
Configuration::parse_config(file).unwrap()
}
#[test]
/// test that all default config values meet expectations
fn default_configuration() {
let config = Configuration::default();
assert_eq!(config.wordlist, wordlist());
assert_eq!(config.proxy, String::new());
assert_eq!(config.target_url, String::new());
assert_eq!(config.time_limit, String::new());
assert_eq!(config.resume_from, String::new());
assert_eq!(config.debug_log, String::new());
assert_eq!(config.config, String::new());
assert_eq!(config.replay_proxy, String::new());
assert_eq!(config.status_codes, status_codes());
assert_eq!(config.replay_codes, config.status_codes);
assert!(config.replay_client.is_none());
assert_eq!(config.threads, threads());
assert_eq!(config.depth, depth());
assert_eq!(config.timeout, timeout());
assert_eq!(config.verbosity, 0);
assert_eq!(config.scan_limit, 0);
assert!(!config.silent);
assert!(!config.quiet);
assert_eq!(config.output_level, OutputLevel::Default);
assert!(!config.dont_filter);
assert!(!config.auto_tune);
assert!(!config.auto_bail);
assert_eq!(config.requester_policy, RequesterPolicy::Default);
assert!(!config.no_recursion);
assert!(!config.random_agent);
assert!(!config.json);
assert!(config.save_state);
assert!(!config.stdin);
assert!(!config.add_slash);
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.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_similar, Vec::<String>::new());
assert_eq!(config.filter_word_count, Vec::<usize>::new());
assert_eq!(config.filter_line_count, Vec::<usize>::new());
assert_eq!(config.filter_status, Vec::<u16>::new());
assert_eq!(config.headers, HashMap::new());
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_wordlist() {
let config = setup_config_test();
assert_eq!(config.wordlist, "/some/path");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_debug_log() {
let config = setup_config_test();
assert_eq!(config.debug_log, "/yet/anotherpath");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_status_codes() {
let config = setup_config_test();
assert_eq!(config.status_codes, vec![201, 301, 401]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_replay_codes() {
let config = setup_config_test();
assert_eq!(config.replay_codes, vec![201, 301]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_threads() {
let config = setup_config_test();
assert_eq!(config.threads, 40);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_depth() {
let config = setup_config_test();
assert_eq!(config.depth, 1);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_scan_limit() {
let config = setup_config_test();
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]
/// parse the test config and see that the value parsed is correct
fn config_reads_rate_limit() {
let config = setup_config_test();
assert_eq!(config.rate_limit, 250);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_timeout() {
let config = setup_config_test();
assert_eq!(config.timeout, 5);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_proxy() {
let config = setup_config_test();
assert_eq!(config.proxy, "http://127.0.0.1:8080");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_replay_proxy() {
let config = setup_config_test();
assert_eq!(config.replay_proxy, "http://127.0.0.1:8081");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_silent() {
let config = setup_config_test();
assert!(config.silent);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_quiet() {
let config = setup_config_test();
assert!(config.quiet);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_json() {
let config = setup_config_test();
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]
/// parse the test config and see that the value parsed is correct
fn config_reads_verbosity() {
let config = setup_config_test();
assert_eq!(config.verbosity, 1);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_output() {
let config = setup_config_test();
assert_eq!(config.output, "/some/otherpath");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_redirects() {
let config = setup_config_test();
assert!(config.redirects);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_insecure() {
let config = setup_config_test();
assert!(config.insecure);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_no_recursion() {
let config = setup_config_test();
assert!(config.no_recursion);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_stdin() {
let config = setup_config_test();
assert!(config.stdin);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_dont_filter() {
let config = setup_config_test();
assert!(config.dont_filter);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_add_slash() {
let config = setup_config_test();
assert!(config.add_slash);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_extract_links() {
let config = setup_config_test();
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]
/// parse the test config and see that the value parsed is correct
fn config_reads_extensions() {
let config = setup_config_test();
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]
/// parse the test config and see that the value parsed is correct
fn config_reads_filter_regex() {
let config = setup_config_test();
assert_eq!(config.filter_regex, vec!["^ignore me$"]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_filter_similar() {
let config = setup_config_test();
assert_eq!(config.filter_similar, vec!["https://somesite.com/soft404"]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_filter_size() {
let config = setup_config_test();
assert_eq!(config.filter_size, vec![4120]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_filter_word_count() {
let config = setup_config_test();
assert_eq!(config.filter_word_count, vec![994, 992]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_filter_line_count() {
let config = setup_config_test();
assert_eq!(config.filter_line_count, vec![34]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_filter_status() {
let config = setup_config_test();
assert_eq!(config.filter_status, vec![201]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_save_state() {
let config = setup_config_test();
assert!(!config.save_state);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_time_limit() {
let config = setup_config_test();
assert_eq!(config.time_limit, "10m");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_resume_from() {
let config = setup_config_test();
assert_eq!(config.resume_from, "/some/state/file");
}
#[test]
/// parse the test config and see that the values parsed are correct
fn config_reads_headers() {
let config = setup_config_test();
let mut headers = HashMap::new();
headers.insert("stuff".to_string(), "things".to_string());
headers.insert("mostuff".to_string(), "mothings".to_string());
assert_eq!(config.headers, headers);
}
#[test]
/// parse the test config and see that the values parsed are correct
fn config_reads_queries() {
let config = setup_config_test();
let queries = vec![
("name".to_string(), "value".to_string()),
("rick".to_string(), "astley".to_string()),
];
assert_eq!(config.queries, queries);
}
#[test]
fn config_default_not_random_agent() {
let config = setup_config_test();
assert!(!config.random_agent);
}
#[test]
#[should_panic]
/// test that an error message is printed and panic is called when report_and_exit is called
fn config_report_and_exit_works() {
report_and_exit("some message");
}
#[test]
/// test as_str method of Configuration
fn as_str_returns_string_with_newline() {
let config = Configuration::new().unwrap();
let config_str = config.as_str();
println!("{}", config_str);
assert!(config_str.starts_with("Configuration {"));
assert!(config_str.ends_with("}\n"));
assert!(config_str.contains("replay_codes:"));
assert!(config_str.contains("client: Client {"));
assert!(config_str.contains("user_agent: \"feroxbuster"));
}
#[test]
/// test as_json method of Configuration
fn as_json_returns_json_representation_of_configuration_with_newline() {
let mut config = Configuration::new().unwrap();
config.timeout = 12;
config.depth = 2;
let config_str = config.as_json().unwrap();
let json: Configuration = serde_json::from_str(&config_str).unwrap();
assert_eq!(json.config, config.config);
assert_eq!(json.wordlist, config.wordlist);
assert_eq!(json.replay_codes, config.replay_codes);
assert_eq!(json.timeout, config.timeout);
assert_eq!(json.depth, config.depth);
}

195
src/config/utils.rs Normal file
View File

@@ -0,0 +1,195 @@
use crate::{
utils::{module_colorizer, status_colorizer},
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION,
};
#[cfg(not(test))]
use std::process::exit;
/// simple helper to clean up some code reuse below; panics under test / exits in prod
pub(super) fn report_and_exit(err: &str) -> ! {
eprintln!(
"{} {}: {}",
status_colorizer("ERROR"),
module_colorizer("Configuration::new"),
err
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
// functions timeout, threads, status_codes, user_agent, wordlist, save_state, and depth are used to provide
// defaults in the event that a ferox-config.toml is found but one or more of the values below
// aren't listed in the config. This way, we get the correct defaults upon Deserialization
/// default Configuration type for use in json output
pub(super) fn serialized_type() -> String {
String::from("configuration")
}
/// default timeout value
pub(super) fn timeout() -> u64 {
7
}
/// default save_state value
pub(super) fn save_state() -> bool {
true
}
/// default threads value
pub(super) fn threads() -> usize {
50
}
/// default status codes
pub(super) fn status_codes() -> Vec<u16> {
DEFAULT_STATUS_CODES
.iter()
.map(|code| code.as_u16())
.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()
}
/// default wordlist
pub(super) fn wordlist() -> String {
String::from(DEFAULT_WORDLIST)
}
/// default user-agent
pub(super) fn user_agent() -> String {
format!("feroxbuster/{}", VERSION)
}
/// default recursion depth
pub(super) fn depth() -> usize {
4
}
/// enum representing the three possible states for informational output (not logging verbosity)
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum OutputLevel {
/// normal scan, no --quiet|--silent
Default,
/// quiet scan, print some information, but not all (new in versions >= 2.0.0)
Quiet,
/// silent scan, only print urls (used to be --quiet in versions 1.x.x)
Silent,
}
/// implement a default for OutputLevel
impl Default for OutputLevel {
/// return Default
fn default() -> Self {
Self::Default
}
}
/// given the current settings for quiet and silent, determine output_level (DRY helper)
pub fn determine_output_level(quiet: bool, silent: bool) -> OutputLevel {
if quiet && silent {
// user COULD have both as true in config file, take the more quiet of the two
OutputLevel::Silent
} else if quiet {
OutputLevel::Quiet
} else if silent {
OutputLevel::Silent
} else {
OutputLevel::Default
}
}
/// represents actions the Requester should take in certain situations
#[derive(Debug, PartialEq, 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)]
mod tests {
use super::*;
#[test]
/// test determine_output_level returns higher of the two levels if both given values are true
fn determine_output_level_returns_correct_results() {
let mut level = determine_output_level(true, true);
assert_eq!(level, OutputLevel::Silent);
level = determine_output_level(false, true);
assert_eq!(level, OutputLevel::Silent);
level = determine_output_level(false, false);
assert_eq!(level, OutputLevel::Default);
level = determine_output_level(true, false);
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]
#[should_panic]
/// report_and_exit should panic/exit when called
fn report_and_exit_panics_under_test() {
report_and_exit("test");
}
}

View File

@@ -0,0 +1,81 @@
use std::sync::Arc;
use reqwest::StatusCode;
use tokio::sync::oneshot::Sender;
use crate::response::FeroxResponse;
use crate::{
message::FeroxMessage,
statistics::{StatError, StatField},
traits::FeroxFilter,
};
/// Protocol definition for updating an event handler via mpsc
#[derive(Debug)]
pub enum Command {
/// Add one to the total number of requests
AddRequest,
/// Add one to the proper field(s) based on the given `StatError`
AddError(StatError),
/// Add one to the proper field(s) based on the given `StatusCode`
AddStatus(StatusCode),
/// Create the progress bar (`BarType::Total`) that is updated from the stats thread
CreateBar,
/// Add to a `Stats` field that corresponds to the given `StatField` by the given `usize` value
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
AddToF64Field(StatField, f64),
/// Save a `Stats` object to disk using `reporter::get_cached_file_handle`
Save,
/// Load a `Stats` object from disk
LoadStats(String),
/// Add a `FeroxFilter` implementor to `FilterHandler`'s instance of `FeroxFilters`
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
Report(Box<FeroxResponse>),
/// Send a group of urls to be scanned (only used for the urls passed in explicitly by the user)
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
TryRecursion(Box<FeroxResponse>),
/// Send a pointer to the wordlist to the recursion handler
UpdateWordlist(Arc<Vec<String>>),
/// Instruct the ScanHandler to join on all known scans, use sender to notify main when done
JoinTasks(Sender<bool>),
/// Command used to test that a spawned task succeeded in initialization
Ping,
/// Just receive a sender and reply, used for slowing down the main thread
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
Exit,
}

View File

@@ -0,0 +1,182 @@
use super::*;
use crate::config::Configuration;
use crate::event_handlers::scans::ScanHandle;
use crate::scan_manager::FeroxScans;
use crate::Joiner;
#[cfg(test)]
use crate::{filters::FeroxFilters, statistics::Stats, Command};
use anyhow::{bail, Result};
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
#[cfg(test)]
use tokio::sync::mpsc::{self, UnboundedReceiver};
#[derive(Debug)]
/// Simple container for multiple JoinHandles
pub struct Tasks {
/// JoinHandle for terminal handler
pub terminal: Joiner,
/// JoinHandle for statistics handler
pub stats: Joiner,
/// JoinHandle for filters handler
pub filters: Joiner,
/// JoinHandle for scans handler
pub scans: Joiner,
}
/// Tasks implementation
impl Tasks {
/// Given JoinHandles for terminal, statistics, and filters create a new Tasks object
pub fn new(terminal: Joiner, stats: Joiner, filters: Joiner, scans: Joiner) -> Self {
Self {
terminal,
stats,
filters,
scans,
}
}
}
#[derive(Debug)]
/// Container for the different *Handles that will be shared across modules
pub struct Handles {
/// Handle for statistics
pub stats: StatsHandle,
/// Handle for filters
pub filters: FiltersHandle,
/// Handle for output (terminal/file)
pub output: TermOutHandle,
/// Handle for Configuration
pub config: Arc<Configuration>,
/// Handle for recursion
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
impl Handles {
/// Given a StatsHandle, FiltersHandle, and OutputHandle, create a Handles object
pub fn new(
stats: StatsHandle,
filters: FiltersHandle,
output: TermOutHandle,
config: Arc<Configuration>,
wordlist: Arc<Vec<String>>,
) -> Self {
Self {
stats,
filters,
output,
config,
scans: RwLock::new(None),
wordlist,
}
}
/// create a Handles object suitable for unit testing (non-functional)
#[cfg(test)]
pub fn for_testing(
scanned_urls: Option<Arc<FeroxScans>>,
config: Option<Arc<Configuration>>,
) -> (Self, UnboundedReceiver<Command>) {
let configuration = config.unwrap_or_else(|| Arc::new(Configuration::new().unwrap()));
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let terminal_handle = TermOutHandle::new(tx.clone(), tx.clone());
let stats_handle = StatsHandle::new(Arc::new(Stats::new(configuration.json)), tx.clone());
let filters_handle = FiltersHandle::new(Arc::new(FeroxFilters::default()), tx.clone());
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 {
let scan_handle = ScanHandle::new(sh, tx);
handles.set_scan_handle(scan_handle);
}
(handles, rx)
}
/// Set the ScanHandle object
pub fn set_scan_handle(&self, handle: ScanHandle) {
if let Ok(mut guard) = self.scans.write() {
if guard.is_none() {
let _ = std::mem::replace(&mut *guard, Some(handle));
}
}
}
/// Helper to easily send a Command over the (locked) underlying CommandSender object
pub fn send_scan_command(&self, command: Command) -> Result<()> {
if let Ok(guard) = self.scans.read().as_ref() {
if let Some(handle) = guard.as_ref() {
handle.send(command)?;
return Ok(());
}
}
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
pub fn ferox_scans(&self) -> Result<Arc<FeroxScans>> {
if let Ok(guard) = self.scans.read().as_ref() {
if let Some(handle) = guard.as_ref() {
return Ok(handle.data.clone());
}
}
bail!("Could not get underlying FeroxScans")
}
}

View File

@@ -0,0 +1,144 @@
use super::*;
use crate::filters::EmptyFilter;
use crate::{filters::FeroxFilters, CommandSender, FeroxChannel, Joiner};
use anyhow::Result;
use std::sync::Arc;
use tokio::sync::{
mpsc::{self, UnboundedReceiver},
oneshot,
};
#[derive(Debug)]
/// Container for filters transmitter and FeroxFilters object
pub struct FiltersHandle {
/// FeroxFilters object used across modules to track active filters
pub data: Arc<FeroxFilters>,
/// transmitter used to update `data`
pub tx: CommandSender,
}
/// implementation of FiltersHandle
impl FiltersHandle {
/// Given an Arc-wrapped FeroxFilters and CommandSender, create a new FiltersHandle
pub fn new(data: Arc<FeroxFilters>, tx: CommandSender) -> Self {
Self { data, tx }
}
/// Send the given Command over `tx`
pub fn send(&self, command: Command) -> Result<()> {
self.tx.send(command)?;
Ok(())
}
/// Sync the handle with the handler
pub async fn sync(&self) -> Result<()> {
let (tx, rx) = oneshot::channel::<bool>();
self.send(Command::Sync(tx))?;
rx.await?;
Ok(())
}
}
/// event handler for updating a single data structure of all active filters
#[derive(Debug)]
pub struct FiltersHandler {
/// collection of FeroxFilters
data: Arc<FeroxFilters>,
/// Receiver half of mpsc from which `Command`s are processed
receiver: UnboundedReceiver<Command>,
}
/// implementation of event handler for filters
impl FiltersHandler {
/// create new event handler
pub fn new(data: Arc<FeroxFilters>, receiver: UnboundedReceiver<Command>) -> Self {
Self { data, receiver }
}
/// Initialize new `FeroxFilters` and the sc side of an mpsc channel that is responsible for
/// updates to the aforementioned object.
pub fn initialize() -> (Joiner, FiltersHandle) {
log::trace!("enter: initialize");
let data = Arc::new(FeroxFilters::default());
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
let mut handler = Self::new(data.clone(), rx);
let task = tokio::spawn(async move { handler.start().await });
let event_handle = FiltersHandle::new(data, tx);
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
(task, event_handle)
}
/// Start a single consumer task (sc side of mpsc)
///
/// The consumer simply receives `Command` and acts accordingly
pub async fn start(&mut self) -> Result<()> {
log::trace!("enter: start({:?})", self);
while let Some(command) = self.receiver.recv().await {
match command {
Command::AddFilter(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) => {
log::debug!("filters: {:?}", self);
sender.send(true).unwrap_or_default();
}
Command::Exit => break,
_ => {} // no other commands needed for FilterHandler
}
}
log::trace!("exit: start");
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

@@ -0,0 +1,145 @@
use super::*;
use crate::{
progress::PROGRESS_PRINTER,
scan_manager::{FeroxState, PAUSE_SCAN},
scanner::RESPONSES,
statistics::StatError,
utils::slugify_filename,
utils::{open_file, write_to},
SLEEP_DURATION,
};
use anyhow::Result;
use console::style;
use crossterm::event::{self, Event, KeyCode};
use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread::sleep,
time::Duration,
};
/// Atomic boolean flag, used to determine whether or not the terminal input handler should exit
pub static SCAN_COMPLETE: AtomicBool = AtomicBool::new(false);
/// Container for filters transmitter and FeroxFilters object
pub struct TermInputHandler {
/// handles to other handlers
handles: Arc<Handles>,
}
/// implementation of event handler for terminal input
///
/// kicks off the following handlers related to terminal input:
/// ctrl+c handler that saves scan state to disk
/// enter handler that listens for enter during scans to drop into interactive scan management menu
impl TermInputHandler {
/// Create new event handler
pub fn new(handles: Arc<Handles>) -> Self {
Self { handles }
}
/// Initialize the sigint and enter handlers that are responsible for handling initial user
/// interaction during scans
pub fn initialize(handles: Arc<Handles>) {
log::trace!("enter: initialize({:?})", handles);
let handler = Self::new(handles);
handler.start();
log::trace!("exit: initialize");
}
/// wrapper around sigint_handler and enter_handler
fn start(&self) {
tokio::task::spawn_blocking(Self::enter_handler);
if self.handles.config.save_state {
// start the ctrl+c handler
let cloned = self.handles.clone();
let result = ctrlc::set_handler(move || {
let _ = Self::sigint_handler(cloned.clone());
});
if result.is_err() {
log::warn!("Could not set Ctrl+c handler; scan state will not be saved");
self.handles
.stats
.send(Command::AddError(StatError::Other))
.unwrap_or_default();
}
}
}
/// Writes the current state of the program to disk (if save_state is true) and then exits
pub fn sigint_handler(handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: sigint_handler({:?})", handles);
let filename = if !handles.config.target_url.is_empty() {
// target url populated
slugify_filename(&handles.config.target_url, "ferox", "state")
} else {
// stdin used
slugify_filename("stdin", "ferox", "state")
};
let warning = format!(
"🚨 Caught {} 🚨 saving scan state to {} ...",
style("ctrl+c").yellow(),
filename
);
PROGRESS_PRINTER.println(warning);
let state = FeroxState::new(
handles.ferox_scans()?,
handles.config.clone(),
&RESPONSES,
handles.stats.data.clone(),
handles.filters.data.clone(),
);
let state_file = open_file(&filename);
let mut buffered_file = state_file?;
write_to(&state, &mut buffered_file, true)?;
log::trace!("exit: sigint_handler (end of program)");
std::process::exit(1);
}
/// Handles specific key events triggered by the user over stdin
fn enter_handler() {
// todo eventually move away from atomics, the blocking recv is the problem
log::trace!("enter: start_enter_handler");
loop {
if PAUSE_SCAN.load(Ordering::Relaxed) {
// if the scan is already paused, we don't want this event poller fighting the user
// over stdin
sleep(Duration::from_millis(SLEEP_DURATION));
} else if event::poll(Duration::from_millis(SLEEP_DURATION)).unwrap_or(false) {
// It's guaranteed that the `read()` won't block when the `poll()`
// function returns `true`
if let Ok(key_pressed) = event::read() {
// ignore any other keys
if key_pressed == Event::Key(KeyCode::Enter.into()) {
// if the user presses Enter, set PAUSE_SCAN to true. The interactive menu
// will be triggered and will handle setting PAUSE_SCAN to false
PAUSE_SCAN.store(true, Ordering::Release);
}
}
} else {
// Timeout expired and no `Event` is available; use the timeout to check SCAN_COMPLETE
if SCAN_COMPLETE.load(Ordering::Relaxed) {
// scan has been marked complete by main, time to exit the loop
break;
}
}
}
log::trace!("exit: start_enter_handler");
}
}

16
src/event_handlers/mod.rs Normal file
View File

@@ -0,0 +1,16 @@
//! collection of event handlers (typically long-running tokio spawned tasks)
mod statistics;
mod filters;
mod container;
mod command;
mod outputs;
mod scans;
mod inputs;
pub use self::command::Command;
pub use self::container::{Handles, Tasks};
pub use self::filters::{FiltersHandle, FiltersHandler};
pub use self::inputs::{TermInputHandler, SCAN_COMPLETE};
pub use self::outputs::{TermOutHandle, TermOutHandler};
pub use self::scans::{ScanHandle, ScanHandler};
pub use self::statistics::{StatsHandle, StatsHandler};

View File

@@ -0,0 +1,557 @@
use super::Command::AddToUsizeField;
use super::*;
use anyhow::{Context, Result};
use futures::future::{BoxFuture, FutureExt};
use tokio::sync::{mpsc, oneshot};
use crate::statistics::StatField::TotalExpected;
use crate::{
config::Configuration,
progress::PROGRESS_PRINTER,
response::FeroxResponse,
scanner::RESPONSES,
send_command, skip_fail,
statistics::StatField::ResourcesDiscovered,
traits::FeroxSerialize,
utils::{ferox_print, fmt_err, make_request, open_file, write_to},
CommandReceiver, CommandSender, Joiner,
};
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)]
/// Container for terminal output transmitter
pub struct TermOutHandle {
/// Transmitter that sends to the TermOutHandler handler
pub tx: CommandSender,
/// Transmitter that sends to the FileOutHandler handler
pub tx_file: CommandSender,
}
/// implementation of OutputHandle
impl TermOutHandle {
/// Given a CommandSender, create a new OutputHandle
pub fn new(tx: CommandSender, tx_file: CommandSender) -> Self {
Self { tx, tx_file }
}
/// Send the given Command over `tx`
pub fn send(&self, command: Command) -> Result<()> {
self.tx.send(command)?;
Ok(())
}
/// Sync the handle with the handler
pub async fn sync(&self, send_to_file: bool) -> Result<()> {
let (tx, rx) = oneshot::channel::<bool>();
self.send(Command::Sync(tx))?;
if send_to_file {
let (tx, rx) = oneshot::channel::<bool>();
self.tx_file.send(Command::Sync(tx))?;
rx.await?;
}
rx.await?;
Ok(())
}
}
#[derive(Debug)]
/// Event handler for files
pub struct FileOutHandler {
/// file output handler's receiver
receiver: CommandReceiver,
/// pointer to "global" configuration struct
config: Arc<Configuration>,
}
impl FileOutHandler {
/// Given a file tx/rx pair along with a filename and awaitable task, create
/// a FileOutHandler
fn new(rx: CommandReceiver, config: Arc<Configuration>) -> Self {
Self {
receiver: rx,
config,
}
}
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives responses from the terminal handler and writes them to disk
async fn start(&mut self, tx_stats: CommandSender) -> Result<()> {
log::trace!("enter: start_file_handler({:?})", tx_stats);
let mut file = open_file(&self.config.output)?;
log::info!("Writing scan results to {}", self.config.output);
while let Some(command) = self.receiver.recv().await {
match command {
Command::Report(response) => {
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 => {
break;
}
Command::Sync(sender) => {
skip_fail!(sender.send(true));
}
_ => {} // no more needed
}
}
// close the file before we tell statistics to save current data to the same file
drop(file);
send_command!(tx_stats, Command::Save);
log::trace!("exit: start_file_handler");
Ok(())
}
}
#[derive(Debug)]
/// Event handler for terminal
pub struct TermOutHandler {
/// terminal output handler's receiver
receiver: CommandReceiver,
/// file handler
tx_file: CommandSender,
/// optional file handler task
file_task: Option<Joiner>,
/// pointer to "global" configuration struct
config: Arc<Configuration>,
}
/// implementation of TermOutHandler
impl TermOutHandler {
/// Given a terminal receiver along with a file transmitter and filename, create
/// an OutputHandler
fn new(
receiver: CommandReceiver,
tx_file: CommandSender,
file_task: Option<Joiner>,
config: Arc<Configuration>,
) -> Self {
Self {
receiver,
tx_file,
file_task,
config,
}
}
/// Creates all required output handlers (terminal, file) and updates the given Handles/Tasks
pub fn initialize(
config: Arc<Configuration>,
tx_stats: CommandSender,
) -> (Joiner, TermOutHandle) {
log::trace!("enter: initialize({:?}, {:?})", config, tx_stats);
let (tx_term, rx_term) = mpsc::unbounded_channel::<Command>();
let (tx_file, rx_file) = mpsc::unbounded_channel::<Command>();
let mut file_handler = FileOutHandler::new(rx_file, config.clone());
let tx_stats_clone = tx_stats.clone();
let file_task = if !config.output.is_empty() {
// -o used, need to spawn the thread for writing to disk
Some(tokio::spawn(async move {
file_handler.start(tx_stats_clone).await
}))
} else {
None
};
let mut term_handler = Self::new(rx_term, tx_file.clone(), file_task, config);
let term_task = tokio::spawn(async move { term_handler.start(tx_stats).await });
let event_handle = TermOutHandle::new(tx_term, tx_file);
log::trace!("exit: initialize -> ({:?}, {:?})", term_task, event_handle);
(term_task, event_handle)
}
/// Start a single consumer task (sc side of mpsc)
///
/// The consumer simply receives `Command` and acts accordingly
async fn start(&mut self, tx_stats: CommandSender) -> Result<()> {
log::trace!("enter: start({:?})", tx_stats);
while let Some(command) = self.receiver.recv().await {
match command {
Command::Report(resp) => {
self.process_response(tx_stats.clone(), resp, ProcessResponseCall::Recursive)
.await?;
}
Command::Sync(sender) => {
sender.send(true).unwrap_or_default();
}
Command::Exit => {
if self.file_task.is_some() && self.tx_file.send(Command::Exit).is_ok() {
self.file_task.as_mut().unwrap().await??; // wait for death
}
break;
}
_ => {} // no more commands needed
}
}
log::trace!("exit: start");
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 contains_sentry = 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;
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 {} 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; 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!(".{}.swp", filename), &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)]
mod tests {
use super::*;
#[test]
/// try to hit struct field coverage of FileOutHandler
fn struct_fields_of_file_out_handler() {
let (_, rx) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let foh = FileOutHandler {
config,
receiver: rx,
};
println!("{:?}", foh);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// try to hit struct field coverage of TermOutHandler
async fn struct_fields_of_term_out_handler() {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
};
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 toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
};
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 toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
};
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 toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
};
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();
}
}

397
src/event_handlers/scans.rs Normal file
View File

@@ -0,0 +1,397 @@
use std::sync::Arc;
use anyhow::{bail, Result};
use tokio::sync::{mpsc, Semaphore};
use crate::{
response::FeroxResponse,
scan_manager::{FeroxScan, FeroxScans, ScanOrder},
scanner::FeroxScanner,
statistics::StatField::TotalScans,
url::FeroxUrl,
utils::should_deny_url,
CommandReceiver, CommandSender, FeroxChannel, Joiner, SLEEP_DURATION,
};
use super::command::Command::AddToUsizeField;
use super::*;
use crate::statistics::StatField;
use reqwest::Url;
use tokio::time::Duration;
#[derive(Debug)]
/// Container for recursion transmitter and FeroxScans object
pub struct ScanHandle {
/// FeroxScans object used across modules to track scans
pub data: Arc<FeroxScans>,
/// transmitter used to update `data`
pub tx: CommandSender,
}
/// implementation of RecursionHandle
impl ScanHandle {
/// Given an Arc-wrapped FeroxScans and CommandSender, create a new RecursionHandle
pub fn new(data: Arc<FeroxScans>, tx: CommandSender) -> Self {
Self { data, tx }
}
/// Send the given Command over `tx`
pub fn send(&self, command: Command) -> Result<()> {
self.tx.send(command)?;
Ok(())
}
}
/// event handler for updating a single data structure of all FeroxScans
#[derive(Debug)]
pub struct ScanHandler {
/// collection of FeroxScans
data: Arc<FeroxScans>,
/// handles to other handlers needed to kick off a scan while already past main
handles: Arc<Handles>,
/// Receiver half of mpsc from which `Command`s are processed
receiver: CommandReceiver,
/// wordlist (re)used for each scan
wordlist: std::sync::Mutex<Option<Arc<Vec<String>>>>,
/// group of scans that need to be joined
tasks: Vec<Arc<FeroxScan>>,
/// Maximum recursion depth, a depth of 0 is infinite recursion
max_depth: usize,
/// depths associated with the initial targets provided by the user
depths: Vec<(String, usize)>,
/// Bounded semaphore used as a barrier to limit concurrent scans
limiter: Arc<Semaphore>,
}
/// implementation of event handler for filters
impl ScanHandler {
/// create new event handler
pub fn new(
data: Arc<FeroxScans>,
handles: Arc<Handles>,
max_depth: usize,
receiver: CommandReceiver,
) -> Self {
let limit = handles.config.scan_limit;
let limiter = Semaphore::new(limit);
if limit == 0 {
// scan_limit == 0 means no limit should be imposed... however, scoping the Semaphore
// permit is tricky, so as a workaround, we'll add a ridiculous number of permits to
// the semaphore (1,152,921,504,606,846,975 to be exact) and call that 'unlimited'
// note to self: the docs say max is usize::MAX >> 3, however, threads will panic if
// that value is used (says adding (1) will overflow the semaphore, even though none
// are being added...)
limiter.add_permits(usize::MAX >> 4);
}
Self {
data,
handles,
receiver,
max_depth,
tasks: Vec::new(),
depths: Vec::new(),
limiter: Arc::new(limiter),
wordlist: std::sync::Mutex::new(None),
}
}
/// Set the wordlist
fn wordlist(&self, wordlist: Arc<Vec<String>>) {
if let Ok(mut guard) = self.wordlist.lock() {
if guard.is_none() {
let _ = std::mem::replace(&mut *guard, Some(wordlist));
}
}
}
/// Initialize new `FeroxScans` and the sc side of an mpsc channel that is responsible for
/// updates to the aforementioned object.
pub fn initialize(handles: Arc<Handles>) -> (Joiner, ScanHandle) {
log::trace!("enter: initialize");
let data = Arc::new(FeroxScans::new(handles.config.output_level));
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
let max_depth = handles.config.depth;
let mut handler = Self::new(data.clone(), handles, max_depth, rx);
let task = tokio::spawn(async move { handler.start().await });
let event_handle = ScanHandle::new(data, tx);
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
(task, event_handle)
}
/// Start a single consumer task (sc side of mpsc)
///
/// The consumer simply receives `Command` and acts accordingly
pub async fn start(&mut self) -> Result<()> {
log::trace!("enter: start({:?})", self);
while let Some(command) = self.receiver.recv().await {
match command {
Command::ScanInitialUrls(targets) => {
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) => {
self.wordlist(wordlist);
}
Command::JoinTasks(sender) => {
let ferox_scans = self.handles.ferox_scans().unwrap_or_default();
let limiter_clone = self.limiter.clone();
tokio::spawn(async move {
while ferox_scans.has_active_scans() {
tokio::time::sleep(Duration::from_millis(SLEEP_DURATION + 250)).await;
}
limiter_clone.close();
sender.send(true).expect("oneshot channel failed");
});
}
Command::TryRecursion(response) => {
self.try_recursion(response).await?;
}
Command::Sync(sender) => {
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
}
}
log::trace!("exit: start");
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()?.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
pub fn get_wordlist(&self) -> Result<Arc<Vec<String>>> {
if let Ok(guard) = self.wordlist.lock().as_ref() {
if let Some(list) = guard.as_ref() {
return Ok(list.clone());
}
}
bail!("Could not get underlying wordlist")
}
/// wrapper around scanning a url to stay DRY
async fn ordered_scan_url(&mut self, targets: Vec<String>, order: ScanOrder) -> Result<()> {
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 {
if self.data.contains(&target) && matches!(order, ScanOrder::Latest) {
// FeroxScans knows about this url and scan isn't an Initial scan
// initial scans are skipped because when resuming from a .state file, the scans
// will already be populated in FeroxScans, so we need to not skip kicking off
// their scans
continue;
}
let scan = if let Some(ferox_scan) = self.data.get_scan_by_url(&target) {
ferox_scan // scan already known
} else {
self.data.add_directory_scan(&target, order).1 // add the new target; return FeroxScan
};
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()?;
log::info!("scan handler received {} - beginning scan", target);
if matches!(order, ScanOrder::Initial) {
// keeps track of the initial targets' scan depths in order to enforce the
// maximum recursion depth on any identified sub-directories
let url = FeroxUrl::from_string(&target, self.handles.clone());
let depth = url.depth().unwrap_or(0);
self.depths.push((target.clone(), depth));
}
let scanner = FeroxScanner::new(
&target,
order,
list,
self.limiter.clone(),
self.handles.clone(),
);
let task = tokio::spawn(async move {
if let Err(e) = scanner.scan_url().await {
log::warn!("{}", e);
}
});
self.handles.stats.send(AddToUsizeField(TotalScans, 1))?;
scan.set_task(task).await?;
self.tasks.push(scan.clone());
}
log::trace!("exit: ordered_scan_url");
Ok(())
}
async fn try_recursion(&mut self, response: Box<FeroxResponse>) -> Result<()> {
log::trace!("enter: try_recursion({:?})", response,);
if !response.is_directory() {
// not a directory, quick exit
return Ok(());
}
let mut base_depth = 1_usize;
for (base_url, base_url_depth) in &self.depths {
if response.url().as_str().starts_with(base_url) {
base_depth = *base_url_depth;
}
}
if response.reached_max_depth(base_depth, self.max_depth, self.handles.clone()) {
// at or past recursion depth
return Ok(());
}
let targets = vec![response.url().to_string()];
self.ordered_scan_url(targets, ScanOrder::Latest).await?;
log::info!("Added new directory to recursive scan: {}", response.url());
log::trace!("exit: try_recursion");
Ok(())
}
}

View File

@@ -0,0 +1,171 @@
use super::*;
use crate::{
config::Configuration,
progress::{add_bar, BarType},
statistics::{StatField, Stats},
CommandSender, FeroxChannel, Joiner,
};
use anyhow::Result;
use console::style;
use indicatif::ProgressBar;
use std::{sync::Arc, time::Instant};
use tokio::sync::{
mpsc::{self, UnboundedReceiver},
oneshot,
};
#[derive(Debug)]
/// Container for statistics transmitter and Stats object
pub struct StatsHandle {
/// Stats object used across modules to track statistics
pub data: Arc<Stats>,
/// transmitter used to update `data`
pub tx: CommandSender,
}
/// implementation of StatsHandle
impl StatsHandle {
/// Given an Arc-wrapped Stats and CommandSender, create a new StatsHandle
pub fn new(data: Arc<Stats>, tx: CommandSender) -> Self {
Self { data, tx }
}
/// Send the given Command over `tx`
pub fn send(&self, command: Command) -> Result<()> {
self.tx.send(command)?;
Ok(())
}
/// Sync the handle with the handler
pub async fn sync(&self) -> Result<()> {
let (tx, rx) = oneshot::channel::<bool>();
self.send(Command::Sync(tx))?;
rx.await?;
Ok(())
}
}
/// event handler struct for updating statistics
#[derive(Debug)]
pub struct StatsHandler {
/// overall scan's progress bar
bar: ProgressBar,
/// Receiver half of mpsc from which `StatCommand`s are processed
receiver: UnboundedReceiver<Command>,
/// data class that stores all statistics updates
stats: Arc<Stats>,
}
/// implementation of event handler for statistics
impl StatsHandler {
/// create new event handler
fn new(stats: Arc<Stats>, rx_stats: UnboundedReceiver<Command>) -> Self {
// will be updated later via StatCommand; delay is for banner to print first
let bar = ProgressBar::hidden();
Self {
bar,
stats,
receiver: rx_stats,
}
}
/// Start a single consumer task (sc side of mpsc)
///
/// The consumer simply receives `StatCommands` and updates the given `Stats` object as appropriate
async fn start(&mut self, output_file: &str) -> Result<()> {
log::trace!("enter: start({:?})", self);
let start = Instant::now();
while let Some(command) = self.receiver.recv().await {
match command as Command {
Command::AddError(err) => {
self.stats.add_error(err);
self.increment_bar();
}
Command::AddStatus(status) => {
self.stats.add_status_code(status);
self.increment_bar();
}
Command::AddRequest => {
self.stats.add_request();
self.increment_bar();
}
Command::Save => {
self.stats
.save(start.elapsed().as_secs_f64(), output_file)?;
}
Command::AddToUsizeField(field, value) => {
self.stats.update_usize_field(field, value);
if matches!(field, StatField::TotalScans | StatField::TotalExpected) {
self.bar.set_length(self.stats.total_expected() as u64);
}
}
Command::SubtractFromUsizeField(field, value) => {
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 => {
self.bar = add_bar("", self.stats.total_expected() as u64, BarType::Total);
}
Command::LoadStats(filename) => {
self.stats.merge_from(&filename)?;
}
Command::Sync(sender) => {
sender.send(true).unwrap_or_default();
}
Command::Exit => break,
_ => {} // no more commands needed
}
}
self.bar.finish();
log::debug!("{:#?}", *self.stats);
log::trace!("exit: start");
Ok(())
}
/// Wrapper around incrementing the overall scan's progress bar
fn increment_bar(&self) {
let msg = format!(
"{}:{:<7} {}:{:<7}",
style("found").green(),
self.stats.resources_discovered(),
style("errors").red(),
self.stats.errors(),
);
self.bar.set_message(&msg);
self.bar.inc(1);
}
/// Initialize new `Stats` object and the sc side of an mpsc channel that is responsible for
/// updates to the aforementioned object.
pub fn initialize(config: Arc<Configuration>) -> (Joiner, StatsHandle) {
log::trace!("enter: initialize");
let data = Arc::new(Stats::new(config.json));
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
let mut handler = StatsHandler::new(data.clone(), rx);
let task = tokio::spawn(async move { handler.start(&config.output).await });
let event_handle = StatsHandle::new(data, tx);
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
(task, event_handle)
}
}

View File

@@ -1,497 +0,0 @@
use crate::{
client,
config::{Configuration, CONFIGURATION},
scanner::SCANNED_URLS,
statistics::{
StatCommand::{self, UpdateUsizeField},
StatField::{LinksExtracted, TotalExpected},
},
utils::{format_url, make_request},
FeroxResponse,
};
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::Url;
use std::collections::HashSet;
use tokio::sync::mpsc::UnboundedSender;
/// Regular expression used in [LinkFinder](https://github.com/GerbenJavado/LinkFinder)
///
/// Incorporates change from this [Pull Request](https://github.com/GerbenJavado/LinkFinder/pull/66/files)
const LINKFINDER_REGEX: &str = r#"(?:"|')(((?:[a-zA-Z]{1,10}://|//)[^"'/]{1,}\.[a-zA-Z]{2,}[^"']{0,})|((?:/|\.\./|\./)[^"'><,;| *()(%%$^/\\\[\]][^"'><,;|()]{1,})|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{1,}\.(?:[a-zA-Z]{1,4}|action)(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{3,}(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-.]{1,}\.(?:php|asp|aspx|jsp|json|action|html|js|txt|xml)(?:[\?|#][^"|']{0,}|)))(?:"|')"#;
/// Regular expression to pull url paths from robots.txt
///
/// ref: https://developers.google.com/search/reference/robots_txt
const ROBOTS_TXT_REGEX: &str =
r#"(?m)^ *(Allow|Disallow): *(?P<url_path>[a-zA-Z0-9._/?#@!&'()+,;%=-]+?)$"#; // multi-line (?m)
lazy_static! {
/// `LINKFINDER_REGEX` as a regex::Regex type
static ref LINKS_REGEX: Regex = Regex::new(LINKFINDER_REGEX).unwrap();
/// `ROBOTS_TXT_REGEX` as a regex::Regex type
static ref ROBOTS_REGEX: Regex = Regex::new(ROBOTS_TXT_REGEX).unwrap();
}
/// 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`
/// the following fragments would be returned:
/// - homepage/assets/img/icons/handshake.svg
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
fn get_sub_paths_from_path(path: &str) -> Vec<String> {
log::trace!("enter: get_sub_paths_from_path({})", path);
let mut paths = vec![];
// filter out any empty strings caused by .split
let mut parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let length = parts.len();
for _ in 0..length {
// iterate over all parts of the path
if parts.is_empty() {
// pop left us with an empty vector, we're done
break;
}
let possible_path = parts.join("/");
if possible_path.is_empty() {
// .join can result in an empty string, which we don't need, ignore
continue;
}
paths.push(possible_path); // good sub-path found
parts.pop(); // use .pop() to remove the last part of the path and continue iteration
}
log::trace!("exit: get_sub_paths_from_path -> {:?}", paths);
paths
}
/// simple helper to stay DRY, trys to join a url + fragment and add it to the `links` HashSet
fn add_link_to_set_of_links(link: &str, url: &Url, links: &mut HashSet<String>) {
log::trace!(
"enter: add_link_to_set_of_links({}, {}, {:?})",
link,
url.to_string(),
links
);
match url.join(&link) {
Ok(new_url) => {
links.insert(new_url.to_string());
}
Err(e) => {
log::error!("Could not join given url to the base url: {}", e);
}
}
log::trace!("exit: add_link_to_set_of_links");
}
/// Given a `reqwest::Response`, perform the following actions
/// - parse the response's text for links using the linkfinder regex
/// - 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`
/// with a base url of http://localhost, the following urls would be returned:
/// - homepage/assets/img/icons/handshake.svg
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
pub async fn get_links(
response: &FeroxResponse,
tx_stats: UnboundedSender<StatCommand>,
) -> HashSet<String> {
log::trace!(
"enter: get_links({}, {:?})",
response.url().as_str(),
tx_stats
);
let mut links = HashSet::<String>::new();
let body = response.text();
for capture in LINKS_REGEX.captures_iter(&body) {
// remove single & double quotes from both ends of the capture
// capture[0] is the entire match, additional capture groups start at [1]
let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
match Url::parse(link) {
Ok(absolute) => {
if absolute.domain() != response.url().domain()
|| absolute.host() != response.url().host()
{
// domains/ips are not the same, don't scan things that aren't part of the original
// target url
continue;
}
add_all_sub_paths(absolute.path(), &response, &mut 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") {
add_all_sub_paths(link, &response, &mut links);
} else {
// unexpected error has occurred
log::error!("Could not parse given url: {}", e);
}
}
}
}
let multiplier = CONFIGURATION.extensions.len().max(1);
update_stat!(tx_stats, UpdateUsizeField(LinksExtracted, links.len()));
update_stat!(
tx_stats,
UpdateUsizeField(TotalExpected, links.len() * multiplier)
);
log::trace!("exit: get_links -> {:?}", links);
links
}
/// take a url fragment like homepage/assets/img/icons/handshake.svg and
/// incrementally add
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
fn add_all_sub_paths(url_path: &str, response: &FeroxResponse, mut links: &mut HashSet<String>) {
log::trace!(
"enter: add_all_sub_paths({}, {}, {:?})",
url_path,
response,
links
);
for sub_path in get_sub_paths_from_path(url_path) {
log::debug!("Adding {} to {:?}", sub_path, links);
add_link_to_set_of_links(&sub_path, &response.url(), &mut links);
}
log::trace!("exit: add_all_sub_paths");
}
/// Wrapper around link extraction logic
/// currently used in two places:
/// - links from response bodys
/// - links from robots.txt responses
///
/// general steps taken:
/// - create a new Url object based on cli options/args
/// - check if the new Url has already been seen/scanned -> None
/// - make a request to the new Url ? -> Some(response) : None
pub async fn request_feroxresponse_from_new_link(
url: &str,
tx_stats: UnboundedSender<StatCommand>,
) -> Option<FeroxResponse> {
log::trace!(
"enter: request_feroxresponse_from_new_link({}, {:?})",
url,
tx_stats
);
// create a url based on the given command line options, return None on error
let new_url = match format_url(
&url,
&"",
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
tx_stats.clone(),
) {
Ok(url) => url,
Err(_) => {
log::trace!("exit: request_feroxresponse_from_new_link -> None");
return None;
}
};
if SCANNED_URLS.get_scan_by_url(&new_url.to_string()).is_some() {
//we've seen the url before and don't need to scan again
log::trace!("exit: request_feroxresponse_from_new_link -> None");
return None;
}
// make the request and store the response
let new_response = match make_request(&CONFIGURATION.client, &new_url, tx_stats).await {
Ok(resp) => resp,
Err(_) => {
log::trace!("exit: request_feroxresponse_from_new_link -> None");
return None;
}
};
let new_ferox_response = FeroxResponse::from(new_response, true).await;
log::trace!(
"exit: request_feroxresponse_from_new_link -> {:?}",
new_ferox_response
);
Some(new_ferox_response)
}
/// helper function that simply requests /robots.txt on the given url's base url
///
/// example:
/// http://localhost/api/users -> http://localhost/robots.txt
///
/// The length of the given path has no effect on what's requested; it's always
/// base url + /robots.txt
pub async fn request_robots_txt(
base_url: &str,
config: &Configuration,
tx_stats: UnboundedSender<StatCommand>,
) -> Option<FeroxResponse> {
log::trace!(
"enter: get_robots_file({}, CONFIGURATION, {:?})",
base_url,
tx_stats
);
// 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 config.proxy.is_empty() {
None
} else {
Some(config.proxy.as_str())
};
let client = client::initialize(
config.timeout,
&config.user_agent,
follow_redirects,
config.insecure,
&config.headers,
proxy,
);
if let Ok(mut url) = Url::parse(base_url) {
url.set_path("/robots.txt"); // overwrite existing path with /robots.txt
if let Ok(response) = make_request(&client, &url, tx_stats).await {
let ferox_response = FeroxResponse::from(response, true).await;
log::trace!("exit: get_robots_file -> {}", ferox_response);
return Some(ferox_response);
}
}
None
}
/// Entry point to perform link extraction from robots.txt
///
/// `base_url` can have paths and subpaths, however robots.txt will be requested from the
/// root of the url
/// given the url:
/// http://localhost/stuff/things
/// this function requests:
/// http://localhost/robots.txt
pub async fn extract_robots_txt(
base_url: &str,
config: &Configuration,
tx_stats: UnboundedSender<StatCommand>,
) -> HashSet<String> {
log::trace!(
"enter: extract_robots_txt({}, CONFIGURATION, {:?})",
base_url,
tx_stats
);
let mut links = HashSet::new();
if let Some(response) = request_robots_txt(&base_url, &config, tx_stats.clone()).await {
for capture in ROBOTS_REGEX.captures_iter(response.text.as_str()) {
if let Some(new_path) = capture.name("url_path") {
if let Ok(mut new_url) = Url::parse(base_url) {
new_url.set_path(new_path.as_str());
add_all_sub_paths(new_url.path(), &response, &mut links);
}
}
}
}
let multiplier = CONFIGURATION.extensions.len().max(1);
update_stat!(tx_stats, UpdateUsizeField(LinksExtracted, links.len()));
update_stat!(
tx_stats,
UpdateUsizeField(TotalExpected, links.len() * multiplier)
);
log::trace!("exit: extract_robots_txt -> {:?}", links);
links
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::make_request;
use crate::FeroxChannel;
use httpmock::Method::GET;
use httpmock::MockServer;
use reqwest::Client;
use tokio::sync::mpsc;
#[test]
/// extract sub paths from the given url fragment; expect 4 sub paths and that all are
/// in the expected array
fn extractor_get_sub_paths_from_path_with_multiple_paths() {
let path = "homepage/assets/img/icons/handshake.svg";
let paths = get_sub_paths_from_path(&path);
let expected = vec![
"homepage",
"homepage/assets",
"homepage/assets/img",
"homepage/assets/img/icons",
"homepage/assets/img/icons/handshake.svg",
];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// extract sub paths from the given url fragment; expect 2 sub paths and that all are
/// in the expected array. the fragment is wrapped in slashes to ensure no empty strings are
/// returned
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
let path = "/homepage/assets/";
let paths = get_sub_paths_from_path(&path);
let expected = vec!["homepage", "homepage/assets"];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// extract sub paths from the given url fragment; expect 1 sub path, no forward slashes are
/// included
fn extractor_get_sub_paths_from_path_with_only_a_word() {
let path = "homepage";
let paths = get_sub_paths_from_path(&path);
let expected = vec!["homepage"];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// 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() {
let path = "/homepage";
let paths = get_sub_paths_from_path(&path);
let expected = vec!["homepage"];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// test that a full url and fragment are joined correctly, then added to the given list
/// i.e. the happy path
fn extractor_add_link_to_set_of_links_happy_path() {
let url = Url::parse("https://localhost").unwrap();
let mut links = HashSet::<String>::new();
let link = "admin";
assert_eq!(links.len(), 0);
add_link_to_set_of_links(link, &url, &mut links);
assert_eq!(links.len(), 1);
assert!(links.contains("https://localhost/admin"));
}
#[test]
/// test that an invalid path fragment doesn't add anything to the set of links
fn extractor_add_link_to_set_of_links_with_non_base_url() {
let url = Url::parse("https://localhost").unwrap();
let mut links = HashSet::<String>::new();
let link = "\\\\\\\\";
assert_eq!(links.len(), 0);
add_link_to_set_of_links(link, &url, &mut links);
assert_eq!(links.len(), 0);
assert!(links.is_empty());
}
#[tokio::test(core_threads = 1)]
/// 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
/// domain; expect an empty set returned
async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let mock = srv.mock(|when, then|{
when.method(GET)
.path("/some-path");
then.status(200)
.body("\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"");
});
let client = Client::new();
let url = Url::parse(&srv.url("/some-path")).unwrap();
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let response = make_request(&client, &url, tx.clone()).await.unwrap();
let ferox_response = FeroxResponse::from(response, true).await;
let links = get_links(&ferox_response, tx).await;
assert!(links.is_empty());
assert_eq!(mock.hits(), 1);
Ok(())
}
#[tokio::test(core_threads = 1)]
/// test that /robots.txt is correctly requested given a base url (happy path)
async fn request_robots_txt_with_and_without_proxy() {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/robots.txt");
then.status(200).body("this is a test");
});
let mut config = Configuration::default();
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
request_robots_txt(&srv.url("/api/users/stuff/things"), &config, tx.clone()).await;
// note: the proxy doesn't actually do anything other than hit a different code branch
// in this unit test; it would however have an effect on an integration test
config.proxy = srv.url("/ima-proxy");
request_robots_txt(&srv.url("/api/different/path"), &config, tx).await;
assert_eq!(mock.hits(), 2);
}
}

103
src/extractor/builder.rs Normal file
View File

@@ -0,0 +1,103 @@
use super::*;
use crate::event_handlers::Handles;
use anyhow::{bail, Result};
/// Regular expression used in [LinkFinder](https://github.com/GerbenJavado/LinkFinder)
///
/// Incorporates change from this [Pull Request](https://github.com/GerbenJavado/LinkFinder/pull/66/files)
pub(super) const LINKFINDER_REGEX: &str = r#"(?:"|')(((?:[a-zA-Z]{1,10}://|//)[^"'/]{1,}\.[a-zA-Z]{2,}[^"']{0,})|((?:/|\.\./|\./)[^"'><,;| *()(%%$^/\\\[\]][^"'><,;|()]{1,})|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{1,}\.(?:[a-zA-Z]{1,4}|action)(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{3,}(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-.]{1,}\.(?:php|asp|aspx|jsp|json|action|html|js|txt|xml)(?:[\?|#][^"|']{0,}|)))(?:"|')"#;
/// Regular expression to pull url paths from robots.txt
///
/// ref: https://developers.google.com/search/reference/robots_txt
pub(super) const ROBOTS_TXT_REGEX: &str =
r#"(?m)^ *(Allow|Disallow): *(?P<url_path>[a-zA-Z0-9._/?#@!&'()+,;%=-]+?)$"#; // multi-line (?m)
/// Which type of extraction should be performed
#[derive(Debug, Copy, Clone)]
pub enum ExtractionTarget {
/// Examine a response body and extract javascript and html links (multiple tags)
ResponseBody,
/// Examine robots.txt (specifically) and extract links
RobotsTxt,
/// Extract all <a> tags from a page
DirectoryListing,
}
/// responsible for building an `Extractor`
pub struct ExtractorBuilder<'a> {
/// Response from which to extract links
response: Option<&'a FeroxResponse>,
/// URL of where to extract links
url: String,
/// Handles object to house the underlying mpsc transmitters
handles: Option<Arc<Handles>>,
/// type of extraction to be performed
target: ExtractionTarget,
}
/// ExtractorBuilder implementation
impl<'a> Default for ExtractorBuilder<'a> {
fn default() -> Self {
Self {
response: None,
url: "".to_string(),
handles: None,
target: ExtractionTarget::ResponseBody,
}
}
}
/// ExtractorBuilder implementation
impl<'a> ExtractorBuilder<'a> {
/// builder call to set `handles`
pub fn handles(&mut self, handles: Arc<Handles>) -> &mut Self {
self.handles = Some(handles);
self
}
/// builder call to set `url`
pub fn url(&mut self, url: &str) -> &mut Self {
self.url = url.to_string();
self
}
/// builder call to set `target`
pub fn target(&mut self, target: ExtractionTarget) -> &mut Self {
self.target = target;
self
}
/// builder call to set `response`
pub fn response(&mut self, response: &'a FeroxResponse) -> &mut Self {
self.response = Some(response);
self
}
/// finalize configuration of `ExtractorBuilder` and return an `Extractor`
///
/// requires either `with_url` or `with_response` to have been used in the build process
pub fn build(&self) -> Result<Extractor<'a>> {
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")
}
Ok(Extractor {
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
response: if self.response.is_some() {
Some(self.response.unwrap())
} else {
None
},
url: self.url.to_owned(),
handles: self.handles.as_ref().unwrap().clone(),
target: self.target,
})
}
}

634
src/extractor/container.rs Normal file
View File

@@ -0,0 +1,634 @@
use super::*;
use crate::{
client,
event_handlers::{
Command,
Command::{AddError, AddToUsizeField},
Handles,
},
scan_manager::ScanOrder,
statistics::{
StatError::Other,
StatField::{LinksExtracted, TotalExpected},
},
url::FeroxUrl,
utils::{logged_request, make_request, should_deny_url},
ExtractionResult, DEFAULT_METHOD,
};
use anyhow::{bail, Context, Result};
use reqwest::{Client, StatusCode, Url};
use scraper::{Html, Selector};
use std::collections::HashSet;
use tokio::sync::oneshot;
/// Whether an active scan is recursive or not
#[derive(Debug)]
enum RecursionStatus {
/// Scan is recursive
Recursive,
/// Scan is not recursive
NotRecursive,
}
/// Handles all logic related to extracting links from requested source code
#[derive(Debug)]
pub struct Extractor<'a> {
/// `LINKFINDER_REGEX` as a regex::Regex type
pub(super) links_regex: Regex,
/// `ROBOTS_TXT_REGEX` as a regex::Regex type
pub(super) robots_regex: Regex,
/// Response from which to extract links
pub(super) response: Option<&'a FeroxResponse>,
/// URL of where to extract links
pub(super) url: String,
/// Handles object to house the underlying mpsc transmitters
pub(super) handles: Arc<Handles>,
/// type of extraction to be performed
pub(super) target: ExtractionTarget,
}
/// Extractor implementation
impl<'a> Extractor<'a> {
/// perform extraction from the given target and return any links found
pub async fn extract(&self) -> Result<ExtractionResult> {
log::trace!(
"enter: extract({:?}) (this fn has no associated trace exit msg)",
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 {
RecursionStatus::NotRecursive
} else {
RecursionStatus::Recursive
};
let scanned_urls = self.handles.ferox_scans()?;
self.update_stats(links.len())?;
for link in links {
let mut resp = match self.request_link(&link).await {
Ok(resp) => resp,
Err(_) => continue,
};
// filter if necessary
if self
.handles
.filters
.data
.should_filter_response(&resp, self.handles.stats.tx.clone())
{
continue;
}
// request and report assumed file
if resp.is_file() || !resp.is_directory() {
log::debug!("Extracted File: {}", resp);
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()) {
log::warn!("Could not send FeroxResponse to output handler: {}", e);
}
continue;
}
if matches!(recursive, RecursionStatus::Recursive) {
log::debug!("Extracted Directory: {}", resp);
if !resp.url().as_str().ends_with('/')
&& (resp.status().is_success()
|| matches!(resp.status(), &StatusCode::FORBIDDEN))
{
// if the url doesn't end with a /
// and the response code is either a 2xx or 403
// since all of these are 2xx or 403, recursion is only attempted if the
// url ends in a /. I am actually ok with adding the slash and not
// adding it, as both have merit. Leaving it in for now to see how
// things turn out (current as of: v1.1.0)
resp.set_url(&format!("{}/", resp.url()));
}
self.handles
.send_scan_command(Command::TryRecursion(Box::new(resp)))?;
let (tx, rx) = oneshot::channel::<bool>();
self.handles.send_scan_command(Command::Sync(tx))?;
rx.await?;
}
}
log::trace!("exit: request_links");
Ok(())
}
/// wrapper around link extraction via html attributes
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");
}
/// 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
/// - 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:
/// - homepage/assets/img/icons/handshake.svg
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
fn extract_all_links_from_javascript(
&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
);
for capture in self.links_regex.captures_iter(response_body) {
// remove single & double quotes from both ends of the capture
// capture[0] is the entire match, additional capture groups start at [1]
let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
if self
.parse_url_and_add_subpaths(link, response_url, links)
.is_err()
{
// purposely not logging the error here, due to the frequency with which it gets hit
}
}
log::trace!("exit: extract_all_links_from_javascript");
}
/// take a url fragment like homepage/assets/img/icons/handshake.svg and
/// incrementally add
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
fn add_all_sub_paths(&self, url_path: &str, links: &mut HashSet<String>) -> Result<()> {
log::trace!("enter: add_all_sub_paths({}, {:?})", url_path, links);
for sub_path in self.get_sub_paths_from_path(url_path) {
self.add_link_to_set_of_links(&sub_path, links)?;
}
log::trace!("exit: add_all_sub_paths");
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
///
/// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg`
/// the following fragments would be returned:
/// - homepage/assets/img/icons/handshake.svg
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
pub(super) fn get_sub_paths_from_path(&self, path: &str) -> Vec<String> {
log::trace!("enter: get_sub_paths_from_path({})", path);
let mut paths = vec![];
let normalized_path = self.normalize_url_path(path);
// filter out any empty strings caused by .split
let mut parts: Vec<&str> = normalized_path
.split('/')
.filter(|s| !s.is_empty())
.collect();
let length = parts.len();
for i in 0..length {
// iterate over all parts of the path
if parts.is_empty() {
// pop left us with an empty vector, we're done
break;
}
let mut possible_path = parts.join("/");
if possible_path.is_empty() {
// .join can result in an empty string, which we don't need, ignore
continue;
}
if i > 0 {
// this isn't the last index of the parts array
// ex: /buried/misc/stupidfile.php
// this block skips the file but sees all parent folders
possible_path = format!("{}/", possible_path);
}
paths.push(possible_path); // good sub-path found
parts.pop(); // use .pop() to remove the last part of the path and continue iteration
}
log::trace!("exit: get_sub_paths_from_path -> {:?}", paths);
paths
}
/// 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(
&self,
link: &str,
links: &mut HashSet<String>,
) -> Result<()> {
log::trace!("enter: add_link_to_set_of_links({}, {:?})", link, links);
let old_url = match self.target {
ExtractionTarget::ResponseBody | ExtractionTarget::DirectoryListing => {
self.response.unwrap().url().clone()
}
ExtractionTarget::RobotsTxt => match Url::parse(&self.url) {
Ok(u) => u,
Err(e) => {
bail!("Could not parse {}: {}", self.url, e);
}
},
};
let new_url = old_url
.join(link)
.with_context(|| format!("Could not join {} with {}", old_url, link))?;
links.insert(new_url.to_string());
log::trace!("exit: add_link_to_set_of_links");
Ok(())
}
/// Wrapper around link extraction logic
/// - create a new Url object based on cli options/args
/// - check if the new Url has already been seen/scanned -> None
/// - make a request to the new Url ? -> Some(response) : None
pub(super) async fn request_link(&self, url: &str) -> Result<FeroxResponse> {
log::trace!("enter: request_link({})", url);
let ferox_url = FeroxUrl::from_string(url, self.handles.clone());
// create a url based on the given command line options
let new_url = ferox_url.format("", None)?;
let scanned_urls = self.handles.ferox_scans()?;
if scanned_urls.get_scan_by_url(&new_url.to_string()).is_some() {
//we've seen the url before and don't need to scan again
log::trace!("exit: request_link -> None");
bail!("previously seen url");
}
if (!self.handles.config.url_denylist.is_empty()
|| !self.handles.config.regex_denylist.is_empty())
&& should_deny_url(&new_url, self.handles.clone())?
{
// can't allow a denied url to be requested
bail!(
"prevented request to {} due to {:?} || {:?}",
url,
self.handles.config.url_denylist,
self.handles.config.regex_denylist,
);
}
// make the request and store the response
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);
Ok(new_ferox_response)
}
/// Entry point to perform link extraction from robots.txt
///
/// `base_url` can have paths and subpaths, however robots.txt will be requested from the
/// root of the url
/// given the url:
/// http://localhost/stuff/things
/// this function requests:
/// http://localhost/robots.txt
pub(super) async fn extract_from_robots(&self) -> Result<ExtractionResult> {
log::trace!("enter: extract_robots_txt");
let mut result: HashSet<_> = ExtractionResult::new();
// request
let response = self.make_extract_request("/robots.txt").await?;
let body = response.text();
for capture in self.robots_regex.captures_iter(body) {
if let Some(new_path) = capture.name("url_path") {
let mut new_url = Url::parse(&self.url)?;
new_url.set_path(new_path.as_str());
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);
}
}
}
log::trace!("exit: extract_robots_txt -> {:?}", result);
Ok(result)
}
/// 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:
/// http://localhost/api/users -> http://localhost/<location>
pub(super) async fn make_extract_request(&self, location: &str) -> Result<FeroxResponse> {
log::trace!("enter: make_extract_request");
// need late binding here to avoid 'creates a temporary which is freed...' in the
// `let ... if` below to avoid cloning the client out of config
let mut client = Client::new();
if location == "/robots.txt" {
// 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 {
&client
};
let mut url = Url::parse(&self.url)?;
url.set_path(location); // overwrite existing path
// purposefully not using logged_request here due to using the special client
let response = make_request(
client,
&url,
DEFAULT_METHOD,
None,
self.handles.config.output_level,
&self.handles.config,
self.handles.stats.tx.clone(),
)
.await?;
let ferox_response = FeroxResponse::from(
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
fn update_stats(&self, num_links: usize) -> Result<()> {
let multiplier = self.handles.expected_num_requests_multiplier();
self.handles
.stats
.send(AddToUsizeField(LinksExtracted, num_links))?;
self.handles
.stats
.send(AddToUsizeField(TotalExpected, num_links * multiplier))?;
Ok(())
}
}

13
src/extractor/mod.rs Normal file
View File

@@ -0,0 +1,13 @@
//! extract links from html source and robots.txt
mod builder;
mod container;
#[cfg(test)]
mod tests;
pub use self::builder::ExtractionTarget;
pub use self::builder::ExtractorBuilder;
pub use self::container::Extractor;
use crate::response::FeroxResponse;
use regex::Regex;
use std::sync::Arc;

398
src/extractor/tests.rs Normal file
View File

@@ -0,0 +1,398 @@
use super::builder::{LINKFINDER_REGEX, ROBOTS_TXT_REGEX};
use super::*;
use crate::config::{Configuration, OutputLevel};
use crate::scan_manager::ScanOrder;
use crate::{
event_handlers::Handles, scan_manager::FeroxScans, utils::make_request, Command, FeroxChannel,
DEFAULT_METHOD,
};
use anyhow::Result;
use httpmock::{Method::GET, MockServer};
use lazy_static::lazy_static;
use reqwest::{Client, StatusCode, Url};
use std::collections::HashSet;
use tokio::sync::mpsc;
lazy_static! {
/// Extractor for testing robots.txt
static ref ROBOTS_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::RobotsTxt, Arc::new(FeroxScans::default()));
/// Extractor for testing response bodies
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
static ref RESPONSE: FeroxResponse = get_test_response();
}
/// constructor for the default FeroxResponse used during testing
fn get_test_response() -> FeroxResponse {
let mut resp = FeroxResponse::default();
resp.set_text("nulla pharetra diam sit amet nisl suscipit adipiscing bibendum est");
resp
}
/// creates a single extractor that can be used to test standalone functions
fn setup_extractor(target: ExtractionTarget, scanned_urls: Arc<FeroxScans>) -> Extractor<'static> {
let mut builder = ExtractorBuilder::default();
let builder = match target {
ExtractionTarget::ResponseBody => builder
.target(ExtractionTarget::ResponseBody)
.response(&RESPONSE),
ExtractionTarget::RobotsTxt => builder
.url("http://localhost")
.target(ExtractionTarget::RobotsTxt),
ExtractionTarget::DirectoryListing => builder
.url("http://localhost")
.target(ExtractionTarget::DirectoryListing),
};
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(Some(scanned_urls), Some(config)).0);
builder.handles(handles).build().unwrap()
}
#[test]
/// extract sub paths from the given url fragment; expect 4 sub paths and that all are
/// in the expected array
fn extractor_get_sub_paths_from_path_with_multiple_paths() {
let path = "homepage/assets/img/icons/handshake.svg";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec![
"homepage/",
"homepage/assets/",
"homepage/assets/img/",
"homepage/assets/img/icons/",
"homepage/assets/img/icons/handshake.svg",
];
assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len());
for expected_path in expected {
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
#[test]
/// extract sub paths from the given url fragment; expect 2 sub paths and that all are
/// in the expected array. the fragment is wrapped in slashes to ensure no empty strings are
/// returned
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
let path = "/homepage/assets/";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec!["homepage/", "homepage/assets"];
assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len());
for expected_path in expected {
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
#[test]
/// extract sub paths from the given url fragment; expect 1 sub path, no forward slashes are
/// included
fn extractor_get_sub_paths_from_path_with_only_a_word() {
let path = "homepage";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec!["homepage"];
assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len());
for expected_path in expected {
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
#[test]
/// 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() {
let path = "/homepage";
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
let expected = vec!["homepage"];
assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len());
for expected_path in expected {
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
#[test]
/// test that an ExtractorBuilder without a FeroxResponse and without a URL bails
fn extractor_builder_bails_when_neither_required_field_is_set() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let extractor = ExtractorBuilder::default()
.url("")
.target(ExtractionTarget::RobotsTxt)
.handles(handles)
.build();
assert!(extractor.is_err());
}
#[test]
/// Extractor with a non-base url bails
fn extractor_with_non_base_url_bails() -> Result<()> {
let mut links = HashSet::<String>::new();
let link = "admin";
let handles = Arc::new(Handles::for_testing(None, None).0);
let extractor = ExtractorBuilder::default()
.url("\\\\\\")
.handles(handles)
.target(ExtractionTarget::RobotsTxt)
.build()?;
let result = extractor.add_link_to_set_of_links(link, &mut links);
assert!(result.is_err());
Ok(())
}
#[test]
/// test that a full url and fragment are joined correctly, then added to the given list
/// i.e. the happy path
fn extractor_add_link_to_set_of_links_happy_path() {
let mut r_links = HashSet::<String>::new();
let r_link = "admin";
let mut b_links = HashSet::<String>::new();
let b_link = "shmadmin";
assert_eq!(r_links.len(), 0);
ROBOTS_EXT
.add_link_to_set_of_links(r_link, &mut r_links)
.unwrap();
assert_eq!(r_links.len(), 1);
assert!(r_links.contains("http://localhost/admin"));
assert_eq!(b_links.len(), 0);
BODY_EXT
.add_link_to_set_of_links(b_link, &mut b_links)
.unwrap();
assert_eq!(b_links.len(), 1);
assert!(b_links.contains("http://localhost/shmadmin"));
}
#[test]
/// test that an invalid path fragment doesn't add anything to the set of links
fn extractor_add_link_to_set_of_links_with_non_base_url() {
let mut links = HashSet::<String>::new();
let link = "\\\\\\\\";
assert_eq!(links.len(), 0);
assert!(ROBOTS_EXT
.add_link_to_set_of_links(link, &mut links)
.is_err());
assert!(BODY_EXT.add_link_to_set_of_links(link, &mut links).is_err());
assert_eq!(links.len(), 0);
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)]
/// 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
/// domain; expect an empty set returned
async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain() -> Result<()> {
let (tx_stats, _): FeroxChannel<Command> = mpsc::unbounded_channel();
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/some-path");
then.status(200).body(
"\"http://definitely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"",
);
});
let client = Client::new();
let url = Url::parse(&srv.url("/some-path")).unwrap();
let config = Configuration::new().unwrap();
let response = make_request(
&client,
&url,
DEFAULT_METHOD,
None,
OutputLevel::Default,
&config,
tx_stats.clone(),
)
.await
.unwrap();
let (handles, _rx) = Handles::for_testing(None, None);
let handles = Arc::new(handles);
let ferox_response =
FeroxResponse::from(response, &srv.url(""), DEFAULT_METHOD, OutputLevel::Default).await;
let extractor = Extractor {
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
response: Some(&ferox_response),
url: String::new(),
target: ExtractionTarget::ResponseBody,
handles: handles.clone(),
};
let links = extractor.extract_from_body().await?;
assert!(links.is_empty());
assert_eq!(mock.hits(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test that /robots.txt is correctly requested given a base url (happy path)
async fn request_robots_txt_without_proxy() -> Result<()> {
let handles = Arc::new(Handles::for_testing(None, None).0);
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/robots.txt");
then.status(200).body("this is a test");
});
let extractor = Extractor {
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
response: None,
url: srv.url("/api/users/stuff/things"),
target: ExtractionTarget::RobotsTxt,
handles,
};
let resp = extractor.make_extract_request("/robots.txt").await?;
assert!(matches!(resp.status(), &StatusCode::OK));
println!("{}", resp);
assert_eq!(resp.content_length(), 14);
assert_eq!(mock.hits(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test that /robots.txt is correctly requested given a base url (happy path) when a proxy is used
async fn request_robots_txt_with_proxy() -> Result<()> {
let handles = Arc::new(Handles::for_testing(None, None).0);
let mut config = Configuration::new()?;
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/robots.txt");
then.status(200).body("this is also a test");
});
// note: the proxy doesn't actually do anything other than hit a different code branch
// in this unit test; it would however have an effect on an integration test
config.proxy = srv.url("/ima-proxy");
config.no_recursion = true;
let extractor = ExtractorBuilder::default()
.url(&srv.url("/api/different/path"))
.target(ExtractionTarget::RobotsTxt)
.handles(handles)
.build()?;
let resp = extractor.make_extract_request("/robots.txt").await?;
assert!(matches!(resp.status(), &StatusCode::OK));
assert_eq!(resp.content_length(), 19);
assert_eq!(mock.hits(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// request_link's happy path, expect back a FeroxResponse
async fn request_link_happy_path() -> Result<()> {
let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET).path("/login.php");
then.status(200).body("this is a test");
});
let r_resp = ROBOTS_EXT.request_link(&srv.url("/login.php")).await?;
let b_resp = BODY_EXT.request_link(&srv.url("/login.php")).await?;
assert!(matches!(r_resp.status(), &StatusCode::OK));
assert!(matches!(b_resp.status(), &StatusCode::OK));
assert_eq!(r_resp.content_length(), 14);
assert_eq!(b_resp.content_length(), 14);
assert_eq!(mock.hits(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// request_link should bail in the event that the url is already in scanned_urls
async fn request_link_bails_on_seen_url() -> Result<()> {
let url = "/unique-for-this-test.php";
let srv = MockServer::start();
let served = srv.url(url);
let mock = srv.mock(|when, then| {
when.method(GET).path(url);
then.status(200)
.body("this is a unique test, don't reuse the endpoint");
});
let scans = Arc::new(FeroxScans::default());
scans.add_file_scan(&served, ScanOrder::Latest);
let robots = setup_extractor(ExtractionTarget::RobotsTxt, scans.clone());
let body = setup_extractor(ExtractionTarget::ResponseBody, scans);
let r_resp = robots.request_link(&served).await;
let b_resp = body.request_link(&served).await;
assert!(r_resp.is_err());
assert!(b_resp.is_err());
assert_eq!(mock.hits(), 0); // function exits before requests can happen
Ok(())
}

View File

@@ -1,513 +0,0 @@
use crate::config::CONFIGURATION;
use crate::utils::get_url_path_length;
use crate::{FeroxResponse, FeroxSerialize};
use fuzzyhash::FuzzyHash;
use regex::Regex;
use std::any::Any;
use std::fmt::Debug;
// references:
// https://dev.to/magnusstrale/rust-trait-objects-in-a-vector-non-trivial-4co5
// https://stackoverflow.com/questions/25339603/how-to-test-for-equality-between-trait-objects
/// FeroxFilter trait; represents different types of possible filters that can be applied to
/// responses
pub trait FeroxFilter: Debug + Send + Sync {
/// Determine whether or not this particular filter should be applied or not
fn should_filter_response(&self, response: &FeroxResponse) -> bool;
/// delegates to the FeroxFilter-implementing type which gives us the actual type of self
fn box_eq(&self, other: &dyn Any) -> bool;
/// gives us `other` as Any in box_eq
fn as_any(&self) -> &dyn Any;
}
/// 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
impl PartialEq for Box<dyn FeroxFilter> {
/// Perform a comparison of two implementors of the FeroxFilter trait
fn eq(&self, other: &Box<dyn FeroxFilter>) -> bool {
self.box_eq(other.as_any())
}
}
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
///
/// `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, Default, Clone, PartialEq)]
pub struct WildcardFilter {
/// size of the response that will later be combined with the length of the path of the url
/// requested
pub dynamic: u64,
/// size of the response that should be included with filters passed via runtime configuration
pub size: u64,
}
/// implementation of FeroxFilter for WildcardFilter
impl FeroxFilter for WildcardFilter {
/// Examine size, dynamic, and content_len to determine whether or not the response received
/// is a wildcard response and therefore should be filtered out
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
// quick return if dont_filter is set
if CONFIGURATION.dont_filter {
// --dont-filter applies specifically to wildcard filters, it is not a 100% catch all
// for not filtering anything. As such, it should live in the implementation of
// a wildcard filter
return false;
}
if self.size > 0 && self.size == response.content_length() {
// static wildcard size found during testing
// size isn't default, size equals response length, and auto-filter is on
log::debug!("static wildcard: filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
if self.dynamic > 0 {
// dynamic wildcard offset found during testing
// I'm about to manually split this url path instead of using reqwest::Url's
// 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 = get_url_path_length(&response.url());
if url_len + self.dynamic == response.content_length() {
log::debug!("dynamic wildcard: filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
log::trace!("exit: should_filter_response -> false");
false
}
/// Compare one WildcardFilter 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
}
}
/// Simple implementor of FeroxFilter; used to filter out status codes specified using
/// -C|--filter-status
#[derive(Default, Debug, PartialEq)]
pub struct StatusCodeFilter {
/// Status code that should not be displayed to the user
pub filter_code: u16,
}
/// implementation of FeroxFilter for StatusCodeFilter
impl FeroxFilter for StatusCodeFilter {
/// Check `filter_code` against what was passed in via -C|--filter-status
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
if response.status().as_u16() == self.filter_code {
log::debug!(
"filtered out {} based on --filter-status of {}",
response.url(),
self.filter_code
);
log::trace!("exit: should_filter_response -> true");
return true;
}
log::trace!("exit: should_filter_response -> false");
false
}
/// Compare one StatusCodeFilter 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
}
}
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of lines
/// in a Response body; specified using -N|--filter-lines
#[derive(Default, Debug, PartialEq)]
pub struct LinesFilter {
/// Number of lines in a Response's body that should be filtered
pub line_count: usize,
}
/// implementation of FeroxFilter for LinesFilter
impl FeroxFilter for LinesFilter {
/// Check `line_count` against what was passed in via -N|--filter-lines
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = response.line_count() == self.line_count;
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one LinesFilter 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
}
}
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of words
/// in a Response body; specified using -W|--filter-words
#[derive(Default, Debug, PartialEq)]
pub struct WordsFilter {
/// Number of words in a Response's body that should be filtered
pub word_count: usize,
}
/// implementation of FeroxFilter for WordsFilter
impl FeroxFilter for WordsFilter {
/// Check `word_count` against what was passed in via -W|--filter-words
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = response.word_count() == self.word_count;
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one WordsFilter 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
}
}
/// Simple implementor of FeroxFilter; used to filter out responses based on the length of a
/// Response body; specified using -S|--filter-size
#[derive(Default, Debug, PartialEq)]
pub struct SizeFilter {
/// Overall length of a Response's body that should be filtered
pub content_length: u64,
}
/// implementation of FeroxFilter for SizeFilter
impl FeroxFilter for SizeFilter {
/// Check `content_length` against what was passed in via -S|--filter-size
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = response.content_length() == self.content_length;
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one SizeFilter 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
}
}
/// Simple implementor of FeroxFilter; used to filter out responses based on a given regular
/// expression; specified using -X|--filter-regex
#[derive(Debug)]
pub struct RegexFilter {
/// Regular expression to be applied to the response body for filtering, compiled
pub compiled: Regex,
/// Regular expression as passed in on the command line, not compiled
pub raw_string: String,
}
/// implementation of FeroxFilter for RegexFilter
impl FeroxFilter for RegexFilter {
/// Check `expression` against the response body, if the expression matches, the response
/// should be filtered out
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = self.compiled.is_match(response.text());
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one SizeFilter 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
}
}
/// PartialEq implementation for RegexFilter
impl PartialEq for RegexFilter {
/// Simple comparison of the raw string passed in via the command line
fn eq(&self, other: &RegexFilter) -> bool {
self.raw_string == other.raw_string
}
}
/// 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
#[derive(Default, Debug, PartialEq)]
pub struct SimilarityFilter {
/// Response's body to be used for comparison for similarity
pub text: String,
/// Percentage of similarity at which a page is determined to be a near-duplicate of another
pub threshold: u32,
}
/// implementation of FeroxFilter for SimilarityFilter
impl FeroxFilter for SimilarityFilter {
/// Check `FeroxResponse::text` against what was requested from the site passed in via
/// --filter-similar-to
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
let other = FuzzyHash::new(&response.text);
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
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
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::Url;
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn lines_filter_as_any() {
let filter = LinesFilter { line_count: 1 };
assert_eq!(filter.line_count, 1);
assert_eq!(
*filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn words_filter_as_any() {
let filter = WordsFilter { word_count: 1 };
assert_eq!(filter.word_count, 1);
assert_eq!(
*filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn size_filter_as_any() {
let filter = SizeFilter { content_length: 1 };
assert_eq!(filter.content_length, 1);
assert_eq!(
*filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn status_code_filter_as_any() {
let filter = StatusCodeFilter { filter_code: 200 };
assert_eq!(filter.filter_code, 200);
assert_eq!(
*filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn regex_filter_as_any() {
let raw = r".*\.txt$";
let compiled = Regex::new(raw).unwrap();
let filter = RegexFilter {
compiled,
raw_string: raw.to_string(),
};
assert_eq!(filter.raw_string, r".*\.txt$");
assert_eq!(
*filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
filter
);
}
#[test]
/// test should_filter on WilcardFilter where static logic matches
fn wildcard_should_filter_when_static_wildcard_found() {
let resp = FeroxResponse {
text: String::new(),
wildcard: true,
url: Url::parse("http://localhost").unwrap(),
content_length: 100,
word_count: 50,
line_count: 25,
headers: reqwest::header::HeaderMap::new(),
status: reqwest::StatusCode::OK,
};
let filter = WildcardFilter {
size: 100,
dynamic: 0,
};
assert!(filter.should_filter_response(&resp));
}
#[test]
/// test should_filter on WilcardFilter where dynamic logic matches
fn wildcard_should_filter_when_dynamic_wildcard_found() {
let resp = FeroxResponse {
text: String::new(),
wildcard: true,
url: Url::parse("http://localhost/stuff").unwrap(),
content_length: 100,
word_count: 50,
line_count: 25,
headers: reqwest::header::HeaderMap::new(),
status: reqwest::StatusCode::OK,
};
let filter = WildcardFilter {
size: 0,
dynamic: 95,
};
assert!(filter.should_filter_response(&resp));
}
#[test]
/// test should_filter on RegexFilter where regex matches body
fn regexfilter_should_filter_when_regex_matches_on_response_body() {
let resp = FeroxResponse {
text: String::from("im a body response hurr durr!"),
wildcard: false,
url: Url::parse("http://localhost/stuff").unwrap(),
content_length: 100,
word_count: 50,
line_count: 25,
headers: reqwest::header::HeaderMap::new(),
status: reqwest::StatusCode::OK,
};
let raw = r"response...rr";
let filter = RegexFilter {
raw_string: raw.to_string(),
compiled: Regex::new(raw).unwrap(),
};
assert!(filter.should_filter_response(&resp));
}
#[test]
/// a few simple tests for similarity filter
fn similarity_filter_is_accurate() {
let mut resp = FeroxResponse {
text: String::from("sitting"),
wildcard: false,
url: Url::parse("http://localhost/stuff").unwrap(),
content_length: 100,
word_count: 50,
line_count: 25,
headers: reqwest::header::HeaderMap::new(),
status: reqwest::StatusCode::OK,
};
let mut filter = SimilarityFilter {
text: FuzzyHash::new("kitten").to_string(),
threshold: 95,
};
// kitten/sitting is 57% similar, so a threshold of 95 should not be filtered
assert!(!filter.should_filter_response(&resp));
resp.text = String::new();
filter.text = String::new();
filter.threshold = 100;
// two empty strings are the same, however ssdeep doesn't accept empty strings, expect false
assert!(!filter.should_filter_response(&resp));
resp.text = String::from("some data to hash for the purposes of running a test");
filter.text =
FuzzyHash::new("some data to hash for the purposes of running a te").to_string();
filter.threshold = 17;
assert!(filter.should_filter_response(&resp));
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn similarity_filter_as_any() {
let filter = SimilarityFilter {
text: String::from("stuff"),
threshold: 95,
};
assert_eq!(filter.text, "stuff");
assert_eq!(
*filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
filter
);
}
}

130
src/filters/container.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::sync::RwLock;
use anyhow::Result;
use serde::{ser::SerializeSeq, Serialize, Serializer};
use crate::{
event_handlers::Command::AddToUsizeField, response::FeroxResponse,
statistics::StatField::WildcardsFiltered, CommandSender,
};
use super::{
FeroxFilter, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
WildcardFilter, WordsFilter,
};
/// Container around a collection of `FeroxFilters`s
#[derive(Debug, Default)]
pub struct FeroxFilters {
/// collection of `FeroxFilters`
pub filters: RwLock<Vec<Box<dyn FeroxFilter>>>,
}
/// implementation of FeroxFilter collection
impl FeroxFilters {
/// add a single FeroxFilter to the collection
pub fn push(&self, filter: Box<dyn FeroxFilter>) -> Result<()> {
if let Ok(mut guard) = self.filters.write() {
if guard.contains(&filter) {
return Ok(());
}
guard.push(filter)
}
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
/// to the user or not.
pub fn should_filter_response(
&self,
response: &FeroxResponse,
tx_stats: CommandSender,
) -> bool {
if let Ok(filters) = self.filters.read() {
for filter in filters.iter() {
// wildcard.should_filter goes here
if filter.should_filter_response(response) {
if filter.as_any().downcast_ref::<WildcardFilter>().is_some() {
tx_stats
.send(AddToUsizeField(WildcardsFiltered, 1))
.unwrap_or_default();
}
return true;
}
}
}
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(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();
} else if let Some(wildcard_filter) =
filter.as_any().downcast_ref::<WildcardFilter>()
{
seq.serialize_element(wildcard_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)]
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
}
}

71
src/filters/init.rs Normal file
View File

@@ -0,0 +1,71 @@
use super::{
utils::create_similarity_filter, LinesFilter, RegexFilter, SizeFilter, StatusCodeFilter,
WordsFilter,
};
use crate::{event_handlers::Handles, skip_fail, utils::fmt_err, Command::AddFilter};
use anyhow::Result;
use regex::Regex;
use std::sync::Arc;
/// add all user-supplied filters to the (already started) filters handler
pub async fn initialize(handles: Arc<Handles>) -> Result<()> {
// add any status code filters to filters handler's FeroxFilters (-C|--filter-status)
for code_filter in &handles.config.filter_status {
let filter = StatusCodeFilter {
filter_code: *code_filter,
};
let boxed_filter = Box::new(filter);
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
}
// add any line count filters to filters handler's FeroxFilters (-N|--filter-lines)
for lines_filter in &handles.config.filter_line_count {
let filter = LinesFilter {
line_count: *lines_filter,
};
let boxed_filter = Box::new(filter);
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
}
// add any line count filters to filters handler's FeroxFilters (-W|--filter-words)
for words_filter in &handles.config.filter_word_count {
let filter = WordsFilter {
word_count: *words_filter,
};
let boxed_filter = Box::new(filter);
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
}
// add any line count filters to filters handler's FeroxFilters (-S|--filter-size)
for size_filter in &handles.config.filter_size {
let filter = SizeFilter {
content_length: *size_filter,
};
let boxed_filter = Box::new(filter);
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
}
// add any regex filters to filters handler's FeroxFilters (-X|--filter-regex)
for regex_filter in &handles.config.filter_regex {
let raw = regex_filter;
let compiled = skip_fail!(Regex::new(raw));
let filter = RegexFilter {
raw_string: raw.to_owned(),
compiled,
};
let boxed_filter = Box::new(filter);
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
}
// add any similarity filters to filters handler's FeroxFilters (--filter-similar-to)
for similarity_filter in &handles.config.filter_similar {
let filter = skip_fail!(create_similarity_filter(similarity_filter, handles.clone()).await);
let boxed_filter = Box::new(filter);
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
}
handles.filters.sync().await?;
Ok(())
}

33
src/filters/lines.rs Normal file
View File

@@ -0,0 +1,33 @@
use super::*;
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of lines
/// in a Response body; specified using -N|--filter-lines
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct LinesFilter {
/// Number of lines in a Response's body that should be filtered
pub line_count: usize,
}
/// implementation of FeroxFilter for LinesFilter
impl FeroxFilter for LinesFilter {
/// Check `line_count` against what was passed in via -N|--filter-lines
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = response.line_count() == self.line_count;
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one LinesFilter 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
}
}

33
src/filters/mod.rs Normal file
View File

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

56
src/filters/regex.rs Normal file
View File

@@ -0,0 +1,56 @@
use super::*;
use ::regex::Regex;
/// Simple implementor of FeroxFilter; used to filter out responses based on a given regular
/// expression; specified using -X|--filter-regex
#[derive(Debug, Serialize, Deserialize)]
pub struct RegexFilter {
/// Regular expression to be applied to the response body for filtering, compiled
#[serde(with = "serde_regex")]
pub compiled: Regex,
/// Regular expression as passed in on the command line, not compiled
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
impl FeroxFilter for RegexFilter {
/// Check `expression` against the response body, if the expression matches, the response
/// should be filtered out
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = self.compiled.is_match(response.text());
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one SizeFilter 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
}
}
/// PartialEq implementation for RegexFilter
impl PartialEq for RegexFilter {
/// Simple comparison of the raw string passed in via the command line
fn eq(&self, other: &RegexFilter) -> bool {
self.raw_string == other.raw_string
}
}

43
src/filters/similarity.rs Normal file
View File

@@ -0,0 +1,43 @@
use super::*;
use fuzzyhash::FuzzyHash;
/// 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
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SimilarityFilter {
/// Hash of Response's body to be used during similarity comparison
pub hash: String,
/// Percentage of similarity at which a page is determined to be a near-duplicate of another
pub threshold: u32,
/// Url originally requested for the similarity filter
pub original_url: String,
}
/// implementation of FeroxFilter for SimilarityFilter
impl FeroxFilter for SimilarityFilter {
/// Check `FeroxResponse::text` against what was requested from the site passed in via
/// --filter-similar-to
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
let other = FuzzyHash::new(&response.text());
if let Ok(result) = FuzzyHash::compare(&self.hash, &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
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
}
}

33
src/filters/size.rs Normal file
View File

@@ -0,0 +1,33 @@
use super::*;
/// Simple implementor of FeroxFilter; used to filter out responses based on the length of a
/// Response body; specified using -S|--filter-size
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SizeFilter {
/// Overall length of a Response's body that should be filtered
pub content_length: u64,
}
/// implementation of FeroxFilter for SizeFilter
impl FeroxFilter for SizeFilter {
/// Check `content_length` against what was passed in via -S|--filter-size
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = response.content_length() == self.content_length;
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one SizeFilter 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

@@ -0,0 +1,40 @@
use super::*;
/// Simple implementor of FeroxFilter; used to filter out status codes specified using
/// -C|--filter-status
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct StatusCodeFilter {
/// Status code that should not be displayed to the user
pub filter_code: u16,
}
/// implementation of FeroxFilter for StatusCodeFilter
impl FeroxFilter for StatusCodeFilter {
/// Check `filter_code` against what was passed in via -C|--filter-status
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
if response.status().as_u16() == self.filter_code {
log::debug!(
"filtered out {} based on --filter-status of {}",
response.url(),
self.filter_code
);
log::trace!("exit: should_filter_response -> true");
return true;
}
log::trace!("exit: should_filter_response -> false");
false
}
/// Compare one StatusCodeFilter 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
}
}

269
src/filters/tests.rs Normal file
View File

@@ -0,0 +1,269 @@
use super::*;
use ::fuzzyhash::FuzzyHash;
use ::regex::Regex;
#[test]
/// simply test the default values for wildcardfilter, expect 0, 0
fn wildcard_filter_default() {
let wcf = WildcardFilter::default();
assert_eq!(wcf.size, u64::MAX);
assert_eq!(wcf.dynamic, u64::MAX);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn wildcard_filter_as_any() {
let filter = WildcardFilter::default();
let filter2 = WildcardFilter::default();
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(
*filter.as_any().downcast_ref::<WildcardFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn lines_filter_as_any() {
let filter = LinesFilter { line_count: 1 };
let filter2 = LinesFilter { line_count: 1 };
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.line_count, 1);
assert_eq!(
*filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn words_filter_as_any() {
let filter = WordsFilter { word_count: 1 };
let filter2 = WordsFilter { word_count: 1 };
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.word_count, 1);
assert_eq!(
*filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn size_filter_as_any() {
let filter = SizeFilter { content_length: 1 };
let filter2 = SizeFilter { content_length: 1 };
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.content_length, 1);
assert_eq!(
*filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn status_code_filter_as_any() {
let filter = StatusCodeFilter { filter_code: 200 };
let filter2 = StatusCodeFilter { filter_code: 200 };
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.filter_code, 200);
assert_eq!(
*filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
filter
);
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn regex_filter_as_any() {
let raw = r".*\.txt$";
let compiled = Regex::new(raw).unwrap();
let compiled2 = Regex::new(raw).unwrap();
let filter = RegexFilter {
compiled,
raw_string: raw.to_string(),
};
let filter2 = RegexFilter {
compiled: compiled2,
raw_string: raw.to_string(),
};
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.raw_string, r".*\.txt$");
assert_eq!(
*filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
filter
);
}
#[test]
/// test should_filter on WilcardFilter where static logic matches
fn wildcard_should_filter_when_static_wildcard_found() {
let mut resp = FeroxResponse::default();
resp.set_wildcard(true);
resp.set_url("http://localhost");
resp.set_text(
"pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus faucibus",
);
let filter = WildcardFilter {
size: 83,
dynamic: 0,
dont_filter: false,
method: "GET".to_owned(),
};
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::new(false);
assert!(filter.should_filter_response(&resp));
}
#[test]
/// test should_filter on WilcardFilter where dynamic logic matches
fn wildcard_should_filter_when_dynamic_wildcard_found() {
let mut resp = FeroxResponse::default();
resp.set_wildcard(true);
resp.set_url("http://localhost/stuff");
resp.set_text("pellentesque diam volutpat commodo sed egestas egestas fringilla");
let filter = WildcardFilter {
size: 0,
dynamic: 59, // content-length - 5 (len('stuff'))
dont_filter: false,
method: "GET".to_owned(),
};
println!("resp: {:?}: filter: {:?}", resp, filter);
assert!(filter.should_filter_response(&resp));
}
#[test]
/// test should_filter on RegexFilter where regex matches body
fn regexfilter_should_filter_when_regex_matches_on_response_body() {
let mut resp = FeroxResponse::default();
resp.set_url("http://localhost/stuff");
resp.set_text("im a body response hurr durr!");
let raw = r"response...rr";
let filter = RegexFilter {
raw_string: raw.to_string(),
compiled: Regex::new(raw).unwrap(),
};
assert!(filter.should_filter_response(&resp));
}
#[test]
/// a few simple tests for similarity filter
fn similarity_filter_is_accurate() {
let mut resp = FeroxResponse::default();
resp.set_url("http://localhost/stuff");
resp.set_text("sitting");
let mut filter = SimilarityFilter {
hash: FuzzyHash::new("kitten").to_string(),
threshold: 95,
original_url: "".to_string(),
};
// kitten/sitting is 57% similar, so a threshold of 95 should not be filtered
assert!(!filter.should_filter_response(&resp));
resp.set_text("");
filter.hash = String::new();
filter.threshold = 100;
// two empty strings are the same, however ssdeep doesn't accept empty strings, expect false
assert!(!filter.should_filter_response(&resp));
resp.set_text("some data to hash for the purposes of running a test");
filter.hash = FuzzyHash::new("some data to hash for the purposes of running a te").to_string();
filter.threshold = 17;
assert!(filter.should_filter_response(&resp));
}
#[test]
/// just a simple test to increase code coverage by hitting as_any and the inner value
fn similarity_filter_as_any() {
let filter = SimilarityFilter {
hash: String::from("stuff"),
threshold: 95,
original_url: "".to_string(),
};
let filter2 = SimilarityFilter {
hash: String::from("stuff"),
threshold: 95,
original_url: "".to_string(),
};
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.hash, "stuff");
assert_eq!(
*filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
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));
}
}

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

@@ -0,0 +1,204 @@
use super::FeroxFilter;
use super::SimilarityFilter;
use crate::event_handlers::Handles;
use crate::response::FeroxResponse;
use crate::utils::logged_request;
use crate::{DEFAULT_METHOD, SIMILARITY_THRESHOLD};
use anyhow::Result;
use fuzzyhash::FuzzyHash;
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())?;
}
// hash the response body and store the resulting hash in the filter object
let hash = FuzzyHash::new(&fr.text()).to_string();
Ok(SimilarityFilter {
hash,
threshold: SIMILARITY_THRESHOLD,
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: String::new(),
threshold: SIMILARITY_THRESHOLD,
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: String::new(),
threshold: SIMILARITY_THRESHOLD,
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: "3:YKEpn:Yfp".to_string(),
threshold: SIMILARITY_THRESHOLD,
original_url: srv.url("/")
}
);
}
}

118
src/filters/wildcard.rs Normal file
View File

@@ -0,0 +1,118 @@
use super::*;
use crate::{url::FeroxUrl, DEFAULT_METHOD};
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
///
/// `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, Serialize, Deserialize)]
pub struct WildcardFilter {
/// size of the response that will later be combined with the length of the path of the url
/// requested
pub dynamic: u64,
/// size of the response that should be included with filters passed via runtime configuration
pub size: u64,
/// method used in request that should be included with filters passed via runtime configuration
pub method: String,
/// whether or not the user passed -D on the command line
pub(super) dont_filter: bool,
}
/// implementation of WildcardFilter
impl WildcardFilter {
/// given a boolean representing whether -D was used or not, create a new WildcardFilter
pub fn new(dont_filter: bool) -> Self {
Self {
dont_filter,
..Default::default()
}
}
}
/// implement default that populates both values with u64::MAX
impl Default for WildcardFilter {
/// populate both values with u64::MAX
fn default() -> Self {
Self {
dont_filter: false,
size: u64::MAX,
method: DEFAULT_METHOD.to_owned(),
dynamic: u64::MAX,
}
}
}
/// implementation of FeroxFilter for WildcardFilter
impl FeroxFilter for WildcardFilter {
/// Examine size, dynamic, and content_len to determine whether or not the response received
/// is a wildcard response and therefore should be filtered out
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
// quick return if dont_filter is set
if self.dont_filter {
// --dont-filter applies specifically to wildcard filters, it is not a 100% catch all
// for not filtering anything. As such, it should live in the implementation of
// a wildcard filter
return false;
}
if self.size != u64::MAX
&& self.size == response.content_length()
&& self.method == response.method().as_str()
{
// static wildcard size found during testing
// size isn't default, size equals response length, and auto-filter is on
log::debug!("static wildcard: filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
if self.size == u64::MAX
&& response.content_length() == 0
&& self.method == response.method().as_str()
{
// static wildcard size found during testing
// but response length was zero; pointed out by @Tib3rius
log::debug!("static wildcard: filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
if self.dynamic != u64::MAX {
// dynamic wildcard offset found during testing
// I'm about to manually split this url path instead of using reqwest::Url's
// 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() {
log::debug!("dynamic wildcard: filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
log::trace!("exit: should_filter_response -> false");
false
}
/// Compare one WildcardFilter 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
}
}

33
src/filters/words.rs Normal file
View File

@@ -0,0 +1,33 @@
use super::*;
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of words
/// in a Response body; specified using -W|--filter-words
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct WordsFilter {
/// Number of words in a Response's body that should be filtered
pub word_count: usize,
}
/// implementation of FeroxFilter for WordsFilter
impl FeroxFilter for WordsFilter {
/// Check `word_count` against what was passed in via -W|--filter-words
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {})", self, response);
let result = response.word_count() == self.word_count;
log::trace!("exit: should_filter_response -> {}", result);
result
}
/// Compare one WordsFilter 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,274 +1,424 @@
use crate::{
config::{CONFIGURATION, PROGRESS_PRINTER},
filters::WildcardFilter,
scanner::should_filter_response,
statistics::StatCommand,
utils::{ferox_print, format_url, get_url_path_length, make_request, status_colorizer},
FeroxResponse,
};
use std::sync::Arc;
use anyhow::{bail, Result};
use console::style;
use indicatif::ProgressBar;
use tokio::sync::mpsc::UnboundedSender;
use scraper::{Html, Selector};
use uuid::Uuid;
use crate::message::FeroxMessage;
use crate::{
config::OutputLevel,
event_handlers::{Command, Handles},
filters::WildcardFilter,
progress::PROGRESS_PRINTER,
response::FeroxResponse,
skip_fail,
url::FeroxUrl,
utils::{ferox_print, fmt_err, logged_request, status_colorizer},
DEFAULT_METHOD,
};
/// length of a standard UUID, used when determining wildcard responses
const UUID_LENGTH: u64 = 32;
/// Simple helper to return a uuid, formatted as lowercase without hyphens
///
/// `length` determines the number of uuids to string together. Each uuid
/// is 32 characters long. So, a length of 1 return a 32 character string,
/// a length of 2 returns a 64 character string, and so on...
fn unique_string(length: usize) -> String {
log::trace!("enter: unique_string({})", length);
let mut ids = vec![];
for _ in 0..length {
ids.push(Uuid::new_v4().to_simple().to_string());
}
let unique_id = ids.join("");
log::trace!("exit: unique_string -> {}", unique_id);
unique_id
/// wrapper around ugly string formatting
macro_rules! format_template {
($template:expr, $method:expr, $length:expr) => {
format!(
$template,
status_colorizer("WLD"),
$method,
"-",
"-",
"-",
style("auto-filtering").yellow(),
style($length).cyan(),
style("--dont-filter").yellow()
)
};
}
/// 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 returned to the caller.
pub async fn wildcard_test(
target_url: &str,
bar: ProgressBar,
tx_term: UnboundedSender<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
) -> Option<WildcardFilter> {
log::trace!(
"enter: wildcard_test({:?}, {:?}, {:?}, {:?})",
target_url,
bar,
tx_term,
tx_stats
);
/// enum representing the different servers that `parse_html` can detect when directory listing is
/// enabled
#[derive(Copy, Debug, Clone)]
pub enum DirListingType {
/// apache server, detected by `Index of /`
Apache,
if CONFIGURATION.dont_filter {
// early return, dont_filter scans don't need tested
log::trace!("exit: wildcard_test -> None");
return None;
/// tomcat/python server, detected by `Directory Listing for /`
TomCatOrPython,
/// ASP.NET server, detected by `Directory Listing -- /`
AspDotNet,
// /// IIS/Azure server, detected by `HOST_NAME - /` (not currently used)
// IIS_AZURE,
/// variant that represents the absence of directory listing
None,
}
/// 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,
}
/// container for heuristics related info
pub struct HeuristicTests {
/// Handles object for event handler interaction
handles: Arc<Handles>,
}
/// HeuristicTests implementation
impl HeuristicTests {
/// create a new HeuristicTests struct
pub fn new(handles: Arc<Handles>) -> Self {
Self { handles }
}
let tx_term_mwcr1 = tx_term.clone();
let tx_term_mwcr2 = tx_term.clone();
let tx_stats_mwcr1 = tx_stats.clone();
let tx_stats_mwcr2 = tx_stats.clone();
/// Simple helper to return a uuid, formatted as lowercase without hyphens
///
/// `length` determines the number of uuids to string together. Each uuid
/// is 32 characters long. So, a length of 1 return a 32 character string,
/// a length of 2 returns a 64 character string, and so on...
fn unique_string(&self, length: usize) -> String {
log::trace!("enter: unique_string({})", length);
let mut ids = vec![];
if let Some(ferox_response) =
make_wildcard_request(&target_url, 1, tx_term_mwcr1, tx_stats_mwcr1).await
{
bar.inc(1);
// found a wildcard response
let mut wildcard = WildcardFilter::default();
let wc_length = ferox_response.content_length();
if wc_length == 0 {
log::trace!("exit: wildcard_test -> Some({:?})", wildcard);
return Some(wildcard);
for _ in 0..length {
ids.push(Uuid::new_v4().to_simple().to_string());
}
// content length of wildcard is non-zero, perform additional tests:
// make a second request, with a known-sized (64) longer request
if let Some(resp_two) =
make_wildcard_request(&target_url, 3, tx_term_mwcr2, tx_stats_mwcr2).await
{
bar.inc(1);
let unique_id = ids.join("");
log::trace!("exit: unique_string -> {}", 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 data = match self.handles.config.data.is_empty() {
true => None,
false => Some(self.handles.config.data.as_slice()),
};
let ferox_url = FeroxUrl::from_string(target_url, self.handles.clone());
for method in self.handles.config.methods.iter() {
let ferox_response = self
.make_wildcard_request(&ferox_url, method.as_str(), data, 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, method.as_str(), data, 3)
.await?;
let wc2_length = resp_two.content_length();
wildcard.method = resp_two.method().as_str().to_owned();
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 = get_url_path_length(&ferox_response.url());
let url_len = ferox_url.path_length()?;
wildcard.dynamic = wc_length - url_len;
if !CONFIGURATION.quiet {
let msg = format!(
"{} {:>9} {:>9} {:>9} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
"-",
"-",
"-",
style("auto-filtering").yellow(),
style(wc_length - url_len).cyan(),
style("--dont-filter").yellow()
);
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let msg = format_template!("{} {:>8} {:>9} {:>9} {:>9} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n", method, wildcard.dynamic);
ferox_print(&msg, &PROGRESS_PRINTER);
}
} else if wc_length == wc2_length {
wildcard.size = wc_length;
if !CONFIGURATION.quiet {
let msg = format!(
"{} {:>9} {:>9} {:>9} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
"-",
"-",
"-",
style("auto-filtering").yellow(),
style(wc_length).cyan(),
style("--dont-filter").yellow()
);
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let msg = format_template!("{} {:>8} {:>9} {:>9} {:>9} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n", method, 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,
method: &str,
data: Option<&[u8]>,
length: usize,
) -> Result<FeroxResponse> {
log::trace!("enter: make_wildcard_request({}, {})", target, length);
let unique_str = self.unique_string(length);
// To take care of slash when needed
let slash = if self.handles.config.add_slash {
Some("/")
} else {
bar.inc(2);
}
log::trace!("exit: wildcard_test -> Some({:?})", wildcard);
return Some(wildcard);
}
log::trace!("exit: wildcard_test -> None");
None
}
/// 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(
target_url: &str,
length: usize,
tx_file: UnboundedSender<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
) -> Option<FeroxResponse> {
log::trace!(
"enter: make_wildcard_request({}, {}, {:?}, {:?})",
target_url,
length,
tx_file,
tx_stats,
);
let unique_str = unique_string(length);
let nonexistent = match format_url(
target_url,
&unique_str,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
tx_stats.clone(),
) {
Ok(url) => url,
Err(e) => {
log::error!("{}", e);
log::trace!("exit: make_wildcard_request -> None");
return None;
}
};
match make_request(
&CONFIGURATION.client,
&nonexistent.to_owned(),
tx_stats.clone(),
)
.await
{
Ok(response) => {
if CONFIGURATION
.status_codes
.contains(&response.status().as_u16())
{
// found a wildcard response
let mut ferox_response = FeroxResponse::from(response, true).await;
ferox_response.wildcard = true;
if !CONFIGURATION.quiet
&& !should_filter_response(&ferox_response, tx_stats.clone())
&& tx_file.send(ferox_response.clone()).is_err()
{
return None;
}
log::trace!("exit: make_wildcard_request -> {}", ferox_response);
return Some(ferox_response);
}
}
Err(e) => {
log::warn!("{}", e);
log::trace!("exit: make_wildcard_request -> None");
return None;
}
}
log::trace!("exit: make_wildcard_request -> None");
None
}
/// 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.
///
/// Any urls that are found to be alive are returned to the caller.
pub async fn connectivity_test(
target_urls: &[String],
tx_stats: UnboundedSender<StatCommand>,
) -> Vec<String> {
log::trace!(
"enter: connectivity_test({:?}, {:?})",
target_urls,
tx_stats
);
let mut good_urls = vec![];
for target_url in target_urls {
let request = match format_url(
target_url,
"",
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
tx_stats.clone(),
) {
Ok(url) => url,
Err(e) => {
log::error!("{}", e);
continue;
}
None
};
match make_request(&CONFIGURATION.client, &request, tx_stats.clone()).await {
Ok(_) => {
good_urls.push(target_url.to_owned());
let nonexistent_url = target.format(&unique_str, slash)?;
let response = logged_request(
&nonexistent_url.to_owned(),
method,
data,
self.handles.clone(),
)
.await?;
if self
.handles
.config
.status_codes
.contains(&response.status().as_u16())
{
// found a wildcard response
let mut ferox_response = FeroxResponse::from(
response,
&target.target,
method,
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")
}
Err(e) => {
if !CONFIGURATION.quiet {
ferox_print(
&format!("Could not connect to {}, skipping...", target_url),
&PROGRESS_PRINTER,
);
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
///
/// In the event that no sites can be reached, the program will exit.
///
/// Any urls that are found to be alive are returned to the caller.
pub async fn connectivity(&self, target_urls: &[String]) -> Result<Vec<String>> {
log::trace!("enter: connectivity_test({:?})", target_urls);
let mut good_urls = vec![];
for target_url in target_urls {
let url = FeroxUrl::from_string(target_url, self.handles.clone());
let request = skip_fail!(url.format("", None));
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await;
match result {
Ok(_) => {
good_urls.push(target_url.to_owned());
}
Err(e) => {
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
if e.to_string().contains(":SSL") {
ferox_print(
&format!("Could not connect to {} due to SSL errors (run with -k to ignore), skipping...", target_url),
&PROGRESS_PRINTER,
);
} else {
ferox_print(
&format!("Could not connect to {}, skipping...", target_url),
&PROGRESS_PRINTER,
);
}
}
log::warn!("{}", e);
}
log::error!("{}", e);
}
}
if good_urls.is_empty() {
bail!("Could not connect to any target provided");
}
log::trace!("exit: connectivity_test -> {:?}", good_urls);
Ok(good_urls)
}
if good_urls.is_empty() {
log::error!("Could not connect to any target provided, exiting.");
/// 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)
}
log::trace!("exit: connectivity_test -> {:?}", good_urls);
/// 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...)");
good_urls
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
}
}
#[cfg(test)]
@@ -278,16 +428,57 @@ mod tests {
#[test]
/// request a unique string of 32bytes * a value returns correct result
fn heuristics_unique_string_returns_correct_length() {
let (handles, _) = Handles::for_testing(None, None);
let tester = HeuristicTests::new(Arc::new(handles));
for i in 0..10 {
assert_eq!(unique_string(i).len(), i * 32);
assert_eq!(tester.unique_string(i).len(), i * 32);
}
}
#[test]
/// simply test the default values for wildcardfilter, expect 0, 0
fn heuristics_wildcardfilter_dafaults() {
let wcf = WildcardFilter::default();
assert_eq!(wcf.size, 0);
assert_eq!(wcf.dynamic, 0);
/// `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,60 +1,67 @@
pub mod utils;
#![deny(clippy::all)]
#![allow(clippy::mutex_atomic)]
use anyhow::Result;
use reqwest::StatusCode;
use std::collections::HashSet;
use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender},
task::JoinHandle,
};
use crate::event_handlers::Command;
pub mod banner;
pub mod client;
pub mod config;
pub mod extractor;
mod client;
pub mod event_handlers;
pub mod filters;
pub mod heuristics;
pub mod logger;
pub mod parser;
mod parser;
pub mod progress;
pub mod reporter;
pub mod scan_manager;
pub mod scanner;
pub mod statistics;
mod traits;
pub mod utils;
mod extractor;
mod macros;
mod url;
mod response;
mod message;
mod nlp;
use crate::utils::{get_url_path_length, status_colorizer};
use console::{style, Color};
use reqwest::header::{HeaderName, HeaderValue};
use reqwest::{header::HeaderMap, Response, StatusCode, Url};
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use std::collections::HashMap;
use std::convert::{TryFrom, TryInto};
use std::str::FromStr;
use std::{error, fmt};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
/// Alias for tokio::sync::mpsc::UnboundedSender<Command>
pub(crate) type CommandSender = UnboundedSender<Command>;
/// Generic Result type to ease error handling in async contexts
pub type FeroxResult<T> = std::result::Result<T, Box<dyn error::Error + Send + Sync + 'static>>;
/// Alias for tokio::sync::mpsc::UnboundedSender<Command>
pub(crate) type CommandReceiver = UnboundedReceiver<Command>;
/// Simple Error implementation to allow for custom error returns
#[derive(Debug, Default)]
pub struct FeroxError {
/// fancy string that can be printed via Display
pub message: String,
}
impl error::Error for FeroxError {}
impl fmt::Display for FeroxError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", &self.message)
}
}
/// Alias for tokio::task::JoinHandle<anyhow::Result<()>>
pub(crate) type Joiner = JoinHandle<Result<()>>;
/// Generic mpsc::unbounded_channel type to tidy up some code
pub 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
pub 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
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%)
pub const SIMILARITY_THRESHOLD: u32 = 95;
/// Default set of extensions to Ignore when auto-collecting extensions during scans
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
/// in a [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file.
///
@@ -64,7 +71,10 @@ pub const DEFAULT_WORDLIST: &str =
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
/// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan
pub static SLEEP_DURATION: u64 = 500;
pub(crate) const SLEEP_DURATION: u64 = 500;
/// The percentage of requests as errors it takes to be deemed too high
pub const HIGH_ERROR_RATIO: f64 = 0.90;
/// Default list of status codes to report
///
@@ -77,7 +87,8 @@ pub static SLEEP_DURATION: u64 = 500;
/// * 401 Unauthorized
/// * 403 Forbidden
/// * 405 Method Not Allowed
pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
/// * 500 Internal Server Error
pub const DEFAULT_STATUS_CODES: [StatusCode; 10] = [
StatusCode::OK,
StatusCode::NO_CONTENT,
StatusCode::MOVED_PERMANENTLY,
@@ -87,449 +98,31 @@ pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
StatusCode::UNAUTHORIZED,
StatusCode::FORBIDDEN,
StatusCode::METHOD_NOT_ALLOWED,
StatusCode::INTERNAL_SERVER_ERROR,
];
/// Default method for requests
pub(crate) const DEFAULT_METHOD: &str = "GET";
/// Default filename for config file settings
///
/// Expected location is in the same directory as the feroxbuster binary.
pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml";
/// FeroxSerialize trait; represents different types that are Serialize and also implement
/// as_str / as_json methods
pub trait FeroxSerialize: Serialize {
/// Return a String representation of the object, generally the human readable version of the
/// implementor
fn as_str(&self) -> String;
/// Return an NDJSON representation of the object
fn as_json(&self) -> String;
}
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
#[derive(Debug, Clone)]
pub struct FeroxResponse {
/// The final `Url` of this `FeroxResponse`
url: Url,
/// The `StatusCode` of this `FeroxResponse`
status: StatusCode,
/// The full response text
text: String,
/// The content-length of this response, if known
content_length: u64,
/// The number of lines contained in the body of this response, if known
line_count: usize,
/// The number of words contained in the body of this response, if known
word_count: usize,
/// The `Headers` of this `FeroxResponse`
headers: HeaderMap,
/// Wildcard response status
wildcard: bool,
}
/// Implement Display for FeroxResponse
impl fmt::Display for FeroxResponse {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"FeroxResponse {{ url: {}, status: {}, content-length: {} }}",
self.url(),
self.status(),
self.content_length()
)
}
}
/// `FeroxResponse` implementation
impl FeroxResponse {
/// Get the `StatusCode` of this `FeroxResponse`
pub fn status(&self) -> &StatusCode {
&self.status
}
/// Get the final `Url` of this `FeroxResponse`.
pub fn url(&self) -> &Url {
&self.url
}
/// Get the full response text
pub fn text(&self) -> &str {
&self.text
}
/// Get the `Headers` of this `FeroxResponse`
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
/// Get the content-length of this response, if known
pub fn content_length(&self) -> u64 {
self.content_length
}
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
pub fn set_url(&mut self, url: &str) {
match Url::parse(&url) {
Ok(url) => {
self.url = url;
}
Err(e) => {
log::error!("Could not parse {} into a Url: {}", url, e);
}
};
}
/// Make a reasonable guess at whether the response is a file or not
///
/// Examines the last part of a path to determine if it has an obvious extension
/// i.e. http://localhost/some/path/stuff.js where stuff.js indicates a file
///
/// Additionally, inspects query parameters, as they're also often indicative of a file
pub fn is_file(&self) -> bool {
let has_extension = match self.url.path_segments() {
Some(path) => {
if let Some(last) = path.last() {
last.contains('.') // last segment has some sort of extension, probably
} else {
false
}
}
None => false,
};
self.url.query_pairs().count() > 0 || has_extension
}
/// Returns line count of the response text.
pub fn line_count(&self) -> usize {
self.line_count
}
/// Returns word count of the response text.
pub fn word_count(&self) -> usize {
self.word_count
}
/// Create a new `FeroxResponse` from the given `Response`
pub async fn from(response: Response, read_body: bool) -> Self {
let url = response.url().clone();
let status = response.status();
let headers = response.headers().clone();
let content_length = response.content_length().unwrap_or(0);
let text = if read_body {
// .text() consumes the response, must be called last
// additionally, --extract-links is currently the only place we use the body of the
// response, so we forego the processing if not performing extraction
match response.text().await {
// await the response's body
Ok(text) => text,
Err(e) => {
log::error!("Could not parse body from response: {}", e);
String::new()
}
}
} else {
String::new()
};
let line_count = text.lines().count();
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
FeroxResponse {
url,
status,
content_length,
text,
headers,
line_count,
word_count,
wildcard: false,
}
}
}
/// Implement FeroxSerialusize::from(ize for FeroxRespons)e
impl FeroxSerialize for FeroxResponse {
/// Simple wrapper around create_report_string
fn as_str(&self) -> String {
let lines = self.line_count().to_string();
let words = self.word_count().to_string();
let chars = self.content_length().to_string();
let status = self.status().as_str();
let wild_status = status_colorizer("WLD");
if self.wildcard {
// response is a wildcard, special messages abound when this is the case...
// create the base message
let mut message = format!(
"{} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
wild_status,
lines,
words,
chars,
status_colorizer(&status),
self.url(),
get_url_path_length(&self.url())
);
if self.status().is_redirection() {
// when it's a redirect, show where it goes, if possible
if let Some(next_loc) = self.headers().get("Location") {
let next_loc_str = next_loc.to_str().unwrap_or("Unknown");
let redirect_msg = format!(
"{} {:>9} {:>9} {:>9} {} redirects to => {}\n",
wild_status,
"-",
"-",
"-",
self.url(),
next_loc_str
);
message.push_str(&redirect_msg);
}
}
// base message + redirection message (if appropriate)
message
} else {
// not a wildcard, just create a normal entry
utils::create_report_string(
self.status.as_str(),
&lines,
&words,
&chars,
self.url().as_str(),
)
}
}
/// Create an NDJSON representation of the FeroxResponse
///
/// (expanded for clarity)
/// ex:
/// {
/// "type":"response",
/// "url":"https://localhost.com/images",
/// "path":"/images",
/// "status":301,
/// "content_length":179,
/// "line_count":10,
/// "word_count":16,
/// "headers":{
/// "x-content-type-options":"nosniff",
/// "strict-transport-security":"max-age=31536000; includeSubDomains",
/// "x-frame-options":"SAMEORIGIN",
/// "connection":"keep-alive",
/// "server":"nginx/1.16.1",
/// "content-type":"text/html; charset=UTF-8",
/// "referrer-policy":"origin-when-cross-origin",
/// "content-security-policy":"default-src 'none'",
/// "access-control-allow-headers":"X-Requested-With",
/// "x-xss-protection":"1; mode=block",
/// "content-length":"179",
/// "date":"Mon, 23 Nov 2020 15:33:24 GMT",
/// "location":"/images/",
/// "access-control-allow-origin":"https://localhost.com"
/// }
/// }\n
fn as_json(&self) -> String {
if let Ok(mut json) = serde_json::to_string(&self) {
json.push('\n');
json
} else {
format!("{{\"error\":\"could not convert {} to json\"}}", self.url())
}
}
}
/// Serialize implementation for FeroxResponse
impl Serialize for FeroxResponse {
/// Function that handles serialization of a FeroxResponse to NDJSON
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut headers = HashMap::new();
let mut state = serializer.serialize_struct("FeroxResponse", 7)?;
// need to convert the HeaderMap to a HashMap in order to pass it to the serializer
for (key, value) in &self.headers {
let k = key.as_str().to_owned();
let v = String::from_utf8_lossy(value.as_bytes());
headers.insert(k, v);
}
state.serialize_field("type", "response")?;
state.serialize_field("url", self.url.as_str())?;
state.serialize_field("path", self.url.path())?;
state.serialize_field("wildcard", &self.wildcard)?;
state.serialize_field("status", &self.status.as_u16())?;
state.serialize_field("content_length", &self.content_length)?;
state.serialize_field("line_count", &self.line_count)?;
state.serialize_field("word_count", &self.word_count)?;
state.serialize_field("headers", &headers)?;
state.end()
}
}
/// Deserialize implementation for FeroxResponse
impl<'de> Deserialize<'de> for FeroxResponse {
/// Deserialize a FeroxResponse from a serde_json::Value
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut response = Self {
url: Url::parse("http://localhost").unwrap(),
status: StatusCode::OK,
text: String::new(),
content_length: 0,
headers: HeaderMap::new(),
wildcard: false,
line_count: 0,
word_count: 0,
};
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"url" => {
if let Some(url) = value.as_str() {
if let Ok(parsed) = Url::parse(url) {
response.url = parsed;
}
}
}
"status" => {
if let Some(num) = value.as_u64() {
if let Ok(smaller) = u16::try_from(num) {
if let Ok(status) = StatusCode::from_u16(smaller) {
response.status = status;
}
}
}
}
"content_length" => {
if let Some(num) = value.as_u64() {
response.content_length = num;
}
}
"line_count" => {
if let Some(num) = value.as_u64() {
response.line_count = num.try_into().unwrap_or_default();
}
}
"word_count" => {
if let Some(num) = value.as_u64() {
response.word_count = num.try_into().unwrap_or_default();
}
}
"headers" => {
let mut headers = HeaderMap::<HeaderValue>::default();
if let Some(map_headers) = value.as_object() {
for (h_key, h_value) in map_headers {
let h_value_str = h_value.as_str().unwrap_or("");
let h_name = HeaderName::from_str(h_key)
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
let h_value_parsed = HeaderValue::from_str(h_value_str)
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
headers.insert(h_name, h_value_parsed);
}
}
response.headers = headers;
}
"wildcard" => {
if let Some(result) = value.as_bool() {
response.wildcard = result;
}
}
_ => {}
}
}
Ok(response)
}
}
#[derive(Serialize, Deserialize, Default)]
/// Representation of a log entry, can be represented as a human readable string or JSON
pub struct FeroxMessage {
#[serde(rename = "type")]
/// Name of this type of struct, used for serialization, i.e. `{"type":"log"}`
kind: String,
/// The log message
pub message: String,
/// The log level
pub level: String,
/// The number of seconds elapsed since the scan started
pub time_offset: f32,
/// The module from which log::* was called
pub module: String,
}
/// Implementation of FeroxMessage
impl FeroxSerialize for FeroxMessage {
/// Create an NDJSON representation of the log message
///
/// (expanded for clarity)
/// ex:
/// {
/// "type": "log",
/// "message": "Sent https://localhost/api to file handler",
/// "level": "DEBUG",
/// "time_offset": 0.86333454,
/// "module": "feroxbuster::reporter"
/// }\n
fn as_json(&self) -> String {
if let Ok(mut json) = serde_json::to_string(&self) {
json.push('\n');
json
} else {
String::from("{\"error\":\"could not convert to json\"}")
}
}
/// Create a string representation of the log message
///
/// ex: 301 10l 16w 173c https://localhost/api
fn as_str(&self) -> String {
let (level_name, level_color) = match self.level.as_str() {
"ERROR" => ("ERR", Color::Red),
"WARN" => ("WRN", Color::Red),
"INFO" => ("INF", Color::Cyan),
"DEBUG" => ("DBG", Color::Yellow),
"TRACE" => ("TRC", Color::Magenta),
"WILDCARD" => ("WLD", Color::Cyan),
_ => ("UNK", Color::White),
};
format!(
"{} {:10.03} {} {}\n",
style(level_name).bg(level_color).black(),
style(self.time_offset).dim(),
self.module,
style(&self.message).dim(),
)
}
}
/// 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)]
mod tests {
@@ -555,46 +148,4 @@ mod tests {
fn default_version() {
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
}
#[test]
/// test as_str method of FeroxMessage
fn ferox_message_as_str_returns_string_with_newline() {
let message = FeroxMessage {
message: "message".to_string(),
module: "utils".to_string(),
time_offset: 1.0,
level: "INFO".to_string(),
kind: "log".to_string(),
};
let message_str = message.as_str();
assert!(message_str.contains("INF"));
assert!(message_str.contains("1.000"));
assert!(message_str.contains("utils"));
assert!(message_str.contains("message"));
assert!(message_str.ends_with('\n'));
}
#[test]
/// test as_json method of FeroxMessage
fn ferox_message_as_json_returns_json_representation_of_ferox_message_with_newline() {
let message = FeroxMessage {
message: "message".to_string(),
module: "utils".to_string(),
time_offset: 1.0,
level: "INFO".to_string(),
kind: "log".to_string(),
};
let message_str = message.as_json();
let error_margin = f32::EPSILON;
let json: FeroxMessage = serde_json::from_str(&message_str).unwrap();
assert_eq!(json.module, message.module);
assert_eq!(json.message, message.message);
assert!((json.time_offset - message.time_offset).abs() < error_margin);
assert_eq!(json.level, message.level);
assert_eq!(json.kind, message.kind);
}
}

View File

@@ -1,24 +1,31 @@
use crate::{
config::{CONFIGURATION, PROGRESS_PRINTER},
reporter::safe_file_write,
utils::open_file,
FeroxMessage, FeroxSerialize,
};
use env_logger::Builder;
use std::env;
use std::fs::OpenOptions;
use std::io::BufWriter;
use std::sync::{Arc, RwLock};
use std::time::Instant;
use anyhow::{Context, Result};
use env_logger::Builder;
use crate::{
config::Configuration,
message::FeroxMessage,
progress::PROGRESS_PRINTER,
traits::FeroxSerialize,
utils::{fmt_err, write_to},
};
/// Create a customized instance of
/// [env_logger::Logger](https://docs.rs/env_logger/latest/env_logger/struct.Logger.html)
/// with timer offset/color and set the log level based on `verbosity`
pub fn initialize(verbosity: u8) {
pub fn initialize(config: Arc<Configuration>) -> Result<()> {
// use occurrences of -v on commandline to or verbosity = N in feroxconfig.toml to set
// log level for the application; respects already specified RUST_LOG environment variable
match env::var("RUST_LOG") {
Ok(_) => {} // RUST_LOG found, don't override
Err(_) => {
// only set log level based on verbosity when RUST_LOG variable doesn't exist
match verbosity {
match config.verbosity {
0 => (),
1 => env::set_var("RUST_LOG", "warn"),
2 => env::set_var("RUST_LOG", "info"),
@@ -31,12 +38,22 @@ pub fn initialize(verbosity: u8) {
let start = Instant::now();
let mut builder = Builder::from_default_env();
let debug_file = open_file(&CONFIGURATION.debug_log);
let file = if !config.debug_log.is_empty() {
let f = OpenOptions::new() // std fs
.create(true)
.append(true)
.open(&config.debug_log)
.with_context(|| fmt_err(&format!("Could not open {}", &config.debug_log)))?;
let mut writer = BufWriter::new(f);
if let Some(buffered_file) = debug_file.clone() {
// write out the configuration to the debug file if it exists
safe_file_write(&*CONFIGURATION, buffered_file, CONFIGURATION.json);
}
write_to(&*config, &mut writer, config.json)?;
Some(Arc::new(RwLock::new(writer)))
} else {
None
};
builder
.format(move |_, record| {
@@ -50,11 +67,15 @@ pub fn initialize(verbosity: u8) {
PROGRESS_PRINTER.println(&log_entry.as_str());
if let Some(buffered_file) = debug_file.clone() {
safe_file_write(&log_entry, buffered_file, CONFIGURATION.json);
if let Some(buffered_file) = file.clone() {
if let Ok(mut unlocked) = buffered_file.write() {
let _ = write_to(&log_entry, &mut unlocked, config.json);
}
}
Ok(())
})
.init();
Ok(())
}

23
src/macros.rs Normal file
View File

@@ -0,0 +1,23 @@
#![macro_use]
#[macro_export]
/// wrapper to improve code readability
macro_rules! send_command {
($tx:expr, $value:expr) => {
$tx.send($value).unwrap_or_default();
};
}
#[macro_export]
/// while looping, check for a Result, if Ok return the value, if Err, continue
macro_rules! skip_fail {
($res:expr) => {
match $res {
Ok(val) => val,
Err(e) => {
log::warn!("{}", fmt_err(&format!("{}; skipping...", e)));
continue;
}
}
};
}

View File

@@ -1,99 +1,68 @@
use crossterm::event::{self, Event, KeyCode};
use std::io::stdin;
use std::{
env::args,
fs::{create_dir, remove_file, File},
io::{stderr, BufRead, BufReader},
ops::Index,
path::Path,
process::Command,
sync::{atomic::Ordering, Arc},
};
use anyhow::{bail, Context, Result};
use futures::StreamExt;
use tokio::{
io,
sync::{oneshot, Semaphore},
};
use tokio_util::codec::{FramedRead, LinesCodec};
use feroxbuster::scan_manager::ScanType;
use feroxbuster::{
banner,
config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER},
extractor::{extract_robots_txt, request_feroxresponse_from_new_link},
heuristics, logger,
progress::{add_bar, BarType},
reporter,
scan_manager::{self, PAUSE_SCAN},
scanner::{self, scan_url, send_report, RESPONSES, SCANNED_URLS},
statistics::{
self,
StatCommand::{self, CreateBar, LoadStats, UpdateUsizeField},
StatField::InitialTargets,
Stats,
banner::{Banner, UPDATE_URL},
config::{Configuration, OutputLevel},
event_handlers::{
Command::{CreateBar, Exit, JoinTasks, LoadStats, ScanInitialUrls, UpdateWordlist},
FiltersHandler, Handles, ScanHandler, StatsHandler, Tasks, TermInputHandler,
TermOutHandler, SCAN_COMPLETE,
},
update_stat,
utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer},
FeroxError, FeroxResponse, FeroxResult, FeroxSerialize, SLEEP_DURATION, VERSION,
filters, heuristics, logger,
progress::{PROGRESS_BAR, PROGRESS_PRINTER},
scan_manager::{self},
scanner,
utils::{fmt_err, slugify_filename},
};
#[cfg(not(target_os = "windows"))]
use feroxbuster::{utils::set_open_file_limit, DEFAULT_OPEN_FILE_LIMIT};
use futures::StreamExt;
use std::{
collections::HashSet,
convert::TryInto,
fs::File,
io::{stderr, BufRead, BufReader},
process,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tokio::{io, sync::mpsc::UnboundedSender, task::JoinHandle};
use tokio_util::codec::{FramedRead, LinesCodec};
use lazy_static::lazy_static;
use regex::Regex;
/// Atomic boolean flag, used to determine whether or not the terminal input handler should exit
pub static SCAN_COMPLETE: AtomicBool = AtomicBool::new(false);
/// Handles specific key events triggered by the user over stdin
fn terminal_input_handler() {
log::trace!("enter: terminal_input_handler");
loop {
if event::poll(Duration::from_millis(SLEEP_DURATION)).unwrap_or(false) {
// It's guaranteed that the `read()` won't block when the `poll()`
// function returns `true`
if let Ok(key_pressed) = event::read() {
if key_pressed == Event::Key(KeyCode::Enter.into()) {
// if the user presses Enter, toggle the value stored in PAUSE_SCAN
// ignore any other keys
let current = PAUSE_SCAN.load(Ordering::Acquire);
PAUSE_SCAN.store(!current, Ordering::Release);
}
}
} else {
// Timeout expired and no `Event` is available; use the timeout to check SCAN_COMPLETE
if SCAN_COMPLETE.load(Ordering::Relaxed) {
// scan has been marked complete by main, time to exit the loop
break;
}
}
}
log::trace!("exit: terminal_input_handler");
lazy_static! {
/// Limits the number of parallel scans active at any given time when using --parallel
static ref PARALLEL_LIMITER: Semaphore = Semaphore::new(0);
}
/// Create a HashSet of Strings from the given wordlist then stores it inside an Arc
fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>>> {
fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<Vec<String>>> {
log::trace!("enter: get_unique_words_from_wordlist({})", path);
let file = match File::open(&path) {
Ok(f) => f,
Err(e) => {
log::error!("Could not open wordlist: {}", e);
log::trace!("exit: get_unique_words_from_wordlist -> {}", e);
return Err(Box::new(e));
}
};
let file = File::open(&path).with_context(|| format!("Could not open {}", path))?;
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() {
let result = line?;
if result.starts_with('#') || result.is_empty() {
continue;
}
words.insert(result);
line.map(|result| {
if !result.starts_with('#') && !result.is_empty() {
words.push(result);
}
})
.ok();
}
log::trace!(
@@ -105,37 +74,14 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
}
/// Determine whether it's a single url scan or urls are coming from stdin, then scan as needed
async fn scan(
mut targets: Vec<String>,
stats: Arc<Stats>,
tx_term: UnboundedSender<FeroxResponse>,
tx_file: UnboundedSender<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
) -> FeroxResult<()> {
log::trace!(
"enter: scan({:?}, {:?}, {:?}, {:?}, {:?})",
targets,
stats,
tx_term,
tx_file,
tx_stats
);
// 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 =
tokio::spawn(async move { get_unique_words_from_wordlist(&CONFIGURATION.wordlist) })
.await??;
async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: scan({:?}, {:?})", targets, handles);
if words.len() == 0 {
let err = FeroxError {
message: format!("Did not find any words in {}", CONFIGURATION.wordlist),
};
let scanned_urls = handles.ferox_scans()?;
return Err(Box::new(err));
}
handles.send_scan_command(UpdateWordlist(handles.wordlist.clone()))?;
scanner::initialize(words.len(), &CONFIGURATION, tx_stats.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
// first:
@@ -143,110 +89,35 @@ async fn scan(
// - scanner initialized (this sent expected requests per directory to the stats thread, which
// having been set, makes it so the progress bar doesn't flash as full before anything has
// even happened
update_stat!(tx_stats, CreateBar);
if matches!(handles.config.output_level, OutputLevel::Default) {
// only create the bar if no --silent|--quiet
handles.stats.send(CreateBar)?;
if CONFIGURATION.resumed {
update_stat!(tx_stats, LoadStats(CONFIGURATION.resume_from.clone()));
if let Ok(responses) = RESPONSES.responses.read() {
for response in responses.iter() {
PROGRESS_PRINTER.println(response.as_str());
}
}
if let Ok(scans) = SCANNED_URLS.scans.lock() {
for scan in scans.iter() {
if let Ok(locked_scan) = scan.lock() {
if locked_scan.complete {
// these scans are complete, and just need to be shown to the user
let pb = add_bar(
&locked_scan.url,
words.len().try_into().unwrap_or_default(),
BarType::Message,
);
pb.finish();
}
}
}
}
// blocks until the bar is created / avoids race condition in first two bars
handles.stats.sync().await?;
}
if CONFIGURATION.extract_links {
for target in targets.clone() {
// modifying the targets vector, so we can't have a reference to it while we borrow
// it as mutable; thus the clone
let robots_links = extract_robots_txt(&target, &CONFIGURATION, tx_stats.clone()).await;
for robot_link in robots_links {
// create a url based on the given command line options, continue on error
let ferox_response = match request_feroxresponse_from_new_link(
&robot_link,
tx_stats.clone(),
)
.await
{
Some(resp) => resp,
None => continue,
};
if ferox_response.is_file() {
SCANNED_URLS.add_file_scan(&robot_link, stats.clone());
send_report(tx_term.clone(), ferox_response);
} else {
let (unknown, _) = SCANNED_URLS.add_directory_scan(&robot_link, stats.clone());
if !unknown {
// known directory; can skip (unlikely)
continue;
}
// unknown directory; add to targets for scanning
targets.push(robot_link);
}
}
}
if handles.config.resumed {
// display what has already been completed
scanned_urls.print_known_responses();
scanned_urls.print_completed_bars(handles.wordlist.len())?;
}
let mut tasks = vec![];
log::debug!("sending {:?} to be scanned as initial targets", targets);
handles.send_scan_command(ScanInitialUrls(targets))?;
for target in targets {
let word_clone = words.clone();
let term_clone = tx_term.clone();
let file_clone = tx_file.clone();
let tx_stats_clone = tx_stats.clone();
let stats_clone = stats.clone();
let task = tokio::spawn(async move {
let base_depth = get_current_depth(&target);
scan_url(
&target,
word_clone,
base_depth,
stats_clone,
term_clone,
file_clone,
tx_stats_clone,
)
.await;
});
tasks.push(task);
}
// drive execution of all accumulated futures
futures::future::join_all(tasks).await;
log::trace!("exit: scan");
Ok(())
}
/// Get targets from either commandline or stdin, pass them back to the caller as a Result<Vec>
async fn get_targets() -> FeroxResult<Vec<String>> {
log::trace!("enter: get_targets");
async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
log::trace!("enter: get_targets({:?})", handles);
let mut targets = vec![];
if CONFIGURATION.stdin {
if handles.config.stdin {
// got targets from stdin, i.e. cat sites | ./feroxbuster ...
// just need to read the targets from stdin and spawn a future for each target found
let stdin = io::stdin(); // tokio's stdin, not std
@@ -255,24 +126,47 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
while let Some(line) = reader.next().await {
targets.push(line?);
}
} else if CONFIGURATION.resumed {
} else if handles.config.resumed {
// resume-from can't be used with --url, and --stdin is marked false for every resumed
// scan, making it mutually exclusive from either of the other two options
if let Ok(scans) = SCANNED_URLS.scans.lock() {
let ferox_scans = handles.ferox_scans()?;
if let Ok(scans) = ferox_scans.scans.read() {
for scan in scans.iter() {
// SCANNED_URLS 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
if let Ok(locked_scan) = scan.lock() {
if locked_scan.complete {
// this one's already done, ignore it
continue;
}
targets.push(locked_scan.url.to_owned());
if scan.is_complete() || matches!(scan.scan_type, ScanType::File) {
// this one's already done, or it's not a directory, ignore it
continue;
}
targets.push(scan.url().to_owned());
}
};
} else {
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 {
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
);
}
}
} else {
targets.push(CONFIGURATION.target_url.clone());
}
log::trace!("exit: get_targets -> {:?}", targets);
@@ -282,7 +176,7 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
/// async main called from real main, broken out in this way to allow for some synchronous code
/// to be executed before bringing the tokio runtime online
async fn wrapped_main() {
async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
// join can only be called once, otherwise it causes the thread to panic
tokio::task::spawn_blocking(move || {
// ok, lazy_static! uses (unsurprisingly in retrospect) a lazy loading model where the
@@ -296,17 +190,47 @@ async fn wrapped_main() {
PROGRESS_BAR.join().unwrap();
});
let (stats, tx_stats, stats_handle) = statistics::initialize();
// 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 = get_unique_words_from_wordlist(&config.wordlist)?;
if !CONFIGURATION.time_limit.is_empty() {
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
let (stats_task, stats_handle) = StatsHandler::initialize(config.clone());
let (filters_task, filters_handle) = FiltersHandler::initialize();
let (out_task, out_handle) =
TermOutHandler::initialize(config.clone(), stats_handle.tx.clone());
// bundle up all the disparate handles and JoinHandles (tasks)
let handles = Arc::new(Handles::new(
stats_handle,
filters_handle,
out_handle,
config.clone(),
words,
));
let (scan_task, scan_handle) = ScanHandler::initialize(handles.clone());
handles.set_scan_handle(scan_handle); // must be done after Handles initialization
filters::initialize(handles.clone()).await?; // send user-supplied filters to the handler
// create new Tasks object, each of these handles is one that will be joined on later
let tasks = Tasks::new(out_task, stats_task, filters_task, scan_task);
if !config.time_limit.is_empty() {
// --time-limit value not an empty string, need to kick off the thread that enforces
// the limit
let max_time_stats = stats.clone();
tokio::spawn(async move {
scan_manager::start_max_time_thread(&CONFIGURATION.time_limit, max_time_stats).await
});
let time_handles = handles.clone();
tokio::spawn(async move { scan_manager::start_max_time_thread(time_handles).await });
}
// can't trace main until after logger is initialized and the above task is started
@@ -315,172 +239,239 @@ async fn wrapped_main() {
// spawn a thread that listens for keyboard input on stdin, when a user presses enter
// the input handler will toggle PAUSE_SCAN, which in turn is used to pause and resume
// scans that are already running
tokio::task::spawn_blocking(terminal_input_handler);
// also starts ctrl+c handler
TermInputHandler::initialize(handles.clone());
let save_output = !CONFIGURATION.output.is_empty(); // was -o used?
if config.resumed {
let scanned_urls = handles.ferox_scans()?;
let from_here = config.resume_from.clone();
if CONFIGURATION.save_state {
// start the ctrl+c handler
scan_manager::initialize(stats.clone());
// populate FeroxScans object with previously seen scans
scanned_urls.add_serialized_scans(&from_here, handles.clone())?;
// populate Stats object with previously known statistics
handles.stats.send(LoadStats(from_here))?;
}
let (tx_term, tx_file, term_handle, file_handle) =
reporter::initialize(&CONFIGURATION.output, save_output, tx_stats.clone());
// get targets from command line or stdin
let targets = match get_targets().await {
let targets = match get_targets(handles.clone()).await {
Ok(t) => t,
Err(e) => {
// should only happen in the event that there was an error reading from stdin
log::error!("{} {}", module_colorizer("main::get_targets"), e);
clean_up(
tx_term,
term_handle,
tx_file,
file_handle,
tx_stats,
stats_handle,
save_output,
)
.await;
return;
clean_up(handles, tasks).await?;
bail!("Could not determine initial targets: {}", e);
}
};
update_stat!(tx_stats, UpdateUsizeField(InitialTargets, targets.len()));
// --parallel branch
if config.parallel > 0 {
log::trace!("enter: parallel branch");
if !CONFIGURATION.quiet {
// only print banner if -q isn't used
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) {
// only print banner if output level is default (no banner on --quiet|--silent)
let std_stderr = stderr(); // std::io::stderr
banner::initialize(
&targets,
&CONFIGURATION,
&VERSION,
std_stderr,
tx_stats.clone(),
)
.await;
let mut banner = Banner::new(&targets, &config);
// only interested in the side-effect that sets banner.update_status
let _ = banner.check_for_updates(UPDATE_URL, handles.clone()).await;
if banner.print_to(std_stderr, config.clone()).is_err() {
clean_up(handles, tasks).await?;
bail!(fmt_err("Could not print banner"));
}
}
{
let send_to_file = !config.output.is_empty();
// 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
// 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() {
// output file specified and file handler could not initialize
clean_up(handles, tasks).await?;
let msg = format!("Couldn't start {} file handler", config.output);
bail!(fmt_err(&msg));
}
}
// discard non-responsive targets
let live_targets = heuristics::connectivity_test(&targets, tx_stats.clone()).await;
let live_targets = {
let test = heuristics::HeuristicTests::new(handles.clone());
let result = test.connectivity(&targets).await;
if result.is_err() {
clean_up(handles, tasks).await?;
bail!(fmt_err(&result.unwrap_err().to_string()));
}
result?
};
if live_targets.is_empty() {
clean_up(
tx_term,
term_handle,
tx_file,
file_handle,
tx_stats,
stats_handle,
save_output,
)
.await;
return;
clean_up(handles, tasks).await?;
bail!(fmt_err("Could not find any live targets to scan"));
}
// kick off a scan against any targets determined to be responsive
match scan(
live_targets,
stats,
tx_term.clone(),
tx_file.clone(),
tx_stats.clone(),
)
.await
{
Ok(_) => {
log::info!("All scans complete!");
}
match scan(live_targets, handles.clone()).await {
Ok(_) => {}
Err(e) => {
ferox_print(
&format!("{} while scanning: {}", status_colorizer("Error"), e),
&PROGRESS_PRINTER,
);
clean_up(
tx_term,
term_handle,
tx_file,
file_handle,
tx_stats,
stats_handle,
save_output,
)
.await;
process::exit(1);
clean_up(handles, tasks).await?;
bail!(fmt_err(&format!("Failed while scanning: {}", e)));
}
};
}
clean_up(
tx_term,
term_handle,
tx_file,
file_handle,
tx_stats,
stats_handle,
save_output,
)
.await;
clean_up(handles, tasks).await?;
log::trace!("exit: main");
log::trace!("exit: wrapped_main");
Ok(())
}
/// Single cleanup function that handles all the necessary drops/finishes etc required to gracefully
/// shutdown the program
async fn clean_up(
tx_term: UnboundedSender<FeroxResponse>,
term_handle: JoinHandle<()>,
tx_file: UnboundedSender<FeroxResponse>,
file_handle: Option<JoinHandle<()>>,
tx_stats: UnboundedSender<StatCommand>,
stats_handle: JoinHandle<()>,
save_output: bool,
) {
log::trace!(
"enter: clean_up({:?}, {:?}, {:?}, {:?}, {:?}, {:?}, {})",
tx_term,
term_handle,
tx_file,
file_handle,
tx_stats,
stats_handle,
save_output
);
async fn clean_up(handles: Arc<Handles>, tasks: Tasks) -> Result<()> {
log::trace!("enter: clean_up({:?}, {:?})", handles, tasks);
drop(tx_term);
log::trace!("dropped terminal output handler's transmitter");
let (tx, rx) = oneshot::channel::<bool>();
handles.send_scan_command(JoinTasks(tx))?;
rx.await?;
log::trace!("awaiting terminal output handler's receiver");
// after dropping tx, we can await the future where rx lived
match term_handle.await {
Ok(_) => {}
Err(e) => {
log::error!("error awaiting terminal output handler's receiver: {}", e);
}
}
log::trace!("done awaiting terminal output handler's receiver");
log::info!("All scans complete!");
log::trace!("tx_file: {:?}", tx_file);
// the same drop/await process used on the terminal handler is repeated for the file handler
// we drop the file transmitter every time, because it's created no matter what
drop(tx_file);
// terminal handler closes file handler if one is in use
handles.output.send(Exit)?;
tasks.terminal.await??;
log::trace!("terminal handler closed");
log::trace!("dropped file output handler's transmitter");
if save_output {
// but we only await if -o was specified
log::trace!("awaiting file output handler's receiver");
match file_handle.unwrap().await {
Ok(_) => {}
Err(e) => {
log::error!("error awaiting file output handler's receiver: {}", e);
}
}
log::trace!("done awaiting file output handler's receiver");
}
handles.filters.send(Exit)?;
tasks.filters.await??;
log::trace!("filters handler closed");
log::trace!("tx_stats: {:?}", tx_stats);
update_stat!(tx_stats, StatCommand::Exit); // send exit command and await the end of the future
stats_handle.await.unwrap_or_default();
handles.stats.send(Exit)?;
tasks.stats.await??;
log::trace!("stats handler closed");
// mark all scans complete so the terminal input handler will exit cleanly
SCAN_COMPLETE.store(true, Ordering::Relaxed);
@@ -490,18 +481,66 @@ async fn clean_up(
PROGRESS_PRINTER.finish();
log::trace!("exit: clean_up");
Ok(())
}
fn main() {
fn main() -> Result<()> {
let config = Arc::new(Configuration::new().with_context(|| "Could not create Configuration")?);
// setup logging based on the number of -v's used
logger::initialize(CONFIGURATION.verbosity);
if matches!(
config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
// don't log on --silent
logger::initialize(config.clone())?;
}
// this function uses rlimit, which is not supported on windows
#[cfg(not(target_os = "windows"))]
set_open_file_limit(DEFAULT_OPEN_FILE_LIMIT);
if let Ok(mut runtime) = tokio::runtime::Runtime::new() {
let future = wrapped_main();
runtime.block_on(future);
if let Ok(runtime) = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
let future = wrapped_main(config.clone());
if let Err(e) = runtime.block_on(future) {
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();
};
}
log::trace!("exit: main");
Ok(())
}

148
src/message.rs Normal file
View File

@@ -0,0 +1,148 @@
use anyhow::Context;
use console::{style, Color};
use serde::{Deserialize, Serialize};
use crate::traits::FeroxSerialize;
use crate::utils::fmt_err;
#[derive(Serialize, Deserialize, Default, Debug)]
/// Representation of a log entry, can be represented as a human readable string or JSON
pub struct FeroxMessage {
#[serde(rename = "type")]
/// Name of this type of struct, used for serialization, i.e. `{"type":"log"}`
pub(crate) kind: String,
/// The log message
pub(crate) message: String,
/// The log level
pub(crate) level: String,
/// The number of seconds elapsed since the scan started
pub(crate) time_offset: f32,
/// The module from which log::* was called
pub(crate) module: String,
}
/// Implementation of FeroxMessage
impl FeroxSerialize for FeroxMessage {
/// Create a string representation of the log message
///
/// ex: 301 10l 16w 173c https://localhost/api
fn as_str(&self) -> String {
let (level_name, level_color) = match self.level.as_str() {
"ERROR" => ("ERR", Color::Red),
"WARN" => ("WRN", Color::Red),
"INFO" => ("INF", Color::Cyan),
"DEBUG" => ("DBG", Color::Yellow),
"TRACE" => ("TRC", Color::Magenta),
"WILDCARD" => ("WLD", Color::Cyan),
_ => ("MSG", Color::White),
};
format!(
"{} {:10.03} {} {}\n",
style(level_name).bg(level_color).black(),
style(self.time_offset).dim(),
self.module,
style(&self.message).dim(),
)
}
/// Create an NDJSON representation of the log message
///
/// (expanded for clarity)
/// ex:
/// {
/// "type": "log",
/// "message": "Sent https://localhost/api to file handler",
/// "level": "DEBUG",
/// "time_offset": 0.86333454,
/// "module": "feroxbuster::reporter"
/// }\n
fn as_json(&self) -> anyhow::Result<String> {
let mut json = serde_json::to_string(&self).with_context(|| {
fmt_err(&format!(
"Could not convert {}:{} to JSON",
self.level, self.message
))
})?;
json.push('\n');
Ok(json)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// test as_str method of FeroxMessage
fn ferox_message_as_str_returns_string_with_newline() {
let message = FeroxMessage {
message: "message".to_string(),
module: "utils".to_string(),
time_offset: 1.0,
level: "INFO".to_string(),
kind: "log".to_string(),
};
let message_str = message.as_str();
assert!(message_str.contains("INF"));
assert!(message_str.contains("1.000"));
assert!(message_str.contains("utils"));
assert!(message_str.contains("message"));
assert!(message_str.ends_with('\n'));
}
#[test]
/// test as_json method of FeroxMessage
fn ferox_message_as_json_returns_json_representation_of_ferox_message_with_newline() {
let message = FeroxMessage {
message: "message".to_string(),
module: "utils".to_string(),
time_offset: 1.0,
level: "INFO".to_string(),
kind: "log".to_string(),
};
let message_str = message.as_json().unwrap();
let error_margin = f32::EPSILON;
let json: FeroxMessage = serde_json::from_str(&message_str).unwrap();
assert_eq!(json.module, message.module);
assert_eq!(json.message, message.message);
assert!((json.time_offset - message.time_offset).abs() < error_margin);
assert_eq!(json.level, message.level);
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));
}
}
}

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

@@ -0,0 +1,10 @@
//! 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;

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(super) 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
}
}
/// remove ascii and some utf-8 punctuation characters from the given string
fn remove_punctuation(text: &str) -> String {
// non-separator type chars can be replaced with an empty string, while separators are replaced
// with a space. This attempts to keep things like
// 'aboutblogfaqcontactpresstermslexicondisclosure' from happening
text.replace(
[
'!', '\\', '"', '#', '$', '%', '&', '(', ')', '*', '+', ':', ';', '<', '=', '>', '?',
'@', '[', ']', '^', '{', '}', '|', '~', ',', '\'', '“', '”', '', '', '', '',
],
"",
)
.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), &["yall", "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,10 @@
use clap::{App, Arg, ArgGroup};
use clap::{
crate_authors, crate_description, crate_name, crate_version, Arg, ArgGroup, Command, ValueHint,
};
use lazy_static::lazy_static;
use regex::Regex;
use std::env;
use std::process;
lazy_static! {
/// Regex used to validate values passed to --time-limit
@@ -12,334 +16,638 @@ lazy_static! {
/// - 1d
pub static ref TIMESPEC_REGEX: 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
pub fn initialize() -> App<'static, 'static> {
App::new("feroxbuster")
.version(env!("CARGO_PKG_VERSION"))
.author("Ben 'epi' Risher (@epi052)")
.about("A fast, simple, recursive content discovery tool written in Rust")
pub fn initialize() -> Command<'static> {
let app = Command::new(crate_name!())
.version(crate_version!())
.author(crate_authors!())
.about(crate_description!());
/////////////////////////////////////////////////////////////////////
// group - target selection
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::with_name("wordlist")
.short("w")
.long("wordlist")
.value_name("FILE")
.help("Path to the wordlist")
.takes_value(true),
)
.arg(
Arg::with_name("url")
.short("u")
Arg::new("url")
.short('u')
.long("url")
.required_unless_one(&["stdin", "resume_from"])
.required_unless_present_any(&["stdin", "resume_from"])
.help_heading("Target selection")
.value_name("URL")
.multiple(true)
.use_delimiter(true)
.help("The target URL(s) (required, unless --stdin used)"),
.use_value_delimiter(true)
.value_hint(ValueHint::Url)
.help("The target URL (required, unless [--stdin || --resume-from] used)"),
)
.arg(
Arg::with_name("threads")
.short("t")
.long("threads")
.value_name("THREADS")
.takes_value(true)
.help("Number of concurrent threads (default: 50)"),
)
.arg(
Arg::with_name("depth")
.short("d")
.long("depth")
.value_name("RECURSION_DEPTH")
.takes_value(true)
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"),
)
.arg(
Arg::with_name("timeout")
.short("T")
.long("timeout")
.value_name("SECONDS")
.takes_value(true)
.help("Number of seconds before a request times out (default: 7)"),
)
.arg(
Arg::with_name("verbosity")
.short("v")
.long("verbosity")
.takes_value(false)
.multiple(true)
.help("Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably too much)"),
)
.arg(
Arg::with_name("proxy")
.short("p")
.long("proxy")
.takes_value(true)
.value_name("PROXY")
.help(
"Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)",
),
)
.arg(
Arg::with_name("replay_proxy")
.short("P")
.long("replay-proxy")
.takes_value(true)
.value_name("REPLAY_PROXY")
.help(
"Send only unfiltered requests through a Replay Proxy, instead of all requests",
),
)
.arg(
Arg::with_name("replay_codes")
.short("R")
.long("replay-codes")
.value_name("REPLAY_CODE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.requires("replay_proxy")
.help(
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
),
)
.arg(
Arg::with_name("status_codes")
.short("s")
.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("quiet")
.short("q")
.long("quiet")
.takes_value(false)
.help("Only print URLs; Don't print status codes, response size, running config, etc...")
)
.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")
.value_name("USER_AGENT")
.takes_value(true)
.help(
"Sets the User-Agent (default: feroxbuster/VERSION)"
),
)
.arg(
Arg::with_name("redirects")
.short("r")
.long("redirects")
.takes_value(false)
.help("Follow redirects")
)
.arg(
Arg::with_name("insecure")
.short("k")
.long("insecure")
.takes_value(false)
.help("Disables TLS certificate validation")
)
.arg(
Arg::with_name("extensions")
.short("x")
.long("extensions")
.value_name("FILE_EXTENSION")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.help(
"File extension(s) to search for (ex: -x php -x pdf js)",
),
)
.arg(
Arg::with_name("headers")
.short("H")
.long("headers")
.value_name("HEADER")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.help(
"Specify HTTP headers (ex: -H Header:val 'stuff: things')",
),
)
.arg(
Arg::with_name("queries")
.short("Q")
.long("query")
.value_name("QUERY")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.help(
"Specify URL query parameters (ex: -Q token=stuff -Q secret=key)",
),
)
.arg(
Arg::with_name("no_recursion")
.short("n")
.long("no-recursion")
.takes_value(false)
.help("Do not scan recursively")
)
.arg(
Arg::with_name("add_slash")
.short("f")
.long("add-slash")
.takes_value(false)
.conflicts_with("extensions")
.help("Append / to each request")
)
.arg(
Arg::with_name("stdin")
Arg::new("stdin")
.long("stdin")
.help_heading("Target selection")
.takes_value(false)
.help("Read url(s) from STDIN")
.conflicts_with("url")
)
.arg(
Arg::with_name("filter_size")
.short("S")
Arg::new("resume_from")
.long("resume-from")
.value_hint(ValueHint::FilePath)
.value_name("STATE_FILE")
.help_heading("Target selection")
.help("State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)")
.conflicts_with("url")
.takes_value(true),
);
/////////////////////////////////////////////////////////////////////
// group - composite settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("burp")
.long("burp")
.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::new("burp_replay")
.long("burp-replay")
.help_heading("Composite settings")
.conflicts_with_all(&["replay_proxy", "insecure"])
.help("Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true"),
)
.arg(
Arg::new("smart")
.long("smart")
.help_heading("Composite settings")
.help("Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true"),
).arg(
Arg::new("thorough")
.long("thorough")
.help_heading("Composite settings")
.help("Use the same settings as --smart and set --collect-extensions to true"),
);
/////////////////////////////////////////////////////////////////////
// group - proxy settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("proxy")
.short('p')
.long("proxy")
.takes_value(true)
.value_name("PROXY")
.value_hint(ValueHint::Url)
.help_heading("Proxy settings")
.help(
"Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)",
),
)
.arg(
Arg::new("replay_proxy")
.short('P')
.long("replay-proxy")
.takes_value(true)
.value_hint(ValueHint::Url)
.value_name("REPLAY_PROXY")
.help_heading("Proxy settings")
.help(
"Send only unfiltered requests through a Replay Proxy, instead of all requests",
),
)
.arg(
Arg::new("replay_codes")
.short('R')
.long("replay-codes")
.value_name("REPLAY_CODE")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.requires("replay_proxy")
.help_heading("Proxy settings")
.help(
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
),
);
/////////////////////////////////////////////////////////////////////
// group - request settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("user_agent")
.short('a')
.long("user-agent")
.value_name("USER_AGENT")
.takes_value(true)
.help_heading("Request settings")
.help(&**DEFAULT_USER_AGENT),
)
.arg(
Arg::new("random_agent")
.short('A')
.long("random-agent")
.takes_value(false)
.help_heading("Request settings")
.help("Use a random User-Agent"),
)
.arg(
Arg::new("extensions")
.short('x')
.long("extensions")
.value_name("FILE_EXTENSION")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Request settings")
.help(
"File extension(s) to search for (ex: -x php -x pdf js)",
),
)
.arg(
Arg::new("methods")
.short('m')
.long("methods")
.value_name("HTTP_METHODS")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.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")
.takes_value(true)
.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")
.value_name("HEADER")
.takes_value(true)
.help_heading("Request settings")
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help(
"Specify HTTP headers to be used in each request (ex: -H Header:val -H 'stuff: things')",
),
)
.arg(
Arg::new("cookies")
.short('b')
.long("cookies")
.value_name("COOKIE")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.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")
.value_name("QUERY")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Request settings")
.help(
"Request's URL query parameters (ex: -Q token=stuff -Q secret=key)",
),
)
.arg(
Arg::new("add_slash")
.short('f')
.long("add-slash")
.help_heading("Request settings")
.takes_value(false)
.help("Append / to each request's URL")
);
/////////////////////////////////////////////////////////////////////
// group - request filters
/////////////////////////////////////////////////////////////////////
let app = app.arg(
Arg::new("url_denylist")
.long("dont-scan")
.value_name("URL")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.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::new("filter_size")
.short('S')
.long("filter-size")
.value_name("SIZE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)",
),
)
.arg(
Arg::with_name("filter_regex")
.short("X")
Arg::new("filter_regex")
.short('X')
.long("filter-regex")
.value_name("REGEX")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Filter out messages via regular expression matching on the response's body (ex: -X '^ignore me$')",
),
)
.arg(
Arg::with_name("filter_words")
.short("W")
Arg::new("filter_words")
.short('W')
.long("filter-words")
.value_name("WORDS")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Filter out messages of a particular word count (ex: -W 312 -W 91,82)",
),
)
.arg(
Arg::with_name("filter_lines")
.short("N")
Arg::new("filter_lines")
.short('N')
.long("filter-lines")
.value_name("LINES")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Filter out messages of a particular line count (ex: -N 20 -N 31,30)",
),
)
.arg(
Arg::with_name("filter_status")
.short("C")
Arg::new("filter_status")
.short('C')
.long("filter-status")
.value_name("STATUS_CODE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Filter out status codes (deny list) (ex: -C 200 -C 401)",
),
)
.arg(
Arg::with_name("filter_similar")
Arg::new("filter_similar")
.long("filter-similar-to")
.value_name("UNWANTED_PAGE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.multiple_values(true)
.multiple_occurrences(true)
.value_hint(ValueHint::Url)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)",
),
)
.arg(
Arg::with_name("extract_links")
.short("e")
.long("extract-links")
.takes_value(false)
.help("Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)")
Arg::new("status_codes")
.short('s')
.long("status-codes")
.value_name("STATUS_CODE")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.use_value_delimiter(true)
.help_heading("Response filters")
.help(
"Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)",
),
);
/////////////////////////////////////////////////////////////////////
// group - client settings
/////////////////////////////////////////////////////////////////////
let app = app
.arg(
Arg::new("timeout")
.short('T')
.long("timeout")
.value_name("SECONDS")
.takes_value(true)
.help_heading("Client settings")
.help("Number of seconds before a client's request times out (default: 7)"),
)
.arg(
Arg::with_name("scan_limit")
.short("L")
Arg::new("redirects")
.short('r')
.long("redirects")
.takes_value(false)
.help_heading("Client settings")
.help("Allow client to follow redirects"),
)
.arg(
Arg::new("insecure")
.short('k')
.long("insecure")
.takes_value(false)
.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")
.takes_value(true)
.help_heading("Scan settings")
.help("Number of concurrent threads (default: 50)"),
)
.arg(
Arg::new("no_recursion")
.short('n')
.long("no-recursion")
.takes_value(false)
.help_heading("Scan settings")
.help("Do not scan recursively"),
)
.arg(
Arg::new("depth")
.short('d')
.long("depth")
.value_name("RECURSION_DEPTH")
.takes_value(true)
.help_heading("Scan settings")
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"),
).arg(
Arg::new("extract_links")
.short('e')
.long("extract-links")
.takes_value(false)
.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")
.value_name("SCAN_LIMIT")
.takes_value(true)
.help_heading("Scan settings")
.help("Limit total number of concurrent scans (default: 0, i.e. no limit)")
)
.arg(
Arg::with_name("time_limit")
Arg::new("parallel")
.long("parallel")
.value_name("PARALLEL_SCANS")
.takes_value(true)
.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")
.value_name("RATE_LIMIT")
.takes_value(true)
.conflicts_with("auto_tune")
.help_heading("Scan settings")
.help("Limit number of requests per second (per directory) (default: 0, i.e. no limit)")
)
.arg(
Arg::new("time_limit")
.long("time-limit")
.value_name("TIME_SPEC")
.takes_value(true)
.validator(valid_time_spec)
.help_heading("Scan settings")
.help("Limit total run time of all scans (ex: --time-limit 10m)")
)
.group(ArgGroup::with_name("output_files")
.args(&["debug_log", "output"])
.multiple(true)
.arg(
Arg::new("wordlist")
.short('w')
.long("wordlist")
.value_hint(ValueHint::FilePath)
.value_name("FILE")
.help("Path to the wordlist")
.help_heading("Scan settings")
.takes_value(true),
).arg(
Arg::new("auto_tune")
.long("auto-tune")
.takes_value(false)
.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")
.takes_value(false)
.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")
.takes_value(false)
.help_heading("Scan settings")
.help("Don't auto-filter wildcard responses")
).arg(
Arg::new("collect_extensions")
.short('E')
.long("collect-extensions")
.takes_value(false)
.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")
.takes_value(false)
.help_heading("Dynamic collection settings")
.help("Automatically request likely backup extensions for \"found\" urls")
).arg(
Arg::new("collect_words")
.short('g')
.long("collect-words")
.takes_value(false)
.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")
.takes_value(true)
.multiple_values(true)
.multiple_occurrences(true)
.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")
.takes_value(false)
.multiple_occurrences(true)
.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")
.takes_value(false)
.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")
.takes_value(false)
.help_heading("Output settings")
.help("Hide progress bars and banner (good for tmux windows w/ notifications)")
)
.arg(
Arg::new("json")
.long("json")
.takes_value(false)
.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)")
.takes_value(true),
)
.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)")
.takes_value(true),
)
.arg(
Arg::new("no_state")
.long("no-state")
.takes_value(false)
.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> {
match TIMESPEC_REGEX.is_match(time_spec) {
true => Ok(()),
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
extensions:
./feroxbuster -u http://127.1 -x pdf -x js,html -x php txt json,docx
@@ -357,7 +665,7 @@ EXAMPLES:
./feroxbuster -u http://[::1] --no-recursion -vv
Read urls from STDIN; pipe only resulting urls out to another tool
cat targets | ./feroxbuster --stdin --quiet -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
./feroxbuster -u http://127.1 --insecure --proxy http://127.0.0.1:8080
@@ -372,30 +680,28 @@ EXAMPLES:
./feroxbuster -u http://127.1 --extract-links
Ludicrous speed... go!
./feroxbuster -u http://127.1 -t 200
"#)
}
/// 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> {
match TIMESPEC_REGEX.is_match(&time_spec) {
true => Ok(()),
false => {
let msg = format!(
"Expected a non-negative, whole number followed by s, m, h, or d (case insensitive); received {}",
time_spec
);
Err(msg)
}
}
}
./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
Send all 200/302 responses to a proxy (only proxy requests/responses you care about)
./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
Abort or reduce scan speed to individual directory scans when too many errors have occurred
./feroxbuster -u http://127.1 --auto-bail
./feroxbuster -u http://127.1 --auto-tune
Examples and demonstrations of all features
https://epi052.github.io/feroxbuster-docs/docs/examples/
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// initalize parser, expect a clap::App returned
/// initialize parser, expect a clap::App returned
fn parser_initialize_gives_defaults() {
let app = initialize();
assert_eq!(app.get_name(), "feroxbuster");
@@ -408,29 +714,29 @@ mod tests {
/// that i didn't hose up the regex. Going to consolidate them into a single test
fn validate_valid_time_spec_validation() {
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";
assert!(valid_time_spec(negative_rejected.into()).is_err());
assert!(valid_time_spec(negative_rejected).is_err());
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";
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"] {
// 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";
assert!(valid_time_spec(leading_space_rejected.into()).is_err());
assert!(valid_time_spec(leading_space_rejected).is_err());
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";
assert!(valid_time_spec(space_between_rejected.into()).is_err());
assert!(valid_time_spec(space_between_rejected).is_err());
}
}

View File

@@ -1,7 +1,16 @@
use crate::config::{CONFIGURATION, PROGRESS_BAR};
use indicatif::{ProgressBar, ProgressStyle};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use lazy_static::lazy_static;
lazy_static! {
/// Global progress bar that houses other progress bars
pub static ref PROGRESS_BAR: MultiProgress = MultiProgress::with_draw_target(ProgressDrawTarget::stdout());
/// Global progress bar that is only used for printing messages that don't jack up other bars
pub static ref PROGRESS_PRINTER: ProgressBar = add_bar("", 0, BarType::Hidden);
}
/// Types of ProgressBars that can be added to `PROGRESS_BAR`
#[derive(Copy, Clone)]
pub enum BarType {
/// no template used / not visible
Hidden,
@@ -14,6 +23,9 @@ pub enum BarType {
/// bar used to show overall scan metrics
Total,
/// simpler output bar that shows only the directory being scanned (no updating info)
Quiet,
}
/// Add an [indicatif::ProgressBar](https://docs.rs/indicatif/latest/indicatif/struct.ProgressBar.html)
@@ -21,29 +33,26 @@ pub enum BarType {
pub fn add_bar(prefix: &str, length: u64, bar_type: BarType) -> ProgressBar {
let mut style = ProgressStyle::default_bar().progress_chars("#>-");
style = if CONFIGURATION.quiet {
style.template("")
} else {
match bar_type {
BarType::Hidden => style.template(""),
BarType::Default => style.template(
"[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {per_sec:7} {prefix}",
),
BarType::Message => style.template(&format!(
"[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}}",
"-"
)),
BarType::Total => {
style.template("[{bar:.yellow/blue}] - {elapsed:<4} {pos:>7}/{len:7} {eta:7} {msg}")
}
style = match bar_type {
BarType::Hidden => style.template(""),
BarType::Default => style.template(
"[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {per_sec:7} {prefix} {msg}",
),
BarType::Message => style.template(&format!(
"[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}} {{msg}}",
"-"
)),
BarType::Total => {
style.template("[{bar:.yellow/blue}] - {elapsed:<4} {pos:>7}/{len:7} {eta:7} {msg}")
}
BarType::Quiet => style.template("Scanning: {prefix}"),
};
let progress_bar = PROGRESS_BAR.add(ProgressBar::new(length));
progress_bar.set_style(style);
progress_bar.set_prefix(&prefix);
progress_bar.set_prefix(prefix);
progress_bar
}

View File

@@ -1,259 +0,0 @@
use crate::{
config::{CONFIGURATION, PROGRESS_PRINTER},
scanner::RESPONSES,
statistics::{
StatCommand::{self, UpdateUsizeField},
StatField::ResourcesDiscovered,
},
utils::{ferox_print, make_request, open_file},
FeroxChannel, FeroxResponse, FeroxSerialize,
};
use console::strip_ansi_codes;
use std::{
fs, io,
io::Write,
sync::{Arc, Once, RwLock},
};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
/// Singleton buffered file behind an Arc/RwLock; used for file writes from two locations:
/// - [logger::initialize](../logger/fn.initialize.html) (specifically a closure on the global logger instance)
/// - `reporter::spawn_file_handler`
pub static mut LOCKED_FILE: Option<Arc<RwLock<io::BufWriter<fs::File>>>> = None;
/// An initializer Once variable used to create `LOCKED_FILE`
static INIT: Once = Once::new();
// Accessing a `static mut` is unsafe much of the time, but if we do so
// in a synchronized fashion (e.g., write once or read all) then we're
// good to go!
//
// This function will only call `open_file` once, and will
// otherwise always return the value returned from the first invocation.
pub fn get_cached_file_handle(filename: &str) -> Option<Arc<RwLock<io::BufWriter<fs::File>>>> {
unsafe {
INIT.call_once(|| {
LOCKED_FILE = open_file(&filename);
});
LOCKED_FILE.clone()
}
}
/// Creates all required output handlers (terminal, file) and returns
/// the transmitter sides of each mpsc along with each receiver's future's JoinHandle to be awaited
///
/// Any other module that needs to write a Response to stdout or output results to a file should
/// be passed a clone of the appropriate returned transmitter
pub fn initialize(
output_file: &str,
save_output: bool,
tx_stats: UnboundedSender<StatCommand>,
) -> (
UnboundedSender<FeroxResponse>,
UnboundedSender<FeroxResponse>,
JoinHandle<()>,
Option<JoinHandle<()>>,
) {
log::trace!(
"enter: initialize({}, {}, {:?})",
output_file,
save_output,
tx_stats
);
let (tx_rpt, rx_rpt): FeroxChannel<FeroxResponse> = mpsc::unbounded_channel();
let (tx_file, rx_file): FeroxChannel<FeroxResponse> = mpsc::unbounded_channel();
let file_clone = tx_file.clone();
let stats_clone = tx_stats.clone();
let term_reporter = tokio::spawn(async move {
spawn_terminal_reporter(rx_rpt, file_clone, stats_clone, save_output).await
});
let file_reporter = if save_output {
// -o used, need to spawn the thread for writing to disk
let file_clone = output_file.to_string();
Some(tokio::spawn(async move {
spawn_file_reporter(rx_file, tx_stats, &file_clone).await
}))
} else {
None
};
log::trace!(
"exit: initialize -> ({:?}, {:?}, {:?}, {:?})",
tx_rpt,
tx_file,
term_reporter,
file_reporter
);
(tx_rpt, tx_file, term_reporter, file_reporter)
}
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives responses and prints them if they meet the given
/// reporting criteria
async fn spawn_terminal_reporter(
mut resp_chan: UnboundedReceiver<FeroxResponse>,
file_chan: UnboundedSender<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
save_output: bool,
) {
log::trace!(
"enter: spawn_terminal_reporter({:?}, {:?}, {:?}, {})",
resp_chan,
file_chan,
tx_stats,
save_output
);
while let Some(mut resp) = resp_chan.recv().await {
log::trace!("received {} on reporting channel", resp.url());
let contains_sentry = CONFIGURATION.status_codes.contains(&resp.status().as_u16());
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);
update_stat!(tx_stats, UpdateUsizeField(ResourcesDiscovered, 1));
if save_output {
// -o used, need to send the report to be written out to disk
match file_chan.send(resp.clone()) {
Ok(_) => {
log::debug!("Sent {} to file handler", resp.url());
}
Err(e) => {
log::error!("Could not send {} to file handler: {}", resp.url(), e);
}
}
}
}
log::trace!("report complete: {}", resp.url());
if CONFIGURATION.replay_client.is_some() && should_process_response {
// replay proxy specified/client created and this response's status code is one that
// should be replayed
match make_request(
CONFIGURATION.replay_client.as_ref().unwrap(),
&resp.url(),
tx_stats.clone(),
)
.await
{
Ok(_) => {}
Err(e) => {
log::error!("{}", e);
}
}
}
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.text = String::new();
RESPONSES.insert(resp);
}
}
log::trace!("exit: spawn_terminal_reporter");
}
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives responses and writes them to the given output file if they meet
/// the given reporting criteria
async fn spawn_file_reporter(
mut report_channel: UnboundedReceiver<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
output_file: &str,
) {
let buffered_file = match get_cached_file_handle(&CONFIGURATION.output) {
Some(file) => file,
None => {
log::trace!("exit: spawn_file_reporter");
return;
}
};
log::trace!(
"enter: spawn_file_reporter({:?}, {})",
report_channel,
output_file
);
log::info!("Writing scan results to {}", output_file);
while let Some(response) = report_channel.recv().await {
safe_file_write(&response, buffered_file.clone(), CONFIGURATION.json);
}
update_stat!(tx_stats, StatCommand::Save);
log::trace!("exit: spawn_file_reporter");
}
/// Given a string and a reference to a locked buffered file, write the contents and flush
/// the buffer to disk.
pub fn safe_file_write<T>(
value: &T,
locked_file: Arc<RwLock<io::BufWriter<fs::File>>>,
convert_to_json: bool,
) where
T: FeroxSerialize,
{
// note to future self: adding logging of anything other than error to this function
// is a bad idea. we call this function while processing records generated by the logger.
// If we then call log::... while already processing some logging output, it results in
// the second log entry being injected into the first.
let contents = if convert_to_json {
value.as_json()
} else {
value.as_str()
};
let contents = strip_ansi_codes(&contents);
if let Ok(mut handle) = locked_file.write() {
// write lock acquired
match handle.write(contents.as_bytes()) {
Ok(_) => {}
Err(e) => {
log::error!("could not write report to disk: {}", e);
}
}
match handle.flush() {
// this function is used within async functions/loops, so i'm flushing so that in
// the event of a ctrl+c or w/e results seen so far are saved instead of left lying
// around in the buffer
Ok(_) => {}
Err(e) => {
log::error!("error writing to file: {}", e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
/// asserts that an empty string for a filename returns None
fn reporter_get_cached_file_handle_without_filename_returns_none() {
let _used = get_cached_file_handle(&"").unwrap();
}
}

783
src/response.rs Normal file
View File

@@ -0,0 +1,783 @@
use std::{
collections::HashMap,
convert::{TryFrom, TryInto},
fmt,
str::FromStr,
sync::Arc,
};
use anyhow::{Context, Result};
use console::style;
use reqwest::{
header::{HeaderMap, HeaderName, HeaderValue},
Method, Response, StatusCode, Url,
};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use crate::{
config::OutputLevel,
event_handlers::{Command, Handles},
traits::FeroxSerialize,
url::FeroxUrl,
utils::{self, fmt_err, status_colorizer},
CommandSender,
};
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
#[derive(Debug, Clone)]
pub struct FeroxResponse {
/// The final `Url` of this `FeroxResponse`
url: Url,
/// The original url from which the final `Url` was derived
original_url: String,
/// The `StatusCode` of this `FeroxResponse`
status: StatusCode,
/// The HTTP Request `Method` of this `FeroxResponse`
method: Method,
/// The full response text
text: String,
/// The content-length of this response, if known
content_length: u64,
/// The number of lines contained in the body of this response, if known
line_count: usize,
/// The number of words contained in the body of this response, if known
word_count: usize,
/// The `Headers` of this `FeroxResponse`
headers: HeaderMap,
/// Wildcard response status
wildcard: bool,
/// whether the user passed --quiet|--silent on the command line
pub(crate) output_level: OutputLevel,
/// Url's file extension, if one exists
pub(crate) extension: Option<String>,
}
/// implement Default trait for FeroxResponse
impl Default for FeroxResponse {
/// return a default reqwest::Url and then normal defaults after that
fn default() -> Self {
Self {
url: Url::parse("http://localhost").unwrap(),
original_url: "".to_string(),
status: Default::default(),
method: Method::default(),
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
extension: None,
}
}
}
/// Implement Display for FeroxResponse
impl fmt::Display for FeroxResponse {
/// formatter for Display
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"FeroxResponse {{ url: {}, method: {}, status: {}, content-length: {} }}",
self.url(),
self.method(),
self.status(),
self.content_length()
)
}
}
/// `FeroxResponse` implementation
impl FeroxResponse {
/// Get the `StatusCode` of this `FeroxResponse`
pub fn status(&self) -> &StatusCode {
&self.status
}
/// Get the `Method` of this `FeroxResponse`
pub fn method(&self) -> &Method {
&self.method
}
/// Get the `wildcard` of this `FeroxResponse`
pub fn wildcard(&self) -> bool {
self.wildcard
}
/// Get the final `Url` of this `FeroxResponse`.
pub fn url(&self) -> &Url {
&self.url
}
/// Get the full response text
pub fn text(&self) -> &str {
&self.text
}
/// Get the `Headers` of this `FeroxResponse`
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
/// Get the content-length of this response, if known
pub fn content_length(&self) -> u64 {
self.content_length
}
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
pub fn set_url(&mut self, url: &str) {
match Url::parse(url) {
Ok(url) => {
self.url = url;
}
Err(e) => {
log::warn!("Could not parse {} into a Url: {}", url, e);
}
};
}
/// set `wildcard` attribute
pub fn set_wildcard(&mut self, is_wildcard: bool) {
self.wildcard = is_wildcard;
}
/// set `text` attribute; update words/lines/content_length
#[cfg(test)]
pub fn set_text(&mut self, text: &str) {
self.text = String::from(text);
self.content_length = self.text.len() as u64;
self.line_count = self.text.lines().count();
self.word_count = self
.text
.lines()
.map(|s| s.split_whitespace().count())
.sum();
}
/// free the `text` data, reducing memory usage
pub fn drop_text(&mut self) {
self.text = String::new();
}
/// Make a reasonable guess at whether the response is a file or not
///
/// Examines the last part of a path to determine if it has an obvious extension
/// i.e. http://localhost/some/path/stuff.js where stuff.js indicates a file
///
/// Additionally, inspects query parameters, as they're also often indicative of a file
pub fn is_file(&self) -> bool {
let has_extension = match self.url.path_segments() {
Some(path) => {
if let Some(last) = path.last() {
last.contains('.') // last segment has some sort of extension, probably
} else {
false
}
}
None => false,
};
self.url.query_pairs().count() > 0 || has_extension
}
/// Returns line count of the response text.
pub fn line_count(&self) -> usize {
self.line_count
}
/// Returns word count of the response text.
pub fn word_count(&self) -> usize {
self.word_count
}
/// Create a new `FeroxResponse` from the given `Response`
pub async fn from(
response: Response,
original_url: &str,
method: &str,
output_level: OutputLevel,
) -> Self {
let url = response.url().clone();
let status = response.status();
let headers = response.headers().clone();
let content_length = response.content_length().unwrap_or(0);
// .text() consumes the response, must be called last
let text = response
.text()
.await
.with_context(|| "Could not parse body from response")
.unwrap_or_default();
let line_count = text.lines().count();
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
FeroxResponse {
url,
original_url: original_url.to_string(),
status,
method: Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET),
content_length,
text,
headers,
line_count,
word_count,
output_level,
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())
{
// 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
///
/// Essentially looks at the Url path and determines how many directories are present in the
/// given Url
pub(crate) fn reached_max_depth(
&self,
base_depth: usize,
max_depth: usize,
handles: Arc<Handles>,
) -> bool {
log::trace!(
"enter: reached_max_depth({}, {}, {:?})",
base_depth,
max_depth,
handles
);
if max_depth == 0 {
// early return, as 0 means recurse forever; no additional processing needed
log::trace!("exit: reached_max_depth -> false");
return false;
}
let url = FeroxUrl::from_url(&self.url, handles);
let depth = url.depth().unwrap_or_default(); // 0 on error
if depth - base_depth >= max_depth {
return true;
}
log::trace!("exit: reached_max_depth -> false");
false
}
/// Helper function to determine suitability for recursion
///
/// handles 2xx and 3xx responses by either checking if the url ends with a / (2xx)
/// or if the Location header is present and matches the base url + / (3xx)
pub fn is_directory(&self) -> bool {
log::trace!("enter: is_directory({})", self);
if self.status().is_redirection() {
// status code is 3xx
match self.headers().get("Location") {
// and has a Location header
Some(loc) => {
// get absolute redirect Url based on the already known base url
log::debug!("Location header: {:?}", loc);
if let Ok(loc_str) = loc.to_str() {
if let Ok(abs_url) = self.url().join(loc_str) {
if format!("{}/", self.url()) == abs_url.as_str() {
// if current response's Url + / == the absolute redirection
// location, we've found a directory suitable for recursion
log::debug!(
"found directory suitable for recursion: {}",
self.url()
);
log::trace!("exit: is_directory -> true");
return true;
}
}
}
}
None => {
log::debug!("expected Location header, but none was found: {}", self);
log::trace!("exit: is_directory -> false");
return false;
}
}
} else if self.status().is_success() || matches!(self.status(), &StatusCode::FORBIDDEN) {
// status code is 2xx or 403, need to check if it ends in /
if self.url().as_str().ends_with('/') {
log::debug!("{} is directory suitable for recursion", self.url());
log::trace!("exit: is_directory -> true");
return true;
}
}
log::trace!("exit: is_directory -> false");
false
}
/// Simple helper to send a `FeroxResponse` over the tx side of an `mpsc::unbounded_channel`
pub fn send_report(self, report_sender: CommandSender) -> Result<()> {
log::trace!("enter: send_report({:?}", report_sender);
report_sender.send(Command::Report(Box::new(self)))?;
log::trace!("exit: send_report");
Ok(())
}
}
/// Implement FeroxSerialize for FeroxResponse
impl FeroxSerialize for FeroxResponse {
/// Simple wrapper around create_report_string
fn as_str(&self) -> String {
let lines = self.line_count().to_string();
let words = self.word_count().to_string();
let chars = self.content_length().to_string();
let status = self.status().as_str();
let method = self.method().as_str();
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");
// 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) {
// --silent was not used and response is a wildcard, special messages abound when
// this is the case...
// create the base message
let mut message = format!(
"{} {:>8} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
wild_status,
method,
lines,
words,
chars,
status_colorizer(status),
self.url(),
FeroxUrl::path_length_of_url(&self.url)
);
if self.status().is_redirection() {
// initial wildcard messages are wordy enough, put the redirect by itself
url_with_redirect = format!(
"{} {:>9} {:>9} {:>9} {}\n",
wild_status, "-", "-", "-", url_with_redirect
);
// base message + redirection message (either empty string or redir msg)
message.push_str(&url_with_redirect);
}
message
} else {
// not a wildcard, just create a normal entry
utils::create_report_string(
self.status.as_str(),
method,
&lines,
&words,
&chars,
&url_with_redirect,
self.output_level,
)
}
}
/// Create an NDJSON representation of the FeroxResponse
///
/// (expanded for clarity)
/// ex:
/// {
/// "type":"response",
/// "url":"https://localhost.com/images",
/// "path":"/images",
/// "status":301,
/// "content_length":179,
/// "line_count":10,
/// "word_count":16,
/// "headers":{
/// "x-content-type-options":"nosniff",
/// "strict-transport-security":"max-age=31536000; includeSubDomains",
/// "x-frame-options":"SAMEORIGIN",
/// "connection":"keep-alive",
/// "server":"nginx/1.16.1",
/// "content-type":"text/html; charset=UTF-8",
/// "referrer-policy":"origin-when-cross-origin",
/// "content-security-policy":"default-src 'none'",
/// "access-control-allow-headers":"X-Requested-With",
/// "x-xss-protection":"1; mode=block",
/// "content-length":"179",
/// "date":"Mon, 23 Nov 2020 15:33:24 GMT",
/// "location":"/images/",
/// "access-control-allow-origin":"https://localhost.com"
/// }
/// }\n
fn as_json(&self) -> anyhow::Result<String> {
let mut json = serde_json::to_string(&self)
.with_context(|| fmt_err(&format!("Could not convert {} to JSON", self.url())))?;
json.push('\n');
Ok(json)
}
}
/// Serialize implementation for FeroxResponse
impl Serialize for FeroxResponse {
/// Function that handles serialization of a FeroxResponse to NDJSON
fn serialize<S>(&self, serializer: S) -> anyhow::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut headers = HashMap::new();
let mut state = serializer.serialize_struct("FeroxResponse", 8)?;
// need to convert the HeaderMap to a HashMap in order to pass it to the serializer
for (key, value) in &self.headers {
let k = key.as_str().to_owned();
let v = String::from_utf8_lossy(value.as_bytes());
headers.insert(k, v);
}
state.serialize_field("type", "response")?;
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("wildcard", &self.wildcard)?;
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("line_count", &self.line_count)?;
state.serialize_field("word_count", &self.word_count)?;
state.serialize_field("headers", &headers)?;
state.serialize_field(
"extension",
self.extension.as_ref().unwrap_or(&String::new()),
)?;
state.end()
}
}
/// Deserialize implementation for FeroxResponse
impl<'de> Deserialize<'de> for FeroxResponse {
/// Deserialize a FeroxResponse from a serde_json::Value
fn deserialize<D>(deserializer: D) -> anyhow::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut response = Self {
url: Url::parse("http://localhost").unwrap(),
original_url: String::new(),
status: StatusCode::OK,
method: Method::GET,
text: String::new(),
content_length: 0,
headers: HeaderMap::new(),
wildcard: false,
output_level: Default::default(),
line_count: 0,
word_count: 0,
extension: None,
};
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"url" => {
if let Some(url) = value.as_str() {
if let Ok(parsed) = Url::parse(url) {
response.url = parsed;
}
}
}
"original_url" => {
if let Some(og_url) = value.as_str() {
response.original_url = String::from(og_url);
}
}
"status" => {
if let Some(num) = value.as_u64() {
if let Ok(smaller) = u16::try_from(num) {
if let Ok(status) = StatusCode::from_u16(smaller) {
response.status = status;
}
}
}
}
"method" => {
if let Some(method) = value.as_str() {
response.method = Method::from_bytes(method.as_bytes()).unwrap_or_default();
}
}
"content_length" => {
if let Some(num) = value.as_u64() {
response.content_length = num;
}
}
"line_count" => {
if let Some(num) = value.as_u64() {
response.line_count = num.try_into().unwrap_or_default();
}
}
"word_count" => {
if let Some(num) = value.as_u64() {
response.word_count = num.try_into().unwrap_or_default();
}
}
"headers" => {
let mut headers = HeaderMap::<HeaderValue>::default();
if let Some(map_headers) = value.as_object() {
for (h_key, h_value) in map_headers {
let h_value_str = h_value.as_str().unwrap_or("");
let h_name = HeaderName::from_str(h_key)
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
let h_value_parsed = HeaderValue::from_str(h_value_str)
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
headers.insert(h_name, h_value_parsed);
}
}
response.headers = headers;
}
"wildcard" => {
if let Some(result) = value.as_bool() {
response.wildcard = result;
}
}
"extension" => {
if let Some(result) = value.as_str() {
response.extension = Some(result.to_string());
}
}
_ => {}
}
}
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Configuration;
use std::default::Default;
#[test]
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
fn reached_max_depth_returns_early_on_zero() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 0, handles);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth equal to max depth, expect true
fn reached_max_depth_current_depth_equals_max() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
assert!(result);
}
#[test]
/// call reached_max_depth with url dpeth less than max depth, expect false
fn reached_max_depth_current_depth_less_than_max() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
assert!(!result);
}
#[test]
/// call reached_max_depth with url of 2, base depth of 2, and max depth of 2, expect false
fn reached_max_depth_base_depth_equals_max_depth() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(2, 2, handles);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth greater than max depth, expect true
fn reached_max_depth_current_greater_than_max() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost/one/two/three").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
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);
}
}

File diff suppressed because it is too large Load Diff

307
src/scan_manager/menu.rs Normal file
View File

@@ -0,0 +1,307 @@
use crate::filters::filter_lookup;
use crate::progress::PROGRESS_BAR;
use crate::traits::FeroxFilter;
use console::{measure_text_width, pad_str, style, Alignment, Term};
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
#[derive(Debug)]
pub(super) struct Menu {
/// header: name surrounded by separators
header: String,
/// footer: instructions surrounded by separators
footer: String,
/// unicode line border, matched to longest displayed line
border: String,
/// target for output
pub(super) term: Term,
}
/// Implementation of Menu
impl Menu {
/// Creates new Menu
pub(super) fn new() -> Self {
let separator = "".to_string();
let name = format!(
"{} {} {}",
"💀",
style("Scan Management Menu").bright().yellow(),
"💀"
);
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 padded_name = pad_str(&name, longest, Alignment::Center, None);
let header = format!("{}\n{}\n{}", border, padded_name, border);
let footer = format!("{}\n{}", commands, border);
Self {
header,
footer,
border,
term: Term::stderr(),
}
}
/// print menu header
pub(super) fn print_header(&self) {
self.println(&self.header);
}
/// print menu unicode border line
pub(super) fn print_border(&self) {
self.println(&self.border);
}
/// print menu footer
pub(super) fn print_footer(&self) {
self.println(&self.footer);
}
/// set PROGRESS_BAR bar target to hidden
pub(super) fn hide_progress_bars(&self) {
PROGRESS_BAR.set_draw_target(ProgressDrawTarget::hidden());
}
/// set PROGRESS_BAR bar target to hidden
pub(super) fn show_progress_bars(&self) {
PROGRESS_BAR.set_draw_target(ProgressDrawTarget::stdout());
}
/// Wrapper around console's Term::clear_screen and flush
pub(super) fn clear_screen(&self) {
self.term.clear_screen().unwrap_or_default();
self.term.flush().unwrap_or_default();
}
/// Wrapper around console's Term::write_line
pub(super) fn println(&self, msg: &str) {
self.term.write_line(msg).unwrap_or_default();
}
/// Helper for parsing a usize from a str
fn str_to_usize(&self, value: &str) -> usize {
if value.is_empty() {
return 0;
}
value
.trim()
.to_string()
.parse::<usize>()
.unwrap_or_else(|e| {
self.println(&format!("Found non-numeric input: {}: {:?}", e, value));
0
})
}
/// split a comma delimited string into vec of usizes
pub(super) fn split_to_nums(&self, line: &str) -> Vec<usize> {
let mut nums = Vec::new();
let values = line.split(',');
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
pub(super) fn confirm_cancellation(&self, url: &str) -> char {
self.println(&format!(
"You sure you wanna cancel this scan: {}? [Y/n]",
url
));
self.term.read_char().unwrap_or('n')
}
}
/// Default implementation for Menu
impl Default for Menu {
/// return Menu::new as default
fn default() -> Menu {
Menu::new()
}
}

18
src/scan_manager/mod.rs Normal file
View File

@@ -0,0 +1,18 @@
mod scan_container;
mod response_container;
mod scan;
mod menu;
mod utils;
mod order;
mod state;
#[cfg(test)]
mod tests;
pub(self) use menu::Menu;
pub use menu::{MenuCmd, MenuCmdResult};
pub use order::ScanOrder;
pub use response_container::FeroxResponses;
pub use scan::{FeroxScan, ScanStatus, ScanType};
pub use scan_container::{FeroxScans, PAUSE_SCAN};
pub use state::FeroxState;
pub use utils::{resume_scan, start_max_time_thread};

10
src/scan_manager/order.rs Normal file
View File

@@ -0,0 +1,10 @@
#[derive(Debug, Copy, Clone)]
/// Simple enum to designate whether a URL was passed in by the user (Initial) or found during
/// scanning (Latest)
pub enum ScanOrder {
/// Url was passed in by the user
Initial,
/// Url was found during scanning
Latest,
}

View File

@@ -0,0 +1,55 @@
use crate::response::FeroxResponse;
use serde::{ser::SerializeSeq, Serialize, Serializer};
use std::sync::{Arc, RwLock};
/// Container around a locked vector of `FeroxResponse`s, adds wrappers for insertion and search
#[derive(Debug, Default)]
pub struct FeroxResponses {
/// Internal structure: locked hashset of `FeroxScan`s
pub responses: Arc<RwLock<Vec<FeroxResponse>>>,
}
/// Serialize implementation for FeroxResponses
impl Serialize for FeroxResponses {
/// Function that handles serialization of FeroxResponses
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Ok(responses) = self.responses.read() {
let mut seq = serializer.serialize_seq(Some(responses.len()))?;
for response in responses.iter() {
seq.serialize_element(response)?;
}
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()
}
}
}
/// Implementation of `FeroxResponses`
impl FeroxResponses {
/// Add a `FeroxResponse` to the internal container
pub fn insert(&self, response: FeroxResponse) {
if let Ok(mut responses) = self.responses.write() {
responses.push(response);
}
}
/// Simple check for whether or not a FeroxResponse is contained within the inner container
pub fn contains(&self, other: &FeroxResponse) -> bool {
if let Ok(responses) = self.responses.read() {
for response in responses.iter() {
if response.url() == other.url() {
return true;
}
}
}
false
}
}

508
src/scan_manager/scan.rs Normal file
View File

@@ -0,0 +1,508 @@
use super::*;
use crate::{
config::OutputLevel,
progress::{add_bar, BarType},
scanner::PolicyTrigger,
};
use anyhow::Result;
use console::style;
use indicatif::ProgressBar;
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use std::{
collections::HashMap,
fmt,
sync::{Arc, Mutex},
time::Instant,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::{sync, task::JoinHandle};
use uuid::Uuid;
/// Struct to hold scan-related state
///
/// The purpose of this container is to open up the pathway to aborting currently running tasks and
/// serialization of all scan state into a state file in order to resume scans that were cut short
#[derive(Debug)]
pub struct FeroxScan {
/// UUID that uniquely ID's the scan
pub(super) id: String,
/// The URL that to be scanned
pub(super) url: String,
/// The type of scan
pub scan_type: ScanType,
/// The order in which the scan was received
pub(crate) scan_order: ScanOrder,
/// Number of requests to populate the progress bar with
pub(super) num_requests: u64,
/// Status of this scan
pub status: Mutex<ScanStatus>,
/// The spawned tokio task performing this scan (uses tokio::sync::Mutex)
pub(super) task: sync::Mutex<Option<JoinHandle<()>>>,
/// The progress bar associated with this scan
pub(super) progress_bar: Mutex<Option<ProgressBar>>,
/// whether or not the user passed --silent|--quiet on the command line
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
impl Default for FeroxScan {
/// Create a default FeroxScan, populates ID with a new UUID
fn default() -> Self {
let new_id = Uuid::new_v4().to_simple().to_string();
FeroxScan {
id: new_id,
task: sync::Mutex::new(None), // tokio mutex
status: Mutex::new(ScanStatus::default()),
num_requests: 0,
scan_order: ScanOrder::Latest,
url: String::new(),
progress_bar: Mutex::new(None),
scan_type: ScanType::File,
output_level: Default::default(),
errors: Default::default(),
status_429s: Default::default(),
status_403s: Default::default(),
start_time: Instant::now(),
}
}
}
/// Implementation of FeroxScan
impl FeroxScan {
/// Stop a currently running scan
pub async fn abort(&self) -> Result<()> {
log::trace!("enter: abort");
match self.task.try_lock() {
Ok(mut guard) => {
if let Some(task) = std::mem::replace(&mut *guard, None) {
log::trace!("aborting {:?}", self);
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(())
}
/// getter for url
pub fn url(&self) -> &str {
&self.url
}
/// small wrapper to set the JoinHandle
pub async fn set_task(&self, task: JoinHandle<()>) -> Result<()> {
let mut guard = self.task.lock().await;
let _ = std::mem::replace(&mut *guard, Some(task));
Ok(())
}
/// small wrapper to set ScanStatus
pub fn set_status(&self, status: ScanStatus) -> Result<()> {
if let Ok(mut guard) = self.status.lock() {
let _ = std::mem::replace(&mut *guard, status);
}
Ok(())
}
/// Simple helper to call .finish on the scan's progress bar
pub(super) fn stop_progress_bar(&self) {
if let Ok(guard) = self.progress_bar.lock() {
if guard.is_some() {
(*guard).as_ref().unwrap().finish_at_current_pos()
}
}
}
/// Simple helper get a progress bar
pub fn progress_bar(&self) -> ProgressBar {
match self.progress_bar.lock() {
Ok(mut guard) => {
if guard.is_some() {
(*guard).as_ref().unwrap().clone()
} else {
let bar_type = match self.output_level {
OutputLevel::Default => BarType::Default,
OutputLevel::Quiet => BarType::Quiet,
OutputLevel::Silent => BarType::Hidden,
};
let pb = add_bar(&self.url, self.num_requests, bar_type);
pb.reset_elapsed();
let _ = std::mem::replace(&mut *guard, Some(pb.clone()));
pb
}
}
Err(_) => {
log::warn!("Could not unlock progress bar on {:?}", self);
let bar_type = match self.output_level {
OutputLevel::Default => BarType::Default,
OutputLevel::Quiet => BarType::Quiet,
OutputLevel::Silent => BarType::Hidden,
};
let pb = add_bar(&self.url, self.num_requests, bar_type);
pb.reset_elapsed();
pb
}
}
}
/// Given a URL and ProgressBar, create a new FeroxScan, wrap it in an Arc and return it
pub fn new(
url: &str,
scan_type: ScanType,
scan_order: ScanOrder,
num_requests: u64,
output_level: OutputLevel,
pb: Option<ProgressBar>,
) -> Arc<Self> {
Arc::new(Self {
url: url.to_string(),
scan_type,
scan_order,
num_requests,
output_level,
progress_bar: Mutex::new(pb),
..Default::default()
})
}
/// Mark the scan as complete and stop the scan's progress bar
pub fn finish(&self) -> Result<()> {
self.set_status(ScanStatus::Complete)?;
self.stop_progress_bar();
Ok(())
}
/// small wrapper to inspect ScanType and ScanStatus to see if a Directory scan is running or
/// in the queue to be run
pub fn is_active(&self) -> bool {
if let Ok(guard) = self.status.lock() {
return matches!(
(self.scan_type, *guard),
(ScanType::Directory, ScanStatus::Running)
| (ScanType::Directory, ScanStatus::NotStarted)
);
}
false
}
/// small wrapper to inspect ScanStatus and see if it's Complete
pub fn is_complete(&self) -> bool {
if let Ok(guard) = self.status.lock() {
return matches!(*guard, ScanStatus::Complete);
}
false
}
/// await a task's completion, similar to a thread's join; perform necessary bookkeeping
pub async fn join(&self) {
log::trace!("enter join({:?})", self);
let mut guard = self.task.lock().await;
if guard.is_some() {
if let Some(task) = std::mem::replace(&mut *guard, None) {
task.await.unwrap();
self.set_status(ScanStatus::Complete)
.unwrap_or_else(|e| log::warn!("Could not mark scan complete: {}", e))
}
}
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(),
}
}
/// 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
impl fmt::Display for FeroxScan {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let status = if let Ok(guard) = self.status.lock() {
match *guard {
ScanStatus::NotStarted => style("not started").bright().blue(),
ScanStatus::Complete => style("complete").green(),
ScanStatus::Cancelled => style("cancelled").red(),
ScanStatus::Running => style("running").bright().yellow(),
}
} else {
style("unknown").red()
};
write!(f, "{:12} {}", status, self.url)
}
}
/// PartialEq implementation; uses FeroxScan.id for comparison
impl PartialEq for FeroxScan {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
/// Serialize implementation for FeroxScan
impl Serialize for FeroxScan {
/// Function that handles serialization of a FeroxScan
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("FeroxScan", 4)?;
state.serialize_field("id", &self.id)?;
state.serialize_field("url", &self.url)?;
state.serialize_field("scan_type", &self.scan_type)?;
state.serialize_field("status", &self.status)?;
state.serialize_field("num_requests", &self.num_requests)?;
state.end()
}
}
/// Deserialize implementation for FeroxScan
impl<'de> Deserialize<'de> for FeroxScan {
/// Deserialize a FeroxScan from a serde_json::Value
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut scan = Self::default();
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"id" => {
if let Some(id) = value.as_str() {
scan.id = id.to_string();
}
}
"scan_type" => {
if let Some(scan_type) = value.as_str() {
scan.scan_type = match scan_type {
"File" => ScanType::File,
"Directory" => ScanType::Directory,
_ => ScanType::File,
}
}
}
"status" => {
if let Some(status) = value.as_str() {
scan.status = Mutex::new(match status {
"NotStarted" => ScanStatus::NotStarted,
"Running" => ScanStatus::Running,
"Complete" => ScanStatus::Complete,
"Cancelled" => ScanStatus::Cancelled,
_ => ScanStatus::default(),
})
}
}
"url" => {
if let Some(url) = value.as_str() {
scan.url = url.to_string();
}
}
"num_requests" => {
if let Some(num_requests) = value.as_u64() {
scan.num_requests = num_requests;
}
}
_ => {}
}
}
Ok(scan)
}
}
/// Simple enum used to flag a `FeroxScan` as likely a directory or file
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
pub enum ScanType {
/// Just a file being requested
File,
/// A an entire directory that might be scanned
Directory,
}
/// Default implementation for ScanType
impl Default for ScanType {
/// Return ScanType::File as default
fn default() -> Self {
Self::File
}
}
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
/// Simple enum to represent a scan's current status ([in]complete, cancelled)
pub enum ScanStatus {
/// Scan hasn't started yet
NotStarted,
/// Scan finished normally
Complete,
/// Scan was cancelled by the user
Cancelled,
/// Scan has started, but hasn't finished, nor been cancelled
Running,
}
/// Default implementation for ScanStatus
impl Default for ScanStatus {
/// Default variant for ScanStatus is NotStarted
fn default() -> Self {
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(),
scan_type: ScanType::Directory,
scan_order: ScanOrder::Initial,
num_requests: 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

@@ -0,0 +1,693 @@
use super::scan::ScanType;
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::{
config::OutputLevel,
progress::PROGRESS_PRINTER,
progress::{add_bar, BarType},
scan_manager::{MenuCmd, MenuCmdResult},
scanner::RESPONSES,
traits::FeroxSerialize,
Command, SLEEP_DURATION,
};
use anyhow::Result;
use console::style;
use reqwest::StatusCode;
use serde::{ser::SerializeSeq, Serialize, Serializer};
use std::{
collections::HashSet,
convert::TryInto,
fs::File,
io::BufReader,
ops::Index,
sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex, RwLock,
},
thread::sleep,
};
use tokio::time::{self, Duration};
/// Single atomic number that gets incremented once, used to track first thread to interact with
/// when pausing a scan
static INTERACTIVE_BARRIER: AtomicUsize = AtomicUsize::new(0);
/// Atomic boolean flag, used to determine whether or not a scan should pause or resume
pub static PAUSE_SCAN: AtomicBool = AtomicBool::new(false);
/// Container around a locked hashset of `FeroxScan`s, adds wrappers for insertion and searching
#[derive(Debug, Default)]
pub struct FeroxScans {
/// Internal structure: locked hashset of `FeroxScan`s
pub scans: RwLock<Vec<Arc<FeroxScan>>>,
/// menu used for providing a way for users to cancel a scan
menu: Menu,
/// number of requests expected per scan (mirrors the same on Stats); used for initializing
/// progress bars and feroxscans
bar_length: Mutex<u64>,
/// whether or not the user passed --silent|--quiet on the command line
output_level: OutputLevel,
/// vector of extensions discovered and collected during scans
pub(crate) collected_extensions: RwLock<HashSet<String>>,
}
/// Serialize implementation for FeroxScans
///
/// purposefully skips menu attribute
impl Serialize for FeroxScans {
/// Function that handles serialization of FeroxScans
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self.scans.read() {
Ok(scans) => {
let mut seq = serializer.serialize_seq(Some(scans.len() + 1))?;
for scan in scans.iter() {
seq.serialize_element(&*scan).unwrap_or_default();
}
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()
}
}
}
}
/// Implementation of `FeroxScans`
impl FeroxScans {
/// given an OutputLevel, create a new FeroxScans object
pub fn new(output_level: OutputLevel) -> Self {
Self {
output_level,
..Default::default()
}
}
/// Add a `FeroxScan` to the internal container
///
/// If the internal container did NOT contain the scan, true is returned; else false
pub fn insert(&self, scan: Arc<FeroxScan>) -> bool {
// If the container did contain the scan, set sentry to false
// If the container did not contain the scan, set sentry to true
let sentry = !self.contains(&scan.url);
if sentry {
// can't update the internal container while the scan itself is locked, so first
// lock the scan and check the container for the scan's presence, then add if
// not found
match self.scans.write() {
Ok(mut scans) => {
scans.push(scan);
}
Err(e) => {
log::warn!("FeroxScans' container's mutex is poisoned: {}", e);
return false;
}
}
}
sentry
}
/// load serialized FeroxScan(s) and any previously collected extensions into this FeroxScans
pub fn add_serialized_scans(&self, filename: &str, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: add_serialized_scans({})", filename);
let file = File::open(filename)?;
let reader = BufReader::new(file);
let state: serde_json::Value = serde_json::from_reader(reader)?;
if let Some(scans) = state.get("scans") {
if let Some(arr_scans) = scans.as_array() {
for scan in arr_scans {
let mut deser_scan: FeroxScan =
serde_json::from_value(scan.clone()).unwrap_or_default();
// 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
// and resumes the scan but adds -q, FeroxScan will not have the proper value
// without the line below
deser_scan.output_level = self.output_level;
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");
Ok(())
}
/// Simple check for whether or not a FeroxScan is contained within the inner container based
/// on the given URL
pub fn contains(&self, url: &str) -> bool {
if let Ok(scans) = self.scans.read() {
for scan in scans.iter() {
if scan.url == url {
return true;
}
}
}
false
}
/// Find and return a `FeroxScan` based on the given URL
pub fn get_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
if let Ok(guard) = self.scans.read() {
for scan in guard.iter() {
if scan.url == url {
return Some(scan.clone());
}
}
}
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
///
/// Example:
/// 0: complete https://10.129.45.20
/// 9: complete https://10.129.45.20/images
/// 10: complete https://10.129.45.20/assets
pub async fn display_scans(&self) {
let scans = {
// written this way in order to grab the vector and drop the lock immediately
// otherwise the spawned task that this is a part of is no longer Send due to
// the scan.task.lock().await below while the lock is held (RwLock is not Send)
self.scans
.read()
.expect("Could not acquire lock in display_scans")
.clone()
};
let mut printed = 0;
for (i, scan) in scans.iter().enumerate() {
if matches!(scan.scan_order, ScanOrder::Initial) || scan.task.try_lock().is_err() {
// original target passed in via either -u or --stdin
continue;
}
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
// the only ones that make sense to be stopped
let scan_msg = format!("{:3}: {}", i, scan);
self.menu.println(&scan_msg);
printed += 1;
}
}
if printed > 0 {
self.menu.print_border();
}
}
/// Given a list of indexes, cancel their associated FeroxScans
async fn cancel_scans(&self, indexes: Vec<usize>, force: bool) -> usize {
let menu_pause_duration = Duration::from_millis(SLEEP_DURATION);
let mut num_cancelled = 0_usize;
for num in indexes {
let selected = match self.scans.read() {
Ok(u_scans) => {
// check if number provided is out of range
if num >= u_scans.len() {
// usize can't be negative, just need to handle exceeding bounds
self.menu
.println(&format!("The number {} is not a valid choice.", num));
sleep(menu_pause_duration);
continue;
}
u_scans.index(num).clone()
}
Err(..) => continue,
};
let input = if force {
'y'
} else {
self.menu.confirm_cancellation(&selected.url)
};
if input == 'y' || input == '\n' {
self.menu.println(&format!("Stopping {}...", selected.url));
selected
.abort()
.await
.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 {
self.menu.println("Ok, doing nothing...");
}
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
async fn interactive_menu(&self, handles: Arc<Handles>) -> Option<MenuCmdResult> {
self.menu.hide_progress_bars();
self.menu.clear_screen();
self.menu.print_header();
self.display_scans().await;
self.display_filters(handles.clone());
self.menu.print_footer();
let menu_cmd = if let Ok(line) = self.menu.term.read_line() {
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.show_progress_bars();
result
}
/// prints all known responses that the scanner has already seen
pub fn print_known_responses(&self) {
if let Ok(mut responses) = RESPONSES.responses.write() {
for response in responses.iter_mut() {
if self.output_level != response.output_level {
// set the output_level prior to printing the response to ensure that the
// response's setting aligns with the overall configuration (since we're
// calling this from a resumed state)
response.output_level = self.output_level;
}
PROGRESS_PRINTER.println(response.as_str());
}
}
}
/// if a resumed scan is already complete, display a completed progress bar to the user
pub fn print_completed_bars(&self, bar_length: usize) -> Result<()> {
let bar_type = match self.output_level {
OutputLevel::Default => BarType::Message,
OutputLevel::Quiet => BarType::Quiet,
OutputLevel::Silent => return Ok(()), // fast exit when --silent was used
};
if let Ok(scans) = self.scans.read() {
for scan in scans.iter() {
if scan.is_complete() {
// these scans are complete, and just need to be shown to the user
let pb = add_bar(
&scan.url,
bar_length.try_into().unwrap_or_default(),
bar_type,
);
pb.finish();
}
}
}
Ok(())
}
/// Forced the calling thread into a busy loop
///
/// Every `SLEEP_DURATION` milliseconds, the function examines the result stored in `PAUSE_SCAN`
///
/// When the value stored in `PAUSE_SCAN` becomes `false`, the function returns, exiting the busy
/// loop
pub async fn pause(
&self,
get_user_input: bool,
handles: Arc<Handles>,
) -> Option<MenuCmdResult> {
// function uses tokio::time, not std
// 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
let mut interval = time::interval(time::Duration::from_millis(SLEEP_DURATION));
let mut command_result = None;
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 0 {
INTERACTIVE_BARRIER.fetch_add(1, Ordering::Relaxed);
if get_user_input {
command_result = self.interactive_menu(handles).await;
PAUSE_SCAN.store(false, Ordering::Relaxed);
self.print_known_responses();
}
}
loop {
// first tick happens immediately, all others wait the specified duration
interval.tick().await;
if !PAUSE_SCAN.load(Ordering::Acquire) {
// PAUSE_SCAN is false, so we can exit the busy loop
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 1 {
INTERACTIVE_BARRIER.fetch_sub(1, Ordering::Relaxed);
}
log::trace!("exit: pause_scan -> {:?}", command_result);
return command_result;
}
}
}
/// set the bar length of FeroxScans
pub fn set_bar_length(&self, bar_length: u64) {
if let Ok(mut guard) = self.bar_length.lock() {
*guard = bar_length;
}
}
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans`
///
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
///
/// Also return a reference to the new `FeroxScan`
pub(super) fn add_scan(
&self,
url: &str,
scan_type: ScanType,
scan_order: ScanOrder,
) -> (bool, Arc<FeroxScan>) {
let bar_length = if let Ok(guard) = self.bar_length.lock() {
*guard
} else {
0
};
let bar = match scan_type {
ScanType::Directory => {
let bar_type = match self.output_level {
OutputLevel::Default => BarType::Default,
OutputLevel::Quiet => BarType::Quiet,
OutputLevel::Silent => BarType::Hidden,
};
let progress_bar = add_bar(url, bar_length, bar_type);
progress_bar.reset_elapsed();
Some(progress_bar)
}
ScanType::File => None,
};
let ferox_scan = FeroxScan::new(
url,
scan_type,
scan_order,
bar_length,
self.output_level,
bar,
);
// If the set did not contain the scan, true is returned.
// If the set did contain the scan, false is returned.
let response = self.insert(ferox_scan.clone());
(response, ferox_scan)
}
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a Directory Scan
///
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
///
/// Also return a reference to the new `FeroxScan`
pub fn add_directory_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
self.add_scan(url, ScanType::Directory, scan_order)
}
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a File Scan
///
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
///
/// Also return a reference to the new `FeroxScan`
pub fn add_file_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
self.add_scan(url, ScanType::File, scan_order)
}
/// small helper to determine whether any scans are active or not
pub fn has_active_scans(&self) -> bool {
if let Ok(guard) = self.scans.read() {
for scan in guard.iter() {
if scan.is_active() {
return true;
}
}
}
false
}
/// Retrieve all active scans
pub fn get_active_scans(&self) -> Vec<Arc<FeroxScan>> {
let mut scans = vec![];
if let Ok(guard) = self.scans.read() {
for scan in guard.iter() {
if !scan.is_active() {
continue;
}
scans.push(scan.clone());
}
}
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());
}
}

69
src/scan_manager/state.rs Normal file
View File

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

777
src/scan_manager/tests.rs Normal file
View File

@@ -0,0 +1,777 @@
use super::*;
use crate::filters::{
FeroxFilters, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
WordsFilter,
};
use crate::{
config::{Configuration, OutputLevel},
event_handlers::Handles,
response::FeroxResponse,
scanner::RESPONSES,
statistics::Stats,
traits::FeroxSerialize,
SIMILARITY_THRESHOLD, SLEEP_DURATION, VERSION,
};
use indicatif::ProgressBar;
use predicates::prelude::*;
use regex::Regex;
use std::sync::{atomic::Ordering, Arc};
use std::thread::sleep;
use std::time::Instant;
use tokio::time::{self, Duration};
#[test]
/// test that ScanType's default is File
fn default_scantype_is_file() {
match ScanType::default() {
ScanType::File => {}
ScanType::Directory => panic!(),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// tests that pause_scan pauses execution and releases execution when PAUSE_SCAN is toggled
/// the spinner used during the test has had .finish_and_clear called on it, meaning that
/// a new one will be created, taking the if branch within the function
async fn scanner_pause_scan_with_finished_spinner() {
let now = time::Instant::now();
let urls = FeroxScans::default();
let handles = Arc::new(Handles::for_testing(None, None).0);
PAUSE_SCAN.store(true, Ordering::Relaxed);
let expected = time::Duration::from_secs(2);
tokio::spawn(async move {
time::sleep(expected).await;
PAUSE_SCAN.store(false, Ordering::Relaxed);
});
urls.pause(false, handles).await;
assert!(now.elapsed() > expected);
}
#[test]
/// add an unknown url to the hashset, expect true
fn add_url_to_list_of_scanned_urls_with_unknown_url() {
let urls = FeroxScans::default();
let url = "http://unknown_url";
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
assert!(result);
}
#[test]
/// add a known url to the hashset, with a trailing slash, expect false
fn add_url_to_list_of_scanned_urls_with_known_url() {
let urls = FeroxScans::default();
let pb = ProgressBar::new(1);
let url = "http://unknown_url/";
let scan = FeroxScan::new(
url,
ScanType::Directory,
ScanOrder::Latest,
pb.length(),
OutputLevel::Default,
Some(pb),
);
assert!(urls.insert(scan));
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
assert!(!result);
}
#[test]
/// stop_progress_bar should stop the progress bar
fn stop_progress_bar_stops_bar() {
let pb = ProgressBar::new(1);
let url = "http://unknown_url/";
let scan = FeroxScan::new(
url,
ScanType::Directory,
ScanOrder::Latest,
pb.length(),
OutputLevel::Default,
Some(pb),
);
assert!(!scan
.progress_bar
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_finished());
scan.stop_progress_bar();
assert!(scan
.progress_bar
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_finished());
}
#[test]
/// add a known url to the hashset, without a trailing slash, expect false
fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
let urls = FeroxScans::default();
let url = "http://unknown_url";
let scan = FeroxScan::new(
url,
ScanType::File,
ScanOrder::Latest,
0,
OutputLevel::Default,
None,
);
assert!(urls.insert(scan));
let (result, _scan) = urls.add_scan(url, ScanType::File, ScanOrder::Latest);
assert!(!result);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// just increasing coverage, no real expectations
async fn call_display_scans() {
let urls = FeroxScans::default();
let pb = ProgressBar::new(1);
let pb_two = ProgressBar::new(2);
let url = "http://unknown_url/";
let url_two = "http://unknown_url/fa";
let scan = FeroxScan::new(
url,
ScanType::Directory,
ScanOrder::Latest,
pb.length(),
OutputLevel::Default,
Some(pb),
);
let scan_two = FeroxScan::new(
url_two,
ScanType::Directory,
ScanOrder::Latest,
pb_two.length(),
OutputLevel::Default,
Some(pb_two),
);
scan_two.finish().unwrap(); // one complete, one incomplete
scan_two
.set_task(tokio::spawn(async move {
sleep(Duration::from_millis(SLEEP_DURATION));
}))
.await
.unwrap();
assert!(urls.insert(scan));
assert!(urls.insert(scan_two));
urls.display_scans().await;
}
#[test]
/// ensure that PartialEq compares FeroxScan.id fields
fn partial_eq_compares_the_id_field() {
let url = "http://unknown_url/";
let scan = FeroxScan::new(
url,
ScanType::Directory,
ScanOrder::Latest,
0,
OutputLevel::Default,
None,
);
let scan_two = FeroxScan::new(
url,
ScanType::Directory,
ScanOrder::Latest,
0,
OutputLevel::Default,
None,
);
assert!(!scan.eq(&scan_two));
let scan_two = scan.clone();
assert!(scan.eq(&scan_two));
}
#[test]
/// show that a new progress bar is created if one doesn't exist
fn ferox_scan_get_progress_bar_when_none_is_set() {
let scan = FeroxScan::default();
assert!(scan.progress_bar.lock().unwrap().is_none()); // no pb exists
let pb = scan.progress_bar();
assert!(scan.progress_bar.lock().unwrap().is_some()); // new pb created
assert!(!pb.is_finished()) // not finished
}
#[test]
/// given a JSON entry representing a FeroxScan, test that it deserializes into the proper type
/// with the right attributes
fn ferox_scan_deserialize() {
let fs_json = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Directory","status":"Complete"}"#;
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: FeroxScan = serde_json::from_str(fs_json).unwrap();
let fs_two: FeroxScan = serde_json::from_str(fs_json_two).unwrap();
let fs_three: FeroxScan = serde_json::from_str(fs_json_three).unwrap();
assert_eq!(fs.url, "https://spiritanimal.com");
match fs.scan_type {
ScanType::Directory => {}
ScanType::File => {
panic!();
}
}
match fs_two.scan_type {
ScanType::Directory => {
panic!();
}
ScanType::File => {}
}
match *fs.progress_bar.lock().unwrap() {
None => {}
Some(_) => {
panic!();
}
}
assert!(matches!(*fs.status.lock().unwrap(), ScanStatus::Complete));
assert!(matches!(
*fs_two.status.lock().unwrap(),
ScanStatus::Cancelled
));
assert!(matches!(
*fs_three.status.lock().unwrap(),
ScanStatus::NotStarted
));
assert_eq!(fs_three.num_requests, 42);
assert_eq!(fs.id, "057016a14769414aac9a7a62707598cb");
}
#[test]
/// given a FeroxScan, test that it serializes into the proper JSON entry
fn ferox_scan_serialize() {
let fs = FeroxScan::new(
"https://spiritanimal.com",
ScanType::Directory,
ScanOrder::Latest,
0,
OutputLevel::Default,
None,
);
let fs_json = format!(
r#"{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}"#,
fs.id
);
assert_eq!(fs_json, serde_json::to_string(&*fs).unwrap());
}
#[test]
/// given a FeroxScans, test that it serializes into the proper JSON entry
fn ferox_scans_serialize() {
let ferox_scan = FeroxScan::new(
"https://spiritanimal.com",
ScanType::Directory,
ScanOrder::Latest,
0,
OutputLevel::Default,
None,
);
let ferox_scans = FeroxScans::default();
let ferox_scans_json = format!(
r#"[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}]"#,
ferox_scan.id
);
ferox_scans.scans.write().unwrap().push(ferox_scan);
assert_eq!(
ferox_scans_json,
serde_json::to_string(&ferox_scans).unwrap()
);
}
#[test]
/// given a FeroxResponses, test that it serializes into the proper JSON entry
fn ferox_responses_serialize() {
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 responses = FeroxResponses::default();
responses.insert(response);
// responses has a response now
// serialized should be a list of responses
let expected = format!("[{}]", json_response);
let serialized = serde_json::to_string(&responses).unwrap();
assert_eq!(expected, serialized);
}
#[test]
/// given a FeroxResponse, test that it serializes into the proper JSON entry
fn ferox_response_serialize_and_deserialize() {
// deserialize
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();
assert_eq!(response.url().as_str(), "https://nerdcore.com/css");
assert_eq!(response.url().path(), "/css");
assert!(response.wildcard());
assert_eq!(response.status().as_u16(), 301);
assert_eq!(response.content_length(), 173);
assert_eq!(response.line_count(), 10);
assert_eq!(response.word_count(), 16);
assert_eq!(response.headers().get("server").unwrap(), "nginx/1.16.1");
// serialize, however, this can fail when headers are out of order
let new_json = serde_json::to_string(&response).unwrap();
assert_eq!(json_response, new_json);
}
#[test]
/// test FeroxSerialize implementation of FeroxState
fn feroxstates_feroxserialize_implementation() {
let ferox_scan = FeroxScan::new(
"https://spiritanimal.com",
ScanType::Directory,
ScanOrder::Latest,
0,
OutputLevel::Default,
None,
);
let ferox_scans = FeroxScans::default();
let saved_id = ferox_scan.id.clone();
ferox_scans.insert(ferox_scan);
ferox_scans
.collected_extensions
.write()
.unwrap()
.insert(String::from("php"));
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();
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: "3:YKEpn:Yfp".to_string(),
threshold: SIMILARITY_THRESHOLD,
original_url: "http://localhost:12345/".to_string(),
}))
.unwrap();
let ferox_state = FeroxState::new(
Arc::new(ferox_scans),
Arc::new(config),
&RESPONSES,
stats,
Arc::new(filters),
);
let expected_strs = predicates::str::contains("scans: FeroxScans").and(
predicate::str::contains("config: Configuration")
.and(predicate::str::contains("responses: FeroxResponses"))
.and(predicate::str::contains("nerdcore.com"))
.and(predicate::str::contains("/css"))
.and(predicate::str::contains("https://spiritanimal.com"))
.and(predicate::str::contains("php")),
);
assert!(expected_strs.eval(&ferox_state.as_str()));
let json_state = ferox_state.as_json().unwrap();
println!("echo '{}'|jq", json_state); // for debugging, if the test fails, can see what's going on
for expected in [
r#""scans""#,
&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":[200,204,301,302,307,308,401,403,405,500]"#,
r#""replay_codes":[200,204,301,302,307,308,401,403,405,500]"#,
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#""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":"3:YKEpn:Yfp","threshold":95,"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]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// call start_max_time_thread with a valid timespec, expect a panic, but only after a certain
/// number of seconds
async fn start_max_time_thread_panics_after_delay() {
let now = time::Instant::now();
let delay = time::Duration::new(3, 0);
let config = Configuration {
time_limit: String::from("3s"),
..Default::default()
};
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
start_max_time_thread(handles).await;
assert!(now.elapsed() > delay);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// call start_max_time_thread with a timespec that's too large to be parsed correctly, expect
/// immediate return and no panic, as the sigint handler is never called
async fn start_max_time_thread_returns_immediately_with_too_large_input() {
let now = time::Instant::now();
let delay = time::Duration::new(1, 0);
let config = Configuration {
time_limit: String::from("18446744073709551616m"),
..Default::default()
};
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
// pub const MAX: usize = usize::MAX; // 18_446_744_073_709_551_615usize
start_max_time_thread(handles).await; // can't fit in dest u64
assert!(now.elapsed() < delay); // assuming function call will take less than 1second
}
#[test]
/// coverage for FeroxScan's Display implementation
fn feroxscan_display() {
let scan = FeroxScan {
id: "".to_string(),
url: String::from("http://localhost"),
scan_order: ScanOrder::Latest,
scan_type: Default::default(),
num_requests: 0,
start_time: Instant::now(),
output_level: OutputLevel::Default,
status_403s: Default::default(),
status_429s: Default::default(),
status: Default::default(),
task: tokio::sync::Mutex::new(None),
progress_bar: std::sync::Mutex::new(None),
errors: Default::default(),
};
let not_started = format!("{}", scan);
assert!(predicate::str::contains("not started")
.and(predicate::str::contains("localhost"))
.eval(&not_started));
scan.set_status(ScanStatus::Complete).unwrap();
let complete = format!("{}", scan);
assert!(predicate::str::contains("complete")
.and(predicate::str::contains("localhost"))
.eval(&complete));
scan.set_status(ScanStatus::Cancelled).unwrap();
let cancelled = format!("{}", scan);
assert!(predicate::str::contains("cancelled")
.and(predicate::str::contains("localhost"))
.eval(&cancelled));
scan.set_status(ScanStatus::Running).unwrap();
let running = format!("{}", scan);
assert!(predicate::str::contains("running")
.and(predicate::str::contains("localhost"))
.eval(&running));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// call FeroxScan::abort, ensure status becomes cancelled
async fn ferox_scan_abort() {
let scan = FeroxScan {
id: "".to_string(),
url: String::from("http://localhost"),
scan_order: ScanOrder::Latest,
scan_type: Default::default(),
num_requests: 0,
start_time: Instant::now(),
output_level: OutputLevel::Default,
status_403s: Default::default(),
status_429s: Default::default(),
status: std::sync::Mutex::new(ScanStatus::Running),
task: tokio::sync::Mutex::new(Some(tokio::spawn(async move {
sleep(Duration::from_millis(SLEEP_DURATION * 2));
}))),
progress_bar: std::sync::Mutex::new(None),
errors: Default::default(),
};
scan.abort().await.unwrap();
assert!(matches!(
*scan.status.lock().unwrap(),
ScanStatus::Cancelled
));
}
#[test]
/// call a few menu functions for coverage's sake
///
/// there's not a trivial way to test these programmatically (at least i'm too lazy rn to do it)
/// and their correctness can be verified easily manually; just calling for now
fn menu_print_header_and_footer() {
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.print_header();
menu.print_footer();
menu.hide_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!("{} -f {}\n", cmd, idx)
} else {
format!("{} {}\n", cmd, idx)
};
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!("{} {}\n", cmd, test_url);
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]
/// ensure spaces are trimmed and numbers are returned from split_to_nums
fn split_to_nums_is_correct() {
let menu = Menu::new();
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, 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
);
}

92
src/scan_manager/utils.rs Normal file
View File

@@ -0,0 +1,92 @@
#[cfg(not(test))]
use crate::event_handlers::TermInputHandler;
use crate::{
config::Configuration, event_handlers::Handles, parser::TIMESPEC_REGEX, scanner::RESPONSES,
};
use std::{fs::File, io::BufReader, sync::Arc};
use tokio::time;
/// Given a string representing some number of seconds, minutes, hours, or days, convert
/// that representation to seconds and then wait for those seconds to elapse. Once that period
/// of time has elapsed, kill all currently running scans and dump a state file to disk that can
/// be used to resume any unfinished scan.
pub async fn start_max_time_thread(handles: Arc<Handles>) {
log::trace!("enter: start_max_time_thread({:?})", handles);
// as this function has already made it through the parser, which calls is_match on
// the value passed to --time-limit using TIMESPEC_REGEX; we can safely assume that
// the capture groups are populated; can expect something like 10m, 30s, 1h, etc...
let captures = TIMESPEC_REGEX.captures(&handles.config.time_limit).unwrap();
let length_match = captures.get(1).unwrap();
let measurement_match = captures.get(2).unwrap();
if let Ok(length) = length_match.as_str().parse::<u64>() {
let length_in_secs = match measurement_match.as_str().to_ascii_lowercase().as_str() {
"s" => length,
"m" => length * 60, // minutes
"h" => length * 60 * 60, // hours
"d" => length * 60 * 60 * 24, // days
_ => length,
};
log::debug!(
"max time limit as string: {} and as seconds: {}",
handles.config.time_limit,
length_in_secs
);
time::sleep(time::Duration::new(length_in_secs, 0)).await;
log::trace!("exit: start_max_time_thread");
#[cfg(test)]
panic!("{:?}", handles);
#[cfg(not(test))]
let _ = TermInputHandler::sigint_handler(handles.clone());
}
log::warn!(
"Could not parse the value provided ({}), can't enforce time limit",
handles.config.time_limit
);
}
/// Primary logic used to load a Configuration from disk and populate the appropriate data
/// structures
pub fn resume_scan(filename: &str) -> Configuration {
log::trace!("enter: resume_scan({})", filename);
let file = File::open(filename).unwrap_or_else(|e| {
log::error!("{}", e);
log::error!("Could not open state file, exiting");
std::process::exit(1);
});
let reader = BufReader::new(file);
let state: serde_json::Value = serde_json::from_reader(reader).unwrap();
let conf = state.get("config").unwrap_or_else(|| {
log::error!("Could not load configuration from state file, exiting");
std::process::exit(1);
});
let config = serde_json::from_value(conf.clone()).unwrap_or_else(|e| {
log::error!("{}", e);
log::error!("Could not deserialize configuration found in state file, exiting");
std::process::exit(1);
});
if let Some(responses) = state.get("responses") {
if let Some(arr_responses) = responses.as_array() {
for response in arr_responses {
if let Ok(deser_resp) = serde_json::from_value(response.clone()) {
RESPONSES.insert(deser_resp);
}
}
}
}
log::trace!("exit: resume_scan -> {:?}", config);
config
}

View File

@@ -1,901 +0,0 @@
use crate::statistics::Stats;
use crate::{
config::{Configuration, CONFIGURATION},
extractor::{get_links, request_feroxresponse_from_new_link},
filters::{
FeroxFilter, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
WildcardFilter, WordsFilter,
},
heuristics,
scan_manager::{FeroxResponses, FeroxScans, PAUSE_SCAN},
statistics::{
StatCommand::{self, UpdateF64Field, UpdateUsizeField},
StatField::{DirScanTimes, ExpectedPerScan, TotalScans, WildcardsFiltered},
},
utils::{format_url, get_current_depth, make_request},
FeroxChannel, FeroxResponse, SIMILARITY_THRESHOLD,
};
use futures::{
future::{BoxFuture, FutureExt},
stream, StreamExt,
};
use fuzzyhash::FuzzyHash;
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::Url;
#[cfg(not(test))]
use std::process::exit;
use std::{
collections::HashSet,
convert::TryInto,
ops::Deref,
sync::atomic::{AtomicUsize, Ordering},
sync::{Arc, RwLock},
time::Instant,
};
use tokio::{
sync::{
mpsc::{self, UnboundedReceiver, UnboundedSender},
Semaphore,
},
task::JoinHandle,
};
/// Single atomic number that gets incremented at least once, used to track first scan(s) vs. all
/// others found during recursion
///
/// -u means this will be incremented once
/// --stdin means this will be incremented by the number of targets passed via STDIN
static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
lazy_static! {
/// Set of urls that have been sent to [scan_url](fn.scan_url.html), used for deduplication
pub static ref SCANNED_URLS: FeroxScans = FeroxScans::default();
/// Vector of implementors of the FeroxFilter trait
static ref FILTERS: Arc<RwLock<Vec<Box<dyn FeroxFilter>>>> = Arc::new(RwLock::new(Vec::<Box<dyn FeroxFilter>>::new()));
/// Vector of FeroxResponse objects
pub static ref RESPONSES: FeroxResponses = FeroxResponses::default();
/// Bounded semaphore used as a barrier to limit concurrent scans
static ref SCAN_LIMITER: Semaphore = Semaphore::new(CONFIGURATION.scan_limit);
}
/// Adds the given FeroxFilter to the given list of FeroxFilter implementors
///
/// If the given list did not already contain the filter, return true; otherwise return false
fn add_filter_to_list_of_ferox_filters(
filter: Box<dyn FeroxFilter>,
ferox_filters: Arc<RwLock<Vec<Box<dyn FeroxFilter>>>>,
) -> bool {
log::trace!(
"enter: add_filter_to_list_of_ferox_filters({:?}, {:?})",
filter,
ferox_filters
);
match ferox_filters.write() {
Ok(mut filters) => {
// If the set did not contain the assigned filter, true is returned.
// If the set did contain the assigned filter, false is returned.
if filters.contains(&filter) {
log::trace!("exit: add_filter_to_list_of_ferox_filters -> false");
return false;
}
filters.push(filter);
log::trace!("exit: add_filter_to_list_of_ferox_filters -> true");
true
}
Err(e) => {
// poisoned lock
log::error!("Set of wildcard filters poisoned: {}", e);
log::trace!("exit: add_filter_to_list_of_ferox_filters -> false");
false
}
}
}
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives Urls and scans them
fn spawn_recursion_handler(
mut recursion_channel: UnboundedReceiver<String>,
wordlist: Arc<HashSet<String>>,
base_depth: usize,
stats: Arc<Stats>,
tx_term: UnboundedSender<FeroxResponse>,
tx_file: UnboundedSender<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
) -> BoxFuture<'static, Vec<JoinHandle<()>>> {
log::trace!(
"enter: spawn_recursion_handler({:?}, wordlist[{} words...], {}, {:?}, {:?}, {:?}, {:?})",
recursion_channel,
wordlist.len(),
base_depth,
stats,
tx_term,
tx_file,
tx_stats
);
let boxed_future = async move {
let mut scans = vec![];
while let Some(resp) = recursion_channel.recv().await {
let (unknown, _) = SCANNED_URLS.add_directory_scan(&resp, stats.clone());
if !unknown {
// not unknown, i.e. we've seen the url before and don't need to scan again
continue;
}
update_stat!(tx_stats, UpdateUsizeField(TotalScans, 1));
log::info!("received {} on recursion channel", resp);
let term_clone = tx_term.clone();
let file_clone = tx_file.clone();
let tx_stats_clone = tx_stats.clone();
let stats_clone = stats.clone();
let resp_clone = resp.clone();
let list_clone = wordlist.clone();
let future = tokio::spawn(async move {
scan_url(
resp_clone.to_owned().as_str(),
list_clone,
base_depth,
stats_clone,
term_clone,
file_clone,
tx_stats_clone,
)
.await
});
scans.push(future);
}
scans
}
.boxed();
log::trace!("exit: spawn_recursion_handler -> BoxFuture<'static, Vec<JoinHandle<()>>>");
boxed_future
}
/// Creates a vector of formatted Urls
///
/// At least one value will be returned (base_url + word)
///
/// If any extensions were passed to the program, each extension will add a
/// (base_url + word + ext) Url to the vector
fn create_urls(
target_url: &str,
word: &str,
extensions: &[String],
tx_stats: UnboundedSender<StatCommand>,
) -> Vec<Url> {
log::trace!(
"enter: create_urls({}, {}, {:?}, {:?})",
target_url,
word,
extensions,
tx_stats
);
let mut urls = vec![];
if let Ok(url) = format_url(
&target_url,
&word,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
tx_stats.clone(),
) {
urls.push(url); // default request, i.e. no extension
}
for ext in extensions.iter() {
if let Ok(url) = format_url(
&target_url,
&word,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
Some(ext),
tx_stats.clone(),
) {
urls.push(url); // any extensions passed in
}
}
log::trace!("exit: create_urls -> {:?}", urls);
urls
}
/// Helper function to determine suitability for recursion
///
/// handles 2xx and 3xx responses by either checking if the url ends with a / (2xx)
/// or if the Location header is present and matches the base url + / (3xx)
fn response_is_directory(response: &FeroxResponse) -> bool {
log::trace!("enter: is_directory({})", response);
if response.status().is_redirection() {
// status code is 3xx
match response.headers().get("Location") {
// and has a Location header
Some(loc) => {
// get absolute redirect Url based on the already known base url
log::debug!("Location header: {:?}", loc);
if let Ok(loc_str) = loc.to_str() {
if let Ok(abs_url) = response.url().join(loc_str) {
if format!("{}/", response.url()) == abs_url.as_str() {
// if current response's Url + / == the absolute redirection
// location, we've found a directory suitable for recursion
log::debug!(
"found directory suitable for recursion: {}",
response.url()
);
log::trace!("exit: is_directory -> true");
return true;
}
}
}
}
None => {
log::debug!("expected Location header, but none was found: {}", response);
log::trace!("exit: is_directory -> false");
return false;
}
}
} else if response.status().is_success() {
// status code is 2xx, need to check if it ends in /
if response.url().as_str().ends_with('/') {
log::debug!("{} is directory suitable for recursion", response.url());
log::trace!("exit: is_directory -> true");
return true;
}
}
log::trace!("exit: is_directory -> false");
false
}
/// 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
/// given Url
fn reached_max_depth(url: &Url, base_depth: usize, max_depth: usize) -> bool {
log::trace!(
"enter: reached_max_depth({}, {}, {})",
url,
base_depth,
max_depth
);
if max_depth == 0 {
// early return, as 0 means recurse forever; no additional processing needed
log::trace!("exit: reached_max_depth -> false");
return false;
}
let depth = get_current_depth(url.as_str());
if depth - base_depth >= max_depth {
return true;
}
log::trace!("exit: reached_max_depth -> false");
false
}
/// Helper function that wraps logic to check for recursion opportunities
///
/// When a recursion opportunity is found, the new url is sent across the recursion channel
async fn try_recursion(
response: &FeroxResponse,
base_depth: usize,
transmitter: UnboundedSender<String>,
) {
log::trace!(
"enter: try_recursion({}, {}, {:?})",
response,
base_depth,
transmitter,
);
if !reached_max_depth(response.url(), base_depth, CONFIGURATION.depth)
&& response_is_directory(&response)
{
if CONFIGURATION.redirects {
// response is 2xx can simply send it because we're following redirects
log::info!("Added new directory to recursive scan: {}", response.url());
match transmitter.send(String::from(response.url().as_str())) {
Ok(_) => {
log::debug!("sent {} across channel to begin a new scan", response.url());
}
Err(e) => {
log::error!(
"Could not send {} to recursion handler: {}",
response.url(),
e
);
}
}
} else {
let new_url = String::from(response.url().as_str());
log::info!("Added new directory to recursive scan: {}", new_url);
match transmitter.send(new_url) {
Ok(_) => {}
Err(e) => {
log::error!(
"Could not send {}/ to recursion handler: {}",
response.url(),
e
);
}
}
}
}
log::trace!("exit: try_recursion");
}
/// Simple helper to stay DRY; determines whether or not a given `FeroxResponse` should be reported
/// to the user or not.
pub fn should_filter_response(
response: &FeroxResponse,
tx_stats: UnboundedSender<StatCommand>,
) -> bool {
match FILTERS.read() {
Ok(filters) => {
for filter in filters.iter() {
// wildcard.should_filter goes here
if filter.should_filter_response(&response) {
if filter.as_any().downcast_ref::<WildcardFilter>().is_some() {
update_stat!(tx_stats, UpdateUsizeField(WildcardsFiltered, 1))
}
return true;
}
}
}
Err(e) => {
log::error!("{}", e);
}
}
false
}
/// Wrapper for [make_request](fn.make_request.html)
///
/// Handles making multiple requests based on the presence of extensions
///
/// Attempts recursion when appropriate and sends Responses to the report handler for processing
async fn make_requests(
target_url: &str,
word: &str,
base_depth: usize,
stats: Arc<Stats>,
dir_chan: UnboundedSender<String>,
report_chan: UnboundedSender<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
) {
log::trace!(
"enter: make_requests({}, {}, {}, {:?}, {:?}, {:?}, {:?})",
target_url,
word,
base_depth,
stats,
dir_chan,
report_chan,
tx_stats
);
let urls = create_urls(
&target_url,
&word,
&CONFIGURATION.extensions,
tx_stats.clone(),
);
for url in urls {
if let Ok(response) = make_request(&CONFIGURATION.client, &url, tx_stats.clone()).await {
// response came back without error, convert it to FeroxResponse
let ferox_response = FeroxResponse::from(response, true).await;
// do recursion if appropriate
if !CONFIGURATION.no_recursion {
try_recursion(&ferox_response, base_depth, dir_chan.clone()).await;
}
// purposefully doing recursion before filtering. the thought process is that
// even though this particular url is filtered, subsequent urls may not
if should_filter_response(&ferox_response, tx_stats.clone()) {
continue;
}
if CONFIGURATION.extract_links && !ferox_response.status().is_redirection() {
let new_links = get_links(&ferox_response, tx_stats.clone()).await;
for new_link in new_links {
let mut new_ferox_response = match request_feroxresponse_from_new_link(
&new_link,
tx_stats.clone(),
)
.await
{
Some(resp) => resp,
None => continue,
};
// filter if necessary
if should_filter_response(&new_ferox_response, tx_stats.clone()) {
continue;
}
if new_ferox_response.is_file() {
// very likely a file, simply request and report
log::debug!("Singular extraction: {}", new_ferox_response);
SCANNED_URLS
.add_file_scan(&new_ferox_response.url().to_string(), stats.clone());
send_report(report_chan.clone(), new_ferox_response);
continue;
}
if !CONFIGURATION.no_recursion {
log::debug!("Recursive extraction: {}", new_ferox_response);
if new_ferox_response.status().is_success()
&& !new_ferox_response.url().as_str().ends_with('/')
{
// since all of these are 2xx, recursion is only attempted if the
// url ends in a /. I am actually ok with adding the slash and not
// adding it, as both have merit. Leaving it in for now to see how
// things turn out (current as of: v1.1.0)
new_ferox_response.set_url(&format!("{}/", new_ferox_response.url()));
}
try_recursion(&new_ferox_response, base_depth, dir_chan.clone()).await;
}
}
}
// everything else should be reported
send_report(report_chan.clone(), ferox_response);
}
}
log::trace!("exit: make_requests");
}
/// Simple helper to send a `FeroxResponse` over the tx side of an `mpsc::unbounded_channel`
pub fn send_report(report_sender: UnboundedSender<FeroxResponse>, response: FeroxResponse) {
log::trace!("enter: send_report({:?}, {}", report_sender, response);
match report_sender.send(response) {
Ok(_) => {}
Err(e) => {
log::error!("{}", e);
}
}
log::trace!("exit: send_report");
}
/// Scan a given url using a given wordlist
///
/// This is the primary entrypoint for the scanner
pub async fn scan_url(
target_url: &str,
wordlist: Arc<HashSet<String>>,
base_depth: usize,
stats: Arc<Stats>,
tx_term: UnboundedSender<FeroxResponse>,
tx_file: UnboundedSender<FeroxResponse>,
tx_stats: UnboundedSender<StatCommand>,
) {
log::trace!(
"enter: scan_url({:?}, wordlist[{} words...], {}, {:?}, {:?}, {:?}, {:?})",
target_url,
wordlist.len(),
base_depth,
stats,
tx_term,
tx_file,
tx_stats
);
log::info!("Starting scan against: {}", target_url);
let scan_timer = Instant::now();
let (tx_dir, rx_dir): FeroxChannel<String> = mpsc::unbounded_channel();
if CALL_COUNT.load(Ordering::Relaxed) < stats.initial_targets.load(Ordering::Relaxed) {
CALL_COUNT.fetch_add(1, Ordering::Relaxed);
update_stat!(tx_stats, UpdateUsizeField(TotalScans, 1));
// this protection allows us to add the first scanned url to SCANNED_URLS
// from within the scan_url function instead of the recursion handler
SCANNED_URLS.add_directory_scan(&target_url, stats.clone());
}
let ferox_scan = match SCANNED_URLS.get_scan_by_url(&target_url) {
Some(scan) => scan,
None => {
log::error!(
"Could not find FeroxScan associated with {}; this shouldn't happen... exiting",
target_url
);
return;
}
};
let progress_bar = match ferox_scan.lock() {
Ok(mut scan) => scan.progress_bar(),
Err(e) => {
log::error!("FeroxScan's ({:?}) mutex is poisoned: {}", ferox_scan, e);
return;
}
};
// 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 this point, the freed permit is assigned
// to the caller.
let permit = SCAN_LIMITER.acquire().await;
// Arc clones to be passed around to the various scans
let wildcard_bar = progress_bar.clone();
let heuristics_term_clone = tx_term.clone();
let heuristics_stats_clone = tx_stats.clone();
let recurser_term_clone = tx_term.clone();
let recurser_file_clone = tx_file.clone();
let recurser_stats_clone = tx_stats.clone();
let recurser_words = wordlist.clone();
let looping_words = wordlist.clone();
let looping_stats = stats.clone();
let recurser = tokio::spawn(async move {
spawn_recursion_handler(
rx_dir,
recurser_words,
base_depth,
stats.clone(),
recurser_term_clone,
recurser_file_clone,
recurser_stats_clone,
)
.await
});
// add any wildcard filters to `FILTERS`
let filter = match heuristics::wildcard_test(
&target_url,
wildcard_bar,
heuristics_term_clone,
heuristics_stats_clone,
)
.await
{
Some(f) => Box::new(f),
None => Box::new(WildcardFilter::default()),
};
add_filter_to_list_of_ferox_filters(filter, FILTERS.clone());
// producer tasks (mp of mpsc); responsible for making requests
let producers = stream::iter(looping_words.deref().to_owned())
.map(|word| {
let txd = tx_dir.clone();
let txr = tx_term.clone();
let txs = tx_stats.clone();
let pb = progress_bar.clone(); // progress bar is an Arc around internal state
let tgt = target_url.to_string(); // done to satisfy 'static lifetime below
let lst = looping_stats.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
// todo change to true when issue #107 is resolved
SCANNED_URLS.pause(false).await;
}
make_requests(&tgt, &word, base_depth, lst, txd, txr, txs).await
}),
pb,
)
})
.for_each_concurrent(CONFIGURATION.threads, |(resp, bar)| async move {
match resp.await {
Ok(_) => {
bar.inc((CONFIGURATION.extensions.len() + 1) as u64);
}
Err(e) => {
log::error!("error awaiting a response: {}", e);
}
}
});
// await tx tasks
log::trace!("awaiting scan producers");
producers.await;
log::trace!("done awaiting scan producers");
update_stat!(
tx_stats,
UpdateF64Field(DirScanTimes, scan_timer.elapsed().as_secs_f64())
);
// drop the current permit so the semaphore will allow another scan to proceed
drop(permit);
if let Ok(mut scan) = ferox_scan.lock() {
scan.finish();
}
// manually drop tx in order for the rx task's while loops to eval to false
log::trace!("dropped recursion handler's transmitter");
drop(tx_dir);
// await rx tasks
log::trace!("awaiting recursive scan receiver/scans");
futures::future::join_all(recurser.await.unwrap()).await;
log::trace!("done awaiting recursive scan receiver/scans");
log::trace!("exit: scan_url");
}
/// 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,
config: &Configuration,
tx_stats: UnboundedSender<StatCommand>,
) {
log::trace!(
"enter: initialize({}, {:?}, {:?})",
num_words,
config,
tx_stats
);
// number of requests only needs to be calculated once, and then can be reused
let num_reqs_expected: u64 = if config.extensions.is_empty() {
num_words.try_into().unwrap()
} else {
let total = num_words * (config.extensions.len() + 1);
total.try_into().unwrap()
};
// tell Stats object about the number of expected requests
update_stat!(
tx_stats,
UpdateUsizeField(ExpectedPerScan, num_reqs_expected as usize)
);
// add any status code filters to `FILTERS` (-C|--filter-status)
for code_filter in &config.filter_status {
let filter = StatusCodeFilter {
filter_code: *code_filter,
};
let boxed_filter = Box::new(filter);
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
}
// add any line count filters to `FILTERS` (-N|--filter-lines)
for lines_filter in &config.filter_line_count {
let filter = LinesFilter {
line_count: *lines_filter,
};
let boxed_filter = Box::new(filter);
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
}
// add any line count filters to `FILTERS` (-W|--filter-words)
for words_filter in &config.filter_word_count {
let filter = WordsFilter {
word_count: *words_filter,
};
let boxed_filter = Box::new(filter);
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
}
// add any line count filters to `FILTERS` (-S|--filter-size)
for size_filter in &config.filter_size {
let filter = SizeFilter {
content_length: *size_filter,
};
let boxed_filter = Box::new(filter);
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
}
// add any regex filters to `FILTERS` (-X|--filter-regex)
for regex_filter in &config.filter_regex {
let raw = regex_filter;
let compiled = match Regex::new(&raw) {
Ok(regex) => regex,
Err(e) => {
log::error!("Invalid regular expression: {}", e);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
};
let filter = RegexFilter {
raw_string: raw.to_owned(),
compiled,
};
let boxed_filter = Box::new(filter);
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
}
// add any similarity filters to `FILTERS` (--filter-similar-to)
for similarity_filter in &config.filter_similar {
// url as-is based on input, ignores user-specified url manipulation options (add-slash etc)
if let Ok(url) = format_url(
&similarity_filter,
&"",
false,
&Vec::new(),
None,
tx_stats.clone(),
) {
// attempt to request the given url
if let Ok(resp) = make_request(&CONFIGURATION.client, &url, tx_stats.clone()).await {
// if successful, create a filter based on the response's body
let fr = FeroxResponse::from(resp, true).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);
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
}
}
}
if config.scan_limit == 0 {
// scan_limit == 0 means no limit should be imposed... however, scoping the Semaphore
// permit is tricky, so as a workaround, we'll add a ridiculous number of permits to
// the semaphore (1,152,921,504,606,846,975 to be exact) and call that 'unlimited'
SCAN_LIMITER.add_permits(usize::MAX >> 4);
}
log::trace!("exit: initialize");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// sending url + word without any extensions should get back one url with the joined word
fn create_urls_no_extension_returns_base_url_with_word() {
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let urls = create_urls("http://localhost", "turbo", &[], tx);
assert_eq!(urls, [Url::parse("http://localhost/turbo").unwrap()])
}
#[test]
/// sending url + word + 1 extension should get back two urls, one base and one with extension
fn create_urls_one_extension_returns_two_urls() {
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
let urls = create_urls("http://localhost", "turbo", &[String::from("js")], tx);
assert_eq!(
urls,
[
Url::parse("http://localhost/turbo").unwrap(),
Url::parse("http://localhost/turbo.js").unwrap()
]
)
}
#[test]
/// sending url + word + multiple extensions should get back n+1 urls
fn create_urls_multiple_extensions_returns_n_plus_one_urls() {
let ext_vec = vec![
vec![String::from("js")],
vec![String::from("js"), String::from("php")],
vec![String::from("js"), String::from("php"), String::from("pdf")],
vec![
String::from("js"),
String::from("php"),
String::from("pdf"),
String::from("tar.gz"),
],
];
let base = Url::parse("http://localhost/turbo").unwrap();
let js = Url::parse("http://localhost/turbo.js").unwrap();
let php = Url::parse("http://localhost/turbo.php").unwrap();
let pdf = Url::parse("http://localhost/turbo.pdf").unwrap();
let tar = Url::parse("http://localhost/turbo.tar.gz").unwrap();
let expected = vec![
vec![base.clone(), js.clone()],
vec![base.clone(), js.clone(), php.clone()],
vec![base.clone(), js.clone(), php.clone(), pdf.clone()],
vec![base, js, php, pdf, tar],
];
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
for (i, ext_set) in ext_vec.into_iter().enumerate() {
let urls = create_urls("http://localhost", "turbo", &ext_set, tx.clone());
assert_eq!(urls, expected[i]);
}
}
#[test]
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
fn reached_max_depth_returns_early_on_zero() {
let url = Url::parse("http://localhost").unwrap();
let result = reached_max_depth(&url, 0, 0);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth equal to max depth, expect true
fn reached_max_depth_current_depth_equals_max() {
let url = Url::parse("http://localhost/one/two").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(result);
}
#[test]
/// call reached_max_depth with url dpeth less than max depth, expect false
fn reached_max_depth_current_depth_less_than_max() {
let url = Url::parse("http://localhost").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(!result);
}
#[test]
/// call reached_max_depth with url of 2, base depth of 2, and max depth of 2, expect false
fn reached_max_depth_base_depth_equals_max_depth() {
let url = Url::parse("http://localhost/one/two").unwrap();
let result = reached_max_depth(&url, 2, 2);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth greater than max depth, expect true
fn reached_max_depth_current_greater_than_max() {
let url = Url::parse("http://localhost/one/two/three").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(result);
}
#[tokio::test(core_threads = 1)]
#[should_panic]
/// call initialize with a bad regex, triggering a panic
async fn initialize_panics_on_bad_regex() {
let config = Configuration {
filter_regex: vec![r"(".to_string()],
..Default::default()
};
let (tx, _): FeroxChannel<StatCommand> = mpsc::unbounded_channel();
initialize(1, &config, tx).await;
}
}

View File

@@ -0,0 +1,354 @@
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::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();
}
}
});
// 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(num_reqs) = test.wildcard(&self.target_url).await {
progress_bar.inc(num_reqs);
}
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 {
message
.push_str(&format!(" (add {} to scan)", style("-e").bright().yellow()))
}
progress_bar.reset_eta();
progress_bar.finish_with_message(&message);
ferox_scan.finish()?;
return Ok(());
}
}
}
// 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) as usize]
);
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;

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