Compare commits

...

485 Commits

Author SHA1 Message Date
epi
ccb10c1c68 Update README.md 2022-04-15 05:53:58 -05:00
epi
20ab0aade3 Update README.md 2022-04-15 05:52:14 -05:00
epi
02ad0b1d85 Update README.md 2022-04-15 05:49:58 -05:00
epi
09aad922c1 Update README.md 2022-04-15 05:48:49 -05:00
epi
697f947bfa Update README.md 2022-04-15 05:47:42 -05:00
epi
d300d68737 Update README.md 2022-04-15 05:46:39 -05:00
epi
c2c6854db4 Merge pull request #541 from epi052/all-contributors/add-Flangyver
docs: add Flangyver as a contributor for ideas
2022-04-15 05:45:26 -05:00
allcontributors[bot]
63be575d89 docs: update .all-contributorsrc [skip ci] 2022-04-15 10:45:17 +00:00
allcontributors[bot]
0d25fda11e docs: update README.md [skip ci] 2022-04-15 10:45:16 +00:00
epi
b0341c2432 Merge pull request #540 from epi052/all-contributors/add-0xdf223
docs: add 0xdf223 as a contributor for bug, ideas
2022-04-15 05:42:54 -05:00
allcontributors[bot]
62352db152 docs: update .all-contributorsrc [skip ci] 2022-04-15 10:42:41 +00:00
allcontributors[bot]
3090edc49c docs: update README.md [skip ci] 2022-04-15 10:42:40 +00:00
epi
85d686d1aa Merge pull request #539 from epi052/all-contributors/add-ThisLimn0
docs: add ThisLimn0 as a contributor for bug
2022-04-15 05:41:41 -05:00
allcontributors[bot]
17138f4ef7 docs: update .all-contributorsrc [skip ci] 2022-04-15 10:41:28 +00:00
allcontributors[bot]
1d30b7db31 docs: update README.md [skip ci] 2022-04-15 10:41:27 +00:00
epi
4c0d3c91a0 Merge pull request #536 from epi052/535-status-code-filter-overhaul
535 status code filter overhaul
2022-04-15 05:39:55 -05:00
epi
96fc6b232a update 2022-04-14 21:08:33 -05:00
epi
9b306aad34 update 2022-04-14 16:45:14 -05:00
epi
10eee184d0 update 2022-04-14 16:42:25 -05:00
epi
986161f05f update 2022-04-14 16:39:50 -05:00
epi
4a19dbfd7d update 2022-04-14 16:14:23 -05:00
epi
b8ceeaff0f update 2022-04-14 16:07:37 -05:00
epi
d04e58036e update 2022-04-14 13:10:56 -05:00
epi
d1a74207f4 one more again 2022-04-14 08:13:51 -05:00
epi
03a36f0b60 update 2022-04-14 07:54:43 -05:00
epi
f2d9269643 update 2022-04-14 07:44:35 -05:00
epi
bba7cba02e update 2022-04-14 06:46:26 -05:00
epi
fffd1e5c82 update 2022-04-14 06:44:51 -05:00
epi
3c6da0f782 update 2022-04-14 06:40:35 -05:00
epi
5ecd937c0e reverted heuristics tests 2022-04-14 06:25:55 -05:00
epi
9f6221daf6 removed more toolchain actions 2022-04-14 06:18:00 -05:00
epi
af49fd8e62 attempting to remove toolchain action 2022-04-14 06:15:14 -05:00
epi
d1daefd8ba removed allows from ci clippy 2022-04-14 06:12:42 -05:00
epi
3e8255d5b7 reverted ci back to normal 2022-04-14 06:11:38 -05:00
epi
5af18e83d8 ci tweak 2022-04-14 05:48:43 -05:00
epi
d1d0757d56 test troubleshoot 2022-04-14 05:28:20 -05:00
epi
f5f9344a81 test troubleshoot 2022-04-14 05:09:50 -05:00
epi
fd52e39188 reverted build to main only 2022-04-13 20:44:57 -05:00
epi
22377dc9a3 trying again with new ci configs 2022-04-13 20:43:34 -05:00
epi
1cf7dff734 trying again with new ci configs 2022-04-13 20:34:16 -05:00
epi
7c9eb900b7 reverted test action 2022-04-13 20:14:57 -05:00
epi
8480b3cc2c reverted coverage 2022-04-13 20:01:18 -05:00
epi
9d29142046 lint 2022-04-13 19:45:30 -05:00
epi
38c194b222 tweaked coverage action 2022-04-13 19:36:05 -05:00
epi
72dc14bf3d fixed nlp tests 2022-04-13 19:20:24 -05:00
epi
9a7c690c17 changed to SecLists 2022-04-13 17:55:33 -05:00
epi
de4514e381 test build windows 2022-04-13 17:27:15 -05:00
epi
2be8aaf2bf changed -w default when on windows host 2022-04-13 17:19:09 -05:00
epi
4db3a0b056 updated help a bit 2022-04-13 16:40:51 -05:00
epi
a9ef7f180f force-recursion and no-recursion are mutually exclusive 2022-04-13 16:34:52 -05:00
epi
ac7cb5d6b6 lint / tests 2022-04-13 06:25:35 -05:00
epi
f8e18abb48 added filter checking logic to collect-backups requests 2022-04-12 21:09:51 -05:00
epi
1d805aca5a tweaked pre-processing and selection criteria for nlp 2022-04-12 20:55:37 -05:00
epi
fa09266804 handles passed through to termhandler 2022-04-12 20:53:24 -05:00
epi
15592c3dfd added AddHandles command 2022-04-12 20:52:57 -05:00
epi
53c171aeb5 extract-links should also abide by forced recursion into found assets only 2022-04-12 07:15:40 -05:00
epi
5167d24c4b another deb builder fix attempt 2022-04-12 06:49:42 -05:00
epi
81ff62c53d force recursion should only happen on found assets 2022-04-12 06:45:37 -05:00
epi
433f68458e updated test runner to nextest 2022-04-12 06:11:09 -05:00
epi
d32720a90a fixed deb build, maybe 2022-04-12 06:04:35 -05:00
epi
0ceef975e6 updated ci coverage job 2022-04-12 05:52:03 -05:00
epi
6d7d6c4e7b added tests for --force-recursion 2022-04-11 20:14:41 -05:00
epi
5f21953bc1 added --force-recursion option 2022-04-11 20:13:10 -05:00
epi
6906ac0ee8 added force-recursion to example config 2022-04-11 20:12:58 -05:00
epi
cafe766d9e bumped version to 2.7.0 2022-04-11 20:12:38 -05:00
epi
98d0d177df updated parse extensions logic and banner to reflect status code changes 2022-04-11 17:37:36 -05:00
epi
23e10833d0 cicd test 2022-04-11 17:13:01 -05:00
epi
7f51d0f7bf initial stab at updated statuscode behavior 2022-04-11 17:10:53 -05:00
epi
9515a5da99 fixed bug related to methods/responses not being displayed 2022-04-09 11:08:46 -05:00
epi
b417ab41a5 fixed bug related to methods/responses not being displayed 2022-04-09 11:08:16 -05:00
epi
b946565c2f updated shell completions for new version 2022-04-09 06:46:14 -05:00
epi
714b054360 bumped version to 2.6.3 2022-04-09 06:24:48 -05:00
epi
30544eaf7d fixed bug in replay proxy related to #501 2022-04-09 06:18:50 -05:00
epi
99e2d46aa2 Merge pull request #534 from epi052/all-contributors/add-jhaddix
docs: add jhaddix as a contributor for ideas
2022-04-07 07:04:05 -05:00
allcontributors[bot]
82a4f16252 docs: update .all-contributorsrc [skip ci] 2022-04-07 12:02:25 +00:00
allcontributors[bot]
6e0fe1eced docs: update README.md [skip ci] 2022-04-07 12:02:24 +00:00
epi
1ffc93a337 Merge pull request #533 from epi052/527-add-filters-via-scan-menu
add and remove filters via scan management menu
2022-04-06 20:53:20 -05:00
epi
7ab0453eb7 lint 2022-04-06 20:13:07 -05:00
epi
50b29a2b74 added remove filter command to SMM 2022-04-06 19:16:30 -05:00
epi
bf78fea926 Merge pull request #528 from epi052/527-add-filters-via-scan-menu
add filters via scan menu
2022-04-04 19:18:07 -05:00
epi
56267726cc finalized tests 2022-04-04 19:03:51 -05:00
epi
01844cffd8 added filters::utils tests 2022-04-04 18:28:39 -05:00
epi
2abc0b78ee removed unnecessary async 2022-04-04 06:22:38 -05:00
epi
fdb8774bfd removed unnecessary async 2022-04-04 06:17:32 -05:00
epi
8d3cd2471b added docs to new util fns 2022-04-04 06:12:18 -05:00
epi
da22371c87 removed duplicate import 2022-04-04 06:06:24 -05:00
epi
ca494ca801 removed duplicate link-attr extractor 2022-04-04 06:01:01 -05:00
epi
376804aa59 added test for empty filter 2022-04-04 05:59:28 -05:00
epi
56a769c197 upgraded deps 2022-04-04 05:44:23 -05:00
epi
9922eb5124 added clippy to makefile 2022-04-03 20:43:12 -05:00
epi
7a58f8fcf8 clippy and tests 2022-04-03 20:42:55 -05:00
epi
8d1872fb3f initial stab at implementation 2022-04-03 20:20:08 -05:00
epi
e1e59e6e8e Merge pull request #515 from epi052/all-contributors/add-gtjamesa
docs: add gtjamesa as a contributor for bug
2022-03-08 05:50:02 -06:00
allcontributors[bot]
0c731c3639 docs: update .all-contributorsrc [skip ci] 2022-03-08 11:49:34 +00:00
allcontributors[bot]
b456215a00 docs: update README.md [skip ci] 2022-03-08 11:49:33 +00:00
epi
d38c66f290 Merge pull request #514 from epi052/513-fix-collect-backups-root-request
backups requested from proper dir
2022-03-08 05:46:37 -06:00
epi
6ba32d926c fixed 513; backups requested from proper dir 2022-03-08 05:43:40 -06:00
epi
86bcfd8fb1 Merge pull request #512 from epi052/all-contributors/add-ippsec
docs: add ippsec as a contributor for ideas
2022-03-05 08:15:33 -06:00
allcontributors[bot]
4a101e4ae5 docs: update .all-contributorsrc [skip ci] 2022-03-05 14:15:17 +00:00
allcontributors[bot]
1ae1430a11 docs: update README.md [skip ci] 2022-03-05 14:15:16 +00:00
epi
cca3163baf fixed test 2022-03-05 07:09:23 -06:00
epi
c9013edce8 Merge pull request #511 from epi052/all-contributors/add-0dayCTF
docs: add 0dayCTF as a contributor for ideas
2022-03-05 07:02:49 -06:00
allcontributors[bot]
7bdb137fd1 docs: update .all-contributorsrc [skip ci] 2022-03-05 13:02:17 +00:00
allcontributors[bot]
5f0eaf8885 docs: update README.md [skip ci] 2022-03-05 13:02:16 +00:00
epi
2b7002d9cf Merge pull request #494 from epi052/release-2.6.0-multi-feature
2.6.0 release branch
2022-03-05 06:57:33 -06:00
epi
86b17f226d removed lint 2022-03-04 21:47:52 -06:00
epi
cbbf9be6c9 added composite flags 2022-03-04 21:15:43 -06:00
epi
f814c4b223 put back stripped comment 2022-03-04 06:54:17 -06:00
epi
7839118379 added cargo make Makefile.toml 2022-03-04 06:52:45 -06:00
epi
8214a2a357 bumped depenedencies 2022-03-04 06:52:23 -06:00
epi
e06e194f77 fixed flaky test 2022-03-04 06:52:13 -06:00
epi
1628ee86a3 added info log for collect words 2022-03-04 06:51:59 -06:00
epi
53d2076176 removed deprecated clap struct/methods 2022-03-04 06:45:22 -06:00
epi
304750fa3f Merge pull request #507 from epi052/collect-words-feature-2.6
add collect-words feature
2022-03-03 06:55:02 -06:00
epi
6c5c812784 added a few tests 2022-03-03 06:38:37 -06:00
epi
063e7b0420 added --collect-words implementation 2022-03-01 17:55:14 -06:00
epi
eed59e1da5 added nlp module 2022-02-27 13:42:07 -06:00
epi
ca4d8f0c52 replay proxy respects --data now 2022-02-19 17:28:19 -06:00
epi
c1132622cf replay proxy respects --data now 2022-02-19 15:19:09 -06:00
epi
2d5aeb444e added temp workaround for +proxy/-data problem 2022-02-19 15:11:22 -06:00
epi
53238a6e5e Merge pull request #474 from godylockz/Misc-Fixes
Add no-state option, filter queries from links, fix headers
2022-02-19 10:18:50 -06:00
epi
7b7eeeebfa Merge branch 'release-2.6.0-multi-feature' into Misc-Fixes 2022-02-18 08:05:12 -06:00
epi
aed0c41d8f Merge pull request #498 from epi052/489-discover-backups
implemented/tested logic for collecting backups
2022-02-17 20:02:20 -06:00
epi
5edd58a3f4 clippy 2022-02-17 19:46:44 -06:00
epi
44693a3498 added cli/banner/tests etc... 2022-02-17 19:44:29 -06:00
epi
02448e9834 dirlist extraction gated behind -e 2022-02-16 21:25:20 -06:00
epi
368035833c fixed up implementation/removed todo items 2022-02-16 20:44:07 -06:00
epi
d13bce2261 implemented/tested logic for collecting backups 2022-02-16 17:16:12 -06:00
epi
88d451144c Merge pull request #495 from epi052/collect-extensions-feature
implemented --collect-extensions; numerous bugfixes/code improvements
2022-02-15 21:04:49 -06:00
epi
8d639a17e4 removed read_body param from FeroxResponse::from 2022-02-15 21:03:02 -06:00
epi
7f0dcb6b46 lint 2022-02-15 20:51:01 -06:00
epi
3230f9c276 removed client param from logged_request 2022-02-15 20:42:15 -06:00
epi
b21ea9ce32 removed assay 2022-02-15 20:37:20 -06:00
epi
801413105d clippy 2022-02-15 16:35:16 -06:00
epi
3030296d1c added more tests again 2022-02-15 16:34:12 -06:00
epi
88a595fd82 added more tests 2022-02-15 07:14:23 -06:00
epi
9a84c5234f fixed banner tests 2022-02-14 17:32:40 -06:00
epi
d0d99ebed6 tests passing 2022-02-14 06:29:25 -06:00
epi
7194326cd1 moved dirlist detection to heuristics/fixed initial extract async issue 2022-02-13 13:20:13 -06:00
epi
71885e7e56 implemented --collect-extensions; numerous bugfixes/code improvements 2022-02-12 07:01:25 -06:00
epi
13cfbe152e bumped version to 2.6.0 2022-02-12 06:57:42 -06:00
godylockz
8b9d640090 Found a bug for redirect links not extracting links properly 2022-02-05 23:34:16 -05:00
godylockz
007bc4a50d Response URL was not being used in concatenation. 2022-01-30 01:24:53 -05:00
godylockz
71c5b66eb6 Revert, did in wrong place. 2022-01-30 01:07:32 -05:00
godylockz
1498122973 Fix warnings / formatting. 2022-01-29 09:22:23 -05:00
godylockz
c0b4040743 Fix relative pathing 2022-01-29 09:20:44 -05:00
godylockz
3c474920bb Fix per comments 2022-01-24 23:10:21 -05:00
godylockz
079b8b2176 Update src/parser.rs
Co-authored-by: epi <43392618+epi052@users.noreply.github.com>
2022-01-24 22:17:09 -05:00
godylockz
4a678ef65b Shell completions 2022-01-23 14:30:41 -05:00
godylockz
e9fb9642a8 Add no-state option, filter queries from links, fix headers 2022-01-23 14:27:24 -05:00
epi
f25475ae4f updated deps 2022-01-17 07:07:08 -06:00
epi
194eec1867 Merge pull request #466 from epi052/add-redirect-messages-to-normal-reports
Add redirect messages to normal reports
2022-01-17 07:03:10 -06:00
epi
d9088be54e Merge pull request #470 from epi052/all-contributors/add-godylockz
docs: add godylockz as a contributor for ideas, code
2022-01-17 07:02:12 -06:00
allcontributors[bot]
c0ae120016 docs: update .all-contributorsrc [skip ci] 2022-01-17 13:02:00 +00:00
allcontributors[bot]
9d5b339708 docs: update README.md [skip ci] 2022-01-17 13:01:59 +00:00
epi
b7e1876d87 Merge pull request #469 from epi052/all-contributors/add-MD-Levitan
docs: add MD-Levitan as a contributor for ideas, code
2022-01-17 07:00:10 -06:00
allcontributors[bot]
4979946471 docs: update .all-contributorsrc [skip ci] 2022-01-17 12:59:53 +00:00
allcontributors[bot]
ef5d267500 docs: update README.md [skip ci] 2022-01-17 12:59:52 +00:00
epi
a0208449cd Merge pull request #468 from epi052/all-contributors/add-its0x08
docs: add its0x08 as a contributor for ideas
2022-01-17 06:58:57 -06:00
allcontributors[bot]
bef48a8441 docs: update .all-contributorsrc [skip ci] 2022-01-17 12:58:46 +00:00
allcontributors[bot]
1bed84394a docs: update README.md [skip ci] 2022-01-17 12:58:45 +00:00
epi
d66ba9c78a nitpickery 2022-01-17 06:49:57 -06:00
epi
b4e8a63429 updated PR template with docs links 2022-01-17 06:43:03 -06:00
epi
257352e22d fixed reported times when scan limit used 2022-01-17 06:36:28 -06:00
epi
ed521005b2 Merge branch 'main' into add-redirect-messages-to-normal-reports 2022-01-17 06:35:04 -06:00
epi
cbc4da53de Merge pull request #464 from godylockz/ferox-parsehtml
Directory Listing & Web Scraping Links
2022-01-17 06:32:16 -06:00
epi
920ce7ce23 fixed dir listing req/s 2022-01-17 05:24:10 -06:00
godylockz
1ef5bc288a Fix tests back after removing extra prints 2022-01-16 22:34:37 -05:00
epi
97be0731d9 added directory listing message to bars 2022-01-16 21:20:26 -06:00
epi
0b42c6a30e Update .github/pull_request_template.md 2022-01-16 21:06:53 -06:00
epi
537d5c69dc Update CONTRIBUTING.md 2022-01-16 21:05:48 -06:00
epi
194d00c073 Update src/extractor/container.rs 2022-01-16 21:04:23 -06:00
epi
f83195120a Update src/scanner/ferox_scanner.rs 2022-01-16 21:03:43 -06:00
epi
af2a4dbde0 Update src/extractor/container.rs 2022-01-16 21:02:04 -06:00
godylockz
c83a2d8ef2 Fixed review comments 2022-01-16 21:37:43 -05:00
godylockz
ba96b686ea Remove iis heuristic, fix test cases 2022-01-16 21:15:04 -05:00
godylockz
fddade6a11 Fix parsing non-relative urls 2022-01-16 20:53:25 -05:00
godylockz
8b97974728 Cleanup code, make function. 2022-01-16 17:54:47 -05:00
godylockz
ab1861ca2c Use scrapper instead. 2022-01-16 17:00:19 -05:00
godylockz
5f241f6034 Cargo lock changed after build 2022-01-16 14:59:10 -05:00
epi
709a787613 removed some cruft 2022-01-16 07:03:11 -06:00
epi
21f0b95f15 updated tests 2022-01-16 06:56:47 -06:00
epi
36b6e49b87 added redirect messages to normal url reporting 2022-01-16 06:50:36 -06:00
godylockz
327bdd6e03 Merge branch 'main' of github.com:godylockz/feroxbuster into ferox-parsehtml 2022-01-15 23:49:17 -05:00
godylockz
b1e8023462 Fix file/directory detection logic again.... 2022-01-15 23:29:36 -05:00
godylockz
ae3a43db28 Add directory listing stop 2022-01-15 23:21:06 -05:00
godylockz
e8f4bbccf4 Add nginx style directory listing heuristic 2022-01-15 21:42:33 -05:00
epi
7cf834d000 Merge pull request #463 from epi052/112-parser-option-groups
added option groups; updated deps; clippy
2022-01-15 19:09:23 -06:00
epi
1500e651fa clippy / nitpickery 2022-01-15 19:04:53 -06:00
epi
e5048f0c8d fixed multiple -u issue 2022-01-15 18:46:41 -06:00
epi
d109608e2a added option groups; updated deps; clippy 2022-01-15 17:27:07 -06:00
godylockz
3e373d1a50 Sync clippy arguments between contributing and pull request template 2022-01-15 17:25:10 -05:00
godylockz
46b3989df7 Fix description 2022-01-15 17:05:42 -05:00
godylockz
cc9467153b Ran cargo fmt 2022-01-15 16:07:12 -05:00
godylockz
eb4b074454 Passed tests 2022-01-15 16:04:53 -05:00
godylockz
a07c9432f2 Fix issue with no extension assumed directory 2022-01-15 14:32:10 -05:00
godylockz
1f4dbeaf65 Fix traces / debug statements 2022-01-15 12:37:03 -05:00
godylockz
1d45325c23 Add directory listing tests 2022-01-15 10:15:35 -05:00
godylockz
216e0e6595 Update test cases for directory hits +1 2022-01-15 01:31:56 -05:00
godylockz
dfa0664a16 Add testing extractor and formatting 2022-01-15 00:58:27 -05:00
godylockz
54144dba89 Second Cut - All Directory Listing Items Obtained 2022-01-14 23:46:40 -05:00
godylockz
18ad9ca733 First cut at parsing html for links 2022-01-14 21:30:22 -05:00
godylockz
2b58113a2c Merge branch 'ferox-parsehtml' of github.com:godylockz/feroxbuster into ferox-parsehtml 2022-01-13 19:58:48 -05:00
godylockz
5ed43e8dbd Add html5ever 2022-01-13 19:58:42 -05:00
godylockz
82f65e58b7 Add html5ever 2022-01-13 19:56:28 -05:00
epi
b87836fb45 Merge pull request #441 from MD-Levitan/feature_methods
Add support of multiple methods during scan #440
2022-01-13 07:29:42 -06:00
epi
4db5835ea3 Update src/banner/container.rs
cargo fmt
2022-01-13 07:23:18 -06:00
epi
203108fc2d Update src/config/container.rs
use `strip_prefix`
2022-01-13 07:16:36 -06:00
epi
b040113115 Update src/config/container.rs
use `strip_prefix`
2022-01-13 07:16:29 -06:00
epi
6158d36279 Update src/event_handlers/outputs.rs
remove unnecessary `&`
2022-01-13 07:16:04 -06:00
epi
215b76246c Update src/banner/container.rs
handle newline/carriage return
2022-01-13 07:15:12 -06:00
MD-Levitan
729c272964 Fix Progress Bar count 2022-01-13 14:04:30 +03:00
MD-Levitan
43a8b7d7e7 Update scan_manager/tests.rs 2022-01-12 14:16:15 +03:00
MD-Levitan
94055ec504 Fix changes from https://github.com/epi052/feroxbuster/pull/441#pullrequestreview-848554319 2022-01-12 14:08:14 +03:00
MD-Levitan
76a1660329 Merge remote-tracking branch 'upstream/main' into feature_methods 2022-01-11 15:08:23 +03:00
epi
d44ad9ca2d Merge pull request #456 from epi052/all-contributors/add-unkn0wnsyst3m
docs: add unkn0wnsyst3m as a contributor for ideas
2022-01-10 20:15:22 -06:00
allcontributors[bot]
8712dee50c docs: update .all-contributorsrc [skip ci] 2022-01-11 02:15:11 +00:00
allcontributors[bot]
fc47a6e4e5 docs: update README.md [skip ci] 2022-01-11 02:15:10 +00:00
epi
87c23b2cde Merge pull request #455 from epi052/all-contributors/add-7047payloads
docs: add 7047payloads as a contributor for code
2022-01-10 20:14:22 -06:00
epi
e3fed0e6ac Merge pull request #454 from epi052/cookie-flag
add --cookie flag
2022-01-10 20:11:34 -06:00
allcontributors[bot]
d43da01dab docs: update .all-contributorsrc [skip ci] 2022-01-11 02:11:16 +00:00
allcontributors[bot]
47726bc25a docs: update README.md [skip ci] 2022-01-11 02:11:15 +00:00
epi
ec26321e42 updated deps 2022-01-10 20:09:12 -06:00
epi
21c0a7458b bumped version to 2.5.0 2022-01-10 20:04:13 -06:00
epi
9950b1381d completions 2022-01-10 20:01:37 -06:00
epi
06460bf9da cargo fmt; parser help 2022-01-10 20:01:10 -06:00
Citizen 7047
701701eea3 Removed extra vector for storing parsed cookies, using map instead 2022-01-10 21:20:32 +05:30
Citizen 7047
9a53053112 Fixed multiple cookie headers. Using semicolon to seperate each key-value pair 2022-01-10 20:50:35 +05:30
MD-Levitan
ee69c558a7 Add reader from file 2022-01-10 17:46:48 +03:00
Citizen 7047
8f9b757e2d Changed match to if let 2022-01-09 09:25:05 +05:30
Citizen 7047
777e5628a5 Added support for specifying cookies with the -b flag 2022-01-03 18:46:43 +05:30
MD-Levitan
4d49401a96 Init commit for 2021-12-30 19:18:51 +03:00
MD-Levitan
03e0d0092d Fix bugs with HTTP methods.
1. Update Configs
2. Add banner test
3. Change lentgh calculation.
4. Add wildcard for each method.
2021-12-30 15:33:04 +03:00
MD-Levitan
21b29a693e Add param for feroxscanner 2021-12-23 18:10:02 +03:00
epi
857ac22266 Merge pull request #436 from epi052/all-contributors/add-justinsteven
docs: add justinsteven as a contributor for ideas
2021-12-19 14:06:10 -06:00
epi
06ca52a19c Merge branch 'main' into all-contributors/add-justinsteven 2021-12-19 14:05:39 -06:00
epi
a1ab0b92c5 Merge pull request #437 from epi052/all-contributors/add-narkopolo
docs: add narkopolo as a contributor for ideas
2021-12-19 14:02:28 -06:00
allcontributors[bot]
45c2285a65 docs: update .all-contributorsrc [skip ci] 2021-12-19 19:50:15 +00:00
allcontributors[bot]
3edab75966 docs: update README.md [skip ci] 2021-12-19 19:50:14 +00:00
allcontributors[bot]
2d0e64dec6 docs: update .all-contributorsrc [skip ci] 2021-12-19 19:49:48 +00:00
allcontributors[bot]
b8386b7e20 docs: update README.md [skip ci] 2021-12-19 19:49:47 +00:00
epi
46e1d00e41 Merge pull request #430 from epi052/429-add-orig-url-to-json-output
429 add orig url to json output
2021-12-19 13:40:11 -06:00
epi
c0bfca4dbf update dependencies 2021-12-19 13:39:30 -06:00
epi
355b50bdc1 updated menu name in main display 2021-12-19 13:12:05 -06:00
epi
db2822b2cb revised a test 2021-12-19 13:03:44 -06:00
epi
f92116c16d enum variants 2021-12-19 12:58:59 -06:00
epi
1c48f754a2 added some tests 2021-12-19 11:12:51 -06:00
epi
1a26e6a992 removed lint 2021-12-11 20:29:50 -06:00
epi
23653b0cc3 added "add scan" to interactive menu 2021-12-11 20:23:52 -06:00
epi
85c529fd48 updated dependencies 2021-12-07 06:57:51 -06:00
epi
1614780c11 appeased clippy 2021-12-07 06:31:25 -06:00
epi
74121bf9be added original url to json output; updated tests 2021-12-07 06:13:53 -06:00
epi
75810ff697 updated AC badge 2021-10-19 07:47:59 -05:00
epi
e79d51b23d Merge pull request #402 from epi052/all-contributors/add-dsaxton
docs: add dsaxton as a contributor for ideas, code
2021-10-19 07:42:44 -05:00
allcontributors[bot]
4f87aa6ab6 docs: update .all-contributorsrc [skip ci] 2021-10-19 12:42:29 +00:00
allcontributors[bot]
9922999cfb docs: update README.md [skip ci] 2021-10-19 12:42:28 +00:00
epi
a73ad768b6 Merge pull request #401 from epi052/all-contributors/add-cortantief
docs: add cortantief as a contributor for bug, code
2021-10-19 07:40:58 -05:00
allcontributors[bot]
8e22a0881f docs: update .all-contributorsrc [skip ci] 2021-10-19 12:40:38 +00:00
allcontributors[bot]
d1fc7a4969 docs: update README.md [skip ci] 2021-10-19 12:40:37 +00:00
epi
f0252bc375 Merge pull request #360 from epi052/329-dont-scan-enhancements
329 dont scan enhancements
2021-10-15 16:44:11 -05:00
epi
1eca023d6e updated libs 2021-10-15 16:34:35 -05:00
epi
3ae3adf11b merged main 2021-10-15 16:29:11 -05:00
epi
b94edc4e57 Merge pull request #355 from cortantief/main
adding support add_slash and extensions + correcting error on double slashes in the start of a word with extension
2021-10-15 16:25:57 -05:00
epi
8116018b8b reverted the revert 2021-10-15 16:16:13 -05:00
epi
d1d37c135e merged upstream main 2021-10-15 15:46:30 -05:00
epi
1cd1d990de Merge pull request #357 from dsaxton/random-agent
Implement random user agent flag
2021-10-15 15:39:53 -05:00
Daniel Saxton
9c50038a25 Nit 2021-10-14 15:15:32 -05:00
Daniel Saxton
1e3cd3a209 Doc 2021-10-13 20:12:43 -05:00
Daniel Saxton
3536587260 Update banner 2021-10-13 20:11:02 -05:00
Daniel Saxton
19cd5c910a Try a test refactor 2021-10-13 20:08:28 -05:00
Daniel Saxton
b8bfbb09f3 Oops 2021-10-13 16:32:39 -05:00
Daniel Saxton
0a3130934c Ignore 2021-10-13 16:31:48 -05:00
Daniel Saxton
7b3201f2f8 Fix test 2021-10-13 16:14:42 -05:00
Daniel Saxton
521d341e36 Remove unused and update make_request call 2021-10-13 16:03:32 -05:00
epi
3f3b24b26f reverted add-slash change in heuristics 2021-10-13 06:38:26 -05:00
epi
2a4a150598 changed counter type 2021-10-12 20:24:05 -05:00
epi
39b2da9735 changed counter type 2021-10-12 20:22:01 -05:00
epi
87aaa84f1e added user-agent logic 2021-10-12 18:21:05 -05:00
epi
2b7d134ede Merge pull request #399 from eltociear/patch-1
fix typo in parser.rs
2021-10-12 05:55:27 -05:00
Ikko Ashimine
4eebacb077 fix typo in parser.rs
initalize -> initialize
2021-10-12 02:53:29 +09:00
epi
1a6ad39b46 Merge pull request #384 from epi052/all-contributors/add-hunter0x8
docs: add hunter0x8 as a contributor for bug
2021-10-10 14:24:20 -05:00
epi
10b473d920 Merge branch 'main' into all-contributors/add-hunter0x8 2021-10-10 14:24:13 -05:00
epi
1d2dc8bd37 Merge pull request #397 from epi052/all-contributors/add-dnaka91
docs: add dnaka91 as a contributor for infra
2021-10-10 14:21:06 -05:00
allcontributors[bot]
2132ceadd5 docs: update .all-contributorsrc [skip ci] 2021-10-10 19:20:45 +00:00
allcontributors[bot]
15bd50dc24 docs: update README.md [skip ci] 2021-10-10 19:20:44 +00:00
epi
48ca8d510a Merge pull request #396 from epi052/add-contrib-badrequest
updated contributor
2021-10-10 06:07:14 -05:00
epi
9ca48fe877 updated contributor 2021-10-10 06:06:51 -05:00
epi
2f09df921d Merge pull request #395 from epi052/add-contrib-SleepiPanda
updated contributor
2021-10-10 06:03:02 -05:00
epi
93686acb48 updated contributor 2021-10-10 06:02:36 -05:00
epi
294159088c Merge pull request #394 from epi052/add-contributors-hoggard
added henry hoggard
2021-10-10 05:52:28 -05:00
epi
da9bec5a67 added henry hoggard 2021-10-10 05:51:43 -05:00
epi
69cf08bf1f Merge pull request #393 from epi052/all-contributors/add-sicks3c
docs: add sicks3c as a contributor for bug
2021-10-10 05:44:12 -05:00
allcontributors[bot]
035a8c75c0 docs: update .all-contributorsrc [skip ci] 2021-10-10 10:44:04 +00:00
allcontributors[bot]
ac56225405 docs: update README.md [skip ci] 2021-10-10 10:44:03 +00:00
epi
82a7aa458c Merge pull request #392 from epi052/all-contributors/add-BitThr3at
docs: add BitThr3at as a contributor for bug
2021-10-10 05:43:03 -05:00
allcontributors[bot]
c29abe4ec4 docs: update .all-contributorsrc [skip ci] 2021-10-10 10:42:51 +00:00
allcontributors[bot]
bb6146c18f docs: update README.md [skip ci] 2021-10-10 10:42:50 +00:00
epi
882aded16c Merge pull request #391 from epi052/all-contributors/add-moscowchill
docs: add moscowchill as a contributor for bug
2021-10-10 05:41:38 -05:00
allcontributors[bot]
86dc8edd3d docs: update .all-contributorsrc [skip ci] 2021-10-10 10:41:27 +00:00
allcontributors[bot]
0281057944 docs: update README.md [skip ci] 2021-10-10 10:41:26 +00:00
epi
96fa07a5e5 Merge pull request #390 from epi052/all-contributors/add-N0ur5
docs: add N0ur5 as a contributor for ideas
2021-10-10 05:40:27 -05:00
allcontributors[bot]
3ee6641a7d docs: update .all-contributorsrc [skip ci] 2021-10-10 10:40:18 +00:00
allcontributors[bot]
90dd18af2e docs: update README.md [skip ci] 2021-10-10 10:40:17 +00:00
epi
b98ab6d691 Merge pull request #388 from epi052/all-contributors/add-dinosn
docs: add dinosn as a contributor for ideas
2021-10-09 16:31:57 -05:00
allcontributors[bot]
1723847672 docs: update .all-contributorsrc [skip ci] 2021-10-09 21:31:48 +00:00
allcontributors[bot]
125a55f72b docs: update README.md [skip ci] 2021-10-09 21:31:47 +00:00
epi
ba58bd942e Merge pull request #387 from epi052/all-contributors/add-black-A
docs: add black-A as a contributor for ideas
2021-10-09 16:29:49 -05:00
allcontributors[bot]
72fc0b026d docs: update .all-contributorsrc [skip ci] 2021-10-09 21:29:16 +00:00
allcontributors[bot]
5350724e5f docs: update README.md [skip ci] 2021-10-09 21:29:15 +00:00
epi
0b208cd011 Merge pull request #386 from epi052/all-contributors/add-sbrun
docs: add sbrun as a contributor for infra
2021-10-09 16:28:39 -05:00
allcontributors[bot]
82f8f687fd docs: update .all-contributorsrc [skip ci] 2021-10-09 21:27:49 +00:00
allcontributors[bot]
b36c3e0318 docs: update README.md [skip ci] 2021-10-09 21:27:49 +00:00
epi
85473916db Merge pull request #385 from epi052/all-contributors/add-secure-77
docs: add secure-77 as a contributor for bug
2021-10-09 16:27:26 -05:00
allcontributors[bot]
7afb261206 docs: update .all-contributorsrc [skip ci] 2021-10-09 21:27:17 +00:00
allcontributors[bot]
0b2d77605e docs: update README.md [skip ci] 2021-10-09 21:27:16 +00:00
allcontributors[bot]
073291360a docs: update .all-contributorsrc [skip ci] 2021-10-09 21:26:29 +00:00
allcontributors[bot]
98d6fdf536 docs: update README.md [skip ci] 2021-10-09 21:26:28 +00:00
epi
2b6de8e7dc Merge pull request #383 from epi052/all-contributors/add-0xdf
docs: add 0xdf as a contributor for bug
2021-10-09 16:25:39 -05:00
allcontributors[bot]
d0cdf5766b docs: update .all-contributorsrc [skip ci] 2021-10-09 21:25:07 +00:00
allcontributors[bot]
46366291f1 docs: update README.md [skip ci] 2021-10-09 21:25:06 +00:00
epi
b1d33f4f7d updated readme 2021-10-09 16:23:16 -05:00
epi
1e4d3802f8 updated readme 2021-10-09 16:21:58 -05:00
epi
a2bc9ecb49 Merge pull request #382 from epi052/all-contributors/add-Tib3rius
docs: add Tib3rius as a contributor for bug
2021-10-09 16:19:23 -05:00
allcontributors[bot]
4b0b26da02 docs: update .all-contributorsrc [skip ci] 2021-10-09 21:18:40 +00:00
allcontributors[bot]
fe5612ce71 docs: update README.md [skip ci] 2021-10-09 21:18:39 +00:00
epi
ea51805552 fixed badge 2021-10-07 07:20:34 -05:00
epi
2ff4dcde8a Merge pull request #376 from epi052/all-contributors/add-wtwver
docs: add wtwver as a contributor for infra
2021-10-07 07:18:28 -05:00
allcontributors[bot]
9f93c2381a docs: update .all-contributorsrc [skip ci] 2021-10-07 12:18:20 +00:00
allcontributors[bot]
ece220263b docs: update README.md [skip ci] 2021-10-07 12:18:19 +00:00
epi
06312f1f09 Merge pull request #375 from epi052/all-contributors/add-EONRaider
docs: add EONRaider as a contributor for infra
2021-10-07 07:18:07 -05:00
allcontributors[bot]
14023f7e05 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:18:00 +00:00
allcontributors[bot]
cc18dfc7d4 docs: update README.md [skip ci] 2021-10-07 12:17:59 +00:00
epi
99bb0200e5 Merge pull request #374 from epi052/all-contributors/add-craig
docs: add craig as a contributor for infra
2021-10-07 07:17:43 -05:00
allcontributors[bot]
542db19180 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:17:35 +00:00
allcontributors[bot]
d9718d0d6a docs: update README.md [skip ci] 2021-10-07 12:17:34 +00:00
epi
491821f0b2 Merge pull request #373 from epi052/all-contributors/add-noraj
docs: add noraj as a contributor for infra, doc
2021-10-07 07:17:15 -05:00
allcontributors[bot]
6d5235ab0a docs: update .all-contributorsrc [skip ci] 2021-10-07 12:17:08 +00:00
allcontributors[bot]
74b23141e0 docs: update README.md [skip ci] 2021-10-07 12:17:07 +00:00
epi
8f4ffc8e22 Merge pull request #372 from epi052/all-contributors/add-bpsizemore
docs: add bpsizemore as a contributor for code
2021-10-07 07:16:53 -05:00
allcontributors[bot]
7d314c7bac docs: update .all-contributorsrc [skip ci] 2021-10-07 12:16:47 +00:00
allcontributors[bot]
b6c41ae2d3 docs: update README.md [skip ci] 2021-10-07 12:16:46 +00:00
epi
b80c58a073 Merge pull request #371 from epi052/all-contributors/add-bsysop
docs: add bsysop as a contributor for doc
2021-10-07 07:16:36 -05:00
allcontributors[bot]
a035f0eeaf docs: update .all-contributorsrc [skip ci] 2021-10-07 12:16:28 +00:00
allcontributors[bot]
672d17ec27 docs: update README.md [skip ci] 2021-10-07 12:16:28 +00:00
epi
f7d4a3e7b4 Merge pull request #370 from epi052/all-contributors/add-tomtastic
docs: add tomtastic as a contributor for doc
2021-10-07 07:16:13 -05:00
allcontributors[bot]
00b0c3c62d docs: update .all-contributorsrc [skip ci] 2021-10-07 12:16:05 +00:00
allcontributors[bot]
f260a981ca docs: update README.md [skip ci] 2021-10-07 12:16:04 +00:00
epi
f254fe172c Merge pull request #369 from epi052/all-contributors/add-n-thumann
docs: add n-thumann as a contributor for code, doc
2021-10-07 07:15:47 -05:00
allcontributors[bot]
cc1dc94459 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:15:35 +00:00
allcontributors[bot]
e39f6cf16d docs: update README.md [skip ci] 2021-10-07 12:15:34 +00:00
epi
2a406960c4 Merge pull request #368 from epi052/all-contributors/add-mzpqnxow
docs: add mzpqnxow as a contributor for ideas, doc
2021-10-07 07:15:16 -05:00
allcontributors[bot]
c65e2f02b3 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:15:06 +00:00
allcontributors[bot]
831ae011e2 docs: update README.md [skip ci] 2021-10-07 12:15:06 +00:00
epi
32a4db4b46 Merge pull request #367 from epi052/all-contributors/add-evanrichter
docs: add evanrichter as a contributor for code, doc
2021-10-07 07:14:51 -05:00
allcontributors[bot]
a439be0305 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:14:42 +00:00
allcontributors[bot]
36994d208d docs: update README.md [skip ci] 2021-10-07 12:14:41 +00:00
epi
b72c42e1d1 Merge pull request #366 from epi052/all-contributors/add-spikecodes
docs: add spikecodes as a contributor for infra, doc
2021-10-07 07:14:26 -05:00
allcontributors[bot]
b508dcce8d docs: update .all-contributorsrc [skip ci] 2021-10-07 12:14:02 +00:00
allcontributors[bot]
c33d397360 docs: update README.md [skip ci] 2021-10-07 12:14:01 +00:00
epi
449f6bda32 Merge pull request #365 from epi052/all-contributors/add-TGotwig
docs: add TGotwig as a contributor for infra, doc
2021-10-07 07:13:43 -05:00
allcontributors[bot]
092515cf3a docs: update .all-contributorsrc [skip ci] 2021-10-07 12:13:30 +00:00
allcontributors[bot]
e5fe9bb360 docs: update README.md [skip ci] 2021-10-07 12:13:29 +00:00
epi
2e42e3efac Merge pull request #364 from epi052/all-contributors/add-jsav0
docs: add jsav0 as a contributor for infra, doc
2021-10-07 07:13:10 -05:00
allcontributors[bot]
0d1cb25b69 docs: update .all-contributorsrc [skip ci] 2021-10-07 12:12:29 +00:00
allcontributors[bot]
653117bda6 docs: update README.md [skip ci] 2021-10-07 12:12:28 +00:00
epi
5c32fab4cb fixed badge 2021-10-07 06:54:26 -05:00
epi
904c70281a Merge pull request #363 from epi052/all-contributors/add-joohoi
docs: add joohoi as a contributor for doc
2021-10-07 06:48:11 -05:00
allcontributors[bot]
2d5825556f docs: create .all-contributorsrc [skip ci] 2021-10-07 11:45:27 +00:00
allcontributors[bot]
ef7fc7a8a3 docs: update README.md [skip ci] 2021-10-07 11:45:26 +00:00
epi
e48a462471 added confirmed tag to stale-bot 2021-10-07 06:40:40 -05:00
epi
f6047e9819 updated multiple items from new-feature checklist 2021-10-04 06:34:04 -05:00
epi
534cbe8fe1 added a few tests 2021-10-03 14:17:28 -05:00
epi
adb5cd75cc fixed existing tests 2021-10-03 12:16:04 -05:00
epi
3469e2c306 fixed existing tests 2021-10-03 11:32:43 -05:00
epi
6de087ae79 Update cicd-to-dockerhub.yml 2021-10-03 11:26:48 -05:00
epi
07a9fdee41 moved parsing of urls/regexes to config
this means that each url / regex passed to --dont-scan is only parsed once
2021-10-03 09:04:31 -05:00
epi
7b9767107f added regex specific banner title to --dont-scan 2021-10-01 07:16:22 -05:00
epi
5388d40c03 added regex support to --dont-scan 2021-10-01 06:54:30 -05:00
epi
28769b5028 broke absolute path denial into its own function; tests pass 2021-10-01 06:23:44 -05:00
epi
28fa90b093 Merge pull request #359 from wtwver/main
Fix docker build error
2021-09-30 06:12:48 -05:00
wtwver
0b16f368a4 . 2021-09-30 17:25:24 +08:00
Daniel Saxton
c5e59b70f7 Hard code 2021-09-28 15:44:11 -05:00
Daniel Saxton
6756a1da74 Merge remote-tracking branch 'upstream/main' into random-agent 2021-09-28 15:23:29 -05:00
epi
d14de76f9a added redirect 2021-09-27 06:29:40 -05:00
Daniel Saxton
ef3cc05ee3 Merge remote-tracking branch 'upstream/main' into random-agent 2021-09-26 10:19:00 -05:00
Daniel Saxton
efd706cb9b cargo fmt 2021-09-26 10:09:52 -05:00
epi
63baa3ec57 removed github pages workflow 2021-09-26 06:52:46 -05:00
epi
51defffd3b cleaned up docs stuff 2021-09-26 06:52:16 -05:00
epi
439afd2e2a updated to reflect new documentation site 2021-09-26 06:50:56 -05:00
epi
10ae4ee524 updated url for submodule 2021-09-25 18:40:53 -05:00
epi
4af448d7b1 updated url for submodule 2021-09-25 18:35:21 -05:00
epi
adc536bf4b added nojekyll 2021-09-25 18:28:24 -05:00
epi
fde52e95e1 added npm install 2021-09-25 18:24:37 -05:00
epi
e4ae5759ff added node 2021-09-25 17:33:07 -05:00
epi
89eda0e62b added node 2021-09-25 17:31:14 -05:00
epi
00330b053f postcss install 2021-09-25 17:28:15 -05:00
epi
0df1d34ee1 recursive checkout 2021-09-25 16:53:15 -05:00
epi
eddab0de13 cd into docs dir for ci 2021-09-25 16:51:19 -05:00
epi
f9335a7867 removed jekyll theme 2021-09-25 16:48:46 -05:00
epi
bc9779be2a Merge branch 'main' of github.com:epi052/feroxbuster 2021-09-25 16:48:01 -05:00
epi
c4b6fed6ef added new documentation site 2021-09-25 16:47:52 -05:00
epi
3818276c7e Set theme jekyll-theme-midnight 2021-09-24 06:52:17 -05:00
epi
6596759132 Set theme jekyll-theme-dinky 2021-09-24 06:48:23 -05:00
epi
5235208aa8 dynamically generate user agents at build time for inclusion into lib.rs 2021-09-23 21:22:52 -05:00
Daniel Saxton
bce55e77f3 Add arg 2021-09-22 19:41:52 -05:00
Daniel Saxton
8d11bb1800 README 2021-09-22 19:21:07 -05:00
Daniel Saxton
40fccb9761 Bump minor version 2021-09-22 19:14:07 -05:00
Daniel Saxton
28f63aae94 Test 2021-09-22 17:09:11 -05:00
Daniel Saxton
1eaf6fc232 Merge remote-tracking branch 'upstream/main' into random-agent 2021-09-22 17:01:19 -05:00
epi
dd1c824d98 updated shell completions that care about mutual exclusion 2021-09-22 07:30:38 -05:00
epi
42c06c87cc bumped version to 2.3.4 2021-09-22 07:29:14 -05:00
epi
d36379ba1b nitpickery stuff; removed the test i added cuz it's a duplicate 2021-09-22 07:28:57 -05:00
epi
83ba49a486 added test for formatted_urls when both extension and slash are set 2021-09-21 20:49:31 -05:00
Daniel Saxton
0b75e1a548 Implement random user agent flag 2021-09-21 13:35:10 -05:00
epi
867e297284 Set theme jekyll-theme-midnight 2021-09-21 07:25:12 -05:00
epi
63bd89ddc3 Set theme jekyll-theme-leap-day 2021-09-21 07:14:31 -05:00
epi
9f39ee3491 Set theme jekyll-theme-cayman 2021-09-21 07:13:00 -05:00
epi
eabf97b776 Set theme jekyll-theme-merlot 2021-09-21 07:05:39 -05:00
epi
3c3b976a71 Set theme jekyll-theme-hacker 2021-09-21 07:02:59 -05:00
epi
81709b5009 Set theme jekyll-theme-midnight 2021-09-21 07:01:26 -05:00
epi
ffa0c6b390 Set theme jekyll-theme-midnight 2021-09-21 07:00:42 -05:00
epi
141fe74129 removed nojekyll 2021-09-21 06:57:32 -05:00
cortantief
d4a69fa2ec clippy plus cargo fmt 2021-09-20 10:58:53 +02:00
cortantief
9a3754a31d Cargo fmt formating 2021-09-20 10:56:19 +02:00
cortantief
c89453c5c3 Adding support for slash and extensions at the same time.
Support for the extensions and slash where added by using a special condition inside the format function allowing us to treat slash as an extension with a particular format.
We had to modify other place to propagate the changes like in make_wildcard_request for example.
Ofcourse we had to delete the limitation inside the arg parser.
A test was added to ensure the validity of the implementation.
2021-09-20 10:01:09 +02:00
cortantief
be57e620f0 Correcting error when extensions are used with word that start with two slashes
The cause of that error was the fact that the check was made inside a chain of if-else-if conditions, since the extension are tested before the slashes
resulting to not check correctly the word.
2021-09-20 07:02:37 +02:00
epi
45efaa7388 new theme options 2021-09-17 06:29:32 -05:00
epi
15cb5e1619 trying dark theme 2021-09-17 06:20:05 -05:00
epi
fc500a5cd5 flatdoc resolve fix 2021-09-17 06:12:03 -05:00
epi
c84612751c github buttons cahnged to https 2021-09-17 06:09:45 -05:00
epi
c8ecbd4ed6 jquery cahnged to https 2021-09-17 06:08:16 -05:00
epi
fbf79ab7c1 pointing docs to readme 2021-09-17 06:06:55 -05:00
epi
a446192b9a pointing docs to readme 2021-09-17 06:06:22 -05:00
epi
26565be18d removed config file 2021-09-17 06:05:18 -05:00
epi
7c4bc213a3 fixed theme bug 2021-09-17 06:04:52 -05:00
epi
a227dcf726 fixed theme bug 2021-09-17 06:01:13 -05:00
epi
b70c92b1e6 added github pages content 2021-09-17 05:46:49 -05:00
epi
033a57a9e9 Merge branch 'main' of github.com:epi052/feroxbuster 2021-09-16 18:58:14 -05:00
epi
dac74ae040 updated dependencies 2021-09-16 18:58:07 -05:00
epi
d4abb84214 updated readme 2021-09-04 17:04:27 -05:00
epi
5201c300e9 update branch name 2021-09-04 16:41:29 -05:00
epi
ec0e5299ed Merge pull request #345 from EONRaider/cicd-to-dockerhub
Optimize Dockerfile and implement CI/CD to DockerHub
2021-09-04 15:55:17 -05:00
EONRaider
242c35c89f Create cicd-to-dockerhub.yml 2021-09-02 08:29:38 -03:00
EONRaider
f717ee534e Optimize Dockerfile 2021-08-28 14:01:26 -03:00
EONRaider
6b66f39122 Update Dockerfile 2021-08-27 18:08:06 -03:00
epi
4b3e9badbb Merge pull request #336 from epi052/335-fix-wildcard-filter-when-response-is-zero
335 fix wildcard filter when response is zero
2021-08-20 20:44:36 -05:00
epi
c680be558a added test for 0-length wildcard response 2021-08-20 20:09:48 -05:00
epi
8cee7ce247 bumped version; updated dependencies 2021-08-20 19:52:06 -05:00
epi
580aa19681 fixed erroneous reporting of total urls expected 2021-08-20 19:46:46 -05:00
epi
cd220fe471 fixed wildcard filtering when wildcard response len is 0 2021-08-20 19:25:34 -05:00
epi
15b4fd04e5 updated status code defaults to include 500 2021-08-02 19:28:09 -05:00
epi
fceba0b68b updated deps 2021-08-02 19:27:55 -05:00
epi
eef4c9b5ed added 500 to status code defaults 2021-08-02 19:23:20 -05:00
epi
24da4e017c adjusted rlimit imports to ignore windows targets 2021-08-02 05:41:50 -05:00
epi
f3cedf01a5 Merge pull request #321 from epi052/319-separate-log-files-for-parallel
Log to separate files when using --parallel
2021-08-02 05:21:16 -05:00
epi
08ee32595f updated documentation for parallel logging change 2021-08-01 21:03:17 -05:00
epi
4c4d1a2a61 updated documentation for parallel logging change 2021-08-01 19:59:28 -05:00
epi
64b54a6308 fixed up a few todo items 2021-08-01 19:57:28 -05:00
epi
e27b3ee8da added coverage for stdin slugifying 2021-08-01 15:17:59 -05:00
epi
129725cedd added test for --parallel with -o 2021-08-01 14:32:14 -05:00
epi
17886da3df handle dir/outfile case to -o 2021-08-01 14:31:48 -05:00
epi
c8a46b7e5a added prefix param to slugify_filename 2021-08-01 09:23:42 -05:00
epi
f97d103fc6 unique file logging with parallel works 2021-08-01 08:07:12 -05:00
epi
aa2fecc5c1 bumped version to 2.3.2 2021-07-31 14:49:15 -05:00
epi
6f2244e1ff put url slug logic into its own function 2021-07-31 14:48:35 -05:00
epi
a1dc90ba06 updated rlimit lib 2021-07-30 20:01:16 -05:00
epi
32f55ddfb7 Merge branch 'main' of github.com:epi052/feroxbuster 2021-07-30 16:14:32 -05:00
epi
9a65c7f1f5 fixed up code for new clippy checks 2021-07-30 16:14:25 -05:00
epi
0f6bc1c160 updated deps 2021-07-30 07:42:25 -05:00
epi
abef7a236b Update README.md 2021-07-12 16:17:08 -05:00
epi
0cff62dbe2 return 0 when -h/--help is used 2021-07-05 06:33:40 -05:00
epi
a590188e44 Merge pull request #293 from epi052/286-url-blacklist
add --dont-scan option for denying urls
2021-06-18 14:40:16 -07:00
epi
dc3aa11966 added tracing to new extractor fns 2021-06-18 16:32:04 -05:00
epi
57714d243a fixed caching for extraction; much better performance now 2021-06-18 16:18:42 -05:00
epi
1d34a5e99f updated readme 2021-06-18 11:05:57 -05:00
epi
9ab3e5515e added short-circuit to deny check 2021-06-18 06:48:41 -05:00
epi
3abef25c8f added integration tests 2021-06-17 20:34:21 -05:00
epi
454f3a4302 satisfied newest version of clippy 2021-06-17 16:13:18 -05:00
epi
acb9c19f4d added should_deny_url function and unit tests 2021-06-17 14:44:33 -05:00
epi
98f06951bd added banner test 2021-06-15 17:31:24 -05:00
epi
c9e1a7adbe added deny list to banner 2021-06-15 16:59:36 -05:00
epi
c57cf82fce added --dont-scan to options parser 2021-06-15 16:45:23 -05:00
epi
a3bcfaf95c added url_denylist to config 2021-06-15 14:31:01 -05:00
epi
c99afec740 bumped version to 2.3.0 2021-06-15 13:52:45 -05:00
epi
fa9fd65c2f bumped version to 2.3.0 2021-06-15 11:47:57 -05:00
91 changed files with 9493 additions and 3124 deletions

