Compare commits
854 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f25475ae4f | ||
|
|
194eec1867 | ||
|
|
d9088be54e | ||
|
|
c0ae120016 | ||
|
|
9d5b339708 | ||
|
|
b7e1876d87 | ||
|
|
4979946471 | ||
|
|
ef5d267500 | ||
|
|
a0208449cd | ||
|
|
bef48a8441 | ||
|
|
1bed84394a | ||
|
|
d66ba9c78a | ||
|
|
b4e8a63429 | ||
|
|
257352e22d | ||
|
|
ed521005b2 | ||
|
|
cbc4da53de | ||
|
|
920ce7ce23 | ||
|
|
1ef5bc288a | ||
|
|
97be0731d9 | ||
|
|
0b42c6a30e | ||
|
|
537d5c69dc | ||
|
|
194d00c073 | ||
|
|
f83195120a | ||
|
|
af2a4dbde0 | ||
|
|
c83a2d8ef2 | ||
|
|
ba96b686ea | ||
|
|
fddade6a11 | ||
|
|
8b97974728 | ||
|
|
ab1861ca2c | ||
|
|
5f241f6034 | ||
|
|
709a787613 | ||
|
|
21f0b95f15 | ||
|
|
36b6e49b87 | ||
|
|
327bdd6e03 | ||
|
|
b1e8023462 | ||
|
|
ae3a43db28 | ||
|
|
e8f4bbccf4 | ||
|
|
7cf834d000 | ||
|
|
1500e651fa | ||
|
|
e5048f0c8d | ||
|
|
d109608e2a | ||
|
|
3e373d1a50 | ||
|
|
46b3989df7 | ||
|
|
cc9467153b | ||
|
|
eb4b074454 | ||
|
|
a07c9432f2 | ||
|
|
1f4dbeaf65 | ||
|
|
1d45325c23 | ||
|
|
216e0e6595 | ||
|
|
dfa0664a16 | ||
|
|
54144dba89 | ||
|
|
18ad9ca733 | ||
|
|
2b58113a2c | ||
|
|
5ed43e8dbd | ||
|
|
82f65e58b7 | ||
|
|
b87836fb45 | ||
|
|
4db5835ea3 | ||
|
|
203108fc2d | ||
|
|
b040113115 | ||
|
|
6158d36279 | ||
|
|
215b76246c | ||
|
|
729c272964 | ||
|
|
43a8b7d7e7 | ||
|
|
94055ec504 | ||
|
|
76a1660329 | ||
|
|
d44ad9ca2d | ||
|
|
8712dee50c | ||
|
|
fc47a6e4e5 | ||
|
|
87c23b2cde | ||
|
|
e3fed0e6ac | ||
|
|
d43da01dab | ||
|
|
47726bc25a | ||
|
|
ec26321e42 | ||
|
|
21c0a7458b | ||
|
|
9950b1381d | ||
|
|
06460bf9da | ||
|
|
701701eea3 | ||
|
|
9a53053112 | ||
|
|
ee69c558a7 | ||
|
|
8f9b757e2d | ||
|
|
777e5628a5 | ||
|
|
4d49401a96 | ||
|
|
03e0d0092d | ||
|
|
21b29a693e | ||
|
|
857ac22266 | ||
|
|
06ca52a19c | ||
|
|
a1ab0b92c5 | ||
|
|
45c2285a65 | ||
|
|
3edab75966 | ||
|
|
2d0e64dec6 | ||
|
|
b8386b7e20 | ||
|
|
46e1d00e41 | ||
|
|
c0bfca4dbf | ||
|
|
355b50bdc1 | ||
|
|
db2822b2cb | ||
|
|
f92116c16d | ||
|
|
1c48f754a2 | ||
|
|
1a26e6a992 | ||
|
|
23653b0cc3 | ||
|
|
85c529fd48 | ||
|
|
1614780c11 | ||
|
|
74121bf9be | ||
|
|
75810ff697 | ||
|
|
e79d51b23d | ||
|
|
4f87aa6ab6 | ||
|
|
9922999cfb | ||
|
|
a73ad768b6 | ||
|
|
8e22a0881f | ||
|
|
d1fc7a4969 | ||
|
|
f0252bc375 | ||
|
|
1eca023d6e | ||
|
|
3ae3adf11b | ||
|
|
b94edc4e57 | ||
|
|
8116018b8b | ||
|
|
d1d37c135e | ||
|
|
1cd1d990de | ||
|
|
9c50038a25 | ||
|
|
1e3cd3a209 | ||
|
|
3536587260 | ||
|
|
19cd5c910a | ||
|
|
b8bfbb09f3 | ||
|
|
0a3130934c | ||
|
|
7b3201f2f8 | ||
|
|
521d341e36 | ||
|
|
3f3b24b26f | ||
|
|
2a4a150598 | ||
|
|
39b2da9735 | ||
|
|
87aaa84f1e | ||
|
|
2b7d134ede | ||
|
|
4eebacb077 | ||
|
|
1a6ad39b46 | ||
|
|
10b473d920 | ||
|
|
1d2dc8bd37 | ||
|
|
2132ceadd5 | ||
|
|
15bd50dc24 | ||
|
|
48ca8d510a | ||
|
|
9ca48fe877 | ||
|
|
2f09df921d | ||
|
|
93686acb48 | ||
|
|
294159088c | ||
|
|
da9bec5a67 | ||
|
|
69cf08bf1f | ||
|
|
035a8c75c0 | ||
|
|
ac56225405 | ||
|
|
82a7aa458c | ||
|
|
c29abe4ec4 | ||
|
|
bb6146c18f | ||
|
|
882aded16c | ||
|
|
86dc8edd3d | ||
|
|
0281057944 | ||
|
|
96fa07a5e5 | ||
|
|
3ee6641a7d | ||
|
|
90dd18af2e | ||
|
|
b98ab6d691 | ||
|
|
1723847672 | ||
|
|
125a55f72b | ||
|
|
ba58bd942e | ||
|
|
72fc0b026d | ||
|
|
5350724e5f | ||
|
|
0b208cd011 | ||
|
|
82f8f687fd | ||
|
|
b36c3e0318 | ||
|
|
85473916db | ||
|
|
7afb261206 | ||
|
|
0b2d77605e | ||
|
|
073291360a | ||
|
|
98d6fdf536 | ||
|
|
2b6de8e7dc | ||
|
|
d0cdf5766b | ||
|
|
46366291f1 | ||
|
|
b1d33f4f7d | ||
|
|
1e4d3802f8 | ||
|
|
a2bc9ecb49 | ||
|
|
4b0b26da02 | ||
|
|
fe5612ce71 | ||
|
|
ea51805552 | ||
|
|
2ff4dcde8a | ||
|
|
9f93c2381a | ||
|
|
ece220263b | ||
|
|
06312f1f09 | ||
|
|
14023f7e05 | ||
|
|
cc18dfc7d4 | ||
|
|
99bb0200e5 | ||
|
|
542db19180 | ||
|
|
d9718d0d6a | ||
|
|
491821f0b2 | ||
|
|
6d5235ab0a | ||
|
|
74b23141e0 | ||
|
|
8f4ffc8e22 | ||
|
|
7d314c7bac | ||
|
|
b6c41ae2d3 | ||
|
|
b80c58a073 | ||
|
|
a035f0eeaf | ||
|
|
672d17ec27 | ||
|
|
f7d4a3e7b4 | ||
|
|
00b0c3c62d | ||
|
|
f260a981ca | ||
|
|
f254fe172c | ||
|
|
cc1dc94459 | ||
|
|
e39f6cf16d | ||
|
|
2a406960c4 | ||
|
|
c65e2f02b3 | ||
|
|
831ae011e2 | ||
|
|
32a4db4b46 | ||
|
|
a439be0305 | ||
|
|
36994d208d | ||
|
|
b72c42e1d1 | ||
|
|
b508dcce8d | ||
|
|
c33d397360 | ||
|
|
449f6bda32 | ||
|
|
092515cf3a | ||
|
|
e5fe9bb360 | ||
|
|
2e42e3efac | ||
|
|
0d1cb25b69 | ||
|
|
653117bda6 | ||
|
|
5c32fab4cb | ||
|
|
904c70281a | ||
|
|
2d5825556f | ||
|
|
ef7fc7a8a3 | ||
|
|
e48a462471 | ||
|
|
f6047e9819 | ||
|
|
534cbe8fe1 | ||
|
|
adb5cd75cc | ||
|
|
3469e2c306 | ||
|
|
6de087ae79 | ||
|
|
07a9fdee41 | ||
|
|
7b9767107f | ||
|
|
5388d40c03 | ||
|
|
28769b5028 | ||
|
|
28fa90b093 | ||
|
|
0b16f368a4 | ||
|
|
c5e59b70f7 | ||
|
|
6756a1da74 | ||
|
|
d14de76f9a | ||
|
|
ef3cc05ee3 | ||
|
|
efd706cb9b | ||
|
|
63baa3ec57 | ||
|
|
51defffd3b | ||
|
|
439afd2e2a | ||
|
|
10ae4ee524 | ||
|
|
4af448d7b1 | ||
|
|
adc536bf4b | ||
|
|
fde52e95e1 | ||
|
|
e4ae5759ff | ||
|
|
89eda0e62b | ||
|
|
00330b053f | ||
|
|
0df1d34ee1 | ||
|
|
eddab0de13 | ||
|
|
f9335a7867 | ||
|
|
bc9779be2a | ||
|
|
c4b6fed6ef | ||
|
|
3818276c7e | ||
|
|
6596759132 | ||
|
|
5235208aa8 | ||
|
|
bce55e77f3 | ||
|
|
8d11bb1800 | ||
|
|
40fccb9761 | ||
|
|
28f63aae94 | ||
|
|
1eaf6fc232 | ||
|
|
dd1c824d98 | ||
|
|
42c06c87cc | ||
|
|
d36379ba1b | ||
|
|
83ba49a486 | ||
|
|
0b75e1a548 | ||
|
|
867e297284 | ||
|
|
63bd89ddc3 | ||
|
|
9f39ee3491 | ||
|
|
eabf97b776 | ||
|
|
3c3b976a71 | ||
|
|
81709b5009 | ||
|
|
ffa0c6b390 | ||
|
|
141fe74129 | ||
|
|
d4a69fa2ec | ||
|
|
9a3754a31d | ||
|
|
c89453c5c3 | ||
|
|
be57e620f0 | ||
|
|
45efaa7388 | ||
|
|
15cb5e1619 | ||
|
|
fc500a5cd5 | ||
|
|
c84612751c | ||
|
|
c8ecbd4ed6 | ||
|
|
fbf79ab7c1 | ||
|
|
a446192b9a | ||
|
|
26565be18d | ||
|
|
7c4bc213a3 | ||
|
|
a227dcf726 | ||
|
|
b70c92b1e6 | ||
|
|
033a57a9e9 | ||
|
|
dac74ae040 | ||
|
|
d4abb84214 | ||
|
|
5201c300e9 | ||
|
|
ec0e5299ed | ||
|
|
242c35c89f | ||
|
|
f717ee534e | ||
|
|
6b66f39122 | ||
|
|
4b3e9badbb | ||
|
|
c680be558a | ||
|
|
8cee7ce247 | ||
|
|
580aa19681 | ||
|
|
cd220fe471 | ||
|
|
15b4fd04e5 | ||
|
|
fceba0b68b | ||
|
|
eef4c9b5ed | ||
|
|
24da4e017c | ||
|
|
f3cedf01a5 | ||
|
|
08ee32595f | ||
|
|
4c4d1a2a61 | ||
|
|
64b54a6308 | ||
|
|
e27b3ee8da | ||
|
|
129725cedd | ||
|
|
17886da3df | ||
|
|
c8a46b7e5a | ||
|
|
f97d103fc6 | ||
|
|
aa2fecc5c1 | ||
|
|
6f2244e1ff | ||
|
|
a1dc90ba06 | ||
|
|
32f55ddfb7 | ||
|
|
9a65c7f1f5 | ||
|
|
0f6bc1c160 | ||
|
|
abef7a236b | ||
|
|
0cff62dbe2 | ||
|
|
a590188e44 | ||
|
|
dc3aa11966 | ||
|
|
57714d243a | ||
|
|
1d34a5e99f | ||
|
|
9ab3e5515e | ||
|
|
3abef25c8f | ||
|
|
454f3a4302 | ||
|
|
acb9c19f4d | ||
|
|
98f06951bd | ||
|
|
c9e1a7adbe | ||
|
|
c57cf82fce | ||
|
|
a3bcfaf95c | ||
|
|
c99afec740 | ||
|
|
fa9fd65c2f | ||
|
|
2af87971d5 | ||
|
|
e6753d9474 | ||
|
|
d23717dc6c | ||
|
|
4debe68ed6 | ||
|
|
e6b78e3986 | ||
|
|
7b268cf197 | ||
|
|
34ff884d52 | ||
|
|
7fef23f888 | ||
|
|
7a8d6d0d52 | ||
|
|
6d4f2a7ed9 | ||
|
|
329d04252f | ||
|
|
9b4092ea8c | ||
|
|
d942a7705a | ||
|
|
e3365b42a2 | ||
|
|
41689bd742 | ||
|
|
bc487475f0 | ||
|
|
393e775285 | ||
|
|
cf6c02307c | ||
|
|
88b9bc3a01 | ||
|
|
d1f90efb09 | ||
|
|
df4fad07a9 | ||
|
|
56d533117e | ||
|
|
9549e27f19 | ||
|
|
1677b51c2d | ||
|
|
d4f9442d38 | ||
|
|
8191fa1a5e | ||
|
|
4811b37aa4 | ||
|
|
941cad5844 | ||
|
|
d59af94f62 | ||
|
|
cf403c4d4a | ||
|
|
57a2b1cbab | ||
|
|
ef195bd653 | ||
|
|
9b1a24bca3 | ||
|
|
c6aefbfa97 | ||
|
|
42bad85208 | ||
|
|
f5709739fa | ||
|
|
248f56ed7a | ||
|
|
3de6ed9696 | ||
|
|
4bad39f4b9 | ||
|
|
9b303d8b5a | ||
|
|
7e0b003216 | ||
|
|
dc36a7bf4d | ||
|
|
d33632c421 | ||
|
|
7dc6a867a5 | ||
|
|
b937a0191e | ||
|
|
d57a83956c | ||
|
|
71efd78f03 | ||
|
|
139006d0a7 | ||
|
|
b5abb8b6e8 | ||
|
|
a076a333df | ||
|
|
461ed0a9ff | ||
|
|
4381569a0f | ||
|
|
a52bd10340 | ||
|
|
56a1144865 | ||
|
|
23ab009c08 | ||
|
|
fa4e3d5d88 | ||
|
|
ad7a1ffe44 | ||
|
|
0e4f8893f8 | ||
|
|
8e0b801ec5 | ||
|
|
97889f917d | ||
|
|
cedb3ccc8d | ||
|
|
d7cfd8ff60 | ||
|
|
223e75923d | ||
|
|
dd9f2f72c0 | ||
|
|
8ffea2500d | ||
|
|
5ed890e3fd | ||
|
|
8fe458263d | ||
|
|
6de36585a9 | ||
|
|
30538c366c | ||
|
|
89a0ac8aa4 | ||
|
|
c9a93f2843 | ||
|
|
bfdb4abdce | ||
|
|
eb17eeecd3 | ||
|
|
c2819ef2e7 | ||
|
|
030b588448 | ||
|
|
4ee143968e | ||
|
|
834d681bb9 | ||
|
|
fc35bb6764 | ||
|
|
13222bfc7b | ||
|
|
8e2b08ce90 | ||
|
|
24a44ff253 | ||
|
|
9e0118fd30 | ||
|
|
3325af2331 | ||
|
|
ec102a8093 | ||
|
|
9d72109023 | ||
|
|
f1d6f3d8cb | ||
|
|
1e01be712a | ||
|
|
1a0c914819 | ||
|
|
19d3f46428 | ||
|
|
6e2e3ff97f | ||
|
|
303eed03d7 | ||
|
|
a0754d2e3a | ||
|
|
3d4417d84b | ||
|
|
6f5de57115 | ||
|
|
7e72d52e4a | ||
|
|
7010b00b00 | ||
|
|
3de31f0393 | ||
|
|
06fe34f291 | ||
|
|
d78dbb76b1 | ||
|
|
a09493b845 | ||
|
|
cd085282ff | ||
|
|
468ff8c3a9 | ||
|
|
a991693584 | ||
|
|
fc6724b4f0 | ||
|
|
3cb5a9b8fa | ||
|
|
16613077df | ||
|
|
b844985528 | ||
|
|
7ad8915d96 | ||
|
|
23ec79d897 | ||
|
|
8849db197e | ||
|
|
ec1a20cd0a | ||
|
|
6c3e41fc3d | ||
|
|
cb8f2c8d34 | ||
|
|
c4f072e159 | ||
|
|
4019c31f9d | ||
|
|
5cb5541eda | ||
|
|
71084979f3 | ||
|
|
96527a1419 | ||
|
|
4e0a85e64f | ||
|
|
ed5e1d86cd | ||
|
|
d8b15da016 | ||
|
|
54e290106d | ||
|
|
161f8f0aed | ||
|
|
c9e2d302be | ||
|
|
bd4f6024c6 | ||
|
|
15de46da7b | ||
|
|
4e3b8701a2 | ||
|
|
dabcedcf23 | ||
|
|
52a2a1f961 | ||
|
|
0345e03e6a | ||
|
|
873539ac92 | ||
|
|
9c85f90faf | ||
|
|
1643643e77 | ||
|
|
a7e4cc914b | ||
|
|
6daa2a230a | ||
|
|
5486e3c95f | ||
|
|
204aa5e226 | ||
|
|
e2dd01fb95 | ||
|
|
0ebbd89778 | ||
|
|
c8c2f7b4c8 | ||
|
|
ac75c01fed | ||
|
|
a823c6040a | ||
|
|
05589f3988 | ||
|
|
5b8b3f148b | ||
|
|
a9c3ba3c00 | ||
|
|
c9d1ed599d | ||
|
|
0d024e2b79 | ||
|
|
d97355207c | ||
|
|
19fbbb88b4 | ||
|
|
910dfbc1b7 | ||
|
|
f329bbc91f | ||
|
|
3a1a1fcd0a | ||
|
|
dfa60099c3 | ||
|
|
bed8c75cd5 | ||
|
|
ba5b1bcbca | ||
|
|
aecb971e11 | ||
|
|
86ef6d705d | ||
|
|
f15bc742fc | ||
|
|
49ac9ec1e0 | ||
|
|
b8bea4ce6a | ||
|
|
923c59faac | ||
|
|
b58f84d48f | ||
|
|
45d5d73cd6 | ||
|
|
766fe567a5 | ||
|
|
50477c8449 | ||
|
|
3e6a7d1c03 | ||
|
|
ab8ebff847 | ||
|
|
9459246bc9 | ||
|
|
0c126c11f8 | ||
|
|
688b514285 | ||
|
|
c9e928ee53 | ||
|
|
360b379a82 | ||
|
|
fdbb403d27 | ||
|
|
7abf5a50cb | ||
|
|
6a2a3b2e97 | ||
|
|
075a209517 | ||
|
|
1c471dc14d | ||
|
|
7bb1d810f6 | ||
|
|
2133bf5edd | ||
|
|
9190bc7f3e | ||
|
|
d86b6be62d | ||
|
|
295da11ef5 | ||
|
|
cc6960e940 | ||
|
|
0c6d6c70bb | ||
|
|
227f8d660a | ||
|
|
6caed557af | ||
|
|
a78c6c2d4a | ||
|
|
5676bf7914 | ||
|
|
35d61147f1 | ||
|
|
f38d7c88a2 | ||
|
|
1b0ca51e31 | ||
|
|
82d261919b | ||
|
|
9fa3d4ac42 | ||
|
|
83c88ae30d | ||
|
|
662521af10 | ||
|
|
4efd31e444 | ||
|
|
43fab73d71 | ||
|
|
a5cfbe72c0 | ||
|
|
d09a875d4d | ||
|
|
050c4f0892 | ||
|
|
cd89a29df0 | ||
|
|
323be9e1ed | ||
|
|
cc59a85609 | ||
|
|
004a045da2 | ||
|
|
950fda2214 | ||
|
|
7e6cfa0075 | ||
|
|
f60532501f | ||
|
|
19728f2cbd | ||
|
|
186fd79dba | ||
|
|
a6e5fc9982 | ||
|
|
3349fb275b | ||
|
|
6e92e5e2d5 | ||
|
|
3060f73ce3 | ||
|
|
cd52647800 | ||
|
|
ece32bf4f3 | ||
|
|
5d230a365c | ||
|
|
bc36dca3cd | ||
|
|
9cecf0c0d4 | ||
|
|
a2ba088d45 | ||
|
|
85c4d5ce59 | ||
|
|
41fdc6a95a | ||
|
|
26019677a4 | ||
|
|
06c4217785 | ||
|
|
033751221b | ||
|
|
50d5d98316 | ||
|
|
1ec6a3fff5 | ||
|
|
eef8fa62a0 | ||
|
|
1511be8d0e | ||
|
|
d9c99913d3 | ||
|
|
f6eae256a4 | ||
|
|
e33816e9da | ||
|
|
8353978b5a | ||
|
|
d9c64aa238 | ||
|
|
9aafca90ee | ||
|
|
907943ad01 | ||
|
|
60a31ce96c | ||
|
|
37a4debf65 | ||
|
|
33be7d4da3 | ||
|
|
63b9d4d93b | ||
|
|
06dcb1e193 | ||
|
|
1fbda3f91c | ||
|
|
90b0068752 | ||
|
|
4d8d96c1b7 | ||
|
|
a9483aef2d | ||
|
|
5fbf554282 | ||
|
|
4ff943fe9f | ||
|
|
f313527b46 | ||
|
|
d65294c4e2 | ||
|
|
947f1b8a33 | ||
|
|
6b87fb7e0e | ||
|
|
96b9152c3a | ||
|
|
9a9ab99914 | ||
|
|
414e71be50 | ||
|
|
269ae86201 | ||
|
|
f03af8056b | ||
|
|
9d760a0712 | ||
|
|
4b2af18ae2 | ||
|
|
db25ddfcf3 | ||
|
|
02fb4a9cf6 | ||
|
|
18727c70a3 | ||
|
|
2fd369b011 | ||
|
|
46eabd25bb | ||
|
|
4b08a3a36f | ||
|
|
df28827c5d | ||
|
|
e7b3c9f7c0 | ||
|
|
c301d54083 | ||
|
|
70a5eed2ee | ||
|
|
218be60bc2 | ||
|
|
5299fb0aa8 | ||
|
|
4b1f1afabc | ||
|
|
6832cbcdd8 | ||
|
|
6b05fba068 | ||
|
|
e867898a31 | ||
|
|
5374d785ae | ||
|
|
3bda77b21b | ||
|
|
5054f6673e | ||
|
|
dfc0c2ba7f | ||
|
|
d22d8aea51 | ||
|
|
1f57f82358 | ||
|
|
2efe3bc5b6 | ||
|
|
03282ed4af | ||
|
|
e2576c8602 | ||
|
|
5d2b10f859 | ||
|
|
9bed9930e8 | ||
|
|
eec54343c5 | ||
|
|
825b36f5da | ||
|
|
97bbbc57e0 | ||
|
|
4869541688 | ||
|
|
eb34a1b2b3 | ||
|
|
bceafecfa6 | ||
|
|
5d6d7bbeaa | ||
|
|
c57e4716ae | ||
|
|
4f5786ddeb | ||
|
|
6c5e6d6784 | ||
|
|
5acbdb4461 | ||
|
|
adaf8bc098 | ||
|
|
78f2babf27 | ||
|
|
c6b919b4fd | ||
|
|
5b23ce2a24 | ||
|
|
42e3bd22fd | ||
|
|
a2b0991da9 | ||
|
|
f2c80b42ed | ||
|
|
7ea74c8ace | ||
|
|
8cc39fd10f | ||
|
|
29ad28d3f8 | ||
|
|
f6c68614bc | ||
|
|
0f9e801cb9 | ||
|
|
710663ec59 | ||
|
|
dd89705e50 | ||
|
|
8d5e3455f1 | ||
|
|
b11a5eceeb | ||
|
|
de7d2963ca | ||
|
|
1a059adaa0 | ||
|
|
74f37611ca | ||
|
|
62efbe3a3c | ||
|
|
2637105e7d | ||
|
|
8332b3cd6d | ||
|
|
12c1cd0230 | ||
|
|
0fdfa2a491 | ||
|
|
7859b6e7c8 | ||
|
|
006cf5bc89 | ||
|
|
84410a4236 | ||
|
|
51ec832633 | ||
|
|
722bf4c9cb | ||
|
|
1b9963c96d | ||
|
|
e55ba7222e | ||
|
|
11cd0215e9 | ||
|
|
ab3177ff7f | ||
|
|
892352914a | ||
|
|
06fe552232 | ||
|
|
51b173179a | ||
|
|
5b8090381e | ||
|
|
eb5857482d | ||
|
|
bc78e9ca69 | ||
|
|
31c5bf9202 | ||
|
|
07b31f5595 | ||
|
|
57a3f4f9b6 | ||
|
|
0567c96b86 | ||
|
|
6439efbf8e | ||
|
|
d8af9c5cc6 | ||
|
|
8a3922ee89 | ||
|
|
ece65450cc | ||
|
|
704ca02698 | ||
|
|
3b2b1bea9b | ||
|
|
05a0857c5b | ||
|
|
c13ec8d290 | ||
|
|
197c5e7aad | ||
|
|
e74e58a2c3 | ||
|
|
9d9ae1f835 | ||
|
|
22c957d3d5 | ||
|
|
6d1cd0df63 | ||
|
|
8f6c2e2e65 | ||
|
|
19a65483e8 | ||
|
|
0718706659 | ||
|
|
6287270c24 | ||
|
|
873a38c246 | ||
|
|
a2053ec253 | ||
|
|
b581bcd4a8 | ||
|
|
cfa5be074a | ||
|
|
d41e01cd5d | ||
|
|
9aa249206f | ||
|
|
0c29f3d31b | ||
|
|
883570731e | ||
|
|
42df23982f | ||
|
|
c7ac717d9f | ||
|
|
73627af26b | ||
|
|
3f594befec | ||
|
|
4d6f541285 | ||
|
|
5308b399bd | ||
|
|
059ba24b68 | ||
|
|
9680e36f9d | ||
|
|
883c5e306b | ||
|
|
0726376955 | ||
|
|
ac3c029bff | ||
|
|
3adf8ff854 | ||
|
|
75ced453b0 | ||
|
|
3c6d7f398e | ||
|
|
2ce988f87d | ||
|
|
d530329478 | ||
|
|
c777ab4f67 | ||
|
|
a6ace6c675 | ||
|
|
bfb228eb6c | ||
|
|
9e6eb05460 | ||
|
|
6cfb006190 | ||
|
|
88cb2a81ca | ||
|
|
b1066cce42 | ||
|
|
0885797ea7 | ||
|
|
4093e7e71b | ||
|
|
25c267eb7f | ||
|
|
3db0b1b771 | ||
|
|
0d0d3198e9 | ||
|
|
7b3540e13f | ||
|
|
4e492939c1 | ||
|
|
d39692d1bd | ||
|
|
086c9808a3 | ||
|
|
f7ef202849 | ||
|
|
77a450195c | ||
|
|
b10c4caefb | ||
|
|
4ee374efb6 | ||
|
|
183dc4cf14 | ||
|
|
81cd6c3a64 | ||
|
|
1f7ae68857 | ||
|
|
f175d759ca | ||
|
|
83f8a33413 | ||
|
|
a22ca731b6 | ||
|
|
e5934cef1f | ||
|
|
1b49c5dfe9 | ||
|
|
47c384e2ec | ||
|
|
8d5a0c590e | ||
|
|
6b04bc6757 | ||
|
|
baa996356c | ||
|
|
ae5f7e5435 | ||
|
|
9241b3c748 | ||
|
|
48b341db39 | ||
|
|
b759e016bb | ||
|
|
8dc7a86b2b | ||
|
|
0db0273513 | ||
|
|
21254ad871 | ||
|
|
5bbf29859f | ||
|
|
730566fd05 | ||
|
|
f05c5eca03 | ||
|
|
8c50d94f8e | ||
|
|
91c42e137d | ||
|
|
a2a9ba289c | ||
|
|
0ea798e70e | ||
|
|
3caa8d2ceb | ||
|
|
bd836c8b55 | ||
|
|
f3bf05ab9b | ||
|
|
ef0b5d3780 | ||
|
|
ab5fbeb6ed | ||
|
|
6c779bd4c1 | ||
|
|
fbb964a893 | ||
|
|
4f1f63671e | ||
|
|
5578e8db5c | ||
|
|
5a93907d74 | ||
|
|
1d4403b497 | ||
|
|
6939884a95 | ||
|
|
509f09165a | ||
|
|
40d8e1b76a | ||
|
|
da1c085f4a | ||
|
|
53281c0921 | ||
|
|
b9cf9b5558 | ||
|
|
295500a746 | ||
|
|
b1f77d202d | ||
|
|
5a29f5fbb1 | ||
|
|
1d6e4374c0 | ||
|
|
eaa7d1c790 | ||
|
|
f29cd16616 | ||
|
|
1279ad6e68 | ||
|
|
8d4ba43cbe | ||
|
|
d2562a5e0a | ||
|
|
a1d67afb72 | ||
|
|
fd61b8506b | ||
|
|
75babad426 | ||
|
|
2b64030c0c | ||
|
|
26fcf457e6 | ||
|
|
26bf1e482d | ||
|
|
107eac7e25 | ||
|
|
e2b442ab0b | ||
|
|
b822a5d862 | ||
|
|
dc4e41305e | ||
|
|
fdfb4cff64 | ||
|
|
2128b9e6a0 | ||
|
|
605661ed47 | ||
|
|
17915c578a | ||
|
|
31891b517b | ||
|
|
81d21ce557 | ||
|
|
20e7d0195e | ||
|
|
ba3529116c | ||
|
|
2a98b48fe6 | ||
|
|
390519996d | ||
|
|
cf9f4acd05 | ||
|
|
360b3f2cd4 | ||
|
|
da1b19236d | ||
|
|
4c39944557 | ||
|
|
2be2da470f | ||
|
|
5d74b2bb2d | ||
|
|
9233bfc548 | ||
|
|
287120832d | ||
|
|
dc02f3bb9a | ||
|
|
2cb05ba17f | ||
|
|
6bb263462b | ||
|
|
563da57545 | ||
|
|
d43142575f | ||
|
|
f6d5739eea | ||
|
|
d10c7f0937 | ||
|
|
dc4cf6e5bf | ||
|
|
7e229a047f | ||
|
|
5845e7f286 | ||
|
|
3881789879 | ||
|
|
df19c63901 | ||
|
|
582ce9ed8d | ||
|
|
697a1cf715 | ||
|
|
8eec5ce1d9 | ||
|
|
c08180872e | ||
|
|
f8b18576aa | ||
|
|
46a471c8a7 | ||
|
|
1b1190582a | ||
|
|
addf867f59 | ||
|
|
4ef95ec246 | ||
|
|
b48445f714 | ||
|
|
dc10a56c79 | ||
|
|
b1b9ea71de | ||
|
|
3c41573db2 | ||
|
|
9929104adc | ||
|
|
eca26b73c5 | ||
|
|
5464ae4ddd | ||
|
|
1c9a42c9ea | ||
|
|
805f02ad2d | ||
|
|
880e884dea | ||
|
|
fd4a8d87a6 | ||
|
|
922014cb9b | ||
|
|
db88e168b2 | ||
|
|
2b7392735a | ||
|
|
b00a47e5e5 | ||
|
|
171238b71d | ||
|
|
d0a6c61de2 | ||
|
|
a2e13ea71a | ||
|
|
169d6c16fd |
368
.all-contributorsrc
Normal file
@@ -0,0 +1,368 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "feroxbuster",
|
||||
"projectOwner": "epi052",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
}
|
||||
5
.cargo/config
Normal file
@@ -0,0 +1,5 @@
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
4
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [epi052]
|
||||
ko_fi: epi052
|
||||
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
12
.github/pull_request_template.md
vendored
@@ -4,19 +4,23 @@ Long form explanations of most of the items below can be found in the [CONTRIBUT
|
||||
|
||||
## Branching checklist
|
||||
- [ ] There is an issue associated with your PR (bug, feature, etc.. if not, create one)
|
||||
- [ ] Your PR description references the associated issue (i.e. fixes #123)
|
||||
- [ ] Your PR description references the associated issue (i.e. fixes #123456)
|
||||
- [ ] Code is in its own branch
|
||||
- [ ] Branch name is related to the PR contents
|
||||
- [ ] PR targets master
|
||||
- [ ] PR targets main
|
||||
|
||||
## Static analysis checks
|
||||
- [ ] All rust files are formatted using `cargo fmt`
|
||||
- [ ] All `clippy` checks pass when running `cargo clippy --all-targets --all-features -- -D warnings -A clippy::unnecessary_unwrap`
|
||||
- [ ] All `clippy` checks pass when running `cargo clippy --all-targets --all-features -- -D warnings -A clippy::mutex-atomic`
|
||||
- [ ] All existing tests pass
|
||||
|
||||
## Documentation
|
||||
- [ ] New code is documented using [doc comments](https://doc.rust-lang.org/stable/rust-by-example/meta/doc.html)
|
||||
- [ ] Documentation about your PR is included in the README, as needed
|
||||
- [ ] Documentation about your PR is included in the `docs`, as needed. The docs live in a [separate repository](https://epi052.github.io/feroxbuster-docs/docs/). Update the appropriate pages at the links below.
|
||||
- [ ] update [example config file section](https://epi052.github.io/feroxbuster-docs/docs/configuration/ferox-config-toml/)
|
||||
- [ ] update [help output section](https://epi052.github.io/feroxbuster-docs/docs/configuration/command-line/)
|
||||
- [ ] add an [example](https://epi052.github.io/feroxbuster-docs/docs/examples/)
|
||||
- [ ] update [comparison table](https://epi052.github.io/feroxbuster-docs/docs/compare/)
|
||||
|
||||
## Additional Tests
|
||||
- [ ] New code is unit tested
|
||||
|
||||
18
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 14
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
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
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
32
.github/workflows/build.yml
vendored
@@ -4,11 +4,13 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build-nix:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/main'
|
||||
strategy:
|
||||
matrix:
|
||||
type: [ubuntu-x64, ubuntu-x86]
|
||||
type: [ubuntu-x64, ubuntu-x86, armv7, aarch64]
|
||||
include:
|
||||
- type: ubuntu-x64
|
||||
os: ubuntu-latest
|
||||
@@ -22,12 +24,25 @@ jobs:
|
||||
name: x86-linux-feroxbuster
|
||||
path: target/i686-unknown-linux-musl/release/feroxbuster
|
||||
pkg_config_path: /usr/lib/i686-linux-gnu/pkgconfig
|
||||
- type: armv7
|
||||
os: ubuntu-latest
|
||||
target: armv7-unknown-linux-gnueabihf
|
||||
name: armv7-feroxbuster
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/feroxbuster
|
||||
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
|
||||
- type: aarch64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
name: aarch64-feroxbuster
|
||||
path: target/aarch64-unknown-linux-gnu/release/feroxbuster
|
||||
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install System Dependencies
|
||||
run: |
|
||||
env
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libssl-dev pkg-config
|
||||
sudo apt-get install -y --no-install-recommends libssl-dev pkg-config gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
@@ -43,7 +58,7 @@ jobs:
|
||||
args: --release --target=${{ matrix.target }}
|
||||
- name: Strip symbols from binary
|
||||
run: |
|
||||
strip -s ${{ matrix.path }}
|
||||
strip -s ${{ matrix.path }} || arm-linux-gnueabihf-strip -s ${{ matrix.path }} || aarch64-linux-gnu-strip -s ${{ matrix.path }}
|
||||
- name: Build tar.gz for homebrew installs
|
||||
if: matrix.type == 'ubuntu-x64'
|
||||
run: |
|
||||
@@ -72,8 +87,10 @@ jobs:
|
||||
path: ./target/x86_64-unknown-linux-musl/debian/*
|
||||
|
||||
build-macos:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: macos-latest
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
@@ -102,8 +119,10 @@ jobs:
|
||||
path: x86_64-macos-feroxbuster.tar.gz
|
||||
|
||||
build-windows:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/main'
|
||||
strategy:
|
||||
matrix:
|
||||
type: [windows-x64, windows-x86]
|
||||
@@ -134,4 +153,3 @@ jobs:
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ matrix.path }}
|
||||
|
||||
|
||||
2
.github/workflows/check.yml
vendored
@@ -61,4 +61,4 @@ jobs:
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --all-targets --all-features -- -D warnings -A clippy::unnecessary_unwrap -A clippy::deref_addrof
|
||||
args: --all-targets --all-features -- -D warnings -A clippy::deref_addrof -A clippy::mutex-atomic
|
||||
|
||||
34
.github/workflows/cicd-to-dockerhub.yml
vendored
Normal file
@@ -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 }}
|
||||
10
.github/workflows/coverage.yml
vendored
@@ -23,9 +23,13 @@ jobs:
|
||||
RUSTFLAGS: '-Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort'
|
||||
RUSTDOCFLAGS: '-Cpanic=abort'
|
||||
- uses: actions-rs/grcov@v0.1
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: lcov.info
|
||||
path: lcov.info
|
||||
- name: Convert lcov to xml
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py
|
||||
curl -O https://raw.githubusercontent.com/epi052/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py
|
||||
chmod +x lcov_cobertura.py
|
||||
./lcov_cobertura.py ./lcov.info
|
||||
- uses: codecov/codecov-action@v1
|
||||
@@ -34,3 +38,7 @@ jobs:
|
||||
file: ./coverage.xml
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: true
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: coverage.xml
|
||||
path: ./coverage.xml
|
||||
|
||||
16
.gitignore
vendored
@@ -3,16 +3,15 @@
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# jetbrains metadata folder
|
||||
.idea/
|
||||
|
||||
# vscode metadata folder
|
||||
.vscode/
|
||||
|
||||
# personal feroxbuster config for testing
|
||||
ferox-config.toml
|
||||
|
||||
@@ -22,3 +21,12 @@ img/**
|
||||
# scripts to check code coverage using nightly compiler
|
||||
check-coverage.sh
|
||||
lcov_cobertura.py
|
||||
|
||||
# dockerignore file that makes it so i can work on the docker config without copying a 4GB manifest or w/e it is
|
||||
.dockerignore
|
||||
|
||||
# state file created during tests
|
||||
ferox-*.state
|
||||
|
||||
# python stuff cuz reasons
|
||||
Pipfile*
|
||||
|
||||
1
.rustfmt.toml
Normal file
@@ -0,0 +1 @@
|
||||
reorder_modules = false
|
||||
@@ -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.
|
||||
|
||||
|
||||
2669
Cargo.lock
generated
Normal file
52
Cargo.toml
@@ -1,45 +1,60 @@
|
||||
[package]
|
||||
name = "feroxbuster"
|
||||
version = "1.6.1"
|
||||
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
|
||||
version = "2.5.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"]
|
||||
exclude = [".github/*", "img/*", "check-coverage.sh"]
|
||||
build = "build.rs"
|
||||
|
||||
[badges]
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[build-dependencies]
|
||||
clap = {version = "3.0", features = ["cargo"]}
|
||||
clap_complete = "3.0"
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
dirs = "4.0"
|
||||
|
||||
[dependencies]
|
||||
scraper = "0.12"
|
||||
futures = { version = "0.3"}
|
||||
tokio = { version = "0.2", features = ["full"] }
|
||||
tokio-util = {version = "0.3", features = ["codec"]}
|
||||
tokio = { version = "1.15", features = ["full"] }
|
||||
tokio-util = {version = "0.6", features = ["codec"]}
|
||||
log = "0.4"
|
||||
env_logger = "0.8"
|
||||
reqwest = { version = "0.10", features = ["socks"] }
|
||||
clap = "2"
|
||||
env_logger = "0.9"
|
||||
reqwest = { version = "0.11", features = ["socks"] }
|
||||
url = { version = "2.2", features = ["serde"]} # uses feature unification to add 'serde' to reqwest::Url
|
||||
serde_regex = "1.1"
|
||||
clap = {version = "3.0", features = ["wrap_help", "cargo"]}
|
||||
lazy_static = "1.4"
|
||||
toml = "0.5"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "0.8", features = ["v4"] }
|
||||
indicatif = "0.15"
|
||||
console = "0.12"
|
||||
console = "0.15"
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
dirs = "3.0"
|
||||
dirs = "4.0"
|
||||
regex = "1"
|
||||
crossterm = "0.18"
|
||||
rlimit = "0.5"
|
||||
crossterm = "0.20"
|
||||
rlimit = "0.6"
|
||||
ctrlc = "3.2"
|
||||
fuzzyhash = "0.2.1"
|
||||
anyhow = "1.0"
|
||||
leaky-bucket = "0.10.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.1"
|
||||
httpmock = "0.4.5"
|
||||
assert_cmd = "1.0.1"
|
||||
predicates = "1.0.5"
|
||||
tempfile = "3.3"
|
||||
httpmock = "0.6"
|
||||
assert_cmd = "2.0"
|
||||
predicates = "2.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
@@ -53,4 +68,7 @@ conf-files = ["/etc/feroxbuster/ferox-config.toml"]
|
||||
assets = [
|
||||
["target/release/feroxbuster", "/usr/bin/", "755"],
|
||||
["ferox-config.toml.example", "/etc/feroxbuster/ferox-config.toml", "644"],
|
||||
["shell_completions/feroxbuster.bash", "/usr/share/bash-completion/completions/feroxbuster.bash", "644"],
|
||||
["shell_completions/feroxbuster.fish", "/usr/share/fish/completions/feroxbuster.fish", "644"],
|
||||
["shell_completions/_feroxbuster", "/usr/share/zsh/vendor-completions/_feroxbuster", "644"],
|
||||
]
|
||||
|
||||
30
Dockerfile
@@ -1,12 +1,28 @@
|
||||
FROM alpine:latest
|
||||
# Image: alpine:3.14.2
|
||||
FROM alpine@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a as build
|
||||
LABEL maintainer="wfnintr@null.net"
|
||||
|
||||
# download default wordlists
|
||||
RUN apk add --no-cache --virtual .depends subversion && \
|
||||
svn export https://github.com/danielmiessler/SecLists/trunk/Discovery/Web-Content /usr/share/seclists/Discovery/Web-Content && \
|
||||
apk del .depends
|
||||
RUN sed -i -e 's/v[[:digit:]]\..*\//edge\//g' /etc/apk/repositories \
|
||||
&& apk upgrade --update-cache --available && apk add --update openssl
|
||||
|
||||
# install latest release
|
||||
RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip && unzip -d /usr/local/bin/ feroxbuster.zip feroxbuster && rm feroxbuster.zip && chmod +x /usr/local/bin/feroxbuster
|
||||
|
||||
# Download latest release
|
||||
RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip \
|
||||
&& unzip -d /tmp/ feroxbuster.zip feroxbuster \
|
||||
&& chmod +x /tmp/feroxbuster \
|
||||
&& wget https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-medium-directories.txt -O /tmp/raft-medium-directories.txt
|
||||
|
||||
# Image: alpine:3.14.2
|
||||
FROM alpine@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a as release
|
||||
|
||||
COPY --from=build /tmp/raft-medium-directories.txt /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
|
||||
COPY --from=build /tmp/feroxbuster /usr/local/bin/feroxbuster
|
||||
|
||||
RUN adduser \
|
||||
--gecos "" \
|
||||
--disabled-password \
|
||||
feroxbuster
|
||||
|
||||
USER feroxbuster
|
||||
|
||||
ENTRYPOINT ["feroxbuster"]
|
||||
|
||||
79
Makefile
Normal file
@@ -0,0 +1,79 @@
|
||||
default_prefix = /usr/local
|
||||
prefix ?= $(default_prefix)
|
||||
exec_prefix = $(prefix)
|
||||
bindir = $(exec_prefix)/bin
|
||||
datarootdir = $(prefix)/share
|
||||
datadir = $(datarootdir)
|
||||
example_config = ferox-config.toml.example
|
||||
config_file = ferox-config.toml
|
||||
completion_dir = shell_completions
|
||||
completion_prefix = $(completion_dir)/$(BIN)
|
||||
|
||||
BIN=feroxbuster
|
||||
SHR_SOURCES = $(shell find src -type f -wholename '*src/*.rs') Cargo.toml Cargo.lock
|
||||
|
||||
RELEASE = debug
|
||||
DEBUG ?= 0
|
||||
|
||||
ifeq (0, $(DEBUG))
|
||||
ARGS = --release
|
||||
RELEASE = release
|
||||
endif
|
||||
|
||||
VENDORED ?= 0
|
||||
ifeq (1,$(VENDORED))
|
||||
ARGS += --frozen
|
||||
endif
|
||||
|
||||
TARGET = target/$(RELEASE)
|
||||
|
||||
.PHONY: all clean install uninstall test update
|
||||
|
||||
all: cli
|
||||
cli: $(TARGET)/$(BIN) $(TARGET)/$(BIN).1.gz $(SHR_SOURCES)
|
||||
install: all install-cli
|
||||
|
||||
verify:
|
||||
cargo fmt
|
||||
cargo clippy --all-targets --all-features -- -D warnings -A clippy::mutex-atomic
|
||||
cargo test
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
vendor: vendor.tar
|
||||
|
||||
vendor.tar:
|
||||
cargo vendor
|
||||
tar pcf vendor.tar vendor
|
||||
rm -rf vendor
|
||||
|
||||
install-cli: cli
|
||||
install -Dm 0644 "$(completion_prefix).bash" "$(DESTDIR)/usr/share/bash-completion/completions/$(BIN).bash"
|
||||
install -Dm 0644 "$(completion_prefix).fish" "$(DESTDIR)/usr/share/fish/completions/$(BIN).fish"
|
||||
install -Dm 0644 "$(completion_dir)/_$(BIN)" "$(DESTDIR)/usr/share/zsh/vendor-completions/_$(BIN)"
|
||||
install -sDm 0755 "$(TARGET)/$(BIN)" "$(DESTDIR)$(bindir)/$(BIN)"
|
||||
install -Dm 0644 "$(TARGET)/$(BIN).1.gz" "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz"
|
||||
install -Dm 0644 "$(example_config)" "$(DESTDIR)/etc/$(BIN)/$(config_file)"
|
||||
|
||||
uninstall:
|
||||
rm -f "$(DESTDIR)$(bindir)/$(BIN)"
|
||||
rm -f "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz"
|
||||
rm -rf "$(DESTDIR)/etc/$(BIN)/"
|
||||
rm -f "$(DESTDIR)/usr/share/bash-completion/completions/$(BIN).bash"
|
||||
rm -f "$(DESTDIR)/usr/share/zsh/vendor-completions/_$(BIN)"
|
||||
rm -f "$(DESTDIR)/usr/share/fish/completions/$(BIN).fish"
|
||||
|
||||
extract:
|
||||
ifeq (1, $(VENDORED))
|
||||
tar pxf vendor.tar
|
||||
endif
|
||||
|
||||
$(TARGET)/$(BIN): extract
|
||||
mkdir -p .cargo
|
||||
cp debian/cargo.config .cargo/config.toml
|
||||
cargo build $(ARGS)
|
||||
|
||||
$(TARGET)/$(BIN).1.gz: $(TARGET)/$(BIN)
|
||||
help2man --no-info $< | gzip -c > $@.partial
|
||||
mv $@.partial $@
|
||||
651
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/epi052/feroxbuster/actions?query=workflow%3A%22CI+Pipeline%22">
|
||||
<img src="https://img.shields.io/github/workflow/status/epi052/feroxbuster/CI%20Pipeline/master?logo=github">
|
||||
<img src="https://img.shields.io/github/workflow/status/epi052/feroxbuster/CI%20Pipeline/main?logo=github">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/epi052/feroxbuster/releases">
|
||||
@@ -22,7 +22,7 @@
|
||||
<a href="https://crates.io/crates/feroxbuster">
|
||||
<img src="https://img.shields.io/crates/v/feroxbuster?color=blue&label=version&logo=rust">
|
||||
</a>
|
||||
|
||||
|
||||
<a href="https://crates.io/crates/feroxbuster">
|
||||
<img src="https://img.shields.io/crates/d/feroxbuster?label=downloads&logo=rust&color=inactive">
|
||||
</a>
|
||||
@@ -30,6 +30,14 @@
|
||||
<a href="https://codecov.io/gh/epi052/feroxbuster">
|
||||
<img src="https://codecov.io/gh/epi052/feroxbuster/branch/master/graph/badge.svg" />
|
||||
</a>
|
||||
<!--
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
<a href="https://github.com/epi052/feroxbuster/graphs/contributors">
|
||||
<img src="https://img.shields.io/badge/all_contributors-31-orange.svg" />
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||

|
||||
@@ -37,77 +45,62 @@
|
||||
<p align="center">
|
||||
🦀
|
||||
<a href="https://github.com/epi052/feroxbuster/releases">Releases</a> ✨
|
||||
<a href="#-example-usage">Example Usage</a> ✨
|
||||
<a href="https://github.com/epi052/feroxbuster/blob/master/CONTRIBUTING.md">Contributing</a> ✨
|
||||
<a href="https://docs.rs/feroxbuster/latest/feroxbuster/">Documentation</a>
|
||||
<a href="https://epi052.github.io/feroxbuster-docs/docs/examples/">Example Usage</a> ✨
|
||||
<a href="https://github.com/epi052/feroxbuster/blob/main/CONTRIBUTING.md">Contributing</a> ✨
|
||||
<a href="https://epi052.github.io/feroxbuster-docs/docs/">Documentation</a>
|
||||
🦀
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<h1><p align="center">✨🎉👉 <a href="https://epi052.github.io/feroxbuster-docs/docs/">NEW DOCUMENTATION SITE</a> 👈🎉✨</p></h1>
|
||||
|
||||
|
||||
## 🚀 Documentation has **moved** 🚀
|
||||
|
||||
Instead of having a 1300 line `README.md` (sorry...), feroxbuster's documentation has moved to GitHub Pages. The move to hosting documentation on Pages should make it a LOT easier to find the information you're looking for, whatever that may be. Please check it out for anything you need beyond a quick-start. The new documentation can be found [here](https://epi052.github.io/feroxbuster-docs/docs/).
|
||||
|
||||
## 😕 What the heck is a ferox anyway?
|
||||
|
||||
Ferox is short for Ferric Oxide. Ferric Oxide, simply put, is rust. The name rustbuster was taken, so I decided on a variation. 🤷
|
||||
Ferox is short for Ferric Oxide. Ferric Oxide, simply put, is rust. The name rustbuster was taken, so I decided on a
|
||||
variation. 🤷
|
||||
|
||||
## 🤔 What's it do tho?
|
||||
## 🤔 What's it do tho?
|
||||
|
||||
`feroxbuster` is a tool designed to perform [Forced Browsing](https://owasp.org/www-community/attacks/Forced_browsing).
|
||||
`feroxbuster` is a tool designed to perform [Forced Browsing](https://owasp.org/www-community/attacks/Forced_browsing).
|
||||
|
||||
Forced browsing is an attack where the aim is to enumerate and access resources that are not referenced by the web application, but are still accessible by an attacker.
|
||||
Forced browsing is an attack where the aim is to enumerate and access resources that are not referenced by the web
|
||||
application, but are still accessible by an attacker.
|
||||
|
||||
`feroxbuster` uses brute force combined with a wordlist to search for unlinked content in target directories. These resources may store sensitive information about web applications and operational systems, such as source code, credentials, internal network addressing, etc...
|
||||
`feroxbuster` uses brute force combined with a wordlist to search for unlinked content in target directories. These
|
||||
resources may store sensitive information about web applications and operational systems, such as source code,
|
||||
credentials, internal network addressing, etc...
|
||||
|
||||
This attack is also known as Predictable Resource Location, File Enumeration, Directory Enumeration, and Resource Enumeration.
|
||||
This attack is also known as Predictable Resource Location, File Enumeration, Directory Enumeration, and Resource
|
||||
Enumeration.
|
||||
|
||||
📖 Table of Contents
|
||||
-----------------
|
||||
- [Installation](#-installation)
|
||||
- [Download a Release](#download-a-release)
|
||||
- [Snap Install](#snap-install)
|
||||
- [Homebrew on MacOS and Linux](#homebrew-on-macos-and-linux)
|
||||
- [Cargo Install](#cargo-install)
|
||||
- [apt Install](#apt-install)
|
||||
- [AUR Install](#aur-install)
|
||||
- [Docker Install](#docker-install)
|
||||
- [Configuration](#%EF%B8%8F-configuration)
|
||||
- [Default Values](#default-values)
|
||||
- [Threads and Connection Limits At A High-Level](#threads-and-connection-limits-at-a-high-level)
|
||||
- [ferox-config.toml](#ferox-configtoml)
|
||||
- [Command Line Parsing](#command-line-parsing)
|
||||
- [Example Usage](#-example-usage)
|
||||
- [Pause and Resume Scans (new in `v1.4.0`)](#pause-and-resume-scans-new-in-v140)
|
||||
- [Multiple Values](#multiple-values)
|
||||
- [Extract Links from Response Body (new in `v1.1.0`)](#extract-links-from-response-body-new-in-v110)
|
||||
- [Include Headers](#include-headers)
|
||||
- [IPv6, Non-recursive scan with INFO logging enabled](#ipv6-non-recursive-scan-with-info-level-logging-enabled)
|
||||
- [Read urls from STDIN; pipe only resulting urls out to another tool](#read-urls-from-stdin-pipe-only-resulting-urls-out-to-another-tool)
|
||||
- [Proxy traffic through Burp](#proxy-traffic-through-burp)
|
||||
- [Proxy traffic through a SOCKS proxy](#proxy-traffic-through-a-socks-proxy)
|
||||
- [Pass auth token via query parameter](#pass-auth-token-via-query-parameter)
|
||||
- [Limit Total Number of Concurrent Scans (new in `v1.2.0`)](#limit-total-number-of-concurrent-scans-new-in-v120)
|
||||
- [Filter Response by Status Code (new in `v1.3.0`)](#filter-response-by-status-code--new-in-v130)
|
||||
- [Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)](#replay-responses-to-a-proxy-based-on-status-code-new-in-v150)
|
||||
- [Comparison w/ Similar Tools](#-comparison-w-similar-tools)
|
||||
- [Common Problems/Issues (FAQ)](#-common-problemsissues-faq)
|
||||
- [No file descriptors available](#no-file-descriptors-available)
|
||||
- [Progress bars print one line at a time](#progress-bars-print-one-line-at-a-time)
|
||||
## ⏳ Quick Start
|
||||
|
||||
## 💿 Installation
|
||||
This section will cover the minimum amount of information to get up and running with feroxbuster. Please refer the the [documentation](https://epi052.github.io/feroxbuster-docs/docs/), as it's much more comprehensive.
|
||||
|
||||
### Download a Release
|
||||
### 💿 Installation
|
||||
|
||||
Releases for multiple architectures can be found in the [Releases](https://github.com/epi052/feroxbuster/releases) section. The latest release for each of the following systems can be downloaded and executed as shown below.
|
||||
There are quite a few other [installation methods](https://epi052.github.io/feroxbuster-docs/docs/installation/), but these snippets should cover the majority of users.
|
||||
|
||||
#### Kali
|
||||
|
||||
If you're using kali, this is the preferred install method. Installing from the repos adds a [**ferox-config.toml**](https://epi052.github.io/feroxbuster-docs/docs/configuration/ferox-config-toml/) in `/etc/feroxbuster/`, adds command completion for bash, fish, and zsh, includes a man page entry, and installs `feroxbuster` itself.
|
||||
|
||||
```
|
||||
sudo apt update && sudo apt install -y feroxbuster
|
||||
```
|
||||
|
||||
#### Linux (32 and 64-bit) & MacOS
|
||||
|
||||
```
|
||||
curl -sL https://raw.githubusercontent.com/epi052/feroxbuster/master/install-nix.sh | bash
|
||||
```
|
||||
|
||||
#### Windows x86
|
||||
|
||||
```
|
||||
https://github.com/epi052/feroxbuster/releases/latest/download/x86-windows-feroxbuster.exe.zip
|
||||
Expand-Archive .\feroxbuster.zip
|
||||
.\feroxbuster\feroxbuster.exe -V
|
||||
```
|
||||
|
||||
#### Windows x86_64
|
||||
|
||||
@@ -117,305 +110,17 @@ Expand-Archive .\feroxbuster.zip
|
||||
.\feroxbuster\feroxbuster.exe -V
|
||||
```
|
||||
|
||||
### Snap Install
|
||||
#### All others
|
||||
|
||||
Install using `snap`
|
||||
|
||||
```
|
||||
sudo snap install feroxbuster
|
||||
```
|
||||
|
||||
The only gotcha here is that the snap package can only read wordlists from a few specific locations. There are a few
|
||||
possible solutions, of which two are shown below.
|
||||
|
||||
If the wordlist is on the same partition as your home directory, it can be hard-linked into `~/snap/feroxbuster/common`
|
||||
|
||||
```
|
||||
ln /path/to/the/wordlist ~/snap/feroxbuster/common
|
||||
./feroxbuster -u http://localhost -w ~/snap/feroxbuster/common/wordlist
|
||||
```
|
||||
|
||||
If the wordlist is on a separate partition, hard-linking won't work. You'll need to copy it into the snap directory.
|
||||
|
||||
```
|
||||
cp /path/to/the/wordlist ~/snap/feroxbuster/common
|
||||
./feroxbuster -u http://localhost -w ~/snap/feroxbuster/common/wordlist
|
||||
```
|
||||
|
||||
### Homebrew on MacOS and Linux
|
||||
|
||||
Install using Homebrew via tap
|
||||
|
||||
🍏 [MacOS](https://github.com/TGotwig/homebrew-feroxbuster/blob/main/feroxbuster.rb)
|
||||
|
||||
```shell
|
||||
brew tap tgotwig/feroxbuster
|
||||
brew install feroxbuster
|
||||
```
|
||||
|
||||
🐧 [Linux](https://github.com/TGotwig/homebrew-linux-feroxbuster/blob/main/feroxbuster.rb)
|
||||
|
||||
```shell
|
||||
brew tap tgotwig/linux-feroxbuster
|
||||
brew install feroxbuster
|
||||
```
|
||||
|
||||
### Cargo Install
|
||||
|
||||
`feroxbuster` is published on crates.io, making it easy to install if you already have rust installed on your system.
|
||||
|
||||
```
|
||||
cargo install feroxbuster
|
||||
```
|
||||
|
||||
### apt Install
|
||||
|
||||
Download `feroxbuster_amd64.deb` from the [Releases](https://github.com/epi052/feroxbuster/releases) section. After that, use your favorite package manager to install the `.deb`.
|
||||
|
||||
```
|
||||
wget -sLO https://github.com/epi052/feroxbuster/releases/latest/download/feroxbuster_amd64.deb.zip
|
||||
unzip feroxbuster_amd64.deb.zip
|
||||
sudo apt install ./feroxbuster_amd64.deb
|
||||
```
|
||||
|
||||
### AUR Install
|
||||
|
||||
Install `feroxbuster-git` on Arch Linux with your AUR helper of choice:
|
||||
|
||||
```
|
||||
yay -S feroxbuster-git
|
||||
```
|
||||
|
||||
### Docker Install
|
||||
|
||||
> The following steps assume you have docker installed / setup
|
||||
|
||||
First, clone the repository.
|
||||
|
||||
```
|
||||
git clone https://github.com/epi052/feroxbuster.git
|
||||
cd feroxbuster
|
||||
```
|
||||
|
||||
Next, build the image.
|
||||
|
||||
```
|
||||
sudo docker build -t feroxbuster .
|
||||
```
|
||||
|
||||
After that, you should be able to use `docker run` to perform scans with `feroxbuster`.
|
||||
|
||||
#### Basic usage
|
||||
|
||||
```
|
||||
sudo docker run --init -it feroxbuster -u http://example.com -x js,html
|
||||
```
|
||||
|
||||
#### Piping from stdin and proxying all requests through socks5 proxy
|
||||
|
||||
```
|
||||
cat targets.txt | sudo docker run --net=host --init -i feroxbuster --stdin -x js,html --proxy socks5://127.0.0.1:9050
|
||||
```
|
||||
|
||||
#### Mount a volume to pass in `ferox-config.toml`
|
||||
|
||||
You've got some options available if you want to pass in a config file. [`ferox-buster.toml`](#ferox-configtoml) can live in multiple locations and still be valid, so it's up to you how you'd like to pass it in. Below are a few valid examples:
|
||||
|
||||
```
|
||||
sudo docker run --init -v $(pwd)/ferox-config.toml:/etc/feroxbuster/ferox-config.toml -it feroxbuster -u http://example.com
|
||||
```
|
||||
|
||||
```
|
||||
sudo docker run --init -v ~/.config/feroxbuster:/root/.config/feroxbuster -it feroxbuster -u http://example.com
|
||||
```
|
||||
|
||||
Note: If you are on a SELinux enforced system, you will need to pass the `:Z` attribute also.
|
||||
|
||||
```
|
||||
docker run --init -v (pwd)/ferox-config.toml:/etc/feroxbuster/ferox-config.toml:Z -it feroxbuster -u http://example.com
|
||||
```
|
||||
|
||||
#### Define an alias for simplicity
|
||||
|
||||
```
|
||||
alias feroxbuster="sudo docker run --init -v ~/.config/feroxbuster:/root/.config/feroxbuster -i feroxbuster"
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
### Default Values
|
||||
Configuration begins with with the following built-in default values baked into the binary:
|
||||
|
||||
- timeout: `7` seconds
|
||||
- follow redirects: `false`
|
||||
- wordlist: `/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt`
|
||||
- threads: `50`
|
||||
- verbosity: `0` (no logging enabled)
|
||||
- scan_limit: `0` (no limit imposed on concurrent scans)
|
||||
- status_codes: `200 204 301 302 307 308 401 403 405`
|
||||
- user_agent: `feroxbuster/VERSION`
|
||||
- recursion depth: `4`
|
||||
- auto-filter wildcards - `true`
|
||||
- output: `stdout`
|
||||
|
||||
### Threads and Connection Limits At A High-Level
|
||||
|
||||
This section explains how the `-t` and `-L` options work together to determine the overall aggressiveness of a scan. The combination of the two values set by these options determines how hard your target will get hit and to some extent also determines how many resources will be consumed on your local machine.
|
||||
|
||||
#### A Note on Green Threads
|
||||
|
||||
`feroxbuster` uses so-called [green threads](https://en.wikipedia.org/wiki/Green_threads) as opposed to traditional kernel/OS threads. This means (at a high-level) that the threads are implemented entirely in userspace, within a single running process. As a result, a scan with 30 green threads will appear to the OS to be a single process with no additional light-weight processes associated with it as far as the kernel is concerned. As such, there will not be any impact to process (`nproc`) limits when specifying larger values for `-t`. However, these threads will still consume file descriptors, so you will need to ensure that you have a suitable `nlimit` set when scaling up the amount of threads. More detailed documentation on setting appropriate `nlimit` values can be found in the [No File Descriptors Available](#no-file-descriptors-available) section of the FAQ
|
||||
|
||||
#### Threads and Connection Limits: The Implementation
|
||||
|
||||
* Threads: The `-t` option specifies the maximum amount of active threads *per-directory* during a scan
|
||||
* Connection Limits: The `-L` option specifies the maximum amount of active connections per thread
|
||||
|
||||
#### Threads and Connection Limits: Examples
|
||||
|
||||
To truly have only 30 active requests to a site at any given time, `-t 30 -L 1` is necessary. Using `-t 30 -L 2` will result in a maximum of 60 total requests being processed at any given time for that site. And so on. For a conversation on this, please see [Issue #126](https://github.com/epi052/feroxbuster/issues/126) which may provide more (or less) clarity :wink:
|
||||
|
||||
### ferox-config.toml
|
||||
After setting built-in default values, any values defined in a `ferox-config.toml` config file will override the
|
||||
built-in defaults.
|
||||
|
||||
`feroxbuster` searches for `ferox-config.toml` in the following locations (in the order shown):
|
||||
- `/etc/feroxbuster/` (global)
|
||||
- `CONFIG_DIR/ferxobuster/` (per-user)
|
||||
- The same directory as the `feroxbuster` executable (per-user)
|
||||
- The user's current working directory (per-target)
|
||||
|
||||
> `CONFIG_DIR` is defined as the following:
|
||||
> - Linux: `$XDG_CONFIG_HOME` or `$HOME/.config` i.e. `/home/bob/.config`
|
||||
> - MacOs: `$HOME/Library/Application Support` i.e. `/Users/bob/Library/Application Support`
|
||||
> - Windows: `{FOLDERID_RoamingAppData}` i.e. `C:\Users\Bob\AppData\Roaming`
|
||||
|
||||
If more than one valid configuration file is found, each one overwrites the values found previously.
|
||||
|
||||
If no configuration file is found, nothing happens at this stage.
|
||||
|
||||
As an example, let's say that we prefer to use a different wordlist as our default when scanning; we can
|
||||
set the `wordlist` value in the config file to override the baked-in default.
|
||||
|
||||
Notes of interest:
|
||||
- it's ok to only specify values you want to change without specifying anything else
|
||||
- variable names in `ferox-config.toml` must match their command-line counterpart
|
||||
|
||||
```toml
|
||||
# ferox-config.toml
|
||||
|
||||
wordlist = "/wordlists/jhaddix/all.txt"
|
||||
```
|
||||
|
||||
A pre-made configuration file with examples of all available settings can be found in `ferox-config.toml.example`.
|
||||
```toml
|
||||
# ferox-config.toml
|
||||
# Example configuration for feroxbuster
|
||||
#
|
||||
# If you wish to provide persistent settings to feroxbuster, rename this file to ferox-config.toml and make sure
|
||||
# it resides in the same directory as the feroxbuster binary.
|
||||
#
|
||||
# After that, uncomment any line to override the default value provided by the binary itself.
|
||||
#
|
||||
# Any setting used here can be overridden by the corresponding command line option/argument
|
||||
#
|
||||
# wordlist = "/wordlists/jhaddix/all.txt"
|
||||
# status_codes = [200, 500]
|
||||
# filter_status = [301]
|
||||
# replay_codes = [301]
|
||||
# threads = 1
|
||||
# timeout = 5
|
||||
# proxy = "http://127.0.0.1:8080"
|
||||
# replay_proxy = "http://127.0.0.1:8081"
|
||||
# verbosity = 1
|
||||
# scan_limit = 6
|
||||
# quiet = true
|
||||
# output = "/targets/ellingson_mineral_company/gibson.txt"
|
||||
# user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
|
||||
# redirects = true
|
||||
# insecure = true
|
||||
# extensions = ["php", "html"]
|
||||
# no_recursion = true
|
||||
# add_slash = true
|
||||
# stdin = true
|
||||
# dont_filter = true
|
||||
# extract_links = true
|
||||
# depth = 1
|
||||
# filter_size = [5174]
|
||||
# filter_word_count = [993]
|
||||
# filter_line_count = [35, 36]
|
||||
# queries = [["name","value"], ["rick", "astley"]]
|
||||
|
||||
# headers can be specified on multiple lines or as an inline table
|
||||
#
|
||||
# inline example
|
||||
# headers = {"stuff" = "things"}
|
||||
#
|
||||
# multi-line example
|
||||
# note: if multi-line is used, all key/value pairs under it belong to the headers table until the next table
|
||||
# is found or the end of the file is reached
|
||||
#
|
||||
# [headers]
|
||||
# stuff = "things"
|
||||
# more = "headers"
|
||||
```
|
||||
|
||||
### Command Line Parsing
|
||||
Finally, after parsing the available config file, any options/arguments given on the commandline will override any values that were set as a built-in or config-file value.
|
||||
|
||||
```
|
||||
USAGE:
|
||||
feroxbuster [FLAGS] [OPTIONS] --url <URL>...
|
||||
|
||||
FLAGS:
|
||||
-f, --add-slash Append / to each request
|
||||
-D, --dont-filter Don't auto-filter wildcard responses
|
||||
-e, --extract-links Extract links from response body (html, javascript, etc...); make new requests based on
|
||||
findings (default: false)
|
||||
-h, --help Prints help information
|
||||
-k, --insecure Disables TLS certificate validation
|
||||
-n, --no-recursion Do not scan recursively
|
||||
-q, --quiet Only print URLs; Don't print status codes, response size, running config, etc...
|
||||
-r, --redirects Follow redirects
|
||||
--stdin Read url(s) from STDIN
|
||||
-V, --version Prints version information
|
||||
-v, --verbosity Increase verbosity level (use -vv or more for greater effect)
|
||||
|
||||
OPTIONS:
|
||||
-d, --depth <RECURSION_DEPTH> Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)
|
||||
-x, --extensions <FILE_EXTENSION>... File extension(s) to search for (ex: -x php -x pdf js)
|
||||
-N, --filter-lines <LINES>... Filter out messages of a particular line count (ex: -N 20 -N 31,30)
|
||||
-S, --filter-size <SIZE>... Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)
|
||||
-C, --filter-status <STATUS_CODE>... Filter out status codes (deny list) (ex: -C 200 -C 401)
|
||||
-W, --filter-words <WORDS>... Filter out messages of a particular word count (ex: -W 312 -W 91,82)
|
||||
-H, --headers <HEADER>... Specify HTTP headers (ex: -H Header:val 'stuff: things')
|
||||
-o, --output <FILE> Output file to write results to (default: stdout)
|
||||
-p, --proxy <PROXY> Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)
|
||||
-Q, --query <QUERY>... Specify URL query parameters (ex: -Q token=stuff -Q secret=key)
|
||||
-R, --replay-codes <REPLAY_CODE>... Status Codes to send through a Replay Proxy when found (default: --status
|
||||
-codes value)
|
||||
-P, --replay-proxy <REPLAY_PROXY> Send only unfiltered requests through a Replay Proxy, instead of all
|
||||
requests
|
||||
-L, --scan-limit <SCAN_LIMIT> Limit total number of concurrent scans (default: 0, i.e. no limit)
|
||||
-s, --status-codes <STATUS_CODE>... Status Codes to include (allow list) (default: 200 204 301 302 307 308 401
|
||||
403 405)
|
||||
-t, --threads <THREADS> Number of concurrent threads (default: 50)
|
||||
-T, --timeout <SECONDS> Number of seconds before a request times out (default: 7)
|
||||
-u, --url <URL>... The target URL(s) (required, unless --stdin used)
|
||||
-a, --user-agent <USER_AGENT> Sets the User-Agent (default: feroxbuster/VERSION)
|
||||
-w, --wordlist <FILE> Path to the wordlist
|
||||
```
|
||||
Please refer the the [documentation](https://epi052.github.io/feroxbuster-docs/docs/).
|
||||
|
||||
## 🧰 Example Usage
|
||||
|
||||
### Pause and Resume Scans (new in `v1.4.0`)
|
||||
|
||||
Scans can be paused and resumed by pressing the ENTER key (shown below)
|
||||
|
||||

|
||||
Here are a few brief examples to get you started. Please note, feroxbuster can do a **lot more** than what's listed below. As a result, there are **many more** examples, with **demonstration gifs** that highlight specific features, in the [documentation](https://epi052.github.io/feroxbuster-docs/docs/).
|
||||
|
||||
### Multiple Values
|
||||
|
||||
Options that take multiple values are very flexible. Consider the following ways of specifying extensions:
|
||||
Options that take multiple values are very flexible. Consider the following ways of specifying extensions:
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 -x pdf -x js,html -x php txt json,docx
|
||||
@@ -423,7 +128,8 @@ Options that take multiple values are very flexible. Consider the following way
|
||||
|
||||
The command above adds .pdf, .js, .html, .php, .txt, .json, and .docx to each url
|
||||
|
||||
All of the methods above (multiple flags, space separated, comma separated, etc...) are valid and interchangeable. The same goes for urls, headers, status codes, queries, and size filters.
|
||||
All of the methods above (multiple flags, space separated, comma separated, etc...) are valid and interchangeable. The
|
||||
same goes for urls, headers, status codes, queries, and size filters.
|
||||
|
||||
### Include Headers
|
||||
|
||||
@@ -431,37 +137,6 @@ All of the methods above (multiple flags, space separated, comma separated, etc.
|
||||
./feroxbuster -u http://127.1 -H Accept:application/json "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
### Extract Links from Response Body (New in `v1.1.0`)
|
||||
|
||||
Search through the body of valid responses (html, javascript, etc...) for additional endpoints to scan. This turns
|
||||
`feroxbuster` into a hybrid that looks for both linked and unlinked content.
|
||||
|
||||
Example request/response with `--extract-links` enabled:
|
||||
- Make request to `http://example.com/index.html`
|
||||
- Receive, and read in, the `body` of the response
|
||||
- Search the `body` for absolute and relative links (i.e. `homepage/assets/img/icons/handshake.svg`)
|
||||
- Add the following directories for recursive scanning:
|
||||
- `http://example.com/homepage`
|
||||
- `http://example.com/homepage/assets`
|
||||
- `http://example.com/homepage/assets/img`
|
||||
- `http://example.com/homepage/assets/img/icons`
|
||||
- Make a single request to `http://example.com/homepage/assets/img/icons/handshake.svg`
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --extract-links
|
||||
```
|
||||
|
||||
Here's a comparison of a wordlist-only scan vs `--extract-links` using [Feline](https://www.hackthebox.eu/home/machines/profile/274) from Hack the Box:
|
||||
|
||||
Wordlist only
|
||||
|
||||

|
||||
|
||||
With `--extract-links`
|
||||
|
||||

|
||||
|
||||
|
||||
### IPv6, non-recursive scan with INFO-level logging enabled
|
||||
|
||||
```
|
||||
@@ -471,7 +146,7 @@ With `--extract-links`
|
||||
### Read urls from STDIN; pipe only resulting urls out to another tool
|
||||
|
||||
```
|
||||
cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
|
||||
cat targets | ./feroxbuster --stdin --silent -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
|
||||
```
|
||||
|
||||
### Proxy traffic through Burp
|
||||
@@ -480,173 +155,87 @@ cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | f
|
||||
./feroxbuster -u http://127.1 --insecure --proxy http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
### Proxy traffic through a SOCKS proxy
|
||||
### Proxy traffic through a SOCKS proxy (including DNS lookups)
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --proxy socks5://127.0.0.1:9050
|
||||
./feroxbuster -u http://127.1 --proxy socks5h://127.0.0.1:9050
|
||||
```
|
||||
|
||||
### Pass auth token via query parameter
|
||||
### Pass auth token via query parameter
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --query token=0123456789ABCDEF
|
||||
```
|
||||
|
||||
### Limit Total Number of Concurrent Scans (new in `v1.2.0`)
|
||||
## 🚀 Documentation has **moved** 🚀
|
||||
|
||||
Limit the number of scans permitted to run at any given time. Recursion will still identify new directories, but newly
|
||||
discovered directories can only begin scanning when the total number of active scans drops below the value passed to
|
||||
`--scan-limit`.
|
||||
For realsies, there used to be over 1300 lines in this README, but it's all been moved to the [new documentation site](https://epi052.github.io/feroxbuster-docs/docs/). Go check it out!
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --scan-limit 2
|
||||
```
|
||||
<h1><p align="center">✨🎉👉 <a href="https://epi052.github.io/feroxbuster-docs/docs/">DOCUMENTATION</a> 👈🎉✨</p></h1>
|
||||
|
||||

|
||||
## Contributors ✨
|
||||
|
||||
### Filter Response by Status Code (new in `v1.3.0`)
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
Version 1.3.0 included an overhaul to the filtering system which will allow for a wide array of filters to be added
|
||||
with minimal effort. The first such filter is a Status Code Filter. As responses come back from the scanned server,
|
||||
each one is checked against a list of known filters and either displayed or not according to which filters are set.
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://io.fi"><img src="https://avatars.githubusercontent.com/u/5235109?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joona Hoikkala</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=joohoi" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/jsav0"><img src="https://avatars.githubusercontent.com/u/20546041?v=4?s=100" width="100px;" alt=""/><br /><sub><b>J Savage</b></sub></a><br /><a href="#infra-jsav0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=jsav0" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://www.tgotwig.dev"><img src="https://avatars.githubusercontent.com/u/30773779?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thomas Gotwig</b></sub></a><br /><a href="#infra-TGotwig" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=TGotwig" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/spikecodes"><img src="https://avatars.githubusercontent.com/u/19519553?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Spike</b></sub></a><br /><a href="#infra-spikecodes" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=spikecodes" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/evanrichter"><img src="https://avatars.githubusercontent.com/u/330292?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Evan Richter</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=evanrichter" title="Code">💻</a> <a href="https://github.com/epi052/feroxbuster/commits?author=evanrichter" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/mzpqnxow"><img src="https://avatars.githubusercontent.com/u/8016228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>AG</b></sub></a><br /><a href="#ideas-mzpqnxow" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=mzpqnxow" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://n-thumann.de/"><img src="https://avatars.githubusercontent.com/u/46975855?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolas Thumann</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=n-thumann" title="Code">💻</a> <a href="https://github.com/epi052/feroxbuster/commits?author=n-thumann" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/tomtastic"><img src="https://avatars.githubusercontent.com/u/302127?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tom Matthews</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=tomtastic" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/bsysop"><img src="https://avatars.githubusercontent.com/u/9998303?v=4?s=100" width="100px;" alt=""/><br /><sub><b>bsysop</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=bsysop" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://bpsizemore.me"><img src="https://avatars.githubusercontent.com/u/11645898?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Brian Sizemore</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=bpsizemore" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://pwn.by/noraj"><img src="https://avatars.githubusercontent.com/u/16578570?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexandre ZANNI</b></sub></a><br /><a href="#infra-noraj" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=noraj" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/craig"><img src="https://avatars.githubusercontent.com/u/99729?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Craig</b></sub></a><br /><a href="#infra-craig" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://www.reddit.com/u/EONRaider"><img src="https://avatars.githubusercontent.com/u/15611424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>EONRaider</b></sub></a><br /><a href="#infra-EONRaider" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/wtwver"><img src="https://avatars.githubusercontent.com/u/53866088?v=4?s=100" width="100px;" alt=""/><br /><sub><b>wtwver</b></sub></a><br /><a href="#infra-wtwver" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://tib3rius.com"><img src="https://avatars.githubusercontent.com/u/48113936?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tib3rius</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ATib3rius" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/0xdf"><img src="https://avatars.githubusercontent.com/u/1489045?v=4?s=100" width="100px;" alt=""/><br /><sub><b>0xdf</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3A0xdf" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="http://secure77.de"><img src="https://avatars.githubusercontent.com/u/31564517?v=4?s=100" width="100px;" alt=""/><br /><sub><b>secure-77</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asecure-77" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/sbrun"><img src="https://avatars.githubusercontent.com/u/7712154?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sophie Brun</b></sub></a><br /><a href="#infra-sbrun" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/black-A"><img src="https://avatars.githubusercontent.com/u/30686803?v=4?s=100" width="100px;" alt=""/><br /><sub><b>black-A</b></sub></a><br /><a href="#ideas-black-A" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/dinosn"><img src="https://avatars.githubusercontent.com/u/3851678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolas Krassas</b></sub></a><br /><a href="#ideas-dinosn" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/N0ur5"><img src="https://avatars.githubusercontent.com/u/24260009?v=4?s=100" width="100px;" alt=""/><br /><sub><b>N0ur5</b></sub></a><br /><a href="#ideas-N0ur5" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/moscowchill"><img src="https://avatars.githubusercontent.com/u/72578879?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mchill</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Amoscowchill" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="http://BitThr3at.github.io"><img src="https://avatars.githubusercontent.com/u/45028933?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Naman</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ABitThr3at" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/Sicks3c"><img src="https://avatars.githubusercontent.com/u/32225186?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ayoub Elaich</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asicks3c" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/HenryHoggard"><img src="https://avatars.githubusercontent.com/u/1208121?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Henry</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AHenryHoggard" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/SleepiPanda"><img src="https://avatars.githubusercontent.com/u/6428561?v=4?s=100" width="100px;" alt=""/><br /><sub><b>SleepiPanda</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ASleepiPanda" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/uBadRequest"><img src="https://avatars.githubusercontent.com/u/47282747?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bad Requests</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AuBadRequest" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://home.dnaka91.rocks"><img src="https://avatars.githubusercontent.com/u/36804488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dominik Nakamura</b></sub></a><br /><a href="#infra-dnaka91" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/hunter0x8"><img src="https://avatars.githubusercontent.com/u/46222314?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Muhammad Ahsan</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Ahunter0x8" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/cortantief"><img src="https://avatars.githubusercontent.com/u/34527333?v=4?s=100" width="100px;" alt=""/><br /><sub><b>cortantief</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Acortantief" title="Bug reports">🐛</a> <a href="https://github.com/epi052/feroxbuster/commits?author=cortantief" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/dsaxton"><img src="https://avatars.githubusercontent.com/u/2658661?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Saxton</b></sub></a><br /><a href="#ideas-dsaxton" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=dsaxton" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/narkopolo"><img src="https://avatars.githubusercontent.com/u/16690056?v=4?s=100" width="100px;" alt=""/><br /><sub><b>narkopolo</b></sub></a><br /><a href="#ideas-narkopolo" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://ring0.lol"><img src="https://avatars.githubusercontent.com/u/1893909?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Justin Steven</b></sub></a><br /><a href="#ideas-justinsteven" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/7047payloads"><img src="https://avatars.githubusercontent.com/u/95562424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>7047payloads</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=7047payloads" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/unkn0wnsyst3m"><img src="https://avatars.githubusercontent.com/u/21272239?v=4?s=100" width="100px;" alt=""/><br /><sub><b>unkn0wnsyst3m</b></sub></a><br /><a href="#ideas-unkn0wnsyst3m" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://ironwort.me/"><img src="https://avatars.githubusercontent.com/u/15280042?v=4?s=100" width="100px;" alt=""/><br /><sub><b>0x08</b></sub></a><br /><a href="#ideas-its0x08" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/MD-Levitan"><img src="https://avatars.githubusercontent.com/u/12116508?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kusok</b></sub></a><br /><a href="#ideas-MD-Levitan" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=MD-Levitan" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/godylockz"><img src="https://avatars.githubusercontent.com/u/81207744?v=4?s=100" width="100px;" alt=""/><br /><sub><b>godylockz</b></sub></a><br /><a href="#ideas-godylockz" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=godylockz" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --filter-status 301
|
||||
```
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
### Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
The `--replay-proxy` and `--replay-codes` options were added as a way to only send a select few responses to a proxy. This is in stark contrast to `--proxy` which proxies EVERY request.
|
||||
|
||||
Imagine you only care about proxying responses that have either the status code `200` or `302` (or you just don't want to clutter up your Burp history). These two options will allow you to fine-tune what gets proxied and what doesn't.
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
|
||||
```
|
||||
|
||||
Of note: this means that for every response that matches your replay criteria, you'll end up sending the request that generated that response a second time. Depending on the target and your engagement terms (if any), it may not make sense from a traffic generated perspective.
|
||||
|
||||

|
||||
|
||||
## 🧐 Comparison w/ Similar Tools
|
||||
|
||||
There are quite a few similar tools for forced browsing/content discovery. Burp Suite Pro, Dirb, Dirbuster, etc...
|
||||
However, in my opinion, there are two that set the standard: [gobuster](https://github.com/OJ/gobuster) and
|
||||
[ffuf](https://github.com/ffuf/ffuf). Both are mature, feature-rich, and all-around incredible tools to use.
|
||||
|
||||
So, why would you ever want to use feroxbuster over ffuf/gobuster? In most cases, you probably won't. ffuf in particular
|
||||
can do the vast majority of things that feroxbuster can, while still offering boatloads more functionality. Here are
|
||||
a few of the use-cases in which feroxbuster may be a better fit:
|
||||
|
||||
- You want a **simple** tool usage experience
|
||||
- You want to be able to run your content discovery as part of some crazy 12 command unix **pipeline extravaganza**
|
||||
- You want to scan through a **SOCKS** proxy
|
||||
- You want **auto-filtering** of Wildcard responses by default
|
||||
- You want an integrated **link extractor** to increase discovered endpoints
|
||||
- You want **recursion** along with some other thing mentioned above (ffuf also does recursion)
|
||||
- You want a **configuration file** option for overriding built-in default values for your scans
|
||||
|
||||
| | feroxbuster | gobuster | ffuf |
|
||||
|------------------------------------------------------------------|---|---|---|
|
||||
| fast | ✔ | ✔ | ✔ |
|
||||
| easy to use | ✔ | ✔ | |
|
||||
| filter out responses by status code (new in `v1.3.0`) | ✔ | ✔ | ✔ |
|
||||
| allows recursion | ✔ | | ✔ |
|
||||
| can specify query parameters | ✔ | | ✔ |
|
||||
| SOCKS proxy support | ✔ | | |
|
||||
| extracts links from response body to increase scan coverage | ✔ | | |
|
||||
| multiple target scan (via stdin or multiple -u) | ✔ | | ✔ |
|
||||
| configuration file for default value override | ✔ | | ✔ |
|
||||
| can accept urls via STDIN as part of a pipeline | ✔ | | ✔ |
|
||||
| can accept wordlists via STDIN | | ✔ | ✔ |
|
||||
| filter based on response size, wordcount, and linecount | ✔ | | ✔ |
|
||||
| auto-filter wildcard responses | ✔ | | ✔ |
|
||||
| performs other scans (vhost, dns, etc) | | ✔ | ✔ |
|
||||
| time delay / rate limiting | | ✔ | ✔ |
|
||||
| **huge** number of other options | | | ✔ |
|
||||
|
||||
Of note, there's another written-in-rust content discovery tool, [rustbuster](https://github.com/phra/rustbuster). I
|
||||
came across rustbuster when I was naming my tool (😢). I don't have any experience using it, but it appears to
|
||||
be able to do POST requests with an HTTP body, has SOCKS support, and has an 8.3 shortname scanner (in addition to vhost
|
||||
dns, directory, etc...). In short, it definitely looks interesting and may be what you're looking for as it has some
|
||||
capability I haven't seen in similar tools.
|
||||
|
||||
## 🤯 Common Problems/Issues (FAQ)
|
||||
|
||||
### No file descriptors available
|
||||
|
||||
Why do I get a bunch of `No file descriptors available (os error 24)` errors?
|
||||
|
||||
---
|
||||
|
||||
There are a few potential causes of this error. The simplest is that your operating system sets an open file limit that is aggressively low. Through personal testing, I've found that `4096` is a reasonable open file limit (this will vary based on your exact setup).
|
||||
|
||||
There are quite a few options to solve this particular problem, of which a handful are shown below.
|
||||
|
||||
#### Increase the Number of Open Files
|
||||
|
||||
We'll start by increasing the number of open files the OS allows. On my Kali install, the default was `1024`, and I know some MacOS installs use `256` 😕.
|
||||
|
||||
##### Edit `/etc/security/limits.conf`
|
||||
|
||||
One option to up the limit is to edit `/etc/security/limits.conf` so that it includes the two lines below.
|
||||
|
||||
- `*` represents all users
|
||||
- `hard` and `soft` indicate the hard and soft limits for the OS
|
||||
- `nofile` is the number of open files option.
|
||||
|
||||
```
|
||||
/etc/security/limits.conf
|
||||
-------------------------
|
||||
...
|
||||
* soft nofile 4096
|
||||
* hard nofile 8192
|
||||
...
|
||||
```
|
||||
|
||||
##### Use `ulimit` directly
|
||||
|
||||
A faster option, that is **not** persistent, is to simply use the `ulimit` command to change the setting.
|
||||
|
||||
```
|
||||
ulimit -n 4096
|
||||
```
|
||||
|
||||
#### Additional Tweaks (may not be needed)
|
||||
|
||||
If you still find yourself hitting the file limit with the above changes, there are a few additional tweaks that may help.
|
||||
|
||||
> This section was shamelessly stolen from this [stackoverflow answer](https://stackoverflow.com/a/3923785). More information is included in that post and is recommended reading if you end up needing to use this section.
|
||||
|
||||
✨ Special thanks to HTB user [@sparkla](https://www.hackthebox.eu/home/users/profile/221599) for their help with identifying these additional tweaks ✨
|
||||
|
||||
##### Increase the ephemeral port range, and decrease the tcp_fin_timeout.
|
||||
|
||||
The ephermal port range defines the maximum number of outbound sockets a host can create from a particular I.P. address. The fin_timeout defines the minimum time these sockets will stay in TIME_WAIT state (unusable after being used once). Usual system defaults are
|
||||
|
||||
- `net.ipv4.ip_local_port_range = 32768 61000`
|
||||
- `net.ipv4.tcp_fin_timeout = 60`
|
||||
|
||||
This basically means your system cannot consistently guarantee more than `(61000 - 32768) / 60 = 470` sockets per second.
|
||||
|
||||
```
|
||||
sudo sysctl net.ipv4.ip_local_port_range="15000 61000"
|
||||
sudo sysctl net.ipv4.tcp_fin_timeout=30
|
||||
```
|
||||
|
||||
##### Allow socket reuse while in a `TIME_WAIT` status
|
||||
|
||||
This allows fast cycling of sockets in time_wait state and re-using them. Make sure to read post [Coping with the TCP TIME-WAIT](https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux) from Vincent Bernat to understand the implications.
|
||||
|
||||
```
|
||||
sudo sysctl net.ipv4.tcp_tw_reuse=1
|
||||
```
|
||||
|
||||
### Progress bars print one line at a time
|
||||
|
||||
`feroxbuster` needs a terminal width of at least the size of what's being printed in order to do progress bar printing correctly. If your width is too small, you may see output like what's shown below.
|
||||
|
||||

|
||||
|
||||
If you can, simply make the terminal wider and rerun. If you're unable to make your terminal wider
|
||||
consider using `-q` to suppress the progress bars.
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
84
build.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use std::fs::{copy, create_dir_all, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
use clap_complete::{generate_to, shells};
|
||||
|
||||
include!("src/parser.rs");
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=src/parser.rs");
|
||||
|
||||
if std::env::var("DOCS_RS").is_ok() {
|
||||
return; // only build when we're not generating docs
|
||||
}
|
||||
|
||||
let outdir = "shell_completions";
|
||||
|
||||
let mut app = initialize();
|
||||
|
||||
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
|
||||
// automate that fix and have it present in all future builds
|
||||
let mut contents = String::new();
|
||||
|
||||
let mut bash_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(format!("{}/feroxbuster.bash", outdir))
|
||||
.expect("Couldn't open bash completion script");
|
||||
|
||||
bash_file
|
||||
.read_to_string(&mut contents)
|
||||
.expect("Couldn't read bash completion script");
|
||||
|
||||
contents = contents.replace("default feroxbuster", "default -o plusdirs feroxbuster");
|
||||
|
||||
bash_file
|
||||
.seek(SeekFrom::Start(0))
|
||||
.expect("Couldn't seek to position 0 in bash completion script");
|
||||
|
||||
bash_file
|
||||
.write_all(contents.as_bytes())
|
||||
.expect("Couldn't write updated bash completion script to disk");
|
||||
|
||||
// hunter0x8 let me know that when installing via cargo, it would be nice if we dropped a
|
||||
// config file during the build process. The following code will place an example config in
|
||||
// the user's configuration directory
|
||||
// - linux: $XDG_CONFIG_HOME or $HOME/.config
|
||||
// - macOS: $HOME/Library/Application Support
|
||||
// - windows: {FOLDERID_RoamingAppData}
|
||||
let mut config_dir = dirs::config_dir().expect("Couldn't resolve user's config directory");
|
||||
config_dir = config_dir.join("feroxbuster"); // $HOME/.config/feroxbuster
|
||||
|
||||
if !config_dir.exists() {
|
||||
// recursively create the feroxbuster directory and all of its parent components if
|
||||
// they are missing
|
||||
if !config_dir.exists() {
|
||||
// recursively create the feroxbuster directory and all of its parent components if
|
||||
// they are missing
|
||||
if create_dir_all(&config_dir).is_err() {
|
||||
// only copy the config file when we're not running in the CI/CD pipeline
|
||||
// which fails with permission denied
|
||||
eprintln!("Couldn't create one or more directories needed to copy the config file");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hard-coding config name here to not rely on the crate we're building, if DEFAULT_CONFIG_NAME
|
||||
// ever changes, this will need to be updated
|
||||
let config_file = config_dir.join("ferox-config.toml");
|
||||
|
||||
if !config_file.exists() {
|
||||
// config file doesn't exist, add it to the config directory
|
||||
if copy("ferox-config.toml.example", config_file).is_err() {
|
||||
eprintln!("Couldn't copy example config into config directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
0
docs/.nojekyll
Normal file
11
docs/index.html
Normal 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>
|
||||
|
||||
@@ -16,13 +16,25 @@
|
||||
# replay_proxy = "http://127.0.0.1:8081"
|
||||
# replay_codes = [200, 302]
|
||||
# verbosity = 1
|
||||
# parallel = 8
|
||||
# scan_limit = 6
|
||||
# rate_limit = 250
|
||||
# quiet = true
|
||||
# silent = true
|
||||
# auto_tune = true
|
||||
# auto_bail = true
|
||||
# json = true
|
||||
# output = "/targets/ellingson_mineral_company/gibson.txt"
|
||||
# debug_log = "/var/log/find-the-derp.log"
|
||||
# user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
|
||||
# random_agent = false
|
||||
# redirects = true
|
||||
# insecure = true
|
||||
# extensions = ["php", "html"]
|
||||
# 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
|
||||
@@ -30,9 +42,13 @@
|
||||
# extract_links = true
|
||||
# depth = 1
|
||||
# filter_size = [5174]
|
||||
# filter_regex = ["^ignore me$"]
|
||||
# filter_similar = ["https://somesite.com/soft404"]
|
||||
# filter_word_count = [993]
|
||||
# filter_line_count = [35, 36]
|
||||
# queries = [["name","value"], ["rick", "astley"]]
|
||||
# save_state = false
|
||||
# time_limit = "10m"
|
||||
|
||||
# headers can be specified on multiple lines or as an inline table
|
||||
#
|
||||
|
||||
BIN
img/auto-bail-demo.gif
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
img/auto-tune-demo.gif
Normal file
|
After Width: | Height: | Size: 735 KiB |
BIN
img/cancel-menu.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
img/cancel-scan.gif
Normal file
|
After Width: | Height: | Size: 670 KiB |
BIN
img/demo.gif
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 716 KiB |
BIN
img/dir-scan-bar-explained.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
img/insecure.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
img/rate-limit-demo.gif
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
img/response-bar-explained.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
img/resumed-scan.gif
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
img/save-state.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
img/time-limit.gif
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
img/total-bar-explained.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -3,54 +3,63 @@
|
||||
BASE_URL=https://github.com/epi052/feroxbuster/releases/latest/download
|
||||
|
||||
MAC_ZIP=x86_64-macos-feroxbuster.zip
|
||||
MAC_URL="${BASE_URL}/${MAC_ZIP}"
|
||||
MAC_URL="$BASE_URL/$MAC_ZIP"
|
||||
|
||||
LIN32_ZIP=x86-linux-feroxbuster.zip
|
||||
LIN32_URL="${BASE_URL}/${LIN32_ZIP}"
|
||||
LIN32_URL="$BASE_URL/$LIN32_ZIP"
|
||||
|
||||
LIN64_ZIP=x86_64-linux-feroxbuster.zip
|
||||
LIN64_URL="${BASE_URL}/${LIN64_ZIP}"
|
||||
LIN64_URL="$BASE_URL/$LIN64_ZIP"
|
||||
|
||||
EMOJI_URL=https://gist.github.com/epi052/8196b550ea51d0907ad4b93751b1b57d/raw/6112c9f32ae07922983fdc549c54fd3fb9a38e4c/NotoColorEmoji.ttf
|
||||
|
||||
echo "[+] Installing feroxbuster!"
|
||||
|
||||
which unzip &>/dev/null
|
||||
if [ "$?" = "0" ]; then
|
||||
echo "[+] unzip found"
|
||||
else
|
||||
echo "[ ] unzip not found, exiting. "
|
||||
exit -1
|
||||
fi
|
||||
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
echo "[=] Found MacOS, downloading from ${MAC_URL}"
|
||||
echo "[=] Found MacOS, downloading from $MAC_URL"
|
||||
|
||||
curl -sLO "${MAC_URL}"
|
||||
unzip -o "${MAC_ZIP}" > /dev/null
|
||||
rm "${MAC_ZIP}"
|
||||
curl -sLO "$MAC_URL"
|
||||
unzip -o "$MAC_ZIP" >/dev/null
|
||||
rm "$MAC_ZIP"
|
||||
elif [[ "$(expr substr $(uname -s) 1 5)" == "Linux" ]]; then
|
||||
if [[ $(getconf LONG_BIT) == 32 ]]; then
|
||||
echo "[=] Found 32-bit Linux, downloading from ${LIN32_URL}"
|
||||
if [[ $(getconf LONG_BIT) == 32 ]]; then
|
||||
echo "[=] Found 32-bit Linux, downloading from $LIN32_URL"
|
||||
|
||||
curl -sLO "${LIN32_URL}"
|
||||
unzip -o "${LIN32_ZIP}" > /dev/null
|
||||
rm "${LIN32_ZIP}"
|
||||
else
|
||||
echo "[=] Found 64-bit Linux, downloading from ${LIN64_URL}"
|
||||
curl -sLO "$LIN32_URL"
|
||||
unzip -o "$LIN32_ZIP" >/dev/null
|
||||
rm "$LIN32_ZIP"
|
||||
else
|
||||
echo "[=] Found 64-bit Linux, downloading from $LIN64_URL"
|
||||
|
||||
curl -sLO "${LIN64_URL}"
|
||||
unzip -o "${LIN64_ZIP}" > /dev/null
|
||||
rm "${LIN64_ZIP}"
|
||||
fi
|
||||
curl -sLO "$LIN64_URL"
|
||||
unzip -o "$LIN64_ZIP" >/dev/null
|
||||
rm "$LIN64_ZIP"
|
||||
fi
|
||||
|
||||
if [[ -e ~/.fonts/NotoColorEmoji.ttf ]]; then
|
||||
echo "[=] Found Noto Emoji Font, skipping install"
|
||||
else
|
||||
echo "[=] Installing Noto Emoji Font"
|
||||
mkdir -p ~/.fonts
|
||||
pushd ~/.fonts 2>&1 >/dev/null
|
||||
|
||||
curl -sLO "${EMOJI_URL}"
|
||||
curl -sLO "$EMOJI_URL"
|
||||
|
||||
fc-cache -f -v >/dev/null
|
||||
|
||||
popd 2>&1 >/dev/null
|
||||
echo "[+] Noto Emoji Font installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
chmod +x ./feroxbuster
|
||||
|
||||
echo "[+] Installed feroxbuster version $(./feroxbuster -V)"
|
||||
|
||||
|
||||
|
||||
|
||||
106
shell_completions/_feroxbuster
Normal file
@@ -0,0 +1,106 @@
|
||||
#compdef feroxbuster
|
||||
|
||||
autoload -U is-at-least
|
||||
|
||||
_feroxbuster() {
|
||||
typeset -A opt_args
|
||||
typeset -a _arguments_options
|
||||
local ret=1
|
||||
|
||||
if is-at-least 5.2; then
|
||||
_arguments_options=(-s -S -C)
|
||||
else
|
||||
_arguments_options=(-s -C)
|
||||
fi
|
||||
|
||||
local context curcontext="$curcontext" state line
|
||||
_arguments "${_arguments_options[@]}" \
|
||||
'-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.5.0)]:USER_AGENT: ' \
|
||||
'--user-agent=[Sets the User-Agent (default: feroxbuster/2.5.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: ' \
|
||||
'*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
|
||||
'*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
|
||||
'*--filter-similar-to=[Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)]:UNWANTED_PAGE:_urls' \
|
||||
'*-s+[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]:STATUS_CODE: ' \
|
||||
'*--status-codes=[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]:STATUS_CODE: ' \
|
||||
'-T+[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
|
||||
'--timeout=[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
|
||||
'-t+[Number of concurrent threads (default: 50)]:THREADS: ' \
|
||||
'--threads=[Number of concurrent threads (default: 50)]:THREADS: ' \
|
||||
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]:RECURSION_DEPTH: ' \
|
||||
'--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]:RECURSION_DEPTH: ' \
|
||||
'-L+[Limit total number of concurrent scans (default: 0, i.e. no limit)]:SCAN_LIMIT: ' \
|
||||
'--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]:SCAN_LIMIT: ' \
|
||||
'--parallel=[Run parallel feroxbuster instances (one child process per url passed via stdin)]:PARALLEL_SCANS: ' \
|
||||
'(--auto-tune)--rate-limit=[Limit number of requests per second (per directory) (default: 0, i.e. no limit)]:RATE_LIMIT: ' \
|
||||
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]:TIME_SPEC: ' \
|
||||
'-w+[Path to the wordlist]:FILE:_files' \
|
||||
'--wordlist=[Path to the wordlist]:FILE:_files' \
|
||||
'-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]' \
|
||||
'-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]' \
|
||||
'-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]' \
|
||||
'(--silent)*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
|
||||
'(--silent)*--verbosity[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
|
||||
'(-q --quiet)--silent[Only print URLs + turn off logging (good for piping a list of urls to other commands)]' \
|
||||
'-q[Hide progress bars and banner (good for tmux windows w/ notifications)]' \
|
||||
'--quiet[Hide progress bars and banner (good for tmux windows w/ notifications)]' \
|
||||
'--json[Emit JSON logs to --output and --debug-log instead of normal text]' \
|
||||
&& ret=0
|
||||
}
|
||||
|
||||
(( $+functions[_feroxbuster_commands] )) ||
|
||||
_feroxbuster_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'feroxbuster commands' commands "$@"
|
||||
}
|
||||
|
||||
_feroxbuster "$@"
|
||||
108
shell_completions/_feroxbuster.ps1
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
using namespace System.Management.Automation
|
||||
using namespace System.Management.Automation.Language
|
||||
|
||||
Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
|
||||
param($wordToComplete, $commandAst, $cursorPosition)
|
||||
|
||||
$commandElements = $commandAst.CommandElements
|
||||
$command = @(
|
||||
'feroxbuster'
|
||||
for ($i = 1; $i -lt $commandElements.Count; $i++) {
|
||||
$element = $commandElements[$i]
|
||||
if ($element -isnot [StringConstantExpressionAst] -or
|
||||
$element.StringConstantType -ne [StringConstantType]::BareWord -or
|
||||
$element.Value.StartsWith('-')) {
|
||||
break
|
||||
}
|
||||
$element.Value
|
||||
}) -join ';'
|
||||
|
||||
$completions = @(switch ($command) {
|
||||
'feroxbuster' {
|
||||
[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('-a', 'a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.5.0)')
|
||||
[CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.5.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('-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$'')')
|
||||
[CompletionResult]::new('--filter-regex', 'filter-regex', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')')
|
||||
[CompletionResult]::new('-W', 'W', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
|
||||
[CompletionResult]::new('--filter-words', 'filter-words', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
|
||||
[CompletionResult]::new('-N', 'N', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
|
||||
[CompletionResult]::new('--filter-lines', 'filter-lines', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
|
||||
[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('-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('-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('-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('-v', 'v', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
|
||||
[CompletionResult]::new('--verbosity', 'verbosity', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
|
||||
[CompletionResult]::new('--silent', 'silent', [CompletionResultType]::ParameterName, 'Only print URLs + turn off logging (good for piping a list of urls to other commands)')
|
||||
[CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)')
|
||||
[CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)')
|
||||
[CompletionResult]::new('--json', 'json', [CompletionResultType]::ParameterName, 'Emit JSON logs to --output and --debug-log instead of normal text')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
$completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
|
||||
Sort-Object -Property ListItemText
|
||||
}
|
||||
246
shell_completions/feroxbuster.bash
Normal file
@@ -0,0 +1,246 @@
|
||||
_feroxbuster() {
|
||||
local i cur prev opts cmds
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
cmd=""
|
||||
opts=""
|
||||
|
||||
for i in ${COMP_WORDS[@]}
|
||||
do
|
||||
case "${i}" in
|
||||
"$1")
|
||||
cmd="feroxbuster"
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${cmd}" in
|
||||
feroxbuster)
|
||||
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 -v -q -o --help --version --url --stdin --resume-from --proxy --replay-proxy --replay-codes --user-agent --random-agent --extensions --methods --data --headers --cookies --query --add-slash --dont-scan --filter-size --filter-regex --filter-words --filter-lines --filter-status --filter-similar-to --status-codes --timeout --redirects --insecure --threads --no-recursion --depth --extract-links --scan-limit --parallel --rate-limit --time-limit --wordlist --auto-tune --auto-bail --dont-filter --verbosity --silent --quiet --json --output --debug-log"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--url)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-u)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--resume-from)
|
||||
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
|
||||
;;
|
||||
--user-agent)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-a)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--extensions)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-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
|
||||
;;
|
||||
--headers)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-H)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--cookies)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-b)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--query)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-Q)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--dont-scan)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--filter-size)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-S)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--filter-regex)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-X)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--filter-words)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-W)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--filter-lines)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-N)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--filter-status)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-C)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--filter-similar-to)
|
||||
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)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--parallel)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--rate-limit)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--time-limit)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--wordlist)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-w)
|
||||
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=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _feroxbuster -o bashdefault -o default -o plusdirs feroxbuster
|
||||
103
shell_completions/feroxbuster.elv
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
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.5.0)'
|
||||
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.5.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 -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 -A 'Use a random User-Agent'
|
||||
cand --random-agent 'Use a random User-Agent'
|
||||
cand -f 'Append / to each request''s URL'
|
||||
cand --add-slash 'Append / to each request''s URL'
|
||||
cand -r 'Allow client to follow redirects'
|
||||
cand --redirects 'Allow client to follow redirects'
|
||||
cand -k 'Disables TLS certificate validation in the client'
|
||||
cand --insecure 'Disables TLS certificate validation in the client'
|
||||
cand -n 'Do not scan recursively'
|
||||
cand --no-recursion 'Do not scan recursively'
|
||||
cand -e 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
|
||||
cand --extract-links 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
|
||||
cand --auto-tune 'Automatically lower scan rate when an excessive amount of errors are encountered'
|
||||
cand --auto-bail 'Automatically stop scanning when an excessive amount of errors are encountered'
|
||||
cand -D 'Don''t auto-filter wildcard responses'
|
||||
cand --dont-filter 'Don''t auto-filter wildcard responses'
|
||||
cand -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'
|
||||
}
|
||||
]
|
||||
$completions[$command]
|
||||
}
|
||||
46
shell_completions/feroxbuster.fish
Normal file
@@ -0,0 +1,46 @@
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s w -l wordlist -d 'Path to the wordlist'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s u -l url -d 'The target URL(s) (required, unless --stdin used)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s t -l threads -d 'Number of concurrent threads (default: 50)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s d -l depth -d 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s T -l timeout -d 'Number of seconds before a request times out (default: 7)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s p -l proxy -d 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s P -l replay-proxy -d 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s R -l replay-codes -d 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s s -l status-codes -d 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s o -l output -d 'Output file to write results to (use w/ --json for JSON entries)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l resume-from -d 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)'
|
||||
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$\')'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s W -l filter-words -d 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s N -l filter-lines -d 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s C -l filter-status -d 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l filter-similar-to -d 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s L -l scan-limit -d 'Limit total number of concurrent scans (default: 0, i.e. no limit)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l parallel -d 'Run parallel feroxbuster instances (one child process per url passed via stdin)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l rate-limit -d 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l time-limit -d 'Limit total run time of all scans (ex: --time-limit 10m)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s v -l verbosity -d 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v\'s is probably too much)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l silent -d 'Only print URLs + turn off logging (good for piping a list of urls to other commands)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s q -l quiet -d 'Hide progress bars and banner (good for tmux windows w/ notifications)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l auto-tune -d 'Automatically lower scan rate when an excessive amount of errors are encountered'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l auto-bail -d 'Automatically stop scanning when an excessive amount of errors are encountered'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l json -d 'Emit JSON logs to --output and --debug-log instead of normal text'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s D -l dont-filter -d 'Don\'t auto-filter wildcard responses'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s A -l random-agent -d 'Use a random User-Agent'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s r -l redirects -d 'Follow redirects'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s k -l insecure -d 'Disables TLS certificate validation'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s n -l no-recursion -d 'Do not scan recursively'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s f -l add-slash -d 'Append / to each request'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l stdin -d 'Read url(s) from STDIN'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s e -l extract-links -d 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s h -l help -d 'Prints help information'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s V -l version -d 'Prints version information'
|
||||
672
src/banner.rs
@@ -1,672 +0,0 @@
|
||||
use crate::config::{Configuration, CONFIGURATION};
|
||||
use crate::utils::{make_request, status_colorizer};
|
||||
use console::style;
|
||||
use reqwest::{Client, Url};
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
|
||||
/// macro helper to abstract away repetitive string formatting
|
||||
macro_rules! format_banner_entry_helper {
|
||||
// \u{0020} -> unicode space
|
||||
// \u{2502} -> vertical box drawing character, i.e. │
|
||||
($rune:expr, $name:expr, $value:expr, $indent:expr, $col_width:expr) => {
|
||||
format!(
|
||||
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<col_w$}\u{2502}\u{0020}{}",
|
||||
$rune,
|
||||
$name,
|
||||
$value,
|
||||
indent = $indent,
|
||||
col_w = $col_width
|
||||
)
|
||||
};
|
||||
($rune:expr, $name:expr, $value:expr, $value2:expr, $indent:expr, $col_width:expr) => {
|
||||
format!(
|
||||
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<col_w$}\u{2502}\u{0020}{}:\u{0020}{}",
|
||||
$rune,
|
||||
$name,
|
||||
$value,
|
||||
$value2,
|
||||
indent = $indent,
|
||||
col_w = $col_width
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// macro that wraps another macro helper to abstract away repetitive string formatting
|
||||
macro_rules! format_banner_entry {
|
||||
// 4 -> unicode emoji padding width
|
||||
// 22 -> column width (when unicode rune is 4 bytes wide, 23 when it's 3)
|
||||
// hardcoded since macros don't allow let statements
|
||||
($rune:expr, $name:expr, $value:expr) => {
|
||||
format_banner_entry_helper!($rune, $name, $value, 3, 22)
|
||||
};
|
||||
($rune:expr, $name:expr, $value1:expr, $value2:expr) => {
|
||||
format_banner_entry_helper!($rune, $name, $value1, $value2, 3, 22)
|
||||
};
|
||||
}
|
||||
|
||||
/// Url used to query github's api; specifically used to look for the latest tagged release name
|
||||
const UPDATE_URL: &str = "https://api.github.com/repos/epi052/feroxbuster/releases/latest";
|
||||
|
||||
/// Simple enum to hold three different update states
|
||||
#[derive(Debug)]
|
||||
enum UpdateStatus {
|
||||
/// this version and latest release are the same
|
||||
UpToDate,
|
||||
|
||||
/// this version and latest release are not the same
|
||||
OutOfDate,
|
||||
|
||||
/// some error occurred during version check
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Makes a request to the given url, expecting to receive a JSON response that contains a field
|
||||
/// named `tag_name` that holds a value representing the latest tagged release of this tool.
|
||||
///
|
||||
/// ex: v1.1.0
|
||||
///
|
||||
/// Returns `UpdateStatus`
|
||||
async fn needs_update(client: &Client, url: &str, bin_version: &str) -> UpdateStatus {
|
||||
log::trace!("enter: needs_update({:?}, {})", client, url);
|
||||
|
||||
let unknown = UpdateStatus::Unknown;
|
||||
|
||||
let api_url = match Url::parse(url) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
return unknown;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(response) = make_request(&client, &api_url).await {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
let json_response: Value = serde_json::from_str(&body).unwrap_or_default();
|
||||
|
||||
if json_response.is_null() {
|
||||
// unwrap_or_default above should result in a null value for the json_response variable
|
||||
log::error!("Could not parse JSON from response body");
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
return unknown;
|
||||
}
|
||||
|
||||
let latest_version = match json_response["tag_name"].as_str() {
|
||||
Some(tag) => tag.trim_start_matches('v'),
|
||||
None => {
|
||||
log::error!("Could not get version field from JSON response");
|
||||
log::debug!("{}", json_response);
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
return unknown;
|
||||
}
|
||||
};
|
||||
|
||||
// if we've gotten this far, we have a string in the form of X.X.X where X is a number
|
||||
// all that's left is to compare the current version with the version found above
|
||||
|
||||
return if latest_version == bin_version {
|
||||
// there's really only two possible outcomes if we accept that the tag conforms to
|
||||
// the X.X.X pattern:
|
||||
// 1. the version strings match, meaning we're up to date
|
||||
// 2. the version strings do not match, meaning we're out of date
|
||||
//
|
||||
// except for developers working on this code, nobody should ever be in a situation
|
||||
// where they have a version greater than the latest tagged release
|
||||
log::trace!("exit: needs_update -> UpdateStatus::UpToDate");
|
||||
UpdateStatus::UpToDate
|
||||
} else {
|
||||
log::trace!("exit: needs_update -> UpdateStatus::OutOfDate");
|
||||
UpdateStatus::OutOfDate
|
||||
};
|
||||
}
|
||||
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
unknown
|
||||
}
|
||||
|
||||
/// Prints the banner to stdout.
|
||||
///
|
||||
/// Only prints those settings which are either always present, or passed in by the user.
|
||||
pub async fn initialize<W>(targets: &[String], config: &Configuration, version: &str, mut writer: W)
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
let artwork = format!(
|
||||
r#"
|
||||
___ ___ __ __ __ __ __ ___
|
||||
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
|
||||
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
|
||||
by Ben "epi" Risher {} ver: {}"#,
|
||||
'\u{1F913}', version
|
||||
);
|
||||
|
||||
let status = needs_update(&CONFIGURATION.client, UPDATE_URL, version).await;
|
||||
|
||||
let top = "───────────────────────────┬──────────────────────";
|
||||
let addl_section = "──────────────────────────────────────────────────";
|
||||
let bottom = "───────────────────────────┴──────────────────────";
|
||||
|
||||
writeln!(&mut writer, "{}", artwork).unwrap_or_default();
|
||||
writeln!(&mut writer, "{}", top).unwrap_or_default();
|
||||
|
||||
// begin with always printed items
|
||||
for target in targets {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F3af}", "Target Url", target)
|
||||
)
|
||||
.unwrap_or_default(); // 🎯
|
||||
}
|
||||
|
||||
let mut codes = vec![];
|
||||
|
||||
for code in &config.status_codes {
|
||||
codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F680}", "Threads", config.threads)
|
||||
)
|
||||
.unwrap_or_default(); // 🚀
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4d6}", "Wordlist", config.wordlist)
|
||||
)
|
||||
.unwrap_or_default(); // 📖
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1F197}",
|
||||
"Status Codes",
|
||||
format!("[{}]", codes.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🆗
|
||||
|
||||
if !config.filter_status.is_empty() {
|
||||
// exception here for optional print due to me wanting the allows and denys to be printed
|
||||
// one after the other
|
||||
let mut code_filters = vec![];
|
||||
|
||||
for code in &config.filter_status {
|
||||
code_filters.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f5d1}",
|
||||
"Status Code Filters",
|
||||
format!("[{}]", code_filters.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🗑
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a5}", "Timeout (secs)", config.timeout)
|
||||
)
|
||||
.unwrap_or_default(); // 💥
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F9a1}", "User-Agent", config.user_agent)
|
||||
)
|
||||
.unwrap_or_default(); // 🦡
|
||||
|
||||
// followed by the maybe printed or variably displayed values
|
||||
if !config.config.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f489}", "Config File", config.config)
|
||||
)
|
||||
.unwrap_or_default(); // 💉
|
||||
}
|
||||
|
||||
if !config.proxy.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f48e}", "Proxy", config.proxy)
|
||||
)
|
||||
.unwrap_or_default(); // 💎
|
||||
}
|
||||
|
||||
if !config.replay_proxy.is_empty() {
|
||||
// i include replay codes logic here because in config.rs, replay codes are set to the
|
||||
// value in status codes, meaning it's never empty
|
||||
|
||||
let mut replay_codes = vec![];
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f3a5}", "Replay Proxy", config.replay_proxy)
|
||||
)
|
||||
.unwrap_or_default(); // 🎥
|
||||
|
||||
for code in &config.replay_codes {
|
||||
replay_codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f4fc}",
|
||||
"Replay Proxy Codes",
|
||||
format!("[{}]", replay_codes.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 📼
|
||||
}
|
||||
|
||||
if !config.headers.is_empty() {
|
||||
for (name, value) in &config.headers {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f92f}", "Header", name, value)
|
||||
)
|
||||
.unwrap_or_default(); // 🤯
|
||||
}
|
||||
}
|
||||
|
||||
if !config.filter_size.is_empty() {
|
||||
for filter in &config.filter_size {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a2}", "Size Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
}
|
||||
|
||||
for filter in &config.filter_word_count {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a2}", "Word Count Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
for filter in &config.filter_line_count {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a2}", "Line Count Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
if config.extract_links {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F50E}", "Extract Links", config.extract_links)
|
||||
)
|
||||
.unwrap_or_default(); // 🔎
|
||||
}
|
||||
|
||||
if !config.queries.is_empty() {
|
||||
for query in &config.queries {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f914}",
|
||||
"Query Parameter",
|
||||
format!("{}={}", query.0, query.1)
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🤔
|
||||
}
|
||||
}
|
||||
|
||||
if !config.output.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4be}", "Output File", config.output)
|
||||
)
|
||||
.unwrap_or_default(); // 💾
|
||||
}
|
||||
|
||||
if !config.extensions.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f4b2}",
|
||||
"Extensions",
|
||||
format!("[{}]", config.extensions.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 💲
|
||||
}
|
||||
|
||||
if config.insecure {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f513}", "Insecure", config.insecure)
|
||||
)
|
||||
.unwrap_or_default(); // 🔓
|
||||
}
|
||||
|
||||
if config.redirects {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4cd}", "Follow Redirects", config.redirects)
|
||||
)
|
||||
.unwrap_or_default(); // 📍
|
||||
}
|
||||
|
||||
if config.dont_filter {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f92a}", "Filter Wildcards", !config.dont_filter)
|
||||
)
|
||||
.unwrap_or_default(); // 🤪
|
||||
}
|
||||
|
||||
match config.verbosity {
|
||||
//speaker medium volume (increasing with verbosity to loudspeaker)
|
||||
1 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f508}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔈
|
||||
}
|
||||
2 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f509}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔉
|
||||
}
|
||||
3 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f50a}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔊
|
||||
}
|
||||
4 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4e2}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 📢
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if config.add_slash {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1fa93}", "Add Slash", config.add_slash)
|
||||
)
|
||||
.unwrap_or_default(); // 🪓
|
||||
}
|
||||
|
||||
if !config.no_recursion {
|
||||
if config.depth == 0 {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f503}", "Recursion Depth", "INFINITE")
|
||||
)
|
||||
.unwrap_or_default(); // 🔃
|
||||
} else {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f503}", "Recursion Depth", config.depth)
|
||||
)
|
||||
.unwrap_or_default(); // 🔃
|
||||
}
|
||||
} else {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f6ab}", "Do Not Recurse", config.no_recursion)
|
||||
)
|
||||
.unwrap_or_default(); // 🚫
|
||||
}
|
||||
|
||||
if CONFIGURATION.scan_limit > 0 {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f9a5}", "Concurrent Scan Limit", config.scan_limit)
|
||||
)
|
||||
.unwrap_or_default(); // 🦥
|
||||
}
|
||||
|
||||
if matches!(status, UpdateStatus::OutOfDate) {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f389}",
|
||||
"New Version Available",
|
||||
"https://github.com/epi052/feroxbuster/releases/latest"
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🎉
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", bottom).unwrap_or_default();
|
||||
// ⏯
|
||||
writeln!(
|
||||
&mut writer,
|
||||
" \u{23ef} Press [{}] to {}|{} your scan",
|
||||
style("ENTER").yellow(),
|
||||
style("pause").red(),
|
||||
style("resume").green()
|
||||
)
|
||||
.unwrap_or_default();
|
||||
writeln!(&mut writer, "{}", addl_section).unwrap_or_default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::VERSION;
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::{Mock, MockServer};
|
||||
use std::fs::read_to_string;
|
||||
use std::io::stderr;
|
||||
use std::time::Duration;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit no execution of targets for loop in banner
|
||||
async fn banner_intialize_without_targets() {
|
||||
let config = Configuration::default();
|
||||
initialize(&[], &config, VERSION, stderr()).await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit no execution of statuscode for loop in banner
|
||||
async fn banner_intialize_without_status_codes() {
|
||||
let mut config = Configuration::default();
|
||||
config.status_codes = vec![];
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
VERSION,
|
||||
stderr(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit an empty config file
|
||||
async fn banner_intialize_without_config_file() {
|
||||
let mut config = Configuration::default();
|
||||
config.config = String::new();
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
VERSION,
|
||||
stderr(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit an empty config file
|
||||
async fn banner_intialize_without_queries() {
|
||||
let mut config = Configuration::default();
|
||||
config.queries = vec![(String::new(), String::new())];
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
VERSION,
|
||||
stderr(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to show that a new version is available for download
|
||||
async fn banner_intialize_with_mismatched_version() {
|
||||
let config = Configuration::default();
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
"mismatched-version",
|
||||
&file,
|
||||
)
|
||||
.await;
|
||||
let contents = read_to_string(file.path()).unwrap();
|
||||
println!("contents: {}", contents);
|
||||
assert!(contents.contains("New Version Available"));
|
||||
assert!(contents.contains("https://github.com/epi052/feroxbuster/releases/latest"));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test that
|
||||
async fn banner_needs_update_returns_unknown_with_bad_url() {
|
||||
let result = needs_update(&CONFIGURATION.client, &"", VERSION).await;
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url to needs_update
|
||||
async fn banner_needs_update_returns_up_to_date() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.1.0").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::UpToDate));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url to needs_update that returns a newer version than current
|
||||
async fn banner_needs_update_returns_out_of_date() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::OutOfDate));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url that times out
|
||||
async fn banner_needs_update_returns_unknown_on_timeout() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.return_with_delay(Duration::from_secs(8))
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url with bad json response
|
||||
async fn banner_needs_update_returns_unknown_on_bad_json_response() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("not json")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url with json response that lacks the tag_name field
|
||||
async fn banner_needs_update_returns_unknown_on_json_without_correct_tag() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"no tag_name\": \"doesn't exist\"}")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
}
|
||||
619
src/banner/container.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
use super::entry::BannerEntry;
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
event_handlers::Handles,
|
||||
utils::{logged_request, status_colorizer},
|
||||
DEFAULT_METHOD, VERSION,
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use console::{style, Emoji};
|
||||
use reqwest::Url;
|
||||
use serde_json::Value;
|
||||
use std::{io::Write, sync::Arc};
|
||||
|
||||
/// Url used to query github's api; specifically used to look for the latest tagged release name
|
||||
pub const UPDATE_URL: &str = "https://api.github.com/repos/epi052/feroxbuster/releases/latest";
|
||||
|
||||
/// Simple enum to hold three different update states
|
||||
#[derive(Debug)]
|
||||
pub(super) enum UpdateStatus {
|
||||
/// this version and latest release are the same
|
||||
UpToDate,
|
||||
|
||||
/// this version and latest release are not the same
|
||||
OutOfDate,
|
||||
|
||||
/// some error occurred during version check
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Banner object, contains multiple BannerEntry's and knows how to display itself
|
||||
pub struct Banner {
|
||||
/// all live targets
|
||||
targets: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.status_codes
|
||||
status_codes: BannerEntry,
|
||||
|
||||
/// represents Configuration.filter_status
|
||||
filter_status: BannerEntry,
|
||||
|
||||
/// represents Configuration.threads
|
||||
threads: BannerEntry,
|
||||
|
||||
/// represents Configuration.wordlist
|
||||
wordlist: BannerEntry,
|
||||
|
||||
/// represents Configuration.timeout
|
||||
timeout: BannerEntry,
|
||||
|
||||
/// represents Configuration.user_agent
|
||||
user_agent: BannerEntry,
|
||||
|
||||
/// represents Configuration.random_agent
|
||||
random_agent: BannerEntry,
|
||||
|
||||
/// represents Configuration.config
|
||||
config: BannerEntry,
|
||||
|
||||
/// represents Configuration.proxy
|
||||
proxy: BannerEntry,
|
||||
|
||||
/// represents Configuration.replay_proxy
|
||||
replay_proxy: BannerEntry,
|
||||
|
||||
/// represents Configuration.replay_codes
|
||||
replay_codes: BannerEntry,
|
||||
|
||||
/// represents Configuration.headers
|
||||
headers: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_size
|
||||
filter_size: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_similar
|
||||
filter_similar: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_word_count
|
||||
filter_word_count: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_line_count
|
||||
filter_line_count: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_regex
|
||||
filter_regex: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.extract_links
|
||||
extract_links: BannerEntry,
|
||||
|
||||
/// represents Configuration.json
|
||||
json: BannerEntry,
|
||||
|
||||
/// represents Configuration.output
|
||||
output: BannerEntry,
|
||||
|
||||
/// represents Configuration.debug_log
|
||||
debug_log: BannerEntry,
|
||||
|
||||
/// represents Configuration.extensions
|
||||
extensions: BannerEntry,
|
||||
|
||||
/// represents Configuration.methods
|
||||
methods: BannerEntry,
|
||||
|
||||
/// represents Configuration.data
|
||||
data: BannerEntry,
|
||||
|
||||
/// represents Configuration.insecure
|
||||
insecure: BannerEntry,
|
||||
|
||||
/// represents Configuration.redirects
|
||||
redirects: BannerEntry,
|
||||
|
||||
/// represents Configuration.dont_filter
|
||||
dont_filter: BannerEntry,
|
||||
|
||||
/// represents Configuration.queries
|
||||
queries: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.verbosity
|
||||
verbosity: BannerEntry,
|
||||
|
||||
/// represents Configuration.add_slash
|
||||
add_slash: BannerEntry,
|
||||
|
||||
/// represents Configuration.no_recursion
|
||||
no_recursion: BannerEntry,
|
||||
|
||||
/// represents Configuration.scan_limit
|
||||
scan_limit: BannerEntry,
|
||||
|
||||
/// represents Configuration.time_limit
|
||||
time_limit: BannerEntry,
|
||||
|
||||
/// represents Configuration.rate_limit
|
||||
rate_limit: BannerEntry,
|
||||
|
||||
/// represents Configuration.parallel
|
||||
parallel: BannerEntry,
|
||||
|
||||
/// represents Configuration.auto_tune
|
||||
auto_tune: BannerEntry,
|
||||
|
||||
/// represents Configuration.auto_bail
|
||||
auto_bail: BannerEntry,
|
||||
|
||||
/// represents Configuration.url_denylist
|
||||
url_denylist: Vec<BannerEntry>,
|
||||
|
||||
/// current version of feroxbuster
|
||||
pub(super) version: String,
|
||||
|
||||
/// whether or not there is a known new version
|
||||
pub(super) update_status: UpdateStatus,
|
||||
}
|
||||
|
||||
/// implementation of Banner
|
||||
impl Banner {
|
||||
/// Create a new Banner from a Configuration and live targets
|
||||
pub fn new(tgts: &[String], config: &Configuration) -> Self {
|
||||
let mut targets = Vec::new();
|
||||
let mut url_denylist = Vec::new();
|
||||
let mut code_filters = Vec::new();
|
||||
let mut replay_codes = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
let mut filter_size = Vec::new();
|
||||
let mut filter_similar = Vec::new();
|
||||
let mut filter_word_count = Vec::new();
|
||||
let mut filter_line_count = Vec::new();
|
||||
let mut filter_regex = Vec::new();
|
||||
let mut queries = Vec::new();
|
||||
|
||||
for target in tgts {
|
||||
targets.push(BannerEntry::new("🎯", "Target Url", target));
|
||||
}
|
||||
|
||||
for denied_url in &config.url_denylist {
|
||||
url_denylist.push(BannerEntry::new(
|
||||
"🚫",
|
||||
"Don't Scan Url",
|
||||
denied_url.as_str(),
|
||||
));
|
||||
}
|
||||
|
||||
for denied_regex in &config.regex_denylist {
|
||||
url_denylist.push(BannerEntry::new(
|
||||
"🚫",
|
||||
"Don't Scan Regex",
|
||||
denied_regex.as_str(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut codes = vec![];
|
||||
for code in &config.status_codes {
|
||||
codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
let status_codes =
|
||||
BannerEntry::new("👌", "Status Codes", &format!("[{}]", codes.join(", ")));
|
||||
|
||||
for code in &config.filter_status {
|
||||
code_filters.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
let filter_status = BannerEntry::new(
|
||||
"💢",
|
||||
"Status Code Filters",
|
||||
&format!("[{}]", code_filters.join(", ")),
|
||||
);
|
||||
|
||||
for code in &config.replay_codes {
|
||||
replay_codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
let replay_codes = BannerEntry::new(
|
||||
"📼",
|
||||
"Replay Proxy Codes",
|
||||
&format!("[{}]", replay_codes.join(", ")),
|
||||
);
|
||||
|
||||
for (name, value) in &config.headers {
|
||||
headers.push(BannerEntry::new(
|
||||
"🤯",
|
||||
"Header",
|
||||
&format!("{}: {}", name, value),
|
||||
));
|
||||
}
|
||||
|
||||
for filter in &config.filter_size {
|
||||
filter_size.push(BannerEntry::new("💢", "Size Filter", &filter.to_string()));
|
||||
}
|
||||
|
||||
for filter in &config.filter_similar {
|
||||
filter_similar.push(BannerEntry::new("💢", "Similarity Filter", filter));
|
||||
}
|
||||
|
||||
for filter in &config.filter_word_count {
|
||||
filter_word_count.push(BannerEntry::new(
|
||||
"💢",
|
||||
"Word Count Filter",
|
||||
&filter.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for filter in &config.filter_line_count {
|
||||
filter_line_count.push(BannerEntry::new(
|
||||
"💢",
|
||||
"Line Count Filter",
|
||||
&filter.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for filter in &config.filter_regex {
|
||||
filter_regex.push(BannerEntry::new("💢", "Regex Filter", filter));
|
||||
}
|
||||
|
||||
for query in &config.queries {
|
||||
queries.push(BannerEntry::new(
|
||||
"🤔",
|
||||
"Query Parameter",
|
||||
&format!("{}={}", query.0, query.1),
|
||||
));
|
||||
}
|
||||
|
||||
let volume = ["🔈", "🔉", "🔊", "📢"];
|
||||
let verbosity = if let 1..=4 = config.verbosity {
|
||||
//speaker medium volume (increasing with verbosity to loudspeaker)
|
||||
BannerEntry::new(
|
||||
volume[config.verbosity as usize - 1],
|
||||
"Verbosity",
|
||||
&config.verbosity.to_string(),
|
||||
)
|
||||
} else {
|
||||
BannerEntry::default()
|
||||
};
|
||||
|
||||
let no_recursion = if !config.no_recursion {
|
||||
let depth = if config.depth == 0 {
|
||||
"INFINITE".to_string()
|
||||
} else {
|
||||
config.depth.to_string()
|
||||
};
|
||||
|
||||
BannerEntry::new("🔃", "Recursion Depth", &depth)
|
||||
} else {
|
||||
BannerEntry::new("🚫", "Do Not Recurse", &config.no_recursion.to_string())
|
||||
};
|
||||
|
||||
let scan_limit = BannerEntry::new(
|
||||
"🦥",
|
||||
"Concurrent Scan Limit",
|
||||
&config.scan_limit.to_string(),
|
||||
);
|
||||
|
||||
let replay_proxy = BannerEntry::new("🎥", "Replay Proxy", &config.replay_proxy);
|
||||
let auto_tune = BannerEntry::new("🎶", "Auto Tune", &config.auto_tune.to_string());
|
||||
let auto_bail = BannerEntry::new("🪣", "Auto Bail", &config.auto_bail.to_string());
|
||||
let cfg = BannerEntry::new("💉", "Config File", &config.config);
|
||||
let proxy = BannerEntry::new("💎", "Proxy", &config.proxy);
|
||||
let threads = BannerEntry::new("🚀", "Threads", &config.threads.to_string());
|
||||
let wordlist = BannerEntry::new("📖", "Wordlist", &config.wordlist);
|
||||
let timeout = BannerEntry::new("💥", "Timeout (secs)", &config.timeout.to_string());
|
||||
let user_agent = BannerEntry::new("🦡", "User-Agent", &config.user_agent);
|
||||
let random_agent = BannerEntry::new("🦡", "User-Agent", "Random");
|
||||
let extract_links =
|
||||
BannerEntry::new("🔎", "Extract Links", &config.extract_links.to_string());
|
||||
let json = BannerEntry::new("🧔", "JSON Output", &config.json.to_string());
|
||||
let output = BannerEntry::new("💾", "Output File", &config.output);
|
||||
let debug_log = BannerEntry::new("🪲", "Debugging Log", &config.debug_log);
|
||||
let extensions = BannerEntry::new(
|
||||
"💲",
|
||||
"Extensions",
|
||||
&format!("[{}]", config.extensions.join(", ")),
|
||||
);
|
||||
let methods = BannerEntry::new(
|
||||
"🏁",
|
||||
"HTTP methods",
|
||||
&format!("[{}]", config.methods.join(", ")),
|
||||
);
|
||||
|
||||
let offset = std::cmp::min(config.data.len(), 30);
|
||||
let data = String::from_utf8(config.data[..offset].to_vec())
|
||||
.unwrap_or_else(|_err| {
|
||||
format!(
|
||||
"{:x?} ...",
|
||||
&config.data[..std::cmp::min(config.data.len(), 13)]
|
||||
)
|
||||
})
|
||||
.replace("\n", " ")
|
||||
.replace("\r", "");
|
||||
let data = BannerEntry::new("💣", "HTTP Body", &data);
|
||||
let insecure = BannerEntry::new("🔓", "Insecure", &config.insecure.to_string());
|
||||
let redirects = BannerEntry::new("📍", "Follow Redirects", &config.redirects.to_string());
|
||||
let dont_filter =
|
||||
BannerEntry::new("🤪", "Filter Wildcards", &(!config.dont_filter).to_string());
|
||||
let add_slash = BannerEntry::new("🪓", "Add Slash", &config.add_slash.to_string());
|
||||
let time_limit = BannerEntry::new("🕖", "Time Limit", &config.time_limit);
|
||||
let parallel = BannerEntry::new("🛤", "Parallel Scans", &config.parallel.to_string());
|
||||
let rate_limit =
|
||||
BannerEntry::new("🚧", "Requests per Second", &config.rate_limit.to_string());
|
||||
|
||||
Self {
|
||||
targets,
|
||||
status_codes,
|
||||
threads,
|
||||
wordlist,
|
||||
filter_status,
|
||||
timeout,
|
||||
user_agent,
|
||||
random_agent,
|
||||
auto_bail,
|
||||
auto_tune,
|
||||
proxy,
|
||||
replay_codes,
|
||||
replay_proxy,
|
||||
headers,
|
||||
filter_size,
|
||||
filter_similar,
|
||||
filter_word_count,
|
||||
filter_line_count,
|
||||
filter_regex,
|
||||
extract_links,
|
||||
parallel,
|
||||
json,
|
||||
queries,
|
||||
output,
|
||||
debug_log,
|
||||
extensions,
|
||||
methods,
|
||||
data,
|
||||
insecure,
|
||||
dont_filter,
|
||||
redirects,
|
||||
verbosity,
|
||||
add_slash,
|
||||
no_recursion,
|
||||
rate_limit,
|
||||
scan_limit,
|
||||
time_limit,
|
||||
url_denylist,
|
||||
config: cfg,
|
||||
version: VERSION.to_string(),
|
||||
update_status: UpdateStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// get a fancy header for the banner
|
||||
fn header(&self) -> String {
|
||||
let artwork = format!(
|
||||
r#"
|
||||
___ ___ __ __ __ __ __ ___
|
||||
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
|
||||
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
|
||||
by Ben "epi" Risher {} ver: {}"#,
|
||||
Emoji("🤓", &format!("{:<2}", "\u{0020}")),
|
||||
self.version
|
||||
);
|
||||
|
||||
let top = "───────────────────────────┬──────────────────────";
|
||||
|
||||
format!("{}\n{}", artwork, top)
|
||||
}
|
||||
|
||||
/// get a fancy footer for the banner
|
||||
fn footer(&self) -> String {
|
||||
let addl_section = "──────────────────────────────────────────────────";
|
||||
let bottom = "───────────────────────────┴──────────────────────";
|
||||
|
||||
let instructions = format!(
|
||||
" 🏁 Press [{}] to use the {}™",
|
||||
style("ENTER").yellow(),
|
||||
style("Scan Management Menu").bright().yellow(),
|
||||
);
|
||||
|
||||
format!("{}\n{}\n{}", bottom, instructions, addl_section)
|
||||
}
|
||||
|
||||
/// Makes a request to the given url, expecting to receive a JSON response that contains a field
|
||||
/// named `tag_name` that holds a value representing the latest tagged release of this tool.
|
||||
///
|
||||
/// ex: v1.1.0
|
||||
pub async fn check_for_updates(&mut self, url: &str, handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: needs_update({}, {:?})", url, handles);
|
||||
|
||||
let api_url = Url::parse(url)?;
|
||||
|
||||
let result = logged_request(&api_url, DEFAULT_METHOD, None, handles.clone()).await?;
|
||||
let body = result.text().await?;
|
||||
|
||||
let json_response: Value = serde_json::from_str(&body)?;
|
||||
|
||||
let latest_version = match json_response["tag_name"].as_str() {
|
||||
Some(tag) => tag.trim_start_matches('v'),
|
||||
None => {
|
||||
bail!("JSON has no tag_name: {}", json_response);
|
||||
}
|
||||
};
|
||||
|
||||
// if we've gotten this far, we have a string in the form of X.X.X where X is a number
|
||||
// all that's left is to compare the current version with the version found above
|
||||
|
||||
if latest_version == self.version {
|
||||
// there's really only two possible outcomes if we accept that the tag conforms to
|
||||
// the X.X.X pattern:
|
||||
// 1. the version strings match, meaning we're up to date
|
||||
// 2. the version strings do not match, meaning we're out of date
|
||||
//
|
||||
// except for developers working on this code, nobody should ever be in a situation
|
||||
// where they have a version greater than the latest tagged release
|
||||
self.update_status = UpdateStatus::UpToDate;
|
||||
} else {
|
||||
self.update_status = UpdateStatus::OutOfDate;
|
||||
}
|
||||
|
||||
log::trace!("exit: check_for_updates -> {:?}", self.update_status);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// display the banner on Write writer
|
||||
pub fn print_to<W>(&self, mut writer: W, config: Arc<Configuration>) -> Result<()>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
writeln!(&mut writer, "{}", self.header())?;
|
||||
|
||||
// begin with always printed items
|
||||
for target in &self.targets {
|
||||
writeln!(&mut writer, "{}", target)?;
|
||||
}
|
||||
|
||||
for denied_url in &self.url_denylist {
|
||||
writeln!(&mut writer, "{}", denied_url)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.threads)?;
|
||||
writeln!(&mut writer, "{}", self.wordlist)?;
|
||||
writeln!(&mut writer, "{}", self.status_codes)?;
|
||||
|
||||
if !config.filter_status.is_empty() {
|
||||
// exception here for an optional print in the middle of always printed values is due
|
||||
// to me wanting the allows and denys to be printed one after the other
|
||||
writeln!(&mut writer, "{}", self.filter_status)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.timeout)?;
|
||||
|
||||
if config.random_agent {
|
||||
writeln!(&mut writer, "{}", self.random_agent)?;
|
||||
} else {
|
||||
writeln!(&mut writer, "{}", self.user_agent)?;
|
||||
}
|
||||
|
||||
// followed by the maybe printed or variably displayed values
|
||||
if !config.config.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.config)?;
|
||||
}
|
||||
|
||||
if !config.proxy.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.proxy)?;
|
||||
}
|
||||
|
||||
if !config.replay_proxy.is_empty() {
|
||||
// i include replay codes logic here because in config.rs, replay codes are set to the
|
||||
// value in status codes, meaning it's never empty
|
||||
writeln!(&mut writer, "{}", self.replay_proxy)?;
|
||||
writeln!(&mut writer, "{}", self.replay_codes)?;
|
||||
}
|
||||
|
||||
for header in &self.headers {
|
||||
writeln!(&mut writer, "{}", header)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_size {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_similar {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_word_count {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_line_count {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_regex {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
if config.extract_links {
|
||||
writeln!(&mut writer, "{}", self.extract_links)?;
|
||||
}
|
||||
|
||||
if config.json {
|
||||
writeln!(&mut writer, "{}", self.json)?;
|
||||
}
|
||||
|
||||
for query in &self.queries {
|
||||
writeln!(&mut writer, "{}", query)?;
|
||||
}
|
||||
|
||||
if !config.output.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.output)?;
|
||||
}
|
||||
|
||||
if !config.debug_log.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.debug_log)?;
|
||||
}
|
||||
|
||||
if !config.extensions.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.extensions)?;
|
||||
}
|
||||
|
||||
if !config.methods.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.methods)?;
|
||||
}
|
||||
|
||||
if !config.data.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.data)?;
|
||||
}
|
||||
|
||||
if config.insecure {
|
||||
writeln!(&mut writer, "{}", self.insecure)?;
|
||||
}
|
||||
|
||||
if config.auto_bail {
|
||||
writeln!(&mut writer, "{}", self.auto_bail)?;
|
||||
}
|
||||
if config.auto_tune {
|
||||
writeln!(&mut writer, "{}", self.auto_tune)?;
|
||||
}
|
||||
|
||||
if config.redirects {
|
||||
writeln!(&mut writer, "{}", self.redirects)?;
|
||||
}
|
||||
|
||||
if config.dont_filter {
|
||||
writeln!(&mut writer, "{}", self.dont_filter)?;
|
||||
}
|
||||
|
||||
if let 1..=4 = config.verbosity {
|
||||
writeln!(&mut writer, "{}", self.verbosity)?;
|
||||
}
|
||||
|
||||
if config.add_slash {
|
||||
writeln!(&mut writer, "{}", self.add_slash)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.no_recursion)?;
|
||||
|
||||
if config.scan_limit > 0 {
|
||||
writeln!(&mut writer, "{}", self.scan_limit)?;
|
||||
}
|
||||
|
||||
if config.parallel > 0 {
|
||||
writeln!(&mut writer, "{}", self.parallel)?;
|
||||
}
|
||||
|
||||
if config.rate_limit > 0 {
|
||||
writeln!(&mut writer, "{}", self.rate_limit)?;
|
||||
}
|
||||
|
||||
if !config.time_limit.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.time_limit)?;
|
||||
}
|
||||
|
||||
if matches!(self.update_status, UpdateStatus::OutOfDate) {
|
||||
let update = BannerEntry::new(
|
||||
"🎉",
|
||||
"New Version Available",
|
||||
"https://github.com/epi052/feroxbuster/releases/latest",
|
||||
);
|
||||
writeln!(&mut writer, "{}", update)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.footer())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
57
src/banner/entry.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use console::{measure_text_width, Emoji};
|
||||
use std::fmt;
|
||||
|
||||
/// Initial visual indentation size used in formatting banner entries
|
||||
const INDENT: usize = 3;
|
||||
|
||||
/// Column width used in formatting banner entries
|
||||
const COL_WIDTH: usize = 22;
|
||||
|
||||
/// Represents a single line on the banner
|
||||
#[derive(Default)]
|
||||
pub(super) struct BannerEntry {
|
||||
/// emoji used in the banner entry
|
||||
emoji: String,
|
||||
|
||||
/// title used in the banner entry
|
||||
title: String,
|
||||
|
||||
/// value passed in via config/cli/defaults
|
||||
value: String,
|
||||
}
|
||||
|
||||
/// implementation of a banner entry
|
||||
impl BannerEntry {
|
||||
/// Create a new banner entry from given fields
|
||||
pub fn new(emoji: &str, title: &str, value: &str) -> Self {
|
||||
BannerEntry {
|
||||
emoji: emoji.to_string(),
|
||||
title: title.to_string(),
|
||||
value: value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple wrapper for emoji or fallback when terminal doesn't support emoji
|
||||
fn format_emoji(&self) -> String {
|
||||
let width = measure_text_width(&self.emoji);
|
||||
let pad_len = width * width;
|
||||
let pad = format!("{:<pad_len$}", "\u{0020}", pad_len = pad_len);
|
||||
Emoji(&self.emoji, &pad).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Display implementation for a banner entry
|
||||
impl fmt::Display for BannerEntry {
|
||||
/// Display formatter for the given banner entry
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<width$}\u{2502}\u{0020}{}",
|
||||
self.format_emoji(),
|
||||
self.title,
|
||||
self.value,
|
||||
indent = INDENT,
|
||||
width = COL_WIDTH
|
||||
)
|
||||
}
|
||||
}
|
||||
8
src/banner/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! all logic related to building/printing the banner seen when scans start
|
||||
mod container;
|
||||
mod entry;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use self::container::{Banner, UPDATE_URL};
|
||||
174
src/banner/tests.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use super::container::UpdateStatus;
|
||||
use super::*;
|
||||
use crate::{config::Configuration, event_handlers::Handles, scan_manager::FeroxScans};
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::MockServer;
|
||||
use std::{io::stderr, sync::Arc, time::Duration};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test to hit no execution of targets for loop in banner
|
||||
async fn banner_intialize_without_targets() {
|
||||
let config = Configuration::new().unwrap();
|
||||
let banner = Banner::new(&[], &config);
|
||||
banner.print_to(stderr(), Arc::new(config)).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test to hit no execution of statuscode for loop in banner
|
||||
async fn banner_intialize_without_status_codes() {
|
||||
let config = Configuration {
|
||||
status_codes: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let banner = Banner::new(&[String::from("http://localhost")], &config);
|
||||
banner.print_to(stderr(), Arc::new(config)).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test to hit an empty config file
|
||||
async fn banner_intialize_without_config_file() {
|
||||
let config = Configuration {
|
||||
config: String::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let banner = Banner::new(&[String::from("http://localhost")], &config);
|
||||
banner.print_to(stderr(), Arc::new(config)).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test to hit an empty queries
|
||||
async fn banner_intialize_without_queries() {
|
||||
let config = Configuration {
|
||||
queries: vec![(String::new(), String::new())],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let banner = Banner::new(&[String::from("http://localhost")], &config);
|
||||
banner.print_to(stderr(), Arc::new(config)).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test that
|
||||
async fn banner_needs_update_returns_unknown_with_bad_url() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let mut banner = Banner::new(
|
||||
&[String::from("http://localhost")],
|
||||
&Configuration::new().unwrap(),
|
||||
);
|
||||
|
||||
let _ = banner.check_for_updates("", handles).await;
|
||||
|
||||
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test return value of good url to needs_update
|
||||
async fn banner_needs_update_returns_up_to_date() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
|
||||
});
|
||||
let scans = Arc::new(FeroxScans::default());
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(Some(scans), None).0);
|
||||
|
||||
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
|
||||
banner.version = String::from("1.1.0");
|
||||
|
||||
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(banner.update_status, UpdateStatus::UpToDate));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test return value of good url to needs_update that returns a newer version than current
|
||||
async fn banner_needs_update_returns_out_of_date() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
|
||||
});
|
||||
|
||||
let scans = Arc::new(FeroxScans::default());
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(Some(scans), None).0);
|
||||
|
||||
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
|
||||
banner.version = String::from("1.0.1");
|
||||
|
||||
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(banner.update_status, UpdateStatus::OutOfDate));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test return value of good url that times out
|
||||
async fn banner_needs_update_returns_unknown_on_timeout() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200)
|
||||
.body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.delay(Duration::from_secs(8));
|
||||
});
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
|
||||
|
||||
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test return value of good url with bad json response
|
||||
async fn banner_needs_update_returns_unknown_on_bad_json_response() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200).body("not json");
|
||||
});
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
|
||||
|
||||
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test return value of good url with json response that lacks the tag_name field
|
||||
async fn banner_needs_update_returns_unknown_on_json_without_correct_tag() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200)
|
||||
.body("{\"no tag_name\": \"doesn't exist\"}");
|
||||
});
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let mut banner = Banner::new(&[srv.url("")], &Configuration::new().unwrap());
|
||||
banner.version = String::from("1.0.1");
|
||||
|
||||
let _ = banner.check_for_updates(&srv.url("/latest"), handles).await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(banner.update_status, UpdateStatus::Unknown));
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::utils::{module_colorizer, status_colorizer};
|
||||
use anyhow::Result;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::{redirect::Policy, Client, Proxy};
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryInto;
|
||||
#[cfg(not(test))]
|
||||
use std::process::exit;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Create and return an instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
|
||||
@@ -15,15 +13,14 @@ pub fn initialize(
|
||||
insecure: bool,
|
||||
headers: &HashMap<String, String>,
|
||||
proxy: Option<&str>,
|
||||
) -> Client {
|
||||
) -> Result<Client> {
|
||||
let policy = if redirects {
|
||||
Policy::limited(10)
|
||||
} else {
|
||||
Policy::none()
|
||||
};
|
||||
|
||||
// try_into returns infallible as its error, unwrap is safe here
|
||||
let header_map: HeaderMap = headers.try_into().unwrap();
|
||||
let header_map: HeaderMap = headers.try_into()?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::new(timeout, 0))
|
||||
@@ -32,51 +29,15 @@ pub fn initialize(
|
||||
.default_headers(header_map)
|
||||
.redirect(policy);
|
||||
|
||||
let client = match proxy {
|
||||
// a proxy is specified, need to add it to the client
|
||||
Some(some_proxy) => {
|
||||
if !some_proxy.is_empty() {
|
||||
// it's not an empty string
|
||||
match Proxy::all(some_proxy) {
|
||||
Ok(proxy_obj) => client.proxy(proxy_obj),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {} {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("Client::initialize"),
|
||||
e
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
panic!();
|
||||
#[cfg(not(test))]
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client // Some("") was used?
|
||||
}
|
||||
}
|
||||
// no proxy specified
|
||||
None => client,
|
||||
};
|
||||
|
||||
match client.build() {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {} {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("Client::build"),
|
||||
e
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
panic!();
|
||||
#[cfg(not(test))]
|
||||
exit(1);
|
||||
if let Some(some_proxy) = proxy {
|
||||
if !some_proxy.is_empty() {
|
||||
// it's not an empty string; set the proxy
|
||||
let proxy_obj = Proxy::all(some_proxy)?;
|
||||
return Ok(client.proxy(proxy_obj).build()?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(client.build()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -88,7 +49,7 @@ mod tests {
|
||||
/// create client with a bad proxy, expect panic
|
||||
fn client_with_bad_proxy() {
|
||||
let headers = HashMap::new();
|
||||
initialize(0, "stuff", true, false, &headers, Some("not a valid proxy"));
|
||||
initialize(0, "stuff", true, false, &headers, Some("not a valid proxy")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -96,6 +57,6 @@ mod tests {
|
||||
fn client_with_good_proxy() {
|
||||
let headers = HashMap::new();
|
||||
let proxy = "http://127.0.0.1:8080";
|
||||
initialize(0, "stuff", true, true, &headers, Some(proxy));
|
||||
initialize(0, "stuff", true, true, &headers, Some(proxy)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
923
src/config.rs
@@ -1,923 +0,0 @@
|
||||
use crate::utils::{module_colorizer, status_colorizer};
|
||||
use crate::{client, parser, progress};
|
||||
use crate::{DEFAULT_CONFIG_NAME, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION};
|
||||
use clap::value_t;
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::env::{current_dir, current_exe};
|
||||
use std::fs::read_to_string;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(not(test))]
|
||||
use std::process::exit;
|
||||
|
||||
lazy_static! {
|
||||
/// Global configuration state
|
||||
pub static ref CONFIGURATION: Configuration = Configuration::new();
|
||||
|
||||
/// Global progress bar that houses other progress bars
|
||||
pub static ref PROGRESS_BAR: MultiProgress = MultiProgress::with_draw_target(ProgressDrawTarget::stdout());
|
||||
|
||||
/// Global progress bar that is only used for printing messages that don't jack up other bars
|
||||
pub static ref PROGRESS_PRINTER: ProgressBar = progress::add_bar("", 0, true);
|
||||
}
|
||||
|
||||
/// simple helper to clean up some code reuse below; panics under test / exits in prod
|
||||
fn report_and_exit(err: &str) -> ! {
|
||||
eprintln!(
|
||||
"{} {}: {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("Configuration::new"),
|
||||
err
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
panic!();
|
||||
#[cfg(not(test))]
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/// Represents the final, global configuration of the program.
|
||||
///
|
||||
/// This struct is the combination of the following:
|
||||
/// - default configuration values
|
||||
/// - plus overrides read from a configuration file
|
||||
/// - plus command-line options
|
||||
///
|
||||
/// In that order.
|
||||
///
|
||||
/// Inspired by and derived from https://github.com/PhilipDaniels/rust-config-example
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Configuration {
|
||||
/// Path to the wordlist
|
||||
#[serde(default = "wordlist")]
|
||||
pub wordlist: String,
|
||||
|
||||
/// Path to the config file used
|
||||
#[serde(default)]
|
||||
pub config: String,
|
||||
|
||||
/// Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)
|
||||
#[serde(default)]
|
||||
pub proxy: String,
|
||||
|
||||
/// Replay Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)
|
||||
#[serde(default)]
|
||||
pub replay_proxy: String,
|
||||
|
||||
/// The target URL
|
||||
#[serde(default)]
|
||||
pub target_url: String,
|
||||
|
||||
/// Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)
|
||||
#[serde(default = "status_codes")]
|
||||
pub status_codes: Vec<u16>,
|
||||
|
||||
/// Status Codes to replay to the Replay Proxy (default: whatever is passed to --status-code)
|
||||
#[serde(default)]
|
||||
pub replay_codes: Vec<u16>,
|
||||
|
||||
/// Status Codes to filter out (deny list)
|
||||
#[serde(default)]
|
||||
pub filter_status: Vec<u16>,
|
||||
|
||||
/// Instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
|
||||
#[serde(skip)]
|
||||
pub client: Client,
|
||||
|
||||
/// Instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
|
||||
#[serde(skip)]
|
||||
pub replay_client: Option<Client>,
|
||||
|
||||
/// Number of concurrent threads (default: 50)
|
||||
#[serde(default = "threads")]
|
||||
pub threads: usize,
|
||||
|
||||
/// Number of seconds before a request times out (default: 7)
|
||||
#[serde(default = "timeout")]
|
||||
pub timeout: u64,
|
||||
|
||||
/// Level of verbosity, equates to log level
|
||||
#[serde(default)]
|
||||
pub verbosity: u8,
|
||||
|
||||
/// Only print URLs
|
||||
#[serde(default)]
|
||||
pub quiet: bool,
|
||||
|
||||
/// Output file to write results to (default: stdout)
|
||||
#[serde(default)]
|
||||
pub output: String,
|
||||
|
||||
/// Sets the User-Agent (default: feroxbuster/VERSION)
|
||||
#[serde(default = "user_agent")]
|
||||
pub user_agent: String,
|
||||
|
||||
/// Follow redirects
|
||||
#[serde(default)]
|
||||
pub redirects: bool,
|
||||
|
||||
/// Disables TLS certificate validation
|
||||
#[serde(default)]
|
||||
pub insecure: bool,
|
||||
|
||||
/// File extension(s) to search for
|
||||
#[serde(default)]
|
||||
pub extensions: Vec<String>,
|
||||
|
||||
/// HTTP headers to be used in each request
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
|
||||
/// URL query parameters
|
||||
#[serde(default)]
|
||||
pub queries: Vec<(String, String)>,
|
||||
|
||||
/// Do not scan recursively
|
||||
#[serde(default)]
|
||||
pub no_recursion: bool,
|
||||
|
||||
/// Extract links from html/javscript
|
||||
#[serde(default)]
|
||||
pub extract_links: bool,
|
||||
|
||||
/// Append / to each request
|
||||
#[serde(default)]
|
||||
pub add_slash: bool,
|
||||
|
||||
/// Read url(s) from STDIN
|
||||
#[serde(default)]
|
||||
pub stdin: bool,
|
||||
|
||||
/// Maximum recursion depth, a depth of 0 is infinite recursion
|
||||
#[serde(default = "depth")]
|
||||
pub depth: usize,
|
||||
|
||||
/// Number of concurrent scans permitted; a limit of 0 means no limit is imposed
|
||||
#[serde(default)]
|
||||
pub scan_limit: usize,
|
||||
|
||||
/// Filter out messages of a particular size
|
||||
#[serde(default)]
|
||||
pub filter_size: Vec<u64>,
|
||||
|
||||
/// Filter out messages of a particular line count
|
||||
#[serde(default)]
|
||||
pub filter_line_count: Vec<usize>,
|
||||
|
||||
/// Filter out messages of a particular word count
|
||||
#[serde(default)]
|
||||
pub filter_word_count: Vec<usize>,
|
||||
|
||||
/// Don't auto-filter wildcard responses
|
||||
#[serde(default)]
|
||||
pub dont_filter: bool,
|
||||
}
|
||||
|
||||
// functions timeout, threads, status_codes, user_agent, wordlist, and depth are used to provide
|
||||
// defaults in the event that a ferox-config.toml is found but one or more of the values below
|
||||
// aren't listed in the config. This way, we get the correct defaults upon Deserialization
|
||||
|
||||
/// default timeout value
|
||||
fn timeout() -> u64 {
|
||||
7
|
||||
}
|
||||
|
||||
/// default threads value
|
||||
fn threads() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
/// default status codes
|
||||
fn status_codes() -> Vec<u16> {
|
||||
DEFAULT_STATUS_CODES
|
||||
.iter()
|
||||
.map(|code| code.as_u16())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// default wordlist
|
||||
fn wordlist() -> String {
|
||||
String::from(DEFAULT_WORDLIST)
|
||||
}
|
||||
|
||||
/// default user-agent
|
||||
fn user_agent() -> String {
|
||||
format!("feroxbuster/{}", VERSION)
|
||||
}
|
||||
|
||||
/// default recursion depth
|
||||
fn depth() -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
impl Default for Configuration {
|
||||
/// Builds the default Configuration for feroxbuster
|
||||
fn default() -> Self {
|
||||
let timeout = timeout();
|
||||
let user_agent = user_agent();
|
||||
let client = client::initialize(timeout, &user_agent, false, false, &HashMap::new(), None);
|
||||
let replay_client = None;
|
||||
let status_codes = status_codes();
|
||||
let replay_codes = status_codes.clone();
|
||||
|
||||
Configuration {
|
||||
client,
|
||||
timeout,
|
||||
user_agent,
|
||||
replay_codes,
|
||||
status_codes,
|
||||
replay_client,
|
||||
dont_filter: false,
|
||||
quiet: false,
|
||||
stdin: false,
|
||||
verbosity: 0,
|
||||
scan_limit: 0,
|
||||
add_slash: false,
|
||||
insecure: false,
|
||||
redirects: false,
|
||||
no_recursion: false,
|
||||
extract_links: false,
|
||||
proxy: String::new(),
|
||||
config: String::new(),
|
||||
output: String::new(),
|
||||
target_url: String::new(),
|
||||
replay_proxy: String::new(),
|
||||
queries: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
filter_size: Vec::new(),
|
||||
filter_line_count: Vec::new(),
|
||||
filter_word_count: Vec::new(),
|
||||
filter_status: Vec::new(),
|
||||
headers: HashMap::new(),
|
||||
depth: depth(),
|
||||
threads: threads(),
|
||||
wordlist: wordlist(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Configuration {
|
||||
/// Creates a [Configuration](struct.Configuration.html) object with the following
|
||||
/// built-in default values
|
||||
///
|
||||
/// - **timeout**: `5` seconds
|
||||
/// - **redirects**: `false`
|
||||
/// - **extract-links**: `false`
|
||||
/// - **wordlist**: [`DEFAULT_WORDLIST`](constant.DEFAULT_WORDLIST.html)
|
||||
/// - **config**: `None`
|
||||
/// - **threads**: `50`
|
||||
/// - **timeout**: `7` seconds
|
||||
/// - **verbosity**: `0` (no logging enabled)
|
||||
/// - **proxy**: `None`
|
||||
/// - **status_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
|
||||
/// - **filter_status**: `None`
|
||||
/// - **output**: `None` (print to stdout)
|
||||
/// - **quiet**: `false`
|
||||
/// - **user_agent**: `feroxer/VERSION`
|
||||
/// - **insecure**: `false` (don't be insecure, i.e. don't allow invalid certs)
|
||||
/// - **extensions**: `None`
|
||||
/// - **filter_size**: `None`
|
||||
/// - **filter_word_count**: `None`
|
||||
/// - **filter_line_count**: `None`
|
||||
/// - **headers**: `None`
|
||||
/// - **queries**: `None`
|
||||
/// - **no_recursion**: `false` (recursively scan enumerated sub-directories)
|
||||
/// - **add_slash**: `false`
|
||||
/// - **stdin**: `false`
|
||||
/// - **dont_filter**: `false` (auto filter wildcard responses)
|
||||
/// - **depth**: `4` (maximum recursion depth)
|
||||
/// - **scan_limit**: `0` (no limit on concurrent scans imposed)
|
||||
/// - **replay_proxy**: `None` (no limit on concurrent scans imposed)
|
||||
/// - **replay_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
|
||||
///
|
||||
/// After which, any values defined in a
|
||||
/// [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file will override the
|
||||
/// built-in defaults.
|
||||
///
|
||||
/// `ferox-config.toml` can be placed in any of the following locations (in the order shown):
|
||||
/// - `/etc/feroxbuster/`
|
||||
/// - `CONFIG_DIR/ferxobuster/`
|
||||
/// - The same directory as the `feroxbuster` executable
|
||||
/// - The user's current working directory
|
||||
///
|
||||
/// If more than one valid configuration file is found, each one overwrites the values found previously.
|
||||
///
|
||||
/// Finally, any options/arguments given on the commandline will override both built-in and
|
||||
/// config-file specified values.
|
||||
///
|
||||
/// The resulting [Configuration](struct.Configuration.html) is a singleton with a `static`
|
||||
/// lifetime.
|
||||
pub fn new() -> Self {
|
||||
// when compiling for test, we want to eliminate the runtime dependency of the parser
|
||||
if cfg!(test) {
|
||||
return Configuration::default();
|
||||
}
|
||||
|
||||
// Get the default configuration, this is what will apply if nothing
|
||||
// else is specified.
|
||||
let mut config = Configuration::default();
|
||||
|
||||
// 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
|
||||
// actually specified in the config file
|
||||
//
|
||||
// search for a config using the following order of precedence
|
||||
// - /etc/feroxbuster/
|
||||
// - CONFIG_DIR/ferxobuster/
|
||||
// - same directory as feroxbuster executable
|
||||
// - current directory
|
||||
|
||||
// merge a config found at /etc/feroxbuster/ferox-config.toml
|
||||
let config_file = PathBuf::new()
|
||||
.join("/etc/feroxbuster")
|
||||
.join(DEFAULT_CONFIG_NAME);
|
||||
Self::parse_and_merge_config(config_file, &mut config);
|
||||
|
||||
// merge a config found at ~/.config/feroxbuster/ferox-config.toml
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
// config_dir() resolves to one of the following
|
||||
// - linux: $XDG_CONFIG_HOME or $HOME/.config
|
||||
// - macOS: $HOME/Library/Application Support
|
||||
// - windows: {FOLDERID_RoamingAppData}
|
||||
|
||||
let config_file = config_dir.join("feroxbuster").join(DEFAULT_CONFIG_NAME);
|
||||
Self::parse_and_merge_config(config_file, &mut config);
|
||||
};
|
||||
|
||||
// merge a config found in same the directory as feroxbuster executable
|
||||
if let Ok(exe_path) = current_exe() {
|
||||
if let Some(bin_dir) = exe_path.parent() {
|
||||
let config_file = bin_dir.join(DEFAULT_CONFIG_NAME);
|
||||
Self::parse_and_merge_config(config_file, &mut config);
|
||||
};
|
||||
};
|
||||
|
||||
// merge a config found in the user's current working directory
|
||||
if let Ok(cwd) = current_dir() {
|
||||
let config_file = cwd.join(DEFAULT_CONFIG_NAME);
|
||||
Self::parse_and_merge_config(config_file, &mut config);
|
||||
}
|
||||
|
||||
let args = parser::initialize().get_matches();
|
||||
|
||||
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
|
||||
}
|
||||
Err(e) => e.exit(), // Exit with error on parse error
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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.wordlist, args, "wordlist", String);
|
||||
update_config_if_present!(&mut config.output, args, "output", String);
|
||||
|
||||
if let Some(arg) = args.values_of("status_codes") {
|
||||
config.status_codes = arg
|
||||
.map(|code| {
|
||||
StatusCode::from_bytes(code.as_bytes())
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
.as_u16()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("replay_codes") {
|
||||
// replay codes passed in by the user
|
||||
config.replay_codes = arg
|
||||
.map(|code| {
|
||||
StatusCode::from_bytes(code.as_bytes())
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
.as_u16()
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
// not passed in by the user, use whatever value is held in status_codes
|
||||
config.replay_codes = config.status_codes.clone();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_status") {
|
||||
config.filter_status = arg
|
||||
.map(|code| {
|
||||
StatusCode::from_bytes(code.as_bytes())
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
.as_u16()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("extensions") {
|
||||
config.extensions = arg.map(|val| val.to_string()).collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_size") {
|
||||
config.filter_size = arg
|
||||
.map(|size| {
|
||||
size.parse::<u64>()
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_words") {
|
||||
config.filter_word_count = arg
|
||||
.map(|size| {
|
||||
size.parse::<usize>()
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_lines") {
|
||||
config.filter_line_count = arg
|
||||
.map(|size| {
|
||||
size.parse::<usize>()
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if args.is_present("quiet") {
|
||||
// the reason this is protected by an if statement:
|
||||
// consider a user specifying quiet = true in ferox-config.toml
|
||||
// if the line below is outside of the if, we'd overwrite true with
|
||||
// false if no -q is used on the command line
|
||||
config.quiet = true;
|
||||
}
|
||||
|
||||
if args.is_present("dont_filter") {
|
||||
config.dont_filter = 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
|
||||
config.verbosity = args.occurrences_of("verbosity") as u8;
|
||||
}
|
||||
|
||||
if args.is_present("no_recursion") {
|
||||
config.no_recursion = true;
|
||||
}
|
||||
|
||||
if args.is_present("add_slash") {
|
||||
config.add_slash = true;
|
||||
}
|
||||
|
||||
if args.is_present("extract_links") {
|
||||
config.extract_links = true;
|
||||
}
|
||||
|
||||
if args.is_present("stdin") {
|
||||
config.stdin = true;
|
||||
} else {
|
||||
config.target_url = String::from(args.value_of("url").unwrap());
|
||||
}
|
||||
|
||||
////
|
||||
// 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);
|
||||
|
||||
if args.is_present("redirects") {
|
||||
config.redirects = true;
|
||||
}
|
||||
|
||||
if args.is_present("insecure") {
|
||||
config.insecure = true;
|
||||
}
|
||||
|
||||
if let Some(headers) = args.values_of("headers") {
|
||||
for val in headers {
|
||||
let mut split_val = val.split(':');
|
||||
|
||||
// explicitly take first split value as header's name
|
||||
let name = split_val.next().unwrap().trim();
|
||||
|
||||
// all other items in the iterator returned by split, when combined with the
|
||||
// original split deliminator (:), make up the header's final value
|
||||
let value = split_val.collect::<Vec<&str>>().join(":");
|
||||
config.headers.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(queries) = args.values_of("queries") {
|
||||
for val in queries {
|
||||
// same basic logic used as reading in the headers HashMap above
|
||||
let mut split_val = val.split('=');
|
||||
|
||||
let name = split_val.next().unwrap().trim();
|
||||
|
||||
let value = split_val.collect::<Vec<&str>>().join("=");
|
||||
|
||||
config.queries.push((name.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// this if statement determines if we've gotten a Client configuration change from
|
||||
// either the config file or command line arguments; if we have, we need to rebuild
|
||||
// the client and store it in the config struct
|
||||
if !config.proxy.is_empty()
|
||||
|| config.timeout != timeout()
|
||||
|| config.user_agent != user_agent()
|
||||
|| config.redirects
|
||||
|| config.insecure
|
||||
|| !config.headers.is_empty()
|
||||
{
|
||||
if config.proxy.is_empty() {
|
||||
config.client = client::initialize(
|
||||
config.timeout,
|
||||
&config.user_agent,
|
||||
config.redirects,
|
||||
config.insecure,
|
||||
&config.headers,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
config.client = client::initialize(
|
||||
config.timeout,
|
||||
&config.user_agent,
|
||||
config.redirects,
|
||||
config.insecure,
|
||||
&config.headers,
|
||||
Some(&config.proxy),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !config.replay_proxy.is_empty() {
|
||||
// only set replay_client when replay_proxy is set
|
||||
config.replay_client = Some(client::initialize(
|
||||
config.timeout,
|
||||
&config.user_agent,
|
||||
config.redirects,
|
||||
config.insecure,
|
||||
&config.headers,
|
||||
Some(&config.replay_proxy),
|
||||
));
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// Given a configuration file's location and an instance of `Configuration`, read in
|
||||
/// the config file if found and update the current settings with the settings found therein
|
||||
fn parse_and_merge_config(config_file: PathBuf, mut config: &mut Self) {
|
||||
if config_file.exists() {
|
||||
// save off a string version of the path before it goes out of scope
|
||||
let conf_str = match config_file.to_str() {
|
||||
Some(cs) => String::from(cs),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
if let Some(settings) = Self::parse_config(config_file) {
|
||||
// set the config used for viewing in the banner
|
||||
config.config = conf_str;
|
||||
|
||||
// update the settings
|
||||
Self::merge_config(&mut config, settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given two Configurations, overwrite `settings` with the fields found in `settings_to_merge`
|
||||
fn merge_config(settings: &mut Self, settings_to_merge: Self) {
|
||||
settings.threads = settings_to_merge.threads;
|
||||
settings.wordlist = settings_to_merge.wordlist;
|
||||
settings.status_codes = settings_to_merge.status_codes;
|
||||
settings.proxy = settings_to_merge.proxy;
|
||||
settings.timeout = settings_to_merge.timeout;
|
||||
settings.verbosity = settings_to_merge.verbosity;
|
||||
settings.quiet = settings_to_merge.quiet;
|
||||
settings.output = settings_to_merge.output;
|
||||
settings.user_agent = settings_to_merge.user_agent;
|
||||
settings.redirects = settings_to_merge.redirects;
|
||||
settings.insecure = settings_to_merge.insecure;
|
||||
settings.extract_links = settings_to_merge.extract_links;
|
||||
settings.extensions = settings_to_merge.extensions;
|
||||
settings.headers = settings_to_merge.headers;
|
||||
settings.queries = settings_to_merge.queries;
|
||||
settings.no_recursion = settings_to_merge.no_recursion;
|
||||
settings.add_slash = settings_to_merge.add_slash;
|
||||
settings.stdin = settings_to_merge.stdin;
|
||||
settings.depth = settings_to_merge.depth;
|
||||
settings.filter_size = settings_to_merge.filter_size;
|
||||
settings.filter_word_count = settings_to_merge.filter_word_count;
|
||||
settings.filter_line_count = settings_to_merge.filter_line_count;
|
||||
settings.filter_status = settings_to_merge.filter_status;
|
||||
settings.dont_filter = settings_to_merge.dont_filter;
|
||||
settings.scan_limit = settings_to_merge.scan_limit;
|
||||
settings.replay_proxy = settings_to_merge.replay_proxy;
|
||||
settings.replay_codes = settings_to_merge.replay_codes;
|
||||
}
|
||||
|
||||
/// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values
|
||||
///
|
||||
/// uses serde to deserialize the toml into a `Configuration` struct
|
||||
fn parse_config(config_file: PathBuf) -> Option<Self> {
|
||||
if let Ok(content) = read_to_string(config_file) {
|
||||
match toml::from_str(content.as_str()) {
|
||||
Ok(config) => {
|
||||
return Some(config);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} {} {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("config::parse_config"),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// creates a dummy configuration file for testing
|
||||
fn setup_config_test() -> Configuration {
|
||||
let data = r#"
|
||||
wordlist = "/some/path"
|
||||
status_codes = [201, 301, 401]
|
||||
replay_codes = [201, 301]
|
||||
threads = 40
|
||||
timeout = 5
|
||||
proxy = "http://127.0.0.1:8080"
|
||||
replay_proxy = "http://127.0.0.1:8081"
|
||||
quiet = true
|
||||
verbosity = 1
|
||||
scan_limit = 6
|
||||
output = "/some/otherpath"
|
||||
redirects = true
|
||||
insecure = true
|
||||
extensions = ["html", "php", "js"]
|
||||
headers = {stuff = "things", mostuff = "mothings"}
|
||||
queries = [["name","value"], ["rick", "astley"]]
|
||||
no_recursion = true
|
||||
add_slash = true
|
||||
stdin = true
|
||||
dont_filter = true
|
||||
extract_links = true
|
||||
depth = 1
|
||||
filter_size = [4120]
|
||||
filter_word_count = [994, 992]
|
||||
filter_line_count = [34]
|
||||
filter_status = [201]
|
||||
"#;
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let file = tmp_dir.path().join(DEFAULT_CONFIG_NAME);
|
||||
write(&file, data).unwrap();
|
||||
Configuration::parse_config(file).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that all default config values meet expectations
|
||||
fn default_configuration() {
|
||||
let config = Configuration::default();
|
||||
assert_eq!(config.wordlist, wordlist());
|
||||
assert_eq!(config.proxy, String::new());
|
||||
assert_eq!(config.target_url, String::new());
|
||||
assert_eq!(config.config, String::new());
|
||||
assert_eq!(config.replay_proxy, String::new());
|
||||
assert_eq!(config.status_codes, status_codes());
|
||||
assert_eq!(config.replay_codes, config.status_codes);
|
||||
assert!(config.replay_client.is_none());
|
||||
assert_eq!(config.threads, threads());
|
||||
assert_eq!(config.depth, depth());
|
||||
assert_eq!(config.timeout, timeout());
|
||||
assert_eq!(config.verbosity, 0);
|
||||
assert_eq!(config.scan_limit, 0);
|
||||
assert_eq!(config.quiet, false);
|
||||
assert_eq!(config.dont_filter, false);
|
||||
assert_eq!(config.no_recursion, false);
|
||||
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_eq!(config.queries, Vec::new());
|
||||
assert_eq!(config.extensions, Vec::<String>::new());
|
||||
assert_eq!(config.filter_size, Vec::<u64>::new());
|
||||
assert_eq!(config.filter_word_count, Vec::<usize>::new());
|
||||
assert_eq!(config.filter_line_count, Vec::<usize>::new());
|
||||
assert_eq!(config.filter_status, Vec::<u16>::new());
|
||||
assert_eq!(config.headers, HashMap::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_wordlist() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.wordlist, "/some/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_status_codes() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.status_codes, vec![201, 301, 401]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_replay_codes() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.replay_codes, vec![201, 301]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_threads() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.threads, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_depth() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.depth, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_scan_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.scan_limit, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_timeout() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.timeout, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_proxy() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.proxy, "http://127.0.0.1:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_replay_proxy() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.replay_proxy, "http://127.0.0.1:8081");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_quiet() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.quiet, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_verbosity() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.verbosity, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_output() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.output, "/some/otherpath");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_redirects() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.redirects, true);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_extensions() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.extensions, vec!["html", "php", "js"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_size() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_size, vec![4120]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_word_count() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_word_count, vec![994, 992]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_line_count() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_line_count, vec![34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_status() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_status, vec![201]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the values parsed are correct
|
||||
fn config_reads_headers() {
|
||||
let config = setup_config_test();
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("stuff".to_string(), "things".to_string());
|
||||
headers.insert("mostuff".to_string(), "mothings".to_string());
|
||||
assert_eq!(config.headers, headers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the values parsed are correct
|
||||
fn config_reads_queries() {
|
||||
let config = setup_config_test();
|
||||
let mut queries = vec![];
|
||||
queries.push(("name".to_string(), "value".to_string()));
|
||||
queries.push(("rick".to_string(), "astley".to_string()));
|
||||
assert_eq!(config.queries, queries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
/// test that an error message is printed and panic is called when report_and_exit is called
|
||||
fn config_report_and_exit_works() {
|
||||
report_and_exit("some message");
|
||||
}
|
||||
}
|
||||
993
src/config/container.rs
Normal file
@@ -0,0 +1,993 @@
|
||||
use super::utils::{
|
||||
depth, 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;
|
||||
use crate::{
|
||||
client, parser, scan_manager::resume_scan, traits::FeroxSerialize, utils::fmt_err,
|
||||
DEFAULT_CONFIG_NAME,
|
||||
};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::ArgMatches;
|
||||
use regex::Regex;
|
||||
use reqwest::{Client, Method, StatusCode, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env::{current_dir, current_exe},
|
||||
fs::read_to_string,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
/// macro helper to abstract away repetitive configuration updates
|
||||
macro_rules! update_config_if_present {
|
||||
($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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// macro helper to abstract away repetitive if not default: update checks
|
||||
macro_rules! update_if_not_default {
|
||||
($old:expr, $new:expr, $default:expr) => {
|
||||
if $new != $default {
|
||||
*$old = $new;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Represents the final, global configuration of the program.
|
||||
///
|
||||
/// This struct is the combination of the following:
|
||||
/// - default configuration values
|
||||
/// - plus overrides read from a configuration file
|
||||
/// - plus command-line options
|
||||
///
|
||||
/// In that order.
|
||||
///
|
||||
/// Inspired by and derived from https://github.com/PhilipDaniels/rust-config-example
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Configuration {
|
||||
#[serde(rename = "type", default = "serialized_type")]
|
||||
/// Name of this type of struct, used for serialization, i.e. `{"type":"configuration"}`
|
||||
pub kind: String,
|
||||
|
||||
/// Path to the wordlist
|
||||
#[serde(default = "wordlist")]
|
||||
pub wordlist: String,
|
||||
|
||||
/// Path to the config file used
|
||||
#[serde(default)]
|
||||
pub config: String,
|
||||
|
||||
/// Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)
|
||||
#[serde(default)]
|
||||
pub proxy: String,
|
||||
|
||||
/// Replay Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)
|
||||
#[serde(default)]
|
||||
pub replay_proxy: String,
|
||||
|
||||
/// The target URL
|
||||
#[serde(default)]
|
||||
pub target_url: String,
|
||||
|
||||
/// Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)
|
||||
#[serde(default = "status_codes")]
|
||||
pub status_codes: Vec<u16>,
|
||||
|
||||
/// Status Codes to replay to the Replay Proxy (default: whatever is passed to --status-code)
|
||||
#[serde(default = "status_codes")]
|
||||
pub replay_codes: Vec<u16>,
|
||||
|
||||
/// Status Codes to filter out (deny list)
|
||||
#[serde(default)]
|
||||
pub filter_status: Vec<u16>,
|
||||
|
||||
/// Instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
|
||||
#[serde(skip)]
|
||||
pub client: Client,
|
||||
|
||||
/// Instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
|
||||
#[serde(skip)]
|
||||
pub replay_client: Option<Client>,
|
||||
|
||||
/// Number of concurrent threads (default: 50)
|
||||
#[serde(default = "threads")]
|
||||
pub threads: usize,
|
||||
|
||||
/// Number of seconds before a request times out (default: 7)
|
||||
#[serde(default = "timeout")]
|
||||
pub timeout: u64,
|
||||
|
||||
/// Level of verbosity, equates to log level
|
||||
#[serde(default)]
|
||||
pub verbosity: u8,
|
||||
|
||||
/// Only print URLs (was --quiet in versions < 2.0.0)
|
||||
#[serde(default)]
|
||||
pub silent: bool,
|
||||
|
||||
/// No header, no status bars
|
||||
#[serde(default)]
|
||||
pub quiet: bool,
|
||||
|
||||
/// more easily differentiate between the three states of output levels
|
||||
#[serde(skip)]
|
||||
pub output_level: OutputLevel,
|
||||
|
||||
/// automatically bail at certain error thresholds
|
||||
#[serde(default)]
|
||||
pub auto_bail: bool,
|
||||
|
||||
/// automatically try to lower request rate in order to reduce errors
|
||||
#[serde(default)]
|
||||
pub auto_tune: bool,
|
||||
|
||||
/// more easily differentiate between the three requester policies
|
||||
#[serde(skip)]
|
||||
pub requester_policy: RequesterPolicy,
|
||||
|
||||
/// Store log output as NDJSON
|
||||
#[serde(default)]
|
||||
pub json: bool,
|
||||
|
||||
/// Output file to write results to (default: stdout)
|
||||
#[serde(default)]
|
||||
pub output: String,
|
||||
|
||||
/// File in which to store debug output, used in conjunction with verbosity to dictate which
|
||||
/// logs are written
|
||||
#[serde(default)]
|
||||
pub debug_log: String,
|
||||
|
||||
/// Sets the User-Agent (default: feroxbuster/VERSION)
|
||||
#[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,
|
||||
|
||||
/// Disables TLS certificate validation
|
||||
#[serde(default)]
|
||||
pub insecure: bool,
|
||||
|
||||
/// File extension(s) to search for
|
||||
#[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>,
|
||||
|
||||
/// URL query parameters
|
||||
#[serde(default)]
|
||||
pub queries: Vec<(String, String)>,
|
||||
|
||||
/// Do not scan recursively
|
||||
#[serde(default)]
|
||||
pub no_recursion: bool,
|
||||
|
||||
/// Extract links from html/javscript
|
||||
#[serde(default)]
|
||||
pub extract_links: bool,
|
||||
|
||||
/// Append / to each request
|
||||
#[serde(default)]
|
||||
pub add_slash: bool,
|
||||
|
||||
/// Read url(s) from STDIN
|
||||
#[serde(default)]
|
||||
pub stdin: bool,
|
||||
|
||||
/// Maximum recursion depth, a depth of 0 is infinite recursion
|
||||
#[serde(default = "depth")]
|
||||
pub depth: usize,
|
||||
|
||||
/// Number of concurrent scans permitted; a limit of 0 means no limit is imposed
|
||||
#[serde(default)]
|
||||
pub scan_limit: usize,
|
||||
|
||||
/// Number of parallel scans permitted; a limit of 0 means no limit is imposed
|
||||
#[serde(default)]
|
||||
pub parallel: usize,
|
||||
|
||||
/// Number of requests per second permitted (per directory); a limit of 0 means no limit is imposed
|
||||
#[serde(default)]
|
||||
pub rate_limit: usize,
|
||||
|
||||
/// Filter out messages of a particular size
|
||||
#[serde(default)]
|
||||
pub filter_size: Vec<u64>,
|
||||
|
||||
/// Filter out messages of a particular line count
|
||||
#[serde(default)]
|
||||
pub filter_line_count: Vec<usize>,
|
||||
|
||||
/// Filter out messages of a particular word count
|
||||
#[serde(default)]
|
||||
pub filter_word_count: Vec<usize>,
|
||||
|
||||
/// Filter out messages by regular expression
|
||||
#[serde(default)]
|
||||
pub filter_regex: Vec<String>,
|
||||
|
||||
/// Don't auto-filter wildcard responses
|
||||
#[serde(default)]
|
||||
pub dont_filter: bool,
|
||||
|
||||
/// Scan started from a state file, not from CLI args
|
||||
#[serde(default)]
|
||||
pub resumed: bool,
|
||||
|
||||
/// Resume scan from this file
|
||||
#[serde(default)]
|
||||
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,
|
||||
|
||||
/// The maximum runtime for a scan, expressed as N[smdh] where N can be parsed into a
|
||||
/// non-negative integer and the next character is either s, m, h, or d (case insensitive)
|
||||
#[serde(default)]
|
||||
pub time_limit: String,
|
||||
|
||||
/// 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>,
|
||||
|
||||
#[serde(with = "serde_regex", default)]
|
||||
pub regex_denylist: Vec<Regex>,
|
||||
}
|
||||
|
||||
impl Default for Configuration {
|
||||
/// Builds the default Configuration for feroxbuster
|
||||
fn default() -> Self {
|
||||
let timeout = timeout();
|
||||
let user_agent = user_agent();
|
||||
let client = client::initialize(timeout, &user_agent, false, false, &HashMap::new(), None)
|
||||
.expect("Could not build client");
|
||||
let replay_client = None;
|
||||
let status_codes = status_codes();
|
||||
let replay_codes = status_codes.clone();
|
||||
let kind = serialized_type();
|
||||
let output_level = OutputLevel::Default;
|
||||
let requester_policy = RequesterPolicy::Default;
|
||||
|
||||
Configuration {
|
||||
kind,
|
||||
client,
|
||||
timeout,
|
||||
user_agent,
|
||||
replay_codes,
|
||||
status_codes,
|
||||
replay_client,
|
||||
requester_policy,
|
||||
dont_filter: false,
|
||||
auto_bail: false,
|
||||
auto_tune: false,
|
||||
silent: false,
|
||||
quiet: false,
|
||||
output_level,
|
||||
resumed: false,
|
||||
stdin: false,
|
||||
json: false,
|
||||
verbosity: 0,
|
||||
scan_limit: 0,
|
||||
parallel: 0,
|
||||
rate_limit: 0,
|
||||
add_slash: false,
|
||||
insecure: false,
|
||||
redirects: false,
|
||||
no_recursion: false,
|
||||
extract_links: false,
|
||||
random_agent: false,
|
||||
save_state: true,
|
||||
proxy: String::new(),
|
||||
config: String::new(),
|
||||
output: String::new(),
|
||||
debug_log: String::new(),
|
||||
target_url: String::new(),
|
||||
time_limit: String::new(),
|
||||
resume_from: String::new(),
|
||||
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(),
|
||||
filter_similar: Vec::new(),
|
||||
headers: HashMap::new(),
|
||||
depth: depth(),
|
||||
threads: threads(),
|
||||
wordlist: wordlist(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Configuration {
|
||||
/// Creates a [Configuration](struct.Configuration.html) object with the following
|
||||
/// built-in default values
|
||||
///
|
||||
/// - **timeout**: `5` seconds
|
||||
/// - **redirects**: `false`
|
||||
/// - **extract-links**: `false`
|
||||
/// - **wordlist**: [`DEFAULT_WORDLIST`](constant.DEFAULT_WORDLIST.html)
|
||||
/// - **config**: `None`
|
||||
/// - **threads**: `50`
|
||||
/// - **timeout**: `7` seconds
|
||||
/// - **verbosity**: `0` (no logging enabled)
|
||||
/// - **proxy**: `None`
|
||||
/// - **status_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
|
||||
/// - **filter_status**: `None`
|
||||
/// - **output**: `None` (print to stdout)
|
||||
/// - **debug_log**: `None`
|
||||
/// - **quiet**: `false`
|
||||
/// - **silent**: `false`
|
||||
/// - **auto_tune**: `false`
|
||||
/// - **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`
|
||||
/// - **methods**: [`DEFAULT_METHOD`]
|
||||
/// - **data**: `None`
|
||||
/// - **url_denylist**: `None`
|
||||
/// - **regex_denylist**: `None`
|
||||
/// - **filter_size**: `None`
|
||||
/// - **filter_similar**: `None`
|
||||
/// - **filter_regex**: `None`
|
||||
/// - **filter_word_count**: `None`
|
||||
/// - **filter_line_count**: `None`
|
||||
/// - **headers**: `None`
|
||||
/// - **queries**: `None`
|
||||
/// - **no_recursion**: `false` (recursively scan enumerated sub-directories)
|
||||
/// - **add_slash**: `false`
|
||||
/// - **stdin**: `false`
|
||||
/// - **json**: `false`
|
||||
/// - **dont_filter**: `false` (auto filter wildcard responses)
|
||||
/// - **depth**: `4` (maximum 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)
|
||||
/// - **time_limit**: `None` (no limit on length of scan imposed)
|
||||
/// - **replay_proxy**: `None` (no limit on concurrent scans imposed)
|
||||
/// - **replay_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
|
||||
///
|
||||
/// After which, any values defined in a
|
||||
/// [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file will override the
|
||||
/// built-in defaults.
|
||||
///
|
||||
/// `ferox-config.toml` can be placed in any of the following locations (in the order shown):
|
||||
/// - `/etc/feroxbuster/`
|
||||
/// - `CONFIG_DIR/ferxobuster/`
|
||||
/// - The same directory as the `feroxbuster` executable
|
||||
/// - The user's current working directory
|
||||
///
|
||||
/// If more than one valid configuration file is found, each one overwrites the values found previously.
|
||||
///
|
||||
/// Finally, any options/arguments given on the commandline will override both built-in and
|
||||
/// config-file specified values.
|
||||
///
|
||||
/// The resulting [Configuration](struct.Configuration.html) is a singleton with a `static`
|
||||
/// lifetime.
|
||||
pub fn new() -> Result<Self> {
|
||||
// when compiling for test, we want to eliminate the runtime dependency of the parser
|
||||
if cfg!(test) {
|
||||
let test_config = Configuration {
|
||||
save_state: false, // don't clutter up junk when testing
|
||||
..Default::default()
|
||||
};
|
||||
return Ok(test_config);
|
||||
}
|
||||
|
||||
let args = parser::initialize().get_matches();
|
||||
|
||||
// Get the default configuration, this is what will apply if nothing
|
||||
// else is specified.
|
||||
let mut config = Configuration::default();
|
||||
|
||||
// read in all config files
|
||||
Self::parse_config_files(&mut config)?;
|
||||
|
||||
// read in the user provided options, this produces a separate instance of Configuration
|
||||
// in order to allow for potentially merging into a --resume-from Configuration
|
||||
let cli_config = Self::parse_cli_args(&args);
|
||||
|
||||
// --resume-from used, need to first read the Configuration from disk, and then
|
||||
// merge the cli_config into the resumed config
|
||||
if let Some(filename) = args.value_of("resume_from") {
|
||||
// when resuming a scan, instead of normal configuration loading, we just
|
||||
// load the config from disk by calling resume_scan
|
||||
let mut previous_config = resume_scan(filename);
|
||||
|
||||
// if any other arguments were passed on the command line, the theory is that the
|
||||
// user meant to modify the previously cancelled/saved scan in some way that we
|
||||
// should take into account
|
||||
Self::merge_config(&mut previous_config, cli_config);
|
||||
|
||||
// the resumed flag isn't printed in the banner and really has no business being
|
||||
// serialized or included in much of the usual config logic; simply setting it to true
|
||||
// here and being done with it
|
||||
previous_config.resumed = true;
|
||||
|
||||
// if the user used --stdin, we already have all the scans started (or complete), we
|
||||
// need to flip stdin to false so that the 'read from stdin' logic doesn't fire (if
|
||||
// not flipped to false, the program hangs waiting for input from stdin again)
|
||||
previous_config.stdin = false;
|
||||
|
||||
// clients aren't serialized, have to remake them from the previous config
|
||||
Self::try_rebuild_clients(&mut previous_config);
|
||||
|
||||
return Ok(previous_config);
|
||||
}
|
||||
|
||||
// if we've gotten to this point in the code, --resume-from was not used, so we need to
|
||||
// merge the cli options into the config file options and return the result
|
||||
Self::merge_config(&mut config, cli_config);
|
||||
|
||||
// rebuild clients is the last step in either code branch
|
||||
Self::try_rebuild_clients(&mut config);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Parse all possible versions of the ferox-config.toml file, adhering to the order of
|
||||
/// precedence outlined above
|
||||
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
|
||||
// actually specified in the config file
|
||||
//
|
||||
// search for a config using the following order of precedence
|
||||
// - /etc/feroxbuster/
|
||||
// - CONFIG_DIR/ferxobuster/
|
||||
// - same directory as feroxbuster executable
|
||||
// - current directory
|
||||
|
||||
// merge a config found at /etc/feroxbuster/ferox-config.toml
|
||||
let config_file = PathBuf::new()
|
||||
.join("/etc/feroxbuster")
|
||||
.join(DEFAULT_CONFIG_NAME);
|
||||
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
|
||||
// - linux: $XDG_CONFIG_HOME or $HOME/.config
|
||||
// - macOS: $HOME/Library/Application Support
|
||||
// - 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, config)?;
|
||||
|
||||
// merge a config found in same the directory as feroxbuster executable
|
||||
let exe_path = current_exe()?;
|
||||
let bin_dir = exe_path
|
||||
.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, 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, config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Given a set of ArgMatches read from the CLI, update and return the default Configuration
|
||||
/// settings
|
||||
fn parse_cli_args(args: &ArgMatches) -> Self {
|
||||
let mut config = Configuration::default();
|
||||
|
||||
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
|
||||
.map(|code| {
|
||||
StatusCode::from_bytes(code.as_bytes())
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
.as_u16()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("replay_codes") {
|
||||
// replay codes passed in by the user
|
||||
config.replay_codes = arg
|
||||
.map(|code| {
|
||||
StatusCode::from_bytes(code.as_bytes())
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
.as_u16()
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
// not passed in by the user, use whatever value is held in status_codes
|
||||
config.replay_codes = config.status_codes.clone();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_status") {
|
||||
config.filter_status = arg
|
||||
.map(|code| {
|
||||
StatusCode::from_bytes(code.as_bytes())
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
.as_u16()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("extensions") {
|
||||
config.extensions = 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();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_similar") {
|
||||
config.filter_similar = arg.map(|val| val.to_string()).collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_size") {
|
||||
config.filter_size = arg
|
||||
.map(|size| {
|
||||
size.parse::<u64>()
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_words") {
|
||||
config.filter_word_count = arg
|
||||
.map(|size| {
|
||||
size.parse::<usize>()
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(arg) = args.values_of("filter_lines") {
|
||||
config.filter_line_count = arg
|
||||
.map(|size| {
|
||||
size.parse::<usize>()
|
||||
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if args.is_present("silent") {
|
||||
// the reason this is protected by an if statement:
|
||||
// consider a user specifying silent = true in ferox-config.toml
|
||||
// if the line below is outside of the if, we'd overwrite true with
|
||||
// false if no --silent is used on the command line
|
||||
config.silent = true;
|
||||
config.output_level = OutputLevel::Silent;
|
||||
}
|
||||
|
||||
if args.is_present("quiet") {
|
||||
config.quiet = true;
|
||||
config.output_level = OutputLevel::Quiet;
|
||||
}
|
||||
|
||||
if args.is_present("auto_tune") {
|
||||
config.auto_tune = true;
|
||||
config.requester_policy = RequesterPolicy::AutoTune;
|
||||
}
|
||||
|
||||
if args.is_present("auto_bail") {
|
||||
config.auto_bail = true;
|
||||
config.requester_policy = RequesterPolicy::AutoBail;
|
||||
}
|
||||
|
||||
if args.is_present("dont_filter") {
|
||||
config.dont_filter = 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
|
||||
config.verbosity = args.occurrences_of("verbosity") as u8;
|
||||
}
|
||||
|
||||
if args.is_present("no_recursion") {
|
||||
config.no_recursion = true;
|
||||
}
|
||||
|
||||
if args.is_present("add_slash") {
|
||||
config.add_slash = true;
|
||||
}
|
||||
|
||||
if args.is_present("extract_links") {
|
||||
config.extract_links = true;
|
||||
}
|
||||
|
||||
if args.is_present("json") {
|
||||
config.json = true;
|
||||
}
|
||||
|
||||
////
|
||||
// organizational breakpoint; all options below alter the Client configuration
|
||||
////
|
||||
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("random_agent") {
|
||||
config.random_agent = true;
|
||||
}
|
||||
|
||||
if args.is_present("redirects") {
|
||||
config.redirects = true;
|
||||
}
|
||||
|
||||
if args.is_present("insecure") {
|
||||
config.insecure = true;
|
||||
}
|
||||
|
||||
if let Some(headers) = args.values_of("headers") {
|
||||
for val in headers {
|
||||
let mut split_val = val.split(':');
|
||||
|
||||
// explicitly take first split value as header's name
|
||||
let name = split_val.next().unwrap().trim();
|
||||
|
||||
// all other items in the iterator returned by split, when combined with the
|
||||
// original split deliminator (:), make up the header's final value
|
||||
let value = split_val.collect::<Vec<&str>>().join(":");
|
||||
config.headers.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
let mut split_val = val.split('=');
|
||||
|
||||
let name = split_val.next().unwrap().trim();
|
||||
|
||||
let value = split_val.collect::<Vec<&str>>().join("=");
|
||||
|
||||
config.queries.push((name.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// this function determines if we've gotten a Client configuration change from
|
||||
/// either the config file or command line arguments; if we have, we need to rebuild
|
||||
/// the client and store it in the config struct
|
||||
fn try_rebuild_clients(configuration: &mut Configuration) {
|
||||
if !configuration.proxy.is_empty()
|
||||
|| configuration.timeout != timeout()
|
||||
|| configuration.user_agent != user_agent()
|
||||
|| configuration.redirects
|
||||
|| configuration.insecure
|
||||
|| !configuration.headers.is_empty()
|
||||
|| configuration.resumed
|
||||
{
|
||||
if configuration.proxy.is_empty() {
|
||||
configuration.client = client::initialize(
|
||||
configuration.timeout,
|
||||
&configuration.user_agent,
|
||||
configuration.redirects,
|
||||
configuration.insecure,
|
||||
&configuration.headers,
|
||||
None,
|
||||
)
|
||||
.expect("Could not rebuild client")
|
||||
} else {
|
||||
configuration.client = client::initialize(
|
||||
configuration.timeout,
|
||||
&configuration.user_agent,
|
||||
configuration.redirects,
|
||||
configuration.insecure,
|
||||
&configuration.headers,
|
||||
Some(&configuration.proxy),
|
||||
)
|
||||
.expect("Could not rebuild client")
|
||||
}
|
||||
}
|
||||
|
||||
if !configuration.replay_proxy.is_empty() {
|
||||
// only set replay_client when replay_proxy is set
|
||||
configuration.replay_client = Some(
|
||||
client::initialize(
|
||||
configuration.timeout,
|
||||
&configuration.user_agent,
|
||||
configuration.redirects,
|
||||
configuration.insecure,
|
||||
&configuration.headers,
|
||||
Some(&configuration.replay_proxy),
|
||||
)
|
||||
.expect("Could not rebuild client"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a configuration file's location and an instance of `Configuration`, read in
|
||||
/// the config file if found and update the current settings with the settings found therein
|
||||
fn parse_and_merge_config(config_file: PathBuf, mut config: &mut Self) -> Result<()> {
|
||||
if config_file.exists() {
|
||||
// save off a string version of the path before it goes out of scope
|
||||
let conf_str = config_file.to_str().unwrap_or("").to_string();
|
||||
let settings = Self::parse_config(config_file)?;
|
||||
|
||||
// set the config used for viewing in the banner
|
||||
config.config = conf_str;
|
||||
|
||||
// update the settings
|
||||
Self::merge_config(config, settings);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Given two Configurations, overwrite `settings` with the fields found in `settings_to_merge`
|
||||
fn merge_config(conf: &mut Self, new: Self) {
|
||||
// does not include the following Configuration fields, as they don't make sense here
|
||||
// - kind
|
||||
// - client
|
||||
// - replay_client
|
||||
// - resumed
|
||||
// - config
|
||||
update_if_not_default!(&mut conf.target_url, new.target_url, "");
|
||||
update_if_not_default!(&mut conf.time_limit, new.time_limit, "");
|
||||
update_if_not_default!(&mut conf.proxy, new.proxy, "");
|
||||
update_if_not_default!(&mut conf.verbosity, new.verbosity, 0);
|
||||
update_if_not_default!(&mut conf.silent, new.silent, false);
|
||||
update_if_not_default!(&mut conf.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);
|
||||
// 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.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);
|
||||
update_if_not_default!(&mut conf.add_slash, new.add_slash, false);
|
||||
update_if_not_default!(&mut conf.stdin, new.stdin, false);
|
||||
update_if_not_default!(&mut conf.filter_size, new.filter_size, Vec::<u64>::new());
|
||||
update_if_not_default!(
|
||||
&mut conf.filter_regex,
|
||||
new.filter_regex,
|
||||
Vec::<String>::new()
|
||||
);
|
||||
update_if_not_default!(
|
||||
&mut conf.filter_similar,
|
||||
new.filter_similar,
|
||||
Vec::<String>::new()
|
||||
);
|
||||
update_if_not_default!(
|
||||
&mut conf.filter_word_count,
|
||||
new.filter_word_count,
|
||||
Vec::<usize>::new()
|
||||
);
|
||||
update_if_not_default!(
|
||||
&mut conf.filter_line_count,
|
||||
new.filter_line_count,
|
||||
Vec::<usize>::new()
|
||||
);
|
||||
update_if_not_default!(
|
||||
&mut conf.filter_status,
|
||||
new.filter_status,
|
||||
Vec::<u16>::new()
|
||||
);
|
||||
update_if_not_default!(&mut conf.dont_filter, new.dont_filter, false);
|
||||
update_if_not_default!(&mut conf.scan_limit, new.scan_limit, 0);
|
||||
update_if_not_default!(&mut conf.parallel, new.parallel, 0);
|
||||
update_if_not_default!(&mut conf.rate_limit, new.rate_limit, 0);
|
||||
update_if_not_default!(&mut conf.replay_proxy, new.replay_proxy, "");
|
||||
update_if_not_default!(&mut conf.debug_log, new.debug_log, "");
|
||||
update_if_not_default!(&mut conf.resume_from, new.resume_from, "");
|
||||
update_if_not_default!(&mut conf.json, new.json, false);
|
||||
|
||||
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());
|
||||
update_if_not_default!(&mut conf.status_codes, new.status_codes, status_codes());
|
||||
// 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());
|
||||
}
|
||||
|
||||
/// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values
|
||||
///
|
||||
/// uses serde to deserialize the toml into a `Configuration` struct
|
||||
pub(super) fn parse_config(config_file: PathBuf) -> Result<Self> {
|
||||
let content = read_to_string(config_file)?;
|
||||
let config: Self = toml::from_str(content.as_str())?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of FeroxMessage
|
||||
impl FeroxSerialize for Configuration {
|
||||
/// Simple wrapper around create_report_string
|
||||
fn as_str(&self) -> String {
|
||||
format!("{:#?}\n", *self)
|
||||
}
|
||||
|
||||
/// Create an NDJSON representation of the current scan's Configuration
|
||||
///
|
||||
/// (expanded for clarity)
|
||||
/// ex:
|
||||
/// {
|
||||
/// "type":"configuration",
|
||||
/// "wordlist":"test",
|
||||
/// "config":"/home/epi/.config/feroxbuster/ferox-config.toml",
|
||||
/// "proxy":"",
|
||||
/// "replay_proxy":"",
|
||||
/// "target_url":"https://localhost.com",
|
||||
/// "status_codes":[
|
||||
/// 200,
|
||||
/// 204,
|
||||
/// 301,
|
||||
/// 302,
|
||||
/// 307,
|
||||
/// 308,
|
||||
/// 401,
|
||||
/// 403,
|
||||
/// 405
|
||||
/// ],
|
||||
/// ...
|
||||
/// }\n
|
||||
fn as_json(&self) -> Result<String> {
|
||||
let mut json = serde_json::to_string(&self)
|
||||
.with_context(|| fmt_err("Could not convert Configuration to JSON"))?;
|
||||
json.push('\n');
|
||||
Ok(json)
|
||||
}
|
||||
}
|
||||
9
src/config/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! all logic related to instantiating a running configuration
|
||||
|
||||
mod container;
|
||||
mod utils;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use self::container::Configuration;
|
||||
pub use self::utils::{determine_output_level, OutputLevel, RequesterPolicy};
|
||||
461
src/config/tests.rs
Normal file
@@ -0,0 +1,461 @@
|
||||
use super::utils::*;
|
||||
use super::*;
|
||||
use crate::{traits::FeroxSerialize, DEFAULT_CONFIG_NAME};
|
||||
use regex::Regex;
|
||||
use reqwest::Url;
|
||||
use std::{collections::HashMap, fs::write};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// creates a dummy configuration file for testing
|
||||
fn setup_config_test() -> Configuration {
|
||||
let data = r#"
|
||||
wordlist = "/some/path"
|
||||
status_codes = [201, 301, 401]
|
||||
replay_codes = [201, 301]
|
||||
threads = 40
|
||||
timeout = 5
|
||||
proxy = "http://127.0.0.1:8080"
|
||||
replay_proxy = "http://127.0.0.1:8081"
|
||||
quiet = true
|
||||
silent = true
|
||||
auto_tune = true
|
||||
auto_bail = true
|
||||
verbosity = 1
|
||||
scan_limit = 6
|
||||
parallel = 14
|
||||
rate_limit = 250
|
||||
time_limit = "10m"
|
||||
output = "/some/otherpath"
|
||||
debug_log = "/yet/anotherpath"
|
||||
resume_from = "/some/state/file"
|
||||
redirects = true
|
||||
insecure = true
|
||||
extensions = ["html", "php", "js"]
|
||||
methods = ["GET", "PUT", "DELETE"]
|
||||
data = [31, 32, 33, 34]
|
||||
url_denylist = ["http://dont-scan.me", "https://also-not.me"]
|
||||
regex_denylist = ["/deny.*"]
|
||||
headers = {stuff = "things", mostuff = "mothings"}
|
||||
queries = [["name","value"], ["rick", "astley"]]
|
||||
no_recursion = true
|
||||
add_slash = true
|
||||
stdin = true
|
||||
dont_filter = true
|
||||
extract_links = true
|
||||
json = true
|
||||
save_state = false
|
||||
depth = 1
|
||||
filter_size = [4120]
|
||||
filter_regex = ["^ignore me$"]
|
||||
filter_similar = ["https://somesite.com/soft404"]
|
||||
filter_word_count = [994, 992]
|
||||
filter_line_count = [34]
|
||||
filter_status = [201]
|
||||
"#;
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let file = tmp_dir.path().join(DEFAULT_CONFIG_NAME);
|
||||
write(&file, data).unwrap();
|
||||
Configuration::parse_config(file).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that all default config values meet expectations
|
||||
fn default_configuration() {
|
||||
let config = Configuration::default();
|
||||
assert_eq!(config.wordlist, wordlist());
|
||||
assert_eq!(config.proxy, String::new());
|
||||
assert_eq!(config.target_url, String::new());
|
||||
assert_eq!(config.time_limit, String::new());
|
||||
assert_eq!(config.resume_from, String::new());
|
||||
assert_eq!(config.debug_log, String::new());
|
||||
assert_eq!(config.config, String::new());
|
||||
assert_eq!(config.replay_proxy, String::new());
|
||||
assert_eq!(config.status_codes, status_codes());
|
||||
assert_eq!(config.replay_codes, config.status_codes);
|
||||
assert!(config.replay_client.is_none());
|
||||
assert_eq!(config.threads, threads());
|
||||
assert_eq!(config.depth, depth());
|
||||
assert_eq!(config.timeout, timeout());
|
||||
assert_eq!(config.verbosity, 0);
|
||||
assert_eq!(config.scan_limit, 0);
|
||||
assert!(!config.silent);
|
||||
assert!(!config.quiet);
|
||||
assert_eq!(config.output_level, OutputLevel::Default);
|
||||
assert!(!config.dont_filter);
|
||||
assert!(!config.auto_tune);
|
||||
assert!(!config.auto_bail);
|
||||
assert_eq!(config.requester_policy, RequesterPolicy::Default);
|
||||
assert!(!config.no_recursion);
|
||||
assert!(!config.random_agent);
|
||||
assert!(!config.json);
|
||||
assert!(config.save_state);
|
||||
assert!(!config.stdin);
|
||||
assert!(!config.add_slash);
|
||||
assert!(!config.redirects);
|
||||
assert!(!config.extract_links);
|
||||
assert!(!config.insecure);
|
||||
assert!(config.regex_denylist.is_empty());
|
||||
assert_eq!(config.queries, Vec::new());
|
||||
assert_eq!(config.filter_size, Vec::<u64>::new());
|
||||
assert_eq!(config.extensions, Vec::<String>::new());
|
||||
assert_eq!(config.methods, vec!["GET"]);
|
||||
assert_eq!(config.data, Vec::<u8>::new());
|
||||
assert_eq!(config.url_denylist, Vec::<Url>::new());
|
||||
assert_eq!(config.filter_regex, Vec::<String>::new());
|
||||
assert_eq!(config.filter_similar, Vec::<String>::new());
|
||||
assert_eq!(config.filter_word_count, Vec::<usize>::new());
|
||||
assert_eq!(config.filter_line_count, Vec::<usize>::new());
|
||||
assert_eq!(config.filter_status, Vec::<u16>::new());
|
||||
assert_eq!(config.headers, HashMap::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_wordlist() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.wordlist, "/some/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_debug_log() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.debug_log, "/yet/anotherpath");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_status_codes() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.status_codes, vec![201, 301, 401]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_replay_codes() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.replay_codes, vec![201, 301]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_threads() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.threads, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_depth() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.depth, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_scan_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.scan_limit, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_parallel() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.parallel, 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_rate_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.rate_limit, 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_timeout() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.timeout, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_proxy() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.proxy, "http://127.0.0.1:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_replay_proxy() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.replay_proxy, "http://127.0.0.1:8081");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_silent() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.silent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_quiet() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.quiet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_json() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_auto_bail() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.auto_bail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_auto_tune() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.auto_tune);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_verbosity() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.verbosity, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_output() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.output, "/some/otherpath");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_redirects() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.redirects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_insecure() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.insecure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_no_recursion() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.no_recursion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_stdin() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.stdin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_dont_filter() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.dont_filter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_add_slash() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.add_slash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_extract_links() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.extract_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_extensions() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.extensions, vec!["html", "php", "js"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_methods() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.methods, vec!["GET", "PUT", "DELETE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_data() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.data, vec![31, 32, 33, 34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_regex_denylist() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(
|
||||
config.regex_denylist[0].as_str(),
|
||||
Regex::new("/deny.*").unwrap().as_str()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_url_denylist() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(
|
||||
config.url_denylist,
|
||||
vec![
|
||||
Url::parse("http://dont-scan.me").unwrap(),
|
||||
Url::parse("https://also-not.me").unwrap(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_regex() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_regex, vec!["^ignore me$"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_similar() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_similar, vec!["https://somesite.com/soft404"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_size() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_size, vec![4120]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_word_count() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_word_count, vec![994, 992]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_line_count() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_line_count, vec![34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_status() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_status, vec![201]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_save_state() {
|
||||
let config = setup_config_test();
|
||||
assert!(!config.save_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_time_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.time_limit, "10m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_resume_from() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.resume_from, "/some/state/file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the values parsed are correct
|
||||
fn config_reads_headers() {
|
||||
let config = setup_config_test();
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("stuff".to_string(), "things".to_string());
|
||||
headers.insert("mostuff".to_string(), "mothings".to_string());
|
||||
assert_eq!(config.headers, headers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the values parsed are correct
|
||||
fn config_reads_queries() {
|
||||
let config = setup_config_test();
|
||||
let queries = vec![
|
||||
("name".to_string(), "value".to_string()),
|
||||
("rick".to_string(), "astley".to_string()),
|
||||
];
|
||||
assert_eq!(config.queries, queries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_default_not_random_agent() {
|
||||
let config = setup_config_test();
|
||||
assert!(!config.random_agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
/// test that an error message is printed and panic is called when report_and_exit is called
|
||||
fn config_report_and_exit_works() {
|
||||
report_and_exit("some message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_str method of Configuration
|
||||
fn as_str_returns_string_with_newline() {
|
||||
let config = Configuration::new().unwrap();
|
||||
let config_str = config.as_str();
|
||||
println!("{}", config_str);
|
||||
assert!(config_str.starts_with("Configuration {"));
|
||||
assert!(config_str.ends_with("}\n"));
|
||||
assert!(config_str.contains("replay_codes:"));
|
||||
assert!(config_str.contains("client: Client {"));
|
||||
assert!(config_str.contains("user_agent: \"feroxbuster"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_json method of Configuration
|
||||
fn as_json_returns_json_representation_of_configuration_with_newline() {
|
||||
let mut config = Configuration::new().unwrap();
|
||||
config.timeout = 12;
|
||||
config.depth = 2;
|
||||
let config_str = config.as_json().unwrap();
|
||||
let json: Configuration = serde_json::from_str(&config_str).unwrap();
|
||||
assert_eq!(json.config, config.config);
|
||||
assert_eq!(json.wordlist, config.wordlist);
|
||||
assert_eq!(json.replay_codes, config.replay_codes);
|
||||
assert_eq!(json.timeout, config.timeout);
|
||||
assert_eq!(json.depth, config.depth);
|
||||
}
|
||||
187
src/config/utils.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use crate::{
|
||||
utils::{module_colorizer, status_colorizer},
|
||||
DEFAULT_METHOD, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
use std::process::exit;
|
||||
|
||||
/// simple helper to clean up some code reuse below; panics under test / exits in prod
|
||||
pub(super) fn report_and_exit(err: &str) -> ! {
|
||||
eprintln!(
|
||||
"{} {}: {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("Configuration::new"),
|
||||
err
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
panic!();
|
||||
#[cfg(not(test))]
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// functions timeout, threads, status_codes, user_agent, wordlist, save_state, and depth are used to provide
|
||||
// defaults in the event that a ferox-config.toml is found but one or more of the values below
|
||||
// aren't listed in the config. This way, we get the correct defaults upon Deserialization
|
||||
|
||||
/// default Configuration type for use in json output
|
||||
pub(super) fn serialized_type() -> String {
|
||||
String::from("configuration")
|
||||
}
|
||||
|
||||
/// default timeout value
|
||||
pub(super) fn timeout() -> u64 {
|
||||
7
|
||||
}
|
||||
|
||||
/// default save_state value
|
||||
pub(super) fn save_state() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// default threads value
|
||||
pub(super) fn threads() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
/// default status codes
|
||||
pub(super) fn status_codes() -> Vec<u16> {
|
||||
DEFAULT_STATUS_CODES
|
||||
.iter()
|
||||
.map(|code| code.as_u16())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// default HTTP Method
|
||||
pub(super) fn methods() -> Vec<String> {
|
||||
vec![DEFAULT_METHOD.to_owned()]
|
||||
}
|
||||
|
||||
/// default wordlist
|
||||
pub(super) fn wordlist() -> String {
|
||||
String::from(DEFAULT_WORDLIST)
|
||||
}
|
||||
|
||||
/// default user-agent
|
||||
pub(super) fn user_agent() -> String {
|
||||
format!("feroxbuster/{}", VERSION)
|
||||
}
|
||||
|
||||
/// default recursion depth
|
||||
pub(super) fn depth() -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
/// enum representing the three possible states for informational output (not logging verbosity)
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum OutputLevel {
|
||||
/// normal scan, no --quiet|--silent
|
||||
Default,
|
||||
|
||||
/// quiet scan, print some information, but not all (new in versions >= 2.0.0)
|
||||
Quiet,
|
||||
|
||||
/// silent scan, only print urls (used to be --quiet in versions 1.x.x)
|
||||
Silent,
|
||||
}
|
||||
|
||||
/// implement a default for OutputLevel
|
||||
impl Default for OutputLevel {
|
||||
/// return Default
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// given the current settings for quiet and silent, determine output_level (DRY helper)
|
||||
pub fn determine_output_level(quiet: bool, silent: bool) -> OutputLevel {
|
||||
if quiet && silent {
|
||||
// user COULD have both as true in config file, take the more quiet of the two
|
||||
OutputLevel::Silent
|
||||
} else if quiet {
|
||||
OutputLevel::Quiet
|
||||
} else if silent {
|
||||
OutputLevel::Silent
|
||||
} else {
|
||||
OutputLevel::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// represents actions the Requester should take in certain situations
|
||||
#[derive(Debug, PartialEq, Copy, Clone)]
|
||||
pub enum RequesterPolicy {
|
||||
/// automatically try to lower request rate in order to reduce errors
|
||||
AutoTune,
|
||||
|
||||
/// automatically bail at certain error thresholds
|
||||
AutoBail,
|
||||
|
||||
/// just let that junk run super natural
|
||||
Default,
|
||||
}
|
||||
|
||||
/// default implementation for RequesterPolicy
|
||||
impl Default for RequesterPolicy {
|
||||
/// Default as default
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// given the current settings for quiet and silent, determine output_level (DRY helper)
|
||||
pub fn determine_requester_policy(auto_tune: bool, auto_bail: bool) -> RequesterPolicy {
|
||||
if auto_tune && auto_bail {
|
||||
// user COULD have both as true in config file, take the more aggressive of the two
|
||||
RequesterPolicy::AutoBail
|
||||
} else if auto_tune {
|
||||
RequesterPolicy::AutoTune
|
||||
} else if auto_bail {
|
||||
RequesterPolicy::AutoBail
|
||||
} else {
|
||||
RequesterPolicy::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// test determine_output_level returns higher of the two levels if both given values are true
|
||||
fn determine_output_level_returns_correct_results() {
|
||||
let mut level = determine_output_level(true, true);
|
||||
assert_eq!(level, OutputLevel::Silent);
|
||||
|
||||
level = determine_output_level(false, true);
|
||||
assert_eq!(level, OutputLevel::Silent);
|
||||
|
||||
level = determine_output_level(false, false);
|
||||
assert_eq!(level, OutputLevel::Default);
|
||||
|
||||
level = determine_output_level(true, false);
|
||||
assert_eq!(level, OutputLevel::Quiet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test determine_requester_policy returns higher of the two levels if both given values are true
|
||||
fn determine_requester_policy_returns_correct_results() {
|
||||
let mut level = determine_requester_policy(true, true);
|
||||
assert_eq!(level, RequesterPolicy::AutoBail);
|
||||
|
||||
level = determine_requester_policy(false, true);
|
||||
assert_eq!(level, RequesterPolicy::AutoBail);
|
||||
|
||||
level = determine_requester_policy(false, false);
|
||||
assert_eq!(level, RequesterPolicy::Default);
|
||||
|
||||
level = determine_requester_policy(true, false);
|
||||
assert_eq!(level, RequesterPolicy::AutoTune);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
/// report_and_exit should panic/exit when called
|
||||
fn report_and_exit_panics_under_test() {
|
||||
report_and_exit("test");
|
||||
}
|
||||
}
|
||||
71
src/event_handlers/command.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use tokio::sync::oneshot::Sender;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::{
|
||||
statistics::{StatError, StatField},
|
||||
traits::FeroxFilter,
|
||||
};
|
||||
|
||||
/// Protocol definition for updating an event handler via mpsc
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
/// Add one to the total number of requests
|
||||
AddRequest,
|
||||
|
||||
/// Add one to the proper field(s) based on the given `StatError`
|
||||
AddError(StatError),
|
||||
|
||||
/// Add one to the proper field(s) based on the given `StatusCode`
|
||||
AddStatus(StatusCode),
|
||||
|
||||
/// Create the progress bar (`BarType::Total`) that is updated from the stats thread
|
||||
CreateBar,
|
||||
|
||||
/// Add to a `Stats` field that corresponds to the given `StatField` by the given `usize` value
|
||||
AddToUsizeField(StatField, usize),
|
||||
|
||||
/// Subtract from a `Stats` field that corresponds to the given `StatField` by the given `usize` value
|
||||
SubtractFromUsizeField(StatField, usize),
|
||||
|
||||
/// Update a `Stats` field that corresponds to the given `StatField` by the given `f64` value
|
||||
AddToF64Field(StatField, f64),
|
||||
|
||||
/// Save a `Stats` object to disk using `reporter::get_cached_file_handle`
|
||||
Save,
|
||||
|
||||
/// Load a `Stats` object from disk
|
||||
LoadStats(String),
|
||||
|
||||
/// Add a `FeroxFilter` implementor to `FilterHandler`'s instance of `FeroxFilters`
|
||||
AddFilter(Box<dyn FeroxFilter>),
|
||||
|
||||
/// Send a `FeroxResponse` to the output handler for reporting
|
||||
Report(Box<FeroxResponse>),
|
||||
|
||||
/// Send a group of urls to be scanned (only used for the urls passed in explicitly by the user)
|
||||
ScanInitialUrls(Vec<String>),
|
||||
|
||||
/// Send a single url to be scanned (presumably added from the interactive menu)
|
||||
ScanNewUrl(String),
|
||||
|
||||
/// Determine whether or not recursion is appropriate, given a FeroxResponse, if so start a scan
|
||||
TryRecursion(Box<FeroxResponse>),
|
||||
|
||||
/// Send a pointer to the wordlist to the recursion handler
|
||||
UpdateWordlist(Arc<Vec<String>>),
|
||||
|
||||
/// Instruct the ScanHandler to join on all known scans, use sender to notify main when done
|
||||
JoinTasks(Sender<bool>),
|
||||
|
||||
/// Command used to test that a spawned task succeeded in initialization
|
||||
Ping,
|
||||
|
||||
/// Just receive a sender and reply, used for slowing down the main thread
|
||||
Sync(Sender<bool>),
|
||||
|
||||
/// Break out of the (infinite) mpsc receive loop
|
||||
Exit,
|
||||
}
|
||||
129
src/event_handlers/container.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use super::*;
|
||||
use crate::config::Configuration;
|
||||
use crate::event_handlers::scans::ScanHandle;
|
||||
use crate::scan_manager::FeroxScans;
|
||||
use crate::Joiner;
|
||||
#[cfg(test)]
|
||||
use crate::{filters::FeroxFilters, statistics::Stats, Command};
|
||||
use anyhow::{bail, Result};
|
||||
use std::sync::{Arc, RwLock};
|
||||
#[cfg(test)]
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Simple container for multiple JoinHandles
|
||||
pub struct Tasks {
|
||||
/// JoinHandle for terminal handler
|
||||
pub terminal: Joiner,
|
||||
|
||||
/// JoinHandle for statistics handler
|
||||
pub stats: Joiner,
|
||||
|
||||
/// JoinHandle for filters handler
|
||||
pub filters: Joiner,
|
||||
|
||||
/// JoinHandle for scans handler
|
||||
pub scans: Joiner,
|
||||
}
|
||||
|
||||
/// Tasks implementation
|
||||
impl Tasks {
|
||||
/// Given JoinHandles for terminal, statistics, and filters create a new Tasks object
|
||||
pub fn new(terminal: Joiner, stats: Joiner, filters: Joiner, scans: Joiner) -> Self {
|
||||
Self {
|
||||
terminal,
|
||||
stats,
|
||||
filters,
|
||||
scans,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for the different *Handles that will be shared across modules
|
||||
pub struct Handles {
|
||||
/// Handle for statistics
|
||||
pub stats: StatsHandle,
|
||||
|
||||
/// Handle for filters
|
||||
pub filters: FiltersHandle,
|
||||
|
||||
/// Handle for output (terminal/file)
|
||||
pub output: TermOutHandle,
|
||||
|
||||
/// Handle for Configuration
|
||||
pub config: Arc<Configuration>,
|
||||
|
||||
/// Handle for recursion
|
||||
pub scans: RwLock<Option<ScanHandle>>,
|
||||
}
|
||||
|
||||
/// implementation of Handles
|
||||
impl Handles {
|
||||
/// Given a StatsHandle, FiltersHandle, and OutputHandle, create a Handles object
|
||||
pub fn new(
|
||||
stats: StatsHandle,
|
||||
filters: FiltersHandle,
|
||||
output: TermOutHandle,
|
||||
config: Arc<Configuration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
stats,
|
||||
filters,
|
||||
output,
|
||||
config,
|
||||
scans: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// create a Handles object suitable for unit testing (non-functional)
|
||||
#[cfg(test)]
|
||||
pub fn for_testing(
|
||||
scanned_urls: Option<Arc<FeroxScans>>,
|
||||
config: Option<Arc<Configuration>>,
|
||||
) -> (Self, UnboundedReceiver<Command>) {
|
||||
let configuration = config.unwrap_or_else(|| Arc::new(Configuration::new().unwrap()));
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let terminal_handle = TermOutHandle::new(tx.clone(), tx.clone());
|
||||
let stats_handle = StatsHandle::new(Arc::new(Stats::new(configuration.json)), tx.clone());
|
||||
let filters_handle = FiltersHandle::new(Arc::new(FeroxFilters::default()), tx.clone());
|
||||
let handles = Self::new(stats_handle, filters_handle, terminal_handle, configuration);
|
||||
if let Some(sh) = scanned_urls {
|
||||
let scan_handle = ScanHandle::new(sh, tx);
|
||||
handles.set_scan_handle(scan_handle);
|
||||
}
|
||||
(handles, rx)
|
||||
}
|
||||
|
||||
/// Set the ScanHandle object
|
||||
pub fn set_scan_handle(&self, handle: ScanHandle) {
|
||||
if let Ok(mut guard) = self.scans.write() {
|
||||
if guard.is_none() {
|
||||
let _ = std::mem::replace(&mut *guard, Some(handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to easily send a Command over the (locked) underlying CommandSender object
|
||||
pub fn send_scan_command(&self, command: Command) -> Result<()> {
|
||||
if let Ok(guard) = self.scans.read().as_ref() {
|
||||
if let Some(handle) = guard.as_ref() {
|
||||
handle.send(command)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Could not get underlying CommandSender object")
|
||||
}
|
||||
|
||||
/// Helper to easily get the (locked) underlying FeroxScans object
|
||||
pub fn ferox_scans(&self) -> Result<Arc<FeroxScans>> {
|
||||
if let Ok(guard) = self.scans.read().as_ref() {
|
||||
if let Some(handle) = guard.as_ref() {
|
||||
return Ok(handle.data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Could not get underlying FeroxScans")
|
||||
}
|
||||
}
|
||||
101
src/event_handlers/filters.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use super::*;
|
||||
use crate::{filters::FeroxFilters, CommandSender, FeroxChannel, Joiner};
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{
|
||||
mpsc::{self, UnboundedReceiver},
|
||||
oneshot,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for filters transmitter and FeroxFilters object
|
||||
pub struct FiltersHandle {
|
||||
/// FeroxFilters object used across modules to track active filters
|
||||
pub data: Arc<FeroxFilters>,
|
||||
|
||||
/// transmitter used to update `data`
|
||||
pub tx: CommandSender,
|
||||
}
|
||||
|
||||
/// implementation of FiltersHandle
|
||||
impl FiltersHandle {
|
||||
/// Given an Arc-wrapped FeroxFilters and CommandSender, create a new FiltersHandle
|
||||
pub fn new(data: Arc<FeroxFilters>, tx: CommandSender) -> Self {
|
||||
Self { data, tx }
|
||||
}
|
||||
|
||||
/// Send the given Command over `tx`
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.tx.send(command)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync the handle with the handler
|
||||
pub async fn sync(&self) -> Result<()> {
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.send(Command::Sync(tx))?;
|
||||
rx.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// event handler for updating a single data structure of all active filters
|
||||
#[derive(Debug)]
|
||||
pub struct FiltersHandler {
|
||||
/// collection of FeroxFilters
|
||||
data: Arc<FeroxFilters>,
|
||||
|
||||
/// Receiver half of mpsc from which `Command`s are processed
|
||||
receiver: UnboundedReceiver<Command>,
|
||||
}
|
||||
|
||||
/// implementation of event handler for filters
|
||||
impl FiltersHandler {
|
||||
/// create new event handler
|
||||
pub fn new(data: Arc<FeroxFilters>, receiver: UnboundedReceiver<Command>) -> Self {
|
||||
Self { data, receiver }
|
||||
}
|
||||
|
||||
/// Initialize new `FeroxFilters` and the sc side of an mpsc channel that is responsible for
|
||||
/// updates to the aforementioned object.
|
||||
pub fn initialize() -> (Joiner, FiltersHandle) {
|
||||
log::trace!("enter: initialize");
|
||||
|
||||
let data = Arc::new(FeroxFilters::default());
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let mut handler = Self::new(data.clone(), rx);
|
||||
|
||||
let task = tokio::spawn(async move { handler.start().await });
|
||||
|
||||
let event_handle = FiltersHandle::new(data, tx);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
|
||||
|
||||
(task, event_handle)
|
||||
}
|
||||
|
||||
/// Start a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives `Command` and acts accordingly
|
||||
pub async fn start(&mut self) -> Result<()> {
|
||||
log::trace!("enter: start({:?})", self);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::AddFilter(filter) => {
|
||||
self.data.push(filter)?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
log::debug!("filters: {:?}", self);
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::Exit => break,
|
||||
_ => {} // no other commands needed for FilterHandler
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
144
src/event_handlers/inputs.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
progress::PROGRESS_PRINTER,
|
||||
scan_manager::{FeroxState, PAUSE_SCAN},
|
||||
scanner::RESPONSES,
|
||||
statistics::StatError,
|
||||
utils::slugify_filename,
|
||||
utils::{open_file, write_to},
|
||||
SLEEP_DURATION,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use console::style;
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// Atomic boolean flag, used to determine whether or not the terminal input handler should exit
|
||||
pub static SCAN_COMPLETE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Container for filters transmitter and FeroxFilters object
|
||||
pub struct TermInputHandler {
|
||||
/// handles to other handlers
|
||||
handles: Arc<Handles>,
|
||||
}
|
||||
|
||||
/// implementation of event handler for terminal input
|
||||
///
|
||||
/// kicks off the following handlers related to terminal input:
|
||||
/// ctrl+c handler that saves scan state to disk
|
||||
/// enter handler that listens for enter during scans to drop into interactive scan management menu
|
||||
impl TermInputHandler {
|
||||
/// Create new event handler
|
||||
pub fn new(handles: Arc<Handles>) -> Self {
|
||||
Self { handles }
|
||||
}
|
||||
|
||||
/// Initialize the sigint and enter handlers that are responsible for handling initial user
|
||||
/// interaction during scans
|
||||
pub fn initialize(handles: Arc<Handles>) {
|
||||
log::trace!("enter: initialize({:?})", handles);
|
||||
|
||||
let handler = Self::new(handles);
|
||||
handler.start();
|
||||
|
||||
log::trace!("exit: initialize");
|
||||
}
|
||||
|
||||
/// wrapper around sigint_handler and enter_handler
|
||||
fn start(&self) {
|
||||
tokio::task::spawn_blocking(Self::enter_handler);
|
||||
|
||||
if self.handles.config.save_state {
|
||||
// start the ctrl+c handler
|
||||
let cloned = self.handles.clone();
|
||||
|
||||
let result = ctrlc::set_handler(move || {
|
||||
let _ = Self::sigint_handler(cloned.clone());
|
||||
});
|
||||
|
||||
if result.is_err() {
|
||||
log::warn!("Could not set Ctrl+c handler; scan state will not be saved");
|
||||
self.handles
|
||||
.stats
|
||||
.send(Command::AddError(StatError::Other))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the current state of the program to disk (if save_state is true) and then exits
|
||||
pub fn sigint_handler(handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: sigint_handler({:?})", handles);
|
||||
|
||||
let filename = if !handles.config.target_url.is_empty() {
|
||||
// target url populated
|
||||
slugify_filename(&handles.config.target_url, "ferox", "state")
|
||||
} else {
|
||||
// stdin used
|
||||
slugify_filename("stdin", "ferox", "state")
|
||||
};
|
||||
|
||||
let warning = format!(
|
||||
"🚨 Caught {} 🚨 saving scan state to {} ...",
|
||||
style("ctrl+c").yellow(),
|
||||
filename
|
||||
);
|
||||
|
||||
PROGRESS_PRINTER.println(warning);
|
||||
|
||||
let state = FeroxState::new(
|
||||
handles.ferox_scans()?,
|
||||
handles.config.clone(),
|
||||
&RESPONSES,
|
||||
handles.stats.data.clone(),
|
||||
);
|
||||
|
||||
let state_file = open_file(&filename);
|
||||
|
||||
let mut buffered_file = state_file?;
|
||||
write_to(&state, &mut buffered_file, true)?;
|
||||
|
||||
log::trace!("exit: sigint_handler (end of program)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
/// Handles specific key events triggered by the user over stdin
|
||||
fn enter_handler() {
|
||||
// todo eventually move away from atomics, the blocking recv is the problem
|
||||
log::trace!("enter: start_enter_handler");
|
||||
|
||||
loop {
|
||||
if PAUSE_SCAN.load(Ordering::Relaxed) {
|
||||
// if the scan is already paused, we don't want this event poller fighting the user
|
||||
// over stdin
|
||||
sleep(Duration::from_millis(SLEEP_DURATION));
|
||||
} else if event::poll(Duration::from_millis(SLEEP_DURATION)).unwrap_or(false) {
|
||||
// It's guaranteed that the `read()` won't block when the `poll()`
|
||||
// function returns `true`
|
||||
|
||||
if let Ok(key_pressed) = event::read() {
|
||||
// ignore any other keys
|
||||
if key_pressed == Event::Key(KeyCode::Enter.into()) {
|
||||
// if the user presses Enter, set PAUSE_SCAN to true. The interactive menu
|
||||
// will be triggered and will handle setting PAUSE_SCAN to false
|
||||
PAUSE_SCAN.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Timeout expired and no `Event` is available; use the timeout to check SCAN_COMPLETE
|
||||
if SCAN_COMPLETE.load(Ordering::Relaxed) {
|
||||
// scan has been marked complete by main, time to exit the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("exit: start_enter_handler");
|
||||
}
|
||||
}
|
||||
16
src/event_handlers/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! collection of event handlers (typically long-running tokio spawned tasks)
|
||||
mod statistics;
|
||||
mod filters;
|
||||
mod container;
|
||||
mod command;
|
||||
mod outputs;
|
||||
mod scans;
|
||||
mod inputs;
|
||||
|
||||
pub use self::command::Command;
|
||||
pub use self::container::{Handles, Tasks};
|
||||
pub use self::filters::{FiltersHandle, FiltersHandler};
|
||||
pub use self::inputs::{TermInputHandler, SCAN_COMPLETE};
|
||||
pub use self::outputs::{TermOutHandle, TermOutHandler};
|
||||
pub use self::scans::{ScanHandle, ScanHandler};
|
||||
pub use self::statistics::{StatsHandle, StatsHandler};
|
||||
289
src/event_handlers/outputs.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use super::Command::AddToUsizeField;
|
||||
use super::*;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
progress::PROGRESS_PRINTER,
|
||||
scanner::RESPONSES,
|
||||
send_command, skip_fail,
|
||||
statistics::StatField::ResourcesDiscovered,
|
||||
traits::FeroxSerialize,
|
||||
utils::{ferox_print, fmt_err, make_request, open_file, write_to},
|
||||
CommandReceiver, CommandSender, Joiner,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for terminal output transmitter
|
||||
pub struct TermOutHandle {
|
||||
/// Transmitter that sends to the TermOutHandler handler
|
||||
pub tx: CommandSender,
|
||||
|
||||
/// Transmitter that sends to the FileOutHandler handler
|
||||
pub tx_file: CommandSender,
|
||||
}
|
||||
|
||||
/// implementation of OutputHandle
|
||||
impl TermOutHandle {
|
||||
/// Given a CommandSender, create a new OutputHandle
|
||||
pub fn new(tx: CommandSender, tx_file: CommandSender) -> Self {
|
||||
Self { tx, tx_file }
|
||||
}
|
||||
|
||||
/// Send the given Command over `tx`
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.tx.send(command)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync the handle with the handler
|
||||
pub async fn sync(&self, send_to_file: bool) -> Result<()> {
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.send(Command::Sync(tx))?;
|
||||
|
||||
if send_to_file {
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.tx_file.send(Command::Sync(tx))?;
|
||||
rx.await?;
|
||||
}
|
||||
|
||||
rx.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Event handler for files
|
||||
pub struct FileOutHandler {
|
||||
/// file output handler's receiver
|
||||
receiver: CommandReceiver,
|
||||
|
||||
/// pointer to "global" configuration struct
|
||||
config: Arc<Configuration>,
|
||||
}
|
||||
|
||||
impl FileOutHandler {
|
||||
/// Given a file tx/rx pair along with a filename and awaitable task, create
|
||||
/// a FileOutHandler
|
||||
fn new(rx: CommandReceiver, config: Arc<Configuration>) -> Self {
|
||||
Self {
|
||||
receiver: rx,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives responses from the terminal handler and writes them to disk
|
||||
async fn start(&mut self, tx_stats: CommandSender) -> Result<()> {
|
||||
log::trace!("enter: start_file_handler({:?})", tx_stats);
|
||||
|
||||
let mut file = open_file(&self.config.output)?;
|
||||
|
||||
log::info!("Writing scan results to {}", self.config.output);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::Report(response) => {
|
||||
skip_fail!(write_to(&*response, &mut file, self.config.json));
|
||||
}
|
||||
Command::Exit => {
|
||||
break;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
skip_fail!(sender.send(true));
|
||||
}
|
||||
_ => {} // no more needed
|
||||
}
|
||||
}
|
||||
|
||||
// close the file before we tell statistics to save current data to the same file
|
||||
drop(file);
|
||||
|
||||
send_command!(tx_stats, Command::Save);
|
||||
|
||||
log::trace!("exit: start_file_handler");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Event handler for terminal
|
||||
pub struct TermOutHandler {
|
||||
/// terminal output handler's receiver
|
||||
receiver: CommandReceiver,
|
||||
|
||||
/// file handler
|
||||
tx_file: CommandSender,
|
||||
|
||||
/// optional file handler task
|
||||
file_task: Option<Joiner>,
|
||||
|
||||
/// pointer to "global" configuration struct
|
||||
config: Arc<Configuration>,
|
||||
}
|
||||
|
||||
/// implementation of TermOutHandler
|
||||
impl TermOutHandler {
|
||||
/// Given a terminal receiver along with a file transmitter and filename, create
|
||||
/// an OutputHandler
|
||||
fn new(
|
||||
receiver: CommandReceiver,
|
||||
tx_file: CommandSender,
|
||||
file_task: Option<Joiner>,
|
||||
config: Arc<Configuration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
tx_file,
|
||||
file_task,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates all required output handlers (terminal, file) and updates the given Handles/Tasks
|
||||
pub fn initialize(
|
||||
config: Arc<Configuration>,
|
||||
tx_stats: CommandSender,
|
||||
) -> (Joiner, TermOutHandle) {
|
||||
log::trace!("enter: initialize({:?}, {:?})", config, tx_stats);
|
||||
|
||||
let (tx_term, rx_term) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, rx_file) = mpsc::unbounded_channel::<Command>();
|
||||
|
||||
let mut file_handler = FileOutHandler::new(rx_file, config.clone());
|
||||
|
||||
let tx_stats_clone = tx_stats.clone();
|
||||
|
||||
let file_task = if !config.output.is_empty() {
|
||||
// -o used, need to spawn the thread for writing to disk
|
||||
Some(tokio::spawn(async move {
|
||||
file_handler.start(tx_stats_clone).await
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut term_handler = Self::new(rx_term, tx_file.clone(), file_task, config);
|
||||
let term_task = tokio::spawn(async move { term_handler.start(tx_stats).await });
|
||||
|
||||
let event_handle = TermOutHandle::new(tx_term, tx_file);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", term_task, event_handle);
|
||||
|
||||
(term_task, event_handle)
|
||||
}
|
||||
|
||||
/// Start a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives `Command` and acts accordingly
|
||||
async fn start(&mut self, tx_stats: CommandSender) -> Result<()> {
|
||||
log::trace!("enter: start({:?})", tx_stats);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::Report(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(),
|
||||
resp.method().as_str(),
|
||||
None,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Could not replay request through replay proxy")?;
|
||||
}
|
||||
|
||||
if should_process_response {
|
||||
// add response to RESPONSES for serialization in case of ctrl+c
|
||||
// placed all by its lonesome like this so that RESPONSES can take ownership
|
||||
// of the FeroxResponse
|
||||
|
||||
// before ownership is transferred, there's no real reason to keep the body anymore
|
||||
// so we can free that piece of data, reducing memory usage
|
||||
resp.drop_text();
|
||||
|
||||
RESPONSES.insert(*resp);
|
||||
}
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::Exit => {
|
||||
if self.file_task.is_some() && self.tx_file.send(Command::Exit).is_ok() {
|
||||
self.file_task.as_mut().unwrap().await??; // wait for death
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {} // no more commands needed
|
||||
}
|
||||
}
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// try to hit struct field coverage of FileOutHandler
|
||||
fn struct_fields_of_file_out_handler() {
|
||||
let (_, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
let foh = FileOutHandler {
|
||||
config,
|
||||
receiver: rx,
|
||||
};
|
||||
println!("{:?}", foh);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// try to hit struct field coverage of TermOutHandler
|
||||
async fn struct_fields_of_term_out_handler() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
};
|
||||
|
||||
println!("{:?}", toh);
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
}
|
||||
292
src/event_handlers/scans.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use tokio::sync::{mpsc, Semaphore};
|
||||
|
||||
use crate::{
|
||||
response::FeroxResponse,
|
||||
scan_manager::{FeroxScan, FeroxScans, ScanOrder},
|
||||
scanner::FeroxScanner,
|
||||
statistics::StatField::TotalScans,
|
||||
url::FeroxUrl,
|
||||
utils::should_deny_url,
|
||||
CommandReceiver, CommandSender, FeroxChannel, Joiner, SLEEP_DURATION,
|
||||
};
|
||||
|
||||
use super::command::Command::AddToUsizeField;
|
||||
use super::*;
|
||||
use reqwest::Url;
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for recursion transmitter and FeroxScans object
|
||||
pub struct ScanHandle {
|
||||
/// FeroxScans object used across modules to track scans
|
||||
pub data: Arc<FeroxScans>,
|
||||
|
||||
/// transmitter used to update `data`
|
||||
pub tx: CommandSender,
|
||||
}
|
||||
|
||||
/// implementation of RecursionHandle
|
||||
impl ScanHandle {
|
||||
/// Given an Arc-wrapped FeroxScans and CommandSender, create a new RecursionHandle
|
||||
pub fn new(data: Arc<FeroxScans>, tx: CommandSender) -> Self {
|
||||
Self { data, tx }
|
||||
}
|
||||
|
||||
/// Send the given Command over `tx`
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.tx.send(command)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// event handler for updating a single data structure of all FeroxScans
|
||||
#[derive(Debug)]
|
||||
pub struct ScanHandler {
|
||||
/// collection of FeroxScans
|
||||
data: Arc<FeroxScans>,
|
||||
|
||||
/// handles to other handlers needed to kick off a scan while already past main
|
||||
handles: Arc<Handles>,
|
||||
|
||||
/// Receiver half of mpsc from which `Command`s are processed
|
||||
receiver: CommandReceiver,
|
||||
|
||||
/// wordlist (re)used for each scan
|
||||
wordlist: std::sync::Mutex<Option<Arc<Vec<String>>>>,
|
||||
|
||||
/// group of scans that need to be joined
|
||||
tasks: Vec<Arc<FeroxScan>>,
|
||||
|
||||
/// Maximum recursion depth, a depth of 0 is infinite recursion
|
||||
max_depth: usize,
|
||||
|
||||
/// depths associated with the initial targets provided by the user
|
||||
depths: Vec<(String, usize)>,
|
||||
|
||||
/// Bounded semaphore used as a barrier to limit concurrent scans
|
||||
limiter: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
/// implementation of event handler for filters
|
||||
impl ScanHandler {
|
||||
/// create new event handler
|
||||
pub fn new(
|
||||
data: Arc<FeroxScans>,
|
||||
handles: Arc<Handles>,
|
||||
max_depth: usize,
|
||||
receiver: CommandReceiver,
|
||||
) -> Self {
|
||||
let limit = handles.config.scan_limit;
|
||||
let limiter = Semaphore::new(limit);
|
||||
|
||||
if limit == 0 {
|
||||
// scan_limit == 0 means no limit should be imposed... however, scoping the Semaphore
|
||||
// permit is tricky, so as a workaround, we'll add a ridiculous number of permits to
|
||||
// the semaphore (1,152,921,504,606,846,975 to be exact) and call that 'unlimited'
|
||||
|
||||
// note to self: the docs say max is usize::MAX >> 3, however, threads will panic if
|
||||
// that value is used (says adding (1) will overflow the semaphore, even though none
|
||||
// are being added...)
|
||||
limiter.add_permits(usize::MAX >> 4);
|
||||
}
|
||||
|
||||
Self {
|
||||
data,
|
||||
handles,
|
||||
receiver,
|
||||
max_depth,
|
||||
tasks: Vec::new(),
|
||||
depths: Vec::new(),
|
||||
limiter: Arc::new(limiter),
|
||||
wordlist: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the wordlist
|
||||
fn wordlist(&self, wordlist: Arc<Vec<String>>) {
|
||||
if let Ok(mut guard) = self.wordlist.lock() {
|
||||
if guard.is_none() {
|
||||
let _ = std::mem::replace(&mut *guard, Some(wordlist));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize new `FeroxScans` and the sc side of an mpsc channel that is responsible for
|
||||
/// updates to the aforementioned object.
|
||||
pub fn initialize(handles: Arc<Handles>) -> (Joiner, ScanHandle) {
|
||||
log::trace!("enter: initialize");
|
||||
|
||||
let data = Arc::new(FeroxScans::new(handles.config.output_level));
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let max_depth = handles.config.depth;
|
||||
|
||||
let mut handler = Self::new(data.clone(), handles, max_depth, rx);
|
||||
|
||||
let task = tokio::spawn(async move { handler.start().await });
|
||||
|
||||
let event_handle = ScanHandle::new(data, tx);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
|
||||
|
||||
(task, event_handle)
|
||||
}
|
||||
|
||||
/// Start a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives `Command` and acts accordingly
|
||||
pub async fn start(&mut self) -> Result<()> {
|
||||
log::trace!("enter: start({:?})", self);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::ScanInitialUrls(targets) => {
|
||||
self.ordered_scan_url(targets, ScanOrder::Initial).await?;
|
||||
}
|
||||
Command::ScanNewUrl(target) => {
|
||||
// added as part of interactive menu ability (2.4.1) to add a new scan.
|
||||
// we don't have a way of knowing if they're adding a new url entirely (i.e.
|
||||
// new base url), or simply adding a new sub-directory found some other way.
|
||||
// Since we can't know, we'll start a scan as though we received the scan
|
||||
// from -u | --stdin
|
||||
self.ordered_scan_url(vec![target], ScanOrder::Initial)
|
||||
.await?;
|
||||
}
|
||||
Command::UpdateWordlist(wordlist) => {
|
||||
self.wordlist(wordlist);
|
||||
}
|
||||
Command::JoinTasks(sender) => {
|
||||
let ferox_scans = self.handles.ferox_scans().unwrap_or_default();
|
||||
let limiter_clone = self.limiter.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while ferox_scans.has_active_scans() {
|
||||
tokio::time::sleep(Duration::from_millis(SLEEP_DURATION + 250)).await;
|
||||
}
|
||||
limiter_clone.close();
|
||||
sender.send(true).expect("oneshot channel failed");
|
||||
});
|
||||
}
|
||||
Command::TryRecursion(response) => {
|
||||
self.try_recursion(response).await?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
_ => {} // no other commands needed for RecursionHandler
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to easily get the (locked) underlying wordlist
|
||||
pub fn get_wordlist(&self) -> Result<Arc<Vec<String>>> {
|
||||
if let Ok(guard) = self.wordlist.lock().as_ref() {
|
||||
if let Some(list) = guard.as_ref() {
|
||||
return Ok(list.clone());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Could not get underlying wordlist")
|
||||
}
|
||||
|
||||
/// wrapper around scanning a url to stay DRY
|
||||
async fn ordered_scan_url(&mut self, targets: Vec<String>, order: ScanOrder) -> Result<()> {
|
||||
log::trace!("enter: ordered_scan_url({:?}, {:?})", targets, order);
|
||||
let should_test_deny = !self.handles.config.url_denylist.is_empty()
|
||||
|| !self.handles.config.regex_denylist.is_empty();
|
||||
|
||||
for target in targets {
|
||||
if self.data.contains(&target) && matches!(order, ScanOrder::Latest) {
|
||||
// FeroxScans knows about this url and scan isn't an Initial scan
|
||||
// initial scans are skipped because when resuming from a .state file, the scans
|
||||
// will already be populated in FeroxScans, so we need to not skip kicking off
|
||||
// their scans
|
||||
continue;
|
||||
}
|
||||
|
||||
let scan = if let Some(ferox_scan) = self.data.get_scan_by_url(&target) {
|
||||
ferox_scan // scan already known
|
||||
} else {
|
||||
self.data.add_directory_scan(&target, order).1 // add the new target; return FeroxScan
|
||||
};
|
||||
|
||||
if should_test_deny && should_deny_url(&Url::parse(&target)?, self.handles.clone())? {
|
||||
// response was caught by a user-provided deny list
|
||||
// checking this last, since it's most susceptible to longer runtimes due to what
|
||||
// input is received
|
||||
continue;
|
||||
}
|
||||
|
||||
let list = self.get_wordlist()?;
|
||||
|
||||
log::info!("scan handler received {} - beginning scan", target);
|
||||
|
||||
if matches!(order, ScanOrder::Initial) {
|
||||
// keeps track of the initial targets' scan depths in order to enforce the
|
||||
// maximum recursion depth on any identified sub-directories
|
||||
let url = FeroxUrl::from_string(&target, self.handles.clone());
|
||||
let depth = url.depth().unwrap_or(0);
|
||||
self.depths.push((target.clone(), depth));
|
||||
}
|
||||
|
||||
let scanner = FeroxScanner::new(
|
||||
&target,
|
||||
order,
|
||||
list,
|
||||
self.limiter.clone(),
|
||||
self.handles.clone(),
|
||||
);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(e) = scanner.scan_url().await {
|
||||
log::warn!("{}", e);
|
||||
}
|
||||
});
|
||||
|
||||
self.handles.stats.send(AddToUsizeField(TotalScans, 1))?;
|
||||
|
||||
scan.set_task(task).await?;
|
||||
|
||||
self.tasks.push(scan.clone());
|
||||
}
|
||||
|
||||
log::trace!("exit: ordered_scan_url");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_recursion(&mut self, response: Box<FeroxResponse>) -> Result<()> {
|
||||
log::trace!("enter: try_recursion({:?})", response,);
|
||||
|
||||
if !response.is_directory() {
|
||||
// not a directory, quick exit
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut base_depth = 1_usize;
|
||||
|
||||
for (base_url, base_url_depth) in &self.depths {
|
||||
if response.url().as_str().starts_with(base_url) {
|
||||
base_depth = *base_url_depth;
|
||||
}
|
||||
}
|
||||
|
||||
if response.reached_max_depth(base_depth, self.max_depth, self.handles.clone()) {
|
||||
// at or past recursion depth
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let targets = vec![response.url().to_string()];
|
||||
self.ordered_scan_url(targets, ScanOrder::Latest).await?;
|
||||
|
||||
log::info!("Added new directory to recursive scan: {}", response.url());
|
||||
|
||||
log::trace!("exit: try_recursion");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
171
src/event_handlers/statistics.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
progress::{add_bar, BarType},
|
||||
statistics::{StatField, Stats},
|
||||
CommandSender, FeroxChannel, Joiner,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use console::style;
|
||||
use indicatif::ProgressBar;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use tokio::sync::{
|
||||
mpsc::{self, UnboundedReceiver},
|
||||
oneshot,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for statistics transmitter and Stats object
|
||||
pub struct StatsHandle {
|
||||
/// Stats object used across modules to track statistics
|
||||
pub data: Arc<Stats>,
|
||||
|
||||
/// transmitter used to update `data`
|
||||
pub tx: CommandSender,
|
||||
}
|
||||
|
||||
/// implementation of StatsHandle
|
||||
impl StatsHandle {
|
||||
/// Given an Arc-wrapped Stats and CommandSender, create a new StatsHandle
|
||||
pub fn new(data: Arc<Stats>, tx: CommandSender) -> Self {
|
||||
Self { data, tx }
|
||||
}
|
||||
|
||||
/// Send the given Command over `tx`
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.tx.send(command)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync the handle with the handler
|
||||
pub async fn sync(&self) -> Result<()> {
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.send(Command::Sync(tx))?;
|
||||
rx.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// event handler struct for updating statistics
|
||||
#[derive(Debug)]
|
||||
pub struct StatsHandler {
|
||||
/// overall scan's progress bar
|
||||
bar: ProgressBar,
|
||||
|
||||
/// Receiver half of mpsc from which `StatCommand`s are processed
|
||||
receiver: UnboundedReceiver<Command>,
|
||||
|
||||
/// data class that stores all statistics updates
|
||||
stats: Arc<Stats>,
|
||||
}
|
||||
|
||||
/// implementation of event handler for statistics
|
||||
impl StatsHandler {
|
||||
/// create new event handler
|
||||
fn new(stats: Arc<Stats>, rx_stats: UnboundedReceiver<Command>) -> Self {
|
||||
// will be updated later via StatCommand; delay is for banner to print first
|
||||
let bar = ProgressBar::hidden();
|
||||
|
||||
Self {
|
||||
bar,
|
||||
stats,
|
||||
receiver: rx_stats,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives `StatCommands` and updates the given `Stats` object as appropriate
|
||||
async fn start(&mut self, output_file: &str) -> Result<()> {
|
||||
log::trace!("enter: start({:?})", self);
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command as Command {
|
||||
Command::AddError(err) => {
|
||||
self.stats.add_error(err);
|
||||
self.increment_bar();
|
||||
}
|
||||
Command::AddStatus(status) => {
|
||||
self.stats.add_status_code(status);
|
||||
|
||||
self.increment_bar();
|
||||
}
|
||||
Command::AddRequest => {
|
||||
self.stats.add_request();
|
||||
self.increment_bar();
|
||||
}
|
||||
Command::Save => {
|
||||
self.stats
|
||||
.save(start.elapsed().as_secs_f64(), output_file)?;
|
||||
}
|
||||
Command::AddToUsizeField(field, value) => {
|
||||
self.stats.update_usize_field(field, value);
|
||||
|
||||
if matches!(field, StatField::TotalScans) {
|
||||
self.bar.set_length(self.stats.total_expected() as u64);
|
||||
}
|
||||
}
|
||||
Command::SubtractFromUsizeField(field, value) => {
|
||||
self.stats.subtract_from_usize_field(field, value);
|
||||
|
||||
if matches!(field, StatField::TotalExpected) {
|
||||
self.bar.set_length(self.stats.total_expected() as u64);
|
||||
}
|
||||
}
|
||||
Command::AddToF64Field(field, value) => self.stats.update_f64_field(field, value),
|
||||
Command::CreateBar => {
|
||||
self.bar = add_bar("", self.stats.total_expected() as u64, BarType::Total);
|
||||
}
|
||||
Command::LoadStats(filename) => {
|
||||
self.stats.merge_from(&filename)?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::Exit => break,
|
||||
_ => {} // no more commands needed
|
||||
}
|
||||
}
|
||||
|
||||
self.bar.finish();
|
||||
|
||||
log::debug!("{:#?}", *self.stats);
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wrapper around incrementing the overall scan's progress bar
|
||||
fn increment_bar(&self) {
|
||||
let msg = format!(
|
||||
"{}:{:<7} {}:{:<7}",
|
||||
style("found").green(),
|
||||
self.stats.resources_discovered(),
|
||||
style("errors").red(),
|
||||
self.stats.errors(),
|
||||
);
|
||||
|
||||
self.bar.set_message(&msg);
|
||||
self.bar.inc(1);
|
||||
}
|
||||
|
||||
/// Initialize new `Stats` object and the sc side of an mpsc channel that is responsible for
|
||||
/// updates to the aforementioned object.
|
||||
pub fn initialize(config: Arc<Configuration>) -> (Joiner, StatsHandle) {
|
||||
log::trace!("enter: initialize");
|
||||
|
||||
let data = Arc::new(Stats::new(config.json));
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let mut handler = StatsHandler::new(data.clone(), rx);
|
||||
|
||||
let task = tokio::spawn(async move { handler.start(&config.output).await });
|
||||
|
||||
let event_handle = StatsHandle::new(data, tx);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
|
||||
|
||||
(task, event_handle)
|
||||
}
|
||||
}
|
||||
269
src/extractor.rs
@@ -1,269 +0,0 @@
|
||||
use crate::FeroxResponse;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use reqwest::Url;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Regular expression used in [LinkFinder](https://github.com/GerbenJavado/LinkFinder)
|
||||
///
|
||||
/// Incorporates change from this [Pull Request](https://github.com/GerbenJavado/LinkFinder/pull/66/files)
|
||||
const LINKFINDER_REGEX: &str = r#"(?:"|')(((?:[a-zA-Z]{1,10}://|//)[^"'/]{1,}\.[a-zA-Z]{2,}[^"']{0,})|((?:/|\.\./|\./)[^"'><,;| *()(%%$^/\\\[\]][^"'><,;|()]{1,})|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{1,}\.(?:[a-zA-Z]{1,4}|action)(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{3,}(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-.]{1,}\.(?:php|asp|aspx|jsp|json|action|html|js|txt|xml)(?:[\?|#][^"|']{0,}|)))(?:"|')"#;
|
||||
|
||||
lazy_static! {
|
||||
/// `LINKFINDER_REGEX` as a regex::Regex type
|
||||
static ref REGEX: Regex = Regex::new(LINKFINDER_REGEX).unwrap();
|
||||
}
|
||||
|
||||
/// Iterate over a given path, return a list of every sub-path found
|
||||
///
|
||||
/// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// the following fragments would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
fn get_sub_paths_from_path(path: &str) -> Vec<String> {
|
||||
log::trace!("enter: get_sub_paths_from_path({})", path);
|
||||
let mut paths = vec![];
|
||||
|
||||
// filter out any empty strings caused by .split
|
||||
let mut parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
let length = parts.len();
|
||||
|
||||
for _ in 0..length {
|
||||
// iterate over all parts of the path
|
||||
if parts.is_empty() {
|
||||
// pop left us with an empty vector, we're done
|
||||
break;
|
||||
}
|
||||
|
||||
let possible_path = parts.join("/");
|
||||
|
||||
if possible_path.is_empty() {
|
||||
// .join can result in an empty string, which we don't need, ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.push(possible_path); // good sub-path found
|
||||
parts.pop(); // use .pop() to remove the last part of the path and continue iteration
|
||||
}
|
||||
|
||||
log::trace!("exit: get_sub_paths_from_path -> {:?}", paths);
|
||||
paths
|
||||
}
|
||||
|
||||
/// simple helper to stay DRY, trys to join a url + fragment and add it to the `links` HashSet
|
||||
fn add_link_to_set_of_links(link: &str, url: &Url, links: &mut HashSet<String>) {
|
||||
log::trace!(
|
||||
"enter: add_link_to_set_of_links({}, {}, {:?})",
|
||||
link,
|
||||
url.to_string(),
|
||||
links
|
||||
);
|
||||
match url.join(&link) {
|
||||
Ok(new_url) => {
|
||||
links.insert(new_url.to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not join given url to the base url: {}", e);
|
||||
}
|
||||
}
|
||||
log::trace!("exit: add_link_to_set_of_links");
|
||||
}
|
||||
|
||||
/// Given a `reqwest::Response`, perform the following actions
|
||||
/// - parse the response's text for links using the linkfinder regex
|
||||
/// - for every link found take its url path and parse each sub-path
|
||||
/// - example: Response contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// with a base url of http://localhost, the following urls would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
pub async fn get_links(response: &FeroxResponse) -> HashSet<String> {
|
||||
log::trace!("enter: get_links({})", response.url().as_str());
|
||||
|
||||
let mut links = HashSet::<String>::new();
|
||||
|
||||
let body = response.text();
|
||||
|
||||
for capture in REGEX.captures_iter(&body) {
|
||||
// remove single & double quotes from both ends of the capture
|
||||
// capture[0] is the entire match, additional capture groups start at [1]
|
||||
let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
|
||||
|
||||
match Url::parse(link) {
|
||||
Ok(absolute) => {
|
||||
if absolute.domain() != response.url().domain()
|
||||
|| absolute.host() != response.url().host()
|
||||
{
|
||||
// domains/ips are not the same, don't scan things that aren't part of the original
|
||||
// target url
|
||||
continue;
|
||||
}
|
||||
|
||||
for sub_path in get_sub_paths_from_path(absolute.path()) {
|
||||
// take a url fragment like homepage/assets/img/icons/handshake.svg and
|
||||
// incrementally add
|
||||
// - homepage/assets/img/icons/
|
||||
// - homepage/assets/img/
|
||||
// - homepage/assets/
|
||||
// - homepage/
|
||||
log::debug!("Adding {} to {:?}", sub_path, links);
|
||||
add_link_to_set_of_links(&sub_path, &response.url(), &mut links);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// this is the expected error that happens when we try to parse a url fragment
|
||||
// ex: Url::parse("/login") -> Err("relative URL without a base")
|
||||
// while this is technically an error, these are good results for us
|
||||
if e.to_string().contains("relative URL without a base") {
|
||||
for sub_path in get_sub_paths_from_path(link) {
|
||||
// incrementally save all sub-paths that led to the relative url's resource
|
||||
log::debug!("Adding {} to {:?}", sub_path, links);
|
||||
add_link_to_set_of_links(&sub_path, &response.url(), &mut links);
|
||||
}
|
||||
} else {
|
||||
// unexpected error has occurred
|
||||
log::error!("Could not parse given url: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: get_links -> {:?}", links);
|
||||
links
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::make_request;
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::{Mock, MockServer};
|
||||
use reqwest::Client;
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 4 sub paths and that all are
|
||||
/// in the expected array
|
||||
fn extractor_get_sub_paths_from_path_with_multiple_paths() {
|
||||
let path = "homepage/assets/img/icons/handshake.svg";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec![
|
||||
"homepage",
|
||||
"homepage/assets",
|
||||
"homepage/assets/img",
|
||||
"homepage/assets/img/icons",
|
||||
"homepage/assets/img/icons/handshake.svg",
|
||||
];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 2 sub paths and that all are
|
||||
/// in the expected array. the fragment is wrapped in slashes to ensure no empty strings are
|
||||
/// returned
|
||||
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
|
||||
let path = "/homepage/assets/";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec!["homepage", "homepage/assets"];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, no forward slashes are
|
||||
/// included
|
||||
fn extractor_get_sub_paths_from_path_with_only_a_word() {
|
||||
let path = "homepage";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed
|
||||
fn extractor_get_sub_paths_from_path_with_an_absolute_word() {
|
||||
let path = "/homepage";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that a full url and fragment are joined correctly, then added to the given list
|
||||
/// i.e. the happy path
|
||||
fn extractor_add_link_to_set_of_links_happy_path() {
|
||||
let url = Url::parse("https://localhost").unwrap();
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "admin";
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
add_link_to_set_of_links(link, &url, &mut links);
|
||||
|
||||
assert_eq!(links.len(), 1);
|
||||
assert!(links.contains("https://localhost/admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that an invalid path fragment doesn't add anything to the set of links
|
||||
fn extractor_add_link_to_set_of_links_with_non_base_url() {
|
||||
let url = Url::parse("https://localhost").unwrap();
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "\\\\\\\\";
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
add_link_to_set_of_links(link, &url, &mut links);
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// use make_request to generate a Response, and use the Response to test get_links;
|
||||
/// the response will contain an absolute path to a domain that is not part of the scanned
|
||||
/// domain; expect an empty set returned
|
||||
async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/some-path")
|
||||
.return_status(200)
|
||||
.return_body("\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"")
|
||||
.create_on(&srv);
|
||||
|
||||
let client = Client::new();
|
||||
let url = Url::parse(&srv.url("/some-path")).unwrap();
|
||||
|
||||
let response = make_request(&client, &url).await.unwrap();
|
||||
|
||||
let ferox_response = FeroxResponse::from(response, true).await;
|
||||
|
||||
let links = get_links(&ferox_response).await;
|
||||
|
||||
assert!(links.is_empty());
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
103
src/extractor/builder.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use super::*;
|
||||
use crate::event_handlers::Handles;
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Regular expression used in [LinkFinder](https://github.com/GerbenJavado/LinkFinder)
|
||||
///
|
||||
/// Incorporates change from this [Pull Request](https://github.com/GerbenJavado/LinkFinder/pull/66/files)
|
||||
pub(super) const LINKFINDER_REGEX: &str = r#"(?:"|')(((?:[a-zA-Z]{1,10}://|//)[^"'/]{1,}\.[a-zA-Z]{2,}[^"']{0,})|((?:/|\.\./|\./)[^"'><,;| *()(%%$^/\\\[\]][^"'><,;|()]{1,})|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{1,}\.(?:[a-zA-Z]{1,4}|action)(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{3,}(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-.]{1,}\.(?:php|asp|aspx|jsp|json|action|html|js|txt|xml)(?:[\?|#][^"|']{0,}|)))(?:"|')"#;
|
||||
|
||||
/// Regular expression to pull url paths from robots.txt
|
||||
///
|
||||
/// ref: https://developers.google.com/search/reference/robots_txt
|
||||
pub(super) const ROBOTS_TXT_REGEX: &str =
|
||||
r#"(?m)^ *(Allow|Disallow): *(?P<url_path>[a-zA-Z0-9._/?#@!&'()+,;%=-]+?)$"#; // multi-line (?m)
|
||||
|
||||
/// Which type of extraction should be performed
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum ExtractionTarget {
|
||||
/// Examine a response body and extract links
|
||||
ResponseBody,
|
||||
|
||||
/// Examine robots.txt (specifically) and extract links
|
||||
RobotsTxt,
|
||||
|
||||
// Parse HTML and extract links
|
||||
ParseHtml,
|
||||
}
|
||||
|
||||
/// responsible for building an `Extractor`
|
||||
pub struct ExtractorBuilder<'a> {
|
||||
/// Response from which to extract links
|
||||
response: Option<&'a FeroxResponse>,
|
||||
|
||||
/// URL of where to extract links
|
||||
url: String,
|
||||
|
||||
/// Handles object to house the underlying mpsc transmitters
|
||||
handles: Option<Arc<Handles>>,
|
||||
|
||||
/// type of extraction to be performed
|
||||
target: ExtractionTarget,
|
||||
}
|
||||
|
||||
/// ExtractorBuilder implementation
|
||||
impl<'a> Default for ExtractorBuilder<'a> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
response: None,
|
||||
url: "".to_string(),
|
||||
handles: None,
|
||||
target: ExtractionTarget::ResponseBody,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ExtractorBuilder implementation
|
||||
impl<'a> ExtractorBuilder<'a> {
|
||||
/// builder call to set `handles`
|
||||
pub fn handles(&mut self, handles: Arc<Handles>) -> &mut Self {
|
||||
self.handles = Some(handles);
|
||||
self
|
||||
}
|
||||
|
||||
/// builder call to set `url`
|
||||
pub fn url(&mut self, url: &str) -> &mut Self {
|
||||
self.url = url.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// builder call to set `target`
|
||||
pub fn target(&mut self, target: ExtractionTarget) -> &mut Self {
|
||||
self.target = target;
|
||||
self
|
||||
}
|
||||
|
||||
/// builder call to set `response`
|
||||
pub fn response(&mut self, response: &'a FeroxResponse) -> &mut Self {
|
||||
self.response = Some(response);
|
||||
self
|
||||
}
|
||||
|
||||
/// finalize configuration of ExtratorBuilder and return an Extractor
|
||||
///
|
||||
/// requires either with_url or with_response to have been used in the build process
|
||||
pub fn build(&self) -> Result<Extractor<'a>> {
|
||||
if (self.url.is_empty() && self.response.is_none()) || self.handles.is_none() {
|
||||
bail!("Extractor requires a URL or a FeroxResponse be specified as well as a Handles object")
|
||||
}
|
||||
|
||||
Ok(Extractor {
|
||||
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
|
||||
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
|
||||
response: if self.response.is_some() {
|
||||
Some(self.response.unwrap())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
url: self.url.to_owned(),
|
||||
handles: self.handles.as_ref().unwrap().clone(),
|
||||
target: self.target,
|
||||
})
|
||||
}
|
||||
}
|
||||
572
src/extractor/container.rs
Normal file
@@ -0,0 +1,572 @@
|
||||
use super::*;
|
||||
use crate::utils::should_deny_url;
|
||||
use crate::{
|
||||
client,
|
||||
event_handlers::{
|
||||
Command,
|
||||
Command::{AddError, AddToUsizeField},
|
||||
Handles,
|
||||
},
|
||||
scan_manager::ScanOrder,
|
||||
statistics::{
|
||||
StatError::Other,
|
||||
StatField::{LinksExtracted, TotalExpected},
|
||||
},
|
||||
url::FeroxUrl,
|
||||
utils::{logged_request, make_request},
|
||||
DEFAULT_METHOD,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use reqwest::{StatusCode, Url};
|
||||
use scraper::{Html, Selector};
|
||||
use std::collections::HashSet;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// Whether an active scan is recursive or not
|
||||
#[derive(Debug)]
|
||||
enum RecursionStatus {
|
||||
/// Scan is recursive
|
||||
Recursive,
|
||||
|
||||
/// Scan is not recursive
|
||||
NotRecursive,
|
||||
}
|
||||
|
||||
/// Handles all logic related to extracting links from requested source code
|
||||
#[derive(Debug)]
|
||||
pub struct Extractor<'a> {
|
||||
/// `LINKFINDER_REGEX` as a regex::Regex type
|
||||
pub(super) links_regex: Regex,
|
||||
|
||||
/// `ROBOTS_TXT_REGEX` as a regex::Regex type
|
||||
pub(super) robots_regex: Regex,
|
||||
|
||||
/// Response from which to extract links
|
||||
pub(super) response: Option<&'a FeroxResponse>,
|
||||
|
||||
/// URL of where to extract links
|
||||
pub(super) url: String,
|
||||
|
||||
/// Handles object to house the underlying mpsc transmitters
|
||||
pub(super) handles: Arc<Handles>,
|
||||
|
||||
/// type of extraction to be performed
|
||||
pub(super) target: ExtractionTarget,
|
||||
}
|
||||
|
||||
/// Extractor implementation
|
||||
impl<'a> Extractor<'a> {
|
||||
/// perform extraction from the given target and return any links found
|
||||
pub async fn extract(&self) -> Result<(HashSet<String>, bool)> {
|
||||
log::trace!("enter: extract (this fn has associated trace exit msg)");
|
||||
match self.target {
|
||||
ExtractionTarget::ResponseBody => Ok(self.extract_from_body().await?),
|
||||
ExtractionTarget::RobotsTxt => Ok(self.extract_from_robots().await?),
|
||||
ExtractionTarget::ParseHtml => Ok(self.parse_html().await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// given a set of links from a normal http body response, task the request handler to make
|
||||
/// the requests
|
||||
pub async fn request_links(&self, links: HashSet<String>) -> Result<()> {
|
||||
log::trace!("enter: request_links({:?})", links);
|
||||
let recursive = if self.handles.config.no_recursion {
|
||||
RecursionStatus::NotRecursive
|
||||
} else {
|
||||
RecursionStatus::Recursive
|
||||
};
|
||||
|
||||
let scanned_urls = self.handles.ferox_scans()?;
|
||||
|
||||
for link in links {
|
||||
let mut resp = match self.request_link(&link).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// filter if necessary
|
||||
if self
|
||||
.handles
|
||||
.filters
|
||||
.data
|
||||
.should_filter_response(&resp, self.handles.stats.tx.clone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// request and report assumed file
|
||||
if resp.is_file() || !resp.is_directory() {
|
||||
log::debug!("Extracted File: {}", resp);
|
||||
|
||||
scanned_urls.add_file_scan(resp.url().as_str(), ScanOrder::Latest);
|
||||
|
||||
if let Err(e) = resp.send_report(self.handles.output.tx.clone()) {
|
||||
log::warn!("Could not send FeroxResponse to output handler: {}", e);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(recursive, RecursionStatus::Recursive) {
|
||||
log::debug!("Extracted Directory: {}", resp);
|
||||
|
||||
if !resp.url().as_str().ends_with('/')
|
||||
&& (resp.status().is_success()
|
||||
|| matches!(resp.status(), &StatusCode::FORBIDDEN))
|
||||
{
|
||||
// if the url doesn't end with a /
|
||||
// and the response code is either a 2xx or 403
|
||||
|
||||
// since all of these are 2xx or 403, recursion is only attempted if the
|
||||
// url ends in a /. I am actually ok with adding the slash and not
|
||||
// adding it, as both have merit. Leaving it in for now to see how
|
||||
// things turn out (current as of: v1.1.0)
|
||||
resp.set_url(&format!("{}/", resp.url()));
|
||||
}
|
||||
|
||||
self.handles
|
||||
.send_scan_command(Command::TryRecursion(Box::new(resp)))?;
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.handles.send_scan_command(Command::Sync(tx))?;
|
||||
rx.await?;
|
||||
}
|
||||
}
|
||||
log::trace!("exit: request_links");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Given a `reqwest::Response`, perform the following actions
|
||||
/// - parse the response's text for links using the linkfinder regex
|
||||
/// - for every link found take its url path and parse each sub-path
|
||||
/// - example: Response contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// with a base url of http://localhost, the following urls would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
pub(super) async fn extract_from_body(&self) -> Result<(HashSet<String>, bool)> {
|
||||
log::trace!("enter: extract_from_body");
|
||||
|
||||
let mut links = HashSet::<String>::new();
|
||||
let dirlist_flag = false;
|
||||
|
||||
// Response
|
||||
let response = self.response.unwrap();
|
||||
let resp_url = response.url();
|
||||
let body = response.text();
|
||||
let html = Html::parse_document(body);
|
||||
|
||||
// Extract Links
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "a", "href");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "img", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "form", "action");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "script", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "iframe", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "div", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "frame", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "embed", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "script", "src");
|
||||
|
||||
for capture in self.links_regex.captures_iter(body) {
|
||||
// remove single & double quotes from both ends of the capture
|
||||
// capture[0] is the entire match, additional capture groups start at [1]
|
||||
let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
|
||||
|
||||
match Url::parse(link) {
|
||||
Ok(absolute) => {
|
||||
if absolute.domain() != 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.update_stats(links.len())?;
|
||||
|
||||
log::trace!("exit: extract_from_body -> {:?} {}", links, dirlist_flag);
|
||||
Ok((links, dirlist_flag))
|
||||
}
|
||||
|
||||
/// take a url fragment like homepage/assets/img/icons/handshake.svg and
|
||||
/// incrementally add
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
fn add_all_sub_paths(&self, url_path: &str, links: &mut HashSet<String>) -> Result<()> {
|
||||
log::trace!("enter: add_all_sub_paths({}, {:?})", url_path, links);
|
||||
|
||||
for sub_path in self.get_sub_paths_from_path(url_path) {
|
||||
self.add_link_to_set_of_links(&sub_path, links)?;
|
||||
}
|
||||
|
||||
log::trace!("exit: add_all_sub_paths");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Iterate over a given path, return a list of every sub-path found
|
||||
///
|
||||
/// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// the following fragments would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
pub(super) fn get_sub_paths_from_path(&self, path: &str) -> Vec<String> {
|
||||
log::trace!("enter: get_sub_paths_from_path({})", path);
|
||||
let mut paths = vec![];
|
||||
|
||||
// filter out any empty strings caused by .split
|
||||
let mut parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
let length = parts.len();
|
||||
|
||||
for i in 0..length {
|
||||
// iterate over all parts of the path
|
||||
if parts.is_empty() {
|
||||
// pop left us with an empty vector, we're done
|
||||
break;
|
||||
}
|
||||
|
||||
let mut possible_path = parts.join("/");
|
||||
|
||||
if possible_path.is_empty() {
|
||||
// .join can result in an empty string, which we don't need, ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
// this isn't the last index of the parts array
|
||||
// ex: /buried/misc/stupidfile.php
|
||||
// this block skips the file but sees all parent folders
|
||||
possible_path = format!("{}/", possible_path);
|
||||
}
|
||||
|
||||
paths.push(possible_path); // good sub-path found
|
||||
parts.pop(); // use .pop() to remove the last part of the path and continue iteration
|
||||
}
|
||||
|
||||
log::trace!("exit: get_sub_paths_from_path -> {:?}", paths);
|
||||
paths
|
||||
}
|
||||
|
||||
/// simple helper to stay DRY, trys to join a url + fragment and add it to the `links` HashSet
|
||||
pub(super) fn add_link_to_set_of_links(
|
||||
&self,
|
||||
link: &str,
|
||||
links: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
log::trace!("enter: add_link_to_set_of_links({}, {:?})", link, links);
|
||||
|
||||
let old_url = match self.target {
|
||||
ExtractionTarget::ResponseBody => self.response.unwrap().url().clone(),
|
||||
ExtractionTarget::ParseHtml | ExtractionTarget::RobotsTxt => {
|
||||
match Url::parse(&self.url) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
bail!("Could not parse {}: {}", self.url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let new_url = old_url
|
||||
.join(link)
|
||||
.with_context(|| format!("Could not join {} with {}", old_url, link))?;
|
||||
|
||||
links.insert(new_url.to_string());
|
||||
|
||||
log::trace!("exit: add_link_to_set_of_links");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wrapper around link extraction logic
|
||||
/// - create a new Url object based on cli options/args
|
||||
/// - check if the new Url has already been seen/scanned -> None
|
||||
/// - make a request to the new Url ? -> Some(response) : None
|
||||
pub(super) async fn request_link(&self, url: &str) -> Result<FeroxResponse> {
|
||||
log::trace!("enter: request_link({})", url);
|
||||
|
||||
let ferox_url = FeroxUrl::from_string(url, self.handles.clone());
|
||||
|
||||
// create a url based on the given command line options
|
||||
let new_url = ferox_url.format("", None)?;
|
||||
|
||||
let scanned_urls = self.handles.ferox_scans()?;
|
||||
|
||||
if scanned_urls.get_scan_by_url(&new_url.to_string()).is_some() {
|
||||
//we've seen the url before and don't need to scan again
|
||||
log::trace!("exit: request_link -> None");
|
||||
bail!("previously seen url");
|
||||
}
|
||||
|
||||
if (!self.handles.config.url_denylist.is_empty()
|
||||
|| !self.handles.config.regex_denylist.is_empty())
|
||||
&& should_deny_url(&new_url, self.handles.clone())?
|
||||
{
|
||||
// can't allow a denied url to be requested
|
||||
bail!(
|
||||
"prevented request to {} due to {:?} || {:?}",
|
||||
url,
|
||||
self.handles.config.url_denylist,
|
||||
self.handles.config.regex_denylist,
|
||||
);
|
||||
}
|
||||
|
||||
// make the request and store the response
|
||||
let new_response =
|
||||
logged_request(&new_url, DEFAULT_METHOD, None, self.handles.clone()).await?;
|
||||
|
||||
let new_ferox_response = FeroxResponse::from(
|
||||
new_response,
|
||||
url,
|
||||
DEFAULT_METHOD,
|
||||
true,
|
||||
self.handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
|
||||
log::trace!("exit: request_link -> {:?}", new_ferox_response);
|
||||
|
||||
Ok(new_ferox_response)
|
||||
}
|
||||
|
||||
/// Entry point to perform link extraction from robots.txt
|
||||
///
|
||||
/// `base_url` can have paths and subpaths, however robots.txt will be requested from the
|
||||
/// root of the url
|
||||
/// given the url:
|
||||
/// http://localhost/stuff/things
|
||||
/// this function requests:
|
||||
/// http://localhost/robots.txt
|
||||
pub(super) async fn extract_from_robots(&self) -> Result<(HashSet<String>, bool)> {
|
||||
log::trace!("enter: extract_robots_txt");
|
||||
|
||||
let mut links: HashSet<String> = HashSet::new();
|
||||
let dirlist_flag = false;
|
||||
|
||||
// request
|
||||
let response = self.make_extract_request("/robots.txt").await?;
|
||||
let body = response.text();
|
||||
|
||||
for capture in self.robots_regex.captures_iter(body) {
|
||||
if let Some(new_path) = capture.name("url_path") {
|
||||
let mut new_url = Url::parse(&self.url)?;
|
||||
new_url.set_path(new_path.as_str());
|
||||
if self.add_all_sub_paths(new_url.path(), &mut links).is_err() {
|
||||
log::warn!("could not add sub-paths from {} to {:?}", new_url, links);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.update_stats(links.len())?;
|
||||
|
||||
log::trace!("exit: extract_robots_txt -> {:?} {}", links, dirlist_flag);
|
||||
Ok((links, dirlist_flag))
|
||||
}
|
||||
|
||||
/// Entry point to parse html for links (i.e. webscraping, directory listings)
|
||||
/// this function requests:
|
||||
/// http://localhost/<location>
|
||||
pub(super) async fn parse_html(&self) -> Result<(HashSet<String>, bool)> {
|
||||
log::trace!("enter: parse_html");
|
||||
|
||||
let mut links: HashSet<String> = HashSet::new();
|
||||
let mut dirlist_flag = false;
|
||||
|
||||
// Response
|
||||
let url = Url::parse(&self.url)?;
|
||||
let response = self.make_extract_request(url.path()).await?;
|
||||
let resp_url = response.url();
|
||||
let body = response.text();
|
||||
let html = Html::parse_document(body);
|
||||
|
||||
// Directory listing heuristic detection to not continue scanning
|
||||
// Index of /: apache
|
||||
// Directory Listing for /: tomcat,
|
||||
// Directory Listing -- /: ASP.NET
|
||||
// <host> - /: iis, azure, skipping due to loose heuristic
|
||||
let title_selector = Selector::parse("title").unwrap();
|
||||
for t in html.select(&title_selector) {
|
||||
let title = t.inner_html().to_lowercase();
|
||||
if title.contains("directory listing for /")
|
||||
|| title.contains("index of /")
|
||||
|| title.contains("directory listing -- /")
|
||||
{
|
||||
log::debug!("Directory listing heuristic detection from \"{}\"", title);
|
||||
dirlist_flag = true;
|
||||
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "a", "href");
|
||||
self.update_stats(links.len())?;
|
||||
|
||||
log::trace!("exit: parse_html -> {:?} {}", links, dirlist_flag);
|
||||
return Ok((links, dirlist_flag));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Links
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "a", "href");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "img", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "form", "action");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "script", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "iframe", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "div", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "frame", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "embed", "src");
|
||||
self.extract_links_by_attr(resp_url, &mut links, &html, "script", "src");
|
||||
|
||||
self.update_stats(links.len())?;
|
||||
|
||||
log::trace!("exit: parse_html -> {:?} {}", links, dirlist_flag);
|
||||
Ok((links, dirlist_flag))
|
||||
}
|
||||
|
||||
/// 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 t in tags {
|
||||
if let Some(link) = t.value().attr(html_attr) {
|
||||
log::debug!("Parsed link \"{}\" from {}", link, resp_url.as_str());
|
||||
|
||||
match Url::parse(link) {
|
||||
Ok(absolute) => {
|
||||
if absolute.domain() != resp_url.domain()
|
||||
|| absolute.host() != resp_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(), 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, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: extract_links_by_attr");
|
||||
}
|
||||
|
||||
/// helper function that simply requests at <location> on the given url's base url
|
||||
///
|
||||
/// example:
|
||||
/// http://localhost/api/users -> http://localhost/<location>
|
||||
pub(super) async fn make_extract_request(&self, location: &str) -> Result<FeroxResponse> {
|
||||
log::trace!("enter: make_extract_request");
|
||||
|
||||
// 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())
|
||||
};
|
||||
|
||||
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(location); // overwrite existing path
|
||||
|
||||
// purposefully not using logged_request here due to using the special client
|
||||
let response = make_request(
|
||||
&client,
|
||||
&url,
|
||||
DEFAULT_METHOD,
|
||||
None,
|
||||
self.handles.config.output_level,
|
||||
&self.handles.config,
|
||||
self.handles.stats.tx.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ferox_response = FeroxResponse::from(
|
||||
response,
|
||||
&self.url,
|
||||
DEFAULT_METHOD,
|
||||
true,
|
||||
self.handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
|
||||
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);
|
||||
|
||||
self.handles
|
||||
.stats
|
||||
.send(AddToUsizeField(LinksExtracted, num_links))?;
|
||||
self.handles
|
||||
.stats
|
||||
.send(AddToUsizeField(TotalExpected, num_links * multiplier))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
13
src/extractor/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! extract links from html source and robots.txt
|
||||
mod builder;
|
||||
mod container;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use self::builder::ExtractionTarget;
|
||||
pub use self::builder::ExtractorBuilder;
|
||||
pub use self::container::Extractor;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use regex::Regex;
|
||||
use std::sync::Arc;
|
||||
377
src/extractor/tests.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use super::builder::{LINKFINDER_REGEX, ROBOTS_TXT_REGEX};
|
||||
use super::*;
|
||||
use crate::config::{Configuration, OutputLevel};
|
||||
use crate::scan_manager::ScanOrder;
|
||||
use crate::{
|
||||
event_handlers::Handles, scan_manager::FeroxScans, utils::make_request, Command, FeroxChannel,
|
||||
DEFAULT_METHOD,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use httpmock::{Method::GET, MockServer};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use std::collections::HashSet;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
lazy_static! {
|
||||
/// Extractor for testing robots.txt
|
||||
static ref ROBOTS_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::RobotsTxt, Arc::new(FeroxScans::default()));
|
||||
|
||||
/// Extractor for testing response bodies
|
||||
static ref BODY_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::ResponseBody, Arc::new(FeroxScans::default()));
|
||||
|
||||
/// Extractor for testing paring html
|
||||
static ref PARSEHTML_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::ParseHtml, Arc::new(FeroxScans::default()));
|
||||
|
||||
/// FeroxResponse for Extractor
|
||||
static ref RESPONSE: FeroxResponse = get_test_response();
|
||||
}
|
||||
|
||||
/// constructor for the default FeroxResponse used during testing
|
||||
fn get_test_response() -> FeroxResponse {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_text("nulla pharetra diam sit amet nisl suscipit adipiscing bibendum est");
|
||||
resp
|
||||
}
|
||||
|
||||
/// creates a single extractor that can be used to test standalone functions
|
||||
fn setup_extractor(target: ExtractionTarget, scanned_urls: Arc<FeroxScans>) -> Extractor<'static> {
|
||||
let mut builder = ExtractorBuilder::default();
|
||||
|
||||
let builder = match target {
|
||||
ExtractionTarget::ResponseBody => builder
|
||||
.target(ExtractionTarget::ResponseBody)
|
||||
.response(&RESPONSE),
|
||||
ExtractionTarget::RobotsTxt => builder
|
||||
.url("http://localhost")
|
||||
.target(ExtractionTarget::RobotsTxt),
|
||||
ExtractionTarget::ParseHtml => builder
|
||||
.url("http://localhost")
|
||||
.target(ExtractionTarget::ParseHtml),
|
||||
};
|
||||
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
let handles = Arc::new(Handles::for_testing(Some(scanned_urls), Some(config)).0);
|
||||
|
||||
builder.handles(handles).build().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 4 sub paths and that all are
|
||||
/// in the expected array
|
||||
fn extractor_get_sub_paths_from_path_with_multiple_paths() {
|
||||
let path = "homepage/assets/img/icons/handshake.svg";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec![
|
||||
"homepage/",
|
||||
"homepage/assets/",
|
||||
"homepage/assets/img/",
|
||||
"homepage/assets/img/icons/",
|
||||
"homepage/assets/img/icons/handshake.svg",
|
||||
];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 2 sub paths and that all are
|
||||
/// in the expected array. the fragment is wrapped in slashes to ensure no empty strings are
|
||||
/// returned
|
||||
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
|
||||
let path = "/homepage/assets/";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec!["homepage/", "homepage/assets"];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, no forward slashes are
|
||||
/// included
|
||||
fn extractor_get_sub_paths_from_path_with_only_a_word() {
|
||||
let path = "homepage";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed
|
||||
fn extractor_get_sub_paths_from_path_with_an_absolute_word() {
|
||||
let path = "/homepage";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that an ExtractorBuilder without a FeroxResponse and without a URL bails
|
||||
fn extractor_builder_bails_when_neither_required_field_is_set() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.url("")
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.handles(handles)
|
||||
.build();
|
||||
|
||||
assert!(extractor.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Extractor with a non-base url bails
|
||||
fn extractor_with_non_base_url_bails() -> Result<()> {
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "admin";
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.url("\\\\\\")
|
||||
.handles(handles)
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.build()?;
|
||||
|
||||
let result = extractor.add_link_to_set_of_links(link, &mut links);
|
||||
|
||||
assert!(result.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that a full url and fragment are joined correctly, then added to the given list
|
||||
/// i.e. the happy path
|
||||
fn extractor_add_link_to_set_of_links_happy_path() {
|
||||
let mut r_links = HashSet::<String>::new();
|
||||
let r_link = "admin";
|
||||
let mut b_links = HashSet::<String>::new();
|
||||
let b_link = "shmadmin";
|
||||
|
||||
assert_eq!(r_links.len(), 0);
|
||||
ROBOTS_EXT
|
||||
.add_link_to_set_of_links(r_link, &mut r_links)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(r_links.len(), 1);
|
||||
assert!(r_links.contains("http://localhost/admin"));
|
||||
|
||||
assert_eq!(b_links.len(), 0);
|
||||
|
||||
BODY_EXT
|
||||
.add_link_to_set_of_links(b_link, &mut b_links)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(b_links.len(), 1);
|
||||
assert!(b_links.contains("http://localhost/shmadmin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that an invalid path fragment doesn't add anything to the set of links
|
||||
fn extractor_add_link_to_set_of_links_with_non_base_url() {
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "\\\\\\\\";
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
assert!(ROBOTS_EXT
|
||||
.add_link_to_set_of_links(link, &mut links)
|
||||
.is_err());
|
||||
assert!(BODY_EXT.add_link_to_set_of_links(link, &mut links).is_err());
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// use make_request to generate a Response, and use the Response to test get_links;
|
||||
/// the response will contain an absolute path to a domain that is not part of the scanned
|
||||
/// domain; expect an empty set returned
|
||||
async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain() -> Result<()> {
|
||||
let (tx_stats, _): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/some-path");
|
||||
then.status(200).body(
|
||||
"\"http://definitely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"",
|
||||
);
|
||||
});
|
||||
|
||||
let client = Client::new();
|
||||
let url = Url::parse(&srv.url("/some-path")).unwrap();
|
||||
let config = Configuration::new().unwrap();
|
||||
|
||||
let response = make_request(
|
||||
&client,
|
||||
&url,
|
||||
DEFAULT_METHOD,
|
||||
None,
|
||||
OutputLevel::Default,
|
||||
&config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handles, _rx) = Handles::for_testing(None, None);
|
||||
|
||||
let handles = Arc::new(handles);
|
||||
let ferox_response = FeroxResponse::from(
|
||||
response,
|
||||
&srv.url(""),
|
||||
DEFAULT_METHOD,
|
||||
true,
|
||||
OutputLevel::Default,
|
||||
)
|
||||
.await;
|
||||
|
||||
let extractor = Extractor {
|
||||
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
|
||||
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
|
||||
response: Some(&ferox_response),
|
||||
url: String::new(),
|
||||
target: ExtractionTarget::ResponseBody,
|
||||
handles: handles.clone(),
|
||||
};
|
||||
|
||||
let links = (extractor.extract_from_body().await?).0;
|
||||
|
||||
assert!(links.is_empty());
|
||||
assert_eq!(mock.hits(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test that /robots.txt is correctly requested given a base url (happy path)
|
||||
async fn request_robots_txt_without_proxy() -> Result<()> {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/robots.txt");
|
||||
then.status(200).body("this is a test");
|
||||
});
|
||||
|
||||
let extractor = Extractor {
|
||||
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
|
||||
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
|
||||
response: None,
|
||||
url: srv.url("/api/users/stuff/things"),
|
||||
target: ExtractionTarget::RobotsTxt,
|
||||
handles,
|
||||
};
|
||||
|
||||
let resp = extractor.make_extract_request("/robots.txt").await?;
|
||||
|
||||
assert!(matches!(resp.status(), &StatusCode::OK));
|
||||
println!("{}", resp);
|
||||
assert_eq!(resp.content_length(), 14);
|
||||
assert_eq!(mock.hits(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test that /robots.txt is correctly requested given a base url (happy path) when a proxy is used
|
||||
async fn request_robots_txt_with_proxy() -> Result<()> {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let mut config = Configuration::new()?;
|
||||
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/robots.txt");
|
||||
then.status(200).body("this is also a test");
|
||||
});
|
||||
|
||||
// note: the proxy doesn't actually do anything other than hit a different code branch
|
||||
// in this unit test; it would however have an effect on an integration test
|
||||
config.proxy = srv.url("/ima-proxy");
|
||||
config.no_recursion = true;
|
||||
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.url(&srv.url("/api/different/path"))
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.handles(handles)
|
||||
.build()?;
|
||||
|
||||
let resp = extractor.make_extract_request("/robots.txt").await?;
|
||||
|
||||
assert!(matches!(resp.status(), &StatusCode::OK));
|
||||
assert_eq!(resp.content_length(), 19);
|
||||
assert_eq!(mock.hits(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// request_link's happy path, expect back a FeroxResponse
|
||||
async fn request_link_happy_path() -> Result<()> {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/login.php");
|
||||
then.status(200).body("this is a test");
|
||||
});
|
||||
|
||||
let r_resp = ROBOTS_EXT.request_link(&srv.url("/login.php")).await?;
|
||||
let b_resp = BODY_EXT.request_link(&srv.url("/login.php")).await?;
|
||||
|
||||
assert!(matches!(r_resp.status(), &StatusCode::OK));
|
||||
assert!(matches!(b_resp.status(), &StatusCode::OK));
|
||||
assert_eq!(r_resp.content_length(), 14);
|
||||
assert_eq!(b_resp.content_length(), 14);
|
||||
assert_eq!(mock.hits(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// request_link should bail in the event that the url is already in scanned_urls
|
||||
async fn request_link_bails_on_seen_url() -> Result<()> {
|
||||
let url = "/unique-for-this-test.php";
|
||||
let srv = MockServer::start();
|
||||
let served = srv.url(url);
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path(url);
|
||||
then.status(200)
|
||||
.body("this is a unique test, don't reuse the endpoint");
|
||||
});
|
||||
|
||||
let scans = Arc::new(FeroxScans::default());
|
||||
scans.add_file_scan(&served, ScanOrder::Latest);
|
||||
|
||||
let robots = setup_extractor(ExtractionTarget::RobotsTxt, scans.clone());
|
||||
let body = setup_extractor(ExtractionTarget::ResponseBody, scans);
|
||||
|
||||
let r_resp = robots.request_link(&served).await;
|
||||
let b_resp = body.request_link(&served).await;
|
||||
|
||||
assert!(r_resp.is_err());
|
||||
assert!(b_resp.is_err());
|
||||
assert_eq!(mock.hits(), 0); // function exits before requests can happen
|
||||
Ok(())
|
||||
}
|
||||
56
src/filters/container.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::{
|
||||
event_handlers::Command::AddToUsizeField, statistics::StatField::WildcardsFiltered,
|
||||
CommandSender,
|
||||
};
|
||||
|
||||
use super::{FeroxFilter, WildcardFilter};
|
||||
|
||||
/// Container around a collection of `FeroxFilters`s
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeroxFilters {
|
||||
/// collection of `FeroxFilters`
|
||||
pub filters: Mutex<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 guard.contains(&filter) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
guard.push(filter)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple helper to stay DRY; determines whether or not a given `FeroxResponse` should be reported
|
||||
/// to the user or not.
|
||||
pub fn should_filter_response(
|
||||
&self,
|
||||
response: &FeroxResponse,
|
||||
tx_stats: CommandSender,
|
||||
) -> bool {
|
||||
if let Ok(filters) = self.filters.lock() {
|
||||
for filter in filters.iter() {
|
||||
// wildcard.should_filter goes here
|
||||
if filter.should_filter_response(response) {
|
||||
if filter.as_any().downcast_ref::<WildcardFilter>().is_some() {
|
||||
tx_stats
|
||||
.send(AddToUsizeField(WildcardsFiltered, 1))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
101
src/filters/init.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use super::{
|
||||
LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter, WordsFilter,
|
||||
};
|
||||
use crate::{
|
||||
event_handlers::Handles,
|
||||
response::FeroxResponse,
|
||||
skip_fail,
|
||||
utils::{fmt_err, logged_request},
|
||||
Command::AddFilter,
|
||||
DEFAULT_METHOD, SIMILARITY_THRESHOLD,
|
||||
};
|
||||
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
|
||||
pub async fn initialize(handles: Arc<Handles>) -> Result<()> {
|
||||
// add any status code filters to filters handler's FeroxFilters (-C|--filter-status)
|
||||
for code_filter in &handles.config.filter_status {
|
||||
let filter = StatusCodeFilter {
|
||||
filter_code: *code_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any line count filters to filters handler's FeroxFilters (-N|--filter-lines)
|
||||
for lines_filter in &handles.config.filter_line_count {
|
||||
let filter = LinesFilter {
|
||||
line_count: *lines_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any line count filters to filters handler's FeroxFilters (-W|--filter-words)
|
||||
for words_filter in &handles.config.filter_word_count {
|
||||
let filter = WordsFilter {
|
||||
word_count: *words_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any line count filters to filters handler's FeroxFilters (-S|--filter-size)
|
||||
for size_filter in &handles.config.filter_size {
|
||||
let filter = SizeFilter {
|
||||
content_length: *size_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any regex filters to filters handler's FeroxFilters (-X|--filter-regex)
|
||||
for regex_filter in &handles.config.filter_regex {
|
||||
let raw = regex_filter;
|
||||
let compiled = skip_fail!(Regex::new(raw));
|
||||
|
||||
let filter = RegexFilter {
|
||||
raw_string: raw.to_owned(),
|
||||
compiled,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any similarity filters to filters handler's FeroxFilters (--filter-similar-to)
|
||||
for similarity_filter in &handles.config.filter_similar {
|
||||
// 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, DEFAULT_METHOD, None, handles.clone()).await);
|
||||
|
||||
// if successful, create a filter based on the response's body
|
||||
let fr = FeroxResponse::from(
|
||||
resp,
|
||||
similarity_filter,
|
||||
DEFAULT_METHOD,
|
||||
true,
|
||||
handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
|
||||
// hash the response body and store the resulting hash in the filter object
|
||||
let hash = FuzzyHash::new(&fr.text()).to_string();
|
||||
|
||||
let filter = SimilarityFilter {
|
||||
text: hash,
|
||||
threshold: SIMILARITY_THRESHOLD,
|
||||
};
|
||||
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
handles.filters.sync().await?;
|
||||
Ok(())
|
||||
}
|
||||
33
src/filters/lines.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of lines
|
||||
/// in a Response body; specified using -N|--filter-lines
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct LinesFilter {
|
||||
/// Number of lines in a Response's body that should be filtered
|
||||
pub line_count: usize,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for LinesFilter
|
||||
impl FeroxFilter for LinesFilter {
|
||||
/// Check `line_count` against what was passed in via -N|--filter-lines
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.line_count() == self.line_count;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one LinesFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
28
src/filters/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! contains all of feroxbuster's filters
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::traits::{FeroxFilter, FeroxSerialize};
|
||||
|
||||
pub use self::container::FeroxFilters;
|
||||
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 use self::wildcard::WildcardFilter;
|
||||
pub use self::words::WordsFilter;
|
||||
|
||||
mod wildcard;
|
||||
mod status_code;
|
||||
mod words;
|
||||
mod lines;
|
||||
mod size;
|
||||
mod regex;
|
||||
mod similarity;
|
||||
mod container;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod init;
|
||||
46
src/filters/regex.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use super::*;
|
||||
use ::regex::Regex;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on a given regular
|
||||
/// expression; specified using -X|--filter-regex
|
||||
#[derive(Debug)]
|
||||
pub struct RegexFilter {
|
||||
/// Regular expression to be applied to the response body for filtering, compiled
|
||||
pub compiled: Regex,
|
||||
|
||||
/// Regular expression as passed in on the command line, not compiled
|
||||
pub raw_string: String,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for RegexFilter
|
||||
impl FeroxFilter for RegexFilter {
|
||||
/// Check `expression` against the response body, if the expression matches, the response
|
||||
/// should be filtered out
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = self.compiled.is_match(response.text());
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one SizeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// PartialEq implementation for RegexFilter
|
||||
impl PartialEq for RegexFilter {
|
||||
/// Simple comparison of the raw string passed in via the command line
|
||||
fn eq(&self, other: &RegexFilter) -> bool {
|
||||
self.raw_string == other.raw_string
|
||||
}
|
||||
}
|
||||
40
src/filters/similarity.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use super::*;
|
||||
use fuzzyhash::FuzzyHash;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the similarity of a
|
||||
/// Response body with a known response; specified using --filter-similar-to
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct SimilarityFilter {
|
||||
/// Response's body to be used for comparison for similarity
|
||||
pub text: String,
|
||||
|
||||
/// Percentage of similarity at which a page is determined to be a near-duplicate of another
|
||||
pub threshold: u32,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for SimilarityFilter
|
||||
impl FeroxFilter for SimilarityFilter {
|
||||
/// Check `FeroxResponse::text` against what was requested from the site passed in via
|
||||
/// --filter-similar-to
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
let other = FuzzyHash::new(&response.text());
|
||||
|
||||
if let Ok(result) = FuzzyHash::compare(&self.text, &other.to_string()) {
|
||||
return result >= self.threshold;
|
||||
}
|
||||
|
||||
// couldn't hash the response, don't filter
|
||||
log::warn!("Could not hash body from {}", response.as_str());
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one SimilarityFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
33
src/filters/size.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the length of a
|
||||
/// Response body; specified using -S|--filter-size
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct SizeFilter {
|
||||
/// Overall length of a Response's body that should be filtered
|
||||
pub content_length: u64,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for SizeFilter
|
||||
impl FeroxFilter for SizeFilter {
|
||||
/// Check `content_length` against what was passed in via -S|--filter-size
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.content_length() == self.content_length;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one SizeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
40
src/filters/status_code.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use super::*;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out status codes specified using
|
||||
/// -C|--filter-status
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct StatusCodeFilter {
|
||||
/// Status code that should not be displayed to the user
|
||||
pub filter_code: u16,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for StatusCodeFilter
|
||||
impl FeroxFilter for StatusCodeFilter {
|
||||
/// Check `filter_code` against what was passed in via -C|--filter-status
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
if response.status().as_u16() == self.filter_code {
|
||||
log::debug!(
|
||||
"filtered out {} based on --filter-status of {}",
|
||||
response.url(),
|
||||
self.filter_code
|
||||
);
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one StatusCodeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
230
src/filters/tests.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use super::*;
|
||||
use ::fuzzyhash::FuzzyHash;
|
||||
use ::regex::Regex;
|
||||
|
||||
#[test]
|
||||
/// simply test the default values for wildcardfilter, expect 0, 0
|
||||
fn wildcard_filter_default() {
|
||||
let wcf = WildcardFilter::default();
|
||||
assert_eq!(wcf.size, u64::MAX);
|
||||
assert_eq!(wcf.dynamic, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn wildcard_filter_as_any() {
|
||||
let filter = WildcardFilter::default();
|
||||
let filter2 = WildcardFilter::default();
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<WildcardFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn lines_filter_as_any() {
|
||||
let filter = LinesFilter { line_count: 1 };
|
||||
let filter2 = LinesFilter { line_count: 1 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.line_count, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn words_filter_as_any() {
|
||||
let filter = WordsFilter { word_count: 1 };
|
||||
let filter2 = WordsFilter { word_count: 1 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.word_count, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn size_filter_as_any() {
|
||||
let filter = SizeFilter { content_length: 1 };
|
||||
let filter2 = SizeFilter { content_length: 1 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.content_length, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn status_code_filter_as_any() {
|
||||
let filter = StatusCodeFilter { filter_code: 200 };
|
||||
let filter2 = StatusCodeFilter { filter_code: 200 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.filter_code, 200);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn regex_filter_as_any() {
|
||||
let raw = r".*\.txt$";
|
||||
let compiled = Regex::new(raw).unwrap();
|
||||
let compiled2 = Regex::new(raw).unwrap();
|
||||
let filter = RegexFilter {
|
||||
compiled,
|
||||
raw_string: raw.to_string(),
|
||||
};
|
||||
let filter2 = RegexFilter {
|
||||
compiled: compiled2,
|
||||
raw_string: raw.to_string(),
|
||||
};
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.raw_string, r".*\.txt$");
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where static logic matches
|
||||
fn wildcard_should_filter_when_static_wildcard_found() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost");
|
||||
resp.set_text(
|
||||
"pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus faucibus",
|
||||
);
|
||||
|
||||
let filter = WildcardFilter {
|
||||
size: 83,
|
||||
dynamic: 0,
|
||||
dont_filter: false,
|
||||
method: "GET".to_owned(),
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where static logic matches but response length is 0
|
||||
fn wildcard_should_filter_when_static_wildcard_len_is_zero() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost");
|
||||
|
||||
// default WildcardFilter is used in the code that executes when response.content_length() == 0
|
||||
let filter = WildcardFilter::new(false);
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where dynamic logic matches
|
||||
fn wildcard_should_filter_when_dynamic_wildcard_found() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost/stuff");
|
||||
resp.set_text("pellentesque diam volutpat commodo sed egestas egestas fringilla");
|
||||
|
||||
let filter = WildcardFilter {
|
||||
size: 0,
|
||||
dynamic: 59, // content-length - 5 (len('stuff'))
|
||||
dont_filter: false,
|
||||
method: "GET".to_owned(),
|
||||
};
|
||||
|
||||
println!("resp: {:?}: filter: {:?}", resp, filter);
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on RegexFilter where regex matches body
|
||||
fn regexfilter_should_filter_when_regex_matches_on_response_body() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_url("http://localhost/stuff");
|
||||
resp.set_text("im a body response hurr durr!");
|
||||
|
||||
let raw = r"response...rr";
|
||||
|
||||
let filter = RegexFilter {
|
||||
raw_string: raw.to_string(),
|
||||
compiled: Regex::new(raw).unwrap(),
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// a few simple tests for similarity filter
|
||||
fn similarity_filter_is_accurate() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_url("http://localhost/stuff");
|
||||
resp.set_text("sitting");
|
||||
|
||||
let mut filter = SimilarityFilter {
|
||||
text: FuzzyHash::new("kitten").to_string(),
|
||||
threshold: 95,
|
||||
};
|
||||
|
||||
// kitten/sitting is 57% similar, so a threshold of 95 should not be filtered
|
||||
assert!(!filter.should_filter_response(&resp));
|
||||
|
||||
resp.set_text("");
|
||||
filter.text = String::new();
|
||||
filter.threshold = 100;
|
||||
|
||||
// two empty strings are the same, however ssdeep doesn't accept empty strings, expect false
|
||||
assert!(!filter.should_filter_response(&resp));
|
||||
|
||||
resp.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.threshold = 17;
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn similarity_filter_as_any() {
|
||||
let filter = SimilarityFilter {
|
||||
text: String::from("stuff"),
|
||||
threshold: 95,
|
||||
};
|
||||
|
||||
let filter2 = SimilarityFilter {
|
||||
text: String::from("stuff"),
|
||||
threshold: 95,
|
||||
};
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.text, "stuff");
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,5 @@
|
||||
use crate::config::CONFIGURATION;
|
||||
use crate::utils::get_url_path_length;
|
||||
use crate::FeroxResponse;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
// references:
|
||||
// https://dev.to/magnusstrale/rust-trait-objects-in-a-vector-non-trivial-4co5
|
||||
// https://stackoverflow.com/questions/25339603/how-to-test-for-equality-between-trait-objects
|
||||
|
||||
/// FeroxFilter trait; represents different types of possible filters that can be applied to
|
||||
/// responses
|
||||
pub trait FeroxFilter: Debug + Send + Sync {
|
||||
/// Determine whether or not this particular filter should be applied or not
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool;
|
||||
|
||||
/// delegates to the FeroxFilter-implementing type which gives us the actual type of self
|
||||
fn box_eq(&self, other: &dyn Any) -> bool;
|
||||
|
||||
/// gives us `other` as Any in box_eq
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
/// implementation of PartialEq, necessary long-form due to "trait cannot be made into an object"
|
||||
/// error when attempting to derive PartialEq on the trait itself
|
||||
impl PartialEq for Box<dyn FeroxFilter> {
|
||||
/// Perform a comparison of two implementors of the FeroxFilter trait
|
||||
fn eq(&self, other: &Box<dyn FeroxFilter>) -> bool {
|
||||
self.box_eq(other.as_any())
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use crate::{url::FeroxUrl, DEFAULT_METHOD};
|
||||
|
||||
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
|
||||
///
|
||||
@@ -38,7 +9,7 @@ impl PartialEq for Box<dyn FeroxFilter> {
|
||||
///
|
||||
/// `size` is size of the response that should be included with filters passed via runtime
|
||||
/// configuration and any static wildcard lengths.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WildcardFilter {
|
||||
/// size of the response that will later be combined with the length of the path of the url
|
||||
/// requested
|
||||
@@ -46,6 +17,36 @@ 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,
|
||||
}
|
||||
|
||||
/// implementation of WildcardFilter
|
||||
impl WildcardFilter {
|
||||
/// given a boolean representing whether -D was used or not, create a new WildcardFilter
|
||||
pub fn new(dont_filter: bool) -> Self {
|
||||
Self {
|
||||
dont_filter,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// implement default that populates both values with u64::MAX
|
||||
impl Default for WildcardFilter {
|
||||
/// populate both values with u64::MAX
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dont_filter: false,
|
||||
size: u64::MAX,
|
||||
method: DEFAULT_METHOD.to_owned(),
|
||||
dynamic: u64::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for WildcardFilter
|
||||
@@ -56,14 +57,17 @@ impl FeroxFilter for WildcardFilter {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
// quick return if dont_filter is set
|
||||
if CONFIGURATION.dont_filter {
|
||||
if self.dont_filter {
|
||||
// --dont-filter applies specifically to wildcard filters, it is not a 100% catch all
|
||||
// for not filtering anything. As such, it should live in the implementation of
|
||||
// a wildcard filter
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.size > 0 && 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());
|
||||
@@ -71,7 +75,18 @@ impl FeroxFilter for WildcardFilter {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.dynamic > 0 {
|
||||
if self.size == u64::MAX
|
||||
&& response.content_length() == 0
|
||||
&& self.method == response.method().as_str()
|
||||
{
|
||||
// static wildcard size found during testing
|
||||
// but response length was zero; pointed out by @Tib3rius
|
||||
log::debug!("static wildcard: filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.dynamic != u64::MAX {
|
||||
// dynamic wildcard offset found during testing
|
||||
|
||||
// I'm about to manually split this url path instead of using reqwest::Url's
|
||||
@@ -79,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 = get_url_path_length(&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());
|
||||
@@ -101,42 +116,3 @@ impl FeroxFilter for WildcardFilter {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out status codes specified using
|
||||
/// -C|--filter-status
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct StatusCodeFilter {
|
||||
/// Status code that should not be displayed to the user
|
||||
pub filter_code: u16,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for StatusCodeFilter
|
||||
impl FeroxFilter for StatusCodeFilter {
|
||||
/// Check `filter_code` against what was passed in via -C|--filter-status
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
if response.status().as_u16() == self.filter_code {
|
||||
log::debug!(
|
||||
"filtered out {} based on --filter-status of {}",
|
||||
response.url(),
|
||||
self.filter_code
|
||||
);
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one StatusCodeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
33
src/filters/words.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of words
|
||||
/// in a Response body; specified using -W|--filter-words
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct WordsFilter {
|
||||
/// Number of words in a Response's body that should be filtered
|
||||
pub word_count: usize,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for WordsFilter
|
||||
impl FeroxFilter for WordsFilter {
|
||||
/// Check `word_count` against what was passed in via -W|--filter-words
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.word_count() == self.word_count;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one WordsFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1,388 +1,300 @@
|
||||
use crate::{
|
||||
config::{CONFIGURATION, PROGRESS_PRINTER},
|
||||
filters::WildcardFilter,
|
||||
scanner::should_filter_response,
|
||||
utils::{
|
||||
ferox_print, format_url, get_url_path_length, make_request, module_colorizer,
|
||||
status_colorizer,
|
||||
},
|
||||
FeroxResponse,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use console::style;
|
||||
use indicatif::ProgressBar;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
event_handlers::{Command, Handles},
|
||||
filters::WildcardFilter,
|
||||
progress::PROGRESS_PRINTER,
|
||||
response::FeroxResponse,
|
||||
skip_fail,
|
||||
url::FeroxUrl,
|
||||
utils::{ferox_print, fmt_err, logged_request, status_colorizer},
|
||||
DEFAULT_METHOD,
|
||||
};
|
||||
|
||||
/// length of a standard UUID, used when determining wildcard responses
|
||||
const UUID_LENGTH: u64 = 32;
|
||||
|
||||
/// Simple helper to return a uuid, formatted as lowercase without hyphens
|
||||
///
|
||||
/// `length` determines the number of uuids to string together. Each uuid
|
||||
/// is 32 characters long. So, a length of 1 return a 32 character string,
|
||||
/// a length of 2 returns a 64 character string, and so on...
|
||||
fn unique_string(length: usize) -> String {
|
||||
log::trace!("enter: unique_string({})", length);
|
||||
let mut ids = vec![];
|
||||
|
||||
for _ in 0..length {
|
||||
ids.push(Uuid::new_v4().to_simple().to_string());
|
||||
}
|
||||
|
||||
let unique_id = ids.join("");
|
||||
|
||||
log::trace!("exit: unique_string -> {}", unique_id);
|
||||
unique_id
|
||||
/// wrapper around ugly string formatting
|
||||
macro_rules! format_template {
|
||||
($template:expr, $method:expr, $length:expr) => {
|
||||
format!(
|
||||
$template,
|
||||
status_colorizer("WLD"),
|
||||
$method,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
style("auto-filtering").yellow(),
|
||||
style($length).cyan(),
|
||||
style("--dont-filter").yellow()
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Tests the given url to see if it issues a wildcard response
|
||||
///
|
||||
/// In the event that url returns a wildcard response, a
|
||||
/// [WildcardFilter](struct.WildcardFilter.html) is created and returned to the caller.
|
||||
pub async fn wildcard_test(
|
||||
target_url: &str,
|
||||
bar: ProgressBar,
|
||||
tx_file: UnboundedSender<String>,
|
||||
) -> Option<WildcardFilter> {
|
||||
log::trace!(
|
||||
"enter: wildcard_test({:?}, {:?}, {:?})",
|
||||
target_url,
|
||||
bar,
|
||||
tx_file
|
||||
);
|
||||
/// container for heuristics related info
|
||||
pub struct HeuristicTests {
|
||||
/// Handles object for event handler interaction
|
||||
handles: Arc<Handles>,
|
||||
}
|
||||
|
||||
if CONFIGURATION.dont_filter {
|
||||
// early return, dont_filter scans don't need tested
|
||||
log::trace!("exit: wildcard_test -> None");
|
||||
return None;
|
||||
/// HeuristicTests implementation
|
||||
impl HeuristicTests {
|
||||
/// create a new HeuristicTests struct
|
||||
pub fn new(handles: Arc<Handles>) -> Self {
|
||||
Self { handles }
|
||||
}
|
||||
|
||||
let clone_req_one = tx_file.clone();
|
||||
let clone_req_two = tx_file.clone();
|
||||
/// Simple helper to return a uuid, formatted as lowercase without hyphens
|
||||
///
|
||||
/// `length` determines the number of uuids to string together. Each uuid
|
||||
/// is 32 characters long. So, a length of 1 return a 32 character string,
|
||||
/// a length of 2 returns a 64 character string, and so on...
|
||||
fn unique_string(&self, length: usize) -> String {
|
||||
log::trace!("enter: unique_string({})", length);
|
||||
let mut ids = vec![];
|
||||
|
||||
if let Some(ferox_response) = make_wildcard_request(&target_url, 1, clone_req_one).await {
|
||||
bar.inc(1);
|
||||
|
||||
// found a wildcard response
|
||||
let mut wildcard = WildcardFilter::default();
|
||||
|
||||
let wc_length = ferox_response.content_length();
|
||||
|
||||
if wc_length == 0 {
|
||||
log::trace!("exit: wildcard_test -> Some({:?})", wildcard);
|
||||
return Some(wildcard);
|
||||
for _ in 0..length {
|
||||
ids.push(Uuid::new_v4().to_simple().to_string());
|
||||
}
|
||||
|
||||
// content length of wildcard is non-zero, perform additional tests:
|
||||
// make a second request, with a known-sized (64) longer request
|
||||
if let Some(resp_two) = make_wildcard_request(&target_url, 3, clone_req_two).await {
|
||||
bar.inc(1);
|
||||
let unique_id = ids.join("");
|
||||
|
||||
log::trace!("exit: unique_string -> {}", unique_id);
|
||||
unique_id
|
||||
}
|
||||
|
||||
/// wrapper for sending a filter to the filters event handler
|
||||
fn send_filter(&self, filter: WildcardFilter) -> Result<()> {
|
||||
self.handles
|
||||
.filters
|
||||
.send(Command::AddFilter(Box::new(filter)))
|
||||
}
|
||||
|
||||
/// Tests the given url to see if it issues a wildcard response
|
||||
///
|
||||
/// In the event that url returns a wildcard response, a
|
||||
/// [WildcardFilter](struct.WildcardFilter.html) is created and sent to the filters event
|
||||
/// handler.
|
||||
///
|
||||
/// Returns the number of times to increment the caller's progress bar
|
||||
pub async fn wildcard(&self, target_url: &str) -> Result<u64> {
|
||||
log::trace!("enter: wildcard_test({:?})", target_url);
|
||||
|
||||
if self.handles.config.dont_filter {
|
||||
// early return, dont_filter scans don't need tested
|
||||
log::trace!("exit: wildcard_test -> 0");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let data = match self.handles.config.data.is_empty() {
|
||||
true => None,
|
||||
false => Some(self.handles.config.data.as_slice()),
|
||||
};
|
||||
|
||||
let ferox_url = FeroxUrl::from_string(target_url, self.handles.clone());
|
||||
|
||||
for method in self.handles.config.methods.iter() {
|
||||
let ferox_response = self
|
||||
.make_wildcard_request(&ferox_url, method.as_str(), data, 1)
|
||||
.await?;
|
||||
|
||||
// found a wildcard response
|
||||
let mut wildcard = WildcardFilter::new(self.handles.config.dont_filter);
|
||||
|
||||
let wc_length = ferox_response.content_length();
|
||||
|
||||
if wc_length == 0 {
|
||||
log::trace!("exit: wildcard_test -> 1");
|
||||
self.send_filter(wildcard)?;
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
// content length of wildcard is non-zero, perform additional tests:
|
||||
// make a second request, with a known-sized (64) longer request
|
||||
let resp_two = self
|
||||
.make_wildcard_request(&ferox_url, method.as_str(), data, 3)
|
||||
.await?;
|
||||
|
||||
let wc2_length = resp_two.content_length();
|
||||
|
||||
wildcard.method = resp_two.method().as_str().to_owned();
|
||||
|
||||
if wc2_length == wc_length + (UUID_LENGTH * 2) {
|
||||
// second length is what we'd expect to see if the requested url is
|
||||
// reflected in the response along with some static content; aka custom 404
|
||||
let url_len = get_url_path_length(&ferox_response.url());
|
||||
let url_len = ferox_url.path_length()?;
|
||||
|
||||
wildcard.dynamic = wc_length - url_len;
|
||||
|
||||
if !CONFIGURATION.quiet {
|
||||
let msg = format!(
|
||||
"{} {:>8}l {:>8}w {:>8}c Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n",
|
||||
status_colorizer("WLD"),
|
||||
ferox_response.line_count(),
|
||||
ferox_response.word_count(),
|
||||
wildcard.dynamic,
|
||||
style("auto-filtering").yellow(),
|
||||
style(wc_length - url_len).cyan(),
|
||||
style("--dont-filter").yellow()
|
||||
);
|
||||
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
let msg = format_template!("{} {:>8} {:>9} {:>9} {:>9} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n", method, wildcard.dynamic);
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
|
||||
try_send_message_to_file(
|
||||
&msg,
|
||||
tx_file.clone(),
|
||||
!CONFIGURATION.output.is_empty(),
|
||||
);
|
||||
}
|
||||
} else if wc_length == wc2_length {
|
||||
wildcard.size = wc_length;
|
||||
|
||||
if !CONFIGURATION.quiet {
|
||||
let msg = format!(
|
||||
"{} {:>8}l {:>8}w {:>8}c Wildcard response is static; {} {} responses; toggle this behavior by using {}\n",
|
||||
status_colorizer("WLD"),
|
||||
ferox_response.line_count(),
|
||||
ferox_response.word_count(),
|
||||
wc_length,
|
||||
style("auto-filtering").yellow(),
|
||||
style(wc_length).cyan(),
|
||||
style("--dont-filter").yellow()
|
||||
);
|
||||
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
let msg = format_template!("{} {:>8} {:>9} {:>9} {:>9} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n", method, wildcard.size);
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
|
||||
try_send_message_to_file(
|
||||
&msg,
|
||||
tx_file.clone(),
|
||||
!CONFIGURATION.output.is_empty(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bar.inc(2);
|
||||
|
||||
self.send_filter(wildcard)?;
|
||||
}
|
||||
|
||||
log::trace!("exit: wildcard_test -> Some({:?})", wildcard);
|
||||
return Some(wildcard);
|
||||
log::trace!("exit: wildcard_test");
|
||||
Ok(2)
|
||||
}
|
||||
|
||||
log::trace!("exit: wildcard_test -> None");
|
||||
None
|
||||
}
|
||||
/// Generates a uuid and appends it to the given target url. The reasoning is that the randomly
|
||||
/// generated unique string should not exist on and be served by the target web server.
|
||||
///
|
||||
/// Once the unique url is created, the request is sent to the server. If the server responds
|
||||
/// back with a valid status code, the response is considered to be a wildcard response. If that
|
||||
/// wildcard response has a 3xx status code, that redirection location is displayed to the user.
|
||||
async fn make_wildcard_request(
|
||||
&self,
|
||||
target: &FeroxUrl,
|
||||
method: &str,
|
||||
data: Option<&[u8]>,
|
||||
length: usize,
|
||||
) -> Result<FeroxResponse> {
|
||||
log::trace!("enter: make_wildcard_request({}, {})", target, length);
|
||||
|
||||
/// Generates a uuid and appends it to the given target url. The reasoning is that the randomly
|
||||
/// generated unique string should not exist on and be served by the target web server.
|
||||
///
|
||||
/// Once the unique url is created, the request is sent to the server. If the server responds
|
||||
/// back with a valid status code, the response is considered to be a wildcard response. If that
|
||||
/// wildcard response has a 3xx status code, that redirection location is displayed to the user.
|
||||
async fn make_wildcard_request(
|
||||
target_url: &str,
|
||||
length: usize,
|
||||
tx_file: UnboundedSender<String>,
|
||||
) -> Option<FeroxResponse> {
|
||||
log::trace!(
|
||||
"enter: make_wildcard_request({}, {}, {:?})",
|
||||
target_url,
|
||||
length,
|
||||
tx_file
|
||||
);
|
||||
let unique_str = self.unique_string(length);
|
||||
|
||||
let unique_str = unique_string(length);
|
||||
// To take care of slash when needed
|
||||
let slash = if self.handles.config.add_slash {
|
||||
Some("/")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let nonexistent = match format_url(
|
||||
target_url,
|
||||
&unique_str,
|
||||
CONFIGURATION.add_slash,
|
||||
&CONFIGURATION.queries,
|
||||
None,
|
||||
) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
log::trace!("exit: make_wildcard_request -> None");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let nonexistent_url = target.format(&unique_str, slash)?;
|
||||
|
||||
let wildcard = status_colorizer("WLD");
|
||||
let response = logged_request(
|
||||
&nonexistent_url.to_owned(),
|
||||
method,
|
||||
data,
|
||||
self.handles.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match make_request(&CONFIGURATION.client, &nonexistent.to_owned()).await {
|
||||
Ok(response) => {
|
||||
if CONFIGURATION
|
||||
.status_codes
|
||||
.contains(&response.status().as_u16())
|
||||
if self
|
||||
.handles
|
||||
.config
|
||||
.status_codes
|
||||
.contains(&response.status().as_u16())
|
||||
{
|
||||
// found a wildcard response
|
||||
let mut ferox_response = FeroxResponse::from(
|
||||
response,
|
||||
&target.target,
|
||||
method,
|
||||
true,
|
||||
self.handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
ferox_response.set_wildcard(true);
|
||||
|
||||
if self
|
||||
.handles
|
||||
.filters
|
||||
.data
|
||||
.should_filter_response(&ferox_response, self.handles.stats.tx.clone())
|
||||
{
|
||||
// found a wildcard response
|
||||
let ferox_response = FeroxResponse::from(response, true).await;
|
||||
let url_len = get_url_path_length(&ferox_response.url());
|
||||
let content_len = ferox_response.content_length();
|
||||
let content_words = ferox_response.word_count();
|
||||
let content_lines = ferox_response.line_count();
|
||||
bail!("filtered response")
|
||||
}
|
||||
|
||||
if !CONFIGURATION.quiet && !should_filter_response(&ferox_response) {
|
||||
let msg = format!(
|
||||
"{} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
|
||||
wildcard,
|
||||
content_lines,
|
||||
content_words,
|
||||
content_len,
|
||||
status_colorizer(&ferox_response.status().as_str()),
|
||||
ferox_response.url(),
|
||||
url_len
|
||||
);
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
let boxed = Box::new(ferox_response.clone());
|
||||
self.handles.output.send(Command::Report(boxed))?;
|
||||
}
|
||||
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
log::trace!("exit: make_wildcard_request -> {}", ferox_response);
|
||||
return Ok(ferox_response);
|
||||
}
|
||||
|
||||
try_send_message_to_file(
|
||||
&msg,
|
||||
tx_file.clone(),
|
||||
!CONFIGURATION.output.is_empty(),
|
||||
);
|
||||
log::trace!("exit: make_wildcard_request -> Err");
|
||||
bail!("uninteresting status code")
|
||||
}
|
||||
|
||||
/// Simply tries to connect to all given sites before starting to scan
|
||||
///
|
||||
/// In the event that no sites can be reached, the program will exit.
|
||||
///
|
||||
/// Any urls that are found to be alive are returned to the caller.
|
||||
pub async fn connectivity(&self, target_urls: &[String]) -> Result<Vec<String>> {
|
||||
log::trace!("enter: connectivity_test({:?})", target_urls);
|
||||
|
||||
let mut good_urls = vec![];
|
||||
|
||||
for target_url in target_urls {
|
||||
let url = FeroxUrl::from_string(target_url, self.handles.clone());
|
||||
let request = skip_fail!(url.format("", None));
|
||||
|
||||
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
good_urls.push(target_url.to_owned());
|
||||
}
|
||||
|
||||
if ferox_response.status().is_redirection() {
|
||||
// show where it goes, if possible
|
||||
if let Some(next_loc) = ferox_response.headers().get("Location") {
|
||||
let next_loc_str = next_loc.to_str().unwrap_or("Unknown");
|
||||
if !CONFIGURATION.quiet && !should_filter_response(&ferox_response) {
|
||||
let msg = format!(
|
||||
"{} {:>8}l {:>8}w {:>8}c {} redirects to => {}\n",
|
||||
wildcard,
|
||||
content_lines,
|
||||
content_words,
|
||||
content_len,
|
||||
ferox_response.url(),
|
||||
next_loc_str
|
||||
Err(e) => {
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
if e.to_string().contains(":SSL") {
|
||||
ferox_print(
|
||||
&format!("Could not connect to {} due to SSL errors (run with -k to ignore), skipping...", target_url),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
|
||||
try_send_message_to_file(
|
||||
&msg,
|
||||
tx_file.clone(),
|
||||
!CONFIGURATION.output.is_empty(),
|
||||
} else {
|
||||
ferox_print(
|
||||
&format!("Could not connect to {}, skipping...", target_url),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
}
|
||||
}
|
||||
log::warn!("{}", e);
|
||||
}
|
||||
log::trace!("exit: make_wildcard_request -> {}", ferox_response);
|
||||
return Some(ferox_response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{}", e);
|
||||
log::trace!("exit: make_wildcard_request -> None");
|
||||
return None;
|
||||
|
||||
if good_urls.is_empty() {
|
||||
bail!("Could not connect to any target provided");
|
||||
}
|
||||
|
||||
log::trace!("exit: connectivity_test -> {:?}", good_urls);
|
||||
Ok(good_urls)
|
||||
}
|
||||
log::trace!("exit: make_wildcard_request -> None");
|
||||
None
|
||||
}
|
||||
|
||||
/// Simply tries to connect to all given sites before starting to scan
|
||||
///
|
||||
/// In the event that no sites can be reached, the program will exit.
|
||||
///
|
||||
/// Any urls that are found to be alive are returned to the caller.
|
||||
pub async fn connectivity_test(target_urls: &[String]) -> Vec<String> {
|
||||
log::trace!("enter: connectivity_test({:?})", target_urls);
|
||||
|
||||
let mut good_urls = vec![];
|
||||
|
||||
for target_url in target_urls {
|
||||
let request = match format_url(
|
||||
target_url,
|
||||
"",
|
||||
CONFIGURATION.add_slash,
|
||||
&CONFIGURATION.queries,
|
||||
None,
|
||||
) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match make_request(&CONFIGURATION.client, &request).await {
|
||||
Ok(_) => {
|
||||
good_urls.push(target_url.to_owned());
|
||||
}
|
||||
Err(e) => {
|
||||
if !CONFIGURATION.quiet {
|
||||
ferox_print(
|
||||
&format!("Could not connect to {}, skipping...", target_url),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
}
|
||||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if good_urls.is_empty() {
|
||||
log::error!("Could not connect to any target provided, exiting.");
|
||||
}
|
||||
|
||||
log::trace!("exit: connectivity_test -> {:?}", good_urls);
|
||||
|
||||
good_urls
|
||||
}
|
||||
|
||||
/// simple helper to keep DRY; sends a message using the transmitter side of the given mpsc channel
|
||||
/// the receiver is expected to be the side that saves the message to CONFIGURATION.output.
|
||||
fn try_send_message_to_file(msg: &str, tx_file: UnboundedSender<String>, save_output: bool) {
|
||||
log::trace!("enter: try_send_message_to_file({}, {:?})", msg, tx_file);
|
||||
|
||||
if save_output {
|
||||
match tx_file.send(msg.to_string()) {
|
||||
Ok(_) => {
|
||||
log::trace!(
|
||||
"sent message from heuristics::try_send_message_to_file to file handler"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"{} {}",
|
||||
module_colorizer("heuristics::try_send_message_to_file"),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("exit: try_send_message_to_file");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::FeroxChannel;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
/// request a unique string of 32bytes * a value returns correct result
|
||||
fn heuristics_unique_string_returns_correct_length() {
|
||||
let (handles, _) = Handles::for_testing(None, None);
|
||||
let tester = HeuristicTests::new(Arc::new(handles));
|
||||
for i in 0..10 {
|
||||
assert_eq!(unique_string(i).len(), i * 32);
|
||||
assert_eq!(tester.unique_string(i).len(), i * 32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// simply test the default values for wildcardfilter, expect 0, 0
|
||||
fn heuristics_wildcardfilter_dafaults() {
|
||||
let wcf = WildcardFilter::default();
|
||||
assert_eq!(wcf.size, 0);
|
||||
assert_eq!(wcf.dynamic, 0);
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// tests that given a message and transmitter, the function sends the message across the
|
||||
/// channel
|
||||
async fn heuristics_try_send_message_to_file_sends_when_true() {
|
||||
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
|
||||
let msg = "It really tied the room together.";
|
||||
let should_save = true;
|
||||
try_send_message_to_file(&msg, tx, should_save);
|
||||
|
||||
assert_eq!(rx.recv().await.unwrap(), msg);
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
#[should_panic]
|
||||
/// tests that when save_output is false, nothing is sent to the receiver
|
||||
async fn heuristics_try_send_message_to_file_sends_when_false() {
|
||||
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
|
||||
let msg = "I'm the Dude, so that's what you call me.";
|
||||
let should_save = false;
|
||||
try_send_message_to_file(&msg, tx, should_save);
|
||||
|
||||
assert_ne!(rx.recv().await.unwrap(), msg);
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// tests that when save_output is true, but the receiver is closed, nothing is sent to the receiver
|
||||
/// this test doesn't assert anything, but reaches the error block of the given function and
|
||||
/// can be verified with --nocapture and RUST_LOG being set
|
||||
async fn heuristics_try_send_message_to_file_sends_with_closed_receiver() {
|
||||
env_logger::init();
|
||||
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
|
||||
let msg = "Hey, nice marmot.";
|
||||
let should_save = true;
|
||||
rx.close();
|
||||
try_send_message_to_file(&msg, tx, should_save);
|
||||
}
|
||||
}
|
||||
|
||||
226
src/lib.rs
@@ -1,49 +1,52 @@
|
||||
use anyhow::Result;
|
||||
use reqwest::StatusCode;
|
||||
use tokio::{
|
||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
use crate::event_handlers::Command;
|
||||
|
||||
pub mod banner;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod extractor;
|
||||
mod client;
|
||||
pub mod event_handlers;
|
||||
pub mod filters;
|
||||
pub mod heuristics;
|
||||
pub mod logger;
|
||||
pub mod parser;
|
||||
mod parser;
|
||||
pub mod progress;
|
||||
pub mod reporter;
|
||||
pub mod scan_manager;
|
||||
pub mod scanner;
|
||||
pub mod statistics;
|
||||
mod traits;
|
||||
pub mod utils;
|
||||
mod extractor;
|
||||
mod macros;
|
||||
mod url;
|
||||
mod response;
|
||||
mod message;
|
||||
|
||||
use reqwest::{
|
||||
header::HeaderMap,
|
||||
{Response, StatusCode, Url},
|
||||
};
|
||||
use std::{error, fmt};
|
||||
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
/// Alias for tokio::sync::mpsc::UnboundedSender<Command>
|
||||
pub(crate) type CommandSender = UnboundedSender<Command>;
|
||||
|
||||
/// Generic Result type to ease error handling in async contexts
|
||||
pub type FeroxResult<T> = std::result::Result<T, Box<dyn error::Error + Send + Sync + 'static>>;
|
||||
/// Alias for tokio::sync::mpsc::UnboundedSender<Command>
|
||||
pub(crate) type CommandReceiver = UnboundedReceiver<Command>;
|
||||
|
||||
/// Simple Error implementation to allow for custom error returns
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeroxError {
|
||||
/// fancy string that can be printed via Display
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl error::Error for FeroxError {}
|
||||
|
||||
impl fmt::Display for FeroxError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", &self.message)
|
||||
}
|
||||
}
|
||||
/// Alias for tokio::task::JoinHandle<anyhow::Result<()>>
|
||||
pub(crate) type Joiner = JoinHandle<Result<()>>;
|
||||
|
||||
/// Generic mpsc::unbounded_channel type to tidy up some code
|
||||
pub type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>);
|
||||
pub(crate) type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>);
|
||||
|
||||
/// Version pulled from Cargo.toml at compile time
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Maximum number of file descriptors that can be opened during a scan
|
||||
pub const DEFAULT_OPEN_FILE_LIMIT: usize = 8192;
|
||||
pub const DEFAULT_OPEN_FILE_LIMIT: u64 = 8192;
|
||||
|
||||
/// Default value used to determine near-duplicate web pages (equivalent to 95%)
|
||||
pub const SIMILARITY_THRESHOLD: u32 = 95;
|
||||
|
||||
/// Default 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.
|
||||
@@ -54,7 +57,10 @@ pub const DEFAULT_WORDLIST: &str =
|
||||
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
|
||||
|
||||
/// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan
|
||||
pub static SLEEP_DURATION: u64 = 500;
|
||||
pub(crate) const SLEEP_DURATION: u64 = 500;
|
||||
|
||||
/// The percentage of requests as errors it takes to be deemed too high
|
||||
pub const HIGH_ERROR_RATIO: f64 = 0.90;
|
||||
|
||||
/// Default list of status codes to report
|
||||
///
|
||||
@@ -67,7 +73,8 @@ pub static SLEEP_DURATION: u64 = 500;
|
||||
/// * 401 Unauthorized
|
||||
/// * 403 Forbidden
|
||||
/// * 405 Method Not Allowed
|
||||
pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
|
||||
/// * 500 Internal Server Error
|
||||
pub const DEFAULT_STATUS_CODES: [StatusCode; 10] = [
|
||||
StatusCode::OK,
|
||||
StatusCode::NO_CONTENT,
|
||||
StatusCode::MOVED_PERMANENTLY,
|
||||
@@ -77,150 +84,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";
|
||||
|
||||
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeroxResponse {
|
||||
/// The final `Url` of this `FeroxResponse`
|
||||
url: Url,
|
||||
|
||||
/// The `StatusCode` of this `FeroxResponse`
|
||||
status: StatusCode,
|
||||
|
||||
/// The full response text
|
||||
text: String,
|
||||
|
||||
/// The content-length of this response, if known
|
||||
content_length: u64,
|
||||
|
||||
/// The `Headers` of this `FeroxResponse`
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
/// Implement Display for FeroxResponse
|
||||
impl fmt::Display for FeroxResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"FeroxResponse {{ url: {}, status: {}, content-length: {} }}",
|
||||
self.url(),
|
||||
self.status(),
|
||||
self.content_length()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `FeroxResponse` implementation
|
||||
impl FeroxResponse {
|
||||
/// Get the `StatusCode` of this `FeroxResponse`
|
||||
pub fn status(&self) -> &StatusCode {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get the final `Url` of this `FeroxResponse`.
|
||||
pub fn url(&self) -> &Url {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Get the full response text
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Get the `Headers` of this `FeroxResponse`
|
||||
pub fn headers(&self) -> &HeaderMap {
|
||||
&self.headers
|
||||
}
|
||||
|
||||
/// Get the content-length of this response, if known
|
||||
pub fn content_length(&self) -> u64 {
|
||||
self.content_length
|
||||
}
|
||||
|
||||
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
|
||||
pub fn set_url(&mut self, url: &str) {
|
||||
match Url::parse(&url) {
|
||||
Ok(url) => {
|
||||
self.url = url;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not parse {} into a Url: {}", url, e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Make a reasonable guess at whether the response is a file or not
|
||||
///
|
||||
/// Examines the last part of a path to determine if it has an obvious extension
|
||||
/// i.e. http://localhost/some/path/stuff.js where stuff.js indicates a file
|
||||
///
|
||||
/// Additionally, inspects query parameters, as they're also often indicative of a file
|
||||
pub fn is_file(&self) -> bool {
|
||||
let has_extension = match self.url.path_segments() {
|
||||
Some(path) => {
|
||||
if let Some(last) = path.last() {
|
||||
last.contains('.') // last segment has some sort of extension, probably
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
self.url.query_pairs().count() > 0 || has_extension
|
||||
}
|
||||
|
||||
/// Returns line count of the response text.
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.text().lines().count()
|
||||
}
|
||||
|
||||
/// Returns word count of the response text.
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.text()
|
||||
.lines()
|
||||
.map(|s| s.split_whitespace().count())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Create a new `FeroxResponse` from the given `Response`
|
||||
pub async fn from(response: Response, read_body: bool) -> Self {
|
||||
let url = response.url().clone();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let content_length = response.content_length().unwrap_or(0);
|
||||
|
||||
let text = if read_body {
|
||||
// .text() consumes the response, must be called last
|
||||
// additionally, --extract-links is currently the only place we use the body of the
|
||||
// response, so we forego the processing if not performing extraction
|
||||
match response.text().await {
|
||||
// await the response's body
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::error!("Could not parse body from response: {}", e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
FeroxResponse {
|
||||
url,
|
||||
status,
|
||||
content_length,
|
||||
text,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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 {
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
|
||||
use crate::reporter::{get_cached_file_handle, safe_file_write};
|
||||
use console::{style, Color};
|
||||
use env_logger::Builder;
|
||||
use std::env;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::BufWriter;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use env_logger::Builder;
|
||||
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
message::FeroxMessage,
|
||||
progress::PROGRESS_PRINTER,
|
||||
traits::FeroxSerialize,
|
||||
utils::{fmt_err, write_to},
|
||||
};
|
||||
|
||||
/// Create a customized instance of
|
||||
/// [env_logger::Logger](https://docs.rs/env_logger/latest/env_logger/struct.Logger.html)
|
||||
/// with timer offset/color and set the log level based on `verbosity`
|
||||
pub fn initialize(verbosity: u8) {
|
||||
pub fn initialize(config: Arc<Configuration>) -> Result<()> {
|
||||
// use occurrences of -v on commandline to or verbosity = N in feroxconfig.toml to set
|
||||
// log level for the application; respects already specified RUST_LOG environment variable
|
||||
match env::var("RUST_LOG") {
|
||||
Ok(_) => {} // RUST_LOG found, don't override
|
||||
Err(_) => {
|
||||
// only set log level based on verbosity when RUST_LOG variable doesn't exist
|
||||
match verbosity {
|
||||
match config.verbosity {
|
||||
0 => (),
|
||||
1 => env::set_var("RUST_LOG", "warn"),
|
||||
2 => env::set_var("RUST_LOG", "info"),
|
||||
@@ -28,47 +38,44 @@ pub fn initialize(verbosity: u8) {
|
||||
let start = Instant::now();
|
||||
let mut builder = Builder::from_default_env();
|
||||
|
||||
// I REALLY wanted the logger to also use the reporting channels found in the `reporter`
|
||||
// module. However, in order to properly clean up the channels, all references to the
|
||||
// transmitter side of a channel need to go out of scope, then you can await the future into
|
||||
// which the receiver was moved.
|
||||
//
|
||||
// The problem was that putting a transmitter reference in this closure, which gets initialized
|
||||
// as part of the global logger, made it so that I couldn't destroy/leak/take/swap the last
|
||||
// reference to allow the channels to gracefully close.
|
||||
//
|
||||
// The workaround was to have a RwLock around the file and allow both the logger and the
|
||||
// file handler to both write independent of each other.
|
||||
let locked_file = get_cached_file_handle(&CONFIGURATION.output);
|
||||
let file = if !config.debug_log.is_empty() {
|
||||
let f = OpenOptions::new() // std fs
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&config.debug_log)
|
||||
.with_context(|| fmt_err(&format!("Could not open {}", &config.debug_log)))?;
|
||||
|
||||
let mut writer = BufWriter::new(f);
|
||||
|
||||
// write out the configuration to the debug file if it exists
|
||||
write_to(&*config, &mut writer, config.json)?;
|
||||
|
||||
Some(Arc::new(RwLock::new(writer)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
builder
|
||||
.format(move |_, record| {
|
||||
let t = start.elapsed().as_secs_f32();
|
||||
let level = record.level();
|
||||
|
||||
let (level_name, level_color) = match level {
|
||||
log::Level::Error => ("ERR", Color::Red),
|
||||
log::Level::Warn => ("WRN", Color::Red),
|
||||
log::Level::Info => ("INF", Color::Cyan),
|
||||
log::Level::Debug => ("DBG", Color::Yellow),
|
||||
log::Level::Trace => ("TRC", Color::Magenta),
|
||||
let log_entry = FeroxMessage {
|
||||
message: record.args().to_string(),
|
||||
level: record.level().to_string(),
|
||||
time_offset: start.elapsed().as_secs_f32(),
|
||||
module: record.target().to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
|
||||
let msg = format!(
|
||||
"{} {:10.03} {} {}\n",
|
||||
style(level_name).bg(level_color).black(),
|
||||
style(t).dim(),
|
||||
record.target(),
|
||||
style(record.args()).dim(),
|
||||
);
|
||||
PROGRESS_PRINTER.println(&log_entry.as_str());
|
||||
|
||||
PROGRESS_PRINTER.println(&msg);
|
||||
|
||||
if let Some(buffered_file) = locked_file.clone() {
|
||||
safe_file_write(&msg, buffered_file);
|
||||
if let Some(buffered_file) = file.clone() {
|
||||
if let Ok(mut unlocked) = buffered_file.write() {
|
||||
let _ = write_to(&log_entry, &mut unlocked, config.json);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
23
src/macros.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
#![macro_use]
|
||||
|
||||
#[macro_export]
|
||||
/// wrapper to improve code readability
|
||||
macro_rules! send_command {
|
||||
($tx:expr, $value:expr) => {
|
||||
$tx.send($value).unwrap_or_default();
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
/// while looping, check for a Result, if Ok return the value, if Err, continue
|
||||
macro_rules! skip_fail {
|
||||
($res:expr) => {
|
||||
match $res {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
log::warn!("{}", fmt_err(&format!("{}; skipping...", e)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
545
src/main.rs
@@ -1,87 +1,62 @@
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
use std::{
|
||||
env::args,
|
||||
fs::{create_dir, remove_file, File},
|
||||
io::{stderr, BufRead, BufReader},
|
||||
ops::Index,
|
||||
path::Path,
|
||||
process::Command,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use futures::StreamExt;
|
||||
use tokio::{
|
||||
io,
|
||||
sync::{oneshot, Semaphore},
|
||||
};
|
||||
use tokio_util::codec::{FramedRead, LinesCodec};
|
||||
|
||||
use feroxbuster::{
|
||||
banner,
|
||||
config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER},
|
||||
heuristics, logger, reporter,
|
||||
scanner::{scan_url, PAUSE_SCAN},
|
||||
utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer},
|
||||
FeroxError, FeroxResponse, FeroxResult, SLEEP_DURATION, VERSION,
|
||||
banner::{Banner, UPDATE_URL},
|
||||
config::{Configuration, OutputLevel},
|
||||
event_handlers::{
|
||||
Command::{CreateBar, Exit, JoinTasks, LoadStats, ScanInitialUrls, UpdateWordlist},
|
||||
FiltersHandler, Handles, ScanHandler, StatsHandler, Tasks, TermInputHandler,
|
||||
TermOutHandler, SCAN_COMPLETE,
|
||||
},
|
||||
filters, heuristics, logger,
|
||||
progress::{PROGRESS_BAR, PROGRESS_PRINTER},
|
||||
scan_manager::{self},
|
||||
scanner,
|
||||
utils::{fmt_err, slugify_filename},
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use feroxbuster::{utils::set_open_file_limit, DEFAULT_OPEN_FILE_LIMIT};
|
||||
use futures::StreamExt;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{stderr, BufRead, BufReader},
|
||||
process,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{io, sync::mpsc::UnboundedSender, task::JoinHandle};
|
||||
use tokio_util::codec::{FramedRead, LinesCodec};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
/// Atomic boolean flag, used to determine whether or not the terminal input handler should exit
|
||||
pub static SCAN_COMPLETE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Handles specific key events triggered by the user over stdin
|
||||
fn terminal_input_handler() {
|
||||
log::trace!("enter: terminal_input_handler");
|
||||
|
||||
loop {
|
||||
if event::poll(Duration::from_millis(SLEEP_DURATION)).unwrap_or(false) {
|
||||
// It's guaranteed that the `read()` won't block when the `poll()`
|
||||
// function returns `true`
|
||||
|
||||
if let Ok(key_pressed) = event::read() {
|
||||
if key_pressed == Event::Key(KeyCode::Enter.into()) {
|
||||
// if the user presses Enter, toggle the value stored in PAUSE_SCAN
|
||||
// ignore any other keys
|
||||
let current = PAUSE_SCAN.load(Ordering::Acquire);
|
||||
|
||||
PAUSE_SCAN.store(!current, Ordering::Release);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Timeout expired and no `Event` is available; use the timeout to check SCAN_COMPLETE
|
||||
if SCAN_COMPLETE.load(Ordering::Relaxed) {
|
||||
// scan has been marked complete by main, time to exit the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("exit: terminal_input_handler");
|
||||
lazy_static! {
|
||||
/// Limits the number of parallel scans active at any given time when using --parallel
|
||||
static ref PARALLEL_LIMITER: Semaphore = Semaphore::new(0);
|
||||
}
|
||||
|
||||
/// Create a HashSet of Strings from the given wordlist then stores it inside an Arc
|
||||
fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>>> {
|
||||
fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<Vec<String>>> {
|
||||
log::trace!("enter: get_unique_words_from_wordlist({})", path);
|
||||
|
||||
let file = match File::open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::error!("Could not open wordlist: {}", e);
|
||||
log::trace!("exit: get_unique_words_from_wordlist -> {}", e);
|
||||
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
};
|
||||
let file = File::open(&path).with_context(|| format!("Could not open {}", path))?;
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut words = HashSet::new();
|
||||
let mut words = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let result = line?;
|
||||
|
||||
if result.starts_with('#') || result.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
words.insert(result);
|
||||
line.map(|result| {
|
||||
if !result.starts_with('#') && !result.is_empty() {
|
||||
words.push(result);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
log::trace!(
|
||||
@@ -93,54 +68,59 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
|
||||
}
|
||||
|
||||
/// Determine whether it's a single url scan or urls are coming from stdin, then scan as needed
|
||||
async fn scan(
|
||||
targets: Vec<String>,
|
||||
tx_term: UnboundedSender<FeroxResponse>,
|
||||
tx_file: UnboundedSender<String>,
|
||||
) -> FeroxResult<()> {
|
||||
log::trace!("enter: scan({:?}, {:?}, {:?})", targets, tx_term, tx_file);
|
||||
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 =
|
||||
tokio::spawn(async move { get_unique_words_from_wordlist(&CONFIGURATION.wordlist) })
|
||||
.await??;
|
||||
|
||||
let words = get_unique_words_from_wordlist(&handles.config.wordlist)?;
|
||||
|
||||
if words.len() == 0 {
|
||||
let mut err = FeroxError::default();
|
||||
err.message = format!("Did not find any words in {}", CONFIGURATION.wordlist);
|
||||
return Err(Box::new(err));
|
||||
bail!("Did not find any words in {}", handles.config.wordlist);
|
||||
}
|
||||
|
||||
let mut tasks = vec![];
|
||||
let scanned_urls = handles.ferox_scans()?;
|
||||
|
||||
for target in targets {
|
||||
let word_clone = words.clone();
|
||||
let term_clone = tx_term.clone();
|
||||
let file_clone = tx_file.clone();
|
||||
handles.send_scan_command(UpdateWordlist(words.clone()))?;
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let base_depth = get_current_depth(&target);
|
||||
scan_url(&target, word_clone, base_depth, term_clone, file_clone).await;
|
||||
});
|
||||
scanner::initialize(words.len(), handles.clone()).await?;
|
||||
|
||||
tasks.push(task);
|
||||
// at this point, the stat thread's progress bar can be created; things that needed to happen
|
||||
// first:
|
||||
// - banner gets printed
|
||||
// - scanner initialized (this sent expected requests per directory to the stats thread, which
|
||||
// having been set, makes it so the progress bar doesn't flash as full before anything has
|
||||
// even happened
|
||||
if matches!(handles.config.output_level, OutputLevel::Default) {
|
||||
// only create the bar if no --silent|--quiet
|
||||
handles.stats.send(CreateBar)?;
|
||||
|
||||
// blocks until the bar is created / avoids race condition in first two bars
|
||||
handles.stats.sync().await?;
|
||||
}
|
||||
|
||||
// drive execution of all accumulated futures
|
||||
futures::future::join_all(tasks).await;
|
||||
if handles.config.resumed {
|
||||
// display what has already been completed
|
||||
scanned_urls.print_known_responses();
|
||||
scanned_urls.print_completed_bars(words.len())?;
|
||||
}
|
||||
|
||||
log::debug!("sending {:?} to be scanned as initial targets", targets);
|
||||
handles.send_scan_command(ScanInitialUrls(targets))?;
|
||||
|
||||
log::trace!("exit: scan");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get targets from either commandline or stdin, pass them back to the caller as a Result<Vec>
|
||||
async fn get_targets() -> FeroxResult<Vec<String>> {
|
||||
log::trace!("enter: get_targets");
|
||||
async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
|
||||
log::trace!("enter: get_targets({:?})", handles);
|
||||
|
||||
let mut targets = vec![];
|
||||
|
||||
if CONFIGURATION.stdin {
|
||||
if handles.config.stdin {
|
||||
// got targets from stdin, i.e. cat sites | ./feroxbuster ...
|
||||
// just need to read the targets from stdin and spawn a future for each target found
|
||||
let stdin = io::stdin(); // tokio's stdin, not std
|
||||
@@ -149,8 +129,47 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
|
||||
while let Some(line) = reader.next().await {
|
||||
targets.push(line?);
|
||||
}
|
||||
} else if handles.config.resumed {
|
||||
// resume-from can't be used with --url, and --stdin is marked false for every resumed
|
||||
// scan, making it mutually exclusive from either of the other two options
|
||||
let ferox_scans = handles.ferox_scans()?;
|
||||
|
||||
if let Ok(scans) = ferox_scans.scans.read() {
|
||||
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
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.push(scan.url().to_owned());
|
||||
}
|
||||
};
|
||||
} else {
|
||||
targets.push(CONFIGURATION.target_url.clone());
|
||||
targets.push(handles.config.target_url.clone());
|
||||
}
|
||||
|
||||
// remove footgun that arises if a --dont-scan value matches on a base url
|
||||
for target in &targets {
|
||||
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);
|
||||
@@ -160,7 +179,7 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
|
||||
|
||||
/// async main called from real main, broken out in this way to allow for some synchronous code
|
||||
/// to be executed before bringing the tokio runtime online
|
||||
async fn wrapped_main() {
|
||||
async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
|
||||
// join can only be called once, otherwise it causes the thread to panic
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// ok, lazy_static! uses (unsurprisingly in retrospect) a lazy loading model where the
|
||||
@@ -174,113 +193,275 @@ async fn wrapped_main() {
|
||||
PROGRESS_BAR.join().unwrap();
|
||||
});
|
||||
|
||||
// spawn all event handlers, expect back a JoinHandle and a *Handle to the specific event
|
||||
let (stats_task, stats_handle) = StatsHandler::initialize(config.clone());
|
||||
let (filters_task, filters_handle) = FiltersHandler::initialize();
|
||||
let (out_task, out_handle) =
|
||||
TermOutHandler::initialize(config.clone(), stats_handle.tx.clone());
|
||||
|
||||
// bundle up all the disparate handles and JoinHandles (tasks)
|
||||
let handles = Arc::new(Handles::new(
|
||||
stats_handle,
|
||||
filters_handle,
|
||||
out_handle,
|
||||
config.clone(),
|
||||
));
|
||||
|
||||
let (scan_task, scan_handle) = ScanHandler::initialize(handles.clone());
|
||||
|
||||
handles.set_scan_handle(scan_handle); // must be done after Handles initialization
|
||||
|
||||
filters::initialize(handles.clone()).await?; // send user-supplied filters to the handler
|
||||
|
||||
// create new Tasks object, each of these handles is one that will be joined on later
|
||||
let tasks = Tasks::new(out_task, stats_task, filters_task, scan_task);
|
||||
|
||||
if !config.time_limit.is_empty() {
|
||||
// --time-limit value not an empty string, need to kick off the thread that enforces
|
||||
// the limit
|
||||
let time_handles = handles.clone();
|
||||
tokio::spawn(async move { scan_manager::start_max_time_thread(time_handles).await });
|
||||
}
|
||||
|
||||
// can't trace main until after logger is initialized and the above task is started
|
||||
log::trace!("enter: main");
|
||||
log::debug!("{:#?}", *CONFIGURATION);
|
||||
|
||||
// spawn a thread that listens for keyboard input on stdin, when a user presses enter
|
||||
// the input handler will toggle PAUSE_SCAN, which in turn is used to pause and resume
|
||||
// scans that are already running
|
||||
tokio::task::spawn_blocking(terminal_input_handler);
|
||||
// also starts ctrl+c handler
|
||||
TermInputHandler::initialize(handles.clone());
|
||||
|
||||
let save_output = !CONFIGURATION.output.is_empty(); // was -o used?
|
||||
if config.resumed {
|
||||
let scanned_urls = handles.ferox_scans()?;
|
||||
let from_here = config.resume_from.clone();
|
||||
|
||||
let (tx_term, tx_file, term_handle, file_handle) =
|
||||
reporter::initialize(&CONFIGURATION.output, save_output);
|
||||
// populate FeroxScans object with previously seen scans
|
||||
scanned_urls.add_serialized_scans(&from_here)?;
|
||||
|
||||
// populate Stats object with previously known statistics
|
||||
handles.stats.send(LoadStats(from_here))?;
|
||||
}
|
||||
|
||||
// get targets from command line or stdin
|
||||
let targets = match get_targets().await {
|
||||
let targets = match get_targets(handles.clone()).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// should only happen in the event that there was an error reading from stdin
|
||||
log::error!("{} {}", module_colorizer("main::get_targets"), e);
|
||||
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
|
||||
return;
|
||||
clean_up(handles, tasks).await?;
|
||||
bail!("Could not determine initial targets: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
if !CONFIGURATION.quiet {
|
||||
// only print banner if -q isn't used
|
||||
// --parallel branch
|
||||
if config.parallel > 0 {
|
||||
log::trace!("enter: parallel branch");
|
||||
|
||||
PARALLEL_LIMITER.add_permits(config.parallel);
|
||||
|
||||
let invocation = args();
|
||||
|
||||
let para_regex =
|
||||
Regex::new("--stdin|-q|--quiet|--silent|--verbosity|-v|-vv|-vvv|-vvvv").unwrap();
|
||||
|
||||
// remove stdin since only the original process will process targets
|
||||
// remove quiet and silent so we can force silent later to normalize output
|
||||
let mut original = invocation
|
||||
.filter(|s| !para_regex.is_match(s))
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
original.push("--silent".to_string()); // only output modifier allowed
|
||||
|
||||
// we need remove --parallel from command line so we don't hit this branch over and over
|
||||
// but we must remove --parallel N manually; the filter above never sees --parallel and the
|
||||
// value passed to it at the same time, so can't filter them out in one pass
|
||||
|
||||
// unwrap is fine, as it has to be in the args for us to be in this code branch
|
||||
let parallel_index = original.iter().position(|s| *s == "--parallel").unwrap();
|
||||
|
||||
// remove --parallel
|
||||
original.remove(parallel_index);
|
||||
|
||||
// remove N passed to --parallel (it's the same index again since everything shifts
|
||||
// from removing --parallel)
|
||||
original.remove(parallel_index);
|
||||
|
||||
// to log unique files to a shared folder, we need to first check for the presence
|
||||
// of -o|--output.
|
||||
let out_dir = if !config.output.is_empty() {
|
||||
// -o|--output was used, so we'll attempt to create a directory to store the files
|
||||
let output_path = Path::new(&handles.config.output);
|
||||
|
||||
// this only returns None if the path terminates in `..`. Since I don't want to
|
||||
// hand-hold to that degree, we'll unwrap and fail if the output path ends in `..`
|
||||
let base_name = output_path.file_name().unwrap();
|
||||
|
||||
let new_folder = slugify_filename(&base_name.to_string_lossy(), "", "logs");
|
||||
|
||||
let final_path = output_path.with_file_name(&new_folder);
|
||||
|
||||
// create the directory or fail silently, assuming the reason for failure is that
|
||||
// the path exists already
|
||||
create_dir(&final_path).unwrap_or(());
|
||||
|
||||
final_path.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// unvalidated targets fresh from stdin, just spawn children and let them do all checks
|
||||
for target in targets {
|
||||
// add the current target to the provided command
|
||||
let mut cloned = original.clone();
|
||||
|
||||
if !out_dir.is_empty() {
|
||||
// output directory value is not empty, need to join output directory with
|
||||
// unique scan filename
|
||||
|
||||
// unwrap is ok, we already know -o was used
|
||||
let out_idx = original
|
||||
.iter()
|
||||
.position(|s| *s == "--output" || *s == "-o")
|
||||
.unwrap();
|
||||
|
||||
let filename = slugify_filename(&target, "ferox", "log");
|
||||
|
||||
let full_path = Path::new(&out_dir)
|
||||
.join(filename)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// a +1 to the index is fine here, as clap has already validated that
|
||||
// -o|--output has a value associated with it
|
||||
cloned[out_idx + 1] = full_path;
|
||||
}
|
||||
|
||||
cloned.push("-u".to_string());
|
||||
cloned.push(target);
|
||||
|
||||
let bin = cloned.index(0).to_owned(); // user's path to feroxbuster
|
||||
let args = cloned.index(1..).to_vec(); // and args
|
||||
|
||||
let permit = PARALLEL_LIMITER.acquire().await?;
|
||||
|
||||
log::debug!("parallel exec: {} {}", bin, args.join(" "));
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = Command::new(bin)
|
||||
.args(&args)
|
||||
.spawn()
|
||||
.expect("failed to spawn a child process")
|
||||
.wait()
|
||||
.expect("child process errored during execution");
|
||||
|
||||
drop(permit);
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
// the output handler creates an empty file to which it will try to write, because
|
||||
// this happens before we enter the --parallel branch, we need to remove that file
|
||||
// if it's empty
|
||||
let output = handles.config.output.to_owned();
|
||||
|
||||
clean_up(handles, tasks).await?;
|
||||
|
||||
let file = Path::new(&output);
|
||||
if file.exists() {
|
||||
// expectation is that this is always true for the first ferox process
|
||||
if file.metadata()?.len() == 0 {
|
||||
// empty file, attempt to remove it
|
||||
remove_file(file)?;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: parallel branch && wrapped main");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(config.output_level, OutputLevel::Default) {
|
||||
// only print banner if output level is default (no banner on --quiet|--silent)
|
||||
let std_stderr = stderr(); // std::io::stderr
|
||||
banner::initialize(&targets, &CONFIGURATION, &VERSION, std_stderr).await;
|
||||
|
||||
let mut banner = Banner::new(&targets, &config);
|
||||
|
||||
// only interested in the side-effect that sets banner.update_status
|
||||
let _ = banner.check_for_updates(UPDATE_URL, handles.clone()).await;
|
||||
|
||||
if banner.print_to(std_stderr, config.clone()).is_err() {
|
||||
clean_up(handles, tasks).await?;
|
||||
bail!(fmt_err("Could not print banner"));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let send_to_file = !config.output.is_empty();
|
||||
|
||||
// The TermOutHandler spawns a FileOutHandler, so errors in the FileOutHandler never bubble
|
||||
// up due to the TermOutHandler never awaiting the result of FileOutHandler::start (that's
|
||||
// done later here in main). sync checks that the tx/rx connection to the file handler works
|
||||
if send_to_file && handles.output.sync(send_to_file).await.is_err() {
|
||||
// output file specified and file handler could not initialize
|
||||
clean_up(handles, tasks).await?;
|
||||
let msg = format!("Couldn't start {} file handler", config.output);
|
||||
bail!(fmt_err(&msg));
|
||||
}
|
||||
}
|
||||
|
||||
// discard non-responsive targets
|
||||
let live_targets = heuristics::connectivity_test(&targets).await;
|
||||
let live_targets = {
|
||||
let test = heuristics::HeuristicTests::new(handles.clone());
|
||||
let result = test.connectivity(&targets).await;
|
||||
if result.is_err() {
|
||||
clean_up(handles, tasks).await?;
|
||||
bail!(fmt_err(&result.unwrap_err().to_string()));
|
||||
}
|
||||
result?
|
||||
};
|
||||
|
||||
if live_targets.is_empty() {
|
||||
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
|
||||
return;
|
||||
clean_up(handles, tasks).await?;
|
||||
bail!(fmt_err("Could not find any live targets to scan"));
|
||||
}
|
||||
|
||||
// kick off a scan against any targets determined to be responsive
|
||||
match scan(live_targets, tx_term.clone(), tx_file.clone()).await {
|
||||
Ok(_) => {
|
||||
log::info!("All scans complete!");
|
||||
}
|
||||
match scan(live_targets, handles.clone()).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
ferox_print(
|
||||
&format!("{} while scanning: {}", status_colorizer("Error"), e),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
|
||||
process::exit(1);
|
||||
clean_up(handles, tasks).await?;
|
||||
bail!(fmt_err(&format!("Failed while scanning: {}", e)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
|
||||
clean_up(handles, tasks).await?;
|
||||
|
||||
log::trace!("exit: main");
|
||||
log::trace!("exit: wrapped_main");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Single cleanup function that handles all the necessary drops/finishes etc required to gracefully
|
||||
/// shutdown the program
|
||||
async fn clean_up(
|
||||
tx_term: UnboundedSender<FeroxResponse>,
|
||||
term_handle: JoinHandle<()>,
|
||||
tx_file: UnboundedSender<String>,
|
||||
file_handle: Option<JoinHandle<()>>,
|
||||
save_output: bool,
|
||||
) {
|
||||
log::trace!(
|
||||
"enter: clean_up({:?}, {:?}, {:?}, {:?}, {}",
|
||||
tx_term,
|
||||
term_handle,
|
||||
tx_file,
|
||||
file_handle,
|
||||
save_output
|
||||
);
|
||||
async fn clean_up(handles: Arc<Handles>, tasks: Tasks) -> Result<()> {
|
||||
log::trace!("enter: clean_up({:?}, {:?})", handles, tasks);
|
||||
|
||||
drop(tx_term);
|
||||
log::trace!("dropped terminal output handler's transmitter");
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
handles.send_scan_command(JoinTasks(tx))?;
|
||||
rx.await?;
|
||||
|
||||
log::trace!("awaiting terminal output handler's receiver");
|
||||
// after dropping tx, we can await the future where rx lived
|
||||
match term_handle.await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("error awaiting terminal output handler's receiver: {}", e);
|
||||
}
|
||||
}
|
||||
log::trace!("done awaiting terminal output handler's receiver");
|
||||
log::info!("All scans complete!");
|
||||
|
||||
log::trace!("tx_file: {:?}", tx_file);
|
||||
// the same drop/await process used on the terminal handler is repeated for the file handler
|
||||
// we drop the file transmitter every time, because it's created no matter what
|
||||
drop(tx_file);
|
||||
// terminal handler closes file handler if one is in use
|
||||
handles.output.send(Exit)?;
|
||||
tasks.terminal.await??;
|
||||
log::trace!("terminal handler closed");
|
||||
|
||||
log::trace!("dropped file output handler's transmitter");
|
||||
if save_output {
|
||||
// but we only await if -o was specified
|
||||
log::trace!("awaiting file output handler's receiver");
|
||||
match file_handle.unwrap().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("error awaiting file output handler's receiver: {}", e);
|
||||
}
|
||||
}
|
||||
log::trace!("done awaiting file output handler's receiver");
|
||||
}
|
||||
handles.filters.send(Exit)?;
|
||||
tasks.filters.await??;
|
||||
log::trace!("filters handler closed");
|
||||
|
||||
handles.stats.send(Exit)?;
|
||||
tasks.stats.await??;
|
||||
log::trace!("stats handler closed");
|
||||
|
||||
// mark all scans complete so the terminal input handler will exit cleanly
|
||||
SCAN_COMPLETE.store(true, Ordering::Relaxed);
|
||||
@@ -290,18 +471,36 @@ async fn clean_up(
|
||||
PROGRESS_PRINTER.finish();
|
||||
|
||||
log::trace!("exit: clean_up");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
fn main() -> Result<()> {
|
||||
let config = Arc::new(Configuration::new().with_context(|| "Could not create Configuration")?);
|
||||
|
||||
// setup logging based on the number of -v's used
|
||||
logger::initialize(CONFIGURATION.verbosity);
|
||||
if matches!(
|
||||
config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
// don't log on --silent
|
||||
logger::initialize(config.clone())?;
|
||||
}
|
||||
|
||||
// this function uses rlimit, which is not supported on windows
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
set_open_file_limit(DEFAULT_OPEN_FILE_LIMIT);
|
||||
|
||||
if let Ok(mut runtime) = tokio::runtime::Runtime::new() {
|
||||
let future = wrapped_main();
|
||||
runtime.block_on(future);
|
||||
if let Ok(runtime) = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
let future = wrapped_main(config);
|
||||
if let Err(e) = runtime.block_on(future) {
|
||||
eprintln!("{}", e);
|
||||
};
|
||||
}
|
||||
|
||||
log::trace!("exit: main");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
148
src/message.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use anyhow::Context;
|
||||
use console::{style, Color};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::traits::FeroxSerialize;
|
||||
use crate::utils::fmt_err;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
/// Representation of a log entry, can be represented as a human readable string or JSON
|
||||
pub struct FeroxMessage {
|
||||
#[serde(rename = "type")]
|
||||
/// Name of this type of struct, used for serialization, i.e. `{"type":"log"}`
|
||||
pub(crate) kind: String,
|
||||
|
||||
/// The log message
|
||||
pub(crate) message: String,
|
||||
|
||||
/// The log level
|
||||
pub(crate) level: String,
|
||||
|
||||
/// The number of seconds elapsed since the scan started
|
||||
pub(crate) time_offset: f32,
|
||||
|
||||
/// The module from which log::* was called
|
||||
pub(crate) module: String,
|
||||
}
|
||||
|
||||
/// Implementation of FeroxMessage
|
||||
impl FeroxSerialize for FeroxMessage {
|
||||
/// Create a string representation of the log message
|
||||
///
|
||||
/// ex: 301 10l 16w 173c https://localhost/api
|
||||
fn as_str(&self) -> String {
|
||||
let (level_name, level_color) = match self.level.as_str() {
|
||||
"ERROR" => ("ERR", Color::Red),
|
||||
"WARN" => ("WRN", Color::Red),
|
||||
"INFO" => ("INF", Color::Cyan),
|
||||
"DEBUG" => ("DBG", Color::Yellow),
|
||||
"TRACE" => ("TRC", Color::Magenta),
|
||||
"WILDCARD" => ("WLD", Color::Cyan),
|
||||
_ => ("UNK", Color::White),
|
||||
};
|
||||
|
||||
format!(
|
||||
"{} {:10.03} {} {}\n",
|
||||
style(level_name).bg(level_color).black(),
|
||||
style(self.time_offset).dim(),
|
||||
self.module,
|
||||
style(&self.message).dim(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an NDJSON representation of the log message
|
||||
///
|
||||
/// (expanded for clarity)
|
||||
/// ex:
|
||||
/// {
|
||||
/// "type": "log",
|
||||
/// "message": "Sent https://localhost/api to file handler",
|
||||
/// "level": "DEBUG",
|
||||
/// "time_offset": 0.86333454,
|
||||
/// "module": "feroxbuster::reporter"
|
||||
/// }\n
|
||||
fn as_json(&self) -> anyhow::Result<String> {
|
||||
let mut json = serde_json::to_string(&self).with_context(|| {
|
||||
fmt_err(&format!(
|
||||
"Could not convert {}:{} to JSON",
|
||||
self.level, self.message
|
||||
))
|
||||
})?;
|
||||
json.push('\n');
|
||||
Ok(json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// test as_str method of FeroxMessage
|
||||
fn ferox_message_as_str_returns_string_with_newline() {
|
||||
let message = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "INFO".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
let message_str = message.as_str();
|
||||
|
||||
assert!(message_str.contains("INF"));
|
||||
assert!(message_str.contains("1.000"));
|
||||
assert!(message_str.contains("utils"));
|
||||
assert!(message_str.contains("message"));
|
||||
assert!(message_str.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_json method of FeroxMessage
|
||||
fn ferox_message_as_json_returns_json_representation_of_ferox_message_with_newline() {
|
||||
let message = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "INFO".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
|
||||
let message_str = message.as_json().unwrap();
|
||||
|
||||
let error_margin = f32::EPSILON;
|
||||
|
||||
let json: FeroxMessage = serde_json::from_str(&message_str).unwrap();
|
||||
assert_eq!(json.module, message.module);
|
||||
assert_eq!(json.message, message.message);
|
||||
assert!((json.time_offset - message.time_offset).abs() < error_margin);
|
||||
assert_eq!(json.level, message.level);
|
||||
assert_eq!(json.kind, message.kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test defaults for coverage
|
||||
fn message_defaults() {
|
||||
let msg = FeroxMessage::default();
|
||||
assert_eq!(msg.level, String::new());
|
||||
assert_eq!(msg.kind, String::new());
|
||||
assert_eq!(msg.message, String::new());
|
||||
assert_eq!(msg.module, String::new());
|
||||
assert!(msg.time_offset < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure WILDCARD messages serialize to WLD and anything not known to UNK
|
||||
fn message_as_str_edges() {
|
||||
let mut msg = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "WILDCARD".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
assert!(console::strip_ansi_codes(&msg.as_str()).starts_with("WLD"));
|
||||
|
||||
msg.level = "UNKNOWN".to_string();
|
||||
assert!(console::strip_ansi_codes(&msg.as_str()).starts_with("UNK"));
|
||||
}
|
||||
}
|
||||
792
src/parser.rs
@@ -1,275 +1,582 @@
|
||||
use crate::VERSION;
|
||||
use clap::{App, Arg};
|
||||
use clap::{
|
||||
crate_authors, crate_description, crate_name, crate_version, App, Arg, ArgGroup, ValueHint,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use std::env;
|
||||
use std::process;
|
||||
|
||||
lazy_static! {
|
||||
/// Regex used to validate values passed to --time-limit
|
||||
///
|
||||
/// Examples of expected values that will this regex will match:
|
||||
/// - 30s
|
||||
/// - 20m
|
||||
/// - 1h
|
||||
/// - 1d
|
||||
pub static ref TIMESPEC_REGEX: Regex =
|
||||
Regex::new(r"^(?i)(?P<n>\d+)(?P<m>[smdh])$").expect("Could not compile regex");
|
||||
|
||||
/// help string for user agent, your guess is as good as mine as to why this is required...
|
||||
static ref DEFAULT_USER_AGENT: String = format!(
|
||||
"Sets the User-Agent (default: feroxbuster/{})",
|
||||
crate_version!()
|
||||
);
|
||||
}
|
||||
|
||||
/// Create and return an instance of [clap::App](https://docs.rs/clap/latest/clap/struct.App.html), i.e. the Command Line Interface's configuration
|
||||
pub fn initialize() -> App<'static, 'static> {
|
||||
App::new("feroxbuster")
|
||||
.version(VERSION)
|
||||
.author("Ben 'epi' Risher (@epi052)")
|
||||
.about("A fast, simple, recursive content discovery tool written in Rust")
|
||||
pub fn initialize() -> App<'static> {
|
||||
let app = App::new(crate_name!())
|
||||
.version(crate_version!())
|
||||
.author(crate_authors!())
|
||||
.about(crate_description!());
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - target selection
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::with_name("wordlist")
|
||||
.short("w")
|
||||
.long("wordlist")
|
||||
.value_name("FILE")
|
||||
.help("Path to the wordlist")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("url")
|
||||
.short("u")
|
||||
Arg::new("url")
|
||||
.short('u')
|
||||
.long("url")
|
||||
.required_unless("stdin")
|
||||
.required_unless_present_any(&["stdin", "resume_from"])
|
||||
.help_heading("Target selection")
|
||||
.value_name("URL")
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help("The target URL(s) (required, unless --stdin used)"),
|
||||
.value_hint(ValueHint::Url)
|
||||
.help("The target URL (required, unless [--stdin || --resume-from] used)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("threads")
|
||||
.short("t")
|
||||
.long("threads")
|
||||
.value_name("THREADS")
|
||||
.takes_value(true)
|
||||
.help("Number of concurrent threads (default: 50)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("depth")
|
||||
.short("d")
|
||||
.long("depth")
|
||||
.value_name("RECURSION_DEPTH")
|
||||
.takes_value(true)
|
||||
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("timeout")
|
||||
.short("T")
|
||||
.long("timeout")
|
||||
.value_name("SECONDS")
|
||||
.takes_value(true)
|
||||
.help("Number of seconds before a request times out (default: 7)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("verbosity")
|
||||
.short("v")
|
||||
.long("verbosity")
|
||||
.takes_value(false)
|
||||
.multiple(true)
|
||||
.help("Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably too much)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("proxy")
|
||||
.short("p")
|
||||
.long("proxy")
|
||||
.takes_value(true)
|
||||
.value_name("PROXY")
|
||||
.help(
|
||||
"Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("replay_proxy")
|
||||
.short("P")
|
||||
.long("replay-proxy")
|
||||
.takes_value(true)
|
||||
.value_name("REPLAY_PROXY")
|
||||
.help(
|
||||
"Send only unfiltered requests through a Replay Proxy, instead of all requests",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("replay_codes")
|
||||
.short("R")
|
||||
.long("replay-codes")
|
||||
.value_name("REPLAY_CODE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.requires("replay_proxy")
|
||||
.help(
|
||||
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("status_codes")
|
||||
.short("s")
|
||||
.long("status-codes")
|
||||
.value_name("STATUS_CODE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("quiet")
|
||||
.short("q")
|
||||
.long("quiet")
|
||||
.takes_value(false)
|
||||
.help("Only print URLs; Don't print status codes, response size, running config, etc...")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("dont_filter")
|
||||
.short("D")
|
||||
.long("dont-filter")
|
||||
.takes_value(false)
|
||||
.help("Don't auto-filter wildcard responses")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("output")
|
||||
.short("o")
|
||||
.long("output")
|
||||
.value_name("FILE")
|
||||
.help("Output file to write results to (default: stdout)")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user_agent")
|
||||
.short("a")
|
||||
.long("user-agent")
|
||||
.value_name("USER_AGENT")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
"Sets the User-Agent (default: feroxbuster/VERSION)"
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("redirects")
|
||||
.short("r")
|
||||
.long("redirects")
|
||||
.takes_value(false)
|
||||
.help("Follow redirects")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("insecure")
|
||||
.short("k")
|
||||
.long("insecure")
|
||||
.takes_value(false)
|
||||
.help("Disables TLS certificate validation")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("extensions")
|
||||
.short("x")
|
||||
.long("extensions")
|
||||
.value_name("FILE_EXTENSION")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"File extension(s) to search for (ex: -x php -x pdf js)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("headers")
|
||||
.short("H")
|
||||
.long("headers")
|
||||
.value_name("HEADER")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"Specify HTTP headers (ex: -H Header:val 'stuff: things')",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("queries")
|
||||
.short("Q")
|
||||
.long("query")
|
||||
.value_name("QUERY")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"Specify URL query parameters (ex: -Q token=stuff -Q secret=key)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("no_recursion")
|
||||
.short("n")
|
||||
.long("no-recursion")
|
||||
.takes_value(false)
|
||||
.help("Do not scan recursively")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("add_slash")
|
||||
.short("f")
|
||||
.long("add-slash")
|
||||
.takes_value(false)
|
||||
.conflicts_with("extensions")
|
||||
.help("Append / to each request")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("stdin")
|
||||
Arg::new("stdin")
|
||||
.long("stdin")
|
||||
.help_heading("Target selection")
|
||||
.takes_value(false)
|
||||
.help("Read url(s) from STDIN")
|
||||
.conflicts_with("url")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_size")
|
||||
.short("S")
|
||||
Arg::new("resume_from")
|
||||
.long("resume-from")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.value_name("STATE_FILE")
|
||||
.help_heading("Target selection")
|
||||
.help("State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)")
|
||||
.conflicts_with("url")
|
||||
.takes_value(true),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - proxy settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("proxy")
|
||||
.short('p')
|
||||
.long("proxy")
|
||||
.takes_value(true)
|
||||
.value_name("PROXY")
|
||||
.value_hint(ValueHint::Url)
|
||||
.help_heading("Proxy settings")
|
||||
.help(
|
||||
"Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("replay_proxy")
|
||||
.short('P')
|
||||
.long("replay-proxy")
|
||||
.takes_value(true)
|
||||
.value_hint(ValueHint::Url)
|
||||
.value_name("REPLAY_PROXY")
|
||||
.help_heading("Proxy settings")
|
||||
.help(
|
||||
"Send only unfiltered requests through a Replay Proxy, instead of all requests",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("replay_codes")
|
||||
.short('R')
|
||||
.long("replay-codes")
|
||||
.value_name("REPLAY_CODE")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.requires("replay_proxy")
|
||||
.help_heading("Proxy settings")
|
||||
.help(
|
||||
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
|
||||
),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - request settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("user_agent")
|
||||
.short('a')
|
||||
.long("user-agent")
|
||||
.value_name("USER_AGENT")
|
||||
.takes_value(true)
|
||||
.help_heading("Request settings")
|
||||
.help(&**DEFAULT_USER_AGENT),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("random_agent")
|
||||
.short('A')
|
||||
.long("random-agent")
|
||||
.takes_value(false)
|
||||
.help_heading("Request settings")
|
||||
.help("Use a random User-Agent"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("extensions")
|
||||
.short('x')
|
||||
.long("extensions")
|
||||
.value_name("FILE_EXTENSION")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"File extension(s) to search for (ex: -x php -x pdf js)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("methods")
|
||||
.short('m')
|
||||
.long("methods")
|
||||
.value_name("HTTP_METHODS")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Which HTTP request method(s) should be sent (default: GET)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("data")
|
||||
.long("data")
|
||||
.value_name("DATA")
|
||||
.takes_value(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Request's Body; can read data from a file if input starts with an @ (ex: @post.bin)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("headers")
|
||||
.short('H')
|
||||
.long("headers")
|
||||
.value_name("HEADER")
|
||||
.takes_value(true)
|
||||
.help_heading("Request settings")
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"Specify HTTP headers to be used in each request (ex: -H Header:val -H 'stuff: things')",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("cookies")
|
||||
.short('b')
|
||||
.long("cookies")
|
||||
.value_name("COOKIE")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Specify HTTP cookies to be used in each request (ex: -b stuff=things)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("queries")
|
||||
.short('Q')
|
||||
.long("query")
|
||||
.value_name("QUERY")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Request's URL query parameters (ex: -Q token=stuff -Q secret=key)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("add_slash")
|
||||
.short('f')
|
||||
.long("add-slash")
|
||||
.help_heading("Request settings")
|
||||
.takes_value(false)
|
||||
.help("Append / to each request's URL")
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - request filters
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app.arg(
|
||||
Arg::new("url_denylist")
|
||||
.long("dont-scan")
|
||||
.value_name("URL")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Request filters")
|
||||
.help("URL(s) or Regex Pattern(s) to exclude from recursion/scans"),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - response filters
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("filter_size")
|
||||
.short('S')
|
||||
.long("filter-size")
|
||||
.value_name("SIZE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_words")
|
||||
.short("W")
|
||||
Arg::new("filter_regex")
|
||||
.short('X')
|
||||
.long("filter-regex")
|
||||
.value_name("REGEX")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages via regular expression matching on the response's body (ex: -X '^ignore me$')",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("filter_words")
|
||||
.short('W')
|
||||
.long("filter-words")
|
||||
.value_name("WORDS")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages of a particular word count (ex: -W 312 -W 91,82)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_lines")
|
||||
.short("N")
|
||||
Arg::new("filter_lines")
|
||||
.short('N')
|
||||
.long("filter-lines")
|
||||
.value_name("LINES")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages of a particular line count (ex: -N 20 -N 31,30)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_status")
|
||||
.short("C")
|
||||
Arg::new("filter_status")
|
||||
.short('C')
|
||||
.long("filter-status")
|
||||
.value_name("STATUS_CODE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out status codes (deny list) (ex: -C 200 -C 401)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("extract_links")
|
||||
.short("e")
|
||||
.long("extract-links")
|
||||
.takes_value(false)
|
||||
.help("Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)")
|
||||
Arg::new("filter_similar")
|
||||
.long("filter-similar-to")
|
||||
.value_name("UNWANTED_PAGE")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.value_hint(ValueHint::Url)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("scan_limit")
|
||||
.short("L")
|
||||
Arg::new("status_codes")
|
||||
.short('s')
|
||||
.long("status-codes")
|
||||
.value_name("STATUS_CODE")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)",
|
||||
),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - client settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("timeout")
|
||||
.short('T')
|
||||
.long("timeout")
|
||||
.value_name("SECONDS")
|
||||
.takes_value(true)
|
||||
.help_heading("Client settings")
|
||||
.help("Number of seconds before a client's request times out (default: 7)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("redirects")
|
||||
.short('r')
|
||||
.long("redirects")
|
||||
.takes_value(false)
|
||||
.help_heading("Client settings")
|
||||
.help("Allow client to follow redirects"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("insecure")
|
||||
.short('k')
|
||||
.long("insecure")
|
||||
.takes_value(false)
|
||||
.help_heading("Client settings")
|
||||
.help("Disables TLS certificate validation in the client"),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - scan settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("threads")
|
||||
.short('t')
|
||||
.long("threads")
|
||||
.value_name("THREADS")
|
||||
.takes_value(true)
|
||||
.help_heading("Scan settings")
|
||||
.help("Number of concurrent threads (default: 50)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("no_recursion")
|
||||
.short('n')
|
||||
.long("no-recursion")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Do not scan recursively"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("depth")
|
||||
.short('d')
|
||||
.long("depth")
|
||||
.value_name("RECURSION_DEPTH")
|
||||
.takes_value(true)
|
||||
.help_heading("Scan settings")
|
||||
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"),
|
||||
).arg(
|
||||
Arg::new("extract_links")
|
||||
.short('e')
|
||||
.long("extract-links")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Extract links from response body (html, javascript, etc...); make new requests based on findings")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("scan_limit")
|
||||
.short('L')
|
||||
.long("scan-limit")
|
||||
.value_name("SCAN_LIMIT")
|
||||
.takes_value(true)
|
||||
.help_heading("Scan settings")
|
||||
.help("Limit total number of concurrent scans (default: 0, i.e. no limit)")
|
||||
)
|
||||
.after_help(r#"NOTE:
|
||||
.arg(
|
||||
Arg::new("parallel")
|
||||
.long("parallel")
|
||||
.value_name("PARALLEL_SCANS")
|
||||
.takes_value(true)
|
||||
.requires("stdin")
|
||||
.help_heading("Scan settings")
|
||||
.help("Run parallel feroxbuster instances (one child process per url passed via stdin)")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("rate_limit")
|
||||
.long("rate-limit")
|
||||
.value_name("RATE_LIMIT")
|
||||
.takes_value(true)
|
||||
.conflicts_with("auto_tune")
|
||||
.help_heading("Scan settings")
|
||||
.help("Limit number of requests per second (per directory) (default: 0, i.e. no limit)")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("time_limit")
|
||||
.long("time-limit")
|
||||
.value_name("TIME_SPEC")
|
||||
.takes_value(true)
|
||||
.validator(valid_time_spec)
|
||||
.help_heading("Scan settings")
|
||||
.help("Limit total run time of all scans (ex: --time-limit 10m)")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("wordlist")
|
||||
.short('w')
|
||||
.long("wordlist")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.value_name("FILE")
|
||||
.help("Path to the wordlist")
|
||||
.help_heading("Scan settings")
|
||||
.takes_value(true),
|
||||
).arg(
|
||||
Arg::new("auto_tune")
|
||||
.long("auto-tune")
|
||||
.takes_value(false)
|
||||
.conflicts_with("auto_bail")
|
||||
.help_heading("Scan settings")
|
||||
.help("Automatically lower scan rate when an excessive amount of errors are encountered")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("auto_bail")
|
||||
.long("auto-bail")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Automatically stop scanning when an excessive amount of errors are encountered")
|
||||
).arg(
|
||||
Arg::new("dont_filter")
|
||||
.short('D')
|
||||
.long("dont-filter")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Don't auto-filter wildcard responses")
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - output settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("verbosity")
|
||||
.short('v')
|
||||
.long("verbosity")
|
||||
.takes_value(false)
|
||||
.multiple_occurrences(true)
|
||||
.conflicts_with("silent")
|
||||
.help_heading("Output settings")
|
||||
.help("Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably too much)"),
|
||||
).arg(
|
||||
Arg::new("silent")
|
||||
.long("silent")
|
||||
.takes_value(false)
|
||||
.conflicts_with("quiet")
|
||||
.help_heading("Output settings")
|
||||
.help("Only print URLs + turn off logging (good for piping a list of urls to other commands)")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("quiet")
|
||||
.short('q')
|
||||
.long("quiet")
|
||||
.takes_value(false)
|
||||
.help_heading("Output settings")
|
||||
.help("Hide progress bars and banner (good for tmux windows w/ notifications)")
|
||||
)
|
||||
|
||||
.arg(
|
||||
Arg::new("json")
|
||||
.long("json")
|
||||
.takes_value(false)
|
||||
.requires("output_files")
|
||||
.help_heading("Output settings")
|
||||
.help("Emit JSON logs to --output and --debug-log instead of normal text")
|
||||
).arg(
|
||||
Arg::new("output")
|
||||
.short('o')
|
||||
.long("output")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.value_name("FILE")
|
||||
.help_heading("Output settings")
|
||||
.help("Output file to write results to (use w/ --json for JSON entries)")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("debug_log")
|
||||
.long("debug-log")
|
||||
.value_name("FILE")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.help_heading("Output settings")
|
||||
.help("Output file to write log entries (use w/ --json for JSON entries)")
|
||||
.takes_value(true),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - miscellaneous
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let mut app = app
|
||||
.group(
|
||||
ArgGroup::new("output_files")
|
||||
.args(&["debug_log", "output"])
|
||||
.multiple(true),
|
||||
)
|
||||
.after_long_help(EPILOGUE);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// end parser
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
for arg in env::args() {
|
||||
// secure-77 noticed that when an incorrect flag/option is used, the short help message is printed
|
||||
// which is fine, but if you add -h|--help, it still errors out on the bad flag/option,
|
||||
// never showing the full help message. This code addresses that behavior
|
||||
if arg == "--help" {
|
||||
app.print_long_help().unwrap();
|
||||
println!(); // just a newline to mirror original --help output
|
||||
process::exit(0);
|
||||
} else if arg == "-h" {
|
||||
// same for -h, just shorter
|
||||
app.print_help().unwrap();
|
||||
println!();
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Validate that a string is formatted as a number followed by s, m, h, or d (10d, 30s, etc...)
|
||||
fn valid_time_spec(time_spec: &str) -> Result<(), String> {
|
||||
match TIMESPEC_REGEX.is_match(time_spec) {
|
||||
true => Ok(()),
|
||||
false => {
|
||||
let msg = format!(
|
||||
"Expected a non-negative, whole number followed by s, m, h, or d (case insensitive); received {}",
|
||||
time_spec
|
||||
);
|
||||
Err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EPILOGUE: &str = r#"NOTE:
|
||||
Options that take multiple values are very flexible. Consider the following ways of specifying
|
||||
extensions:
|
||||
./feroxbuster -u http://127.1 -x pdf -x js,html -x php txt json,docx
|
||||
@@ -287,7 +594,7 @@ EXAMPLES:
|
||||
./feroxbuster -u http://[::1] --no-recursion -vv
|
||||
|
||||
Read urls from STDIN; pipe only resulting urls out to another tool
|
||||
cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
|
||||
cat targets | ./feroxbuster --stdin --silent -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
|
||||
|
||||
Proxy traffic through Burp
|
||||
./feroxbuster -u http://127.1 --insecure --proxy http://127.0.0.1:8080
|
||||
@@ -302,18 +609,63 @@ EXAMPLES:
|
||||
./feroxbuster -u http://127.1 --extract-links
|
||||
|
||||
Ludicrous speed... go!
|
||||
./feroxbuster -u http://127.1 -t 200
|
||||
"#)
|
||||
}
|
||||
./feroxbuster -u http://127.1 -threads 200
|
||||
|
||||
Limit to a total of 60 active requests at any given time (threads * scan limit)
|
||||
./feroxbuster -u http://127.1 --threads 30 --scan-limit 2
|
||||
|
||||
Send all 200/302 responses to a proxy (only proxy requests/responses you care about)
|
||||
./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
|
||||
|
||||
Abort or reduce scan speed to individual directory scans when too many errors have occurred
|
||||
./feroxbuster -u http://127.1 --auto-bail
|
||||
./feroxbuster -u http://127.1 --auto-tune
|
||||
|
||||
Examples and demonstrations of all features
|
||||
https://epi052.github.io/feroxbuster-docs/docs/examples/
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// initalize parser, expect a clap::App returned
|
||||
/// initialize parser, expect a clap::App returned
|
||||
fn parser_initialize_gives_defaults() {
|
||||
let app = initialize();
|
||||
assert_eq!(app.get_name(), "feroxbuster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// sanity checks that valid_time_spec correctly checks and rejects a given string
|
||||
///
|
||||
/// instead of having a bunch of single tests here, they're all quick and are mostly checking
|
||||
/// that i didn't hose up the regex. Going to consolidate them into a single test
|
||||
fn validate_valid_time_spec_validation() {
|
||||
let float_rejected = "1.4m";
|
||||
assert!(valid_time_spec(float_rejected).is_err());
|
||||
|
||||
let negative_rejected = "-1m";
|
||||
assert!(valid_time_spec(negative_rejected).is_err());
|
||||
|
||||
let only_number_rejected = "1";
|
||||
assert!(valid_time_spec(only_number_rejected).is_err());
|
||||
|
||||
let only_measurement_rejected = "m";
|
||||
assert!(valid_time_spec(only_measurement_rejected).is_err());
|
||||
|
||||
for accepted_measurement in &["s", "m", "h", "d", "S", "M", "H", "D"] {
|
||||
// all upper/lowercase should be good
|
||||
assert!(valid_time_spec(&format!("1{}", *accepted_measurement)).is_ok());
|
||||
}
|
||||
|
||||
let leading_space_rejected = " 14m";
|
||||
assert!(valid_time_spec(leading_space_rejected).is_err());
|
||||
|
||||
let trailing_space_rejected = "14m ";
|
||||
assert!(valid_time_spec(trailing_space_rejected).is_err());
|
||||
|
||||
let space_between_rejected = "1 4m";
|
||||
assert!(valid_time_spec(space_between_rejected).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,82 @@
|
||||
use crate::config::{CONFIGURATION, PROGRESS_BAR};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
/// Global progress bar that houses other progress bars
|
||||
pub static ref PROGRESS_BAR: MultiProgress = MultiProgress::with_draw_target(ProgressDrawTarget::stdout());
|
||||
|
||||
/// Global progress bar that is only used for printing messages that don't jack up other bars
|
||||
pub static ref PROGRESS_PRINTER: ProgressBar = add_bar("", 0, BarType::Hidden);
|
||||
}
|
||||
|
||||
/// Types of ProgressBars that can be added to `PROGRESS_BAR`
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum BarType {
|
||||
/// no template used / not visible
|
||||
Hidden,
|
||||
|
||||
/// normal directory status bar (reqs/sec shown)
|
||||
Default,
|
||||
|
||||
/// similar to `Default`, except `-` is used in place of line/word/char count
|
||||
Message,
|
||||
|
||||
/// bar used to show overall scan metrics
|
||||
Total,
|
||||
|
||||
/// simpler output bar that shows only the directory being scanned (no updating info)
|
||||
Quiet,
|
||||
}
|
||||
|
||||
/// Add an [indicatif::ProgressBar](https://docs.rs/indicatif/latest/indicatif/struct.ProgressBar.html)
|
||||
/// to the global [PROGRESS_BAR](../config/struct.PROGRESS_BAR.html)
|
||||
pub fn add_bar(prefix: &str, length: u64, hidden: bool) -> ProgressBar {
|
||||
let style = if hidden || CONFIGURATION.quiet {
|
||||
ProgressStyle::default_bar().template("")
|
||||
} else {
|
||||
ProgressStyle::default_bar()
|
||||
.template("[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {per_sec:7} {prefix}")
|
||||
.progress_chars("#>-")
|
||||
pub fn add_bar(prefix: &str, length: u64, bar_type: BarType) -> ProgressBar {
|
||||
let mut style = ProgressStyle::default_bar().progress_chars("#>-");
|
||||
|
||||
style = match bar_type {
|
||||
BarType::Hidden => style.template(""),
|
||||
BarType::Default => style.template(
|
||||
"[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {per_sec:7} {prefix} {msg}",
|
||||
),
|
||||
BarType::Message => style.template(&format!(
|
||||
"[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}} {{msg}}",
|
||||
"-"
|
||||
)),
|
||||
BarType::Total => {
|
||||
style.template("[{bar:.yellow/blue}] - {elapsed:<4} {pos:>7}/{len:7} {eta:7} {msg}")
|
||||
}
|
||||
BarType::Quiet => style.template("Scanning: {prefix}"),
|
||||
};
|
||||
|
||||
let progress_bar = PROGRESS_BAR.add(ProgressBar::new(length));
|
||||
|
||||
progress_bar.set_style(style);
|
||||
|
||||
progress_bar.set_prefix(&prefix);
|
||||
progress_bar.set_prefix(prefix);
|
||||
|
||||
progress_bar
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// hit all code branches for add_bar
|
||||
fn add_bar_with_all_configurations() {
|
||||
let p1 = add_bar("prefix", 2, BarType::Hidden); // hidden
|
||||
let p2 = add_bar("prefix", 2, BarType::Message); // no per second field
|
||||
let p3 = add_bar("prefix", 2, BarType::Default); // normal bar
|
||||
let p4 = add_bar("prefix", 2, BarType::Total); // totals bar
|
||||
|
||||
p1.finish();
|
||||
p2.finish();
|
||||
p3.finish();
|
||||
p4.finish();
|
||||
|
||||
assert!(p1.is_finished());
|
||||
assert!(p2.is_finished());
|
||||
assert!(p3.is_finished());
|
||||
assert!(p4.is_finished());
|
||||
}
|
||||
}
|
||||
|
||||
234
src/reporter.rs
@@ -1,234 +0,0 @@
|
||||
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
|
||||
use crate::utils::{create_report_string, ferox_print, make_request};
|
||||
use crate::{FeroxChannel, FeroxResponse};
|
||||
use console::strip_ansi_codes;
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, Once, RwLock};
|
||||
use std::{fs, io};
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// Singleton buffered file behind an Arc/RwLock; used for file writes from two locations:
|
||||
/// - [logger::initialize](../logger/fn.initialize.html) (specifically a closure on the global logger instance)
|
||||
/// - `reporter::spawn_file_handler`
|
||||
pub static mut LOCKED_FILE: Option<Arc<RwLock<io::BufWriter<fs::File>>>> = None;
|
||||
|
||||
/// An initializer Once variable used to create `LOCKED_FILE`
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
// Accessing a `static mut` is unsafe much of the time, but if we do so
|
||||
// in a synchronized fashion (e.g., write once or read all) then we're
|
||||
// good to go!
|
||||
//
|
||||
// This function will only call `open_file` once, and will
|
||||
// otherwise always return the value returned from the first invocation.
|
||||
pub fn get_cached_file_handle(filename: &str) -> Option<Arc<RwLock<io::BufWriter<fs::File>>>> {
|
||||
unsafe {
|
||||
INIT.call_once(|| {
|
||||
LOCKED_FILE = open_file(&filename);
|
||||
});
|
||||
LOCKED_FILE.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates all required output handlers (terminal, file) and returns
|
||||
/// the transmitter sides of each mpsc along with each receiver's future's JoinHandle to be awaited
|
||||
///
|
||||
/// Any other module that needs to write a Response to stdout or output results to a file should
|
||||
/// be passed a clone of the appropriate returned transmitter
|
||||
pub fn initialize(
|
||||
output_file: &str,
|
||||
save_output: bool,
|
||||
) -> (
|
||||
UnboundedSender<FeroxResponse>,
|
||||
UnboundedSender<String>,
|
||||
JoinHandle<()>,
|
||||
Option<JoinHandle<()>>,
|
||||
) {
|
||||
log::trace!("enter: initialize({}, {})", output_file, save_output);
|
||||
|
||||
let (tx_rpt, rx_rpt): FeroxChannel<FeroxResponse> = mpsc::unbounded_channel();
|
||||
let (tx_file, rx_file): FeroxChannel<String> = mpsc::unbounded_channel();
|
||||
|
||||
let file_clone = tx_file.clone();
|
||||
|
||||
let term_reporter =
|
||||
tokio::spawn(async move { spawn_terminal_reporter(rx_rpt, file_clone, save_output).await });
|
||||
|
||||
let file_reporter = if save_output {
|
||||
// -o used, need to spawn the thread for writing to disk
|
||||
let file_clone = output_file.to_string();
|
||||
Some(tokio::spawn(async move {
|
||||
spawn_file_reporter(rx_file, &file_clone).await
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
log::trace!(
|
||||
"exit: initialize -> ({:?}, {:?}, {:?}, {:?})",
|
||||
tx_rpt,
|
||||
tx_file,
|
||||
term_reporter,
|
||||
file_reporter
|
||||
);
|
||||
(tx_rpt, tx_file, term_reporter, file_reporter)
|
||||
}
|
||||
|
||||
/// Spawn a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives responses and prints them if they meet the given
|
||||
/// reporting criteria
|
||||
async fn spawn_terminal_reporter(
|
||||
mut resp_chan: UnboundedReceiver<FeroxResponse>,
|
||||
file_chan: UnboundedSender<String>,
|
||||
save_output: bool,
|
||||
) {
|
||||
log::trace!(
|
||||
"enter: spawn_terminal_reporter({:?}, {:?}, {})",
|
||||
resp_chan,
|
||||
file_chan,
|
||||
save_output
|
||||
);
|
||||
|
||||
while let Some(resp) = resp_chan.recv().await {
|
||||
log::trace!("received {} on reporting channel", resp.url());
|
||||
|
||||
if CONFIGURATION.status_codes.contains(&resp.status().as_u16()) {
|
||||
let report = create_report_string(
|
||||
resp.status().as_str(),
|
||||
&resp.line_count().to_string(),
|
||||
&resp.word_count().to_string(),
|
||||
&resp.content_length().to_string(),
|
||||
&resp.url().to_string(),
|
||||
);
|
||||
|
||||
// print to stdout
|
||||
ferox_print(&report, &PROGRESS_PRINTER);
|
||||
|
||||
if save_output {
|
||||
// -o used, need to send the report to be written out to disk
|
||||
match file_chan.send(report.to_string()) {
|
||||
Ok(_) => {
|
||||
log::debug!("Sent {} to file handler", resp.url());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not send {} to file handler: {}", resp.url(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("report complete: {}", resp.url());
|
||||
|
||||
if CONFIGURATION.replay_client.is_some()
|
||||
&& CONFIGURATION.replay_codes.contains(&resp.status().as_u16())
|
||||
{
|
||||
// replay proxy specified/client created and this response's status code is one that
|
||||
// should be replayed
|
||||
match make_request(CONFIGURATION.replay_client.as_ref().unwrap(), &resp.url()).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("exit: spawn_terminal_reporter");
|
||||
}
|
||||
|
||||
/// Spawn a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives responses and writes them to the given output file if they meet
|
||||
/// the given reporting criteria
|
||||
async fn spawn_file_reporter(mut report_channel: UnboundedReceiver<String>, output_file: &str) {
|
||||
let buffered_file = match get_cached_file_handle(&CONFIGURATION.output) {
|
||||
Some(file) => file,
|
||||
None => {
|
||||
log::trace!("exit: spawn_file_reporter");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::trace!(
|
||||
"enter: spawn_file_reporter({:?}, {})",
|
||||
report_channel,
|
||||
output_file
|
||||
);
|
||||
|
||||
log::info!("Writing scan results to {}", output_file);
|
||||
|
||||
while let Some(report) = report_channel.recv().await {
|
||||
safe_file_write(&report, buffered_file.clone());
|
||||
}
|
||||
|
||||
log::trace!("exit: spawn_file_reporter");
|
||||
}
|
||||
|
||||
/// 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 file that is buffered and locked
|
||||
fn open_file(filename: &str) -> Option<Arc<RwLock<io::BufWriter<fs::File>>>> {
|
||||
log::trace!("enter: open_file({})", filename);
|
||||
|
||||
match fs::OpenOptions::new() // std fs
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(filename)
|
||||
{
|
||||
Ok(file) => {
|
||||
let writer = io::BufWriter::new(file); // std io
|
||||
|
||||
let locked_file = Some(Arc::new(RwLock::new(writer)));
|
||||
|
||||
log::trace!("exit: open_file -> {:?}", locked_file);
|
||||
locked_file
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
log::trace!("exit: open_file -> None");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a string and a reference to a locked buffered file, write the contents and flush
|
||||
/// the buffer to disk.
|
||||
pub fn safe_file_write(contents: &str, locked_file: Arc<RwLock<io::BufWriter<fs::File>>>) {
|
||||
// note to future self: adding logging of anything other than error to this function
|
||||
// is a bad idea. we call this function while processing records generated by the logger.
|
||||
// If we then call log::... while already processing some logging output, it results in
|
||||
// the second log entry being injected into the first.
|
||||
|
||||
let contents = strip_ansi_codes(&contents);
|
||||
|
||||
if let Ok(mut handle) = locked_file.write() {
|
||||
// write lock acquired
|
||||
match handle.write(contents.as_bytes()) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("could not write report to disk: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match handle.flush() {
|
||||
// this function is used within async functions/loops, so i'm flushing so that in
|
||||
// the event of a ctrl+c or w/e results seen so far are saved instead of left lying
|
||||
// around in the buffer
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("error writing to file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
/// asserts that an empty string for a filename returns None
|
||||
fn reporter_get_cached_file_handle_without_filename_returns_none() {
|
||||
let _used = get_cached_file_handle(&"").unwrap();
|
||||
}
|
||||
}
|
||||
705
src/response.rs
Normal file
@@ -0,0 +1,705 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
convert::{TryFrom, TryInto},
|
||||
fmt,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use console::style;
|
||||
use reqwest::{
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
Method, Response, StatusCode, Url,
|
||||
};
|
||||
use serde::ser::SerializeStruct;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
event_handlers::{Command, Handles},
|
||||
traits::FeroxSerialize,
|
||||
url::FeroxUrl,
|
||||
utils::{self, fmt_err, status_colorizer},
|
||||
CommandSender,
|
||||
};
|
||||
|
||||
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeroxResponse {
|
||||
/// The final `Url` of this `FeroxResponse`
|
||||
url: Url,
|
||||
|
||||
/// The original url from which the final `Url` was derived
|
||||
original_url: String,
|
||||
|
||||
/// The `StatusCode` of this `FeroxResponse`
|
||||
status: StatusCode,
|
||||
|
||||
/// The HTTP Request `Method` of this `FeroxResponse`
|
||||
method: Method,
|
||||
|
||||
/// The full response text
|
||||
text: String,
|
||||
|
||||
/// The content-length of this response, if known
|
||||
content_length: u64,
|
||||
|
||||
/// The number of lines contained in the body of this response, if known
|
||||
line_count: usize,
|
||||
|
||||
/// The number of words contained in the body of this response, if known
|
||||
word_count: usize,
|
||||
|
||||
/// The `Headers` of this `FeroxResponse`
|
||||
headers: HeaderMap,
|
||||
|
||||
/// Wildcard response status
|
||||
wildcard: bool,
|
||||
|
||||
/// whether the user passed --quiet|--silent on the command line
|
||||
pub(crate) output_level: OutputLevel,
|
||||
}
|
||||
|
||||
/// implement Default trait for FeroxResponse
|
||||
impl Default for FeroxResponse {
|
||||
/// return a default reqwest::Url and then normal defaults after that
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: Url::parse("http://localhost").unwrap(),
|
||||
original_url: "".to_string(),
|
||||
status: Default::default(),
|
||||
method: Method::default(),
|
||||
text: "".to_string(),
|
||||
content_length: 0,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
headers: Default::default(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement Display for FeroxResponse
|
||||
impl fmt::Display for FeroxResponse {
|
||||
/// formatter for Display
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"FeroxResponse {{ url: {}, method: {}, status: {}, content-length: {} }}",
|
||||
self.url(),
|
||||
self.method(),
|
||||
self.status(),
|
||||
self.content_length()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `FeroxResponse` implementation
|
||||
impl FeroxResponse {
|
||||
/// Get the `StatusCode` of this `FeroxResponse`
|
||||
pub fn status(&self) -> &StatusCode {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get the `Method` of this `FeroxResponse`
|
||||
pub fn method(&self) -> &Method {
|
||||
&self.method
|
||||
}
|
||||
|
||||
/// Get the `wildcard` of this `FeroxResponse`
|
||||
pub fn wildcard(&self) -> bool {
|
||||
self.wildcard
|
||||
}
|
||||
|
||||
/// Get the final `Url` of this `FeroxResponse`.
|
||||
pub fn url(&self) -> &Url {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Get the full response text
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Get the `Headers` of this `FeroxResponse`
|
||||
pub fn headers(&self) -> &HeaderMap {
|
||||
&self.headers
|
||||
}
|
||||
|
||||
/// Get the content-length of this response, if known
|
||||
pub fn content_length(&self) -> u64 {
|
||||
self.content_length
|
||||
}
|
||||
|
||||
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
|
||||
pub fn set_url(&mut self, url: &str) {
|
||||
match Url::parse(url) {
|
||||
Ok(url) => {
|
||||
self.url = url;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not parse {} into a Url: {}", url, e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// set `wildcard` attribute
|
||||
pub fn set_wildcard(&mut self, is_wildcard: bool) {
|
||||
self.wildcard = is_wildcard;
|
||||
}
|
||||
|
||||
/// set `text` attribute; update words/lines/content_length
|
||||
#[cfg(test)]
|
||||
pub fn set_text(&mut self, text: &str) {
|
||||
self.text = String::from(text);
|
||||
self.content_length = self.text.len() as u64;
|
||||
self.line_count = self.text.lines().count();
|
||||
self.word_count = self
|
||||
.text
|
||||
.lines()
|
||||
.map(|s| s.split_whitespace().count())
|
||||
.sum();
|
||||
}
|
||||
|
||||
/// free the `text` data, reducing memory usage
|
||||
pub fn drop_text(&mut self) {
|
||||
self.text = String::new();
|
||||
}
|
||||
|
||||
/// Make a reasonable guess at whether the response is a file or not
|
||||
///
|
||||
/// Examines the last part of a path to determine if it has an obvious extension
|
||||
/// i.e. http://localhost/some/path/stuff.js where stuff.js indicates a file
|
||||
///
|
||||
/// Additionally, inspects query parameters, as they're also often indicative of a file
|
||||
pub fn is_file(&self) -> bool {
|
||||
let has_extension = match self.url.path_segments() {
|
||||
Some(path) => {
|
||||
if let Some(last) = path.last() {
|
||||
last.contains('.') // last segment has some sort of extension, probably
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
self.url.query_pairs().count() > 0 || has_extension
|
||||
}
|
||||
|
||||
/// Returns line count of the response text.
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.line_count
|
||||
}
|
||||
|
||||
/// Returns word count of the response text.
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.word_count
|
||||
}
|
||||
|
||||
/// Create a new `FeroxResponse` from the given `Response`
|
||||
pub async fn from(
|
||||
response: Response,
|
||||
original_url: &str,
|
||||
method: &str,
|
||||
read_body: bool,
|
||||
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()
|
||||
};
|
||||
|
||||
let line_count = text.lines().count();
|
||||
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
|
||||
|
||||
FeroxResponse {
|
||||
url,
|
||||
original_url: original_url.to_string(),
|
||||
status,
|
||||
method: Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET),
|
||||
content_length,
|
||||
text,
|
||||
headers,
|
||||
line_count,
|
||||
word_count,
|
||||
output_level,
|
||||
wildcard: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function that determines if the configured maximum recursion depth has been reached
|
||||
///
|
||||
/// Essentially looks at the Url path and determines how many directories are present in the
|
||||
/// given Url
|
||||
pub(crate) fn reached_max_depth(
|
||||
&self,
|
||||
base_depth: usize,
|
||||
max_depth: usize,
|
||||
handles: Arc<Handles>,
|
||||
) -> bool {
|
||||
log::trace!(
|
||||
"enter: reached_max_depth({}, {}, {:?})",
|
||||
base_depth,
|
||||
max_depth,
|
||||
handles
|
||||
);
|
||||
|
||||
if max_depth == 0 {
|
||||
// early return, as 0 means recurse forever; no additional processing needed
|
||||
log::trace!("exit: reached_max_depth -> false");
|
||||
return false;
|
||||
}
|
||||
let url = FeroxUrl::from_url(&self.url, handles);
|
||||
let depth = url.depth().unwrap_or_default(); // 0 on error
|
||||
|
||||
if depth - base_depth >= max_depth {
|
||||
return true;
|
||||
}
|
||||
|
||||
log::trace!("exit: reached_max_depth -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Helper function to determine suitability for recursion
|
||||
///
|
||||
/// handles 2xx and 3xx responses by either checking if the url ends with a / (2xx)
|
||||
/// or if the Location header is present and matches the base url + / (3xx)
|
||||
pub fn is_directory(&self) -> bool {
|
||||
log::trace!("enter: is_directory({})", self);
|
||||
|
||||
if self.status().is_redirection() {
|
||||
// status code is 3xx
|
||||
match self.headers().get("Location") {
|
||||
// and has a Location header
|
||||
Some(loc) => {
|
||||
// get absolute redirect Url based on the already known base url
|
||||
log::debug!("Location header: {:?}", loc);
|
||||
|
||||
if let Ok(loc_str) = loc.to_str() {
|
||||
if let Ok(abs_url) = self.url().join(loc_str) {
|
||||
if format!("{}/", self.url()) == abs_url.as_str() {
|
||||
// if current response's Url + / == the absolute redirection
|
||||
// location, we've found a directory suitable for recursion
|
||||
log::debug!(
|
||||
"found directory suitable for recursion: {}",
|
||||
self.url()
|
||||
);
|
||||
log::trace!("exit: is_directory -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::debug!("expected Location header, but none was found: {}", self);
|
||||
log::trace!("exit: is_directory -> false");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if self.status().is_success() || matches!(self.status(), &StatusCode::FORBIDDEN) {
|
||||
// status code is 2xx or 403, need to check if it ends in /
|
||||
|
||||
if self.url().as_str().ends_with('/') {
|
||||
log::debug!("{} is directory suitable for recursion", self.url());
|
||||
log::trace!("exit: is_directory -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: is_directory -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Simple helper to send a `FeroxResponse` over the tx side of an `mpsc::unbounded_channel`
|
||||
pub fn send_report(self, report_sender: CommandSender) -> Result<()> {
|
||||
log::trace!("enter: send_report({:?}", report_sender);
|
||||
|
||||
report_sender.send(Command::Report(Box::new(self)))?;
|
||||
|
||||
log::trace!("exit: send_report");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement FeroxSerialize for FeroxResponse
|
||||
impl FeroxSerialize for FeroxResponse {
|
||||
/// Simple wrapper around create_report_string
|
||||
fn as_str(&self) -> String {
|
||||
let lines = self.line_count().to_string();
|
||||
let words = self.word_count().to_string();
|
||||
let chars = self.content_length().to_string();
|
||||
let status = self.status().as_str();
|
||||
let method = self.method().as_str();
|
||||
let wild_status = status_colorizer("WLD");
|
||||
|
||||
let mut url_with_redirect = match (
|
||||
self.status().is_redirection(),
|
||||
self.headers().get("Location").is_some(),
|
||||
) {
|
||||
(true, true) => {
|
||||
// redirect with Location header, show where it goes if possible
|
||||
let loc = self
|
||||
.headers()
|
||||
.get("Location")
|
||||
.unwrap() // known Some() already
|
||||
.to_str()
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
// prettify the redirect target
|
||||
let loc = style(loc).yellow();
|
||||
|
||||
format!("{} => {loc}", self.url())
|
||||
}
|
||||
_ => {
|
||||
// no redirect, just use the normal url
|
||||
self.url().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
if self.wildcard && matches!(self.output_level, OutputLevel::Default | OutputLevel::Quiet) {
|
||||
// --silent was not used and response is a wildcard, special messages abound when
|
||||
// this is the case...
|
||||
|
||||
// create the base message
|
||||
let mut message = format!(
|
||||
"{} {:>8} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
|
||||
wild_status,
|
||||
method,
|
||||
lines,
|
||||
words,
|
||||
chars,
|
||||
status_colorizer(status),
|
||||
self.url(),
|
||||
FeroxUrl::path_length_of_url(&self.url)
|
||||
);
|
||||
|
||||
if self.status().is_redirection() {
|
||||
// initial wildcard messages are wordy enough, put the redirect by itself
|
||||
url_with_redirect = format!(
|
||||
"{} {:>9} {:>9} {:>9} {}\n",
|
||||
wild_status, "-", "-", "-", url_with_redirect
|
||||
);
|
||||
|
||||
// base message + redirection message (either empty string or redir msg)
|
||||
message.push_str(&url_with_redirect);
|
||||
}
|
||||
|
||||
message
|
||||
} else {
|
||||
// not a wildcard, just create a normal entry
|
||||
utils::create_report_string(
|
||||
self.status.as_str(),
|
||||
method,
|
||||
&lines,
|
||||
&words,
|
||||
&chars,
|
||||
&url_with_redirect,
|
||||
self.output_level,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an NDJSON representation of the FeroxResponse
|
||||
///
|
||||
/// (expanded for clarity)
|
||||
/// ex:
|
||||
/// {
|
||||
/// "type":"response",
|
||||
/// "url":"https://localhost.com/images",
|
||||
/// "path":"/images",
|
||||
/// "status":301,
|
||||
/// "content_length":179,
|
||||
/// "line_count":10,
|
||||
/// "word_count":16,
|
||||
/// "headers":{
|
||||
/// "x-content-type-options":"nosniff",
|
||||
/// "strict-transport-security":"max-age=31536000; includeSubDomains",
|
||||
/// "x-frame-options":"SAMEORIGIN",
|
||||
/// "connection":"keep-alive",
|
||||
/// "server":"nginx/1.16.1",
|
||||
/// "content-type":"text/html; charset=UTF-8",
|
||||
/// "referrer-policy":"origin-when-cross-origin",
|
||||
/// "content-security-policy":"default-src 'none'",
|
||||
/// "access-control-allow-headers":"X-Requested-With",
|
||||
/// "x-xss-protection":"1; mode=block",
|
||||
/// "content-length":"179",
|
||||
/// "date":"Mon, 23 Nov 2020 15:33:24 GMT",
|
||||
/// "location":"/images/",
|
||||
/// "access-control-allow-origin":"https://localhost.com"
|
||||
/// }
|
||||
/// }\n
|
||||
fn as_json(&self) -> anyhow::Result<String> {
|
||||
let mut json = serde_json::to_string(&self)
|
||||
.with_context(|| fmt_err(&format!("Could not convert {} to JSON", self.url())))?;
|
||||
json.push('\n');
|
||||
Ok(json)
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxResponse
|
||||
impl Serialize for FeroxResponse {
|
||||
/// Function that handles serialization of a FeroxResponse to NDJSON
|
||||
fn serialize<S>(&self, serializer: S) -> anyhow::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut headers = HashMap::new();
|
||||
let mut state = serializer.serialize_struct("FeroxResponse", 8)?;
|
||||
|
||||
// need to convert the HeaderMap to a HashMap in order to pass it to the serializer
|
||||
for (key, value) in &self.headers {
|
||||
let k = key.as_str().to_owned();
|
||||
let v = String::from_utf8_lossy(value.as_bytes());
|
||||
headers.insert(k, v);
|
||||
}
|
||||
|
||||
state.serialize_field("type", "response")?;
|
||||
state.serialize_field("url", self.url.as_str())?;
|
||||
state.serialize_field("original_url", self.original_url.as_str())?;
|
||||
state.serialize_field("path", self.url.path())?;
|
||||
state.serialize_field("wildcard", &self.wildcard)?;
|
||||
state.serialize_field("status", &self.status.as_u16())?;
|
||||
state.serialize_field("method", &self.method.as_str())?;
|
||||
state.serialize_field("content_length", &self.content_length)?;
|
||||
state.serialize_field("line_count", &self.line_count)?;
|
||||
state.serialize_field("word_count", &self.word_count)?;
|
||||
state.serialize_field("headers", &headers)?;
|
||||
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize implementation for FeroxResponse
|
||||
impl<'de> Deserialize<'de> for FeroxResponse {
|
||||
/// Deserialize a FeroxResponse from a serde_json::Value
|
||||
fn deserialize<D>(deserializer: D) -> anyhow::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut response = Self {
|
||||
url: Url::parse("http://localhost").unwrap(),
|
||||
original_url: String::new(),
|
||||
status: StatusCode::OK,
|
||||
method: Method::GET,
|
||||
text: String::new(),
|
||||
content_length: 0,
|
||||
headers: HeaderMap::new(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
};
|
||||
|
||||
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
|
||||
|
||||
for (key, value) in &map {
|
||||
match key.as_str() {
|
||||
"url" => {
|
||||
if let Some(url) = value.as_str() {
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
response.url = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
"original_url" => {
|
||||
if let Some(og_url) = value.as_str() {
|
||||
response.original_url = String::from(og_url);
|
||||
}
|
||||
}
|
||||
"status" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
if let Ok(smaller) = u16::try_from(num) {
|
||||
if let Ok(status) = StatusCode::from_u16(smaller) {
|
||||
response.status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"method" => {
|
||||
if let Some(method) = value.as_str() {
|
||||
response.method = Method::from_bytes(method.as_bytes()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"content_length" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.content_length = num;
|
||||
}
|
||||
}
|
||||
"line_count" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.line_count = num.try_into().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"word_count" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.word_count = num.try_into().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"headers" => {
|
||||
let mut headers = HeaderMap::<HeaderValue>::default();
|
||||
|
||||
if let Some(map_headers) = value.as_object() {
|
||||
for (h_key, h_value) in map_headers {
|
||||
let h_value_str = h_value.as_str().unwrap_or("");
|
||||
let h_name = HeaderName::from_str(h_key)
|
||||
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
|
||||
let h_value_parsed = HeaderValue::from_str(h_value_str)
|
||||
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
|
||||
headers.insert(h_name, h_value_parsed);
|
||||
}
|
||||
}
|
||||
|
||||
response.headers = headers;
|
||||
}
|
||||
"wildcard" => {
|
||||
if let Some(result) = value.as_bool() {
|
||||
response.wildcard = result;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
|
||||
fn reached_max_depth_returns_early_on_zero() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
original_url: String::new(),
|
||||
status: Default::default(),
|
||||
method: Default::default(),
|
||||
text: "".to_string(),
|
||||
content_length: 0,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
headers: Default::default(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
};
|
||||
let result = response.reached_max_depth(0, 0, handles);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url depth equal to max depth, expect true
|
||||
fn reached_max_depth_current_depth_equals_max() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let url = Url::parse("http://localhost/one/two").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
original_url: String::new(),
|
||||
status: Default::default(),
|
||||
method: Default::default(),
|
||||
text: "".to_string(),
|
||||
content_length: 0,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
headers: Default::default(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(0, 2, handles);
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url dpeth less than max depth, expect false
|
||||
fn reached_max_depth_current_depth_less_than_max() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
original_url: String::new(),
|
||||
status: Default::default(),
|
||||
method: Default::default(),
|
||||
text: "".to_string(),
|
||||
content_length: 0,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
headers: Default::default(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(0, 2, handles);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url of 2, base depth of 2, and max depth of 2, expect false
|
||||
fn reached_max_depth_base_depth_equals_max_depth() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost/one/two").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
original_url: String::new(),
|
||||
status: Default::default(),
|
||||
method: Default::default(),
|
||||
text: "".to_string(),
|
||||
content_length: 0,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
headers: Default::default(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(2, 2, handles);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url depth greater than max depth, expect true
|
||||
fn reached_max_depth_current_greater_than_max() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost/one/two/three").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
original_url: String::new(),
|
||||
status: Default::default(),
|
||||
method: Default::default(),
|
||||
text: "".to_string(),
|
||||
content_length: 0,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
headers: Default::default(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(0, 2, handles);
|
||||
assert!(result);
|
||||
}
|
||||
}
|
||||
227
src/scan_manager/menu.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use crate::progress::PROGRESS_BAR;
|
||||
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
|
||||
Add(String),
|
||||
|
||||
/// user wants to cancel one or more active scans
|
||||
Cancel(Vec<usize>, bool),
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// Interactive scan cancellation menu
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Menu {
|
||||
/// header: name surrounded by separators
|
||||
header: String,
|
||||
|
||||
/// footer: instructions surrounded by separators
|
||||
footer: String,
|
||||
|
||||
/// target for output
|
||||
pub(super) term: Term,
|
||||
}
|
||||
|
||||
/// Implementation of Menu
|
||||
impl Menu {
|
||||
/// Creates new Menu
|
||||
pub(super) fn new() -> Self {
|
||||
let separator = "─".to_string();
|
||||
|
||||
let name = format!(
|
||||
"{} {} {}",
|
||||
"💀",
|
||||
style("Scan Management Menu").bright().yellow(),
|
||||
"💀"
|
||||
);
|
||||
|
||||
let add_cmd = format!(
|
||||
" {}[{}] NEW_URL (ex: {} http://localhost)\n",
|
||||
style("a").green(),
|
||||
style("dd").green(),
|
||||
style("add").green()
|
||||
);
|
||||
|
||||
let canx_cmd = format!(
|
||||
" {}[{}] [-f] SCAN_ID[-SCAN_ID[,...]] (ex: {} 1-4,8,9-13 or {} -f 3)",
|
||||
style("c").red(),
|
||||
style("ancel").red(),
|
||||
style("cancel").red(),
|
||||
style("c").red(),
|
||||
);
|
||||
|
||||
let mut commands = String::from("Commands:\n");
|
||||
commands.push_str(&add_cmd);
|
||||
commands.push_str(&canx_cmd);
|
||||
|
||||
let longest = measure_text_width(&canx_cmd).max(measure_text_width(&name));
|
||||
|
||||
let border = separator.repeat(longest);
|
||||
|
||||
let padded_name = pad_str(&name, longest, Alignment::Center, None);
|
||||
|
||||
let header = format!("{}\n{}\n{}", border, padded_name, border);
|
||||
let footer = format!("{}\n{}\n{}", border, commands, border);
|
||||
|
||||
Self {
|
||||
header,
|
||||
footer,
|
||||
term: Term::stderr(),
|
||||
}
|
||||
}
|
||||
|
||||
/// print menu header
|
||||
pub(super) fn print_header(&self) {
|
||||
self.println(&self.header);
|
||||
}
|
||||
|
||||
/// print menu footer
|
||||
pub(super) fn print_footer(&self) {
|
||||
self.println(&self.footer);
|
||||
}
|
||||
|
||||
/// set PROGRESS_BAR bar target to hidden
|
||||
pub(super) fn hide_progress_bars(&self) {
|
||||
PROGRESS_BAR.set_draw_target(ProgressDrawTarget::hidden());
|
||||
}
|
||||
|
||||
/// set PROGRESS_BAR bar target to hidden
|
||||
pub(super) fn show_progress_bars(&self) {
|
||||
PROGRESS_BAR.set_draw_target(ProgressDrawTarget::stdout());
|
||||
}
|
||||
|
||||
/// Wrapper around console's Term::clear_screen and flush
|
||||
pub(super) fn clear_screen(&self) {
|
||||
self.term.clear_screen().unwrap_or_default();
|
||||
self.term.flush().unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Wrapper around console's Term::write_line
|
||||
pub(super) fn println(&self, msg: &str) {
|
||||
self.term.write_line(msg).unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Helper for parsing a usize from a str
|
||||
fn str_to_usize(&self, value: &str) -> usize {
|
||||
if value.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
value
|
||||
.trim()
|
||||
.to_string()
|
||||
.parse::<usize>()
|
||||
.unwrap_or_else(|e| {
|
||||
self.println(&format!("Found non-numeric input: {}: {:?}", e, value));
|
||||
0
|
||||
})
|
||||
}
|
||||
|
||||
/// split a comma delimited string into vec of usizes
|
||||
pub(super) fn split_to_nums(&self, line: &str) -> Vec<usize> {
|
||||
let mut nums = Vec::new();
|
||||
let values = line.split(',');
|
||||
|
||||
for mut value in values {
|
||||
value = value.trim();
|
||||
|
||||
if value.contains('-') {
|
||||
// range of two values, needs further processing
|
||||
|
||||
let range: Vec<usize> = value
|
||||
.split('-')
|
||||
.map(|s| self.str_to_usize(s))
|
||||
.filter(|m| *m != 0)
|
||||
.collect();
|
||||
|
||||
if range.len() != 2 {
|
||||
// expecting [1, 4] or similar, if a 0 was used, we'd be left with a vec of size 1
|
||||
self.println(&format!("Found invalid range of scans: {}", value));
|
||||
continue;
|
||||
}
|
||||
|
||||
(range[0]..=range[1]).for_each(|n| {
|
||||
// iterate from lower to upper bound and add all interim values, skipping
|
||||
// any already known
|
||||
if !nums.contains(&n) {
|
||||
nums.push(n)
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let value = self.str_to_usize(value);
|
||||
|
||||
if value != 0 && !nums.contains(&value) {
|
||||
// the zeroth scan is always skipped, skip already known values
|
||||
nums.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nums
|
||||
}
|
||||
|
||||
/// get input from the user and translate it to a `MenuCmd`
|
||||
pub(super) fn get_command_input_from_user(&self, line: &str) -> Option<MenuCmd> {
|
||||
let line = line.trim(); // normalize input if there are leading spaces
|
||||
|
||||
match line.chars().next().unwrap_or('_').to_ascii_lowercase() {
|
||||
'c' => {
|
||||
// cancel command; start by determining if -f was used
|
||||
let force = line.contains("-f");
|
||||
|
||||
// then remove c[ancel] from the command so it can be passed to the number
|
||||
// splitter
|
||||
let re = Regex::new(r"^[cC][ancelANCEL]*").unwrap();
|
||||
let line = line.replace("-f", "");
|
||||
let line = re.replace(&line, "").to_string();
|
||||
|
||||
Some(MenuCmd::Cancel(self.split_to_nums(&line), force))
|
||||
}
|
||||
'a' => {
|
||||
// add command
|
||||
// similar to cancel, we need to remove the a[dd] substring, the rest should be
|
||||
// a url
|
||||
let re = Regex::new(r"^[aA][dD]*").unwrap();
|
||||
let line = re.replace(line, "").to_string().trim().to_string();
|
||||
|
||||
Some(MenuCmd::Add(line))
|
||||
}
|
||||
_ => {
|
||||
// invalid input
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a url, confirm with user that we should cancel
|
||||
pub(super) fn confirm_cancellation(&self, url: &str) -> char {
|
||||
self.println(&format!(
|
||||
"You sure you wanna cancel this scan: {}? [Y/n]",
|
||||
url
|
||||
));
|
||||
|
||||
self.term.read_char().unwrap_or('n')
|
||||
}
|
||||
}
|
||||
|
||||
/// Default implementation for Menu
|
||||
impl Default for Menu {
|
||||
/// return Menu::new as default
|
||||
fn default() -> Menu {
|
||||
Menu::new()
|
||||
}
|
||||
}
|
||||
18
src/scan_manager/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
mod scan_container;
|
||||
mod response_container;
|
||||
mod scan;
|
||||
mod menu;
|
||||
mod utils;
|
||||
mod order;
|
||||
mod state;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(self) use menu::Menu;
|
||||
pub use menu::{MenuCmd, MenuCmdResult};
|
||||
pub use order::ScanOrder;
|
||||
pub use response_container::FeroxResponses;
|
||||
pub use scan::{FeroxScan, ScanStatus, ScanType};
|
||||
pub use scan_container::{FeroxScans, PAUSE_SCAN};
|
||||
pub use state::FeroxState;
|
||||
pub use utils::{resume_scan, start_max_time_thread};
|
||||
10
src/scan_manager/order.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
/// Simple enum to designate whether a URL was passed in by the user (Initial) or found during
|
||||
/// scanning (Latest)
|
||||
pub enum ScanOrder {
|
||||
/// Url was passed in by the user
|
||||
Initial,
|
||||
|
||||
/// Url was found during scanning
|
||||
Latest,
|
||||
}
|
||||
55
src/scan_manager/response_container.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use crate::response::FeroxResponse;
|
||||
use serde::{ser::SerializeSeq, Serialize, Serializer};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Container around a locked vector of `FeroxResponse`s, adds wrappers for insertion and search
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeroxResponses {
|
||||
/// Internal structure: locked hashset of `FeroxScan`s
|
||||
pub responses: Arc<RwLock<Vec<FeroxResponse>>>,
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxResponses
|
||||
impl Serialize for FeroxResponses {
|
||||
/// Function that handles serialization of FeroxResponses
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
if let Ok(responses) = self.responses.read() {
|
||||
let mut seq = serializer.serialize_seq(Some(responses.len()))?;
|
||||
|
||||
for response in responses.iter() {
|
||||
seq.serialize_element(response)?;
|
||||
}
|
||||
|
||||
seq.end()
|
||||
} else {
|
||||
// if for some reason we can't unlock the mutex, just write an empty list
|
||||
let seq = serializer.serialize_seq(Some(0))?;
|
||||
seq.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of `FeroxResponses`
|
||||
impl FeroxResponses {
|
||||
/// Add a `FeroxResponse` to the internal container
|
||||
pub fn insert(&self, response: FeroxResponse) {
|
||||
if let Ok(mut responses) = self.responses.write() {
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple check for whether or not a FeroxResponse is contained within the inner container
|
||||
pub fn contains(&self, other: &FeroxResponse) -> bool {
|
||||
if let Ok(responses) = self.responses.read() {
|
||||
for response in responses.iter() {
|
||||
if response.url() == other.url() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
508
src/scan_manager/scan.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
progress::{add_bar, BarType},
|
||||
scanner::PolicyTrigger,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use console::style;
|
||||
use indicatif::ProgressBar;
|
||||
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::{sync, task::JoinHandle};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Struct to hold scan-related state
|
||||
///
|
||||
/// The purpose of this container is to open up the pathway to aborting currently running tasks and
|
||||
/// serialization of all scan state into a state file in order to resume scans that were cut short
|
||||
#[derive(Debug)]
|
||||
pub struct FeroxScan {
|
||||
/// UUID that uniquely ID's the scan
|
||||
pub(super) id: String,
|
||||
|
||||
/// The URL that to be scanned
|
||||
pub(super) url: String,
|
||||
|
||||
/// The type of scan
|
||||
pub(super) scan_type: ScanType,
|
||||
|
||||
/// The order in which the scan was received
|
||||
pub(crate) scan_order: ScanOrder,
|
||||
|
||||
/// Number of requests to populate the progress bar with
|
||||
pub(super) num_requests: u64,
|
||||
|
||||
/// Status of this scan
|
||||
pub(super) status: Mutex<ScanStatus>,
|
||||
|
||||
/// The spawned tokio task performing this scan (uses tokio::sync::Mutex)
|
||||
pub(super) task: sync::Mutex<Option<JoinHandle<()>>>,
|
||||
|
||||
/// The progress bar associated with this scan
|
||||
pub(super) progress_bar: Mutex<Option<ProgressBar>>,
|
||||
|
||||
/// whether or not the user passed --silent|--quiet on the command line
|
||||
pub(super) output_level: OutputLevel,
|
||||
|
||||
/// tracker for overall number of 403s seen by the FeroxScan instance
|
||||
pub(super) status_403s: AtomicUsize,
|
||||
|
||||
/// tracker for overall number of 429s seen by the FeroxScan instance
|
||||
pub(super) status_429s: AtomicUsize,
|
||||
|
||||
/// tracker for total number of errors encountered by the FeroxScan instance
|
||||
pub(super) errors: AtomicUsize,
|
||||
|
||||
/// tracker for the time at which this scan was started
|
||||
pub(super) start_time: Instant,
|
||||
}
|
||||
|
||||
/// Default implementation for FeroxScan
|
||||
impl Default for FeroxScan {
|
||||
/// Create a default FeroxScan, populates ID with a new UUID
|
||||
fn default() -> Self {
|
||||
let new_id = Uuid::new_v4().to_simple().to_string();
|
||||
|
||||
FeroxScan {
|
||||
id: new_id,
|
||||
task: sync::Mutex::new(None), // tokio mutex
|
||||
status: Mutex::new(ScanStatus::default()),
|
||||
num_requests: 0,
|
||||
scan_order: ScanOrder::Latest,
|
||||
url: String::new(),
|
||||
progress_bar: Mutex::new(None),
|
||||
scan_type: ScanType::File,
|
||||
output_level: Default::default(),
|
||||
errors: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
status_403s: Default::default(),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of FeroxScan
|
||||
impl FeroxScan {
|
||||
/// Stop a currently running scan
|
||||
pub async fn abort(&self) -> Result<()> {
|
||||
log::trace!("enter: abort");
|
||||
|
||||
match self.task.try_lock() {
|
||||
Ok(mut guard) => {
|
||||
if let Some(task) = std::mem::replace(&mut *guard, None) {
|
||||
log::trace!("aborting {:?}", self);
|
||||
task.abort();
|
||||
self.set_status(ScanStatus::Cancelled)?;
|
||||
self.stop_progress_bar();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not acquire lock to abort scan (we're already waiting for its results): {:?} {}", self, e);
|
||||
}
|
||||
}
|
||||
log::trace!("exit: abort");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// getter for url
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// small wrapper to set the JoinHandle
|
||||
pub async fn set_task(&self, task: JoinHandle<()>) -> Result<()> {
|
||||
let mut guard = self.task.lock().await;
|
||||
let _ = std::mem::replace(&mut *guard, Some(task));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// small wrapper to set ScanStatus
|
||||
pub fn set_status(&self, status: ScanStatus) -> Result<()> {
|
||||
if let Ok(mut guard) = self.status.lock() {
|
||||
let _ = std::mem::replace(&mut *guard, status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple helper to call .finish on the scan's progress bar
|
||||
pub(super) fn stop_progress_bar(&self) {
|
||||
if let Ok(guard) = self.progress_bar.lock() {
|
||||
if guard.is_some() {
|
||||
(*guard).as_ref().unwrap().finish_at_current_pos()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple helper get a progress bar
|
||||
pub fn progress_bar(&self) -> ProgressBar {
|
||||
match self.progress_bar.lock() {
|
||||
Ok(mut guard) => {
|
||||
if guard.is_some() {
|
||||
(*guard).as_ref().unwrap().clone()
|
||||
} else {
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Default,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => BarType::Hidden,
|
||||
};
|
||||
|
||||
let pb = add_bar(&self.url, self.num_requests, bar_type);
|
||||
pb.reset_elapsed();
|
||||
|
||||
let _ = std::mem::replace(&mut *guard, Some(pb.clone()));
|
||||
|
||||
pb
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("Could not unlock progress bar on {:?}", self);
|
||||
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Default,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => BarType::Hidden,
|
||||
};
|
||||
|
||||
let pb = add_bar(&self.url, self.num_requests, bar_type);
|
||||
pb.reset_elapsed();
|
||||
|
||||
pb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a URL and ProgressBar, create a new FeroxScan, wrap it in an Arc and return it
|
||||
pub fn new(
|
||||
url: &str,
|
||||
scan_type: ScanType,
|
||||
scan_order: ScanOrder,
|
||||
num_requests: u64,
|
||||
output_level: OutputLevel,
|
||||
pb: Option<ProgressBar>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
url: url.to_string(),
|
||||
scan_type,
|
||||
scan_order,
|
||||
num_requests,
|
||||
output_level,
|
||||
progress_bar: Mutex::new(pb),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark the scan as complete and stop the scan's progress bar
|
||||
pub fn finish(&self) -> Result<()> {
|
||||
self.set_status(ScanStatus::Complete)?;
|
||||
self.stop_progress_bar();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// small wrapper to inspect ScanType and ScanStatus to see if a Directory scan is running or
|
||||
/// in the queue to be run
|
||||
pub fn is_active(&self) -> bool {
|
||||
if let Ok(guard) = self.status.lock() {
|
||||
return matches!(
|
||||
(self.scan_type, *guard),
|
||||
(ScanType::Directory, ScanStatus::Running)
|
||||
| (ScanType::Directory, ScanStatus::NotStarted)
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// small wrapper to inspect ScanStatus and see if it's Complete
|
||||
pub fn is_complete(&self) -> bool {
|
||||
if let Ok(guard) = self.status.lock() {
|
||||
return matches!(*guard, ScanStatus::Complete);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// await a task's completion, similar to a thread's join; perform necessary bookkeeping
|
||||
pub async fn join(&self) {
|
||||
log::trace!("enter join({:?})", self);
|
||||
let mut guard = self.task.lock().await;
|
||||
|
||||
if guard.is_some() {
|
||||
if let Some(task) = std::mem::replace(&mut *guard, None) {
|
||||
task.await.unwrap();
|
||||
self.set_status(ScanStatus::Complete)
|
||||
.unwrap_or_else(|e| log::warn!("Could not mark scan complete: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit join({:?})", self);
|
||||
}
|
||||
/// increment the value in question by 1
|
||||
pub(crate) fn add_403(&self) {
|
||||
self.status_403s.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// increment the value in question by 1
|
||||
pub(crate) fn add_429(&self) {
|
||||
self.status_429s.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// increment the value in question by 1
|
||||
pub(crate) fn add_error(&self) {
|
||||
self.errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// simple wrapper to call the appropriate getter based on the given PolicyTrigger
|
||||
pub fn num_errors(&self, trigger: PolicyTrigger) -> usize {
|
||||
match trigger {
|
||||
PolicyTrigger::Status403 => self.status_403s(),
|
||||
PolicyTrigger::Status429 => self.status_429s(),
|
||||
PolicyTrigger::Errors => self.errors(),
|
||||
}
|
||||
}
|
||||
|
||||
/// return the number of errors seen by this scan
|
||||
fn errors(&self) -> usize {
|
||||
self.errors.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// return the number of 403s seen by this scan
|
||||
fn status_403s(&self) -> usize {
|
||||
self.status_403s.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// return the number of 429s seen by this scan
|
||||
fn status_429s(&self) -> usize {
|
||||
self.status_429s.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// return the number of requests per second performed by this scan's scanner
|
||||
pub fn requests_per_second(&self) -> u64 {
|
||||
if !self.is_active() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let reqs = self.requests();
|
||||
let seconds = self.start_time.elapsed().as_secs();
|
||||
|
||||
reqs.checked_div(seconds).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// return the number of requests performed by this scan's scanner
|
||||
pub fn requests(&self) -> u64 {
|
||||
self.progress_bar().position()
|
||||
}
|
||||
}
|
||||
|
||||
/// Display implementation
|
||||
impl fmt::Display for FeroxScan {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let status = if let Ok(guard) = self.status.lock() {
|
||||
match *guard {
|
||||
ScanStatus::NotStarted => style("not started").bright().blue(),
|
||||
ScanStatus::Complete => style("complete").green(),
|
||||
ScanStatus::Cancelled => style("cancelled").red(),
|
||||
ScanStatus::Running => style("running").bright().yellow(),
|
||||
}
|
||||
} else {
|
||||
style("unknown").red()
|
||||
};
|
||||
|
||||
write!(f, "{:12} {}", status, self.url)
|
||||
}
|
||||
}
|
||||
|
||||
/// PartialEq implementation; uses FeroxScan.id for comparison
|
||||
impl PartialEq for FeroxScan {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxScan
|
||||
impl Serialize for FeroxScan {
|
||||
/// Function that handles serialization of a FeroxScan
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("FeroxScan", 4)?;
|
||||
|
||||
state.serialize_field("id", &self.id)?;
|
||||
state.serialize_field("url", &self.url)?;
|
||||
state.serialize_field("scan_type", &self.scan_type)?;
|
||||
state.serialize_field("status", &self.status)?;
|
||||
state.serialize_field("num_requests", &self.num_requests)?;
|
||||
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize implementation for FeroxScan
|
||||
impl<'de> Deserialize<'de> for FeroxScan {
|
||||
/// Deserialize a FeroxScan from a serde_json::Value
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut scan = Self::default();
|
||||
|
||||
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
|
||||
|
||||
for (key, value) in &map {
|
||||
match key.as_str() {
|
||||
"id" => {
|
||||
if let Some(id) = value.as_str() {
|
||||
scan.id = id.to_string();
|
||||
}
|
||||
}
|
||||
"scan_type" => {
|
||||
if let Some(scan_type) = value.as_str() {
|
||||
scan.scan_type = match scan_type {
|
||||
"File" => ScanType::File,
|
||||
"Directory" => ScanType::Directory,
|
||||
_ => ScanType::File,
|
||||
}
|
||||
}
|
||||
}
|
||||
"status" => {
|
||||
if let Some(status) = value.as_str() {
|
||||
scan.status = Mutex::new(match status {
|
||||
"NotStarted" => ScanStatus::NotStarted,
|
||||
"Running" => ScanStatus::Running,
|
||||
"Complete" => ScanStatus::Complete,
|
||||
"Cancelled" => ScanStatus::Cancelled,
|
||||
_ => ScanStatus::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
"url" => {
|
||||
if let Some(url) = value.as_str() {
|
||||
scan.url = url.to_string();
|
||||
}
|
||||
}
|
||||
"num_requests" => {
|
||||
if let Some(num_requests) = value.as_u64() {
|
||||
scan.num_requests = num_requests;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scan)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple enum used to flag a `FeroxScan` as likely a directory or file
|
||||
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
|
||||
pub enum ScanType {
|
||||
/// Just a file being requested
|
||||
File,
|
||||
|
||||
/// A an entire directory that might be scanned
|
||||
Directory,
|
||||
}
|
||||
|
||||
/// Default implementation for ScanType
|
||||
impl Default for ScanType {
|
||||
/// Return ScanType::File as default
|
||||
fn default() -> Self {
|
||||
Self::File
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
|
||||
/// Simple enum to represent a scan's current status ([in]complete, cancelled)
|
||||
pub enum ScanStatus {
|
||||
/// Scan hasn't started yet
|
||||
NotStarted,
|
||||
|
||||
/// Scan finished normally
|
||||
Complete,
|
||||
|
||||
/// Scan was cancelled by the user
|
||||
Cancelled,
|
||||
|
||||
/// Scan has started, but hasn't finished, nor been cancelled
|
||||
Running,
|
||||
}
|
||||
|
||||
/// Default implementation for ScanStatus
|
||||
impl Default for ScanStatus {
|
||||
/// Default variant for ScanStatus is NotStarted
|
||||
fn default() -> Self {
|
||||
Self::NotStarted
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread::sleep;
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[test]
|
||||
/// ensure that num_errors returns the correct values for the given PolicyTrigger
|
||||
///
|
||||
/// covers tests for add_[403,429,error] and the related getters in addition to num_errors
|
||||
fn num_errors_returns_correct_values() {
|
||||
let scan = FeroxScan::new(
|
||||
"http://localhost",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
1000,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
|
||||
scan.add_error();
|
||||
scan.add_403();
|
||||
scan.add_403();
|
||||
scan.add_429();
|
||||
scan.add_429();
|
||||
scan.add_429();
|
||||
|
||||
assert_eq!(scan.num_errors(PolicyTrigger::Errors), 1);
|
||||
assert_eq!(scan.num_errors(PolicyTrigger::Status403), 2);
|
||||
assert_eq!(scan.num_errors(PolicyTrigger::Status429), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure that requests_per_second returns the correct values
|
||||
fn requests_per_second_returns_correct_values() {
|
||||
let scan = FeroxScan {
|
||||
id: "".to_string(),
|
||||
url: "".to_string(),
|
||||
scan_type: ScanType::Directory,
|
||||
scan_order: ScanOrder::Initial,
|
||||
num_requests: 0,
|
||||
status: Mutex::new(ScanStatus::Running),
|
||||
task: Default::default(),
|
||||
progress_bar: Mutex::new(None),
|
||||
output_level: Default::default(),
|
||||
status_403s: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
errors: Default::default(),
|
||||
start_time: Instant::now(),
|
||||
};
|
||||
|
||||
let pb = scan.progress_bar();
|
||||
pb.set_position(100);
|
||||
|
||||
sleep(Duration::new(1, 0));
|
||||
|
||||
let req_sec = scan.requests_per_second();
|
||||
|
||||
assert_eq!(req_sec, 100);
|
||||
|
||||
scan.finish().unwrap();
|
||||
assert_eq!(scan.requests_per_second(), 0);
|
||||
}
|
||||
}
|
||||
514
src/scan_manager/scan_container.rs
Normal file
@@ -0,0 +1,514 @@
|
||||
use super::scan::ScanType;
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
progress::PROGRESS_PRINTER,
|
||||
progress::{add_bar, BarType},
|
||||
scan_manager::{MenuCmd, MenuCmdResult},
|
||||
scanner::RESPONSES,
|
||||
traits::FeroxSerialize,
|
||||
SLEEP_DURATION,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use reqwest::StatusCode;
|
||||
use serde::{ser::SerializeSeq, Serialize, Serializer};
|
||||
use std::{
|
||||
convert::TryInto,
|
||||
fs::File,
|
||||
io::BufReader,
|
||||
ops::Index,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
Arc, Mutex, RwLock,
|
||||
},
|
||||
thread::sleep,
|
||||
};
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
/// Single atomic number that gets incremented once, used to track first thread to interact with
|
||||
/// when pausing a scan
|
||||
static INTERACTIVE_BARRIER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Atomic boolean flag, used to determine whether or not a scan should pause or resume
|
||||
pub static PAUSE_SCAN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Container around a locked hashset of `FeroxScan`s, adds wrappers for insertion and searching
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeroxScans {
|
||||
/// Internal structure: locked hashset of `FeroxScan`s
|
||||
pub scans: RwLock<Vec<Arc<FeroxScan>>>,
|
||||
|
||||
/// menu used for providing a way for users to cancel a scan
|
||||
menu: Menu,
|
||||
|
||||
/// number of requests expected per scan (mirrors the same on Stats); used for initializing
|
||||
/// progress bars and feroxscans
|
||||
bar_length: Mutex<u64>,
|
||||
|
||||
/// whether or not the user passed --silent|--quiet on the command line
|
||||
output_level: OutputLevel,
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxScans
|
||||
///
|
||||
/// purposefully skips menu attribute
|
||||
impl Serialize for FeroxScans {
|
||||
/// Function that handles serialization of FeroxScans
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of `FeroxScans`
|
||||
impl FeroxScans {
|
||||
/// given an OutputLevel, create a new FeroxScans object
|
||||
pub fn new(output_level: OutputLevel) -> Self {
|
||||
Self {
|
||||
output_level,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a `FeroxScan` to the internal container
|
||||
///
|
||||
/// If the internal container did NOT contain the scan, true is returned; else false
|
||||
pub fn insert(&self, scan: Arc<FeroxScan>) -> bool {
|
||||
// If the container did contain the scan, set sentry to false
|
||||
// If the container did not contain the scan, set sentry to true
|
||||
let sentry = !self.contains(&scan.url);
|
||||
|
||||
if sentry {
|
||||
// can't update the internal container while the scan itself is locked, so first
|
||||
// lock the scan and check the container for the scan's presence, then add if
|
||||
// not found
|
||||
match self.scans.write() {
|
||||
Ok(mut scans) => {
|
||||
scans.push(scan);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("FeroxScans' container's mutex is poisoned: {}", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sentry
|
||||
}
|
||||
|
||||
/// load serialized FeroxScan(s) into this FeroxScans
|
||||
pub fn add_serialized_scans(&self, filename: &str) -> Result<()> {
|
||||
log::trace!("enter: add_serialized_scans({})", filename);
|
||||
let file = File::open(filename)?;
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
let state: serde_json::Value = serde_json::from_reader(reader)?;
|
||||
|
||||
if let Some(scans) = state.get("scans") {
|
||||
if let Some(arr_scans) = scans.as_array() {
|
||||
for scan in arr_scans {
|
||||
let mut deser_scan: FeroxScan =
|
||||
serde_json::from_value(scan.clone()).unwrap_or_default();
|
||||
// FeroxScans gets -q value from config as usual; the FeroxScans themselves
|
||||
// rely on that value being passed in. If the user starts a scan without -q
|
||||
// and resumes the scan but adds -q, FeroxScan will not have the proper value
|
||||
// without the line below
|
||||
deser_scan.output_level = self.output_level;
|
||||
|
||||
log::debug!("added: {}", deser_scan);
|
||||
self.insert(Arc::new(deser_scan));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: add_serialized_scans");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple check for whether or not a FeroxScan is contained within the inner container based
|
||||
/// on the given URL
|
||||
pub fn contains(&self, url: &str) -> bool {
|
||||
if let Ok(scans) = self.scans.read() {
|
||||
for scan in scans.iter() {
|
||||
if scan.url == url {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find and return a `FeroxScan` based on the given URL
|
||||
pub fn get_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for scan in guard.iter() {
|
||||
if scan.url == url {
|
||||
return Some(scan.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn get_base_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
|
||||
log::trace!("enter: get_sub_paths_from_path({})", url);
|
||||
|
||||
// rmatch_indices returns tuples in index, match form, i.e. (10, "/")
|
||||
// with the furthest-right match in the first position in the vector
|
||||
let matches: Vec<_> = url.rmatch_indices('/').collect();
|
||||
|
||||
// iterate from the furthest right matching index and check the given url from the
|
||||
// start to the furthest-right '/' character. compare that slice to the urls associated
|
||||
// with directory scans and return the first match, since it should be the 'deepest'
|
||||
// match.
|
||||
// Example:
|
||||
// url: http://shmocalhost/src/release/examples/stuff.php
|
||||
// scans:
|
||||
// http://shmocalhost/src/statistics
|
||||
// http://shmocalhost/src/banner
|
||||
// http://shmocalhost/src/release
|
||||
// http://shmocalhost/src/release/examples
|
||||
//
|
||||
// returns: http://shmocalhost/src/release/examples
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for (idx, _) in &matches {
|
||||
for scan in guard.iter() {
|
||||
let slice = url.index(0..*idx);
|
||||
if slice == scan.url || format!("{}/", slice).as_str() == scan.url {
|
||||
log::trace!("enter: get_sub_paths_from_path -> {}", scan);
|
||||
return Some(scan.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("enter: get_sub_paths_from_path -> None");
|
||||
None
|
||||
}
|
||||
/// add one to either 403 or 429 tracker in the scan related to the given url
|
||||
pub fn increment_status_code(&self, url: &str, code: StatusCode) {
|
||||
if let Some(scan) = self.get_base_scan_by_url(url) {
|
||||
match code {
|
||||
StatusCode::TOO_MANY_REQUESTS => {
|
||||
scan.add_429();
|
||||
}
|
||||
StatusCode::FORBIDDEN => {
|
||||
scan.add_403();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// add one to either 403 or 429 tracker in the scan related to the given url
|
||||
pub fn increment_error(&self, url: &str) {
|
||||
if let Some(scan) = self.get_base_scan_by_url(url) {
|
||||
scan.add_error();
|
||||
}
|
||||
}
|
||||
|
||||
/// Print all FeroxScans of type Directory
|
||||
///
|
||||
/// Example:
|
||||
/// 0: complete https://10.129.45.20
|
||||
/// 9: complete https://10.129.45.20/images
|
||||
/// 10: complete https://10.129.45.20/assets
|
||||
pub async fn display_scans(&self) {
|
||||
let scans = {
|
||||
// written this way in order to grab the vector and drop the lock immediately
|
||||
// otherwise the spawned task that this is a part of is no longer Send due to
|
||||
// the scan.task.lock().await below while the lock is held (RwLock is not Send)
|
||||
self.scans
|
||||
.read()
|
||||
.expect("Could not acquire lock in display_scans")
|
||||
.clone()
|
||||
};
|
||||
|
||||
for (i, scan) in scans.iter().enumerate() {
|
||||
if matches!(scan.scan_order, ScanOrder::Initial) || scan.task.try_lock().is_err() {
|
||||
// original target passed in via either -u or --stdin
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(scan.scan_type, ScanType::Directory) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a list of indexes, cancel their associated FeroxScans
|
||||
async fn cancel_scans(&self, indexes: Vec<usize>, force: bool) -> usize {
|
||||
let menu_pause_duration = Duration::from_millis(SLEEP_DURATION);
|
||||
|
||||
let mut num_cancelled = 0_usize;
|
||||
|
||||
for num in indexes {
|
||||
let selected = match self.scans.read() {
|
||||
Ok(u_scans) => {
|
||||
// check if number provided is out of range
|
||||
if num >= u_scans.len() {
|
||||
// usize can't be negative, just need to handle exceeding bounds
|
||||
self.menu
|
||||
.println(&format!("The number {} is not a valid choice.", num));
|
||||
sleep(menu_pause_duration);
|
||||
continue;
|
||||
}
|
||||
u_scans.index(num).clone()
|
||||
}
|
||||
Err(..) => continue,
|
||||
};
|
||||
|
||||
let input = if force {
|
||||
'y'
|
||||
} else {
|
||||
self.menu.confirm_cancellation(&selected.url)
|
||||
};
|
||||
|
||||
if input == 'y' || input == '\n' {
|
||||
self.menu.println(&format!("Stopping {}...", selected.url));
|
||||
|
||||
selected
|
||||
.abort()
|
||||
.await
|
||||
.unwrap_or_else(|e| log::warn!("Could not cancel task: {}", e));
|
||||
|
||||
let pb = selected.progress_bar();
|
||||
num_cancelled += pb.length() as usize - pb.position() as usize
|
||||
} else {
|
||||
self.menu.println("Ok, doing nothing...");
|
||||
}
|
||||
|
||||
sleep(menu_pause_duration);
|
||||
}
|
||||
|
||||
num_cancelled
|
||||
}
|
||||
|
||||
/// CLI menu that allows for interactive cancellation of recursed-into directories
|
||||
async fn interactive_menu(&self) -> Option<MenuCmdResult> {
|
||||
self.menu.hide_progress_bars();
|
||||
self.menu.clear_screen();
|
||||
self.menu.print_header();
|
||||
self.display_scans().await;
|
||||
self.menu.print_footer();
|
||||
|
||||
let menu_cmd = if let Ok(line) = self.menu.term.read_line() {
|
||||
self.menu.get_command_input_from_user(&line)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = match menu_cmd {
|
||||
Some(MenuCmd::Cancel(indices, should_force)) => {
|
||||
// cancel the things
|
||||
let num_cancelled = self.cancel_scans(indices, should_force).await;
|
||||
Some(MenuCmdResult::NumCancelled(num_cancelled))
|
||||
}
|
||||
Some(MenuCmd::Add(url)) => Some(MenuCmdResult::Url(url)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
self.menu.clear_screen();
|
||||
self.menu.show_progress_bars();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// prints all known responses that the scanner has already seen
|
||||
pub fn print_known_responses(&self) {
|
||||
if let Ok(mut responses) = RESPONSES.responses.write() {
|
||||
for response in responses.iter_mut() {
|
||||
if self.output_level != response.output_level {
|
||||
// set the output_level prior to printing the response to ensure that the
|
||||
// response's setting aligns with the overall configuration (since we're
|
||||
// calling this from a resumed state)
|
||||
response.output_level = self.output_level;
|
||||
}
|
||||
PROGRESS_PRINTER.println(response.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// if a resumed scan is already complete, display a completed progress bar to the user
|
||||
pub fn print_completed_bars(&self, bar_length: usize) -> Result<()> {
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Message,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => return Ok(()), // fast exit when --silent was used
|
||||
};
|
||||
|
||||
if let Ok(scans) = self.scans.read() {
|
||||
for scan in scans.iter() {
|
||||
if scan.is_complete() {
|
||||
// these scans are complete, and just need to be shown to the user
|
||||
let pb = add_bar(
|
||||
&scan.url,
|
||||
bar_length.try_into().unwrap_or_default(),
|
||||
bar_type,
|
||||
);
|
||||
pb.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forced the calling thread into a busy loop
|
||||
///
|
||||
/// Every `SLEEP_DURATION` milliseconds, the function examines the result stored in `PAUSE_SCAN`
|
||||
///
|
||||
/// When the value stored in `PAUSE_SCAN` becomes `false`, the function returns, exiting the busy
|
||||
/// loop
|
||||
pub async fn pause(&self, get_user_input: bool) -> Option<MenuCmdResult> {
|
||||
// function uses tokio::time, not std
|
||||
|
||||
// local testing showed a pretty slow increase (less than linear) in CPU usage as # of
|
||||
// concurrent scans rose when SLEEP_DURATION was set to 500, using that as the default for now
|
||||
let mut interval = time::interval(time::Duration::from_millis(SLEEP_DURATION));
|
||||
let mut command_result = None;
|
||||
|
||||
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 0 {
|
||||
INTERACTIVE_BARRIER.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if get_user_input {
|
||||
command_result = self.interactive_menu().await;
|
||||
PAUSE_SCAN.store(false, Ordering::Relaxed);
|
||||
self.print_known_responses();
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
// first tick happens immediately, all others wait the specified duration
|
||||
interval.tick().await;
|
||||
|
||||
if !PAUSE_SCAN.load(Ordering::Acquire) {
|
||||
// PAUSE_SCAN is false, so we can exit the busy loop
|
||||
|
||||
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 1 {
|
||||
INTERACTIVE_BARRIER.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
log::trace!("exit: pause_scan -> {:?}", command_result);
|
||||
return command_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// set the bar length of FeroxScans
|
||||
pub fn set_bar_length(&self, bar_length: u64) {
|
||||
if let Ok(mut guard) = self.bar_length.lock() {
|
||||
*guard = bar_length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans`
|
||||
///
|
||||
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
|
||||
///
|
||||
/// Also return a reference to the new `FeroxScan`
|
||||
pub(super) fn add_scan(
|
||||
&self,
|
||||
url: &str,
|
||||
scan_type: ScanType,
|
||||
scan_order: ScanOrder,
|
||||
) -> (bool, Arc<FeroxScan>) {
|
||||
let bar_length = if let Ok(guard) = self.bar_length.lock() {
|
||||
*guard
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let bar = match scan_type {
|
||||
ScanType::Directory => {
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Default,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => BarType::Hidden,
|
||||
};
|
||||
|
||||
let progress_bar = add_bar(url, bar_length, bar_type);
|
||||
|
||||
progress_bar.reset_elapsed();
|
||||
|
||||
Some(progress_bar)
|
||||
}
|
||||
ScanType::File => None,
|
||||
};
|
||||
|
||||
let ferox_scan = FeroxScan::new(
|
||||
url,
|
||||
scan_type,
|
||||
scan_order,
|
||||
bar_length,
|
||||
self.output_level,
|
||||
bar,
|
||||
);
|
||||
|
||||
// If the set did not contain the scan, true is returned.
|
||||
// If the set did contain the scan, false is returned.
|
||||
let response = self.insert(ferox_scan.clone());
|
||||
|
||||
(response, ferox_scan)
|
||||
}
|
||||
|
||||
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a Directory Scan
|
||||
///
|
||||
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
|
||||
///
|
||||
/// Also return a reference to the new `FeroxScan`
|
||||
pub fn add_directory_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
|
||||
self.add_scan(url, ScanType::Directory, scan_order)
|
||||
}
|
||||
|
||||
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a File Scan
|
||||
///
|
||||
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
|
||||
///
|
||||
/// Also return a reference to the new `FeroxScan`
|
||||
pub fn add_file_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
|
||||
self.add_scan(url, ScanType::File, scan_order)
|
||||
}
|
||||
|
||||
/// small helper to determine whether any scans are active or not
|
||||
pub fn has_active_scans(&self) -> bool {
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for scan in guard.iter() {
|
||||
if scan.is_active() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Retrieve all active scans
|
||||
pub fn get_active_scans(&self) -> Vec<Arc<FeroxScan>> {
|
||||
let mut scans = vec![];
|
||||
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for scan in guard.iter() {
|
||||
if !scan.is_active() {
|
||||
continue;
|
||||
}
|
||||
scans.push(scan.clone());
|
||||
}
|
||||
}
|
||||
scans
|
||||
}
|
||||
}
|
||||
53
src/scan_manager/state.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use super::*;
|
||||
use crate::{config::Configuration, statistics::Stats, traits::FeroxSerialize, utils::fmt_err};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Data container for (de)?serialization of multiple items
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct FeroxState {
|
||||
/// Known scans
|
||||
scans: Arc<FeroxScans>,
|
||||
|
||||
/// Current running config
|
||||
config: Arc<Configuration>,
|
||||
|
||||
/// Known responses
|
||||
responses: &'static FeroxResponses,
|
||||
|
||||
/// Gathered statistics
|
||||
statistics: Arc<Stats>,
|
||||
}
|
||||
|
||||
/// implementation of FeroxState
|
||||
impl FeroxState {
|
||||
/// create new FeroxState object
|
||||
pub fn new(
|
||||
scans: Arc<FeroxScans>,
|
||||
config: Arc<Configuration>,
|
||||
responses: &'static FeroxResponses,
|
||||
statistics: Arc<Stats>,
|
||||
) -> Self {
|
||||
Self {
|
||||
scans,
|
||||
config,
|
||||
responses,
|
||||
statistics,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FeroxSerialize implementation for FeroxState
|
||||
impl FeroxSerialize for FeroxState {
|
||||
/// Simply return debug format of FeroxState to satisfy as_str
|
||||
fn as_str(&self) -> String {
|
||||
format!("{:?}", self)
|
||||
}
|
||||
|
||||
/// Simple call to produce a JSON string using the given FeroxState
|
||||
fn as_json(&self) -> Result<String> {
|
||||
serde_json::to_string(&self)
|
||||
.with_context(|| fmt_err("Could not convert scan's running state to JSON"))
|
||||
}
|
||||
}
|
||||
723
src/scan_manager/tests.rs
Normal file
@@ -0,0 +1,723 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::{Configuration, OutputLevel},
|
||||
event_handlers::Handles,
|
||||
response::FeroxResponse,
|
||||
scanner::RESPONSES,
|
||||
statistics::Stats,
|
||||
traits::FeroxSerialize,
|
||||
SLEEP_DURATION, VERSION,
|
||||
};
|
||||
use indicatif::ProgressBar;
|
||||
use predicates::prelude::*;
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
use std::thread::sleep;
|
||||
use std::time::Instant;
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
#[test]
|
||||
/// test that ScanType's default is File
|
||||
fn default_scantype_is_file() {
|
||||
match ScanType::default() {
|
||||
ScanType::File => {}
|
||||
ScanType::Directory => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// tests that pause_scan pauses execution and releases execution when PAUSE_SCAN is toggled
|
||||
/// the spinner used during the test has had .finish_and_clear called on it, meaning that
|
||||
/// a new one will be created, taking the if branch within the function
|
||||
async fn scanner_pause_scan_with_finished_spinner() {
|
||||
let now = time::Instant::now();
|
||||
let urls = FeroxScans::default();
|
||||
|
||||
PAUSE_SCAN.store(true, Ordering::Relaxed);
|
||||
|
||||
let expected = time::Duration::from_secs(2);
|
||||
|
||||
tokio::spawn(async move {
|
||||
time::sleep(expected).await;
|
||||
PAUSE_SCAN.store(false, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
urls.pause(false).await;
|
||||
|
||||
assert!(now.elapsed() > expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add an unknown url to the hashset, expect true
|
||||
fn add_url_to_list_of_scanned_urls_with_unknown_url() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://unknown_url";
|
||||
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add a known url to the hashset, with a trailing slash, expect false
|
||||
fn add_url_to_list_of_scanned_urls_with_known_url() {
|
||||
let urls = FeroxScans::default();
|
||||
let pb = ProgressBar::new(1);
|
||||
let url = "http://unknown_url/";
|
||||
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb),
|
||||
);
|
||||
|
||||
assert!(urls.insert(scan));
|
||||
|
||||
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// stop_progress_bar should stop the progress bar
|
||||
fn stop_progress_bar_stops_bar() {
|
||||
let pb = ProgressBar::new(1);
|
||||
let url = "http://unknown_url/";
|
||||
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb),
|
||||
);
|
||||
|
||||
assert!(!scan
|
||||
.progress_bar
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.is_finished());
|
||||
|
||||
scan.stop_progress_bar();
|
||||
|
||||
assert!(scan
|
||||
.progress_bar
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.is_finished());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add a known url to the hashset, without a trailing slash, expect false
|
||||
fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://unknown_url";
|
||||
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::File,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(urls.insert(scan));
|
||||
|
||||
let (result, _scan) = urls.add_scan(url, ScanType::File, ScanOrder::Latest);
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// just increasing coverage, no real expectations
|
||||
async fn call_display_scans() {
|
||||
let urls = FeroxScans::default();
|
||||
let pb = ProgressBar::new(1);
|
||||
let pb_two = ProgressBar::new(2);
|
||||
let url = "http://unknown_url/";
|
||||
let url_two = "http://unknown_url/fa";
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb),
|
||||
);
|
||||
let scan_two = FeroxScan::new(
|
||||
url_two,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb_two.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb_two),
|
||||
);
|
||||
|
||||
scan_two.finish().unwrap(); // one complete, one incomplete
|
||||
scan_two
|
||||
.set_task(tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(SLEEP_DURATION));
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(urls.insert(scan));
|
||||
assert!(urls.insert(scan_two));
|
||||
|
||||
urls.display_scans().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure that PartialEq compares FeroxScan.id fields
|
||||
fn partial_eq_compares_the_id_field() {
|
||||
let url = "http://unknown_url/";
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let scan_two = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!scan.eq(&scan_two));
|
||||
|
||||
let scan_two = scan.clone();
|
||||
|
||||
assert!(scan.eq(&scan_two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// show that a new progress bar is created if one doesn't exist
|
||||
fn ferox_scan_get_progress_bar_when_none_is_set() {
|
||||
let scan = FeroxScan::default();
|
||||
|
||||
assert!(scan.progress_bar.lock().unwrap().is_none()); // no pb exists
|
||||
|
||||
let pb = scan.progress_bar();
|
||||
|
||||
assert!(scan.progress_bar.lock().unwrap().is_some()); // new pb created
|
||||
assert!(!pb.is_finished()) // not finished
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a JSON entry representing a FeroxScan, test that it deserializes into the proper type
|
||||
/// with the right attributes
|
||||
fn ferox_scan_deserialize() {
|
||||
let fs_json = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Directory","status":"Complete"}"#;
|
||||
let fs_json_two = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"Cancelled"}"#;
|
||||
let fs_json_three = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"","num_requests":42}"#;
|
||||
|
||||
let fs: FeroxScan = serde_json::from_str(fs_json).unwrap();
|
||||
let fs_two: FeroxScan = serde_json::from_str(fs_json_two).unwrap();
|
||||
let fs_three: FeroxScan = serde_json::from_str(fs_json_three).unwrap();
|
||||
assert_eq!(fs.url, "https://spiritanimal.com");
|
||||
|
||||
match fs.scan_type {
|
||||
ScanType::Directory => {}
|
||||
ScanType::File => {
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
match fs_two.scan_type {
|
||||
ScanType::Directory => {
|
||||
panic!();
|
||||
}
|
||||
ScanType::File => {}
|
||||
}
|
||||
|
||||
match *fs.progress_bar.lock().unwrap() {
|
||||
None => {}
|
||||
Some(_) => {
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
assert!(matches!(*fs.status.lock().unwrap(), ScanStatus::Complete));
|
||||
assert!(matches!(
|
||||
*fs_two.status.lock().unwrap(),
|
||||
ScanStatus::Cancelled
|
||||
));
|
||||
assert!(matches!(
|
||||
*fs_three.status.lock().unwrap(),
|
||||
ScanStatus::NotStarted
|
||||
));
|
||||
assert_eq!(fs_three.num_requests, 42);
|
||||
assert_eq!(fs.id, "057016a14769414aac9a7a62707598cb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxScan, test that it serializes into the proper JSON entry
|
||||
fn ferox_scan_serialize() {
|
||||
let fs = FeroxScan::new(
|
||||
"https://spiritanimal.com",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let fs_json = format!(
|
||||
r#"{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}"#,
|
||||
fs.id
|
||||
);
|
||||
assert_eq!(fs_json, serde_json::to_string(&*fs).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxScans, test that it serializes into the proper JSON entry
|
||||
fn ferox_scans_serialize() {
|
||||
let ferox_scan = FeroxScan::new(
|
||||
"https://spiritanimal.com",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let ferox_scans = FeroxScans::default();
|
||||
let ferox_scans_json = format!(
|
||||
r#"[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}]"#,
|
||||
ferox_scan.id
|
||||
);
|
||||
ferox_scans.scans.write().unwrap().push(ferox_scan);
|
||||
assert_eq!(
|
||||
ferox_scans_json,
|
||||
serde_json::to_string(&ferox_scans).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxResponses, test that it serializes into the proper JSON entry
|
||||
fn ferox_responses_serialize() {
|
||||
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
|
||||
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
|
||||
|
||||
let responses = FeroxResponses::default();
|
||||
responses.insert(response);
|
||||
// responses has a response now
|
||||
|
||||
// serialized should be a list of responses
|
||||
let expected = format!("[{}]", json_response);
|
||||
|
||||
let serialized = serde_json::to_string(&responses).unwrap();
|
||||
assert_eq!(expected, serialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxResponse, test that it serializes into the proper JSON entry
|
||||
fn ferox_response_serialize_and_deserialize() {
|
||||
// deserialize
|
||||
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
|
||||
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
|
||||
|
||||
assert_eq!(response.url().as_str(), "https://nerdcore.com/css");
|
||||
assert_eq!(response.url().path(), "/css");
|
||||
assert!(response.wildcard());
|
||||
assert_eq!(response.status().as_u16(), 301);
|
||||
assert_eq!(response.content_length(), 173);
|
||||
assert_eq!(response.line_count(), 10);
|
||||
assert_eq!(response.word_count(), 16);
|
||||
assert_eq!(response.headers().get("server").unwrap(), "nginx/1.16.1");
|
||||
|
||||
// serialize, however, this can fail when headers are out of order
|
||||
let new_json = serde_json::to_string(&response).unwrap();
|
||||
assert_eq!(json_response, new_json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test FeroxSerialize implementation of FeroxState
|
||||
fn feroxstates_feroxserialize_implementation() {
|
||||
let ferox_scan = FeroxScan::new(
|
||||
"https://spiritanimal.com",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let ferox_scans = FeroxScans::default();
|
||||
let saved_id = ferox_scan.id.clone();
|
||||
ferox_scans.insert(ferox_scan);
|
||||
|
||||
let config = Configuration::new().unwrap();
|
||||
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"}}"#;
|
||||
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
|
||||
RESPONSES.insert(response);
|
||||
|
||||
let ferox_state = FeroxState::new(
|
||||
Arc::new(ferox_scans),
|
||||
Arc::new(Configuration::new().unwrap()),
|
||||
&RESPONSES,
|
||||
stats,
|
||||
);
|
||||
|
||||
let expected_strs = predicates::str::contains("scans: FeroxScans").and(
|
||||
predicate::str::contains("config: Configuration")
|
||||
.and(predicate::str::contains("responses: FeroxResponses"))
|
||||
.and(predicate::str::contains("nerdcore.com"))
|
||||
.and(predicate::str::contains("/css"))
|
||||
.and(predicate::str::contains("https://spiritanimal.com")),
|
||||
);
|
||||
|
||||
assert!(expected_strs.eval(&ferox_state.as_str()));
|
||||
|
||||
let json_state = ferox_state.as_json().unwrap();
|
||||
for expected in [
|
||||
r#""scans""#,
|
||||
&format!(r#""id":"{}""#, saved_id),
|
||||
r#""url":"https://spiritanimal.com""#,
|
||||
r#""scan_type":"Directory""#,
|
||||
r#""status":"NotStarted""#,
|
||||
r#""num_requests":0"#,
|
||||
r#""config""#,
|
||||
r#""type":"configuration""#,
|
||||
r#""wordlist":"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt""#,
|
||||
r#""config""#,
|
||||
r#""proxy":"""#,
|
||||
r#""replay_proxy":"""#,
|
||||
r#""target_url":"""#,
|
||||
r#""status_codes":[200,204,301,302,307,308,401,403,405,500]"#,
|
||||
r#""replay_codes":[200,204,301,302,307,308,401,403,405,500]"#,
|
||||
r#""filter_status":[]"#,
|
||||
r#""threads":50"#,
|
||||
r#""timeout":7"#,
|
||||
r#""verbosity":0"#,
|
||||
r#""silent":false"#,
|
||||
r#""quiet":false"#,
|
||||
r#""auto_bail":false"#,
|
||||
r#""auto_tune":false"#,
|
||||
r#""json":false"#,
|
||||
r#""output":"""#,
|
||||
r#""debug_log":"""#,
|
||||
&format!(r#""user_agent":"feroxbuster/{}""#, VERSION),
|
||||
r#""random_agent":false"#,
|
||||
r#""redirects":false"#,
|
||||
r#""insecure":false"#,
|
||||
r#""extensions":[]"#,
|
||||
r#""methods":["GET"],"#,
|
||||
r#""data":[]"#,
|
||||
r#""headers""#,
|
||||
r#""queries":[]"#,
|
||||
r#""no_recursion":false"#,
|
||||
r#""extract_links":false"#,
|
||||
r#""add_slash":false"#,
|
||||
r#""stdin":false"#,
|
||||
r#""depth":4"#,
|
||||
r#""scan_limit":0"#,
|
||||
r#""parallel":0"#,
|
||||
r#""rate_limit":0"#,
|
||||
r#""filter_size":[]"#,
|
||||
r#""filter_line_count":[]"#,
|
||||
r#""filter_word_count":[]"#,
|
||||
r#""filter_regex":[]"#,
|
||||
r#""dont_filter":false"#,
|
||||
r#""resumed":false"#,
|
||||
r#""resume_from":"""#,
|
||||
r#""save_state":false"#,
|
||||
r#""time_limit":"""#,
|
||||
r#""filter_similar":[]"#,
|
||||
r#""url_denylist":[]"#,
|
||||
r#""responses""#,
|
||||
r#""type":"response""#,
|
||||
r#""url":"https://nerdcore.com/css""#,
|
||||
r#""path":"/css""#,
|
||||
r#""wildcard":true"#,
|
||||
r#""status":301"#,
|
||||
r#""method":"GET""#,
|
||||
r#""content_length":173"#,
|
||||
r#""line_count":10"#,
|
||||
r#""word_count":16"#,
|
||||
r#""headers""#,
|
||||
r#""server":"nginx/1.16.1"#,
|
||||
]
|
||||
.iter()
|
||||
{
|
||||
assert!(
|
||||
predicates::str::contains(*expected).eval(&json_state),
|
||||
"{}",
|
||||
expected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[should_panic]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// call start_max_time_thread with a valid timespec, expect a panic, but only after a certain
|
||||
/// number of seconds
|
||||
async fn start_max_time_thread_panics_after_delay() {
|
||||
let now = time::Instant::now();
|
||||
let delay = time::Duration::new(3, 0);
|
||||
|
||||
let config = Configuration {
|
||||
time_limit: String::from("3s"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
|
||||
|
||||
start_max_time_thread(handles).await;
|
||||
|
||||
assert!(now.elapsed() > delay);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// call start_max_time_thread with a timespec that's too large to be parsed correctly, expect
|
||||
/// immediate return and no panic, as the sigint handler is never called
|
||||
async fn start_max_time_thread_returns_immediately_with_too_large_input() {
|
||||
let now = time::Instant::now();
|
||||
let delay = time::Duration::new(1, 0);
|
||||
let config = Configuration {
|
||||
time_limit: String::from("18446744073709551616m"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
|
||||
|
||||
// pub const MAX: usize = usize::MAX; // 18_446_744_073_709_551_615usize
|
||||
start_max_time_thread(handles).await; // can't fit in dest u64
|
||||
|
||||
assert!(now.elapsed() < delay); // assuming function call will take less than 1second
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// coverage for FeroxScan's Display implementation
|
||||
fn feroxscan_display() {
|
||||
let scan = FeroxScan {
|
||||
id: "".to_string(),
|
||||
url: String::from("http://localhost"),
|
||||
scan_order: ScanOrder::Latest,
|
||||
scan_type: Default::default(),
|
||||
num_requests: 0,
|
||||
start_time: Instant::now(),
|
||||
output_level: OutputLevel::Default,
|
||||
status_403s: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
status: Default::default(),
|
||||
task: tokio::sync::Mutex::new(None),
|
||||
progress_bar: std::sync::Mutex::new(None),
|
||||
errors: Default::default(),
|
||||
};
|
||||
|
||||
let not_started = format!("{}", scan);
|
||||
|
||||
assert!(predicate::str::contains("not started")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(¬_started));
|
||||
|
||||
scan.set_status(ScanStatus::Complete).unwrap();
|
||||
let complete = format!("{}", scan);
|
||||
assert!(predicate::str::contains("complete")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(&complete));
|
||||
|
||||
scan.set_status(ScanStatus::Cancelled).unwrap();
|
||||
let cancelled = format!("{}", scan);
|
||||
assert!(predicate::str::contains("cancelled")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(&cancelled));
|
||||
|
||||
scan.set_status(ScanStatus::Running).unwrap();
|
||||
let running = format!("{}", scan);
|
||||
assert!(predicate::str::contains("running")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(&running));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// call FeroxScan::abort, ensure status becomes cancelled
|
||||
async fn ferox_scan_abort() {
|
||||
let scan = FeroxScan {
|
||||
id: "".to_string(),
|
||||
url: String::from("http://localhost"),
|
||||
scan_order: ScanOrder::Latest,
|
||||
scan_type: Default::default(),
|
||||
num_requests: 0,
|
||||
start_time: Instant::now(),
|
||||
output_level: OutputLevel::Default,
|
||||
status_403s: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
status: std::sync::Mutex::new(ScanStatus::Running),
|
||||
task: tokio::sync::Mutex::new(Some(tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(SLEEP_DURATION * 2));
|
||||
}))),
|
||||
progress_bar: std::sync::Mutex::new(None),
|
||||
errors: Default::default(),
|
||||
};
|
||||
|
||||
scan.abort().await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
*scan.status.lock().unwrap(),
|
||||
ScanStatus::Cancelled
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call a few menu functions for coverage's sake
|
||||
///
|
||||
/// there's not a trivial way to test these programmatically (at least i'm too lazy rn to do it)
|
||||
/// and their correctness can be verified easily manually; just calling for now
|
||||
fn menu_print_header_and_footer() {
|
||||
let menu = Menu::new();
|
||||
let menu_cmd_1 = MenuCmd::Add(String::from("http://localhost"));
|
||||
let menu_cmd_2 = MenuCmd::Cancel(vec![0], false);
|
||||
let menu_cmd_res_1 = MenuCmdResult::Url(String::from("http://localhost"));
|
||||
let menu_cmd_res_2 = MenuCmdResult::NumCancelled(2);
|
||||
println!(
|
||||
"{:?}{:?}{:?}{:?}",
|
||||
menu_cmd_1, menu_cmd_2, menu_cmd_res_1, menu_cmd_res_2
|
||||
);
|
||||
menu.clear_screen();
|
||||
menu.print_header();
|
||||
menu.print_footer();
|
||||
menu.hide_progress_bars();
|
||||
menu.show_progress_bars();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure command parsing from user input results int he correct MenuCmd returned
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure command parsing from user input results int he correct MenuCmd returned
|
||||
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::Add(_)));
|
||||
|
||||
if let MenuCmd::Add(url) = result {
|
||||
assert_eq!(url, test_url);
|
||||
}
|
||||
} else {
|
||||
assert!(menu.get_command_input_from_user(&full_cmd).is_none());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure spaces are trimmed and numbers are returned from split_to_nums
|
||||
fn split_to_nums_is_correct() {
|
||||
let menu = Menu::new();
|
||||
|
||||
let nums = menu.split_to_nums("1, 3, 4, 7 - 12, 10-10, 10-11, 9-12, 12-6, -1, 4-");
|
||||
|
||||
assert_eq!(nums, vec![1, 3, 4, 7, 8, 9, 10, 11, 12]);
|
||||
assert_eq!(menu.split_to_nums("9-12"), vec![9, 10, 11, 12]);
|
||||
assert!(menu.split_to_nums("-12").is_empty());
|
||||
assert!(menu.split_to_nums("12-").is_empty());
|
||||
assert!(menu.split_to_nums("\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a deep url, find the correct scan
|
||||
fn get_base_scan_by_url_finds_correct_scan() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://localhost";
|
||||
let url1 = "http://localhost/stuff";
|
||||
let url2 = "http://shlocalhost/stuff/things";
|
||||
let url3 = "http://shlocalhost/stuff/things/mostuff";
|
||||
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
let (_, scan1) = urls.add_scan(url1, ScanType::Directory, ScanOrder::Latest);
|
||||
let (_, scan2) = urls.add_scan(url2, ScanType::Directory, ScanOrder::Latest);
|
||||
let (_, scan3) = urls.add_scan(url3, ScanType::Directory, ScanOrder::Latest);
|
||||
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://localhost/things.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan.id
|
||||
);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://localhost/stuff/things.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan1.id
|
||||
);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://shlocalhost/stuff/things/mostuff.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan2.id
|
||||
);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://shlocalhost/stuff/things/mostuff/mothings.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan3.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a shallow url without a trailing slash, find the correct scan
|
||||
fn get_base_scan_by_url_finds_correct_scan_without_trailing_slash() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://localhost";
|
||||
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://localhost/BKPMiherrortBPKcw")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a shallow url with a trailing slash, find the correct scan
|
||||
fn get_base_scan_by_url_finds_correct_scan_with_trailing_slash() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://127.0.0.1:41971/";
|
||||
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://127.0.0.1:41971/BKPMiherrortBPKcw")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan.id
|
||||
);
|
||||
}
|
||||
92
src/scan_manager/utils.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::event_handlers::TermInputHandler;
|
||||
use crate::{
|
||||
config::Configuration, event_handlers::Handles, parser::TIMESPEC_REGEX, scanner::RESPONSES,
|
||||
};
|
||||
|
||||
use std::{fs::File, io::BufReader, sync::Arc};
|
||||
use tokio::time;
|
||||
|
||||
/// Given a string representing some number of seconds, minutes, hours, or days, convert
|
||||
/// that representation to seconds and then wait for those seconds to elapse. Once that period
|
||||
/// of time has elapsed, kill all currently running scans and dump a state file to disk that can
|
||||
/// be used to resume any unfinished scan.
|
||||
pub async fn start_max_time_thread(handles: Arc<Handles>) {
|
||||
log::trace!("enter: start_max_time_thread({:?})", handles);
|
||||
|
||||
// as this function has already made it through the parser, which calls is_match on
|
||||
// the value passed to --time-limit using TIMESPEC_REGEX; we can safely assume that
|
||||
// the capture groups are populated; can expect something like 10m, 30s, 1h, etc...
|
||||
let captures = TIMESPEC_REGEX.captures(&handles.config.time_limit).unwrap();
|
||||
let length_match = captures.get(1).unwrap();
|
||||
let measurement_match = captures.get(2).unwrap();
|
||||
|
||||
if let Ok(length) = length_match.as_str().parse::<u64>() {
|
||||
let length_in_secs = match measurement_match.as_str().to_ascii_lowercase().as_str() {
|
||||
"s" => length,
|
||||
"m" => length * 60, // minutes
|
||||
"h" => length * 60 * 60, // hours
|
||||
"d" => length * 60 * 60 * 24, // days
|
||||
_ => length,
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"max time limit as string: {} and as seconds: {}",
|
||||
handles.config.time_limit,
|
||||
length_in_secs
|
||||
);
|
||||
|
||||
time::sleep(time::Duration::new(length_in_secs, 0)).await;
|
||||
|
||||
log::trace!("exit: start_max_time_thread");
|
||||
|
||||
#[cfg(test)]
|
||||
panic!("{:?}", handles);
|
||||
#[cfg(not(test))]
|
||||
let _ = TermInputHandler::sigint_handler(handles.clone());
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Could not parse the value provided ({}), can't enforce time limit",
|
||||
handles.config.time_limit
|
||||
);
|
||||
}
|
||||
|
||||
/// Primary logic used to load a Configuration from disk and populate the appropriate data
|
||||
/// structures
|
||||
pub fn resume_scan(filename: &str) -> Configuration {
|
||||
log::trace!("enter: resume_scan({})", filename);
|
||||
|
||||
let file = File::open(filename).unwrap_or_else(|e| {
|
||||
log::error!("{}", e);
|
||||
log::error!("Could not open state file, exiting");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
let state: serde_json::Value = serde_json::from_reader(reader).unwrap();
|
||||
|
||||
let conf = state.get("config").unwrap_or_else(|| {
|
||||
log::error!("Could not load configuration from state file, exiting");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let config = serde_json::from_value(conf.clone()).unwrap_or_else(|e| {
|
||||
log::error!("{}", e);
|
||||
log::error!("Could not deserialize configuration found in state file, exiting");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
if let Some(responses) = state.get("responses") {
|
||||
if let Some(arr_responses) = responses.as_array() {
|
||||
for response in arr_responses {
|
||||
if let Ok(deser_resp) = serde_json::from_value(response.clone()) {
|
||||
RESPONSES.insert(deser_resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: resume_scan -> {:?}", config);
|
||||
config
|
||||
}
|
||||
879
src/scanner.rs
@@ -1,879 +0,0 @@
|
||||
use crate::{
|
||||
config::CONFIGURATION,
|
||||
extractor::get_links,
|
||||
filters::{FeroxFilter, StatusCodeFilter, WildcardFilter},
|
||||
heuristics, progress,
|
||||
utils::{format_url, get_current_depth, make_request},
|
||||
FeroxChannel, FeroxResponse, SLEEP_DURATION,
|
||||
};
|
||||
use console::style;
|
||||
use futures::{
|
||||
future::{BoxFuture, FutureExt},
|
||||
stream, StreamExt,
|
||||
};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Url;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
convert::TryInto,
|
||||
io::{stderr, Write},
|
||||
ops::Deref,
|
||||
sync::atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use tokio::{
|
||||
sync::{
|
||||
mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
Semaphore,
|
||||
},
|
||||
task::JoinHandle,
|
||||
time,
|
||||
};
|
||||
|
||||
/// Single atomic number that gets incremented once, used to track first scan vs. all others
|
||||
static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Atomic boolean flag, used to determine whether or not a scan should pause or resume
|
||||
pub static PAUSE_SCAN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
lazy_static! {
|
||||
/// Set of urls that have been sent to [scan_url](fn.scan_url.html), used for deduplication
|
||||
static ref SCANNED_URLS: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
|
||||
|
||||
/// A clock spinner protected with a RwLock to allow for a single thread to use at a time
|
||||
static ref SINGLE_SPINNER: RwLock<ProgressBar> = RwLock::new(get_single_spinner());
|
||||
|
||||
/// Vector of implementors of the FeroxFilter trait
|
||||
static ref FILTERS: Arc<RwLock<Vec<Box<dyn FeroxFilter>>>> = Arc::new(RwLock::new(Vec::<Box<dyn FeroxFilter>>::new()));
|
||||
|
||||
/// Bounded semaphore used as a barrier to limit concurrent scans
|
||||
static ref SCAN_LIMITER: Semaphore = Semaphore::new(CONFIGURATION.scan_limit);
|
||||
}
|
||||
|
||||
/// Return a clock spinner, used when scans are paused
|
||||
fn get_single_spinner() -> ProgressBar {
|
||||
log::trace!("enter: get_single_spinner");
|
||||
|
||||
let spinner = ProgressBar::new_spinner().with_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.tick_strings(&[
|
||||
"🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚",
|
||||
])
|
||||
.template(&format!(
|
||||
"\t-= All Scans {{spinner}} {} =-",
|
||||
style("Paused").red()
|
||||
)),
|
||||
);
|
||||
|
||||
log::trace!("exit: get_single_spinner -> {:?}", spinner);
|
||||
spinner
|
||||
}
|
||||
|
||||
/// Forced the calling thread into a busy loop
|
||||
///
|
||||
/// Every `SLEEP_DURATION` milliseconds, the function examines the result stored in `PAUSE_SCAN`
|
||||
///
|
||||
/// When the value stored in `PAUSE_SCAN` becomes `false`, the function returns, exiting the busy
|
||||
/// loop
|
||||
async fn pause_scan() {
|
||||
log::trace!("enter: pause_scan");
|
||||
// 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));
|
||||
|
||||
// ignore any error returned
|
||||
let _ = stderr().flush();
|
||||
|
||||
if SINGLE_SPINNER.read().unwrap().is_finished() {
|
||||
// in order to not leave draw artifacts laying around in the terminal, we call
|
||||
// finish_and_clear on the progress bar when resuming scans. For this reason, we need to
|
||||
// check if the spinner is finished, and repopulate the RwLock with a new spinner if
|
||||
// necessary
|
||||
if let Ok(mut guard) = SINGLE_SPINNER.write() {
|
||||
*guard = get_single_spinner();
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(spinner) = SINGLE_SPINNER.write() {
|
||||
spinner.enable_steady_tick(120);
|
||||
}
|
||||
|
||||
loop {
|
||||
// first tick happens immediately, all others wait the specified duration
|
||||
interval.tick().await;
|
||||
|
||||
if !PAUSE_SCAN.load(Ordering::Acquire) {
|
||||
// PAUSE_SCAN is false, so we can exit the busy loop
|
||||
if let Ok(spinner) = SINGLE_SPINNER.write() {
|
||||
spinner.finish_and_clear();
|
||||
}
|
||||
let _ = stderr().flush();
|
||||
log::trace!("exit: pause_scan");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the given url to `SCANNED_URLS`
|
||||
///
|
||||
/// If `SCANNED_URLS` did not already contain the url, return true; otherwise return false
|
||||
fn add_url_to_list_of_scanned_urls(resp: &str, scanned_urls: &RwLock<HashSet<String>>) -> bool {
|
||||
log::trace!(
|
||||
"enter: add_url_to_list_of_scanned_urls({}, {:?})",
|
||||
resp,
|
||||
scanned_urls
|
||||
);
|
||||
|
||||
match scanned_urls.write() {
|
||||
// check new url against what's already been scanned
|
||||
Ok(mut urls) => {
|
||||
// If the set did not contain resp, true is returned.
|
||||
// If the set did contain resp, false is returned.
|
||||
let response = urls.insert(resp.to_string());
|
||||
|
||||
log::trace!("exit: add_url_to_list_of_scanned_urls -> {}", response);
|
||||
response
|
||||
}
|
||||
Err(e) => {
|
||||
// poisoned lock
|
||||
log::error!("Set of scanned urls poisoned: {}", e);
|
||||
log::trace!("exit: add_url_to_list_of_scanned_urls -> false");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the given FeroxFilter to the given list of FeroxFilter implementors
|
||||
///
|
||||
/// If the given list did not already contain the filter, return true; otherwise return false
|
||||
fn add_filter_to_list_of_ferox_filters(
|
||||
filter: Box<dyn FeroxFilter>,
|
||||
ferox_filters: Arc<RwLock<Vec<Box<dyn FeroxFilter>>>>,
|
||||
) -> bool {
|
||||
log::trace!(
|
||||
"enter: add_filter_to_list_of_ferox_filters({:?}, {:?})",
|
||||
filter,
|
||||
ferox_filters
|
||||
);
|
||||
|
||||
match ferox_filters.write() {
|
||||
Ok(mut filters) => {
|
||||
// If the set did not contain the assigned filter, true is returned.
|
||||
// If the set did contain the assigned filter, false is returned.
|
||||
if filters.contains(&filter) {
|
||||
log::trace!("exit: add_filter_to_list_of_ferox_filters -> false");
|
||||
return false;
|
||||
}
|
||||
|
||||
filters.push(filter);
|
||||
|
||||
log::trace!("exit: add_filter_to_list_of_ferox_filters -> true");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// poisoned lock
|
||||
log::error!("Set of wildcard filters poisoned: {}", e);
|
||||
log::trace!("exit: add_filter_to_list_of_ferox_filters -> false");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives Urls and scans them
|
||||
fn spawn_recursion_handler(
|
||||
mut recursion_channel: UnboundedReceiver<String>,
|
||||
wordlist: Arc<HashSet<String>>,
|
||||
base_depth: usize,
|
||||
tx_term: UnboundedSender<FeroxResponse>,
|
||||
tx_file: UnboundedSender<String>,
|
||||
) -> BoxFuture<'static, Vec<JoinHandle<()>>> {
|
||||
log::trace!(
|
||||
"enter: spawn_recursion_handler({:?}, wordlist[{} words...], {}, {:?}, {:?})",
|
||||
recursion_channel,
|
||||
wordlist.len(),
|
||||
base_depth,
|
||||
tx_term,
|
||||
tx_file
|
||||
);
|
||||
|
||||
let boxed_future = async move {
|
||||
let mut scans = vec![];
|
||||
|
||||
while let Some(resp) = recursion_channel.recv().await {
|
||||
let unknown = add_url_to_list_of_scanned_urls(&resp, &SCANNED_URLS);
|
||||
|
||||
if !unknown {
|
||||
// not unknown, i.e. we've seen the url before and don't need to scan again
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("received {} on recursion channel", resp);
|
||||
|
||||
let term_clone = tx_term.clone();
|
||||
let file_clone = tx_file.clone();
|
||||
let resp_clone = resp.clone();
|
||||
let list_clone = wordlist.clone();
|
||||
|
||||
scans.push(tokio::spawn(async move {
|
||||
scan_url(
|
||||
resp_clone.to_owned().as_str(),
|
||||
list_clone,
|
||||
base_depth,
|
||||
term_clone,
|
||||
file_clone,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
}
|
||||
scans
|
||||
}
|
||||
.boxed();
|
||||
|
||||
log::trace!("exit: spawn_recursion_handler -> BoxFuture<'static, Vec<JoinHandle<()>>>");
|
||||
boxed_future
|
||||
}
|
||||
|
||||
/// Creates a vector of formatted Urls
|
||||
///
|
||||
/// At least one value will be returned (base_url + word)
|
||||
///
|
||||
/// If any extensions were passed to the program, each extension will add a
|
||||
/// (base_url + word + ext) Url to the vector
|
||||
fn create_urls(target_url: &str, word: &str, extensions: &[String]) -> Vec<Url> {
|
||||
log::trace!(
|
||||
"enter: create_urls({}, {}, {:?})",
|
||||
target_url,
|
||||
word,
|
||||
extensions
|
||||
);
|
||||
|
||||
let mut urls = vec![];
|
||||
|
||||
if let Ok(url) = format_url(
|
||||
&target_url,
|
||||
&word,
|
||||
CONFIGURATION.add_slash,
|
||||
&CONFIGURATION.queries,
|
||||
None,
|
||||
) {
|
||||
urls.push(url); // default request, i.e. no extension
|
||||
}
|
||||
|
||||
for ext in extensions.iter() {
|
||||
if let Ok(url) = format_url(
|
||||
&target_url,
|
||||
&word,
|
||||
CONFIGURATION.add_slash,
|
||||
&CONFIGURATION.queries,
|
||||
Some(ext),
|
||||
) {
|
||||
urls.push(url); // any extensions passed in
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: create_urls -> {:?}", urls);
|
||||
urls
|
||||
}
|
||||
|
||||
/// Helper function to determine suitability for recursion
|
||||
///
|
||||
/// handles 2xx and 3xx responses by either checking if the url ends with a / (2xx)
|
||||
/// or if the Location header is present and matches the base url + / (3xx)
|
||||
fn response_is_directory(response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: is_directory({})", response);
|
||||
|
||||
if response.status().is_redirection() {
|
||||
// status code is 3xx
|
||||
match response.headers().get("Location") {
|
||||
// and has a Location header
|
||||
Some(loc) => {
|
||||
// get absolute redirect Url based on the already known base url
|
||||
log::debug!("Location header: {:?}", loc);
|
||||
|
||||
if let Ok(loc_str) = loc.to_str() {
|
||||
if let Ok(abs_url) = response.url().join(loc_str) {
|
||||
if format!("{}/", response.url()) == abs_url.as_str() {
|
||||
// if current response's Url + / == the absolute redirection
|
||||
// location, we've found a directory suitable for recursion
|
||||
log::debug!(
|
||||
"found directory suitable for recursion: {}",
|
||||
response.url()
|
||||
);
|
||||
log::trace!("exit: is_directory -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::debug!("expected Location header, but none was found: {}", response);
|
||||
log::trace!("exit: is_directory -> false");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if response.status().is_success() {
|
||||
// status code is 2xx, need to check if it ends in /
|
||||
|
||||
if response.url().as_str().ends_with('/') {
|
||||
log::debug!("{} is directory suitable for recursion", response.url());
|
||||
log::trace!("exit: is_directory -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: is_directory -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Helper function that determines if the configured maximum recursion depth has been reached
|
||||
///
|
||||
/// Essentially looks at the Url path and determines how many directories are present in the
|
||||
/// given Url
|
||||
fn reached_max_depth(url: &Url, base_depth: usize, max_depth: usize) -> bool {
|
||||
log::trace!(
|
||||
"enter: reached_max_depth({}, {}, {})",
|
||||
url,
|
||||
base_depth,
|
||||
max_depth
|
||||
);
|
||||
|
||||
if max_depth == 0 {
|
||||
// early return, as 0 means recurse forever; no additional processing needed
|
||||
log::trace!("exit: reached_max_depth -> false");
|
||||
return false;
|
||||
}
|
||||
|
||||
let depth = get_current_depth(url.as_str());
|
||||
|
||||
if depth - base_depth >= max_depth {
|
||||
return true;
|
||||
}
|
||||
|
||||
log::trace!("exit: reached_max_depth -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Helper function that wraps logic to check for recursion opportunities
|
||||
///
|
||||
/// When a recursion opportunity is found, the new url is sent across the recursion channel
|
||||
async fn try_recursion(
|
||||
response: &FeroxResponse,
|
||||
base_depth: usize,
|
||||
transmitter: UnboundedSender<String>,
|
||||
) {
|
||||
log::trace!(
|
||||
"enter: try_recursion({}, {}, {:?})",
|
||||
response,
|
||||
base_depth,
|
||||
transmitter
|
||||
);
|
||||
|
||||
if !reached_max_depth(response.url(), base_depth, CONFIGURATION.depth)
|
||||
&& response_is_directory(&response)
|
||||
{
|
||||
if CONFIGURATION.redirects {
|
||||
// response is 2xx can simply send it because we're following redirects
|
||||
log::info!("Added new directory to recursive scan: {}", response.url());
|
||||
|
||||
match transmitter.send(String::from(response.url().as_str())) {
|
||||
Ok(_) => {
|
||||
log::debug!("sent {} across channel to begin a new scan", response.url());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Could not send {} to recursion handler: {}",
|
||||
response.url(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let new_url = String::from(response.url().as_str());
|
||||
|
||||
log::info!("Added new directory to recursive scan: {}", new_url);
|
||||
|
||||
match transmitter.send(new_url) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Could not send {}/ to recursion handler: {}",
|
||||
response.url(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("exit: try_recursion");
|
||||
}
|
||||
|
||||
/// Simple helper to stay DRY; determines whether or not a given `FeroxResponse` should be reported
|
||||
/// to the user or not.
|
||||
pub fn should_filter_response(response: &FeroxResponse) -> bool {
|
||||
if CONFIGURATION
|
||||
.filter_size
|
||||
.contains(&response.content_length())
|
||||
|| CONFIGURATION
|
||||
.filter_line_count
|
||||
.contains(&response.line_count())
|
||||
|| CONFIGURATION
|
||||
.filter_word_count
|
||||
.contains(&response.word_count())
|
||||
{
|
||||
// filtered value from --filter-size, size filters and wildcards are two separate filters
|
||||
// and are applied independently
|
||||
log::debug!("size filter: filtered out {}", response.url());
|
||||
return true;
|
||||
}
|
||||
|
||||
match FILTERS.read() {
|
||||
Ok(filters) => {
|
||||
for filter in filters.iter() {
|
||||
// wildcard.should_filter goes here
|
||||
if filter.should_filter_response(&response) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Wrapper for [make_request](fn.make_request.html)
|
||||
///
|
||||
/// Handles making multiple requests based on the presence of extensions
|
||||
///
|
||||
/// Attempts recursion when appropriate and sends Responses to the report handler for processing
|
||||
async fn make_requests(
|
||||
target_url: &str,
|
||||
word: &str,
|
||||
base_depth: usize,
|
||||
dir_chan: UnboundedSender<String>,
|
||||
report_chan: UnboundedSender<FeroxResponse>,
|
||||
) {
|
||||
log::trace!(
|
||||
"enter: make_requests({}, {}, {}, {:?}, {:?})",
|
||||
target_url,
|
||||
word,
|
||||
base_depth,
|
||||
dir_chan,
|
||||
report_chan
|
||||
);
|
||||
|
||||
let urls = create_urls(&target_url, &word, &CONFIGURATION.extensions);
|
||||
|
||||
for url in urls {
|
||||
if let Ok(response) = make_request(&CONFIGURATION.client, &url).await {
|
||||
// response came back without error, convert it to FeroxResponse
|
||||
let ferox_response = FeroxResponse::from(response, true).await;
|
||||
|
||||
// do recursion if appropriate
|
||||
if !CONFIGURATION.no_recursion {
|
||||
try_recursion(&ferox_response, base_depth, dir_chan.clone()).await;
|
||||
}
|
||||
|
||||
// purposefully doing recursion before filtering. the thought process is that
|
||||
// even though this particular url is filtered, subsequent urls may not
|
||||
|
||||
if should_filter_response(&ferox_response) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if CONFIGURATION.extract_links && !ferox_response.status().is_redirection() {
|
||||
let new_links = get_links(&ferox_response).await;
|
||||
|
||||
for new_link in new_links {
|
||||
let unknown = add_url_to_list_of_scanned_urls(&new_link, &SCANNED_URLS);
|
||||
|
||||
if !unknown {
|
||||
// not unknown, i.e. we've seen the url before and don't need to scan again
|
||||
continue;
|
||||
}
|
||||
|
||||
// create a url based on the given command line options, continue on error
|
||||
let new_url = match format_url(
|
||||
&new_link,
|
||||
&"",
|
||||
CONFIGURATION.add_slash,
|
||||
&CONFIGURATION.queries,
|
||||
None,
|
||||
) {
|
||||
Ok(url) => url,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// make the request and store the response
|
||||
let new_response = match make_request(&CONFIGURATION.client, &new_url).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut new_ferox_response = FeroxResponse::from(new_response, true).await;
|
||||
|
||||
// filter if necessary
|
||||
if should_filter_response(&new_ferox_response) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if new_ferox_response.is_file() {
|
||||
// very likely a file, simply request and report
|
||||
log::debug!("Singular extraction: {}", new_ferox_response);
|
||||
|
||||
send_report(report_chan.clone(), new_ferox_response);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if !CONFIGURATION.no_recursion {
|
||||
log::debug!("Recursive extraction: {}", new_ferox_response);
|
||||
|
||||
if new_ferox_response.status().is_success()
|
||||
&& !new_ferox_response.url().as_str().ends_with('/')
|
||||
{
|
||||
// since all of these are 2xx, recursion is only attempted if the
|
||||
// url ends in a /. I am actually ok with adding the slash and not
|
||||
// adding it, as both have merit. Leaving it in for now to see how
|
||||
// things turn out (current as of: v1.1.0)
|
||||
new_ferox_response.set_url(&format!("{}/", new_ferox_response.url()));
|
||||
}
|
||||
|
||||
try_recursion(&new_ferox_response, base_depth, dir_chan.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// everything else should be reported
|
||||
send_report(report_chan.clone(), ferox_response);
|
||||
}
|
||||
}
|
||||
log::trace!("exit: make_requests");
|
||||
}
|
||||
|
||||
/// Simple helper to send a `FeroxResponse` over the tx side of an `mpsc::unbounded_channel`
|
||||
fn send_report(report_sender: UnboundedSender<FeroxResponse>, response: FeroxResponse) {
|
||||
log::trace!("enter: send_report({:?}, {}", report_sender, response);
|
||||
|
||||
match report_sender.send(response) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: send_report");
|
||||
}
|
||||
|
||||
/// Scan a given url using a given wordlist
|
||||
///
|
||||
/// This is the primary entrypoint for the scanner
|
||||
pub async fn scan_url(
|
||||
target_url: &str,
|
||||
wordlist: Arc<HashSet<String>>,
|
||||
base_depth: usize,
|
||||
tx_term: UnboundedSender<FeroxResponse>,
|
||||
tx_file: UnboundedSender<String>,
|
||||
) {
|
||||
log::trace!(
|
||||
"enter: scan_url({:?}, wordlist[{} words...], {}, {:?}, {:?})",
|
||||
target_url,
|
||||
wordlist.len(),
|
||||
base_depth,
|
||||
tx_term,
|
||||
tx_file
|
||||
);
|
||||
|
||||
log::info!("Starting scan against: {}", target_url);
|
||||
|
||||
let (tx_dir, rx_dir): FeroxChannel<String> = mpsc::unbounded_channel();
|
||||
|
||||
let num_reqs_expected: u64 = if CONFIGURATION.extensions.is_empty() {
|
||||
wordlist.len().try_into().unwrap()
|
||||
} else {
|
||||
let total = wordlist.len() * (CONFIGURATION.extensions.len() + 1);
|
||||
total.try_into().unwrap()
|
||||
};
|
||||
|
||||
let progress_bar = progress::add_bar(&target_url, num_reqs_expected, false);
|
||||
progress_bar.reset_elapsed();
|
||||
|
||||
if CALL_COUNT.load(Ordering::Relaxed) == 0 {
|
||||
CALL_COUNT.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// this protection allows us to add the first scanned url to SCANNED_URLS
|
||||
// from within the scan_url function instead of the recursion handler
|
||||
add_url_to_list_of_scanned_urls(&target_url, &SCANNED_URLS);
|
||||
|
||||
if CONFIGURATION.scan_limit == 0 {
|
||||
// scan_limit == 0 means no limit should be imposed... however, scoping the Semaphore
|
||||
// permit is tricky, so as a workaround, we'll add a ridiculous number of permits to
|
||||
// the semaphore (1,152,921,504,606,846,975 to be exact) and call that 'unlimited'
|
||||
SCAN_LIMITER.add_permits(usize::MAX >> 4);
|
||||
}
|
||||
}
|
||||
|
||||
// When acquire is called and the semaphore has remaining permits, the function immediately
|
||||
// returns a permit. However, if no remaining permits are available, acquire (asynchronously)
|
||||
// waits until an outstanding permit is dropped. At this point, the freed permit is assigned
|
||||
// to the caller.
|
||||
let permit = SCAN_LIMITER.acquire().await;
|
||||
|
||||
// Arc clones to be passed around to the various scans
|
||||
let wildcard_bar = progress_bar.clone();
|
||||
let heuristics_file_clone = tx_file.clone();
|
||||
let recurser_term_clone = tx_term.clone();
|
||||
let recurser_file_clone = tx_file.clone();
|
||||
let recurser_words = wordlist.clone();
|
||||
let looping_words = wordlist.clone();
|
||||
|
||||
let recurser = tokio::spawn(async move {
|
||||
spawn_recursion_handler(
|
||||
rx_dir,
|
||||
recurser_words,
|
||||
base_depth,
|
||||
recurser_term_clone,
|
||||
recurser_file_clone,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// add any wildcard filters to `FILTERS`
|
||||
let filter =
|
||||
match heuristics::wildcard_test(&target_url, wildcard_bar, heuristics_file_clone).await {
|
||||
Some(f) => Box::new(f),
|
||||
None => Box::new(WildcardFilter::default()),
|
||||
};
|
||||
|
||||
add_filter_to_list_of_ferox_filters(filter, FILTERS.clone());
|
||||
|
||||
// add any status code filters to `FILTERS`
|
||||
for code_filter in &CONFIGURATION.filter_status {
|
||||
let filter = StatusCodeFilter {
|
||||
filter_code: *code_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
|
||||
}
|
||||
|
||||
// producer tasks (mp of mpsc); responsible for making requests
|
||||
let producers = stream::iter(looping_words.deref().to_owned())
|
||||
.map(|word| {
|
||||
let txd = tx_dir.clone();
|
||||
let txr = tx_term.clone();
|
||||
let pb = progress_bar.clone(); // progress bar is an Arc around internal state
|
||||
let tgt = target_url.to_string(); // done to satisfy 'static lifetime below
|
||||
(
|
||||
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
|
||||
pause_scan().await;
|
||||
}
|
||||
make_requests(&tgt, &word, base_depth, txd, txr).await
|
||||
}),
|
||||
pb,
|
||||
)
|
||||
})
|
||||
.for_each_concurrent(CONFIGURATION.threads, |(resp, bar)| async move {
|
||||
match resp.await {
|
||||
Ok(_) => {
|
||||
bar.inc((CONFIGURATION.extensions.len() + 1) as u64);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("error awaiting a response: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// await tx tasks
|
||||
log::trace!("awaiting scan producers");
|
||||
producers.await;
|
||||
log::trace!("done awaiting scan producers");
|
||||
|
||||
// drop the current permit so the semaphore will allow another scan to proceed
|
||||
drop(permit);
|
||||
|
||||
progress_bar.finish();
|
||||
|
||||
// manually drop tx in order for the rx task's while loops to eval to false
|
||||
log::trace!("dropped recursion handler's transmitter");
|
||||
drop(tx_dir);
|
||||
|
||||
// await rx tasks
|
||||
log::trace!("awaiting recursive scan receiver/scans");
|
||||
futures::future::join_all(recurser.await.unwrap()).await;
|
||||
log::trace!("done awaiting recursive scan receiver/scans");
|
||||
|
||||
log::trace!("exit: scan_url");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// sending url + word without any extensions should get back one url with the joined word
|
||||
fn create_urls_no_extension_returns_base_url_with_word() {
|
||||
let urls = create_urls("http://localhost", "turbo", &[]);
|
||||
assert_eq!(urls, [Url::parse("http://localhost/turbo").unwrap()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// sending url + word + 1 extension should get back two urls, one base and one with extension
|
||||
fn create_urls_one_extension_returns_two_urls() {
|
||||
let urls = create_urls("http://localhost", "turbo", &[String::from("js")]);
|
||||
assert_eq!(
|
||||
urls,
|
||||
[
|
||||
Url::parse("http://localhost/turbo").unwrap(),
|
||||
Url::parse("http://localhost/turbo.js").unwrap()
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// sending url + word + multiple extensions should get back n+1 urls
|
||||
fn create_urls_multiple_extensions_returns_n_plus_one_urls() {
|
||||
let ext_vec = vec![
|
||||
vec![String::from("js")],
|
||||
vec![String::from("js"), String::from("php")],
|
||||
vec![String::from("js"), String::from("php"), String::from("pdf")],
|
||||
vec![
|
||||
String::from("js"),
|
||||
String::from("php"),
|
||||
String::from("pdf"),
|
||||
String::from("tar.gz"),
|
||||
],
|
||||
];
|
||||
|
||||
let base = Url::parse("http://localhost/turbo").unwrap();
|
||||
let js = Url::parse("http://localhost/turbo.js").unwrap();
|
||||
let php = Url::parse("http://localhost/turbo.php").unwrap();
|
||||
let pdf = Url::parse("http://localhost/turbo.pdf").unwrap();
|
||||
let tar = Url::parse("http://localhost/turbo.tar.gz").unwrap();
|
||||
|
||||
let expected = vec![
|
||||
vec![base.clone(), js.clone()],
|
||||
vec![base.clone(), js.clone(), php.clone()],
|
||||
vec![base.clone(), js.clone(), php.clone(), pdf.clone()],
|
||||
vec![base, js, php, pdf, tar],
|
||||
];
|
||||
|
||||
for (i, ext_set) in ext_vec.into_iter().enumerate() {
|
||||
let urls = create_urls("http://localhost", "turbo", &ext_set);
|
||||
assert_eq!(urls, expected[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
|
||||
fn reached_max_depth_returns_early_on_zero() {
|
||||
let url = Url::parse("http://localhost").unwrap();
|
||||
let result = reached_max_depth(&url, 0, 0);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url depth equal to max depth, expect true
|
||||
fn reached_max_depth_current_depth_equals_max() {
|
||||
let url = Url::parse("http://localhost/one/two").unwrap();
|
||||
let result = reached_max_depth(&url, 0, 2);
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url dpeth less than max depth, expect false
|
||||
fn reached_max_depth_current_depth_less_than_max() {
|
||||
let url = Url::parse("http://localhost").unwrap();
|
||||
let result = reached_max_depth(&url, 0, 2);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url of 2, base depth of 2, and max depth of 2, expect false
|
||||
fn reached_max_depth_base_depth_equals_max_depth() {
|
||||
let url = Url::parse("http://localhost/one/two").unwrap();
|
||||
let result = reached_max_depth(&url, 2, 2);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url depth greater than max depth, expect true
|
||||
fn reached_max_depth_current_greater_than_max() {
|
||||
let url = Url::parse("http://localhost/one/two/three").unwrap();
|
||||
let result = reached_max_depth(&url, 0, 2);
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add an unknown url to the hashset, expect true
|
||||
fn add_url_to_list_of_scanned_urls_with_unknown_url() {
|
||||
let urls = RwLock::new(HashSet::<String>::new());
|
||||
let url = "http://unknown_url";
|
||||
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add a known url to the hashset, with a trailing slash, expect false
|
||||
fn add_url_to_list_of_scanned_urls_with_known_url() {
|
||||
let urls = RwLock::new(HashSet::<String>::new());
|
||||
let url = "http://unknown_url/";
|
||||
|
||||
assert_eq!(urls.write().unwrap().insert(url.to_string()), true);
|
||||
|
||||
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add a known url to the hashset, without a trailing slash, expect false
|
||||
fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
|
||||
let urls = RwLock::new(HashSet::<String>::new());
|
||||
let url = "http://unknown_url";
|
||||
|
||||
assert_eq!(
|
||||
urls.write()
|
||||
.unwrap()
|
||||
.insert("http://unknown_url".to_string()),
|
||||
true
|
||||
);
|
||||
|
||||
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that get_single_spinner returns the correct spinner
|
||||
fn scanner_get_single_spinner_returns_spinner() {
|
||||
let spinner = get_single_spinner();
|
||||
assert!(!spinner.is_finished());
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// tests that pause_scan pauses execution and releases execution when PAUSE_SCAN is toggled
|
||||
/// the spinner used during the test has had .finish_and_clear called on it, meaning that
|
||||
/// a new one will be created, taking the if branch within the function
|
||||
async fn scanner_pause_scan_with_finished_spinner() {
|
||||
let now = time::Instant::now();
|
||||
|
||||
PAUSE_SCAN.store(true, Ordering::Relaxed);
|
||||
SINGLE_SPINNER.write().unwrap().finish_and_clear();
|
||||
|
||||
let expected = time::Duration::from_secs(2);
|
||||
|
||||
tokio::spawn(async move {
|
||||
time::delay_for(expected).await;
|
||||
PAUSE_SCAN.store(false, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
pause_scan().await;
|
||||
|
||||
assert!(now.elapsed() > expected);
|
||||
}
|
||||
}
|
||||
245
src/scanner/ferox_scanner.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use std::{ops::Deref, sync::atomic::Ordering, sync::Arc, time::Instant};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use console::style;
|
||||
use futures::{stream, StreamExt};
|
||||
use lazy_static::lazy_static;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::{
|
||||
event_handlers::{
|
||||
Command::{AddError, AddToF64Field, SubtractFromUsizeField},
|
||||
Handles,
|
||||
},
|
||||
extractor::{ExtractionTarget, ExtractorBuilder},
|
||||
heuristics,
|
||||
scan_manager::{FeroxResponses, MenuCmdResult, ScanOrder, ScanStatus, PAUSE_SCAN},
|
||||
statistics::{
|
||||
StatError::Other,
|
||||
StatField::{DirScanTimes, TotalExpected},
|
||||
},
|
||||
utils::fmt_err,
|
||||
Command,
|
||||
};
|
||||
|
||||
use super::requester::Requester;
|
||||
|
||||
lazy_static! {
|
||||
/// Vector of FeroxResponse objects
|
||||
pub static ref RESPONSES: FeroxResponses = FeroxResponses::default();
|
||||
// todo consider removing this
|
||||
}
|
||||
/// handles the main muscle movement of scanning a url
|
||||
pub struct FeroxScanner {
|
||||
/// handles to handlers and config
|
||||
pub(super) handles: Arc<Handles>,
|
||||
|
||||
/// url that will be scanned
|
||||
pub(super) target_url: String,
|
||||
|
||||
/// whether or not this scanner is targeting an initial target specified by the user or one
|
||||
/// found via recursion
|
||||
order: ScanOrder,
|
||||
|
||||
/// wordlist that's already been read from disk
|
||||
wordlist: Arc<Vec<String>>,
|
||||
|
||||
/// limiter that restricts the number of active FeroxScanners
|
||||
scan_limiter: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
/// FeroxScanner implementation
|
||||
impl FeroxScanner {
|
||||
/// create a new FeroxScanner
|
||||
pub fn new(
|
||||
target_url: &str,
|
||||
order: ScanOrder,
|
||||
wordlist: Arc<Vec<String>>,
|
||||
scan_limiter: Arc<Semaphore>,
|
||||
handles: Arc<Handles>,
|
||||
) -> Self {
|
||||
Self {
|
||||
order,
|
||||
handles,
|
||||
wordlist,
|
||||
scan_limiter,
|
||||
target_url: target_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a given url using a given wordlist
|
||||
///
|
||||
/// This is the primary entrypoint for the scanner
|
||||
pub async fn scan_url(&self) -> Result<()> {
|
||||
log::trace!("enter: scan_url");
|
||||
log::info!("Starting scan against: {}", self.target_url);
|
||||
|
||||
let mut scan_timer = Instant::now();
|
||||
let mut dirlist_flag = false;
|
||||
|
||||
if self.handles.config.extract_links {
|
||||
// parse html for links (i.e. web scraping)
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.target(ExtractionTarget::ParseHtml)
|
||||
.url(&self.target_url)
|
||||
.handles(self.handles.clone())
|
||||
.build()?;
|
||||
let extract_out = extractor.extract().await?;
|
||||
let links = extract_out.0;
|
||||
dirlist_flag = extract_out.1;
|
||||
extractor.request_links(links).await?;
|
||||
|
||||
if matches!(self.order, ScanOrder::Initial) {
|
||||
// check for robots.txt (cannot be in subdirs)
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.url(&self.target_url)
|
||||
.handles(self.handles.clone())
|
||||
.build()?;
|
||||
let links = (extractor.extract().await?).0;
|
||||
extractor.request_links(links).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let scanned_urls = self.handles.ferox_scans()?;
|
||||
let ferox_scan = match scanned_urls.get_scan_by_url(&self.target_url) {
|
||||
Some(scan) => {
|
||||
scan.set_status(ScanStatus::Running)?;
|
||||
scan
|
||||
}
|
||||
None => {
|
||||
let msg = format!(
|
||||
"Could not find FeroxScan associated with {}; this shouldn't happen... exiting",
|
||||
self.target_url
|
||||
);
|
||||
bail!(fmt_err(&msg))
|
||||
}
|
||||
};
|
||||
|
||||
let progress_bar = ferox_scan.progress_bar();
|
||||
|
||||
// Directory listing heuristic detection to not continue scanning
|
||||
if dirlist_flag {
|
||||
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,
|
||||
))?;
|
||||
|
||||
progress_bar.reset_eta();
|
||||
progress_bar.finish_with_message(&format!(
|
||||
"=> {}",
|
||||
style("Directory listing").blue().bright()
|
||||
));
|
||||
|
||||
ferox_scan.finish()?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// When acquire is called and the semaphore has remaining permits, the function immediately
|
||||
// returns a permit. However, if no remaining permits are available, acquire (asynchronously)
|
||||
// waits until an outstanding permit is dropped, at which point, the freed permit is assigned
|
||||
// to the caller.
|
||||
let _permit = self.scan_limiter.acquire().await;
|
||||
if self.handles.config.scan_limit > 0 {
|
||||
scan_timer = Instant::now();
|
||||
progress_bar.reset();
|
||||
}
|
||||
|
||||
// Arc clones to be passed around to the various scans
|
||||
let looping_words = self.wordlist.clone();
|
||||
|
||||
{
|
||||
let test = heuristics::HeuristicTests::new(self.handles.clone());
|
||||
if let Ok(num_reqs) = test.wildcard(&self.target_url).await {
|
||||
progress_bar.inc(num_reqs);
|
||||
}
|
||||
}
|
||||
|
||||
let requester = Arc::new(Requester::from(self, ferox_scan.clone())?);
|
||||
let increment_len =
|
||||
((self.handles.config.extensions.len() + 1) * self.handles.config.methods.len()) 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
|
||||
match scanned_urls_clone.pause(true).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_clone
|
||||
.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_clone
|
||||
.stats
|
||||
.send(SubtractFromUsizeField(TotalExpected, num_canx))
|
||||
.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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// await tx tasks
|
||||
log::trace!("awaiting scan producers");
|
||||
producers.await;
|
||||
log::trace!("done awaiting scan producers");
|
||||
|
||||
self.handles.stats.send(AddToF64Field(
|
||||
DirScanTimes,
|
||||
scan_timer.elapsed().as_secs_f64(),
|
||||
))?;
|
||||
|
||||
ferox_scan.finish()?;
|
||||
|
||||
log::trace!("exit: scan_url");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
31
src/scanner/init.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::{
|
||||
event_handlers::{Command::AddToUsizeField, Handles},
|
||||
statistics::StatField::ExpectedPerScan,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use std::{convert::TryInto, sync::Arc};
|
||||
|
||||
/// Perform steps necessary to run scans that only need to be performed once (warming up the
|
||||
/// engine, as it were)
|
||||
pub async fn initialize(num_words: usize, handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: initialize({}, {:?})", num_words, handles);
|
||||
|
||||
// number of requests only needs to be calculated once, and then can be reused
|
||||
let num_reqs_expected: u64 =
|
||||
(num_words * (handles.config.extensions.len() + 1) * (handles.config.methods.len()))
|
||||
.try_into()?;
|
||||
|
||||
{
|
||||
// no real reason to keep the arc around beyond this call
|
||||
let scans = handles.ferox_scans()?;
|
||||
scans.set_bar_length(num_reqs_expected);
|
||||
}
|
||||
|
||||
// tell Stats object about the number of expected requests
|
||||
handles
|
||||
.stats
|
||||
.send(AddToUsizeField(ExpectedPerScan, num_reqs_expected as usize))?;
|
||||
|
||||
log::trace!("exit: initialize");
|
||||
Ok(())
|
||||
}
|
||||
171
src/scanner/limit_heap.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use std::fmt::{Debug, Formatter, Result};
|
||||
|
||||
/// bespoke variation on an array-backed max-heap
|
||||
///
|
||||
/// 255 possible values generated from the initial requests/second
|
||||
///
|
||||
/// when no additional errors are encountered, the left child is taken (increasing req/sec)
|
||||
/// if errors have increased since the last interval, the right child is taken (decreasing req/sec)
|
||||
///
|
||||
/// formula for each child:
|
||||
/// - left: (|parent - current|) / 2 + current
|
||||
/// - right: current - ((|parent - current|) / 2)
|
||||
pub(super) struct LimitHeap {
|
||||
/// backing array, 255 nodes == height of 7 ( 2^(h+1) -1 nodes )
|
||||
pub(super) inner: [i32; 255],
|
||||
|
||||
/// original # of requests / second
|
||||
pub(super) original: i32,
|
||||
|
||||
/// current position w/in the backing array
|
||||
pub(super) current: usize,
|
||||
}
|
||||
|
||||
/// default implementation of a LimitHeap
|
||||
impl Default for LimitHeap {
|
||||
/// zero-initialize the backing array
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: [0; 255],
|
||||
original: 0,
|
||||
current: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug implementation of a LimitHeap
|
||||
impl Debug for LimitHeap {
|
||||
/// return debug representation that conforms to <32 elements in array
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
|
||||
let msg = format!(
|
||||
"LimitHeap {{ original: {}, current: {}, inner: [{}...] }}",
|
||||
self.original, self.current, self.inner[0]
|
||||
);
|
||||
write!(f, "{}", msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// implementation of a LimitHeap
|
||||
impl LimitHeap {
|
||||
/// move to right child, return node's index from which the move was requested
|
||||
pub(super) fn move_right(&mut self) -> usize {
|
||||
if self.has_children() {
|
||||
let tmp = self.current;
|
||||
self.current = self.current * 2 + 2;
|
||||
return tmp;
|
||||
}
|
||||
self.current
|
||||
}
|
||||
|
||||
/// move to left child, return node's index from which the move was requested
|
||||
pub(super) fn move_left(&mut self) -> usize {
|
||||
if self.has_children() {
|
||||
let tmp = self.current;
|
||||
self.current = self.current * 2 + 1;
|
||||
return tmp;
|
||||
}
|
||||
self.current
|
||||
}
|
||||
|
||||
/// move to parent, return node's index from which the move was requested
|
||||
pub(super) fn move_up(&mut self) -> usize {
|
||||
if self.has_parent() {
|
||||
let tmp = self.current;
|
||||
self.current = (self.current - 1) / 2;
|
||||
return tmp;
|
||||
}
|
||||
self.current
|
||||
}
|
||||
|
||||
/// move directly to the given index
|
||||
pub(super) fn move_to(&mut self, index: usize) {
|
||||
self.current = index;
|
||||
}
|
||||
|
||||
/// get the current node's value
|
||||
pub(super) fn value(&self) -> i32 {
|
||||
self.inner[self.current]
|
||||
}
|
||||
|
||||
/// set the current node's value
|
||||
pub(super) fn set_value(&mut self, value: i32) {
|
||||
self.inner[self.current] = value;
|
||||
}
|
||||
|
||||
/// check that this node has a parent (true for all except root)
|
||||
pub(super) fn has_parent(&self) -> bool {
|
||||
self.current > 0
|
||||
}
|
||||
|
||||
/// get node's parent's value or self.original if at the root
|
||||
pub(super) fn parent_value(&mut self) -> i32 {
|
||||
if self.has_parent() {
|
||||
let current = self.move_up();
|
||||
let val = self.value();
|
||||
self.move_to(current);
|
||||
return val;
|
||||
}
|
||||
self.original
|
||||
}
|
||||
|
||||
/// check if the current node has children
|
||||
pub(super) fn has_children(&self) -> bool {
|
||||
// inner structure is a complete tree, just check for the right child
|
||||
self.current * 2 + 2 <= self.inner.len()
|
||||
}
|
||||
|
||||
/// get current node's right child's value
|
||||
fn right_child_value(&mut self) -> i32 {
|
||||
let tmp = self.move_right();
|
||||
let val = self.value();
|
||||
self.move_to(tmp);
|
||||
val
|
||||
}
|
||||
|
||||
/// set current node's left child's value
|
||||
fn set_left_child(&mut self) {
|
||||
let parent = self.parent_value();
|
||||
let current = self.value();
|
||||
let value = ((parent - current).abs() / 2) + current;
|
||||
|
||||
self.move_left();
|
||||
self.set_value(value);
|
||||
self.move_up();
|
||||
}
|
||||
|
||||
/// set current node's right child's value
|
||||
fn set_right_child(&mut self) {
|
||||
let parent = self.parent_value();
|
||||
let current = self.value();
|
||||
let value = current - ((parent - current).abs() / 2);
|
||||
|
||||
self.move_right();
|
||||
self.set_value(value);
|
||||
self.move_up();
|
||||
}
|
||||
|
||||
/// iterate over the backing array, filling in each child's value based on the original value
|
||||
pub(super) fn build(&mut self) {
|
||||
// ex: original is 400
|
||||
// arr[0] == 200
|
||||
// arr[1] (left child) == 300
|
||||
// arr[2] (right child) == 100
|
||||
let root = self.original / 2;
|
||||
|
||||
self.inner[0] = root; // set root node to half of the original value
|
||||
self.inner[1] = ((self.original - root).abs() / 2) + root;
|
||||
self.inner[2] = root - ((self.original - root).abs() / 2);
|
||||
|
||||
// start with index 1 and fill in each child below that node
|
||||
for i in 1..self.inner.len() {
|
||||
self.move_to(i);
|
||||
|
||||
if self.has_children() && self.right_child_value() == 0 {
|
||||
// this node has an unset child since the rchild is 0
|
||||
self.set_left_child();
|
||||
self.set_right_child();
|
||||
}
|
||||
}
|
||||
self.move_to(0); // reset current index to the root of the tree
|
||||
}
|
||||
}
|
||||
12
src/scanner/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
mod ferox_scanner;
|
||||
mod utils;
|
||||
mod init;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod limit_heap;
|
||||
mod policy_data;
|
||||
mod requester;
|
||||
|
||||
pub use self::ferox_scanner::{FeroxScanner, RESPONSES};
|
||||
pub use self::init::initialize;
|
||||
pub use self::utils::PolicyTrigger;
|
||||