432
.all-contributorsrc Normal file
View File

@@ -0,0 +1,432 @@
{
"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"
]
},
{
"login": "ThisLimn0",
"name": "Limn0",
"avatar_url": "https://avatars.githubusercontent.com/u/67125885?v=4",
"profile": "https://github.com/ThisLimn0",
"contributions": [
"bug"
]
},
{
"login": "0xdf223",
"name": "0xdf",
"avatar_url": "https://avatars.githubusercontent.com/u/76954092?v=4",
"profile": "https://github.com/0xdf223",
"contributions": [
"bug",
"ideas"
]
},
{
"login": "Flangyver",
"name": "Flangyver",
"avatar_url": "https://avatars.githubusercontent.com/u/59575870?v=4",
"profile": "https://github.com/Flangyver",
"contributions": [
"ideas"
]
}
],
"contributorsPerLine": 7,
"projectName": "feroxbuster",
"projectOwner": "epi052",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true
}

View File

@@ -16,7 +16,11 @@ Long form explanations of most of the items below can be found in the [CONTRIBUT
## 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

@@ -73,18 +73,22 @@ jobs:
name: ${{ matrix.name }}.tar.gz
path: ${{ matrix.name }}.tar.gz
build-deb:
needs: [build-nix]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Deb Build
uses: ebbflow-io/cargo-deb-amd64-ubuntu@1.0
- name: Upload Deb Artifact
uses: actions/upload-artifact@v2
with:
name: feroxbuster_amd64.deb
path: ./target/x86_64-unknown-linux-musl/debian/*
# build-deb:
# needs: [build-nix]
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@master
# - name: Install cargo-deb
# run: cargo install -f cargo-deb
# - name: Install musl toolchain
# run: rustup target add x86_64-unknown-linux-musl
# - name: Deb Build
# run: cargo deb --target=x86_64-unknown-linux-musl
# - name: Upload Deb Artifact
# uses: actions/upload-artifact@v2
# with:
# name: feroxbuster_amd64.deb
# path: ./target/x86_64-unknown-linux-musl/debian/*
build-macos:
env:

View File

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

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

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

View File

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

5
.gitignore vendored
View File

@@ -9,6 +9,9 @@ target/
# jetbrains metadata folder
.idea/
# vscode metadata folder
.vscode/
# personal feroxbuster config for testing
ferox-config.toml
@@ -23,7 +26,7 @@ lcov_cobertura.py
.dockerignore
# state file created during tests
ferox-http*
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.

1217
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,20 @@
[package]
name = "feroxbuster"
version = "2.2.5"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
version = "2.7.0"
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."
categories = ["command-line-utilities"]
keywords = ["pentest", "enumeration", "url-bruteforce", "content-discovery", "web"]
keywords = [
"pentest",
"enumeration",
"url-bruteforce",
"content-discovery",
"web",
]
exclude = [".github/*", "img/*", "check-coverage.sh"]
build = "build.rs"
@@ -16,41 +22,46 @@ build = "build.rs"
maintenance = { status = "actively-developed" }
[build-dependencies]
clap = "2.33"
regex = "1"
lazy_static = "1.4"
dirs = "3.0"
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.14"}
tokio = { version = "1.6", features = ["full"] }
tokio-util = {version = "0.6.6", features = ["codec"]}
log = "0.4"
env_logger = "0.8"
reqwest = { version = "0.11", features = ["socks"] }
clap = "2.33"
lazy_static = "1.4"
toml = "0.5"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0.64"
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.20"
rlimit = "0.5.4"
ctrlc = "3.1.9"
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"
leaky-bucket = "0.10.0"
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.8"
assert_cmd = "1.0"
predicates = "1.0.8"
tempfile = "3.3.0"
httpmock = "0.6.6"
assert_cmd = "2.0.4"
predicates = "2.1.1"
[profile.release]
lto = true
@@ -62,9 +73,29 @@ section = "utility"
license-file = ["LICENSE", "4"]
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"],
[
"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"]

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

1114
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
use std::fs::{copy, create_dir_all, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
extern crate clap;
extern crate dirs;
use clap::Shell;
use clap_complete::{generate_to, shells};
include!("src/parser.rs");
@@ -18,11 +16,11 @@ fn main() {
let mut app = initialize();
let shells: [Shell; 4] = [Shell::Bash, Shell::Fish, Shell::Zsh, Shell::PowerShell];
for shell in &shells {
app.gen_completions("feroxbuster", *shell, outdir);
}
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();
// 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

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

@@ -27,15 +27,25 @@
# 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
# dont_filter = true
# extract_links = true
# depth = 1
# force_recursion = true
# filter_size = [5174]
# filter_regex = ["^ignore me$"]
# filter_similar = ["https://somesite.com/soft404"]

View File

@@ -15,87 +15,106 @@ _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)]' \
'--parallel=[Run parallel feroxbuster instances (one child process per url passed via stdin)]' \
'(--auto-tune)--rate-limit=[Limit number of requests per second (per directory) (default: 0, i.e. no limit)]' \
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]' \
'-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.7.0)]:USER_AGENT: ' \
'--user-agent=[Sets the User-Agent (default: feroxbuster/2.7.0)]: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: ' \
'(-s --status-codes)*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
'(-s --status-codes)*--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]' \
'(-n --no-recursion)--force-recursion[Force recursion attempts on all '\''found'\'' endpoints (still respects recursion depth)]' \
'-e[Extract links from response body (html, javascript, etc...); make new requests based on findings]' \
'--extract-links[Extract links from response body (html, javascript, etc...); make new requests based on findings]' \
'(--auto-bail)--auto-tune[Automatically lower scan rate when an excessive amount of errors are encountered]' \
'--auto-bail[Automatically stop scanning when an excessive amount of errors are encountered]' \
'-D[Don'\''t auto-filter wildcard responses]' \
'--dont-filter[Don'\''t auto-filter wildcard responses]' \
'-E[Automatically discover extensions and add them to --extensions (unless they'\''re in --dont-collect)]' \
'--collect-extensions[Automatically discover extensions and add them to --extensions (unless they'\''re in --dont-collect)]' \
'-B[Automatically request likely backup extensions for "found" urls]' \
'--collect-backups[Automatically request likely backup extensions for "found" urls]' \
'-g[Automatically discover important words from within responses and add them to the wordlist]' \
'--collect-words[Automatically discover important words from within responses and add them to the wordlist]' \
'(--silent)*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
'(--silent)*--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)]' \
'(--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]' \
'--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]' \
'-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]' \
'--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.7.0)')
[CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.7.0)')
[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,36 +55,65 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
[CompletionResult]::new('-C', 'C', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
[CompletionResult]::new('--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('-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('--force-recursion', 'force-recursion', [CompletionResultType]::ParameterName, 'Force recursion attempts on all ''found'' endpoints (still respects recursion depth)')
[CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings')
[CompletionResult]::new('--extract-links', 'extract-links', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings')
[CompletionResult]::new('--auto-tune', 'auto-tune', [CompletionResultType]::ParameterName, 'Automatically lower scan rate when an excessive amount of errors are encountered')
[CompletionResult]::new('--auto-bail', 'auto-bail', [CompletionResultType]::ParameterName, 'Automatically stop scanning when an excessive amount of errors are encountered')
[CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
[CompletionResult]::new('--dont-filter', 'dont-filter', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
[CompletionResult]::new('-E', 'E', [CompletionResultType]::ParameterName, 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)')
[CompletionResult]::new('--collect-extensions', 'collect-extensions', [CompletionResultType]::ParameterName, 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)')
[CompletionResult]::new('-B', 'B', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls')
[CompletionResult]::new('--collect-backups', 'collect-backups', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls')
[CompletionResult]::new('-g', 'g', [CompletionResultType]::ParameterName, 'Automatically discover important words from within responses and add them to the wordlist')
[CompletionResult]::new('--collect-words', 'collect-words', [CompletionResultType]::ParameterName, 'Automatically discover important words from within responses and add them to the wordlist')
[CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
[CompletionResult]::new('--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('--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('--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('-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('--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 --silent --quiet --auto-tune --auto-bail --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 --parallel --rate-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 --force-recursion --extract-links --scan-limit --parallel --rate-limit --time-limit --wordlist --auto-tune --auto-bail --dont-filter --collect-extensions --collect-backups --collect-words --dont-collect --verbosity --silent --quiet --json --output --debug-log --no-state"
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,43 @@ _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
;;
@@ -211,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=()
;;
@@ -218,7 +248,6 @@ _feroxbuster() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
esac
}

View File

@@ -0,0 +1,117 @@
use builtin;
use str;
set edit:completion:arg-completer[feroxbuster] = {|@words|
fn spaces {|n|
builtin:repeat $n ' ' | str:join ''
}
fn cand {|text desc|
edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc
}
var command = 'feroxbuster'
for word $words[1..-1] {
if (str:has-prefix $word '-') {
break
}
set command = $command';'$word
}
var completions = [
&'feroxbuster'= {
cand -u 'The target URL (required, unless [--stdin || --resume-from] used)'
cand --url 'The target URL (required, unless [--stdin || --resume-from] used)'
cand --resume-from 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)'
cand -p 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)'
cand --proxy 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)'
cand -P 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
cand --replay-proxy 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
cand -R 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
cand --replay-codes 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
cand -a 'Sets the User-Agent (default: feroxbuster/2.7.0)'
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.7.0)'
cand -x 'File extension(s) to search for (ex: -x php -x pdf js)'
cand --extensions 'File extension(s) to search for (ex: -x php -x pdf js)'
cand -m 'Which HTTP request method(s) should be sent (default: GET)'
cand --methods 'Which HTTP request method(s) should be sent (default: GET)'
cand --data 'Request''s Body; can read data from a file if input starts with an @ (ex: @post.bin)'
cand -H 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')'
cand --headers 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')'
cand -b 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)'
cand --cookies 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)'
cand -Q 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)'
cand --query 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)'
cand --dont-scan 'URL(s) or Regex Pattern(s) to exclude from recursion/scans'
cand -S 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
cand --filter-size 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
cand -X 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')'
cand --filter-regex 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')'
cand -W 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
cand --filter-words 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
cand -N 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
cand --filter-lines 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
cand -C 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
cand --filter-status 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
cand --filter-similar-to 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)'
cand -s 'Status Codes to include (allow list) (default: 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 --force-recursion 'Force recursion attempts on all ''found'' endpoints (still respects recursion depth)'
cand -e 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
cand --extract-links 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
cand --auto-tune 'Automatically lower scan rate when an excessive amount of errors are encountered'
cand --auto-bail 'Automatically stop scanning when an excessive amount of errors are encountered'
cand -D 'Don''t auto-filter wildcard responses'
cand --dont-filter 'Don''t auto-filter wildcard responses'
cand -E 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)'
cand --collect-extensions 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)'
cand -B 'Automatically request likely backup extensions for "found" urls'
cand --collect-backups 'Automatically request likely backup extensions for "found" urls'
cand -g 'Automatically discover important words from within responses and add them to the wordlist'
cand --collect-words 'Automatically discover important words from within responses and add them to the wordlist'
cand -v 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)'
cand --verbosity 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)'
cand --silent 'Only print URLs + turn off logging (good for piping a list of urls to other commands)'
cand -q 'Hide progress bars and banner (good for tmux windows w/ notifications)'
cand --quiet 'Hide progress bars and banner (good for tmux windows w/ notifications)'
cand --json 'Emit JSON logs to --output and --debug-log instead of normal text'
cand --no-state 'Disable state output file (*.state)'
}
]
$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$\')'
@@ -31,6 +35,7 @@ complete -c feroxbuster -n "__fish_use_subcommand" -l auto-tune -d 'Automaticall
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

@@ -3,7 +3,7 @@ use crate::{
config::Configuration,
event_handlers::Handles,
utils::{logged_request, status_colorizer},
VERSION,
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, VERSION,
};
use anyhow::{bail, Result};
use console::{style, Emoji};
@@ -50,6 +50,9 @@ pub struct Banner {
/// represents Configuration.user_agent
user_agent: BannerEntry,
/// represents Configuration.random_agent
random_agent: BannerEntry,
/// represents Configuration.config
config: BannerEntry,
@@ -95,6 +98,12 @@ pub struct Banner {
/// represents Configuration.extensions
extensions: BannerEntry,
/// represents Configuration.methods
methods: BannerEntry,
/// represents Configuration.data
data: BannerEntry,
/// represents Configuration.insecure
insecure: BannerEntry,
@@ -134,11 +143,29 @@ pub struct Banner {
/// 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,
/// represents Configuration.collect_words
force_recursion: BannerEntry,
}
/// implementation of Banner
@@ -146,6 +173,7 @@ 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();
@@ -160,6 +188,22 @@ impl Banner {
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()))
@@ -259,6 +303,8 @@ impl Banner {
&config.scan_limit.to_string(),
);
let force_recursion =
BannerEntry::new("🤘", "Force Recursion", &config.force_recursion.to_string());
let replay_proxy = BannerEntry::new("🎥", "Replay Proxy", &config.replay_proxy);
let auto_tune = BannerEntry::new("🎶", "Auto Tune", &config.auto_tune.to_string());
let auto_bail = BannerEntry::new("🪣", "Auto Bail", &config.auto_bail.to_string());
@@ -268,6 +314,7 @@ impl Banner {
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());
@@ -278,6 +325,38 @@ impl Banner {
"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 =
@@ -287,6 +366,16 @@ impl Banner {
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,
@@ -296,6 +385,7 @@ impl Banner {
filter_status,
timeout,
user_agent,
random_agent,
auto_bail,
auto_tune,
proxy,
@@ -314,6 +404,8 @@ impl Banner {
output,
debug_log,
extensions,
methods,
data,
insecure,
dont_filter,
redirects,
@@ -322,7 +414,13 @@ impl Banner {
no_recursion,
rate_limit,
scan_limit,
force_recursion,
time_limit,
url_denylist,
collect_extensions,
collect_backups,
collect_words,
dont_collect,
config: cfg,
version: VERSION.to_string(),
update_status: UpdateStatus::Unknown,
@@ -354,7 +452,7 @@ by Ben "epi" Risher {} ver: {}"#,
let instructions = format!(
" 🏁 Press [{}] to use the {}",
style("ENTER").yellow(),
style("Scan Cancel Menu").bright().yellow(),
style("Scan Management Menu").bright().yellow(),
);
format!("{}\n{}\n{}", bottom, instructions, addl_section)
@@ -369,7 +467,7 @@ by Ben "epi" Risher {} ver: {}"#,
let api_url = Url::parse(url)?;
let result = logged_request(&api_url, handles.clone()).await?;
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)?;
@@ -413,18 +511,28 @@ by Ben "epi" Risher {} ver: {}"#,
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
if config.filter_status.is_empty() {
// -C and -s are mutually exclusive, and -s meaning changes when -C is used
// so only print one or the other
writeln!(&mut writer, "{}", self.status_codes)?;
} else {
writeln!(&mut writer, "{}", self.filter_status)?;
}
writeln!(&mut writer, "{}", self.timeout)?;
writeln!(&mut writer, "{}", self.user_agent)?;
if config.random_agent {
writeln!(&mut writer, "{}", self.random_agent)?;
} else {
writeln!(&mut writer, "{}", self.user_agent)?;
}
// followed by the maybe printed or variably displayed values
if !config.config.is_empty() {
@@ -490,6 +598,28 @@ by Ben "epi" Risher {} ver: {}"#,
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)?;
}
@@ -519,6 +649,10 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(&mut writer, "{}", self.no_recursion)?;
if config.force_recursion {
writeln!(&mut writer, "{}", self.force_recursion)?;
}
if config.scan_limit > 0 {
writeln!(&mut writer, "{}", self.scan_limit)?;
}

View File

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

View File

@@ -1,6 +1,6 @@
use super::utils::{
depth, report_and_exit, save_state, serialized_type, status_codes, threads, timeout,
user_agent, wordlist, OutputLevel, RequesterPolicy,
depth, ignored_extensions, methods, report_and_exit, save_state, serialized_type, status_codes,
threads, timeout, user_agent, wordlist, OutputLevel, RequesterPolicy,
};
use crate::config::determine_output_level;
use crate::config::utils::determine_requester_policy;
@@ -9,8 +9,9 @@ use crate::{
DEFAULT_CONFIG_NAME,
};
use anyhow::{anyhow, Context, Result};
use clap::{value_t, ArgMatches};
use reqwest::{Client, StatusCode};
use clap::ArgMatches;
use regex::Regex;
use reqwest::{Client, Method, StatusCode, Url};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
@@ -21,17 +22,15 @@ use std::{
/// macro helper to abstract away repetitive configuration updates
macro_rules! update_config_if_present {
($c:expr, $m:ident, $v:expr, $t:ty) => {
match value_t!($m, $v, $t) {
Ok(value) => *$c = value, // Update value
Err(clap::Error {
kind: clap::ErrorKind::ArgumentNotFound,
message: _,
info: _,
}) => {
// Do nothing if argument not found
($conf_val:expr, $matches:ident, $arg_name:expr) => {
match $matches.value_of_t($arg_name) {
Ok(value) => *$conf_val = value, // Update value
Err(err) => {
if !matches!(err.kind(), clap::ErrorKind::ArgumentNotFound) {
// Do nothing if argument not found
err.exit() // Exit with error on any other parse error
}
}
Err(e) => e.exit(), // Exit with error on parse error
}
};
}
@@ -154,6 +153,10 @@ pub struct Configuration {
#[serde(default = "user_agent")]
pub user_agent: String,
/// Use random User-Agent
#[serde(default)]
pub random_agent: bool,
/// Follow redirects
#[serde(default)]
pub redirects: bool,
@@ -166,6 +169,14 @@ pub struct Configuration {
#[serde(default)]
pub extensions: Vec<String>,
/// HTTP requests methods(s) to search for
#[serde(default = "methods")]
pub methods: Vec<String>,
/// HTTP Body data to send during request
#[serde(default)]
pub data: Vec<u8>,
/// HTTP headers to be used in each request
#[serde(default)]
pub headers: HashMap<String, String>,
@@ -235,8 +246,6 @@ pub struct Configuration {
pub resume_from: String,
/// Whether or not a scan's current state should be saved when user presses Ctrl+C
///
/// Not configurable from CLI; can only be set from a config file
#[serde(default = "save_state")]
pub save_state: bool,
@@ -248,6 +257,34 @@ pub struct Configuration {
/// Filter out response bodies that meet a certain threshold of similarity
#[serde(default)]
pub filter_similar: Vec<String>,
/// URLs that should never be scanned/recursed into
#[serde(default)]
pub url_denylist: Vec<Url>,
/// URLs that should never be scanned/recursed into based on a regular expression
#[serde(with = "serde_regex", default)]
pub regex_denylist: Vec<Regex>,
/// Automatically discover extensions and add them to --extensions (unless they're in --dont-collect)
#[serde(default)]
pub collect_extensions: bool,
/// don't collect any of these extensions when --collect-extensions is used
#[serde(default = "ignored_extensions")]
pub dont_collect: Vec<String>,
/// Automatically request likely backup extensions on "found" urls
#[serde(default)]
pub collect_backups: bool,
/// Automatically discover important words from within responses and add them to the wordlist
#[serde(default)]
pub collect_words: bool,
/// override recursion logic to always attempt recursion, still respects --depth
#[serde(default)]
pub force_recursion: bool,
}
impl Default for Configuration {
@@ -291,7 +328,12 @@ impl Default for Configuration {
redirects: false,
no_recursion: false,
extract_links: false,
random_agent: false,
collect_extensions: false,
collect_backups: false,
collect_words: false,
save_state: true,
force_recursion: false,
proxy: String::new(),
config: String::new(),
output: String::new(),
@@ -302,8 +344,12 @@ impl Default for Configuration {
replay_proxy: String::new(),
queries: Vec::new(),
extensions: Vec::new(),
methods: methods(),
data: Vec::new(),
filter_size: Vec::new(),
filter_regex: Vec::new(),
url_denylist: Vec::new(),
regex_denylist: Vec::new(),
filter_line_count: Vec::new(),
filter_word_count: Vec::new(),
filter_status: Vec::new(),
@@ -312,6 +358,7 @@ impl Default for Configuration {
depth: depth(),
threads: threads(),
wordlist: wordlist(),
dont_collect: ignored_extensions(),
}
}
}
@@ -339,8 +386,17 @@ impl Configuration {
/// - **auto_bail**: `false`
/// - **save_state**: `true`
/// - **user_agent**: `feroxbuster/VERSION`
/// - **random_agent**: `false`
/// - **insecure**: `false` (don't be insecure, i.e. don't allow invalid certs)
/// - **extensions**: `None`
/// - **collect_extensions**: `false`
/// - **collect_backups**: `false`
/// - **collect_words**: `false`
/// - **dont_collect**: [`DEFAULT_IGNORED_EXTENSIONS`](constant.DEFAULT_RESPONSE_CODES.html)
/// - **methods**: [`DEFAULT_METHOD`](constant.DEFAULT_METHOD.html)
/// - **data**: `None`
/// - **url_denylist**: `None`
/// - **regex_denylist**: `None`
/// - **filter_size**: `None`
/// - **filter_similar**: `None`
/// - **filter_regex**: `None`
@@ -354,6 +410,7 @@ impl Configuration {
/// - **json**: `false`
/// - **dont_filter**: `false` (auto filter wildcard responses)
/// - **depth**: `4` (maximum recursion depth)
/// - **force_recursion**: `false` (still respects recursion depth)
/// - **scan_limit**: `0` (no limit on concurrent scans imposed)
/// - **parallel**: `0` (no limit on parallel scans imposed)
/// - **rate_limit**: `0` (no limit on requests per second imposed)
@@ -441,7 +498,7 @@ impl Configuration {
/// Parse all possible versions of the ferox-config.toml file, adhering to the order of
/// precedence outlined above
fn parse_config_files(mut config: &mut Self) -> Result<()> {
fn parse_config_files(config: &mut Self) -> Result<()> {
// Next, we parse the ferox-config.toml file, if present and set the values
// therein to overwrite our default values. Deserialized defaults are specified
// in the Configuration struct so that we don't change anything that isn't
@@ -457,7 +514,7 @@ impl Configuration {
let config_file = PathBuf::new()
.join("/etc/feroxbuster")
.join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?;
Self::parse_and_merge_config(config_file, config)?;
// merge a config found at ~/.config/feroxbuster/ferox-config.toml
// config_dir() resolves to one of the following
@@ -466,7 +523,7 @@ impl Configuration {
// - windows: {FOLDERID_RoamingAppData}
let config_dir = dirs::config_dir().ok_or_else(|| anyhow!("Couldn't load config"))?;
let config_file = config_dir.join("feroxbuster").join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?;
Self::parse_and_merge_config(config_file, config)?;
// merge a config found in same the directory as feroxbuster executable
let exe_path = current_exe()?;
@@ -474,12 +531,12 @@ impl Configuration {
.parent()
.ok_or_else(|| anyhow!("Couldn't load config"))?;
let config_file = bin_dir.join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?;
Self::parse_and_merge_config(config_file, config)?;
// merge a config found in the user's current working directory
let cwd = current_dir()?;
let config_file = cwd.join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config)?;
Self::parse_and_merge_config(config_file, config)?;
Ok(())
}
@@ -489,16 +546,16 @@ impl Configuration {
fn parse_cli_args(args: &ArgMatches) -> Self {
let mut config = Configuration::default();
update_config_if_present!(&mut config.threads, args, "threads", usize);
update_config_if_present!(&mut config.depth, args, "depth", usize);
update_config_if_present!(&mut config.scan_limit, args, "scan_limit", usize);
update_config_if_present!(&mut config.parallel, args, "parallel", usize);
update_config_if_present!(&mut config.rate_limit, args, "rate_limit", usize);
update_config_if_present!(&mut config.wordlist, args, "wordlist", String);
update_config_if_present!(&mut config.output, args, "output", String);
update_config_if_present!(&mut config.debug_log, args, "debug_log", String);
update_config_if_present!(&mut config.time_limit, args, "time_limit", String);
update_config_if_present!(&mut config.resume_from, args, "resume_from", String);
update_config_if_present!(&mut config.threads, args, "threads");
update_config_if_present!(&mut config.depth, args, "depth");
update_config_if_present!(&mut config.scan_limit, args, "scan_limit");
update_config_if_present!(&mut config.parallel, args, "parallel");
update_config_if_present!(&mut config.rate_limit, args, "rate_limit");
update_config_if_present!(&mut config.wordlist, args, "wordlist");
update_config_if_present!(&mut config.output, args, "output");
update_config_if_present!(&mut config.debug_log, args, "debug_log");
update_config_if_present!(&mut config.time_limit, args, "time_limit");
update_config_if_present!(&mut config.resume_from, args, "resume_from");
if let Some(arg) = args.values_of("status_codes") {
config.status_codes = arg
@@ -538,6 +595,81 @@ impl Configuration {
config.extensions = arg.map(|val| val.to_string()).collect();
}
if let Some(arg) = args.values_of("dont_collect") {
config.dont_collect = arg.map(|val| val.to_string()).collect();
}
if let Some(arg) = args.values_of("methods") {
config.methods = arg
.map(|val| {
// Check methods if they are correct
Method::from_bytes(val.as_bytes())
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
.as_str()
.to_string()
})
.collect();
}
if let Some(arg) = args.value_of("data") {
if let Some(stripped) = arg.strip_prefix('@') {
config.data =
std::fs::read(stripped).unwrap_or_else(|e| report_and_exit(&e.to_string()));
} else {
config.data = arg.as_bytes().to_vec();
}
}
if args.is_present("stdin") {
config.stdin = true;
} else if let Some(url) = args.value_of("url") {
config.target_url = String::from(url);
}
if let Some(arg) = args.values_of("url_denylist") {
// compile all regular expressions and absolute urls used for --dont-scan
//
// when --dont-scan is used, the should_deny_url function is called at least once per
// url to be scanned. With the addition of regex support, I want to move parsing
// out of should_deny_url and into here, so it's performed once instead of thousands
// of times
for denier in arg {
// could be an absolute url or a regex, need to determine which and populate the
// appropriate vector
match Url::parse(denier.trim_end_matches('/')) {
Ok(absolute) => {
// denier is an absolute url and can be parsed as such
config.url_denylist.push(absolute);
}
Err(err) => {
// there are some expected errors that happen when we try to parse a url
// ex: Url::parse("/login") -> Err("relative URL without a base")
// ex: Url::parse("http:") -> Err("empty host")
//
// these are known errors and are used to determine a valid value to
// --dont-scan, when it's not an absolute url
//
// when expected errors are encountered, we're going to assume
// that the input is a regular expression to be parsed. The possibility
// exists that the user rolled their face across the keyboard and we're
// dealing with the results, in which case we'll report it as an error and
// give up
if err.to_string().contains("relative URL without a base")
|| err.to_string().contains("empty host")
{
let regex = Regex::new(denier)
.unwrap_or_else(|e| report_and_exit(&e.to_string()));
config.regex_denylist.push(regex);
} else {
// unexpected error has occurred; bail
report_and_exit(&err.to_string());
}
}
}
}
}
if let Some(arg) = args.values_of("filter_regex") {
config.filter_regex = arg.map(|val| val.to_string()).collect();
}
@@ -587,7 +719,7 @@ impl Configuration {
config.output_level = OutputLevel::Quiet;
}
if args.is_present("auto_tune") {
if args.is_present("auto_tune") || args.is_present("smart") || args.is_present("thorough") {
config.auto_tune = true;
config.requester_policy = RequesterPolicy::AutoTune;
}
@@ -597,10 +729,32 @@ impl Configuration {
config.requester_policy = RequesterPolicy::AutoBail;
}
if args.is_present("no_state") {
config.save_state = false;
}
if args.is_present("dont_filter") {
config.dont_filter = true;
}
if args.is_present("collect_extensions") || args.is_present("thorough") {
config.collect_extensions = true;
}
if args.is_present("collect_backups")
|| args.is_present("smart")
|| args.is_present("thorough")
{
config.collect_backups = true;
}
if args.is_present("collect_words")
|| args.is_present("smart")
|| args.is_present("thorough")
{
config.collect_words = true;
}
if args.occurrences_of("verbosity") > 0 {
// occurrences_of returns 0 if none are found; this is protected in
// an if block for the same reason as the quiet option
@@ -615,7 +769,10 @@ impl Configuration {
config.add_slash = true;
}
if args.is_present("extract_links") {
if args.is_present("extract_links")
|| args.is_present("smart")
|| args.is_present("thorough")
{
config.extract_links = true;
}
@@ -623,25 +780,36 @@ impl Configuration {
config.json = true;
}
if args.is_present("stdin") {
config.stdin = true;
} else if let Some(url) = args.value_of("url") {
config.target_url = String::from(url);
if args.is_present("force_recursion") {
config.force_recursion = true;
}
////
// organizational breakpoint; all options below alter the Client configuration
////
update_config_if_present!(&mut config.proxy, args, "proxy", String);
update_config_if_present!(&mut config.replay_proxy, args, "replay_proxy", String);
update_config_if_present!(&mut config.user_agent, args, "user_agent", String);
update_config_if_present!(&mut config.timeout, args, "timeout", u64);
update_config_if_present!(&mut config.proxy, args, "proxy");
update_config_if_present!(&mut config.replay_proxy, args, "replay_proxy");
update_config_if_present!(&mut config.user_agent, args, "user_agent");
update_config_if_present!(&mut config.timeout, args, "timeout");
if args.is_present("burp") {
config.proxy = String::from("http://127.0.0.1:8080");
}
if args.is_present("burp_replay") {
config.replay_proxy = String::from("http://127.0.0.1:8080");
}
if args.is_present("random_agent") {
config.random_agent = true;
}
if args.is_present("redirects") {
config.redirects = true;
}
if args.is_present("insecure") {
if args.is_present("insecure") || args.is_present("burp") || args.is_present("burp_replay")
{
config.insecure = true;
}
@@ -659,6 +827,22 @@ impl Configuration {
}
}
if let Some(cookies) = args.values_of("cookies") {
config.headers.insert(
// we know the header name is always "cookie"
"Cookie".to_string(),
// on splitting, there should be only two elements,
// a key and a value
cookies
.map(|cookie| cookie.split('=').collect::<Vec<&str>>()[..].to_owned())
.filter(|parts| parts.len() == 2)
.map(|parts| format!("{}={}", parts[0].trim(), parts[1].trim()))
// trim the spaces, join with an equals sign
.collect::<Vec<String>>()
.join("; "), // join all the cookies with semicolons for the final header
);
}
if let Some(queries) = args.values_of("queries") {
for val in queries {
// same basic logic used as reading in the headers HashMap above
@@ -738,7 +922,7 @@ impl Configuration {
config.config = conf_str;
// update the settings
Self::merge_config(&mut config, settings);
Self::merge_config(config, settings);
}
Ok(())
}
@@ -759,14 +943,29 @@ impl Configuration {
update_if_not_default!(&mut conf.quiet, new.quiet, false);
update_if_not_default!(&mut conf.auto_bail, new.auto_bail, false);
update_if_not_default!(&mut conf.auto_tune, new.auto_tune, false);
update_if_not_default!(&mut conf.collect_extensions, new.collect_extensions, false);
update_if_not_default!(&mut conf.collect_backups, new.collect_backups, false);
update_if_not_default!(&mut conf.collect_words, new.collect_words, false);
// use updated quiet/silent values to determine output level; same for requester policy
conf.output_level = determine_output_level(conf.quiet, conf.silent);
conf.requester_policy = determine_requester_policy(conf.auto_tune, conf.auto_bail);
update_if_not_default!(&mut conf.output, new.output, "");
update_if_not_default!(&mut conf.redirects, new.redirects, false);
update_if_not_default!(&mut conf.insecure, new.insecure, false);
update_if_not_default!(&mut conf.force_recursion, new.force_recursion, false);
update_if_not_default!(&mut conf.extract_links, new.extract_links, false);
update_if_not_default!(&mut conf.extensions, new.extensions, Vec::<String>::new());
update_if_not_default!(&mut conf.methods, new.methods, Vec::<String>::new());
update_if_not_default!(&mut conf.data, new.data, Vec::<u8>::new());
update_if_not_default!(&mut conf.url_denylist, new.url_denylist, Vec::<Url>::new());
if !new.regex_denylist.is_empty() {
// cant use the update_if_not_default macro due to the following error
//
// binary operation `!=` cannot be applied to type `Vec<regex::Regex>`
//
// if we get a non-empty list of regex in the new config, override the old
conf.regex_denylist = new.regex_denylist;
}
update_if_not_default!(&mut conf.headers, new.headers, HashMap::new());
update_if_not_default!(&mut conf.queries, new.queries, Vec::new());
update_if_not_default!(&mut conf.no_recursion, new.no_recursion, false);
@@ -809,6 +1008,7 @@ impl Configuration {
update_if_not_default!(&mut conf.timeout, new.timeout, timeout());
update_if_not_default!(&mut conf.user_agent, new.user_agent, user_agent());
update_if_not_default!(&mut conf.random_agent, new.random_agent, false);
update_if_not_default!(&mut conf.threads, new.threads, threads());
update_if_not_default!(&mut conf.depth, new.depth, depth());
update_if_not_default!(&mut conf.wordlist, new.wordlist, wordlist());
@@ -816,6 +1016,11 @@ impl Configuration {
// status_codes() is the default for replay_codes, if they're not provided
update_if_not_default!(&mut conf.replay_codes, new.replay_codes, status_codes());
update_if_not_default!(&mut conf.save_state, new.save_state, save_state());
update_if_not_default!(
&mut conf.dont_collect,
new.dont_collect,
ignored_extensions()
);
}
/// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values

View File

@@ -1,6 +1,8 @@
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;
@@ -28,7 +30,15 @@ fn setup_config_test() -> Configuration {
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
@@ -39,6 +49,7 @@ fn setup_config_test() -> Configuration {
json = true
save_state = false
depth = 1
force_recursion = true
filter_size = [4120]
filter_regex = ["^ignore me$"]
filter_similar = ["https://somesite.com/soft404"]
@@ -72,24 +83,34 @@ fn default_configuration() {
assert_eq!(config.timeout, timeout());
assert_eq!(config.verbosity, 0);
assert_eq!(config.scan_limit, 0);
assert_eq!(config.silent, false);
assert_eq!(config.quiet, false);
assert!(!config.silent);
assert!(!config.quiet);
assert_eq!(config.output_level, OutputLevel::Default);
assert_eq!(config.dont_filter, false);
assert_eq!(config.auto_tune, false);
assert_eq!(config.auto_bail, false);
assert!(!config.dont_filter);
assert!(!config.auto_tune);
assert!(!config.auto_bail);
assert_eq!(config.requester_policy, RequesterPolicy::Default);
assert_eq!(config.no_recursion, false);
assert_eq!(config.json, false);
assert_eq!(config.save_state, true);
assert_eq!(config.stdin, false);
assert_eq!(config.add_slash, false);
assert_eq!(config.redirects, false);
assert_eq!(config.extract_links, false);
assert_eq!(config.insecure, false);
assert!(!config.no_recursion);
assert!(!config.random_agent);
assert!(!config.json);
assert!(config.save_state);
assert!(!config.stdin);
assert!(!config.add_slash);
assert!(!config.force_recursion);
assert!(!config.redirects);
assert!(!config.extract_links);
assert!(!config.insecure);
assert!(!config.collect_extensions);
assert!(!config.collect_backups);
assert!(!config.collect_words);
assert!(config.regex_denylist.is_empty());
assert_eq!(config.queries, Vec::new());
assert_eq!(config.extensions, Vec::<String>::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());
@@ -186,35 +207,42 @@ fn config_reads_replay_proxy() {
/// parse the test config and see that the value parsed is correct
fn config_reads_silent() {
let config = setup_config_test();
assert_eq!(config.silent, true);
assert!(config.silent);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_force_recursion() {
let config = setup_config_test();
assert!(config.force_recursion);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_quiet() {
let config = setup_config_test();
assert_eq!(config.quiet, true);
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_eq!(config.json, true);
assert!(config.json);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_auto_bail() {
let config = setup_config_test();
assert_eq!(config.auto_bail, true);
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_eq!(config.auto_tune, true);
assert!(config.auto_tune);
}
#[test]
@@ -235,49 +263,70 @@ fn config_reads_output() {
/// parse the test config and see that the value parsed is correct
fn config_reads_redirects() {
let config = setup_config_test();
assert_eq!(config.redirects, true);
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_eq!(config.insecure, true);
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_eq!(config.no_recursion, true);
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_eq!(config.stdin, true);
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_eq!(config.dont_filter, true);
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_eq!(config.add_slash, true);
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_eq!(config.extract_links, true);
assert!(config.extract_links);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_collect_extensions() {
let config = setup_config_test();
assert!(config.collect_extensions);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_collect_backups() {
let config = setup_config_test();
assert!(config.collect_backups);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_collect_words() {
let config = setup_config_test();
assert!(config.collect_words);
}
#[test]
@@ -287,6 +336,50 @@ fn config_reads_extensions() {
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() {
@@ -333,7 +426,7 @@ fn config_reads_filter_status() {
/// parse the test config and see that the value parsed is correct
fn config_reads_save_state() {
let config = setup_config_test();
assert_eq!(config.save_state, false);
assert!(!config.save_state);
}
#[test]
@@ -371,6 +464,12 @@ fn config_reads_queries() {
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

View File

@@ -1,6 +1,6 @@
use crate::{
utils::{module_colorizer, status_colorizer},
DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION,
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION,
};
#[cfg(not(test))]
use std::process::exit;
@@ -52,6 +52,19 @@ pub(super) fn status_codes() -> Vec<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)

View File

@@ -5,6 +5,8 @@ use tokio::sync::oneshot::Sender;
use crate::response::FeroxResponse;
use crate::{
event_handlers::Handles,
message::FeroxMessage,
statistics::{StatError, StatField},
traits::FeroxFilter,
};
@@ -42,12 +44,18 @@ pub enum Command {
/// 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>),
@@ -63,6 +71,16 @@ pub enum Command {
/// 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,
/// Give a handler access to an Arc<Handles> instance after the handler has
/// already been initialized
AddHandles(Arc<Handles>),
}

View File

@@ -6,6 +6,7 @@ use crate::Joiner;
#[cfg(test)]
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};
@@ -56,6 +57,9 @@ pub struct Handles {
/// 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
@@ -66,6 +70,7 @@ impl Handles {
filters: FiltersHandle,
output: TermOutHandle,
config: Arc<Configuration>,
wordlist: Arc<Vec<String>>,
) -> Self {
Self {
stats,
@@ -73,6 +78,7 @@ impl Handles {
output,
config,
scans: RwLock::new(None),
wordlist,
}
}
@@ -85,15 +91,16 @@ impl Handles {
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.extensions.len(),
configuration.json,
)),
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 handles = Self::new(stats_handle, filters_handle, terminal_handle, configuration);
let wordlist = Arc::new(vec![String::from("this_is_a_test")]);
let handles = Self::new(
stats_handle,
filters_handle,
terminal_handle,
configuration,
wordlist,
);
if let Some(sh) = scanned_urls {
let scan_handle = ScanHandle::new(sh, tx);
handles.set_scan_handle(scan_handle);
@@ -122,6 +129,46 @@ impl Handles {
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() {

View File

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

View File

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

View File

@@ -2,19 +2,32 @@ use super::Command::AddToUsizeField;
use super::*;
use anyhow::{Context, Result};
use futures::future::{BoxFuture, FutureExt};
use tokio::sync::{mpsc, oneshot};
use crate::{
config::Configuration,
progress::PROGRESS_PRINTER,
response::FeroxResponse,
scanner::RESPONSES,
send_command, skip_fail,
statistics::StatField::ResourcesDiscovered,
statistics::StatField::{ResourcesDiscovered, TotalExpected},
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
@@ -90,6 +103,12 @@ impl FileOutHandler {
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;
}
@@ -124,6 +143,9 @@ pub struct TermOutHandler {
/// pointer to "global" configuration struct
config: Arc<Configuration>,
/// handles instance
handles: Option<Arc<Handles>>,
}
/// implementation of TermOutHandler
@@ -141,6 +163,7 @@ impl TermOutHandler {
tx_file,
file_task,
config,
handles: None,
}
}
@@ -185,57 +208,16 @@ impl TermOutHandler {
while let Some(command) = self.receiver.recv().await {
match command {
Command::Report(mut resp) => {
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
make_request(
self.config.replay_client.as_ref().unwrap(),
&resp.url(),
self.config.output_level,
tx_stats.clone(),
)
.await
.with_context(|| "Could not replay request through replay proxy")?;
}
if should_process_response {
// add response to RESPONSES for serialization in case of ctrl+c
// placed all by its lonesome like this so that RESPONSES can take ownership
// of the FeroxResponse
// before ownership is transferred, there's no real reason to keep the body anymore
// so we can free that piece of data, reducing memory usage
resp.drop_text();
RESPONSES.insert(*resp);
}
Command::Report(resp) => {
self.process_response(tx_stats.clone(), resp, ProcessResponseCall::Recursive)
.await?;
}
Command::Sync(sender) => {
sender.send(true).unwrap_or_default();
}
Command::AddHandles(handles) => {
self.handles = Some(handles);
}
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
@@ -248,11 +230,198 @@ impl TermOutHandler {
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 should_filter = self
.handles
.as_ref()
.unwrap()
.filters
.data
.should_filter_response(&resp, self.handles.as_ref().unwrap().stats.tx.clone());
let contains_sentry = if !self.config.filter_status.is_empty() {
// -C was used, meaning -s was not and we should ignore the defaults
// https://github.com/epi052/feroxbuster/issues/535
// -C indicates that we should filter that status code, but allow all others
!self.config.filter_status.contains(&resp.status().as_u16())
} else {
// -C wasn't used, so, we defer to checking the -s values
self.config.status_codes.contains(&resp.status().as_u16())
};
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
let should_process_response = contains_sentry && unknown_sentry && !should_filter;
if should_process_response {
// print to stdout
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
send_command!(tx_stats, AddToUsizeField(ResourcesDiscovered, 1));
if self.file_task.is_some() {
// -o used, need to send the report to be written out to disk
self.tx_file
.send(Command::Report(resp.clone()))
.with_context(|| {
fmt_err(&format!("Could not send {} 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::*;
use crate::event_handlers::Command;
#[test]
/// try to hit struct field coverage of FileOutHandler
@@ -272,15 +441,149 @@ mod tests {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
handles: Some(handles),
};
println!("{:?}", toh);
tx.send(Command::Exit).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// when the feroxresponse's url contains an extension, there should be 7 urls returned
async fn generate_backup_urls_creates_correct_urls_when_extension_present() {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
handles: Some(handles),
};
let expected: Vec<_> = vec![
"derp.php~",
"derp.php.bak",
"derp.php.bak2",
"derp.php.old",
"derp.php.1",
".derp.php.swp",
"derp.bak",
];
let mut fr = FeroxResponse::default();
fr.set_url("http://localhost/derp.php");
let urls = toh.generate_backup_urls(&fr).await;
let paths: Vec<_> = urls
.iter()
.map(|url| url.path_segments().unwrap().last().unwrap())
.collect();
assert_eq!(urls.len(), 7);
for path in paths {
assert!(expected.contains(&path));
}
tx.send(Command::Exit).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// when the feroxresponse's url doesn't contain an extension, there should be 6 urls returned
async fn generate_backup_urls_creates_correct_urls_when_extension_not_present() {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
handles: Some(handles),
};
let expected: Vec<_> = vec![
"derp~",
"derp.bak",
"derp.bak2",
"derp.old",
"derp.1",
".derp.swp",
];
let mut fr = FeroxResponse::default();
fr.set_url("http://localhost/derp");
let urls = toh.generate_backup_urls(&fr).await;
let paths: Vec<_> = urls
.iter()
.map(|url| url.path_segments().unwrap().last().unwrap())
.collect();
assert_eq!(urls.len(), 6);
for path in paths {
assert!(expected.contains(&path));
}
tx.send(Command::Exit).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
/// test to ensure that backups are requested from the directory in which they were found
/// re: issue #513
async fn generate_backup_urls_creates_correct_urls_when_not_at_root() {
let (tx, rx) = mpsc::unbounded_channel::<Command>();
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
let config = Arc::new(Configuration::new().unwrap());
let handles = Arc::new(Handles::for_testing(None, None).0);
let toh = TermOutHandler {
config,
file_task: None,
receiver: rx,
tx_file,
handles: Some(handles),
};
let expected: Vec<_> = vec![
"http://localhost/wordpress/derp.php~",
"http://localhost/wordpress/derp.php.bak",
"http://localhost/wordpress/derp.php.bak2",
"http://localhost/wordpress/derp.php.old",
"http://localhost/wordpress/derp.php.1",
"http://localhost/wordpress/.derp.php.swp",
"http://localhost/wordpress/derp.bak",
];
let mut fr = FeroxResponse::default();
fr.set_url("http://localhost/wordpress/derp.php");
let urls = toh.generate_backup_urls(&fr).await;
let url_strs: Vec<_> = urls.iter().map(|url| url.as_str()).collect();
assert_eq!(urls.len(), 7);
for url_str in url_strs {
assert!(expected.contains(&url_str));
}
tx.send(Command::Exit).unwrap();
}
}

View File

@@ -3,17 +3,20 @@ use std::sync::Arc;
use anyhow::{bail, Result};
use tokio::sync::{mpsc, Semaphore};
use crate::response::FeroxResponse;
use crate::url::FeroxUrl;
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)]
@@ -144,6 +147,15 @@ impl ScanHandler {
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);
}
@@ -165,6 +177,23 @@ impl ScanHandler {
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
}
}
@@ -173,6 +202,93 @@ impl ScanHandler {
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() {
@@ -187,6 +303,8 @@ impl ScanHandler {
/// 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) {
@@ -203,6 +321,13 @@ impl ScanHandler {
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);
@@ -243,6 +368,11 @@ impl ScanHandler {
async fn try_recursion(&mut self, response: Box<FeroxResponse>) -> Result<()> {
log::trace!("enter: try_recursion({:?})", response,);
if !self.handles.config.force_recursion && !response.is_directory() {
// not a directory and --force-recursion wasn't used, quick exit
return Ok(());
}
let mut base_depth = 1_usize;
for (base_url, base_url_depth) in &self.depths {
@@ -256,11 +386,6 @@ impl ScanHandler {
return Ok(());
}
if !response.is_directory() {
// not a directory
return Ok(());
}
let targets = vec![response.url().to_string()];
self.ordered_scan_url(targets, ScanOrder::Latest).await?;

View File

@@ -103,7 +103,7 @@ impl StatsHandler {
Command::AddToUsizeField(field, value) => {
self.stats.update_usize_field(field, value);
if matches!(field, StatField::TotalScans) {
if matches!(field, StatField::TotalScans | StatField::TotalExpected) {
self.bar.set_length(self.stats.total_expected() as u64);
}
}
@@ -155,7 +155,7 @@ impl StatsHandler {
pub fn initialize(config: Arc<Configuration>) -> (Joiner, StatsHandle) {
log::trace!("enter: initialize");
let data = Arc::new(Stats::new(config.extensions.len(), config.json));
let data = Arc::new(Stats::new(config.json));
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
let mut handler = StatsHandler::new(data.clone(), rx);

View File

@@ -16,11 +16,14 @@ pub(super) const ROBOTS_TXT_REGEX: &str =
/// Which type of extraction should be performed
#[derive(Debug, Copy, Clone)]
pub enum ExtractionTarget {
/// Examine a response body and extract links
/// 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`
@@ -28,7 +31,7 @@ pub struct ExtractorBuilder<'a> {
/// Response from which to extract links
response: Option<&'a FeroxResponse>,
/// Response from which to extract links
/// URL of where to extract links
url: String,
/// Handles object to house the underlying mpsc transmitters
@@ -76,9 +79,9 @@ impl<'a> ExtractorBuilder<'a> {
self
}
/// finalize configuration of ExtratorBuilder and return an Extractor
/// finalize configuration of `ExtractorBuilder` and return an `Extractor`
///
/// requires either with_url or with_response to have been used in the build process
/// requires either `with_url` or `with_response` to have been used in the build process
pub fn build(&self) -> Result<Extractor<'a>> {
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")

View File

@@ -2,7 +2,6 @@ use super::*;
use crate::{
client,
event_handlers::{
Command,
Command::{AddError, AddToUsizeField},
Handles,
},
@@ -12,12 +11,13 @@ use crate::{
StatField::{LinksExtracted, TotalExpected},
},
url::FeroxUrl,
utils::{logged_request, make_request},
utils::{logged_request, make_request, send_try_recursion_command, should_deny_url},
ExtractionResult, DEFAULT_METHOD,
};
use anyhow::{bail, Context, Result};
use reqwest::{StatusCode, Url};
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)]
@@ -41,7 +41,7 @@ pub struct Extractor<'a> {
/// Response from which to extract links
pub(super) response: Option<&'a FeroxResponse>,
/// Response from which to extract links
/// URL of where to extract links
pub(super) url: String,
/// Handles object to house the underlying mpsc transmitters
@@ -53,12 +53,77 @@ pub struct Extractor<'a> {
/// Extractor implementation
impl<'a> Extractor<'a> {
/// business logic that handles getting links from a normal http body response
pub async fn extract(&self) -> Result<()> {
let links = match self.target {
ExtractionTarget::ResponseBody => self.extract_from_body().await?,
ExtractionTarget::RobotsTxt => self.extract_from_robots().await?,
};
/// 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
@@ -67,6 +132,7 @@ impl<'a> Extractor<'a> {
};
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 {
@@ -84,11 +150,15 @@ impl<'a> Extractor<'a> {
continue;
}
if resp.is_file() {
// very likely a file, simply request and report
log::debug!("Extracted file: {}", resp);
// request and report assumed file
if resp.is_file() || !resp.is_directory() {
log::debug!("Extracted File: {}", resp);
scanned_urls.add_file_scan(&resp.url().to_string(), ScanOrder::Latest);
scanned_urls.add_file_scan(resp.url().as_str(), ScanOrder::Latest);
if self.handles.config.collect_extensions {
resp.parse_extension(self.handles.clone())?;
}
if let Err(e) = resp.send_report(self.handles.output.tx.clone()) {
log::warn!("Could not send FeroxResponse to output handler: {}", e);
@@ -114,18 +184,46 @@ impl<'a> Extractor<'a> {
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?;
if self.handles.config.filter_status.is_empty() {
// -C wasn't used, so -s is the only 'filter' left to account for
if self
.handles
.config
.status_codes
.contains(&resp.status().as_u16())
{
send_try_recursion_command(self.handles.clone(), resp).await?;
}
} else {
// -C was used, that means the filters above would have removed
// those responses, and anything else should be let through
send_try_recursion_command(self.handles.clone(), resp).await?;
}
}
}
log::trace!("exit: request_links");
Ok(())
}
/// Given a `reqwest::Response`, perform the following actions
/// - parse the response's text for links using the linkfinder regex
/// 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:
@@ -134,73 +232,90 @@ impl<'a> Extractor<'a> {
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
pub(super) async fn extract_from_body(&self) -> Result<HashSet<String>> {
log::trace!("enter: get_links");
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
);
let mut links = HashSet::<String>::new();
let body = self.response.unwrap().text();
for capture in self.links_regex.captures_iter(&body) {
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 == '"');
match Url::parse(link) {
Ok(absolute) => {
if absolute.domain() != self.response.unwrap().url().domain()
|| absolute.host() != self.response.unwrap().url().host()
{
// domains/ips are not the same, don't scan things that aren't part of the original
// target url
continue;
}
if self.add_all_sub_paths(absolute.path(), &mut links).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", absolute, links);
}
}
Err(e) => {
// this is the expected error that happens when we try to parse a url fragment
// ex: Url::parse("/login") -> Err("relative URL without a base")
// while this is technically an error, these are good results for us
if e.to_string().contains("relative URL without a base") {
if self.add_all_sub_paths(link, &mut links).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", link, links);
}
} else {
// unexpected error has occurred
log::warn!("Could not parse given url: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
}
}
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
}
}
self.update_stats(links.len())?;
log::trace!("exit: get_links -> {:?}", links);
Ok(links)
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, mut links: &mut HashSet<String>) -> Result<()> {
/// - 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, &mut links)?;
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`
@@ -214,8 +329,13 @@ impl<'a> Extractor<'a> {
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> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut parts: Vec<&str> = normalized_path
.split('/')
.filter(|s| !s.is_empty())
.collect();
let length = parts.len();
@@ -248,7 +368,7 @@ impl<'a> Extractor<'a> {
paths
}
/// simple helper to stay DRY, trys to join a url + fragment and add it to the `links` HashSet
/// simple helper to stay DRY, tries to join a url + fragment and add it to the `links` HashSet
pub(super) fn add_link_to_set_of_links(
&self,
link: &str,
@@ -257,7 +377,9 @@ impl<'a> Extractor<'a> {
log::trace!("enter: add_link_to_set_of_links({}, {:?})", link, links);
let old_url = match self.target {
ExtractionTarget::ResponseBody => self.response.unwrap().url().clone(),
ExtractionTarget::ResponseBody | ExtractionTarget::DirectoryListing => {
self.response.unwrap().url().clone()
}
ExtractionTarget::RobotsTxt => match Url::parse(&self.url) {
Ok(u) => u,
Err(e) => {
@@ -267,7 +389,7 @@ impl<'a> Extractor<'a> {
};
let new_url = old_url
.join(&link)
.join(link)
.with_context(|| format!("Could not join {} with {}", old_url, link))?;
links.insert(new_url.to_string());
@@ -278,21 +400,16 @@ impl<'a> Extractor<'a> {
}
/// Wrapper around link extraction logic
/// currently used in two places:
/// - links from response bodies
/// - links from robots.txt responses
///
/// general steps taken:
/// - create a new Url object based on cli options/args
/// - 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());
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 new_url = ferox_url.format("", None)?;
let scanned_urls = self.handles.ferox_scans()?;
@@ -302,11 +419,30 @@ impl<'a> Extractor<'a> {
bail!("previously seen url");
}
// make the request and store the response
let new_response = logged_request(&new_url, self.handles.clone()).await?;
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,
);
}
let new_ferox_response =
FeroxResponse::from(new_response, true, self.handles.config.output_level).await;
// 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);
@@ -321,82 +457,178 @@ impl<'a> Extractor<'a> {
/// http://localhost/stuff/things
/// this function requests:
/// http://localhost/robots.txt
pub(super) async fn extract_from_robots(&self) -> Result<HashSet<String>> {
pub(super) async fn extract_from_robots(&self) -> Result<ExtractionResult> {
log::trace!("enter: extract_robots_txt");
let mut links: HashSet<String> = HashSet::new();
let mut result: HashSet<_> = ExtractionResult::new();
let response = self.request_robots_txt().await?;
// request
let response = self.make_extract_request("/robots.txt").await?;
let body = response.text();
for capture in self.robots_regex.captures_iter(response.text()) {
for capture in self.robots_regex.captures_iter(body) {
if let Some(new_path) = capture.name("url_path") {
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 links).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", new_url, links);
if self.add_all_sub_paths(new_url.path(), &mut result).is_err() {
log::warn!("could not add sub-paths from {} to {:?}", new_url, result);
}
}
}
self.update_stats(links.len())?;
log::trace!("exit: extract_robots_txt -> {:?}", links);
Ok(links)
log::trace!("exit: extract_robots_txt -> {:?}", result);
Ok(result)
}
/// helper function that simply requests /robots.txt on the given url's base url
/// outer-most wrapper for parsing html response bodies in search of additional content.
/// performs the following high-level steps:
/// - requests the page, if necessary
/// - checks the page to see if directory listing is enabled and sucks up all the links, if so
/// - uses the linkfinder regex to grab links from embedded javascript/javascript files
/// - extracts many different types of link sources from the html itself
pub(super) async fn extract_from_body(&self) -> Result<ExtractionResult> {
log::trace!("enter: extract_from_body");
let mut result = ExtractionResult::new();
let response = self.response.unwrap();
let resp_url = response.url();
let body = response.text();
let html = Html::parse_document(body);
// extract links from html tags/attributes and embedded javascript
self.extract_all_links_from_html_tags(resp_url, &mut result, &html);
self.extract_all_links_from_javascript(body, resp_url, &mut result);
log::trace!("exit: extract_from_body -> {:?}", result);
Ok(result)
}
/// parses html response bodies in search of <a> tags.
///
/// the assumption is that directory listing is turned on and this extraction target simply
/// scoops up all the links for the given directory. The test to detect a directory listing
/// is located in `HeuristicTests`
pub async fn extract_from_dir_listing(&self) -> Result<ExtractionResult> {
log::trace!("enter: extract_from_dir_listing");
let mut result = ExtractionResult::new();
let response = self.response.unwrap();
let html = Html::parse_document(response.text());
self.extract_links_by_attr(response.url(), &mut result, &html, "a", "href");
log::trace!("exit: extract_from_dir_listing -> {:?}", result);
Ok(result)
}
/// simple helper to get html links by tag/attribute and add it to the `links` HashSet
fn extract_links_by_attr(
&self,
resp_url: &Url,
links: &mut HashSet<String>,
html: &Html,
html_tag: &str,
html_attr: &str,
) {
log::trace!("enter: extract_links_by_attr");
let selector = Selector::parse(html_tag).unwrap();
let tags = html
.select(&selector)
.filter(|a| a.value().attrs().any(|attr| attr.0 == html_attr));
for tag in tags {
if let Some(link) = tag.value().attr(html_attr) {
log::debug!("Parsed link \"{}\" from {}", link, resp_url.as_str());
if self
.parse_url_and_add_subpaths(link, resp_url, links)
.is_err()
{
log::debug!("link didn't belong to the target domain/host: {}", link);
}
}
}
log::trace!("exit: extract_links_by_attr");
}
/// helper function that simply requests at <location> on the given url's base url
///
/// example:
/// 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(super) async fn request_robots_txt(&self) -> Result<FeroxResponse> {
log::trace!("enter: get_robots_file");
/// 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");
// 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;
// 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();
let proxy = if self.handles.config.proxy.is_empty() {
None
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 {
Some(self.handles.config.proxy.as_str())
&client
};
let client = client::initialize(
self.handles.config.timeout,
&self.handles.config.user_agent,
follow_redirects,
self.handles.config.insecure,
&self.handles.config.headers,
proxy,
)?;
let mut url = Url::parse(&self.url)?;
url.set_path("/robots.txt"); // overwrite existing path with /robots.txt
url.set_path(location); // overwrite existing path
// purposefully not using logged_request here due to using the special client
let response = make_request(
&client,
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, true, self.handles.config.output_level).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: get_robots_file -> {}", ferox_response);
return Ok(ferox_response);
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.config.extensions.len().max(1);
let multiplier = self.handles.expected_num_requests_multiplier();
self.handles
.stats

View File

@@ -4,6 +4,7 @@ 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};
@@ -19,6 +20,9 @@ lazy_static! {
/// 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();
}
@@ -41,6 +45,9 @@ fn setup_extractor(target: ExtractionTarget, scanned_urls: Arc<FeroxScans>) -> E
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());
@@ -54,8 +61,8 @@ fn setup_extractor(target: ExtractionTarget, scanned_urls: Arc<FeroxScans>) -> E
/// 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 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/",
@@ -67,8 +74,8 @@ fn extractor_get_sub_paths_from_path_with_multiple_paths() {
assert_eq!(r_paths.len(), expected.len());
assert_eq!(b_paths.len(), expected.len());
for expected_path in expected {
assert_eq!(r_paths.contains(&expected_path.to_string()), true);
assert_eq!(b_paths.contains(&expected_path.to_string()), true);
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
@@ -78,15 +85,15 @@ fn extractor_get_sub_paths_from_path_with_multiple_paths() {
/// 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 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_eq!(r_paths.contains(&expected_path.to_string()), true);
assert_eq!(b_paths.contains(&expected_path.to_string()), true);
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
@@ -95,15 +102,15 @@ fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
/// 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 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_eq!(r_paths.contains(&expected_path.to_string()), true);
assert_eq!(b_paths.contains(&expected_path.to_string()), true);
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
@@ -111,15 +118,15 @@ fn extractor_get_sub_paths_from_path_with_only_a_word() {
/// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed
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 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_eq!(r_paths.contains(&expected_path.to_string()), true);
assert_eq!(b_paths.contains(&expected_path.to_string()), true);
assert!(r_paths.contains(&expected_path.to_string()));
assert!(b_paths.contains(&expected_path.to_string()));
}
}
@@ -188,7 +195,6 @@ fn extractor_add_link_to_set_of_links_happy_path() {
fn extractor_add_link_to_set_of_links_with_non_base_url() {
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)
@@ -199,6 +205,34 @@ fn extractor_add_link_to_set_of_links_with_non_base_url() {
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
@@ -211,20 +245,30 @@ async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain()
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\"",
"\"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, OutputLevel::Default, tx_stats.clone())
.await
.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, true, OutputLevel::Default).await;
let ferox_response =
FeroxResponse::from(response, &srv.url(""), DEFAULT_METHOD, OutputLevel::Default).await;
let extractor = Extractor {
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
@@ -263,7 +307,7 @@ async fn request_robots_txt_without_proxy() -> Result<()> {
handles,
};
let resp = extractor.request_robots_txt().await?;
let resp = extractor.make_extract_request("/robots.txt").await?;
assert!(matches!(resp.status(), &StatusCode::OK));
println!("{}", resp);
@@ -296,7 +340,7 @@ async fn request_robots_txt_with_proxy() -> Result<()> {
.handles(handles)
.build()?;
let resp = extractor.request_robots_txt().await?;
let resp = extractor.make_extract_request("/robots.txt").await?;
assert!(matches!(resp.status(), &StatusCode::OK));
assert_eq!(resp.content_length(), 19);

View File

@@ -1,27 +1,30 @@
use std::sync::Mutex;
use std::sync::RwLock;
use anyhow::Result;
use serde::{ser::SerializeSeq, Serialize, Serializer};
use crate::response::FeroxResponse;
use crate::{
event_handlers::Command::AddToUsizeField, statistics::StatField::WildcardsFiltered,
CommandSender,
event_handlers::Command::AddToUsizeField, response::FeroxResponse,
statistics::StatField::WildcardsFiltered, CommandSender,
};
use super::{FeroxFilter, WildcardFilter};
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: Mutex<Vec<Box<dyn FeroxFilter>>>,
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.lock() {
if let Ok(mut guard) = self.filters.write() {
if guard.contains(&filter) {
return Ok(());
}
@@ -31,6 +34,37 @@ impl FeroxFilters {
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(
@@ -38,10 +72,10 @@ impl FeroxFilters {
response: &FeroxResponse,
tx_stats: CommandSender,
) -> bool {
if let Ok(filters) = self.filters.lock() {
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.should_filter_response(response) {
if filter.as_any().downcast_ref::<WildcardFilter>().is_some() {
tx_stats
.send(AddToUsizeField(WildcardsFiltered, 1))
@@ -54,3 +88,43 @@ impl FeroxFilters {
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
}
}

View File

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

View File

@@ -2,7 +2,7 @@ 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)]
#[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,

View File

@@ -1,4 +1,5 @@
//! contains all of feroxbuster's filters
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::fmt::Debug;
@@ -6,12 +7,14 @@ 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;
@@ -26,3 +29,5 @@ mod container;
#[cfg(test)]
mod tests;
mod init;
mod utils;
mod empty;

View File

@@ -3,15 +3,25 @@ use ::regex::Regex;
/// Simple implementor of FeroxFilter; used to filter out responses based on a given regular
/// expression; specified using -X|--filter-regex
#[derive(Debug)]
#[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

View File

@@ -3,13 +3,16 @@ 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)]
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SimilarityFilter {
/// Response's body to be used for comparison for similarity
pub text: String,
/// 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
@@ -19,7 +22,7 @@ impl FeroxFilter for SimilarityFilter {
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()) {
if let Ok(result) = FuzzyHash::compare(&self.hash, &other.to_string()) {
return result >= self.threshold;
}

View File

@@ -2,7 +2,7 @@ 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)]
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SizeFilter {
/// Overall length of a Response's body that should be filtered
pub content_length: u64,

View File

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

View File

@@ -122,11 +122,25 @@ fn wildcard_should_filter_when_static_wildcard_found() {
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() {
@@ -139,6 +153,7 @@ fn wildcard_should_filter_when_dynamic_wildcard_found() {
size: 0,
dynamic: 59, // content-length - 5 (len('stuff'))
dont_filter: false,
method: "GET".to_owned(),
};
println!("resp: {:?}: filter: {:?}", resp, filter);
@@ -171,22 +186,23 @@ fn similarity_filter_is_accurate() {
resp.set_text("sitting");
let mut filter = SimilarityFilter {
text: FuzzyHash::new("kitten").to_string(),
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.text = String::new();
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.text = FuzzyHash::new("some data to hash for the purposes of running a te").to_string();
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));
@@ -196,20 +212,58 @@ fn similarity_filter_is_accurate() {
/// 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"),
hash: String::from("stuff"),
threshold: 95,
original_url: "".to_string(),
};
let filter2 = SimilarityFilter {
text: String::from("stuff"),
hash: String::from("stuff"),
threshold: 95,
original_url: "".to_string(),
};
assert!(filter.box_eq(filter2.as_any()));
assert_eq!(filter.text, "stuff");
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("/")
}
);
}
}

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::url::FeroxUrl;
use crate::{url::FeroxUrl, DEFAULT_METHOD};
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
///
@@ -9,7 +9,7 @@ use crate::url::FeroxUrl;
///
/// `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)]
#[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
@@ -18,6 +18,9 @@ pub struct WildcardFilter {
/// 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,
}
@@ -40,6 +43,7 @@ impl Default for WildcardFilter {
Self {
dont_filter: false,
size: u64::MAX,
method: DEFAULT_METHOD.to_owned(),
dynamic: u64::MAX,
}
}
@@ -60,7 +64,10 @@ impl FeroxFilter for WildcardFilter {
return false;
}
if self.size != u64::MAX && self.size == response.content_length() {
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());
@@ -68,6 +75,17 @@ impl FeroxFilter for WildcardFilter {
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
@@ -76,7 +94,7 @@ impl FeroxFilter for WildcardFilter {
// 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());
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());

View File

@@ -2,7 +2,7 @@ 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)]
#[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,

View File

@@ -2,8 +2,10 @@ use std::sync::Arc;
use anyhow::{bail, Result};
use console::style;
use scraper::{Html, Selector};
use uuid::Uuid;
use crate::message::FeroxMessage;
use crate::{
config::OutputLevel,
event_handlers::{Command, Handles},
@@ -13,6 +15,7 @@ use crate::{
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
@@ -20,10 +23,11 @@ const UUID_LENGTH: u64 = 32;
/// wrapper around ugly string formatting
macro_rules! format_template {
($template:expr, $length:expr) => {
($template:expr, $method:expr, $length:expr) => {
format!(
$template,
status_colorizer("WLD"),
$method,
"-",
"-",
"-",
@@ -34,6 +38,36 @@ macro_rules! format_template {
};
}
/// 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,
/// 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
@@ -89,55 +123,68 @@ impl HeuristicTests {
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());
let ferox_response = self.make_wildcard_request(&ferox_url, 1).await?;
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);
// found a wildcard response
let mut wildcard = WildcardFilter::new(self.handles.config.dont_filter);
let wc_length = ferox_response.content_length();
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 = ferox_url.path_length()?;
wildcard.dynamic = wc_length - url_len;
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let msg = format_template!("{} {:>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 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);
}
}
if wc_length == 0 {
log::trace!("exit: wildcard_test -> 1");
self.send_filter(wildcard)?;
return Ok(1);
}
// content length of wildcard is non-zero, perform additional tests:
// make a second request, with a known-sized (64) longer request
let resp_two = self.make_wildcard_request(&ferox_url, 3).await?;
let wc2_length = resp_two.content_length();
if wc2_length == wc_length + (UUID_LENGTH * 2) {
// second length is what we'd expect to see if the requested url is
// reflected in the response along with some static content; aka custom 404
let url_len = ferox_url.path_length()?;
wildcard.dynamic = wc_length - url_len;
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let msg = format_template!("{} {:>9} {:>9} {:>9} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n", wildcard.dynamic);
ferox_print(&msg, &PROGRESS_PRINTER);
}
} else if wc_length == wc2_length {
wildcard.size = wc_length;
if matches!(
self.handles.config.output_level,
OutputLevel::Default | OutputLevel::Quiet
) {
let msg = format_template!("{} {:>9} {:>9} {:>9} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n", wildcard.size);
ferox_print(&msg, &PROGRESS_PRINTER);
}
}
self.send_filter(wildcard)?;
log::trace!("exit: wildcard_test");
Ok(2)
}
@@ -151,14 +198,30 @@ impl HeuristicTests {
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);
let nonexistent_url = target.format(&unique_str, None)?;
let response = logged_request(&nonexistent_url.to_owned(), self.handles.clone()).await?;
// To take care of slash when needed
let slash = if self.handles.config.add_slash {
Some("/")
} else {
None
};
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
@@ -167,8 +230,14 @@ impl HeuristicTests {
.contains(&response.status().as_u16())
{
// found a wildcard response
let mut ferox_response =
FeroxResponse::from(response, true, self.handles.config.output_level).await;
let mut ferox_response = FeroxResponse::from(
response,
&target.target,
method,
self.handles.config.output_level,
)
.await;
ferox_response.set_wildcard(true);
if self
@@ -207,10 +276,10 @@ impl HeuristicTests {
let mut good_urls = vec![];
for target_url in target_urls {
let url = FeroxUrl::from_string(&target_url, self.handles.clone());
let url = FeroxUrl::from_string(target_url, self.handles.clone());
let request = skip_fail!(url.format("", None));
let result = logged_request(&request, self.handles.clone()).await;
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await;
match result {
Ok(_) => {
@@ -245,6 +314,111 @@ impl HeuristicTests {
log::trace!("exit: connectivity_test -> {:?}", good_urls);
Ok(good_urls)
}
/// heuristic designed to detect when a server has directory listing enabled
pub async fn directory_listing(&self, target_url: &str) -> Result<Option<DirListingResult>> {
log::trace!("enter: directory_listing({})", target_url);
let tgt = if !target_url.ends_with('/') {
// if left unchanged, this function would be called against redirects that point to
// valid directories for most, if not all, directories beyond the initial urls.
// so, instead of `directory_listing("http://localhost") -> None` we get
// `directory_listing("http://localhost/") -> Some(DirListingResult)` if there is
// directory listing beyond the redirect
format!("{}/", target_url)
} else {
target_url.to_string()
};
let url = FeroxUrl::from_string(&tgt, self.handles.clone());
let request = url.format("", None)?;
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await?;
let ferox_response = FeroxResponse::from(
result,
&url.target,
DEFAULT_METHOD,
self.handles.config.output_level,
)
.await;
let body = ferox_response.text();
let html = Html::parse_document(body);
let dirlist_type = self.detect_directory_listing(&html);
if dirlist_type.is_some() {
// folks that run things and step away/rely on logs need to be notified of directory
// listing, since they won't see the message on the bar; bastardizing FeroxMessage
// for ease of implementation. This could use a bit of polish at some point.
let msg = format!(
"detected directory listing: {} ({:?})",
target_url,
dirlist_type.unwrap()
);
let ferox_msg = FeroxMessage {
kind: "log".to_string(),
message: msg.clone(),
level: "MSG".to_string(),
time_offset: 0.0,
module: "feroxbuster::heuristics".to_string(),
};
self.handles
.output
.tx_file
.send(Command::WriteToDisk(Box::new(ferox_msg)))
.unwrap_or_default();
log::info!("{}", msg);
let result = DirListingResult {
dir_list_type: dirlist_type,
response: ferox_response,
};
log::trace!("exit: directory_listing -> {:?}", result);
return Ok(Some(result));
}
log::trace!("exit: directory_listing -> None");
Ok(None)
}
/// Directory listing heuristic detection, uses <title> tag to make its determination. When
/// the inner html of <title> matches one of the following, a `DirListingType` is returned.
/// - apache: `Index of /`
/// - tomcat/python: `Directory Listing for /`
/// - ASP.NET: `Directory Listing -- /`
/// - <host> - /: iis, azure, skipping due to loose heuristic
fn detect_directory_listing(&self, html: &Html) -> Option<DirListingType> {
log::trace!("enter: detect_directory_listing(html body...)");
let title_selector = Selector::parse("title").expect("couldn't parse title selector");
for t in html.select(&title_selector) {
let title = t.inner_html().to_lowercase();
let dirlist_type = if title.contains("directory listing for /") {
Some(DirListingType::TomCatOrPython)
} else if title.contains("index of /") {
Some(DirListingType::Apache)
} else if title.contains("directory listing -- /") {
Some(DirListingType::AspDotNet)
} else {
// IIS_AZURE purposely skipped for now
None
};
if dirlist_type.is_some() {
log::trace!("exit: detect_directory_listing -> {:?}", dirlist_type);
return dirlist_type;
}
}
log::trace!("exit: detect_directory_listing -> None");
None
}
}
#[cfg(test)]
@@ -260,4 +434,51 @@ mod tests {
assert_eq!(tester.unique_string(i).len(), i * 32);
}
}
#[test]
/// `detect_directory_listing` correctly identifies tomcat/python instances
fn detect_directory_listing_finds_tomcat_python() {
let html = "<title>directory listing for /</title>";
let parsed = Html::parse_document(html);
let handles = Handles::for_testing(None, None);
let heuristics = HeuristicTests::new(Arc::new(handles.0));
let dirlist_type = heuristics.detect_directory_listing(&parsed);
assert!(matches!(
dirlist_type.unwrap(),
DirListingType::TomCatOrPython
));
}
#[test]
/// `detect_directory_listing` correctly identifies apache instances
fn detect_directory_listing_finds_apache() {
let html = "<title>index of /</title>";
let parsed = Html::parse_document(html);
let handles = Handles::for_testing(None, None);
let heuristics = HeuristicTests::new(Arc::new(handles.0));
let dirlist_type = heuristics.detect_directory_listing(&parsed);
assert!(matches!(dirlist_type.unwrap(), DirListingType::Apache));
}
#[test]
/// `detect_directory_listing` correctly identifies ASP.NET instances
fn detect_directory_listing_finds_asp_dot_net() {
let html = "<title>directory listing -- /</title>";
let parsed = Html::parse_document(html);
let handles = Handles::for_testing(None, None);
let heuristics = HeuristicTests::new(Arc::new(handles.0));
let dirlist_type = heuristics.detect_directory_listing(&parsed);
assert!(matches!(dirlist_type.unwrap(), DirListingType::AspDotNet));
}
#[test]
/// `detect_directory_listing` returns None when heuristic doesn't match
fn detect_directory_listing_returns_none_as_default() {
let html = "<title>derp listing -- /</title>";
let parsed = Html::parse_document(html);
let handles = Handles::for_testing(None, None);
let heuristics = HeuristicTests::new(Arc::new(handles.0));
let dirlist_type = heuristics.detect_directory_listing(&parsed);
assert!(dirlist_type.is_none());
}
}

View File

@@ -1,5 +1,8 @@
#![deny(clippy::all)]
#![allow(clippy::mutex_atomic)]
use anyhow::Result;
use reqwest::StatusCode;
use std::collections::HashSet;
use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender},
task::JoinHandle,
@@ -26,6 +29,7 @@ mod macros;
mod url;
mod response;
mod message;
mod nlp;
/// Alias for tokio::sync::mpsc::UnboundedSender<Command>
pub(crate) type CommandSender = UnboundedSender<Command>;
@@ -39,22 +43,39 @@ pub(crate) type Joiner = JoinHandle<Result<()>>;
/// Generic mpsc::unbounded_channel type to tidy up some code
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(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.
///
/// defaults to kali's default install location:
/// defaults to kali's default install location on linux:
/// - `/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt`
///
/// and to the current directory on windows
/// - `.\seclists\Discovery\Web-Content\raft-medium-directories.txt`
#[cfg(not(target_os = "windows"))]
pub const DEFAULT_WORDLIST: &str =
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
#[cfg(target_os = "windows")]
pub const DEFAULT_WORDLIST: &str =
".\\SecLists\\Discovery\\Web-Content\\raft-medium-directories.txt";
/// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan
pub(crate) const SLEEP_DURATION: u64 = 500;
@@ -73,7 +94,8 @@ pub const HIGH_ERROR_RATIO: f64 = 0.90;
/// * 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,
@@ -83,12 +105,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";
/// 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 {

View File

@@ -1,8 +1,10 @@
use std::io::stdin;
use std::{
env::args,
fs::File,
fs::{create_dir, remove_file, File},
io::{stderr, BufRead, BufReader},
ops::Index,
path::Path,
process::Command,
sync::{atomic::Ordering, Arc},
};
@@ -15,11 +17,14 @@ use tokio::{
};
use tokio_util::codec::{FramedRead, LinesCodec};
use feroxbuster::scan_manager::ScanType;
use feroxbuster::{
banner::{Banner, UPDATE_URL},
config::{Configuration, OutputLevel},
event_handlers::{
Command::{CreateBar, Exit, JoinTasks, LoadStats, ScanInitialUrls, UpdateWordlist},
Command::{
AddHandles, CreateBar, Exit, JoinTasks, LoadStats, ScanInitialUrls, UpdateWordlist,
},
FiltersHandler, Handles, ScanHandler, StatsHandler, Tasks, TermInputHandler,
TermOutHandler, SCAN_COMPLETE,
},
@@ -27,7 +32,7 @@ use feroxbuster::{
progress::{PROGRESS_BAR, PROGRESS_PRINTER},
scan_manager::{self},
scanner,
utils::fmt_err,
utils::{fmt_err, slugify_filename},
};
#[cfg(not(target_os = "windows"))]
use feroxbuster::{utils::set_open_file_limit, DEFAULT_OPEN_FILE_LIMIT};
@@ -47,19 +52,19 @@ fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<Vec<String>>> {
let reader = BufReader::new(file);
let mut words = Vec::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 = match line {
Ok(read_line) => read_line,
Err(_) => continue,
};
if result.starts_with('#') || result.is_empty() {
continue;
}
words.push(result);
line.map(|result| {
if !result.starts_with('#') && !result.is_empty() {
words.push(result);
}
})
.ok();
}
log::trace!(
@@ -73,21 +78,12 @@ fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<Vec<String>>> {
/// Determine whether it's a single url scan or urls are coming from stdin, then scan as needed
async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: scan({:?}, {:?})", targets, handles);
// cloning an Arc is cheap (it's basically a pointer into the heap)
// so that will allow for cheap/safe sharing of a single wordlist across multi-target scans
// as well as additional directories found as part of recursion
let words = get_unique_words_from_wordlist(&handles.config.wordlist)?;
if words.len() == 0 {
bail!("Did not find any words in {}", handles.config.wordlist);
}
let scanned_urls = handles.ferox_scans()?;
handles.send_scan_command(UpdateWordlist(words.clone()))?;
handles.send_scan_command(UpdateWordlist(handles.wordlist.clone()))?;
scanner::initialize(words.len(), handles.clone()).await?;
scanner::initialize(handles.wordlist.len(), handles.clone()).await?;
// at this point, the stat thread's progress bar can be created; things that needed to happen
// first:
@@ -106,7 +102,7 @@ async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> {
if handles.config.resumed {
// display what has already been completed
scanned_urls.print_known_responses();
scanned_urls.print_completed_bars(words.len())?;
scanned_urls.print_completed_bars(handles.wordlist.len())?;
}
log::debug!("sending {:?} to be scanned as initial targets", targets);
@@ -141,8 +137,8 @@ async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
for scan in scans.iter() {
// 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 scan.is_complete() {
// this one's already done, ignore it
if scan.is_complete() || matches!(scan.scan_type, ScanType::File) {
// this one's already done, or it's not a directory, ignore it
continue;
}
@@ -153,6 +149,28 @@ async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
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
);
}
}
}
log::trace!("exit: get_targets -> {:?}", targets);
Ok(targets)
@@ -174,6 +192,18 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
PROGRESS_BAR.join().unwrap();
});
// cloning an Arc is cheap (it's basically a pointer into the heap)
// so that will allow for cheap/safe sharing of a single wordlist across multi-target scans
// as well as additional directories found as part of recursion
let words = get_unique_words_from_wordlist(&config.wordlist)?;
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();
@@ -186,11 +216,13 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
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
handles.output.send(AddHandles(handles.clone()))?;
filters::initialize(handles.clone()).await?; // send user-supplied filters to the handler
@@ -218,7 +250,7 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
let from_here = config.resume_from.clone();
// populate FeroxScans object with previously seen scans
scanned_urls.add_serialized_scans(&from_here)?;
scanned_urls.add_serialized_scans(&from_here, handles.clone())?;
// populate Stats object with previously known statistics
handles.stats.send(LoadStats(from_here))?;
@@ -230,7 +262,7 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
Err(e) => {
// should only happen in the event that there was an error reading from stdin
clean_up(handles, tasks).await?;
bail!("Could not get determine initial targets: {}", e);
bail!("Could not determine initial targets: {}", e);
}
};
@@ -267,10 +299,56 @@ async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
// 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);
@@ -294,8 +372,22 @@ async fn wrapped_main(config: Arc<Configuration>) -> 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(());
}
@@ -415,9 +507,39 @@ fn main() -> Result<()> {
.enable_all()
.build()
{
let future = wrapped_main(config);
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();
};
}

View File

@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::traits::FeroxSerialize;
use crate::utils::fmt_err;
#[derive(Serialize, Deserialize, Default)]
#[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")]
@@ -38,7 +38,7 @@ impl FeroxSerialize for FeroxMessage {
"DEBUG" => ("DBG", Color::Yellow),
"TRACE" => ("TRC", Color::Magenta),
"WILDCARD" => ("WLD", Color::Cyan),
_ => ("UNK", Color::White),
_ => ("MSG", Color::White),
};
format!(
@@ -143,6 +143,6 @@ mod tests {
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("UNK"));
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
}
}
/// replace ascii and some utf-8 punctuation characters with ' ' (space) in the given string
fn remove_punctuation(text: &str) -> String {
text.replace(
[
'!', '\\', '"', '#', '$', '%', '&', '(', ')', '*', '+', ':', ';', '<', '=', '>', '?',
'@', '[', ']', '^', '{', '}', '|', '~', ',', '\'', '“', '”', '', '', '', '', '/',
'', '—', '.',
],
" ",
)
}
/// remove stop words from the given string
fn remove_stop_words(text: &str) -> String {
BOUNDED_WORD_REGEX
.replace_all(text, |caps: &Captures| {
let word = &caps[0];
if !STOP_WORDS.contains(&word) {
word.to_owned()
} else {
String::new()
}
})
.into()
}
/// calculate inverse document frequency
pub(super) fn inverse_document_frequency(num_docs: f32, doc_frequency: f32) -> f32 {
f32::log10(num_docs / doc_frequency)
}
/// calculate term frequency-inverse document frequency (tf-idf)
pub(super) fn tf_idf_score(term_frequency: f32, idf: f32) -> f32 {
term_frequency * idf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// ensure all expected punctuation characters are removed
fn test_remove_punctuation() {
let tester = "!\\\"#$%&()*+/:;<=>?@[]^{}|~,.'“”’‘–—\n";
// the `" \n"` is because of the things like / getting replaced with a space
assert_eq!(
remove_punctuation(tester),
" \n "
);
}
#[test]
/// ensure uppercase characters are swapped to lowercase
fn test_normalize_case() {
let tester = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
assert_eq!(normalize_case(tester), "abcdefghijklmnopqrstuvwxyz");
}
#[test]
/// ensure all stop words are removed from the list of stopwords ... intestuous
fn test_remove_stopwords() {
let all_words = STOP_WORDS
.iter()
.map(|&word| word.to_string())
.collect::<Vec<_>>()
.join(" ");
let removed = remove_stop_words(&all_words).replace(' ', "");
// the remaining chars are from the contraction-based stop words
assert_eq!(removed, "'d'll'm''s'ven'tntntdllmsvedllmsve");
}
#[test]
/// ensure preprocess
fn test_preprocess_results() {
let tester = "WHY are Y'all YELLing?";
assert_eq!(&preprocess(tester), &["y", "all", "yelling"]);
}
#[test]
/// ensure our calculations conform to the example provided at the link below
///
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
///
/// Consider a document containing 100 words wherein the word cat appears 3 times.
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
/// million documents and the word cat appears in one thousand of these. Then, the inverse
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
fn idf_returns_expected_value() {
let num_docs = 10_000_000_f32;
let num_occurrences = 1_000_f32;
let abs_diff = (inverse_document_frequency(num_docs, num_occurrences) - 4.0).abs();
assert!(abs_diff <= f32::EPSILON);
}
#[test]
/// ensure our calculations conform to the example provided at the link below
///
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
///
/// Consider a document containing 100 words wherein the word cat appears 3 times.
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
/// million documents and the word cat appears in one thousand of these. Then, the inverse
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
fn tf_idf_returns_expected_value() {
let term_freq = 0.03_f32;
let num_docs = 10_000_000_f32;
let num_occurrences = 1_000_f32;
let idf = inverse_document_frequency(num_docs, num_occurrences);
let abs_diff = (tf_idf_score(term_freq, idf) - 0.12).abs();
assert!(abs_diff <= f32::EPSILON);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,9 +7,10 @@ use std::{
};
use anyhow::{Context, Result};
use console::style;
use reqwest::{
header::{HeaderMap, HeaderName, HeaderValue},
Response, StatusCode, Url,
Method, Response, StatusCode, Url,
};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -30,9 +31,15 @@ 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,
@@ -53,6 +60,9 @@ pub struct FeroxResponse {
/// 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
@@ -61,7 +71,9 @@ impl Default for FeroxResponse {
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,
@@ -69,6 +81,7 @@ impl Default for FeroxResponse {
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
extension: None,
}
}
}
@@ -79,8 +92,9 @@ impl fmt::Display for FeroxResponse {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"FeroxResponse {{ url: {}, status: {}, content-length: {} }}",
"FeroxResponse {{ url: {}, method: {}, status: {}, content-length: {} }}",
self.url(),
self.method(),
self.status(),
self.content_length()
)
@@ -94,6 +108,11 @@ impl FeroxResponse {
&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
@@ -121,7 +140,7 @@ impl FeroxResponse {
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
pub fn set_url(&mut self, url: &str) {
match Url::parse(&url) {
match Url::parse(url) {
Ok(url) => {
self.url = url;
}
@@ -186,34 +205,32 @@ impl FeroxResponse {
}
/// Create a new `FeroxResponse` from the given `Response`
pub async fn from(response: Response, read_body: bool, output_level: OutputLevel) -> Self {
pub async fn from(
response: Response,
original_url: &str,
method: &str,
output_level: OutputLevel,
) -> Self {
let url = response.url().clone();
let 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::warn!("Could not parse body from response: {}", e);
String::new()
}
}
} else {
String::new()
};
// .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,
@@ -221,9 +238,67 @@ impl FeroxResponse {
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()) // in -s list
// or -C was used, and -s should be all responses that aren't filtered
|| !handles.config.filter_status.is_empty()
{
// only add extensions to those responses that pass our checks; filtered out
// status codes are handled by should_filter, but we need to still check against
// the allow list for what we want to keep
#[cfg(test)]
handles
.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))
.unwrap_or_default();
#[cfg(not(test))]
handles.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))?;
}
}
log::trace!("exit: parse_extension");
Ok(())
}
/// Helper function that determines if the configured maximum recursion depth has been reached
///
/// Essentially looks at the Url path and determines how many directories are present in the
@@ -326,53 +401,71 @@ impl FeroxSerialize for FeroxResponse {
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}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
"{} {:>8} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
wild_status,
method,
lines,
words,
chars,
status_colorizer(&status),
status_colorizer(status),
self.url(),
FeroxUrl::path_length_of_url(&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");
// initial wildcard messages are wordy enough, put the redirect by itself
url_with_redirect = format!(
"{} {:>9} {:>9} {:>9} {}\n",
wild_status, "-", "-", "-", url_with_redirect
);
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 (either empty string or redir msg)
message.push_str(&url_with_redirect);
}
// base message + redirection message (if appropriate)
message
} else {
// not a wildcard, just create a normal entry
utils::create_report_string(
self.status.as_str(),
method,
&lines,
&words,
&chars,
self.url().as_str(),
&url_with_redirect,
self.output_level,
)
}
@@ -423,7 +516,7 @@ impl Serialize for FeroxResponse {
S: Serializer,
{
let mut headers = HashMap::new();
let mut state = serializer.serialize_struct("FeroxResponse", 7)?;
let mut state = serializer.serialize_struct("FeroxResponse", 8)?;
// need to convert the HeaderMap to a HashMap in order to pass it to the serializer
for (key, value) in &self.headers {
@@ -434,13 +527,19 @@ impl Serialize for FeroxResponse {
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()
}
@@ -455,7 +554,9 @@ impl<'de> Deserialize<'de> for FeroxResponse {
{
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(),
@@ -463,6 +564,7 @@ impl<'de> Deserialize<'de> for FeroxResponse {
output_level: Default::default(),
line_count: 0,
word_count: 0,
extension: None,
};
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
@@ -476,6 +578,11 @@ impl<'de> Deserialize<'de> for FeroxResponse {
}
}
}
"original_url" => {
if let Some(og_url) = value.as_str() {
response.original_url = String::from(og_url);
}
}
"status" => {
if let Some(num) = value.as_u64() {
if let Ok(smaller) = u16::try_from(num) {
@@ -485,6 +592,11 @@ impl<'de> Deserialize<'de> for FeroxResponse {
}
}
}
"method" => {
if let Some(method) = value.as_str() {
response.method = Method::from_bytes(method.as_bytes()).unwrap_or_default();
}
}
"content_length" => {
if let Some(num) = value.as_u64() {
response.content_length = num;
@@ -521,6 +633,11 @@ impl<'de> Deserialize<'de> for FeroxResponse {
response.wildcard = result;
}
}
"extension" => {
if let Some(result) = value.as_str() {
response.extension = Some(result.to_string());
}
}
_ => {}
}
}
@@ -532,6 +649,8 @@ impl<'de> Deserialize<'de> for FeroxResponse {
#[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
@@ -540,14 +659,7 @@ mod tests {
let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse {
url,
status: Default::default(),
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
..Default::default()
};
let result = response.reached_max_depth(0, 0, handles);
assert!(!result);
@@ -561,14 +673,7 @@ mod tests {
let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse {
url,
status: Default::default(),
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
@@ -582,14 +687,7 @@ mod tests {
let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse {
url,
status: Default::default(),
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
@@ -603,14 +701,7 @@ mod tests {
let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse {
url,
status: Default::default(),
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
..Default::default()
};
let result = response.reached_max_depth(2, 2, handles);
@@ -624,17 +715,71 @@ mod tests {
let url = Url::parse("http://localhost/one/two/three").unwrap();
let response = FeroxResponse {
url,
status: Default::default(),
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
..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);
}
}

View File

@@ -1,27 +1,53 @@
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 {
/// character to use as visual separator of lines
separator: String,
/// name of menu
name: String,
/// header: name surrounded by separators
header: String,
/// instructions
instructions: String,
/// footer: instructions surrounded by separators
footer: String,
/// unicode line border, matched to longest displayed line
border: String,
/// target for output
term: Term,
pub(super) term: Term,
}
/// Implementation of Menu
@@ -30,43 +56,73 @@ impl Menu {
pub(super) fn new() -> Self {
let separator = "".to_string();
let instructions = format!(
"Enter a {} list of indexes/ranges to {} ({}: 1-4,8,9-13)",
style("comma-separated").yellow(),
style("cancel").red(),
style("ex").cyan(),
);
let name = format!(
"{} {} {}",
"💀",
style("Scan Cancel Menu").bright().yellow(),
style("Scan Management Menu").bright().yellow(),
"💀"
);
let force_msg = format!(
"Add {} to {} confirmation ({}: 3-5 -f)",
style("-f").yellow(),
style("skip").yellow(),
style("ex").cyan(),
let add_cmd = format!(
" {}[{}] NEW_URL (ex: {} http://localhost)\n",
style("a").green(),
style("dd").green(),
style("add").green()
);
let longest = measure_text_width(&instructions).max(measure_text_width(&name));
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 padded_force = pad_str(&force_msg, longest, Alignment::Center, None);
let header = format!("{}\n{}\n{}", border, padded_name, border);
let footer = format!("{}\n{}\n{}\n{}", border, instructions, padded_force, border);
let footer = format!("{}\n{}", commands, border);
Self {
separator,
name,
header,
instructions,
footer,
border,
term: Term::stderr(),
}
}
@@ -76,6 +132,11 @@ impl Menu {
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);
@@ -161,14 +222,68 @@ impl Menu {
nums
}
/// get comma-separated list of scan indexes from the user
pub(super) fn get_scans_from_user(&self) -> Option<(Vec<usize>, bool)> {
if let Ok(line) = self.term.read_line() {
let force = line.contains("-f");
let line = line.replace("-f", "");
Some((self.split_to_nums(&line), force))
} else {
None
/// 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
}
}
}

View File

@@ -9,6 +9,7 @@ mod state;
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};

View File

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

View File

@@ -33,16 +33,16 @@ pub struct FeroxScan {
pub(super) url: String,
/// The type of scan
pub(super) scan_type: ScanType,
pub scan_type: ScanType,
/// The order in which the scan was received
pub(super) scan_order: ScanOrder,
pub(crate) scan_order: ScanOrder,
/// Number of requests to populate the progress bar with
pub(super) num_requests: u64,
/// Status of this scan
pub(super) status: Mutex<ScanStatus>,
pub status: Mutex<ScanStatus>,
/// The spawned tokio task performing this scan (uses tokio::sync::Mutex)
pub(super) task: sync::Mutex<Option<JoinHandle<()>>>,

View File

@@ -1,17 +1,27 @@
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,
SLEEP_DURATION,
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,
@@ -46,6 +56,9 @@ pub struct FeroxScans {
/// 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
@@ -57,17 +70,20 @@ impl Serialize for FeroxScans {
where
S: Serializer,
{
if let Ok(scans) = self.scans.read() {
let mut seq = serializer.serialize_seq(Some(scans.len()))?;
for scan in scans.iter() {
seq.serialize_element(&*scan).unwrap_or_default();
}
match self.scans.read() {
Ok(scans) => {
let mut seq = serializer.serialize_seq(Some(scans.len() + 1))?;
seq.end()
} else {
// if for some reason we can't unlock the RwLock, just write an empty list
let seq = serializer.serialize_seq(Some(0))?;
seq.end()
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()
}
}
}
}
@@ -108,8 +124,8 @@ impl FeroxScans {
sentry
}
/// load serialized FeroxScan(s) into this FeroxScans
pub fn add_serialized_scans(&self, filename: &str) -> Result<()> {
/// 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)?;
@@ -121,18 +137,74 @@ impl FeroxScans {
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;
log::debug!("added: {}", deser_scan);
self.insert(Arc::new(deser_scan));
}
}
}
if let Some(extensions) = state.get("collected_extensions") {
if let Some(arr_exts) = extensions.as_array() {
if let Ok(mut guard) = self.collected_extensions.write() {
for ext in arr_exts {
let deser_ext: String =
serde_json::from_value(ext.clone()).unwrap_or_default();
guard.insert(deser_ext);
}
}
}
}
if let Some(filters) = state.get("filters") {
if let Some(arr_filters) = filters.as_array() {
for filter in arr_filters {
let final_filter: Box<dyn FeroxFilter> = if let Ok(deserialized) =
serde_json::from_value::<RegexFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<WordsFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<WildcardFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<SizeFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<LinesFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<SimilarityFilter>(filter.clone())
{
Box::new(deserialized)
} else if let Ok(deserialized) =
serde_json::from_value::<StatusCodeFilter>(filter.clone())
{
Box::new(deserialized)
} else {
Box::new(EmptyFilter {})
};
handles
.filters
.send(AddFilter(final_filter))
.unwrap_or_default();
}
}
}
log::trace!("exit: add_serialized_scans");
Ok(())
}
@@ -162,8 +234,8 @@ impl FeroxScans {
None
}
pub(super) fn get_base_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
log::trace!("enter: get_sub_paths_from_path({})", url);
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
@@ -187,14 +259,14 @@ impl FeroxScans {
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_sub_paths_from_path -> {}", scan);
log::trace!("enter: get_base_scan_by_url -> {}", scan);
return Some(scan.clone());
}
}
}
}
log::trace!("enter: get_sub_paths_from_path -> None");
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
@@ -236,6 +308,8 @@ impl FeroxScans {
.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
@@ -243,12 +317,21 @@ impl FeroxScans {
}
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
@@ -299,24 +382,64 @@ impl FeroxScans {
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) -> usize {
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 mut num_cancelled = 0_usize;
let menu_cmd = if let Ok(line) = self.menu.term.read_line() {
self.menu.get_command_input_from_user(&line)
} else {
None
};
if let Some((input, force)) = self.menu.get_scans_from_user() {
num_cancelled += self.cancel_scans(input, force).await;
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();
num_cancelled
result
}
/// prints all known responses that the scanner has already seen
@@ -364,19 +487,23 @@ impl FeroxScans {
///
/// 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) -> usize {
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 num_cancelled = 0_usize;
let mut command_result = None;
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 0 {
INTERACTIVE_BARRIER.fetch_add(1, Ordering::Relaxed);
if get_user_input {
num_cancelled += self.interactive_menu().await;
command_result = self.interactive_menu(handles).await;
PAUSE_SCAN.store(false, Ordering::Relaxed);
self.print_known_responses();
}
@@ -393,8 +520,8 @@ impl FeroxScans {
INTERACTIVE_BARRIER.fetch_sub(1, Ordering::Relaxed);
}
log::trace!("exit: pause_scan -> {}", num_cancelled);
return num_cancelled;
log::trace!("exit: pause_scan -> {:?}", command_result);
return command_result;
}
}
}
@@ -431,7 +558,7 @@ impl FeroxScans {
OutputLevel::Silent => BarType::Hidden,
};
let progress_bar = add_bar(&url, bar_length, bar_type);
let progress_bar = add_bar(url, bar_length, bar_type);
progress_bar.reset_elapsed();
@@ -441,7 +568,7 @@ impl FeroxScans {
};
let ferox_scan = FeroxScan::new(
&url,
url,
scan_type,
scan_order,
bar_length,
@@ -462,7 +589,7 @@ impl FeroxScans {
///
/// 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)
self.add_scan(url, ScanType::Directory, scan_order)
}
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a File Scan
@@ -471,7 +598,7 @@ impl FeroxScans {
///
/// 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)
self.add_scan(url, ScanType::File, scan_order)
}
/// small helper to determine whether any scans are active or not
@@ -500,4 +627,67 @@ impl FeroxScans {
}
scans
}
/// given an extension, add it to `collected_extensions` if all constraints are met
/// returns `true` if an extension was added, `false` otherwise
pub fn add_discovered_extension(&self, extension: String) -> bool {
log::trace!("enter: add_discovered_extension({})", extension);
let mut extension_added = false;
// note: the filter by --dont-collect happens in the event handler, since it has access
// to a Handles object form which it can check the config value. additionally, the check
// against --extensions is performed there for the same reason
if let Ok(extensions) = self.collected_extensions.read() {
// quicker to allow most to read and return and then reopen for write if necessary
if extensions.contains(&extension) {
return extension_added;
}
}
if let Ok(mut extensions) = self.collected_extensions.write() {
log::info!("discovered new extension: {}", extension);
extensions.insert(extension);
extension_added = true;
}
log::trace!("exit: add_discovered_extension -> {}", extension_added);
extension_added
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// unknown extension should be added to collected_extensions
fn unknown_extension_is_added_to_collected_extensions() {
let scans = FeroxScans::new(OutputLevel::Default);
assert_eq!(0, scans.collected_extensions.read().unwrap().len());
let added = scans.add_discovered_extension(String::from("js"));
assert!(added);
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
}
#[test]
/// known extension should not be added to collected_extensions
fn known_extension_is_added_to_collected_extensions() {
let scans = FeroxScans::new(OutputLevel::Default);
scans
.collected_extensions
.write()
.unwrap()
.insert(String::from("js"));
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
let added = scans.add_discovered_extension(String::from("js"));
assert!(!added);
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
}
}

View File

@@ -1,7 +1,9 @@
use super::*;
use 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
@@ -18,6 +20,12 @@ pub struct FeroxState {
/// 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
@@ -28,12 +36,20 @@ impl FeroxState {
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,
}
}
}

View File

@@ -1,4 +1,8 @@
use super::*;
use crate::filters::{
FeroxFilters, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
WordsFilter,
};
use crate::{
config::{Configuration, OutputLevel},
event_handlers::Handles,
@@ -6,10 +10,11 @@ use crate::{
scanner::RESPONSES,
statistics::Stats,
traits::FeroxSerialize,
SLEEP_DURATION, VERSION,
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;
@@ -31,6 +36,7 @@ fn default_scantype_is_file() {
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);
@@ -41,7 +47,7 @@ async fn scanner_pause_scan_with_finished_spinner() {
PAUSE_SCAN.store(false, Ordering::Relaxed);
});
urls.pause(false).await;
urls.pause(false, handles).await;
assert!(now.elapsed() > expected);
}
@@ -52,7 +58,7 @@ 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_eq!(result, true);
assert!(result);
}
#[test]
@@ -71,11 +77,11 @@ fn add_url_to_list_of_scanned_urls_with_known_url() {
Some(pb),
);
assert_eq!(urls.insert(scan), true);
assert!(urls.insert(scan));
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
assert_eq!(result, false);
assert!(!result);
}
#[test]
@@ -93,27 +99,23 @@ fn stop_progress_bar_stops_bar() {
Some(pb),
);
assert_eq!(
scan.progress_bar
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_finished(),
false
);
assert!(!scan
.progress_bar
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_finished());
scan.stop_progress_bar();
assert_eq!(
scan.progress_bar
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_finished(),
true
);
assert!(scan
.progress_bar
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_finished());
}
#[test]
@@ -131,11 +133,11 @@ fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
None,
);
assert_eq!(urls.insert(scan), true);
assert!(urls.insert(scan));
let (result, _scan) = urls.add_scan(url, ScanType::File, ScanOrder::Latest);
assert_eq!(result, false);
assert!(!result);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -171,8 +173,8 @@ async fn call_display_scans() {
.await
.unwrap();
assert_eq!(urls.insert(scan), true);
assert_eq!(urls.insert(scan_two), true);
assert!(urls.insert(scan));
assert!(urls.insert(scan_two));
urls.display_scans().await;
}
@@ -307,7 +309,7 @@ fn ferox_scans_serialize() {
#[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","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
let responses = FeroxResponses::default();
@@ -325,12 +327,12 @@ fn ferox_responses_serialize() {
/// 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","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
assert_eq!(response.url().as_str(), "https://nerdcore.com/css");
assert_eq!(response.url().path(), "/css");
assert_eq!(response.wildcard(), true);
assert!(response.wildcard());
assert_eq!(response.status().as_u16(), 301);
assert_eq!(response.content_length(), 173);
assert_eq!(response.line_count(), 10);
@@ -355,20 +357,60 @@ fn feroxstates_feroxserialize_implementation() {
);
let ferox_scans = FeroxScans::default();
let saved_id = ferox_scan.id.clone();
ferox_scans.insert(ferox_scan);
let config = Configuration::new().unwrap();
let stats = Arc::new(Stats::new(config.extensions.len(), config.json));
ferox_scans
.collected_extensions
.write()
.unwrap()
.insert(String::from("php"));
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
let mut config = Configuration::new().unwrap();
config.collect_extensions = true;
let stats = Arc::new(Stats::new(config.json));
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
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(Configuration::new().unwrap()),
Arc::new(config),
&RESPONSES,
stats,
Arc::new(filters),
);
let expected_strs = predicates::str::contains("scans: FeroxScans").and(
@@ -376,18 +418,97 @@ fn feroxstates_feroxserialize_implementation() {
.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("https://spiritanimal.com"))
.and(predicate::str::contains("php")),
);
assert!(expected_strs.eval(&ferox_state.as_str()));
let json_state = ferox_state.as_json().unwrap();
let expected = format!(
r#"{{"scans":[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}],"config":{{"type":"configuration","wordlist":"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt","config":"","proxy":"","replay_proxy":"","target_url":"","status_codes":[200,204,301,302,307,308,401,403,405],"replay_codes":[200,204,301,302,307,308,401,403,405],"filter_status":[],"threads":50,"timeout":7,"verbosity":0,"silent":false,"quiet":false,"auto_bail":false,"auto_tune":false,"json":false,"output":"","debug_log":"","user_agent":"feroxbuster/{}","redirects":false,"insecure":false,"extensions":[],"headers":{{}},"queries":[],"no_recursion":false,"extract_links":false,"add_slash":false,"stdin":false,"depth":4,"scan_limit":0,"parallel":0,"rate_limit":0,"filter_size":[],"filter_line_count":[],"filter_word_count":[],"filter_regex":[],"dont_filter":false,"resumed":false,"resume_from":"","save_state":false,"time_limit":"","filter_similar":[]}},"responses":[{{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{{"server":"nginx/1.16.1"}}}}]"#,
saved_id, VERSION
);
println!("{}\n{}", expected, json_state);
assert!(predicates::str::contains(expected).eval(&json_state));
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#""force_recursion":false"#,
r#""json":false"#,
r#""output":"""#,
r#""debug_log":"""#,
&format!(r#""user_agent":"feroxbuster/{}""#, VERSION),
r#""random_agent":false"#,
r#""redirects":false"#,
r#""insecure":false"#,
r#""extensions":[]"#,
r#""methods":["GET"],"#,
r#""data":[]"#,
r#""headers""#,
r#""queries":[]"#,
r#""no_recursion":false"#,
r#""extract_links":false"#,
r#""add_slash":false"#,
r#""stdin":false"#,
r#""depth":4"#,
r#""scan_limit":0"#,
r#""parallel":0"#,
r#""rate_limit":0"#,
r#""filter_size":[]"#,
r#""filter_line_count":[]"#,
r#""filter_word_count":[]"#,
r#""filter_regex":[]"#,
r#""dont_filter":false"#,
r#""resumed":false"#,
r#""resume_from":"""#,
r#""save_state":false"#,
r#""time_limit":"""#,
r#""filter_similar":[]"#,
r#""url_denylist":[]"#,
r#""responses""#,
r#""type":"response""#,
r#""url":"https://nerdcore.com/css""#,
r#""path":"/css""#,
r#""wildcard":true"#,
r#""status":301"#,
r#""method":"GET""#,
r#""content_length":173"#,
r#""line_count":10"#,
r#""word_count":16"#,
r#""headers""#,
r#""server":"nginx/1.16.1"#,
r#""collect_extensions":true"#,
r#""collect_backups":false"#,
r#""collect_words":false"#,
r#""filters":[{"filter_code":100},{"word_count":200},{"content_length":300},{"line_count":400},{"compiled":".*","raw_string":".*"},{"hash":"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]
@@ -509,6 +630,14 @@ async fn ferox_scan_abort() {
/// 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();
@@ -516,6 +645,57 @@ fn menu_print_header_and_footer() {
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() {

View File

@@ -1,23 +1,30 @@
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, SubtractFromUsizeField},
Command::{AddError, AddToF64Field, AddToUsizeField, SubtractFromUsizeField},
Handles,
},
extractor::{ExtractionTarget::RobotsTxt, ExtractorBuilder},
extractor::{ExtractionTarget, ExtractorBuilder},
heuristics,
scan_manager::{FeroxResponses, ScanOrder, ScanStatus, PAUSE_SCAN},
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;
@@ -27,6 +34,75 @@ lazy_static! {
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
@@ -42,7 +118,7 @@ pub struct FeroxScanner {
/// wordlist that's already been read from disk
wordlist: Arc<Vec<String>>,
/// limiter that restricts the number of active FeroxScanners
/// limiter that restricts the number of active FeroxScanners
scan_limiter: Arc<Semaphore>,
}
@@ -65,6 +141,57 @@ impl FeroxScanner {
}
}
/// 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
@@ -72,22 +199,21 @@ impl FeroxScanner {
log::trace!("enter: scan_url");
log::info!("Starting scan against: {}", self.target_url);
let scan_timer = Instant::now();
let mut scan_timer = Instant::now();
if matches!(self.order, ScanOrder::Initial) && self.handles.config.extract_links {
// only grab robots.txt on the initial scan_url calls. all fresh dirs will be passed
// to try_recursion
let extractor = ExtractorBuilder::default()
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())
.target(RobotsTxt)
.build()?;
let _ = extractor.extract().await;
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)?;
@@ -110,66 +236,109 @@ impl FeroxScanner {
// to the caller.
let _permit = self.scan_limiter.acquire().await;
// Arc clones to be passed around to the various scans
let looping_words = self.wordlist.clone();
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())?);
let increment_len = (self.handles.config.extensions.len() + 1) as u64;
// producer tasks (mp of mpsc); responsible for making requests
let producers = stream::iter(looping_words.deref().to_owned())
.map(|word| {
let pb = progress_bar.clone(); // progress bar is an Arc around internal state
let scanned_urls_clone = scanned_urls.clone();
let requester_clone = requester.clone();
let handles_clone = self.handles.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
let num_cancelled = scanned_urls_clone.pause(true).await;
if num_cancelled > 0 {
handles_clone
.stats
.send(SubtractFromUsizeField(TotalExpected, num_cancelled))
.unwrap_or_else(|e| {
log::warn!("Could not update overall scan bar: {}", e)
});
}
}
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(_) => {
bar.inc(increment_len);
}
Err(e) => {
log::warn!("error awaiting a response: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
}
}
});
self.stream_requests(
looping_words.clone(),
progress_bar.clone(),
scanned_urls.clone(),
requester.clone(),
)
.await;
// await tx tasks
log::trace!("awaiting scan producers");
producers.await;
log::trace!("done awaiting scan producers");
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,

View File

@@ -11,12 +11,7 @@ pub async fn initialize(num_words: usize, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: initialize({}, {:?})", num_words, handles);
// number of requests only needs to be calculated once, and then can be reused
let num_reqs_expected: u64 = if handles.config.extensions.is_empty() {
num_words.try_into()?
} else {
let total = num_words * (handles.config.extensions.len() + 1);
total.try_into()?
};
let num_reqs_expected: u64 = handles.expected_num_requests_per_dir().try_into()?;
{
// no real reason to keep the arc around beyond this call

View File

@@ -90,11 +90,9 @@ impl PolicyData {
atomic_store!(self.remove_limit, true);
}
}
self.set_limit(heap.value() as usize);
} else if heap.has_children() {
// streak not at 3, just check that we can move down, and do so
heap.move_left();
self.set_limit(heap.value() as usize);
} else {
// tree bottomed out, need to move back up the tree a bit
let current = heap.value();
@@ -104,9 +102,8 @@ impl PolicyData {
if current > heap.value() {
heap.move_up();
}
self.set_limit(heap.value() as usize);
}
self.set_limit(heap.value() as usize);
}
}
@@ -200,7 +197,7 @@ mod tests {
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 300);
assert_eq!(pd.limit.load(Ordering::Relaxed), 300);
assert_eq!(pd.remove_limit.load(Ordering::Relaxed), false);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
@@ -217,7 +214,7 @@ mod tests {
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 200);
assert_eq!(pd.limit.load(Ordering::Relaxed), 200);
assert_eq!(pd.remove_limit.load(Ordering::Relaxed), true);
assert!(pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
@@ -234,7 +231,7 @@ mod tests {
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 350);
assert_eq!(pd.limit.load(Ordering::Relaxed), 350);
assert_eq!(pd.remove_limit.load(Ordering::Relaxed), false);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
@@ -251,7 +248,7 @@ mod tests {
pd.adjust_up(&3);
assert_eq!(pd.heap.read().unwrap().value(), 300);
assert_eq!(pd.limit.load(Ordering::Relaxed), 300);
assert_eq!(pd.remove_limit.load(Ordering::Relaxed), false);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
@@ -269,7 +266,7 @@ mod tests {
pd.adjust_up(&0);
assert_eq!(pd.heap.read().unwrap().value(), 43);
assert_eq!(pd.limit.load(Ordering::Relaxed), 43);
assert_eq!(pd.remove_limit.load(Ordering::Relaxed), false);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]
@@ -287,7 +284,7 @@ mod tests {
pd.adjust_up(&0);
assert_eq!(pd.heap.read().unwrap().value(), 37);
assert_eq!(pd.limit.load(Ordering::Relaxed), 37);
assert_eq!(pd.remove_limit.load(Ordering::Relaxed), false);
assert!(!pd.remove_limit.load(Ordering::Relaxed));
}
#[test]

View File

@@ -1,12 +1,14 @@
use std::{
cmp::max,
sync::{atomic::Ordering, Arc, Mutex},
collections::HashSet,
sync::{self, atomic::Ordering, Arc, Mutex},
};
use anyhow::Result;
use lazy_static::lazy_static;
use leaky_bucket::LeakyBucket;
use tokio::{
sync::{oneshot, RwLock},
sync::RwLock,
time::{sleep, Duration},
};
@@ -14,20 +16,26 @@ use crate::{
atomic_load, atomic_store,
config::RequesterPolicy,
event_handlers::{
Command::{self, AddError, SubtractFromUsizeField},
Command::{AddError, SubtractFromUsizeField},
Handles,
},
extractor::{ExtractionTarget::ResponseBody, ExtractorBuilder},
extractor::{ExtractionTarget, ExtractorBuilder},
nlp::{Document, TfIdf},
response::FeroxResponse,
scan_manager::{FeroxScan, ScanStatus},
statistics::{StatError::Other, StatField::TotalExpected},
url::FeroxUrl,
utils::logged_request,
utils::{logged_request, send_try_recursion_command, should_deny_url},
HIGH_ERROR_RATIO,
};
use super::{policy_data::PolicyData, FeroxScanner, PolicyTrigger};
lazy_static! {
/// make sure to note that this is a std rwlock and not tokio
pub(crate) static ref TF_IDF: Arc<sync::RwLock<TfIdf>> = Arc::new(sync::RwLock::new(TfIdf::new()));
}
/// Makes multiple requests based on the presence of extensions
pub(super) struct Requester {
/// handles to handlers and config
@@ -45,11 +53,16 @@ pub(super) struct Requester {
/// FeroxScan associated with the creation of this Requester
ferox_scan: Arc<FeroxScan>,
/// cache of previously seen links gotten via link extraction. since the requester is passed
/// around as an arc, and seen_links needs to be mutable, putting it behind a lock for
/// interior mutability, similar to the tuning_lock below
seen_links: RwLock<HashSet<String>>,
/// simple lock to control access to tuning to a single thread (per-scan)
///
/// need a usize to determine the number of consecutive non-error calls that a requester has
/// seen; this will satisfy the non-mut self constraint (due to us being behind an Arc, and
/// the need for a counter
/// the need for a counter)
tuning_lock: Mutex<usize>,
}
@@ -73,6 +86,7 @@ impl Requester {
Ok(Self {
ferox_scan,
policy_data,
seen_links: RwLock::new(HashSet::<String>::new()),
rate_limiter: RwLock::new(rate_limiter),
handles: scanner.handles.clone(),
target_url: scanner.target_url.to_owned(),
@@ -295,84 +309,176 @@ impl Requester {
pub async fn request(&self, word: &str) -> Result<()> {
log::trace!("enter: request({})", word);
let urls =
FeroxUrl::from_string(&self.target_url, self.handles.clone()).formatted_urls(word)?;
let collected = self.handles.collected_extensions();
let urls = FeroxUrl::from_string(&self.target_url, self.handles.clone())
.formatted_urls(word, collected)?;
let should_test_deny = !self.handles.config.url_denylist.is_empty()
|| !self.handles.config.regex_denylist.is_empty();
for url in urls {
// auto_tune is true, or rate_limit was set (mutually exclusive to user)
// and a rate_limiter has been created
// short-circuiting the lock access behind the first boolean check
let should_tune = self.handles.config.auto_tune || self.handles.config.rate_limit > 0;
let should_limit = should_tune && self.rate_limiter.read().await.is_some();
for method in self.handles.config.methods.iter() {
// auto_tune is true, or rate_limit was set (mutually exclusive to user)
// and a rate_limiter has been created
// short-circuiting the lock access behind the first boolean check
let should_tune =
self.handles.config.auto_tune || self.handles.config.rate_limit > 0;
let should_limit = should_tune && self.rate_limiter.read().await.is_some();
if should_limit {
// found a rate limiter, limit that junk!
if let Err(e) = self.limit().await {
log::warn!("Could not rate limit scan: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
if should_limit {
// found a rate limiter, limit that junk!
if let Err(e) = self.limit().await {
log::warn!("Could not rate limit scan: {}", e);
self.handles.stats.send(AddError(Other)).unwrap_or_default();
}
}
}
let response = logged_request(&url, self.handles.clone()).await?;
if should_test_deny && should_deny_url(&url, self.handles.clone())? {
// can't allow a denied url to be requested
continue;
}
if (should_tune || self.handles.config.auto_bail)
&& !atomic_load!(self.policy_data.cooling_down, Ordering::SeqCst)
{
// only check for policy enforcement when the trigger isn't on cooldown and tuning
// or bailing is in place (should_tune used here because when auto-tune is on, we'll
// reach this without a rate_limiter in place)
match self.policy_data.policy {
RequesterPolicy::AutoTune => {
if let Some(trigger) = self.should_enforce_policy() {
self.tune(trigger).await?;
let data = if self.handles.config.data.is_empty() {
None
} else {
Some(self.handles.config.data.as_slice())
};
let response =
logged_request(&url, method.as_str(), data, self.handles.clone()).await?;
if (should_tune || self.handles.config.auto_bail)
&& !atomic_load!(self.policy_data.cooling_down, Ordering::SeqCst)
{
// only check for policy enforcement when the trigger isn't on cooldown and tuning
// or bailing is in place (should_tune used here because when auto-tune is on, we'll
// reach this without a rate_limiter in place)
match self.policy_data.policy {
RequesterPolicy::AutoTune => {
if let Some(trigger) = self.should_enforce_policy() {
self.tune(trigger).await?;
}
}
RequesterPolicy::AutoBail => {
if let Some(trigger) = self.should_enforce_policy() {
self.bail(trigger).await?;
}
}
RequesterPolicy::Default => {}
}
}
// response came back without error, convert it to FeroxResponse
let mut ferox_response = FeroxResponse::from(
response,
&self.target_url,
method,
self.handles.config.output_level,
)
.await;
// do recursion if appropriate
if !self.handles.config.no_recursion && !self.handles.config.force_recursion {
// to support --force-recursion, we want to limit recursive calls to only
// 'found' assets. That means we need to either gate or delay the call.
//
// this branch will retain the 'old' behavior by checking that
// --force-recursion isn't turned on
send_try_recursion_command(self.handles.clone(), ferox_response.clone())
.await?;
}
// purposefully doing recursion before filtering. the thought process is that
// even though this particular url is filtered, subsequent urls may not
if self
.handles
.filters
.data
.should_filter_response(&ferox_response, self.handles.stats.tx.clone())
{
continue;
}
if !self.handles.config.no_recursion && self.handles.config.force_recursion {
// in this branch, we're saying that both recursion AND force recursion
// are turned on. It comes after should_filter_response, so those cases
// are handled. Now we need to account for -s/-C options.
if self.handles.config.filter_status.is_empty() {
// -C wasn't used, so -s is the only 'filter' left to account for
if self
.handles
.config
.status_codes
.contains(&ferox_response.status().as_u16())
{
send_try_recursion_command(
self.handles.clone(),
ferox_response.clone(),
)
.await?;
}
} else {
// -C was used, that means the filters above would have removed
// those responses, and anything else should be let through
send_try_recursion_command(self.handles.clone(), ferox_response.clone())
.await?;
}
}
if self.handles.config.collect_extensions {
ferox_response.parse_extension(self.handles.clone())?;
}
if self.handles.config.collect_words {
if let Ok(mut guard) = TF_IDF.write() {
let doc = Document::from_html(ferox_response.text());
guard.add_document(doc);
if guard.num_documents() % 12 == 0
|| (guard.num_documents() < 5 && guard.num_documents() % 2 == 0)
{
guard.calculate_tf_idf_scores();
}
}
RequesterPolicy::AutoBail => {
if let Some(trigger) = self.should_enforce_policy() {
self.bail(trigger).await?;
}
if self.handles.config.extract_links {
let mut extractor = ExtractorBuilder::default()
.target(ExtractionTarget::ResponseBody)
.response(&ferox_response)
.handles(self.handles.clone())
.build()?;
let new_links: HashSet<_>;
let result = extractor.extract().await?;
{
// gain and quickly drop the read lock on seen_links, using it while unlocked
// to determine if there are any new links to process
let read_links = self.seen_links.read().await;
new_links = result.difference(&read_links).cloned().collect();
}
if !new_links.is_empty() {
// using is_empty instead of direct iteration to acquire the write lock behind
// some kind of less expensive gate (and not in a loop, obv)
let mut write_links = self.seen_links.write().await;
for new_link in &new_links {
write_links.insert(new_link.to_owned());
}
}
RequesterPolicy::Default => {}
if !new_links.is_empty() {
extractor.request_links(new_links).await?;
}
}
}
// response came back without error, convert it to FeroxResponse
let ferox_response =
FeroxResponse::from(response, true, self.handles.config.output_level).await;
// do recursion if appropriate
if !self.handles.config.no_recursion {
self.handles
.send_scan_command(Command::TryRecursion(Box::new(ferox_response.clone())))?;
let (tx, rx) = oneshot::channel::<bool>();
self.handles.send_scan_command(Command::Sync(tx))?;
rx.await?;
}
// purposefully doing recursion before filtering. the thought process is that
// even though this particular url is filtered, subsequent urls may not
if self
.handles
.filters
.data
.should_filter_response(&ferox_response, self.handles.stats.tx.clone())
{
continue;
}
if self.handles.config.extract_links && !ferox_response.status().is_redirection() {
let extractor = ExtractorBuilder::default()
.target(ResponseBody)
.response(&ferox_response)
.handles(self.handles.clone())
.build()?;
extractor.extract().await?;
}
// everything else should be reported
if let Err(e) = ferox_response.send_report(self.handles.output.tx.clone()) {
log::warn!("Could not send FeroxResponse to output handler: {}", e);
// everything else should be reported
if let Err(e) = ferox_response.send_report(self.handles.output.tx.clone()) {
log::warn!("Could not send FeroxResponse to output handler: {}", e);
}
}
}
@@ -390,6 +496,7 @@ mod tests {
use crate::{
config::Configuration,
config::OutputLevel,
event_handlers::Command::AddStatus,
event_handlers::{FiltersHandler, ScanHandler, StatsHandler, Tasks, TermOutHandler},
filters,
scan_manager::{ScanOrder, ScanType},
@@ -407,12 +514,14 @@ mod tests {
let (filters_task, filters_handle) = FiltersHandler::initialize();
let (out_task, out_handle) =
TermOutHandler::initialize(configuration.clone(), stats_handle.tx.clone());
let wordlist = Arc::new(vec![String::from("this_is_a_test")]);
let handles = Arc::new(Handles::new(
stats_handle,
filters_handle,
out_handle,
configuration.clone(),
wordlist,
));
let (scan_task, scan_handle) = ScanHandler::initialize(handles.clone());
@@ -428,10 +537,7 @@ mod tests {
/// helper to stay DRY
async fn increment_errors(handles: Arc<Handles>, scan: Arc<FeroxScan>, num_errors: usize) {
for _ in 0..num_errors {
handles
.stats
.send(Command::AddError(StatError::Other))
.unwrap();
handles.stats.send(AddError(StatError::Other)).unwrap();
scan.add_error();
}
@@ -468,7 +574,7 @@ mod tests {
code: StatusCode,
) {
for _ in 0..num_codes {
handles.stats.send(Command::AddStatus(code)).unwrap();
handles.stats.send(AddStatus(code)).unwrap();
if code == StatusCode::FORBIDDEN {
scan.add_403();
} else {
@@ -536,9 +642,10 @@ mod tests {
let requester = Requester {
handles,
target_url: "http://localhost".to_string(),
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(FeroxScan::default()),
target_url: "http://localhost".to_string(),
rate_limiter: RwLock::new(None),
policy_data: Default::default(),
};
@@ -563,6 +670,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: ferox_scan.clone(),
target_url: "http://localhost".to_string(),
@@ -587,6 +695,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: ferox_scan.clone(),
target_url: "http://localhost".to_string(),
@@ -626,6 +735,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: ferox_scan.clone(),
target_url: "http://localhost".to_string(),
@@ -680,6 +790,7 @@ mod tests {
let req_clone = scan_two.clone();
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: req_clone,
target_url: "http://one/one/stuff.php".to_string(),
@@ -713,6 +824,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(FeroxScan::default()),
target_url: "http://one/one/stuff.php".to_string(),
@@ -734,6 +846,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(FeroxScan::default()),
target_url: "http://localhost".to_string(),
@@ -756,6 +869,7 @@ mod tests {
let requester = Arc::new(Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(FeroxScan::default()),
target_url: "http://localhost".to_string(),
@@ -772,7 +886,7 @@ mod tests {
requester.cool_down().await;
assert_eq!(resp.await.unwrap(), true);
assert!(resp.await.unwrap());
println!("{}", start.elapsed().as_millis());
assert!(start.elapsed().as_millis() >= 3500);
}
@@ -785,6 +899,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(FeroxScan::default()),
target_url: "http://localhost".to_string(),
@@ -822,6 +937,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(scan),
target_url: "http://localhost".to_string(),
@@ -857,6 +973,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(scan),
target_url: "http://localhost".to_string(),
@@ -884,6 +1001,7 @@ mod tests {
let mut requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(FeroxScan::default()),
target_url: "http://localhost".to_string(),
@@ -891,28 +1009,16 @@ mod tests {
policy_data: PolicyData::new(RequesterPolicy::AutoBail, 7),
};
assert_eq!(
requester.too_many_status_errors(PolicyTrigger::Errors),
false
);
assert!(!requester.too_many_status_errors(PolicyTrigger::Errors));
assert_eq!(
requester.too_many_status_errors(PolicyTrigger::Status429),
false
);
assert!(!requester.too_many_status_errors(PolicyTrigger::Status429));
requester.ferox_scan.progress_bar().set_position(10);
requester.ferox_scan.add_429();
requester.ferox_scan.add_429();
requester.ferox_scan.add_429();
assert_eq!(
requester.too_many_status_errors(PolicyTrigger::Status429),
true
);
assert!(requester.too_many_status_errors(PolicyTrigger::Status429));
assert_eq!(
requester.too_many_status_errors(PolicyTrigger::Status403),
false
);
assert!(!requester.too_many_status_errors(PolicyTrigger::Status403));
requester.ferox_scan = Arc::new(FeroxScan::default());
requester.ferox_scan.progress_bar().set_position(10);
requester.ferox_scan.add_403();
@@ -924,10 +1030,7 @@ mod tests {
requester.ferox_scan.add_403();
requester.ferox_scan.add_403();
requester.ferox_scan.add_403();
assert_eq!(
requester.too_many_status_errors(PolicyTrigger::Status403),
true
);
assert!(requester.too_many_status_errors(PolicyTrigger::Status403));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -941,6 +1044,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: Arc::new(FeroxScan::default()),
target_url: "http://localhost".to_string(),
@@ -983,6 +1087,7 @@ mod tests {
let requester = Requester {
handles,
seen_links: RwLock::new(HashSet::<String>::new()),
tuning_lock: Mutex::new(0),
ferox_scan: scan.clone(),
target_url: "http://localhost".to_string(),

View File

@@ -69,6 +69,10 @@ pub struct Stats {
/// response bodies and robots.txt as of v1.11.0
links_extracted: AtomicUsize,
/// tracker for number of extensions discovered when `--collect-extensions` is used; sources
/// are response bodies
extensions_collected: AtomicUsize,
/// tracker for overall number of 200s seen by the client
status_200s: AtomicUsize,
@@ -126,9 +130,6 @@ pub struct Stats {
/// tracker for total runtime
total_runtime: Mutex<Vec<f64>>,
/// tracker for the number of extensions the user specified
num_extensions: usize,
/// tracker for whether to use json during serialization or not
json: bool,
}
@@ -169,6 +170,10 @@ impl Serialize for Stats {
state.serialize_field("total_scans", &atomic_load!(self.total_scans))?;
state.serialize_field("initial_targets", &atomic_load!(self.initial_targets))?;
state.serialize_field("links_extracted", &atomic_load!(self.links_extracted))?;
state.serialize_field(
"extensions_collected",
&atomic_load!(self.extensions_collected),
)?;
state.serialize_field("status_200s", &atomic_load!(self.status_200s))?;
state.serialize_field("status_301s", &atomic_load!(self.status_301s))?;
state.serialize_field("status_302s", &atomic_load!(self.status_302s))?;
@@ -203,7 +208,7 @@ impl<'a> Deserialize<'a> for Stats {
where
D: Deserializer<'a>,
{
let stats = Self::new(0, false);
let stats = Self::new(false);
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
@@ -293,6 +298,13 @@ impl<'a> Deserialize<'a> for Stats {
}
}
}
"extensions_collected" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
atomic_increment!(stats.extensions_collected, parsed);
}
}
}
"status_200s" => {
if let Some(num) = value.as_u64() {
if let Ok(parsed) = usize::try_from(num) {
@@ -446,9 +458,8 @@ impl<'a> Deserialize<'a> for Stats {
impl Stats {
/// Small wrapper for default to set `kind` to "statistics" and `total_runtime` to have at least
/// one value
pub fn new(num_extensions: usize, is_json: bool) -> Self {
pub fn new(is_json: bool) -> Self {
Self {
num_extensions,
json: is_json,
kind: String::from("statistics"),
total_runtime: Mutex::new(vec![0.0]),
@@ -620,12 +631,10 @@ impl Stats {
atomic_increment!(self.expected_per_scan, value);
}
StatField::TotalScans => {
let multiplier = self.num_extensions.max(1);
atomic_increment!(self.total_scans, value);
atomic_increment!(
self.total_expected,
value * self.expected_per_scan.load(Ordering::Relaxed) * multiplier
value * self.expected_per_scan.load(Ordering::Relaxed)
);
}
StatField::TotalExpected => {
@@ -634,6 +643,9 @@ impl Stats {
StatField::LinksExtracted => {
atomic_increment!(self.links_extracted, value);
}
StatField::ExtensionsCollected => {
atomic_increment!(self.extensions_collected, value);
}
StatField::WildcardsFiltered => {
atomic_increment!(self.wildcards_filtered, value);
atomic_increment!(self.responses_filtered, value);
@@ -670,6 +682,10 @@ impl Stats {
atomic_increment!(self.client_errors, atomic_load!(d_stats.client_errors));
atomic_increment!(self.server_errors, atomic_load!(d_stats.server_errors));
atomic_increment!(self.links_extracted, atomic_load!(d_stats.links_extracted));
atomic_increment!(
self.extensions_collected,
atomic_load!(d_stats.extensions_collected)
);
atomic_increment!(self.status_200s, atomic_load!(d_stats.status_200s));
atomic_increment!(self.status_301s, atomic_load!(d_stats.status_301s));
atomic_increment!(self.status_302s, atomic_load!(d_stats.status_302s));
@@ -792,7 +808,7 @@ mod tests {
/// - errors
fn stats_increments_timeouts() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json);
let stats = Stats::new(config.json);
stats.add_error(StatError::Timeout);
stats.add_error(StatError::Timeout);
@@ -810,7 +826,7 @@ mod tests {
/// - responses_filtered
fn stats_increments_wildcards() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json);
let stats = Stats::new(config.json);
assert_eq!(stats.responses_filtered.load(Ordering::Relaxed), 0);
assert_eq!(stats.wildcards_filtered.load(Ordering::Relaxed), 0);
@@ -826,7 +842,7 @@ mod tests {
/// when Stats::update_usize_field receives StatField::ResponsesFiltered, it should increment
fn stats_increments_responses_filtered() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json);
let stats = Stats::new(config.json);
assert_eq!(stats.responses_filtered.load(Ordering::Relaxed), 0);
@@ -840,9 +856,9 @@ mod tests {
#[test]
/// Stats::merge_from should properly increment expected fields and ignore others
fn stats_merge_from_alters_correct_fields() {
let contents = r#"{"statistics":{"type":"statistics","timeouts":1,"requests":9207,"expected_per_scan":707,"total_expected":9191,"errors":3,"successes":720,"redirects":13,"client_errors":8474,"server_errors":2,"total_scans":13,"initial_targets":1,"links_extracted":51,"status_403s":3,"status_200s":720,"status_301s":12,"status_302s":1,"status_401s":4,"status_429s":2,"status_500s":5,"status_503s":9,"status_504s":6,"status_508s":7,"wildcards_filtered":707,"responses_filtered":707,"resources_discovered":27,"directory_scan_times":[2.211973078,1.989015505,1.898675839,3.9714468910000003,4.938152838,5.256073528,6.021986595,6.065740734,6.42633762,7.095142125,7.336982137,5.319785619,4.843649778],"total_runtime":[11.556575456000001],"url_format_errors":17,"redirection_errors":12,"connection_errors":21,"request_errors":4}}"#;
let contents = r#"{"statistics":{"type":"statistics","timeouts":1,"requests":9207,"expected_per_scan":707,"total_expected":9191,"errors":3,"successes":720,"redirects":13,"client_errors":8474,"server_errors":2,"total_scans":13,"initial_targets":1,"links_extracted":51,"extensions_collected":4,"status_403s":3,"status_200s":720,"status_301s":12,"status_302s":1,"status_401s":4,"status_429s":2,"status_500s":5,"status_503s":9,"status_504s":6,"status_508s":7,"wildcards_filtered":707,"responses_filtered":707,"resources_discovered":27,"directory_scan_times":[2.211973078,1.989015505,1.898675839,3.9714468910000003,4.938152838,5.256073528,6.021986595,6.065740734,6.42633762,7.095142125,7.336982137,5.319785619,4.843649778],"total_runtime":[11.556575456000001],"url_format_errors":17,"redirection_errors":12,"connection_errors":21,"request_errors":4}}"#;
let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json);
let stats = Stats::new(config.json);
let tfile = NamedTempFile::new().unwrap();
write(&tfile, contents).unwrap();
@@ -863,6 +879,7 @@ mod tests {
assert_eq!(atomic_load!(stats.total_scans), 0); // not updated in merge_from
assert_eq!(atomic_load!(stats.initial_targets), 0); // not updated in merge_from
assert_eq!(atomic_load!(stats.links_extracted), 51);
assert_eq!(atomic_load!(stats.extensions_collected), 4);
assert_eq!(atomic_load!(stats.status_200s), 720);
assert_eq!(atomic_load!(stats.status_301s), 12);
assert_eq!(atomic_load!(stats.status_302s), 1);
@@ -893,7 +910,7 @@ mod tests {
/// ensure update runtime overwrites the default 0th entry
fn update_runtime_works() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json);
let stats = Stats::new(config.json);
assert!((stats.total_runtime.lock().unwrap()[0] - 0.0).abs() < f64::EPSILON);
stats.update_runtime(20.2);
@@ -904,7 +921,7 @@ mod tests {
/// ensure status_403s returns the correct value
fn status_403s_returns_correct_value() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json);
let stats = Stats::new(config.json);
stats.status_403s.store(12, Ordering::Relaxed);
assert_eq!(stats.status_403s(), 12);
}
@@ -913,7 +930,7 @@ mod tests {
/// ensure status_403s returns the correct value
fn status_429s_returns_correct_value() {
let config = Configuration::new().unwrap();
let stats = Stats::new(config.extensions.len(), config.json);
let stats = Stats::new(config.json);
stats.status_429s.store(141, Ordering::Relaxed);
assert_eq!(stats.status_429s(), 141);
}

View File

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

View File

@@ -18,10 +18,10 @@ macro_rules! atomic_increment {
#[macro_export]
macro_rules! atomic_load {
($metric:expr) => {
$metric.load(Ordering::Relaxed);
$metric.load(Ordering::Relaxed)
};
($metric:expr, $ordering:expr) => {
$metric.load($ordering);
$metric.load($ordering)
};
}

View File

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

View File

@@ -1,9 +1,14 @@
//! collection of all traits used
use crate::filters::{
LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter, WildcardFilter,
WordsFilter,
};
use crate::response::FeroxResponse;
use anyhow::Result;
use crossterm::style::{style, Stylize};
use serde::Serialize;
use std::any::Any;
use std::fmt::Debug;
use std::fmt::{self, Debug, Display, Formatter};
// references:
// https://dev.to/magnusstrale/rust-trait-objects-in-a-vector-non-trivial-4co5
@@ -22,6 +27,36 @@ pub trait FeroxFilter: Debug + Send + Sync {
fn as_any(&self) -> &dyn Any;
}
impl Display for dyn FeroxFilter {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
if let Some(filter) = self.as_any().downcast_ref::<LinesFilter>() {
write!(f, "Line count: {}", style(filter.line_count).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<WordsFilter>() {
write!(f, "Word count: {}", style(filter.word_count).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<SizeFilter>() {
write!(f, "Response size: {}", style(filter.content_length).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<RegexFilter>() {
write!(f, "Regex: {}", style(&filter.raw_string).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<WildcardFilter>() {
if filter.dynamic != u64::MAX {
write!(f, "Dynamic wildcard: {}", style(filter.dynamic).cyan())
} else {
write!(f, "Static wildcard: {}", style(filter.size).cyan())
}
} else if let Some(filter) = self.as_any().downcast_ref::<StatusCodeFilter>() {
write!(f, "Status code: {}", style(filter.filter_code).cyan())
} else if let Some(filter) = self.as_any().downcast_ref::<SimilarityFilter>() {
write!(
f,
"Pages similar to: {}",
style(&filter.original_url).cyan()
)
} else {
write!(f, "Filter: {:?}", self)
}
}
}
/// implementation of PartialEq, necessary long-form due to "trait cannot be made into an object"
/// error when attempting to derive PartialEq on the trait itself
impl PartialEq for Box<dyn FeroxFilter> {

View File

@@ -1,13 +1,14 @@
use crate::{event_handlers::Handles, statistics::StatError::UrlFormat, Command::AddError};
use anyhow::{anyhow, bail, Result};
use reqwest::Url;
use std::collections::HashSet;
use std::{convert::TryInto, fmt, sync::Arc};
/// abstraction around target urls; collects all Url related shenanigans in one place
#[derive(Debug)]
pub struct FeroxUrl {
/// string representation of the target url
target: String,
pub target: String,
/// Handles object for grabbing config values
handles: Arc<Handles>,
@@ -37,25 +38,40 @@ impl FeroxUrl {
///
/// If any extensions were passed to the program, each extension will add a
/// (base_url + word + ext) Url to the vector
pub fn formatted_urls(&self, word: &str) -> Result<Vec<Url>> {
pub fn formatted_urls(
&self,
word: &str,
collected_extensions: HashSet<String>,
) -> Result<Vec<Url>> {
log::trace!("enter: formatted_urls({})", word);
let mut urls = vec![];
match self.format(word, None) {
let slash = if self.handles.config.add_slash {
Some("/")
} else {
None
};
match self.format(word, slash) {
// default request, i.e. no extension
Ok(url) => urls.push(url),
Err(_) => self.handles.stats.send(AddError(UrlFormat))?,
}
for ext in self.handles.config.extensions.iter() {
for ext in self
.handles
.config
.extensions
.iter()
.chain(collected_extensions.iter())
{
match self.format(word, Some(ext)) {
// any extensions passed in
Ok(url) => urls.push(url),
Err(_) => self.handles.stats.send(AddError(UrlFormat))?,
}
}
log::trace!("exit: formatted_urls -> {:?}", urls);
Ok(urls)
}
@@ -66,7 +82,7 @@ impl FeroxUrl {
pub fn format(&self, word: &str, extension: Option<&str>) -> Result<Url> {
log::trace!("enter: format({}, {:?})", word, extension);
if Url::parse(&word).is_ok() {
if Url::parse(word).is_ok() {
// when a full url is passed in as a word to be joined to a base url using
// reqwest::Url::join, the result is that the word (url) completely overwrites the base
// url, potentially resulting in requests to places that aren't actually the target
@@ -97,13 +113,25 @@ impl FeroxUrl {
self.target.to_string()
};
// extensions and slashes are mutually exclusive cases
let word = if extension.is_some() {
format!("{}.{}", word, extension.unwrap())
} else if self.handles.config.add_slash && !word.ends_with('/') {
// -f used, and word doesn't already end with a /
format!("{}/", word)
} else if word.starts_with("//") {
// As of version 2.3.4, extensions and trailing slashes are no longer mutually exclusive.
// Trailing slashes are now treated as just another extension, which is pretty clever.
//
// In addition to the change above, @cortantief ID'd a bug here that incorrectly handled
// 2 leading forward slashes when extensions were used. This block addresses the bugfix.
let mut word = if let Some(ext) = extension {
// We handle the special case of forward slash
// That allow us to treat it as an extension with a particular format
if ext == "/" {
format!("{}/", word)
} else {
format!("{}.{}", word, ext)
}
} else {
String::from(word)
};
// We check separately if the current word begins with 2 forward slashes
if word.starts_with("//") {
// bug ID'd by @Sicks3c, when a wordlist contains words that begin with 2 forward slashes
// i.e. //1_40_0/static/js, it gets joined onto the base url in a surprising way
// ex: https://localhost/ + //1_40_0/static/js -> https://1_40_0/static/js
@@ -111,9 +139,7 @@ impl FeroxUrl {
// and simply removes prefixed forward slashes if there are two of them. Additionally,
// trim_start_matches will trim the pattern until it's gone, so even if there are more than
// 2 /'s, they'll still be trimmed
word.trim_start_matches('/').to_string()
} else {
String::from(word)
word = word.trim_start_matches('/').to_string();
};
let base_url = Url::parse(&url)?;
@@ -239,7 +265,7 @@ mod tests {
fn formatted_urls_no_extension_returns_base_url_with_word() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = FeroxUrl::from_string("http://localhost", handles);
let urls = url.formatted_urls("turbo").unwrap();
let urls = url.formatted_urls("turbo", HashSet::new()).unwrap();
assert_eq!(urls, [Url::parse("http://localhost/turbo").unwrap()])
}
@@ -253,7 +279,7 @@ mod tests {
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
let url = FeroxUrl::from_string("http://localhost", handles);
let urls = url.formatted_urls("turbo").unwrap();
let urls = url.formatted_urls("turbo", HashSet::new()).unwrap();
assert_eq!(
urls,
@@ -300,7 +326,7 @@ mod tests {
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
let url = FeroxUrl::from_string("http://localhost", handles);
let urls = url.formatted_urls("turbo").unwrap();
let urls = url.formatted_urls("turbo", HashSet::new()).unwrap();
assert_eq!(urls, expected[i]);
}
}
@@ -451,6 +477,20 @@ mod tests {
);
}
#[test]
/// word with two prepended slashes and extensions doesn't discard the entire domain
fn format_url_word_with_two_prepended_slashes_and_extensions() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = FeroxUrl::from_string("http://localhost", handles);
for ext in ["rocks", "fun"] {
let to_check = format!("http://localhost/upload/ferox.{}", ext);
assert_eq!(
url.format("//upload/ferox", Some(ext)).unwrap(),
reqwest::Url::parse(&to_check[..]).unwrap()
);
}
}
#[test]
/// word that is a fully formed url, should return an error
fn format_url_word_that_is_a_url() {
@@ -460,4 +500,33 @@ mod tests {
assert!(formatted.is_err());
}
#[test]
/// sending url + word with both an extension and add-slash should get back
/// two urls, one with '/' appended to the word, and the other with the extension
/// appended
fn formatted_urls_with_postslash_and_extensions() {
let config = Configuration {
add_slash: true,
extensions: vec!["rocks".to_string(), "fun".to_string()],
..Default::default()
};
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
let url = FeroxUrl::from_string("http://localhost", handles);
match url.formatted_urls("ferox", HashSet::new()) {
Ok(urls) => {
// 3 = One for the main word + slash and for the two extensions
assert_eq!(urls.len(), 3);
assert_eq!(
urls,
[
Url::parse("http://localhost/ferox/").unwrap(),
Url::parse("http://localhost/ferox.rocks").unwrap(),
Url::parse("http://localhost/ferox.fun").unwrap(),
]
)
}
Err(err) => panic!("{}", err.to_string()),
}
}
}

View File

@@ -1,28 +1,37 @@
use anyhow::{bail, Context, Result};
use console::{strip_ansi_codes, style, user_attended};
use indicatif::ProgressBar;
use reqwest::{Client, Response, StatusCode, Url};
use regex::Regex;
use reqwest::{Client, Method, Response, StatusCode, Url};
#[cfg(not(target_os = "windows"))]
use rlimit::{getrlimit, setrlimit, Resource, Rlim};
use rlimit::{getrlimit, setrlimit, Resource};
use std::{
fs,
io::{self, BufWriter, Write},
sync::Arc,
time::Duration,
time::{SystemTime, UNIX_EPOCH},
};
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::{mpsc::UnboundedSender, oneshot};
use crate::{
config::Configuration,
config::OutputLevel,
event_handlers::{
Command::{self, AddError, AddStatus},
Handles,
},
progress::PROGRESS_PRINTER,
response::FeroxResponse,
send_command,
statistics::StatError::{Connection, Other, Redirection, Request, Timeout},
traits::FeroxSerialize,
USER_AGENTS,
};
/// simple counter for grabbing 'random' user agents
static mut USER_AGENT_CTR: usize = 0;
/// Given the path to a file, open the file in append mode (create it if it doesn't exist) and
/// return a reference to the buffered file
pub fn open_file(filename: &str) -> Result<BufWriter<fs::File>> {
@@ -59,6 +68,20 @@ pub fn fmt_err(msg: &str) -> String {
format!("{}: {}", status_colorizer("ERROR"), msg)
}
/// given a FeroxResponse, send a TryRecursion command
///
/// moved to utils to allow for calls from extractor and scanner
pub(crate) async fn send_try_recursion_command(
handles: Arc<Handles>,
response: FeroxResponse,
) -> Result<()> {
handles.send_scan_command(Command::TryRecursion(Box::new(response.clone())))?;
let (tx, rx) = oneshot::channel::<bool>();
handles.send_scan_command(Command::Sync(tx))?;
rx.await?;
Ok(())
}
/// Takes in a string and colors it using console::style
///
/// mainly putting this here in case i want to change the color later, making any changes easy
@@ -87,15 +110,19 @@ pub fn ferox_print(msg: &str, bar: &ProgressBar) {
/// wrapper for make_request used to pass error/response codes to FeroxScans for per-scan stats
/// tracking of information related to auto-tune/bail
pub async fn logged_request(url: &Url, handles: Arc<Handles>) -> Result<Response> {
pub async fn logged_request(
url: &Url,
method: &str,
data: Option<&[u8]>,
handles: Arc<Handles>,
) -> Result<Response> {
let client = &handles.config.client;
let level = handles.config.output_level;
let tx_stats = handles.stats.tx.clone();
let response = make_request(client, url, level, tx_stats).await;
let response = make_request(client, url, method, data, level, &handles.config, tx_stats).await;
let scans = handles.ferox_scans()?;
match response {
Ok(resp) => {
match resp.status() {
@@ -118,7 +145,10 @@ pub async fn logged_request(url: &Url, handles: Arc<Handles>) -> Result<Response
pub async fn make_request(
client: &Client,
url: &Url,
method: &str,
mut data: Option<&[u8]>,
output_level: OutputLevel,
config: &Configuration,
tx_stats: UnboundedSender<Command>,
) -> Result<Response> {
log::trace!(
@@ -127,8 +157,46 @@ pub async fn make_request(
output_level,
tx_stats
);
let tmp_workaround: Option<&[u8]> = Some(&[0xd_u8, 0xa]); // \r\n
match client.get(url.to_owned()).send().await {
let mut request = client.request(Method::from_bytes(method.as_bytes())?, url.to_owned());
if (!config.proxy.is_empty() || !config.replay_proxy.is_empty())
&& data.is_none()
&& ["post", "put", "patch"].contains(&method.to_ascii_lowercase().as_str())
{
// either --proxy or --replay-proxy was specified
// AND
// --data wasn't used
// AND
// the method is either post/put/patch (case insensitive)
//
// this combination of factors results in requests that are delayed for 10 seconds before
// being issued. The tracking issues are
// https://github.com/epi052/feroxbuster/issues/501
// https://github.com/seanmonstar/reqwest/issues/1474
//
// as a (hopefully temporary) workaround, we'll add \r\n to the body so that there's no
// delay
data = tmp_workaround;
}
if let Some(body_data) = data {
request = request.body(body_data.to_vec());
}
if config.random_agent {
let index = unsafe {
USER_AGENT_CTR += 1;
USER_AGENT_CTR % USER_AGENTS.len()
};
let user_agent = USER_AGENTS[index];
request = request.header("User-Agent", user_agent);
}
match request.send().await {
Err(e) => {
log::trace!("exit: make_request -> {}", e);
@@ -137,22 +205,28 @@ pub async fn make_request(
} else if e.is_redirect() {
if let Some(last_redirect) = e.url() {
// get where we were headed (last_redirect) and where we came from (url)
let fancy_message = format!("{} !=> {}", url, last_redirect);
let fancy_message = format!(
"{} !=> {} ({})",
url,
last_redirect,
style("too many redirects").red(),
);
let report = if let Some(msg_status) = e.status() {
send_command!(tx_stats, AddStatus(msg_status));
create_report_string(
msg_status.as_str(),
"-1",
"-1",
"-1",
&fancy_message,
output_level,
)
} else {
create_report_string("UNK", "-1", "-1", "-1", &fancy_message, output_level)
let msg_status = match e.status() {
Some(status) => status.to_string(),
None => "ERR".to_string(),
};
let report = create_report_string(
&msg_status,
method,
"-1",
"-1",
"-1",
&fancy_message,
output_level,
);
send_command!(tx_stats, AddError(Redirection));
ferox_print(&report, &PROGRESS_PRINTER)
@@ -182,6 +256,7 @@ pub async fn make_request(
/// 200 127l 283w 4134c http://localhost/faq
pub fn create_report_string(
status: &str,
method: &str,
line_count: &str,
word_count: &str,
content_length: &str,
@@ -194,10 +269,17 @@ pub fn create_report_string(
} else {
// normal printing with status and sizes
let color_status = status_colorizer(status);
format!(
"{} {:>8}l {:>8}w {:>8}c {}\n",
color_status, line_count, word_count, content_length, url
)
if status.contains("MSG") {
format!(
"{} {:>8} {:>9} {:>9} {:>9} {}\n",
color_status, method, line_count, word_count, content_length, url
)
} else {
format!(
"{} {:>8} {:>8}l {:>8}w {:>8}c {}\n",
color_status, method, line_count, word_count, content_length, url
)
}
}
}
@@ -217,16 +299,15 @@ pub fn create_report_string(
/// as the adjustment made here is only valid for the scan itself (and any child processes, of which
/// there are none).
#[cfg(not(target_os = "windows"))]
pub fn set_open_file_limit(limit: usize) -> bool {
pub fn set_open_file_limit(limit: u64) -> bool {
log::trace!("enter: set_open_file_limit");
if let Ok((soft, hard)) = getrlimit(Resource::NOFILE) {
if hard.as_usize() > limit {
if hard > limit {
// our default open file limit is less than the current hard limit, this means we can
// set the soft limit to our default
let new_soft_limit = Rlim::from_usize(limit);
if setrlimit(Resource::NOFILE, new_soft_limit, hard).is_ok() {
if setrlimit(Resource::NOFILE, limit, hard).is_ok() {
log::debug!("set open file descriptor limit to {}", limit);
log::trace!("exit: set_open_file_limit -> {}", true);
@@ -287,15 +368,183 @@ where
Ok(())
}
/// determine if a url should be denied based on the given absolute url
fn should_deny_absolute(url_to_test: &Url, denier: &Url, handles: Arc<Handles>) -> Result<bool> {
log::trace!(
"enter: should_deny_absolute({}, {:?})",
url_to_test.as_str(),
denier.as_str(),
);
// simplest case is an exact match, check for it first
if url_to_test == denier {
log::trace!("exit: should_deny_absolute -> true");
return Ok(true);
}
match (url_to_test.host(), denier.host()) {
// .host() will return an enum with ipv4|6 or domain and is comparable
// whereas .domain() returns None for ip addresses
(Some(normed_host), Some(denier_host)) => {
if normed_host != denier_host {
// domains don't even match
return Ok(false);
}
}
_ => {
// one or the other couldn't determine the host value, which probably means
// it's not suitable for further comparison
return Ok(false);
}
}
let tested_host = url_to_test.host().unwrap(); // match above will catch errors
// at this point, we have a matching set of ips or domain names. now we can process the
// url path. The goal is to determine whether the given url's path is a subpath of any
// url in the deny list, for example
// GIVEN URL URL DENY LIST USER-SPECIFIED URLS TO SCAN
// http://some.domain/stuff/things, [http://some.domain/stuff], [http://some.domain] => true
// http://some.domain/stuff/things, [http://some.domain/stuff/things], [http://some.domain] => true
// http://some.domain/stuff/things, [http://some.domain/api], [http://some.domain] => false
// the examples above are all pretty obvious, the kicker comes when the blocking url's
// path is a parent to a scanned url
// http://some.domain/stuff/things, [http://some.domain/], [http://some.domain/stuff] => false
// http://some.domain/api, [http://some.domain/], [http://some.domain/stuff] => true
// we want to deny all children of the parent, unless that child is a child of a scan
// we specified through -u(s) or --stdin
let deny_path = denier.path();
let tested_path = url_to_test.path();
if tested_path.starts_with(deny_path) {
// at this point, we know that the given normalized path is a sub-path of the
// current deny-url, now we just need to check to see if this deny-url is a parent
// to a scanned url that is also a parent of the given url
for ferox_scan in handles.ferox_scans()?.get_active_scans() {
let scanner = Url::parse(ferox_scan.url().trim_end_matches('/'))
.with_context(|| format!("Could not parse {} as a url", ferox_scan))?;
if let Some(scan_host) = scanner.host() {
// same domain/ip check we perform on the denier above
if tested_host != scan_host {
// domains don't even match, keep on keepin' on...
continue;
}
} else {
// couldn't process .host from scanner
continue;
};
let scan_path = scanner.path();
if scan_path.starts_with(deny_path) && tested_path.starts_with(scan_path) {
// user-specified scan url is a sub-path of the deny-urls's path AND the
// url to check is a sub-path of the user-specified scan url
//
// the assumption is the user knew what they wanted and we're going to give
// the scanned url precedence, even though it's a sub-path
log::trace!("exit: should_deny_absolute -> false");
return Ok(false);
}
}
log::trace!("exit: should_deny_absolute -> true");
return Ok(true);
}
log::trace!("exit: should_deny_absolute -> false");
Ok(false)
}
/// determine if a url should be denied based on the given regular expression
///
/// the regex ONLY matches against the PATH of the url (not the scheme, host, port, etc)
fn should_deny_regex(url_to_test: &Url, denier: &Regex) -> bool {
log::trace!(
"enter: should_deny_regex({}, {})",
url_to_test.as_str(),
denier,
);
let result = denier.is_match(url_to_test.as_str());
log::trace!("exit: should_deny_regex -> {}", result);
result
}
/// determines whether or not a given url should be denied based on the user-supplied --dont-scan
/// flag
pub fn should_deny_url(url: &Url, handles: Arc<Handles>) -> Result<bool> {
log::trace!(
"enter: should_deny_url({}, {:?}, {:?})",
url.as_str(),
handles.config.url_denylist,
handles.ferox_scans()?
);
// normalization for comparison is to remove the trailing / if one exists, this is done for
// the given url and any url to which it's compared
let normed_url = Url::parse(url.to_string().trim_end_matches('/'))?;
for denier in &handles.config.url_denylist {
// note to self: it may seem as though we can use regex only for --dont-scan, however, in
// doing so, we lose the ability to block a parent directory while scanning a child
if let Ok(should_deny) = should_deny_absolute(&normed_url, denier, handles.clone()) {
if should_deny {
return Ok(true);
}
}
}
for denier in &handles.config.regex_denylist {
if should_deny_regex(&normed_url, denier) {
return Ok(true);
}
}
// made it to the end of the deny lists unscathed, return false, indicating we should not deny
// this particular url
log::trace!("exit: should_deny_url -> false");
Ok(false)
}
/// given a url and filename-suffix, return a unique filename comprised of the slugified url,
/// current unix timestamp and suffix
///
/// ex: ferox-http_telsa_com-1606947491.state
pub fn slugify_filename(url: &str, prefix: &str, suffix: &str) -> String {
log::trace!("enter: slugify({:?}, {:?}, {:?})", url, prefix, suffix);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs();
let altered_prefix = if !prefix.is_empty() {
format!("{}-", prefix)
} else {
String::new()
};
let slug = url.replace("://", "_").replace('/', "_").replace('.', "_");
let filename = format!("{}{}-{}.{}", altered_prefix, slug, ts, suffix);
log::trace!("exit: slugify -> {}", filename);
filename
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Configuration;
use crate::scan_manager::{FeroxScans, ScanOrder};
#[test]
/// set_open_file_limit with a low requested limit succeeds
fn utils_set_open_file_limit_with_low_requested_limit() {
let (_, hard) = getrlimit(Resource::NOFILE).unwrap();
let lower_limit = hard.as_usize() - 1;
let lower_limit = hard - 1;
assert!(set_open_file_limit(lower_limit));
}
@@ -303,9 +552,9 @@ mod tests {
/// set_open_file_limit with a high requested limit succeeds
fn utils_set_open_file_limit_with_high_requested_limit() {
let (_, hard) = getrlimit(Resource::NOFILE).unwrap();
let higher_limit = hard.as_usize() + 1;
let higher_limit = hard + 1;
// calculate a new soft to ensure soft != hard and hit that logic branch
let new_soft = Rlim::from_usize(hard.as_usize() - 1);
let new_soft = hard - 1;
setrlimit(Resource::NOFILE, new_soft, hard).unwrap();
assert!(set_open_file_limit(higher_limit));
}
@@ -316,7 +565,7 @@ mod tests {
let (_, hard) = getrlimit(Resource::NOFILE).unwrap();
// calculate a new soft to ensure soft == hard and hit the failure logic branch
setrlimit(Resource::NOFILE, hard, hard).unwrap();
assert!(!set_open_file_limit(hard.as_usize())); // returns false
assert!(!set_open_file_limit(hard)); // returns false
}
#[test]
@@ -366,4 +615,222 @@ mod tests {
fn status_colorizer_returns_as_is() {
assert_eq!(status_colorizer("farfignewton"), "farfignewton".to_string());
}
#[test]
/// provide a url that should be blocked where the denier is an exact match for the tested url
/// expect true
fn should_deny_url_blocks_when_denier_is_exact_match() {
let scan_url = "https://testdomain.com/";
let deny_url = "https://testdomain.com/denied";
let tested_url = Url::parse("https://testdomain.com/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a url that has a different host than the denier but the same path, expect false
fn should_deny_url_doesnt_compare_mismatched_domains() {
let scan_url = "https://testdomain.com/";
let deny_url = "https://dev.testdomain.com/denied";
let tested_url = Url::parse("https://testdomain.com/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(!should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier from which we can't check a host, which results in no comparison, expect false
fn should_deny_url_doesnt_compare_non_domains() {
let scan_url = "https://testdomain.com/";
let deny_url = "unix:/run/foo.socket";
let tested_url = Url::parse("https://testdomain.com/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(!should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a url that has a different host than the denier but the same path, expect false
/// because the denier is a parent to the tested, even tho the scanned doesn't compare, it
/// still returns true
fn should_deny_url_doesnt_compare_mismatched_domains_in_scanned() {
let deny_url = "https://testdomain.com/";
let scan_url = "https://dev.testdomain.com/denied";
let tested_url = Url::parse("https://testdomain.com/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier from which we can't check a host, which results in no comparison, expect false
/// because the denier is a parent to the tested, even tho the scanned doesn't compare, it
/// still returns true
fn should_deny_url_doesnt_compare_non_domains_in_scanned() {
let deny_url = "https://testdomain.com/";
let scan_url = "unix:/run/foo.socket";
let tested_url = Url::parse("https://testdomain.com/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier where the tested url is a sub-path and the scanned url is not, expect true
fn should_deny_url_blocks_child() {
let scan_url = "https://testdomain.com/";
let deny_url = "https://testdomain.com/api";
let tested_url = Url::parse("https://testdomain.com/api/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier where the tested url is not a sub-path and the scanned url is not, expect false
fn should_deny_url_doesnt_block_non_child() {
let scan_url = "https://testdomain.com/";
let deny_url = "https://testdomain.com/api";
let tested_url = Url::parse("https://testdomain.com/not-denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(!should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier where the tested url is a sub-path and the scanned url is not, expect true
fn should_deny_url_blocks_child_when_scan_url_isnt_parent() {
let scan_url = "https://testdomain.com/api";
let deny_url = "https://testdomain.com/";
let tested_url = Url::parse("https://testdomain.com/stuff/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier where the tested url is not a sub-path and the scanned url is not, expect false
fn should_deny_url_doesnt_block_child_when_scan_url_is_parent() {
let scan_url = "https://testdomain.com/api";
let deny_url = "https://testdomain.com/";
let tested_url = Url::parse("https://testdomain.com/api/not-denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.url_denylist = vec![Url::parse(deny_url).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(!should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier where the tested url is matched against a regular expression in the path
/// of the url
fn should_deny_url_blocks_urls_based_on_regex_in_path() {
let scan_url = "https://testdomain.com/";
let deny_pattern = "/deni.*";
let tested_url = Url::parse("https://testdomain.com/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.regex_denylist = vec![Regex::new(deny_pattern).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(should_deny_url(&tested_url, handles).unwrap());
}
#[test]
/// provide a denier where the tested url is matched against a regular expression in the scheme
/// of the url
fn should_deny_url_blocks_urls_based_on_regex_in_scheme() {
let scan_url = "https://testdomain.com/";
let deny_pattern = "http:";
let tested_http_url = Url::parse("http://testdomain.com/denied/").unwrap();
let tested_https_url = Url::parse("https://testdomain.com/denied/").unwrap();
let scans = Arc::new(FeroxScans::default());
scans.add_directory_scan(scan_url, ScanOrder::Initial);
let mut config = Configuration::new().unwrap();
config.regex_denylist = vec![Regex::new(deny_pattern).unwrap()];
let config = Arc::new(config);
let handles = Arc::new(Handles::for_testing(Some(scans), Some(config)).0);
assert!(!should_deny_url(&tested_https_url, handles.clone()).unwrap());
assert!(should_deny_url(&tested_http_url, handles).unwrap());
}
}

View File

@@ -16,10 +16,10 @@ fn banner_prints_proxy() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--stdin")
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--proxy")
.arg("http://127.0.0.1:8080")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.pipe_stdin(file)
.unwrap()
.assert()
@@ -57,7 +57,7 @@ fn banner_prints_replay_proxy() -> Result<(), Box<dyn std::error::Error>> {
.unwrap()
.arg("--stdin")
.arg("--wordlist")
.arg(file.as_os_str())
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.arg("--replay-proxy")
.arg("http://127.0.0.1:8081")
.pipe_stdin(file)
@@ -95,6 +95,8 @@ fn banner_prints_headers() {
.arg("stuff:things")
.arg("-H")
.arg("mostuff:mothings")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -113,6 +115,69 @@ fn banner_prints_headers() {
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + multiple dont scan url & regex entries
fn banner_prints_denied_urls() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--dont-scan")
.arg("http://dont-scan.me")
.arg("https://also-not.me")
.arg("https:")
.arg("/deny.*")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Don't Scan Url"))
.and(predicate::str::contains("Don't Scan Regex"))
.and(predicate::str::contains("http://dont-scan.me"))
.and(predicate::str::contains("https://also-not.me"))
.and(predicate::str::contains("https:"))
.and(predicate::str::contains("/deny.*"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + multiple headers
fn banner_prints_random_agent() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--random-agent")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Random"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + multiple size filters
@@ -133,6 +198,8 @@ fn banner_prints_filter_sizes() {
.arg("93")
.arg("--filter-words")
.arg("94")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -169,6 +236,8 @@ fn banner_prints_queries() {
.arg("token=supersecret")
.arg("--query")
.arg("stuff=things")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -197,6 +266,8 @@ fn banner_prints_status_codes() {
.arg("http://localhost")
.arg("-s")
.arg("201,301,401")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -225,6 +296,8 @@ fn banner_prints_replay_codes() {
.arg("200,302")
.arg("--replay-proxy")
.arg("http://localhost:8081")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -253,6 +326,8 @@ fn banner_prints_output_file() {
.arg("http://localhost")
.arg("--output")
.arg("/super/cool/path")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -267,7 +342,7 @@ fn banner_prints_output_file() {
.and(predicate::str::contains("Output File"))
.and(predicate::str::contains("/super/cool/path"))
.and(predicate::str::contains(
"ERROR: Couldn't start /super/cool/path file handler",
"Could not open /definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676",
))
.and(predicate::str::contains("─┴─")),
);
@@ -282,6 +357,8 @@ fn banner_prints_insecure() {
.arg("--url")
.arg("http://localhost")
.arg("-k")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -308,6 +385,8 @@ fn banner_prints_redirects() {
.arg("--url")
.arg("http://localhost")
.arg("-r")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -337,6 +416,8 @@ fn banner_prints_extensions() {
.arg("js")
.arg("--extensions")
.arg("pdf")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -363,6 +444,8 @@ fn banner_prints_dont_filter() {
.arg("--url")
.arg("http://localhost")
.arg("--dont-filter")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -389,6 +472,8 @@ fn banner_prints_verbosity_one() {
.arg("--url")
.arg("http://localhost")
.arg("-v")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -415,6 +500,8 @@ fn banner_prints_verbosity_two() {
.arg("--url")
.arg("http://localhost")
.arg("-vv")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -441,6 +528,8 @@ fn banner_prints_verbosity_three() {
.arg("--url")
.arg("http://localhost")
.arg("-vvv")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -467,6 +556,8 @@ fn banner_prints_verbosity_four() {
.arg("--url")
.arg("http://localhost")
.arg("-vvvv")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -493,6 +584,8 @@ fn banner_prints_add_slash() {
.arg("--url")
.arg("http://localhost")
.arg("-f")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -520,6 +613,8 @@ fn banner_prints_infinite_depth() {
.arg("http://localhost")
.arg("--depth")
.arg("0")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -547,6 +642,8 @@ fn banner_prints_recursion_depth() {
.arg("http://localhost")
.arg("--depth")
.arg("343214")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -573,6 +670,8 @@ fn banner_prints_no_recursion() {
.arg("--url")
.arg("http://localhost")
.arg("-n")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -599,10 +698,12 @@ fn banner_doesnt_print() {
.arg("--url")
.arg("http://localhost")
.arg("-q")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(predicate::str::contains(
"Could not connect to any target provided",
"Could not open /definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676",
));
}
@@ -615,6 +716,8 @@ fn banner_prints_extract_links() {
.arg("--url")
.arg("http://localhost")
.arg("-e")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -642,6 +745,8 @@ fn banner_prints_scan_limit() {
.arg("http://localhost")
.arg("-L")
.arg("4")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -669,6 +774,8 @@ fn banner_prints_filter_status() {
.arg("http://localhost")
.arg("-C")
.arg("200")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -677,7 +784,6 @@ fn banner_prints_filter_status() {
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Status Code Filters"))
@@ -697,6 +803,8 @@ fn banner_prints_json() {
.arg("--json")
.arg("--output")
.arg("/dev/null")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -724,6 +832,8 @@ fn banner_prints_debug_log() {
.arg("http://localhost")
.arg("--debug-log")
.arg("/dev/null")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -751,6 +861,8 @@ fn banner_prints_filter_regex() {
.arg("http://localhost")
.arg("--filter-regex")
.arg("^ignore me$")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -778,6 +890,8 @@ fn banner_prints_time_limit() {
.arg("http://localhost")
.arg("--time-limit")
.arg("10m")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -805,6 +919,8 @@ fn banner_prints_similarity_filter() {
.arg("http://localhost")
.arg("--filter-similar-to")
.arg("https://somesite.com")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -832,6 +948,8 @@ fn banner_prints_rate_limit() {
.arg("http://localhost")
.arg("--rate-limit")
.arg("6735")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -858,6 +976,8 @@ fn banner_prints_auto_tune() {
.arg("--url")
.arg("http://localhost")
.arg("--auto-tune")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -884,6 +1004,8 @@ fn banner_prints_auto_bail() {
.arg("--url")
.arg("http://localhost")
.arg("--auto-bail")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -910,6 +1032,8 @@ fn banner_doesnt_print_when_silent() {
.arg("--url")
.arg("http://localhost")
.arg("--silent")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -934,6 +1058,8 @@ fn banner_doesnt_print_when_quiet() {
.arg("--url")
.arg("http://localhost")
.arg("--quiet")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
@@ -958,17 +1084,339 @@ fn banner_prints_parallel() {
.arg("--stdin")
.arg("--parallel")
.arg("4316")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.not()
.and(predicate::str::contains("Target Url").not())
.and(predicate::str::contains("Parallel Scans").not())
.and(predicate::str::contains("Threads").not())
.and(predicate::str::contains("Wordlist").not())
.and(predicate::str::contains("Status Codes").not())
.and(predicate::str::contains("Timeout (secs)").not())
.and(predicate::str::contains("User-Agent").not()),
.and(predicate::str::contains("Parallel Scans"))
.and(predicate::str::contains("4316"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + methods
fn banner_prints_methods() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-m")
.arg("PUT")
.arg("--methods")
.arg("OPTIONS")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("HTTP methods"))
.and(predicate::str::contains("[PUT, OPTIONS]"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + data body
fn banner_prints_data() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-m")
.arg("PUT")
.arg("--methods")
.arg("POST")
.arg("--data")
.arg("some_data")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("HTTP Body"))
.and(predicate::str::contains("some_data"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + ignored extensions
fn banner_prints_collect_extensions_and_dont_collect_default() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--collect-extensions")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Collect Extensions"))
.and(predicate::str::contains("Ignored Extensions"))
.and(predicate::str::contains("Images, Movies, Audio, etc..."))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + collect extensions
fn banner_prints_collect_extensions_and_dont_collect_with_input() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--collect-extensions")
.arg("--dont-collect")
.arg("pdf")
.arg("xps")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Collect Extensions"))
.and(predicate::str::contains("Ignored Extensions"))
.and(predicate::str::contains("[pdf, xps]"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + collect backups
fn banner_prints_collect_backups() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--collect-backups")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Collect Backups"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + collect words
fn banner_prints_collect_words() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--collect-words")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Collect Words"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + collect words
fn banner_prints_all_composite_settings_smart() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--smart")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Collect Words"))
.and(predicate::str::contains("Collect Backups"))
.and(predicate::str::contains("Extract Links"))
.and(predicate::str::contains("Auto Tune"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + collect words
fn banner_prints_all_composite_settings_thorough() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--thorough")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Collect Words"))
.and(predicate::str::contains("Collect Extensions"))
.and(predicate::str::contains("Collect Backups"))
.and(predicate::str::contains("Extract Links"))
.and(predicate::str::contains("Auto Tune"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + collect words
fn banner_prints_all_composite_settings_burp() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--burp")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Proxy"))
.and(predicate::str::contains("Insecure"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + collect words
fn banner_prints_all_composite_settings_burp_replay() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--burp-replay")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Replay Proxy"))
.and(predicate::str::contains("Insecure"))
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + force recursion
fn banner_prints_force_recursion() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--force-recursion")
.arg("--wordlist")
.arg("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Force Recursion"))
.and(predicate::str::contains("─┴─")),
);
}

282
tests/test_deny_list.rs Normal file
View File

@@ -0,0 +1,282 @@
mod utils;
use assert_cmd::prelude::*;
use assert_cmd::Command;
use httpmock::Method::GET;
use httpmock::MockServer;
use predicates::prelude::*;
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
/// test that the deny list prevents a request if the requested url is a match
fn deny_list_works_during_with_a_normal_scan() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--dont-scan")
.arg(srv.url("/LICENSE"))
.unwrap();
teardown_tmp_directory(tmp_dir);
cmd.assert()
.success()
.stdout(predicate::str::contains(srv.url("/LICENSE")).not());
assert_eq!(mock.hits(), 0);
}
#[test]
/// test that the deny list prevents requests of urls found during extraction
fn deny_list_works_during_extraction() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200)
.body(&srv.url("'/homepage/assets/img/icons/handshake.svg'"));
});
let mock_two = srv.mock(|when, then| {
when.method(GET)
.path("/homepage/assets/img/icons/handshake.svg");
then.status(200);
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.arg("--dont-scan")
.arg(srv.url("/homepage/"))
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("/homepage/assets/img/icons/handshake.svg").not()),
);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 0);
teardown_tmp_directory(tmp_dir);
}
#[test]
/// test that the deny list prevents requests of urls found during recursion
fn deny_list_works_during_recursion() {
let srv = MockServer::start();
let urls = [
"js".to_string(),
"prod".to_string(),
"dev".to_string(),
"file.js".to_string(),
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist").unwrap();
let js_mock = srv.mock(|when, then| {
when.method(GET).path("/js");
then.status(301).header("Location", &srv.url("/js/"));
});
let js_prod_mock = srv.mock(|when, then| {
when.method(GET).path("/js/prod");
then.status(301).header("Location", &srv.url("/js/prod/"));
});
let js_dev_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev");
then.status(301).header("Location", &srv.url("/js/dev/"));
});
let js_dev_file_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/file.js");
then.status(200)
.body("this is a test and is more bytes than other ones");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-t")
.arg("1")
.arg("--dont-scan")
.arg(srv.url("/js/dev"))
.unwrap();
cmd.assert().success().stdout(
predicate::str::is_match("301.*js")
.unwrap()
.and(predicate::str::is_match("301.*js/prod").unwrap())
.and(predicate::str::is_match("301.*js/dev").unwrap())
.not()
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap())
.not(),
);
assert_eq!(js_mock.hits(), 2);
assert_eq!(js_prod_mock.hits(), 2);
assert_eq!(js_dev_mock.hits(), 0);
assert_eq!(js_dev_file_mock.hits(), 0);
teardown_tmp_directory(tmp_dir);
}
#[test]
/// test that the deny list prevents requests of urls found during recursion when the denier is a
/// parent of a user-specified scan
fn deny_list_works_during_recursion_with_inverted_parents() {
let srv = MockServer::start();
let urls = [
"js".to_string(),
"prod".to_string(),
"dev".to_string(),
"api".to_string(),
"file.js".to_string(),
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist").unwrap();
let js_mock = srv.mock(|when, then| {
when.method(GET).path("/js");
then.status(301).header("Location", &srv.url("/js/"));
});
let api_mock = srv.mock(|when, then| {
when.method(GET).path("/api");
then.status(200);
});
let js_prod_mock = srv.mock(|when, then| {
when.method(GET).path("/js/prod");
then.status(301).header("Location", &srv.url("/js/prod/"));
});
let js_dev_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev");
then.status(301).header("Location", &srv.url("/js/dev/"));
});
let js_dev_file_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/file.js");
then.status(200)
.body("this is a test and is more bytes than other ones");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/js"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-t")
.arg("1")
.arg("-vvvv")
.arg("--dont-scan")
.arg(srv.url("/"))
.unwrap();
cmd.assert().success().stdout(
predicate::str::is_match("301.*js")
.unwrap()
.and(predicate::str::is_match("301.*js/prod").unwrap())
.and(predicate::str::is_match("301.*js/dev").unwrap())
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap())
.and(predicate::str::is_match("200.*api").unwrap())
.not(),
);
assert_eq!(js_mock.hits(), 2);
assert_eq!(js_prod_mock.hits(), 2);
assert_eq!(js_dev_mock.hits(), 2);
assert_eq!(js_dev_file_mock.hits(), 1);
assert_eq!(api_mock.hits(), 0);
teardown_tmp_directory(tmp_dir);
}
#[test]
/// test that a regex that prevents the base url from being scanned results in an early exit
fn deny_list_prevents_regex_that_denies_base_url() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--dont-scan")
.arg("/")
.unwrap();
teardown_tmp_directory(tmp_dir);
let err_msg = format!(
"Could not determine initial targets: The regex '/' matches {}/; the scan will never start",
srv.base_url()
);
cmd.assert()
.success()
.stderr(predicate::str::contains(err_msg));
assert_eq!(mock.hits(), 0);
}
#[test]
/// test that a url that prevents the base url from being scanned results in an early exit
fn deny_list_prevents_url_that_denies_base_url() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--dont-scan")
.arg(srv.base_url())
.unwrap();
teardown_tmp_directory(tmp_dir);
let err_msg = format!(
"Could not determine initial targets: The url '{}/' matches {}/; the scan will never start",
srv.base_url(),
srv.base_url()
);
cmd.assert()
.success()
.stderr(predicate::str::contains(err_msg));
assert_eq!(mock.hits(), 0);
}

View File

@@ -284,11 +284,11 @@ fn extractor_finds_robots_txt_links_and_displays_files_or_scans_directories() {
.and(predicate::str::contains("22c"))
.and(predicate::str::contains("/misc/LICENSE"))
.and(predicate::str::contains("29c"))
.and(predicate::str::contains("200").count(3)),
.and(predicate::str::contains("200").count(4)),
);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_dir.hits(), 1);
assert_eq!(mock_dir.hits(), 3);
assert_eq!(mock_two.hits(), 1);
assert_eq!(mock_file.hits(), 1);
assert_eq!(mock_disallowed.hits(), 1);
@@ -370,6 +370,226 @@ fn extractor_finds_robots_txt_links_and_displays_files_non_recursive() {
teardown_tmp_directory(tmp_dir);
}
#[test]
/// serve a directory listing with a file and and a folder contained within it. ferox should
/// find both links and request each one.
fn extractor_finds_directory_listing_links_and_displays_files() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["invalid".to_string()], "wordlist").unwrap();
let mock_root = srv.mock(|when, then| {
when.method(GET).path("/");
then.status(200).body(
r#"
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
<li><a href="disallowed-subdir/">disallowed-subdir/</a></li>
<li><a href="LICENSE">LICENSE</a></li>
<li><a href="misc/">misc/</a></li>
</ul>
<hr>
</body>
</html>
"#,
);
});
let mock_root_file = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("im a little teapot"); // 18
});
let mock_dir_disallowed = srv.mock(|when, then| {
when.method(GET).path("/disallowed-subdir");
then.status(404);
});
let mock_dir_redir = srv.mock(|when, then| {
when.method(GET).path("/misc");
then.status(301).header("Location", &srv.url("/misc/"));
});
let mock_dir = srv.mock(|when, then| {
when.method(GET).path("/misc/");
then.status(200).body(
r#"
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for /misc</title>
</head>
<body>
<h1>Directory listing for /misc</h1>
<hr>
<ul>
<li><a href="LICENSE">LICENSE</a></li>
<li><a href="stupidfile.php">stupidfile.php</a></li>
</ul>
<hr>
</body>
</html>
"#,
);
});
let mock_dir_file = srv.mock(|when, then| {
when.method(GET).path("/misc/LICENSE");
then.status(200).body("i too, am a container for tea"); // 29
});
let mock_dir_file_ext = srv.mock(|when, then| {
when.method(GET).path("/misc/stupidfile.php");
then.status(200).body("im a little teapot too"); // 22
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.arg("--redirects")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE") // 2 directories contain LICENSE
.count(2)
.and(predicate::str::contains("18c"))
.and(predicate::str::contains("/misc/stupidfile.php"))
.and(predicate::str::contains("22c"))
.and(predicate::str::contains("/misc/LICENSE"))
.and(predicate::str::contains("29c"))
.and(predicate::str::contains("200").count(3)),
);
assert_eq!(mock_root.hits(), 2);
assert_eq!(mock_root_file.hits(), 1);
assert_eq!(mock_dir_disallowed.hits(), 1);
assert_eq!(mock_dir_redir.hits(), 1);
assert_eq!(mock_dir.hits(), 2);
assert_eq!(mock_dir_file.hits(), 1);
assert_eq!(mock_dir_file_ext.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
#[test]
/// serve a directory listing with a file and and a folder contained within it. ferox should
/// find both links and request each one. This is the non-recursive version of the test above
fn extractor_finds_directory_listing_links_and_displays_files_non_recursive() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["invalid".to_string()], "wordlist").unwrap();
let mock_root = srv.mock(|when, then| {
when.method(GET).path("/");
then.status(200).body(
r#"
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
<li><a href="disallowed-subdir/">disallowed-subdir/</a></li>
<li><a href="LICENSE">LICENSE</a></li>
<li><a href="misc/">misc/</a></li>
</ul>
<hr>
</body>
</html>
"#,
);
});
let mock_root_file = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("im a little teapot"); // 18
});
let mock_dir_disallowed = srv.mock(|when, then| {
when.method(GET).path("/disallowed-subdir");
then.status(404);
});
let mock_dir_redir = srv.mock(|when, then| {
when.method(GET).path("/misc");
then.status(301).header("Location", &srv.url("/misc/"));
});
let mock_dir = srv.mock(|when, then| {
when.method(GET).path("/misc/");
then.status(200).body(
r#"
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for /misc</title>
</head>
<body>
<h1>Directory listing for /misc</h1>
<hr>
<ul>
<li><a href="LICENSE">LICENSE</a></li>
<li><a href="stupidfile.php">stupidfile.php</a></li>
</ul>
<hr>
</body>
</html>
"#,
);
});
let mock_dir_file = srv.mock(|when, then| {
when.method(GET).path("/misc/LICENSE");
then.status(200).body("i too, am a container for tea"); // 29
});
let mock_dir_file_ext = srv.mock(|when, then| {
when.method(GET).path("/misc/stupidfile.php");
then.status(200).body("im a little teapot too"); // 22
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.arg("--redirects")
.arg("--no-recursion")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("18c"))
.and(predicate::str::contains("/misc/stupidfile.php"))
.not()
.and(predicate::str::contains("22c"))
.not()
.and(predicate::str::contains("/misc/LICENSE").not())
.and(predicate::str::contains("29c").not())
.and(predicate::str::contains("200").count(1)),
);
assert_eq!(mock_root.hits(), 2);
assert_eq!(mock_root_file.hits(), 1);
assert_eq!(mock_dir_disallowed.hits(), 1);
assert_eq!(mock_dir_redir.hits(), 1);
assert_eq!(mock_dir.hits(), 1);
assert_eq!(mock_dir_file.hits(), 0);
assert_eq!(mock_dir_file_ext.hits(), 0);
teardown_tmp_directory(tmp_dir);
}
#[test]
/// send a request to a page that contains a link that contains a directory that returns a 403
/// --extract-links should find the link and make recurse into the 403 directory, finding LICENSE
@@ -416,7 +636,7 @@ fn extractor_recurses_into_403_directories() -> Result<(), Box<dyn std::error::E
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
assert_eq!(forbidden_dir.hits(), 1);
assert_eq!(forbidden_dir.hits(), 3);
teardown_tmp_directory(tmp_dir);
Ok(())
}

View File

@@ -224,11 +224,11 @@ fn test_dynamic_wildcard_request_found() {
teardown_tmp_directory(tmp_dir);
assert_eq!(contents.contains("WLD"), true);
assert_eq!(contents.contains("Got"), true);
assert_eq!(contents.contains("200"), true);
assert_eq!(contents.contains("(url length: 32)"), true);
assert_eq!(contents.contains("(url length: 96)"), true);
assert!(contents.contains("WLD"));
assert!(contents.contains("Got"));
assert!(contents.contains("200"));
assert!(contents.contains("(url length: 32)"));
assert!(contents.contains("(url length: 96)"));
cmd.assert().success().stdout(
predicate::str::contains("WLD")
@@ -269,51 +269,53 @@ fn heuristics_static_wildcard_request_with_dont_filter() -> Result<(), Box<dyn s
Ok(())
}
#[test]
/// test finds a static wildcard and reports as much to stdout
fn heuristics_wildcard_test_with_two_static_wildcards() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
// #[test]
// /// test finds a static wildcard and reports as much to stdout
// fn heuristics_wildcard_test_with_two_static_wildcards() {
// let srv = MockServer::start();
// let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
// let mock = srv.mock(|when, then| {
// when.method(GET)
// .path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
// then.status(200)
// .body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
// });
let mock2 = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
// let mock2 = srv.mock(|when, then| {
// when.method(GET)
// .path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
// then.status(200)
// .body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
// });
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--add-slash")
.unwrap();
// let cmd = Command::cargo_bin("feroxbuster")
// .unwrap()
// .arg("--url")
// .arg(srv.url("/"))
// .arg("--wordlist")
// .arg(file.as_os_str())
// .arg("--add-slash")
// .arg("--threads")
// .arg("1")
// .unwrap();
teardown_tmp_directory(tmp_dir);
// teardown_tmp_directory(tmp_dir);
cmd.assert().success().stdout(
predicate::str::contains("WLD")
.and(predicate::str::contains("Got"))
.and(predicate::str::contains("200"))
.and(predicate::str::contains("(url length: 32)"))
.and(predicate::str::contains("(url length: 96)"))
.and(predicate::str::contains(
"Wildcard response is static; auto-filtering 46",
)),
);
// cmd.assert().success().stdout(
// predicate::str::contains("WLD")
// .and(predicate::str::contains("Got"))
// .and(predicate::str::contains("200"))
// .and(predicate::str::contains("(url length: 32)"))
// .and(predicate::str::contains("(url length: 96)"))
// .and(predicate::str::contains(
// "Wildcard response is static; auto-filtering 46",
// )),
// );
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
}
// assert_eq!(mock.hits(), 1);
// assert_eq!(mock2.hits(), 1);
// }
#[test]
/// test finds a static wildcard and reports nothing to stdout
@@ -344,6 +346,8 @@ fn heuristics_wildcard_test_with_two_static_wildcards_with_silent_enabled(
.arg(file.as_os_str())
.arg("--add-slash")
.arg("--silent")
.arg("--threads")
.arg("1")
.unwrap();
teardown_tmp_directory(tmp_dir);
@@ -355,119 +359,126 @@ fn heuristics_wildcard_test_with_two_static_wildcards_with_silent_enabled(
Ok(())
}
#[test]
/// test finds a static wildcard and reports as much to stdout and a file
fn heuristics_wildcard_test_with_two_static_wildcards_and_output_to_file() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let outfile = tmp_dir.path().join("outfile");
// #[test]
// /// test finds a static wildcard and reports as much to stdout and a file
// fn heuristics_wildcard_test_with_two_static_wildcards_and_output_to_file() {
// let srv = MockServer::start();
// let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
// let outfile = tmp_dir.path().join("outfile");
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
// let mock = srv.mock(|when, then| {
// when.method(GET)
// .path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
// then.status(200)
// .body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
// });
let mock2 = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
// let mock2 = srv.mock(|when, then| {
// when.method(GET)
// .path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
// then.status(200)
// .body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
// });
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--add-slash")
.arg("--output")
.arg(outfile.as_os_str())
.unwrap();
// let cmd = Command::cargo_bin("feroxbuster")
// .unwrap()
// .arg("--url")
// .arg(srv.url("/"))
// .arg("--wordlist")
// .arg(file.as_os_str())
// .arg("--add-slash")
// .arg("--output")
// .arg(outfile.as_os_str())
// .arg("--threads")
// .arg("1")
// .unwrap();
let contents = std::fs::read_to_string(outfile).unwrap();
// let contents = std::fs::read_to_string(outfile).unwrap();
teardown_tmp_directory(tmp_dir);
// teardown_tmp_directory(tmp_dir);
assert_eq!(contents.contains("WLD"), true);
assert_eq!(contents.contains("Got"), true);
assert_eq!(contents.contains("200"), true);
assert_eq!(contents.contains("(url length: 32)"), true);
assert_eq!(contents.contains("(url length: 96)"), true);
// assert!(contents.contains("WLD"));
// assert!(contents.contains("Got"));
// assert!(contents.contains("200"));
// assert!(contents.contains("(url length: 32)"));
// assert!(contents.contains("(url length: 96)"));
cmd.assert().success().stdout(
predicate::str::contains("WLD")
.and(predicate::str::contains("Got"))
.and(predicate::str::contains("200"))
.and(predicate::str::contains("(url length: 32)"))
.and(predicate::str::contains("(url length: 96)"))
.and(predicate::str::contains(
"Wildcard response is static; auto-filtering 46",
)),
);
// cmd.assert().success().stdout(
// predicate::str::contains("WLD")
// .and(predicate::str::contains("Got"))
// .and(predicate::str::contains("200"))
// .and(predicate::str::contains("(url length: 32)"))
// .and(predicate::str::contains("(url length: 96)"))
// .and(predicate::str::contains(
// "Wildcard response is static; auto-filtering 46",
// )),
// );
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
}
// assert_eq!(mock.hits(), 1);
// assert_eq!(mock2.hits(), 1);
// }
#[test]
/// test finds a static wildcard that returns 3xx, expect redirects to => in response as well as
/// in the output file
fn heuristics_wildcard_test_with_redirect_as_response_code(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let outfile = tmp_dir.path().join("outfile");
// #[test]
// /// test finds a static wildcard that returns 3xx, expect redirects to => in response as well as
// /// in the output file
// fn heuristics_wildcard_test_with_redirect_as_response_code(
// ) -> Result<(), Box<dyn std::error::Error>> {
// let srv = MockServer::start();
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(301)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
// let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
// let outfile = tmp_dir.path().join("outfile");
let mock2 = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(301)
.header("Location", &srv.url("/some-redirect"))
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
// let mock = srv.mock(|when, then| {
// when.method(GET)
// .path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
// then.status(301)
// .body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
// });
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--add-slash")
.arg("--output")
.arg(outfile.as_os_str())
.unwrap();
// let mock2 = srv.mock(|when, then| {
// when.method(GET)
// .path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
// then.status(301)
// .header("Location", &srv.url("/some-redirect"))
// .body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
// });
let contents = std::fs::read_to_string(outfile).unwrap();
// let cmd = Command::cargo_bin("feroxbuster")
// .unwrap()
// .arg("--url")
// .arg(srv.url("/"))
// .arg("--wordlist")
// .arg(file.as_os_str())
// .arg("--add-slash")
// .arg("--output")
// .arg(outfile.as_os_str())
// .arg("--threads")
// .arg("1")
// .unwrap();
teardown_tmp_directory(tmp_dir);
// let contents = std::fs::read_to_string(outfile).unwrap();
assert_eq!(contents.contains("WLD"), true);
assert_eq!(contents.contains("301"), true);
assert_eq!(contents.contains("/some-redirect"), true);
assert_eq!(contents.contains("redirects to => "), true);
assert_eq!(contents.contains(&srv.url("/")), true);
assert_eq!(contents.contains("(url length: 32)"), true);
// teardown_tmp_directory(tmp_dir);
cmd.assert().success().stdout(
predicate::str::contains("redirects to => ")
.and(predicate::str::contains("/some-redirect"))
.and(predicate::str::contains("301"))
.and(predicate::str::contains(srv.url("/")))
.and(predicate::str::contains("(url length: 32)"))
.and(predicate::str::contains("WLD")),
);
// assert!(contents.contains("WLD"));
// assert!(contents.contains("301"));
// assert!(contents.contains("/some-redirect"));
// assert!(contents.contains(" => "));
// assert!(contents.contains(&srv.url("/")));
// assert!(contents.contains("(url length: 32)"));
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
Ok(())
}
// cmd.assert().success().stdout(
// predicate::str::contains(" => ")
// .and(predicate::str::contains("/some-redirect"))
// .and(predicate::str::contains("301"))
// .and(predicate::str::contains(srv.url("/")))
// .and(predicate::str::contains("(url length: 32)"))
// .and(predicate::str::contains("WLD")),
// );
// assert_eq!(mock.hits(), 1);
// assert_eq!(mock2.hits(), 1);
// Ok(())
// }
// todo figure out why ci hates these tests

View File

@@ -3,7 +3,7 @@ use assert_cmd::Command;
use httpmock::Method::GET;
use httpmock::{MockServer, Regex};
use predicates::prelude::*;
use std::fs::read_to_string;
use std::fs::{read_dir, read_to_string};
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
@@ -25,12 +25,9 @@ fn main_use_root_owned_file_as_wordlist() {
.arg("-vvvv")
.assert()
.success()
.stderr(predicate::str::contains(
"Failed while scanning: Could not open /etc/shadow",
));
.stderr(predicate::str::contains("Could not open /etc/shadow"));
// connectivity test hits it once
assert_eq!(mock.hits(), 1);
assert_eq!(mock.hits(), 0);
}
#[test]
@@ -53,11 +50,9 @@ fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
.arg("-vvvv")
.assert()
.success()
.stderr(predicate::str::contains(
"Failed while scanning: Did not find any words in",
));
.stderr(predicate::str::contains("Did not find any words in"));
assert_eq!(mock.hits(), 1);
assert_eq!(mock.hits(), 0);
teardown_tmp_directory(tmp_dir);
Ok(())
@@ -153,3 +148,73 @@ fn main_parallel_spawns_children() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[test]
/// send three targets over stdin with --output enabled, expect parallel to create a new directory
/// and the log files therein
fn main_parallel_creates_output_directory() -> Result<(), Box<dyn std::error::Error>> {
let t1 = MockServer::start();
let t2 = MockServer::start();
let t3 = MockServer::start();
let words = [
String::from("LICENSE"),
String::from("stuff"),
String::from("things"),
String::from("mostuff"),
String::from("mothings"),
];
let (word_tmp_dir, wordlist) = setup_tmp_directory(&words, "wordlist")?;
let (output_dir, outfile) = setup_tmp_directory(&[], "output-file")?;
let (tgt_tmp_dir, targets) =
setup_tmp_directory(&[t1.url("/"), t2.url("/"), t3.url("/")], "targets")?;
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--stdin")
.arg("--parallel")
.arg("2")
.arg("--output")
.arg(outfile.as_os_str())
.arg("--wordlist")
.arg(wordlist.as_os_str())
.pipe_stdin(targets)
.unwrap()
.assert()
.success()
.stderr(
predicate::str::contains("Could not connect to any target provided")
.and(predicate::str::contains("Target Url"))
.not(), // no target url found
);
// output_dir should return something similar to output-file-1627845244.logs with the
// line below. if it ever fails, can use the regex below to filter out the right directory
let sub_dir = read_dir(&output_dir)?.next().unwrap()?.file_name();
let mut num_logs = 0;
let file_regex = Regex::new("ferox-[a-zA-Z_:0-9]+-[0-9]+.log").unwrap();
let dir_regex = Regex::new("output-file-[0-9]+.logs").unwrap();
let sub_dir = output_dir.as_ref().join(&sub_dir);
// created directory like output-file-1627845741.logs/
assert!(dir_regex.is_match(&sub_dir.to_string_lossy().to_string()));
for entry in sub_dir.read_dir()? {
let entry = entry?;
// created each file like ferox-https_localhost-1627845741.log
println!("name: {:?}", entry.file_name().to_string_lossy());
assert!(file_regex.is_match(&entry.file_name().to_string_lossy()));
num_logs += 1;
}
// should be 3 log files total
assert_eq!(num_logs, 3);
teardown_tmp_directory(word_tmp_dir);
teardown_tmp_directory(tgt_tmp_dir);
teardown_tmp_directory(output_dir);
Ok(())
}

View File

@@ -19,7 +19,7 @@ fn parser_incorrect_param_with_tack_tack_help() {
.arg("-fc")
.arg("--help")
.assert()
.failure()
.success()
.stdout(predicate::str::contains("Ludicrous speed... go!"));
}
@@ -33,14 +33,16 @@ fn parser_incorrect_param_with_tack_tack_help() {
///
/// For more information try --help
///
/// the new behavior we expect to see is to print the long form help message, of which
/// Ludicrous speed... go! is near the bottom of that output, so we can test for that
/// the new behavior we expect to see is to print the short form help message, of which
/// "[CAUTION] 4 -v's is probably too much" is near the bottom of that output, so we can test for that
fn parser_incorrect_param_with_tack_h() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("-fc")
.arg("-h")
.assert()
.failure()
.stdout(predicate::str::contains("Ludicrous speed... go!"));
.success()
.stdout(predicate::str::contains(
"[CAUTION] 4 -v's is probably too much",
));
}

View File

@@ -77,7 +77,7 @@ fn auto_bail_cancels_scan_with_timeouts() {
// read debug log to get the number of errors enforced
for line in debug_log.lines() {
let log: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
let log: serde_json::Value = serde_json::from_str(line).unwrap_or_default();
if let Some(message) = log.get("message") {
let str_msg = message.as_str().unwrap_or_default().to_string();
@@ -156,7 +156,7 @@ fn auto_bail_cancels_scan_with_403s() {
// read debug log to get the number of errors enforced
for line in debug_log.lines() {
let log: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
let log: serde_json::Value = serde_json::from_str(line).unwrap_or_default();
if let Some(message) = log.get("message") {
let str_msg = message.as_str().unwrap_or_default().to_string();
@@ -238,7 +238,7 @@ fn auto_bail_cancels_scan_with_429s() {
// read debug log to get the number of errors enforced
for line in debug_log.lines() {
let log: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
let log: serde_json::Value = serde_json::from_str(line).unwrap_or_default();
if let Some(message) = log.get("message") {
let str_msg = message.as_str().unwrap_or_default().to_string();

View File

@@ -93,9 +93,13 @@ fn resume_scan_works() {
#[test]
/// kick off scan with a time limit;
fn time_limit_enforced_when_specified() {
let srv = MockServer::start();
let t1 = MockServer::start();
let t2 = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["css".to_string(), "stuff".to_string()], "wordlist").unwrap();
let (tgt_tmp_dir, targets) =
setup_tmp_directory(&[t1.url("/"), t2.url("/")], "targets").unwrap();
// ensure the command will run long enough by adding crap to the wordlist
let more_words = read_to_string(Path::new("tests/extra-words")).unwrap();
@@ -109,12 +113,13 @@ fn time_limit_enforced_when_specified() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--stdin")
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--time-limit")
.arg("5s")
.pipe_stdin(targets)
.unwrap()
.assert()
.failure();
@@ -127,4 +132,5 @@ fn time_limit_enforced_when_specified() {
assert!(now.elapsed() > lower_bound && now.elapsed() < upper_bound);
teardown_tmp_directory(tmp_dir);
teardown_tmp_directory(tgt_tmp_dir);
}

View File

@@ -3,6 +3,8 @@ use assert_cmd::prelude::*;
use httpmock::Method::GET;
use httpmock::MockServer;
use predicates::prelude::*;
use std::thread::sleep;
use std::time::Duration;
use std::{process::Command, time};
use utils::{setup_tmp_directory, teardown_tmp_directory};
@@ -89,9 +91,9 @@ fn scanner_recursive_request_scan() -> Result<(), Box<dyn std::error::Error>> {
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap()),
);
assert_eq!(js_mock.hits(), 1);
assert_eq!(js_prod_mock.hits(), 1);
assert_eq!(js_dev_mock.hits(), 1);
assert_eq!(js_mock.hits(), 2);
assert_eq!(js_prod_mock.hits(), 2);
assert_eq!(js_dev_mock.hits(), 2);
assert_eq!(js_dev_file_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
@@ -153,9 +155,9 @@ fn scanner_recursive_request_scan_using_only_success_responses(
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap()),
);
assert_eq!(js_mock.hits(), 1);
assert_eq!(js_prod_mock.hits(), 1);
assert_eq!(js_dev_mock.hits(), 1);
assert_eq!(js_mock.hits(), 3);
assert_eq!(js_prod_mock.hits(), 3);
assert_eq!(js_dev_mock.hits(), 3);
assert_eq!(js_dev_file_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
@@ -363,7 +365,7 @@ fn scanner_single_request_replayed_to_proxy() -> Result<(), Box<dyn std::error::
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--replay-proxy")
.arg(format!("http://{}", proxy.address().to_string()))
.arg(format!("http://{}", proxy.address()))
.arg("--replay-codes")
.arg("200")
.unwrap();
@@ -596,7 +598,7 @@ fn scanner_recursion_works_with_403_directories() {
assert_eq!(mock.hits(), 1);
assert_eq!(found_anyway.hits(), 1);
assert_eq!(forbidden_dir.hits(), 1);
assert_eq!(forbidden_dir.hits(), 3);
teardown_tmp_directory(tmp_dir);
}
@@ -638,3 +640,274 @@ fn rate_limit_enforced_when_specified() {
teardown_tmp_directory(tmp_dir);
}
#[test]
/// ensure that auto-discovered extensions are tracked in statistics and bar lengths are updated
fn add_discovered_extension_updates_bars_and_stats() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(
&["LICENSE".to_string(), "stuff.php".to_string()],
"wordlist",
)
.unwrap();
srv.mock(|when, then| {
when.method(GET).path("/stuff.php");
then.status(200).body("cool... coolcoolcool");
});
let file_path = tmp_dir.path().join("debug-file.txt");
assert!(!file_path.exists());
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.arg("--collect-extensions")
.arg("-vvvv")
.arg("--debug-log")
.arg(file_path.as_os_str())
.unwrap()
.assert()
.success();
let contents = std::fs::read_to_string(file_path).unwrap();
println!("{}", contents);
assert!(contents.contains("discovered new extension: php"));
assert!(contents.contains("extensions_collected: 1"));
assert!(contents.contains("expected_per_scan: 6"));
}
#[test]
/// send a request to a 200 file, expect pre-configured backup collection rules to be applied
/// and then requested
fn collect_backups_makes_appropriate_requests() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE.txt".to_string()], "wordlist").unwrap();
let valid_paths = vec![
"/LICENSE.txt",
"/LICENSE.txt~",
"/LICENSE.txt.bak",
"/LICENSE.txt.bak2",
"/LICENSE.txt.old",
"/LICENSE.txt.1",
"/LICENSE.bak",
"/.LICENSE.txt.swp",
];
let valid_mocks: Vec<_> = valid_paths
.iter()
.map(|&p| {
srv.mock(|when, then| {
when.method(GET).path(p);
then.status(200).body("this is a valid test");
})
})
.collect();
let invalid_paths: Vec<_> = vec![
"/LICENSE.txt~~",
"/LICENSE.txt.bak.bak",
"/LICENSE.txt.bak2.bak2",
"/LICENSE.txt.old.old",
"/LICENSE.txt.1.1",
"/..LICENSE.txt.swp.swp",
];
let invalid_mocks: Vec<_> = invalid_paths
.iter()
.map(|&p| {
srv.mock(|when, then| {
when.method(GET).path(p);
then.status(200).body("this is an invalid test");
})
})
.collect();
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--collect-backups")
.arg("--wordlist")
.arg(file.as_os_str())
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE.txt")
.and(predicate::str::contains("/LICENSE.txt~"))
.and(predicate::str::contains("/LICENSE.txt.bak"))
.and(predicate::str::contains("/LICENSE.txt.bak2"))
.and(predicate::str::contains("/LICENSE.txt.old"))
.and(predicate::str::contains("/LICENSE.txt.1"))
.and(predicate::str::contains("/LICENSE.bak"))
.and(predicate::str::contains("/.LICENSE.txt.swp")),
);
for valid_mock in valid_mocks {
assert_eq!(valid_mock.hits(), 1);
}
for invalid_mock in invalid_mocks {
assert_eq!(invalid_mock.hits(), 0);
}
teardown_tmp_directory(tmp_dir);
}
#[test]
/// send a request to 4 200 files, expect non-zero tf-idf rated words to be requested as well
fn collect_words_makes_appropriate_requests() {
let srv = MockServer::start();
let wordlist: Vec<_> = [
"doc1", "doc2", "doc3", "doc4", "blah", "blah2", "blah3", "blah4",
]
.iter()
.map(|w| w.to_string())
.collect();
let (tmp_dir, file) = setup_tmp_directory(&wordlist, "wordlist").unwrap();
srv.mock(|when, then| {
when.method(GET).path("/doc1");
then.status(200)
.body("Air quality in the sunny island improved gradually throughout Wednesday.");
});
srv.mock(|when, then| {
when.method(GET).path("/doc2");
then.status(200).body(
"Air quality in Singapore on Wednesday continued to get worse as haze hit the island.",
);
});
srv.mock(|when, then| {
when.method(GET).path("/doc3");
then.status(200).body("The air quality in Singapore is monitored through a network of air monitoring stations located in different parts of the island");
});
srv.mock(|when, then| {
when.method(GET).path("/doc4");
then.status(200)
.body("The air quality in Singapore got worse on Wednesday.");
});
let valid_paths = vec![
"/gradually",
"/network",
"/hit",
"/located",
"/continued",
"/island",
"/worse",
"/monitored",
"/monitoring",
"/haze",
"/different",
"/stations",
"/sunny",
"/singapore",
"/improved",
"/parts",
"/wednesday",
];
let valid_mocks: Vec<_> = valid_paths
.iter()
.map(|&p| {
srv.mock(|when, then| {
when.method(GET).path(p);
then.status(200);
})
})
.collect();
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("-vv")
.arg("--collect-words")
.arg("-t")
.arg("1")
.arg("--wordlist")
.arg(file.as_os_str())
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/doc1")
.and(predicate::str::contains("/doc2"))
.and(predicate::str::contains("/doc3"))
.and(predicate::str::contains("/doc4")),
);
sleep(Duration::new(2, 0));
for valid_mock in valid_mocks {
assert_eq!(valid_mock.hits(), 1);
}
teardown_tmp_directory(tmp_dir);
}
#[test]
/// send a request to an endpoint that has abnormal redirect logic, ala fast-api
fn scanner_forced_recursion_ignores_normal_redirect_logic() -> Result<(), Box<dyn std::error::Error>>
{
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock1 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(301)
.body("this is a test")
.header("Location", &srv.url("/LICENSE"));
});
let mock2 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE/LICENSE");
then.status(404);
});
let mock3 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE/LICENSE/LICENSE");
then.status(404);
});
let mock4 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE/LICENSE/LICENSE/LICENSE");
then.status(404);
});
let outfile = tmp_dir.path().join("output");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--force-recursion")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile)?;
println!("{}", contents);
assert!(contents.contains("/LICENSE"));
assert!(contents.contains("301"));
assert!(contents.contains("14"));
assert_eq!(mock1.hits(), 2);
assert_eq!(mock2.hits(), 1);
assert_eq!(mock3.hits(), 0);
assert_eq!(mock4.hits(), 0);
teardown_tmp_directory(tmp_dir);
Ok(())
}