Compare commits
822 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9515a5da99 | ||
|
|
b417ab41a5 | ||
|
|
b946565c2f | ||
|
|
714b054360 | ||
|
|
30544eaf7d | ||
|
|
99e2d46aa2 | ||
|
|
82a4f16252 | ||
|
|
6e0fe1eced | ||
|
|
1ffc93a337 | ||
|
|
7ab0453eb7 | ||
|
|
50b29a2b74 | ||
|
|
bf78fea926 | ||
|
|
56267726cc | ||
|
|
01844cffd8 | ||
|
|
2abc0b78ee | ||
|
|
fdb8774bfd | ||
|
|
8d3cd2471b | ||
|
|
da22371c87 | ||
|
|
ca494ca801 | ||
|
|
376804aa59 | ||
|
|
56a769c197 | ||
|
|
9922eb5124 | ||
|
|
7a58f8fcf8 | ||
|
|
8d1872fb3f | ||
|
|
e1e59e6e8e | ||
|
|
0c731c3639 | ||
|
|
b456215a00 | ||
|
|
d38c66f290 | ||
|
|
6ba32d926c | ||
|
|
86bcfd8fb1 | ||
|
|
4a101e4ae5 | ||
|
|
1ae1430a11 | ||
|
|
cca3163baf | ||
|
|
c9013edce8 | ||
|
|
7bdb137fd1 | ||
|
|
5f0eaf8885 | ||
|
|
2b7002d9cf | ||
|
|
86b17f226d | ||
|
|
cbbf9be6c9 | ||
|
|
f814c4b223 | ||
|
|
7839118379 | ||
|
|
8214a2a357 | ||
|
|
e06e194f77 | ||
|
|
1628ee86a3 | ||
|
|
53d2076176 | ||
|
|
304750fa3f | ||
|
|
6c5c812784 | ||
|
|
063e7b0420 | ||
|
|
eed59e1da5 | ||
|
|
ca4d8f0c52 | ||
|
|
c1132622cf | ||
|
|
2d5aeb444e | ||
|
|
53238a6e5e | ||
|
|
7b7eeeebfa | ||
|
|
aed0c41d8f | ||
|
|
5edd58a3f4 | ||
|
|
44693a3498 | ||
|
|
02448e9834 | ||
|
|
368035833c | ||
|
|
d13bce2261 | ||
|
|
88d451144c | ||
|
|
8d639a17e4 | ||
|
|
7f0dcb6b46 | ||
|
|
3230f9c276 | ||
|
|
b21ea9ce32 | ||
|
|
801413105d | ||
|
|
3030296d1c | ||
|
|
88a595fd82 | ||
|
|
9a84c5234f | ||
|
|
d0d99ebed6 | ||
|
|
7194326cd1 | ||
|
|
71885e7e56 | ||
|
|
13cfbe152e | ||
|
|
8b9d640090 | ||
|
|
007bc4a50d | ||
|
|
71c5b66eb6 | ||
|
|
1498122973 | ||
|
|
c0b4040743 | ||
|
|
3c474920bb | ||
|
|
079b8b2176 | ||
|
|
4a678ef65b | ||
|
|
e9fb9642a8 | ||
|
|
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 |
404
.all-contributorsrc
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "joohoi",
|
||||
"name": "Joona Hoikkala",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5235109?v=4",
|
||||
"profile": "https://io.fi",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jsav0",
|
||||
"name": "J Savage",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/20546041?v=4",
|
||||
"profile": "https://github.com/jsav0",
|
||||
"contributions": [
|
||||
"infra",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "TGotwig",
|
||||
"name": "Thomas Gotwig",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30773779?v=4",
|
||||
"profile": "http://www.tgotwig.dev",
|
||||
"contributions": [
|
||||
"infra",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "spikecodes",
|
||||
"name": "Spike",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/19519553?v=4",
|
||||
"profile": "https://github.com/spikecodes",
|
||||
"contributions": [
|
||||
"infra",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "evanrichter",
|
||||
"name": "Evan Richter",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/330292?v=4",
|
||||
"profile": "https://github.com/evanrichter",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mzpqnxow",
|
||||
"name": "AG",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8016228?v=4",
|
||||
"profile": "https://github.com/mzpqnxow",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "n-thumann",
|
||||
"name": "Nicolas Thumann",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/46975855?v=4",
|
||||
"profile": "https://n-thumann.de/",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "tomtastic",
|
||||
"name": "Tom Matthews",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/302127?v=4",
|
||||
"profile": "https://github.com/tomtastic",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "bsysop",
|
||||
"name": "bsysop",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/9998303?v=4",
|
||||
"profile": "https://github.com/bsysop",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "bpsizemore",
|
||||
"name": "Brian Sizemore",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/11645898?v=4",
|
||||
"profile": "http://bpsizemore.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "noraj",
|
||||
"name": "Alexandre ZANNI",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/16578570?v=4",
|
||||
"profile": "https://pwn.by/noraj",
|
||||
"contributions": [
|
||||
"infra",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "craig",
|
||||
"name": "Craig",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/99729?v=4",
|
||||
"profile": "https://github.com/craig",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "EONRaider",
|
||||
"name": "EONRaider",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/15611424?v=4",
|
||||
"profile": "https://www.reddit.com/u/EONRaider",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "wtwver",
|
||||
"name": "wtwver",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/53866088?v=4",
|
||||
"profile": "https://github.com/wtwver",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Tib3rius",
|
||||
"name": "Tib3rius",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/48113936?v=4",
|
||||
"profile": "https://tib3rius.com",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "0xdf",
|
||||
"name": "0xdf",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1489045?v=4",
|
||||
"profile": "https://github.com/0xdf",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "secure-77",
|
||||
"name": "secure-77",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/31564517?v=4",
|
||||
"profile": "http://secure77.de",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sbrun",
|
||||
"name": "Sophie Brun",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7712154?v=4",
|
||||
"profile": "https://github.com/sbrun",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "black-A",
|
||||
"name": "black-A",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30686803?v=4",
|
||||
"profile": "https://github.com/black-A",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dinosn",
|
||||
"name": "Nicolas Krassas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3851678?v=4",
|
||||
"profile": "https://github.com/dinosn",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "N0ur5",
|
||||
"name": "N0ur5",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/24260009?v=4",
|
||||
"profile": "https://github.com/N0ur5",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "moscowchill",
|
||||
"name": "mchill",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/72578879?v=4",
|
||||
"profile": "https://github.com/moscowchill",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "BitThr3at",
|
||||
"name": "Naman",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/45028933?v=4",
|
||||
"profile": "http://BitThr3at.github.io",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sicks3c",
|
||||
"name": "Ayoub Elaich",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/32225186?v=4",
|
||||
"profile": "https://github.com/Sicks3c",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "HenryHoggard",
|
||||
"name": "Henry",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1208121?v=4",
|
||||
"profile": "https://github.com/HenryHoggard",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "SleepiPanda",
|
||||
"name": "SleepiPanda",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6428561?v=4",
|
||||
"profile": "https://github.com/SleepiPanda",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "uBadRequest",
|
||||
"name": "Bad Requests",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/47282747?v=4",
|
||||
"profile": "https://github.com/uBadRequest",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dnaka91",
|
||||
"name": "Dominik Nakamura",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36804488?v=4",
|
||||
"profile": "https://home.dnaka91.rocks",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hunter0x8",
|
||||
"name": "Muhammad Ahsan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/46222314?v=4",
|
||||
"profile": "https://github.com/hunter0x8",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "cortantief",
|
||||
"name": "cortantief",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/34527333?v=4",
|
||||
"profile": "https://github.com/cortantief",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dsaxton",
|
||||
"name": "Daniel Saxton",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2658661?v=4",
|
||||
"profile": "https://github.com/dsaxton",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "narkopolo",
|
||||
"name": "narkopolo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/16690056?v=4",
|
||||
"profile": "https://github.com/narkopolo",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "justinsteven",
|
||||
"name": "Justin Steven",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1893909?v=4",
|
||||
"profile": "https://ring0.lol",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "7047payloads",
|
||||
"name": "7047payloads",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/95562424?v=4",
|
||||
"profile": "https://github.com/7047payloads",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "unkn0wnsyst3m",
|
||||
"name": "unkn0wnsyst3m",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/21272239?v=4",
|
||||
"profile": "https://github.com/unkn0wnsyst3m",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "its0x08",
|
||||
"name": "0x08",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/15280042?v=4",
|
||||
"profile": "https://ironwort.me/",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "MD-Levitan",
|
||||
"name": "kusok",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12116508?v=4",
|
||||
"profile": "https://github.com/MD-Levitan",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "godylockz",
|
||||
"name": "godylockz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/81207744?v=4",
|
||||
"profile": "https://github.com/godylockz",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "0dayCTF",
|
||||
"name": "Ryan Montgomery",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/44453666?v=4",
|
||||
"profile": "http://ryanmontgomery.me",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ippsec",
|
||||
"name": "ippsec",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/24677271?v=4",
|
||||
"profile": "https://github.com/IppSec",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "gtjamesa",
|
||||
"name": "James",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2078364?v=4",
|
||||
"profile": "https://github.com/gtjamesa",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jhaddix",
|
||||
"name": "Jason Haddix",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3488554?v=4",
|
||||
"profile": "https://twitter.com/Jhaddix",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "feroxbuster",
|
||||
"projectOwner": "epi052",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
}
|
||||
5
.cargo/config
Normal file
@@ -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
|
||||
10
.github/pull_request_template.md
vendored
@@ -7,16 +7,20 @@ Long form explanations of most of the items below can be found in the [CONTRIBUT
|
||||
- [ ] Your PR description references the associated issue (i.e. fixes #123456)
|
||||
- [ ] Code is in its own branch
|
||||
- [ ] Branch name is related to the PR contents
|
||||
- [ ] PR targets master
|
||||
- [ ] PR targets main
|
||||
|
||||
## Static analysis checks
|
||||
- [ ] All rust files are formatted using `cargo fmt`
|
||||
- [ ] All `clippy` checks pass when running `cargo clippy --all-targets --all-features -- -D warnings -A clippy::deref_addrof`
|
||||
- [ ] All `clippy` checks pass when running `cargo clippy --all-targets --all-features -- -D warnings -A clippy::mutex-atomic`
|
||||
- [ ] All existing tests pass
|
||||
|
||||
## Documentation
|
||||
- [ ] New code is documented using [doc comments](https://doc.rust-lang.org/stable/rust-by-example/meta/doc.html)
|
||||
- [ ] Documentation about your PR is included in the README, as needed
|
||||
- [ ] Documentation about your PR is included in the `docs`, as needed. The docs live in a [separate repository](https://epi052.github.io/feroxbuster-docs/docs/). Update the appropriate pages at the links below.
|
||||
- [ ] update [example config file section](https://epi052.github.io/feroxbuster-docs/docs/configuration/ferox-config-toml/)
|
||||
- [ ] update [help output section](https://epi052.github.io/feroxbuster-docs/docs/configuration/command-line/)
|
||||
- [ ] add an [example](https://epi052.github.io/feroxbuster-docs/docs/examples/)
|
||||
- [ ] update [comparison table](https://epi052.github.io/feroxbuster-docs/docs/compare/)
|
||||
|
||||
## Additional Tests
|
||||
- [ ] New code is unit tested
|
||||
|
||||
3
.github/stale.yml
vendored
@@ -1,11 +1,12 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 21
|
||||
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
|
||||
|
||||
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::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
|
||||
|
||||
13
.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
|
||||
|
||||
@@ -25,3 +24,9 @@ lcov_cobertura.py
|
||||
|
||||
# dockerignore file that makes it so i can work on the docker config without copying a 4GB manifest or w/e it is
|
||||
.dockerignore
|
||||
|
||||
# state file created during tests
|
||||
ferox-*.state
|
||||
|
||||
# python stuff cuz reasons
|
||||
Pipfile*
|
||||
|
||||
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.
|
||||
|
||||
|
||||
2768
Cargo.lock
generated
Normal file
70
Cargo.toml
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "feroxbuster"
|
||||
version = "1.10.0"
|
||||
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
|
||||
version = "2.6.4"
|
||||
authors = ["Ben 'epi' Risher (@epi052)"]
|
||||
license = "MIT"
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/epi052/feroxbuster"
|
||||
repository = "https://github.com/epi052/feroxbuster"
|
||||
description = "A fast, simple, recursive content discovery tool."
|
||||
@@ -16,37 +16,46 @@ build = "build.rs"
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[build-dependencies]
|
||||
clap = "2.33"
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
clap = { version = "3.1.8", features = ["wrap_help", "cargo"] }
|
||||
clap_complete = "3.1.1"
|
||||
regex = "1.5.5"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "4.0.0"
|
||||
|
||||
[dependencies]
|
||||
futures = { version = "0.3"}
|
||||
tokio = { version = "0.2", features = ["full"] }
|
||||
tokio-util = {version = "0.3", features = ["codec"]}
|
||||
log = "0.4"
|
||||
env_logger = "0.8"
|
||||
reqwest = { version = "0.10", features = ["socks"] }
|
||||
clap = "2.33"
|
||||
lazy_static = "1.4"
|
||||
toml = "0.5"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "0.8", features = ["v4"] }
|
||||
scraper = "0.12.0"
|
||||
futures = "0.3.21"
|
||||
tokio = { version = "1.17.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.1", features = ["codec"] }
|
||||
log = "0.4.16"
|
||||
env_logger = "0.9.0"
|
||||
reqwest = { version = "0.11.10", features = ["socks"] }
|
||||
# uses feature unification to add 'serde' to reqwest::Url
|
||||
url = { version = "2.2.2", features = ["serde"] }
|
||||
serde_regex = "1.1.0"
|
||||
clap = { version = "3.1.8", features = ["wrap_help", "cargo"] }
|
||||
lazy_static = "1.4.0"
|
||||
toml = "0.5.8"
|
||||
serde = { version = "1.0.136", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.79"
|
||||
uuid = { version = "0.8.2", features = ["v4"] }
|
||||
indicatif = "0.15"
|
||||
console = "0.13"
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
dirs = "3.0"
|
||||
regex = "1"
|
||||
crossterm = "0.18"
|
||||
rlimit = "0.5"
|
||||
ctrlc = "3.1"
|
||||
console = "0.15.0"
|
||||
openssl = { version = "0.10.38", features = ["vendored"] }
|
||||
dirs = "4.0.0"
|
||||
regex = "1.5.5"
|
||||
crossterm = "0.23.2"
|
||||
rlimit = "0.8.3"
|
||||
ctrlc = "3.2.1"
|
||||
fuzzyhash = "0.2.1"
|
||||
anyhow = "1.0.56"
|
||||
leaky-bucket = "0.10.0" # todo: upgrade, will take a little work/thought since api changed
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.1"
|
||||
httpmock = "0.5.2"
|
||||
assert_cmd = "1.0.1"
|
||||
predicates = "1.0.5"
|
||||
tempfile = "3.3.0"
|
||||
httpmock = "0.6.6"
|
||||
assert_cmd = "2.0.4"
|
||||
predicates = "2.1.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
@@ -60,4 +69,7 @@ conf-files = ["/etc/feroxbuster/ferox-config.toml"]
|
||||
assets = [
|
||||
["target/release/feroxbuster", "/usr/bin/", "755"],
|
||||
["ferox-config.toml.example", "/etc/feroxbuster/ferox-config.toml", "644"],
|
||||
["shell_completions/feroxbuster.bash", "/usr/share/bash-completion/completions/feroxbuster.bash", "644"],
|
||||
["shell_completions/feroxbuster.fish", "/usr/share/fish/completions/feroxbuster.fish", "644"],
|
||||
["shell_completions/_feroxbuster", "/usr/share/zsh/vendor-completions/_feroxbuster", "644"],
|
||||
]
|
||||
|
||||
30
Dockerfile
@@ -1,14 +1,28 @@
|
||||
FROM alpine:latest
|
||||
# Image: alpine:3.14.2
|
||||
FROM alpine@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a as build
|
||||
LABEL maintainer="wfnintr@null.net"
|
||||
|
||||
RUN sed -i -e 's/v[[:digit:]]\..*\//edge\//g' /etc/apk/repositories && apk upgrade --update-cache --available
|
||||
RUN sed -i -e 's/v[[:digit:]]\..*\//edge\//g' /etc/apk/repositories \
|
||||
&& apk upgrade --update-cache --available && apk add --update openssl
|
||||
|
||||
# download default wordlists
|
||||
RUN apk add --no-cache --virtual .depends subversion font-noto-emoji && \
|
||||
svn export https://github.com/danielmiessler/SecLists/trunk/Discovery/Web-Content /usr/share/seclists/Discovery/Web-Content && \
|
||||
apk del .depends
|
||||
|
||||
# install latest release
|
||||
RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip && unzip -d /usr/local/bin/ feroxbuster.zip feroxbuster && rm feroxbuster.zip && chmod +x /usr/local/bin/feroxbuster
|
||||
# Download latest release
|
||||
RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip \
|
||||
&& unzip -d /tmp/ feroxbuster.zip feroxbuster \
|
||||
&& chmod +x /tmp/feroxbuster \
|
||||
&& wget https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-medium-directories.txt -O /tmp/raft-medium-directories.txt
|
||||
|
||||
# Image: alpine:3.14.2
|
||||
FROM alpine@sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a as release
|
||||
|
||||
COPY --from=build /tmp/raft-medium-directories.txt /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
|
||||
COPY --from=build /tmp/feroxbuster /usr/local/bin/feroxbuster
|
||||
|
||||
RUN adduser \
|
||||
--gecos "" \
|
||||
--disabled-password \
|
||||
feroxbuster
|
||||
|
||||
USER feroxbuster
|
||||
|
||||
ENTRYPOINT ["feroxbuster"]
|
||||
|
||||
79
Makefile
Normal file
@@ -0,0 +1,79 @@
|
||||
default_prefix = /usr/local
|
||||
prefix ?= $(default_prefix)
|
||||
exec_prefix = $(prefix)
|
||||
bindir = $(exec_prefix)/bin
|
||||
datarootdir = $(prefix)/share
|
||||
datadir = $(datarootdir)
|
||||
example_config = ferox-config.toml.example
|
||||
config_file = ferox-config.toml
|
||||
completion_dir = shell_completions
|
||||
completion_prefix = $(completion_dir)/$(BIN)
|
||||
|
||||
BIN=feroxbuster
|
||||
SHR_SOURCES = $(shell find src -type f -wholename '*src/*.rs') Cargo.toml Cargo.lock
|
||||
|
||||
RELEASE = debug
|
||||
DEBUG ?= 0
|
||||
|
||||
ifeq (0, $(DEBUG))
|
||||
ARGS = --release
|
||||
RELEASE = release
|
||||
endif
|
||||
|
||||
VENDORED ?= 0
|
||||
ifeq (1,$(VENDORED))
|
||||
ARGS += --frozen
|
||||
endif
|
||||
|
||||
TARGET = target/$(RELEASE)
|
||||
|
||||
.PHONY: all clean install uninstall test update
|
||||
|
||||
all: cli
|
||||
cli: $(TARGET)/$(BIN) $(TARGET)/$(BIN).1.gz $(SHR_SOURCES)
|
||||
install: all install-cli
|
||||
|
||||
verify:
|
||||
cargo fmt
|
||||
cargo clippy --all-targets --all-features -- -D warnings -A clippy::mutex-atomic
|
||||
cargo test
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
vendor: vendor.tar
|
||||
|
||||
vendor.tar:
|
||||
cargo vendor
|
||||
tar pcf vendor.tar vendor
|
||||
rm -rf vendor
|
||||
|
||||
install-cli: cli
|
||||
install -Dm 0644 "$(completion_prefix).bash" "$(DESTDIR)/usr/share/bash-completion/completions/$(BIN).bash"
|
||||
install -Dm 0644 "$(completion_prefix).fish" "$(DESTDIR)/usr/share/fish/completions/$(BIN).fish"
|
||||
install -Dm 0644 "$(completion_dir)/_$(BIN)" "$(DESTDIR)/usr/share/zsh/vendor-completions/_$(BIN)"
|
||||
install -sDm 0755 "$(TARGET)/$(BIN)" "$(DESTDIR)$(bindir)/$(BIN)"
|
||||
install -Dm 0644 "$(TARGET)/$(BIN).1.gz" "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz"
|
||||
install -Dm 0644 "$(example_config)" "$(DESTDIR)/etc/$(BIN)/$(config_file)"
|
||||
|
||||
uninstall:
|
||||
rm -f "$(DESTDIR)$(bindir)/$(BIN)"
|
||||
rm -f "$(DESTDIR)$(datadir)/man/man1/$(BIN).1.gz"
|
||||
rm -rf "$(DESTDIR)/etc/$(BIN)/"
|
||||
rm -f "$(DESTDIR)/usr/share/bash-completion/completions/$(BIN).bash"
|
||||
rm -f "$(DESTDIR)/usr/share/zsh/vendor-completions/_$(BIN)"
|
||||
rm -f "$(DESTDIR)/usr/share/fish/completions/$(BIN).fish"
|
||||
|
||||
extract:
|
||||
ifeq (1, $(VENDORED))
|
||||
tar pxf vendor.tar
|
||||
endif
|
||||
|
||||
$(TARGET)/$(BIN): extract
|
||||
mkdir -p .cargo
|
||||
cp debian/cargo.config .cargo/config.toml
|
||||
cargo build $(ARGS)
|
||||
|
||||
$(TARGET)/$(BIN).1.gz: $(TARGET)/$(BIN)
|
||||
help2man --no-info $< | gzip -c > $@.partial
|
||||
mv $@.partial $@
|
||||
25
Makefile.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
# composite tasks
|
||||
[tasks.upgrade]
|
||||
dependencies = ["upgrade-deps", "update"]
|
||||
|
||||
# cleaning
|
||||
[tasks.clean-state]
|
||||
script = """
|
||||
rm ferox-*.state
|
||||
"""
|
||||
|
||||
# dependency management
|
||||
[tasks.upgrade-deps]
|
||||
command = "cargo"
|
||||
args = ["upgrade", "--exclude", "indicatif", "leaky-bucket"]
|
||||
|
||||
[tasks.update]
|
||||
command = "cargo"
|
||||
args = ["update"]
|
||||
|
||||
# clippy / lint
|
||||
[tasks.clippy]
|
||||
clear = true
|
||||
script = """
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
"""
|
||||
806
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,81 +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)
|
||||
- [Multiple Values](#multiple-values)
|
||||
- [Include Headers](#include-headers)
|
||||
- [IPv6, Non-recursive scan with INFO logging enabled](#ipv6-non-recursive-scan-with-info-level-logging-enabled)
|
||||
- [Read urls from STDIN; pipe only resulting urls out to another tool](#read-urls-from-stdin-pipe-only-resulting-urls-out-to-another-tool)
|
||||
- [Proxy traffic through Burp](#proxy-traffic-through-burp)
|
||||
- [Proxy traffic through a SOCKS proxy](#proxy-traffic-through-a-socks-proxy)
|
||||
- [Pass auth token via query parameter](#pass-auth-token-via-query-parameter)
|
||||
- [Extract Links from Response Body (new in `v1.1.0`)](#extract-links-from-response-body-new-in-v110)
|
||||
- [Limit Total Number of Concurrent Scans (new in `v1.2.0`)](#limit-total-number-of-concurrent-scans-new-in-v120)
|
||||
- [Filter Response by Status Code (new in `v1.3.0`)](#filter-response-by-status-code--new-in-v130)
|
||||
- [Pause an Active Scan (new in `v1.4.0`)](#pause-an-active-scan-new-in-v140)
|
||||
- [Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)](#replay-responses-to-a-proxy-based-on-status-code-new-in-v150)
|
||||
- [Filter Response by Word Count & Line Count (new in `v1.6.0`)](#filter-response-by-word-count--line-count--new-in-v160)
|
||||
- [Filter Response Using a Regular Expression (new in `v1.8.0`)](#filter-response-using-a-regular-expression-new-in-v180)
|
||||
- [Stop and Resume Scans (save scan's state to disk) (new in `v1.9.0`)](#stop-and-resume-scans---resume-from-file-new-in-v190)
|
||||
- [Enforce a Time Limit on Your Scan (new in `v1.10.0`)](#enforce-a-time-limit-on-your-scan-new-in-v1100)
|
||||
- [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
|
||||
|
||||
@@ -121,313 +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`
|
||||
- save_state: `true` (create a state file in cwd when `Ctrl+C` is received)
|
||||
|
||||
### Threads and Connection Limits At A High-Level
|
||||
|
||||
This section explains how the `-t` and `-L` options work together to determine the overall aggressiveness of a scan. The combination of the two values set by these options determines how hard your target will get hit and to some extent also determines how many resources will be consumed on your local machine.
|
||||
|
||||
#### A Note on Green Threads
|
||||
|
||||
`feroxbuster` uses so-called [green threads](https://en.wikipedia.org/wiki/Green_threads) as opposed to traditional kernel/OS threads. This means (at a high-level) that the threads are implemented entirely in userspace, within a single running process. As a result, a scan with 30 green threads will appear to the OS to be a single process with no additional light-weight processes associated with it as far as the kernel is concerned. As such, there will not be any impact to process (`nproc`) limits when specifying larger values for `-t`. However, these threads will still consume file descriptors, so you will need to ensure that you have a suitable `nlimit` set when scaling up the amount of threads. More detailed documentation on setting appropriate `nlimit` values can be found in the [No File Descriptors Available](#no-file-descriptors-available) section of the FAQ
|
||||
|
||||
#### Threads and Connection Limits: The Implementation
|
||||
|
||||
* Threads: The `-t` option specifies the maximum amount of active threads *per-directory* during a scan
|
||||
* Connection Limits: The `-L` option specifies the maximum amount of active connections per thread
|
||||
|
||||
#### Threads and Connection Limits: Examples
|
||||
|
||||
To truly have only 30 active requests to a site at any given time, `-t 30 -L 1` is necessary. Using `-t 30 -L 2` will result in a maximum of 60 total requests being processed at any given time for that site. And so on. For a conversation on this, please see [Issue #126](https://github.com/epi052/feroxbuster/issues/126) which may provide more (or less) clarity :wink:
|
||||
|
||||
### ferox-config.toml
|
||||
After setting built-in default values, any values defined in a `ferox-config.toml` config file will override the
|
||||
built-in defaults.
|
||||
|
||||
`feroxbuster` searches for `ferox-config.toml` in the following locations (in the order shown):
|
||||
- `/etc/feroxbuster/` (global)
|
||||
- `CONFIG_DIR/ferxobuster/` (per-user)
|
||||
- The same directory as the `feroxbuster` executable (per-user)
|
||||
- The user's current working directory (per-target)
|
||||
|
||||
> `CONFIG_DIR` is defined as the following:
|
||||
> - Linux: `$XDG_CONFIG_HOME` or `$HOME/.config` i.e. `/home/bob/.config`
|
||||
> - MacOs: `$HOME/Library/Application Support` i.e. `/Users/bob/Library/Application Support`
|
||||
> - Windows: `{FOLDERID_RoamingAppData}` i.e. `C:\Users\Bob\AppData\Roaming`
|
||||
|
||||
If more than one valid configuration file is found, each one overwrites the values found previously.
|
||||
|
||||
If no configuration file is found, nothing happens at this stage.
|
||||
|
||||
As an example, let's say that we prefer to use a different wordlist as our default when scanning; we can
|
||||
set the `wordlist` value in the config file to override the baked-in default.
|
||||
|
||||
Notes of interest:
|
||||
- it's ok to only specify values you want to change without specifying anything else
|
||||
- variable names in `ferox-config.toml` must match their command-line counterpart
|
||||
|
||||
```toml
|
||||
# ferox-config.toml
|
||||
|
||||
wordlist = "/wordlists/jhaddix/all.txt"
|
||||
```
|
||||
|
||||
A pre-made configuration file with examples of all available settings can be found in `ferox-config.toml.example`.
|
||||
```toml
|
||||
# ferox-config.toml
|
||||
# Example configuration for feroxbuster
|
||||
#
|
||||
# If you wish to provide persistent settings to feroxbuster, rename this file to ferox-config.toml and make sure
|
||||
# it resides in the same directory as the feroxbuster binary.
|
||||
#
|
||||
# After that, uncomment any line to override the default value provided by the binary itself.
|
||||
#
|
||||
# Any setting used here can be overridden by the corresponding command line option/argument
|
||||
#
|
||||
# wordlist = "/wordlists/jhaddix/all.txt"
|
||||
# status_codes = [200, 500]
|
||||
# filter_status = [301]
|
||||
# threads = 1
|
||||
# timeout = 5
|
||||
# proxy = "http://127.0.0.1:8080"
|
||||
# replay_proxy = "http://127.0.0.1:8081"
|
||||
# replay_codes = [200, 302]
|
||||
# verbosity = 1
|
||||
# scan_limit = 6
|
||||
# quiet = true
|
||||
# json = true
|
||||
# output = "/targets/ellingson_mineral_company/gibson.txt"
|
||||
# debug_log = "/var/log/find-the-derp.log"
|
||||
# user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
|
||||
# redirects = true
|
||||
# insecure = true
|
||||
# extensions = ["php", "html"]
|
||||
# no_recursion = true
|
||||
# add_slash = true
|
||||
# stdin = true
|
||||
# dont_filter = true
|
||||
# extract_links = true
|
||||
# depth = 1
|
||||
# filter_size = [5174]
|
||||
# filter_regex = ["^ignore me$"]
|
||||
# filter_word_count = [993]
|
||||
# filter_line_count = [35, 36]
|
||||
# queries = [["name","value"], ["rick", "astley"]]
|
||||
# save_state = false
|
||||
# time_limit = 10m
|
||||
|
||||
# headers can be specified on multiple lines or as an inline table
|
||||
#
|
||||
# inline example
|
||||
# headers = {"stuff" = "things"}
|
||||
#
|
||||
# multi-line example
|
||||
# note: if multi-line is used, all key/value pairs under it belong to the headers table until the next table
|
||||
# is found or the end of the file is reached
|
||||
#
|
||||
# [headers]
|
||||
# stuff = "things"
|
||||
# more = "headers"
|
||||
```
|
||||
|
||||
### Command Line Parsing
|
||||
Finally, after parsing the available config file, any options/arguments given on the commandline will override any values that were set as a built-in or config-file value.
|
||||
|
||||
```
|
||||
USAGE:
|
||||
feroxbuster [FLAGS] [OPTIONS] --url <URL>...
|
||||
|
||||
FLAGS:
|
||||
-f, --add-slash Append / to each request
|
||||
-D, --dont-filter Don't auto-filter wildcard responses
|
||||
-e, --extract-links Extract links from response body (html, javascript, etc...); make new requests based on
|
||||
findings (default: false)
|
||||
-h, --help Prints help information
|
||||
-k, --insecure Disables TLS certificate validation
|
||||
--json Emit JSON logs to --output and --debug-log instead of normal text
|
||||
-n, --no-recursion Do not scan recursively
|
||||
-q, --quiet Only print URLs; Don't print status codes, response size, running config, etc...
|
||||
-r, --redirects Follow redirects
|
||||
--stdin Read url(s) from STDIN
|
||||
-V, --version Prints version information
|
||||
-v, --verbosity Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably
|
||||
too much)
|
||||
|
||||
OPTIONS:
|
||||
--debug-log <FILE> Output file to write log entries (use w/ --json for JSON entries)
|
||||
-d, --depth <RECURSION_DEPTH> Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)
|
||||
-x, --extensions <FILE_EXTENSION>... File extension(s) to search for (ex: -x php -x pdf js)
|
||||
-N, --filter-lines <LINES>... Filter out messages of a particular line count (ex: -N 20 -N 31,30)
|
||||
-X, --filter-regex <REGEX>... Filter out messages via regular expression matching on the response's body
|
||||
(ex: -X '^ignore me$')
|
||||
-S, --filter-size <SIZE>... Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)
|
||||
-C, --filter-status <STATUS_CODE>... Filter out status codes (deny list) (ex: -C 200 -C 401)
|
||||
-W, --filter-words <WORDS>... Filter out messages of a particular word count (ex: -W 312 -W 91,82)
|
||||
-H, --headers <HEADER>... Specify HTTP headers (ex: -H Header:val 'stuff: things')
|
||||
-o, --output <FILE> Output file to write results to (use w/ --json for JSON entries)
|
||||
-p, --proxy <PROXY> Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)
|
||||
-Q, --query <QUERY>... Specify URL query parameters (ex: -Q token=stuff -Q secret=key)
|
||||
-R, --replay-codes <REPLAY_CODE>... Status Codes to send through a Replay Proxy when found (default: --status-
|
||||
codes value)
|
||||
-P, --replay-proxy <REPLAY_PROXY> Send only unfiltered requests through a Replay Proxy, instead of all
|
||||
requests
|
||||
--resume-from <STATE_FILE> State file from which to resume a partially complete scan (ex. --resume-from
|
||||
ferox-1606586780.state)
|
||||
-L, --scan-limit <SCAN_LIMIT> Limit total number of concurrent scans (default: 0, i.e. no limit)
|
||||
-s, --status-codes <STATUS_CODE>... Status Codes to include (allow list) (default: 200 204 301 302 307 308 401
|
||||
403 405)
|
||||
-t, --threads <THREADS> Number of concurrent threads (default: 50)
|
||||
--time-limit <TIME_SPEC> Limit total run time of all scans (ex: --time-limit 10m)
|
||||
-T, --timeout <SECONDS> Number of seconds before a request times out (default: 7)
|
||||
-u, --url <URL>... The target URL(s) (required, unless --stdin used)
|
||||
-a, --user-agent <USER_AGENT> Sets the User-Agent (default: feroxbuster/VERSION)
|
||||
-w, --wordlist <FILE> Path to the wordlist
|
||||
```
|
||||
Please refer the the [documentation](https://epi052.github.io/feroxbuster-docs/docs/).
|
||||
|
||||
## 🧰 Example Usage
|
||||
|
||||
Here are a few brief examples to get you started. Please note, feroxbuster can do a **lot more** than what's listed below. As a result, there are **many more** examples, with **demonstration gifs** that highlight specific features, in the [documentation](https://epi052.github.io/feroxbuster-docs/docs/).
|
||||
|
||||
### Multiple Values
|
||||
|
||||
Options that take multiple values are very flexible. Consider the following ways of specifying extensions:
|
||||
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
|
||||
@@ -435,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
|
||||
|
||||
@@ -452,7 +146,7 @@ All of the methods above (multiple flags, space separated, comma separated, etc.
|
||||
### 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
|
||||
@@ -461,325 +155,91 @@ 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
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||

|
||||
|
||||
### Limit Total Number of Concurrent Scans (new in `v1.2.0`)
|
||||
|
||||
Limit the number of scans permitted to run at any given time. Recursion will still identify new directories, but newly
|
||||
discovered directories can only begin scanning when the total number of active scans drops below the value passed to
|
||||
`--scan-limit`.
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --scan-limit 2
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Filter Response by Status Code (new in `v1.3.0`)
|
||||
|
||||
Version 1.3.0 included an overhaul to the filtering system which will allow for a wide array of filters to be added
|
||||
with minimal effort. The first such filter is a Status Code Filter. As responses come back from the scanned server,
|
||||
each one is checked against a list of known filters and either displayed or not according to which filters are set.
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --filter-status 301
|
||||
```
|
||||
|
||||
### Pause an Active Scan (new in `v1.4.0`)
|
||||
|
||||
Scans can be paused and resumed by pressing the ENTER key (shown below)
|
||||
|
||||

|
||||
|
||||
### Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)
|
||||
|
||||
The `--replay-proxy` and `--replay-codes` options were added as a way to only send a select few responses to a proxy. This is in stark contrast to `--proxy` which proxies EVERY request.
|
||||
|
||||
Imagine you only care about proxying responses that have either the status code `200` or `302` (or you just don't want to clutter up your Burp history). These two options will allow you to fine-tune what gets proxied and what doesn't.
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
|
||||
```
|
||||
|
||||
Of note: this means that for every response that matches your replay criteria, you'll end up sending the request that generated that response a second time. Depending on the target and your engagement terms (if any), it may not make sense from a traffic generated perspective.
|
||||
|
||||

|
||||
|
||||
### Filter Response by Word Count & Line Count (new in `v1.6.0`)
|
||||
|
||||
In addition to filtering on the size of a response, version 1.6.0 added the ability to filter out responses based on the number of lines and/or words contained within the response body. This change drove a change to the information displayed to the user as well. This section will detail the new information and how to make use of it with the new filters provided.
|
||||
|
||||
Example output:
|
||||
```
|
||||
200 10l 212w 38437c https://example-site.com/index.html
|
||||
```
|
||||
|
||||
There are five columns of output above:
|
||||
- column 1: status code - can be filtered with `-C|--filter-status`
|
||||
- column 2: number of lines - can be filtered with `-N|--filter-lines`
|
||||
- column 3: number of words - can be filtered with `-W|--filter-words`
|
||||
- column 4: number of bytes (overall size) - can be filtered with `-S|--filter-size`
|
||||
- column 5: url to discovered resource
|
||||
|
||||
### Filter Response Using a Regular Expression (new in `v1.8.0`)
|
||||
|
||||
Version 1.3.0 included an overhaul to the filtering system which will allow for a wide array of filters to be added
|
||||
with minimal effort. The latest addition is a Regular Expression Filter. As responses come back from the scanned server,
|
||||
the **body** of the response is checked against the filter's regular expression. If the expression is found in the
|
||||
body, then that response is filtered out.
|
||||
|
||||
**NOTE: Using regular expressions to filter large responses or many regular expressions may negatively impact performance.**
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --filter-regex '[aA]ccess [dD]enied.?' --output results.txt --json
|
||||
```
|
||||
|
||||
### Stop and Resume Scans (`--resume-from FILE`) (new in `v1.9.0`)
|
||||
|
||||
Version 1.9.0 adds a few features that allow for completely stopping a scan, and resuming that same scan from a file on disk.
|
||||
|
||||
A simple `Ctrl+C` during a scan will create a file that contains information about the scan that was cancelled.
|
||||
|
||||

|
||||
|
||||
```json
|
||||
// example snippet of state file
|
||||
|
||||
{
|
||||
"scans":[
|
||||
{
|
||||
"id":"057016a14769414aac9a7a62707598cb",
|
||||
"url":"https://localhost.com",
|
||||
"scan_type":"Directory",
|
||||
"complete":true
|
||||
},
|
||||
{
|
||||
"id":"400b2323a16f43468a04ffcbbeba34c6",
|
||||
"url":"https://localhost.com/css",
|
||||
"scan_type":"Directory",
|
||||
"complete":false
|
||||
}
|
||||
],
|
||||
"config":{
|
||||
"wordlist":"/wordlists/seclists/Discovery/Web-Content/common.txt",
|
||||
"...":"..."
|
||||
},
|
||||
"responses":[
|
||||
{
|
||||
"type":"response",
|
||||
"url":"https://localhost.com/Login",
|
||||
"path":"/Login",
|
||||
"wildcard":false,
|
||||
"status":302,
|
||||
"content_length":0,
|
||||
"line_count":0,
|
||||
"word_count":0,
|
||||
"headers":{
|
||||
"content-length":"0",
|
||||
"server":"nginx/1.16.1"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
```
|
||||
|
||||
Based on the example image above, the same scan can be resumed by using `feroxbuster --resume-from ferox-http_localhost-1606947491.state`. Directories that were already complete are not rescanned, however partially complete scans are started from the beginning.
|
||||
|
||||

|
||||
|
||||
In order to prevent state file creation when `Ctrl+C` is pressed, you can simply add the entry below to your `ferox-config.toml`.
|
||||
|
||||
```toml
|
||||
# ferox-config.toml
|
||||
|
||||
save_state = false
|
||||
```
|
||||
|
||||
### Enforce a Time Limit on Your Scan (new in `v1.10.0`)
|
||||
|
||||
Version 1.10.0 adds the ability to set a maximum runtime, or time limit, on your scan. The usage is pretty simple: a number followed directly by a single character representing seconds, minutes, hours, or days. `feroxbuster` refers to this combination as a time_spec.
|
||||
|
||||
Examples of possible time_specs:
|
||||
- `30s` - 30 seconds
|
||||
- `20m` - 20 minutes
|
||||
- `1h` - 1 hour
|
||||
- `1d` - 1 day (why??)
|
||||
|
||||
A valid time_spec can be passed to `--time-limit` in order to force a shutdown after the given time has elapsed.
|
||||
|
||||

|
||||
|
||||
## 🧐 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 | ✔ | ✔ | |
|
||||
| allows recursion | ✔ | | ✔ |
|
||||
| can specify query parameters | ✔ | | ✔ |
|
||||
| SOCKS proxy support | ✔ | | |
|
||||
| multiple target scan (via stdin or multiple -u) | ✔ | | ✔ |
|
||||
| configuration file for default value override | ✔ | | ✔ |
|
||||
| can accept urls via STDIN as part of a pipeline | ✔ | | ✔ |
|
||||
| can accept wordlists via STDIN | | ✔ | ✔ |
|
||||
| filter based on response size, wordcount, and linecount | ✔ | | ✔ |
|
||||
| auto-filter wildcard responses | ✔ | | ✔ |
|
||||
| performs other scans (vhost, dns, etc) | | ✔ | ✔ |
|
||||
| time delay / rate limiting | | ✔ | ✔ |
|
||||
| extracts links from response body to increase scan coverage (`v1.1.0`) | ✔ | | |
|
||||
| limit number of concurrent recursive scans (`v1.2.0`) | ✔ | | |
|
||||
| filter out responses by status code (`v1.3.0`) | ✔ | ✔ | ✔ |
|
||||
| interactive pause and resume of active scan (`v1.4.0`) | ✔ | | |
|
||||
| replay only matched requests to a proxy (`v1.5.0`) | ✔ | | ✔ |
|
||||
| filter out responses by line & word count (`v1.6.0`) | ✔ | | ✔ |
|
||||
| json output (ffuf supports other formats as well) (`v1.7.0`) | ✔ | | ✔ |
|
||||
| filter out responses by regular expression (`v1.8.0`) | ✔ | | ✔ |
|
||||
| save scan's state to disk (can pick up where it left off) (`v1.9.0`) | ✔ | | |
|
||||
| maximum run time limit (`v1.10.0`) | ✔ | | ✔ |
|
||||
| **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.
|
||||
|
||||
### What do each of the numbers beside the URL mean?
|
||||
|
||||
Please refer to [this section](#filter-response-by-word-count--line-count--new-in-v160) where each number's meaning and how to use it to filter responses is discussed.
|
||||
## 🚀 Documentation has **moved** 🚀
|
||||
|
||||
For realsies, there used to be over 1300 lines in this README, but it's all been moved to the [new documentation site](https://epi052.github.io/feroxbuster-docs/docs/). Go check it out!
|
||||
|
||||
<h1><p align="center">✨🎉👉 <a href="https://epi052.github.io/feroxbuster-docs/docs/">DOCUMENTATION</a> 👈🎉✨</p></h1>
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://io.fi"><img src="https://avatars.githubusercontent.com/u/5235109?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joona Hoikkala</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=joohoi" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/jsav0"><img src="https://avatars.githubusercontent.com/u/20546041?v=4?s=100" width="100px;" alt=""/><br /><sub><b>J Savage</b></sub></a><br /><a href="#infra-jsav0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=jsav0" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://www.tgotwig.dev"><img src="https://avatars.githubusercontent.com/u/30773779?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thomas Gotwig</b></sub></a><br /><a href="#infra-TGotwig" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=TGotwig" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/spikecodes"><img src="https://avatars.githubusercontent.com/u/19519553?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Spike</b></sub></a><br /><a href="#infra-spikecodes" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=spikecodes" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/evanrichter"><img src="https://avatars.githubusercontent.com/u/330292?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Evan Richter</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=evanrichter" title="Code">💻</a> <a href="https://github.com/epi052/feroxbuster/commits?author=evanrichter" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/mzpqnxow"><img src="https://avatars.githubusercontent.com/u/8016228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>AG</b></sub></a><br /><a href="#ideas-mzpqnxow" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=mzpqnxow" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://n-thumann.de/"><img src="https://avatars.githubusercontent.com/u/46975855?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolas Thumann</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=n-thumann" title="Code">💻</a> <a href="https://github.com/epi052/feroxbuster/commits?author=n-thumann" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/tomtastic"><img src="https://avatars.githubusercontent.com/u/302127?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tom Matthews</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=tomtastic" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/bsysop"><img src="https://avatars.githubusercontent.com/u/9998303?v=4?s=100" width="100px;" alt=""/><br /><sub><b>bsysop</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=bsysop" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://bpsizemore.me"><img src="https://avatars.githubusercontent.com/u/11645898?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Brian Sizemore</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=bpsizemore" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://pwn.by/noraj"><img src="https://avatars.githubusercontent.com/u/16578570?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexandre ZANNI</b></sub></a><br /><a href="#infra-noraj" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=noraj" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/craig"><img src="https://avatars.githubusercontent.com/u/99729?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Craig</b></sub></a><br /><a href="#infra-craig" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://www.reddit.com/u/EONRaider"><img src="https://avatars.githubusercontent.com/u/15611424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>EONRaider</b></sub></a><br /><a href="#infra-EONRaider" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/wtwver"><img src="https://avatars.githubusercontent.com/u/53866088?v=4?s=100" width="100px;" alt=""/><br /><sub><b>wtwver</b></sub></a><br /><a href="#infra-wtwver" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://tib3rius.com"><img src="https://avatars.githubusercontent.com/u/48113936?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tib3rius</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ATib3rius" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/0xdf"><img src="https://avatars.githubusercontent.com/u/1489045?v=4?s=100" width="100px;" alt=""/><br /><sub><b>0xdf</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3A0xdf" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="http://secure77.de"><img src="https://avatars.githubusercontent.com/u/31564517?v=4?s=100" width="100px;" alt=""/><br /><sub><b>secure-77</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asecure-77" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/sbrun"><img src="https://avatars.githubusercontent.com/u/7712154?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sophie Brun</b></sub></a><br /><a href="#infra-sbrun" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/black-A"><img src="https://avatars.githubusercontent.com/u/30686803?v=4?s=100" width="100px;" alt=""/><br /><sub><b>black-A</b></sub></a><br /><a href="#ideas-black-A" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/dinosn"><img src="https://avatars.githubusercontent.com/u/3851678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolas Krassas</b></sub></a><br /><a href="#ideas-dinosn" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/N0ur5"><img src="https://avatars.githubusercontent.com/u/24260009?v=4?s=100" width="100px;" alt=""/><br /><sub><b>N0ur5</b></sub></a><br /><a href="#ideas-N0ur5" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/moscowchill"><img src="https://avatars.githubusercontent.com/u/72578879?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mchill</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Amoscowchill" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="http://BitThr3at.github.io"><img src="https://avatars.githubusercontent.com/u/45028933?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Naman</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ABitThr3at" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/Sicks3c"><img src="https://avatars.githubusercontent.com/u/32225186?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ayoub Elaich</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asicks3c" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/HenryHoggard"><img src="https://avatars.githubusercontent.com/u/1208121?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Henry</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AHenryHoggard" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/SleepiPanda"><img src="https://avatars.githubusercontent.com/u/6428561?v=4?s=100" width="100px;" alt=""/><br /><sub><b>SleepiPanda</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ASleepiPanda" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/uBadRequest"><img src="https://avatars.githubusercontent.com/u/47282747?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bad Requests</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AuBadRequest" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://home.dnaka91.rocks"><img src="https://avatars.githubusercontent.com/u/36804488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dominik Nakamura</b></sub></a><br /><a href="#infra-dnaka91" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/hunter0x8"><img src="https://avatars.githubusercontent.com/u/46222314?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Muhammad Ahsan</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Ahunter0x8" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://github.com/cortantief"><img src="https://avatars.githubusercontent.com/u/34527333?v=4?s=100" width="100px;" alt=""/><br /><sub><b>cortantief</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Acortantief" title="Bug reports">🐛</a> <a href="https://github.com/epi052/feroxbuster/commits?author=cortantief" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/dsaxton"><img src="https://avatars.githubusercontent.com/u/2658661?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Saxton</b></sub></a><br /><a href="#ideas-dsaxton" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=dsaxton" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/narkopolo"><img src="https://avatars.githubusercontent.com/u/16690056?v=4?s=100" width="100px;" alt=""/><br /><sub><b>narkopolo</b></sub></a><br /><a href="#ideas-narkopolo" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://ring0.lol"><img src="https://avatars.githubusercontent.com/u/1893909?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Justin Steven</b></sub></a><br /><a href="#ideas-justinsteven" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/7047payloads"><img src="https://avatars.githubusercontent.com/u/95562424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>7047payloads</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=7047payloads" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/unkn0wnsyst3m"><img src="https://avatars.githubusercontent.com/u/21272239?v=4?s=100" width="100px;" alt=""/><br /><sub><b>unkn0wnsyst3m</b></sub></a><br /><a href="#ideas-unkn0wnsyst3m" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://ironwort.me/"><img src="https://avatars.githubusercontent.com/u/15280042?v=4?s=100" width="100px;" alt=""/><br /><sub><b>0x08</b></sub></a><br /><a href="#ideas-its0x08" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/MD-Levitan"><img src="https://avatars.githubusercontent.com/u/12116508?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kusok</b></sub></a><br /><a href="#ideas-MD-Levitan" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=MD-Levitan" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/godylockz"><img src="https://avatars.githubusercontent.com/u/81207744?v=4?s=100" width="100px;" alt=""/><br /><sub><b>godylockz</b></sub></a><br /><a href="#ideas-godylockz" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=godylockz" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://ryanmontgomery.me"><img src="https://avatars.githubusercontent.com/u/44453666?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ryan Montgomery</b></sub></a><br /><a href="#ideas-0dayCTF" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/IppSec"><img src="https://avatars.githubusercontent.com/u/24677271?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ippsec</b></sub></a><br /><a href="#ideas-ippsec" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/gtjamesa"><img src="https://avatars.githubusercontent.com/u/2078364?v=4?s=100" width="100px;" alt=""/><br /><sub><b>James</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Agtjamesa" title="Bug reports">🐛</a></td>
|
||||
<td align="center"><a href="https://twitter.com/Jhaddix"><img src="https://avatars.githubusercontent.com/u/3488554?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jason Haddix</b></sub></a><br /><a href="#ideas-jhaddix" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
77
build.rs
@@ -1,17 +1,84 @@
|
||||
extern crate clap;
|
||||
use std::fs::{copy, create_dir_all, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
use clap::Shell;
|
||||
use clap_complete::{generate_to, shells};
|
||||
|
||||
include!("src/parser.rs");
|
||||
|
||||
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();
|
||||
|
||||
let shells: [Shell; 4] = [Shell::Bash, Shell::Fish, Shell::Zsh, Shell::PowerShell];
|
||||
generate_to(shells::Bash, &mut app, "feroxbuster", outdir).unwrap();
|
||||
generate_to(shells::Zsh, &mut app, "feroxbuster", outdir).unwrap();
|
||||
generate_to(shells::Zsh, &mut app, "feroxbuster", outdir).unwrap();
|
||||
generate_to(shells::PowerShell, &mut app, "feroxbuster", outdir).unwrap();
|
||||
generate_to(shells::Elvish, &mut app, "feroxbuster", outdir).unwrap();
|
||||
|
||||
for shell in &shells {
|
||||
app.gen_completions("feroxbuster", *shell, outdir);
|
||||
// 0xdf pointed out an oddity when tab-completing options that expect file paths, the fix we
|
||||
// landed on was to add -o plusdirs to the bash completion script. The following code aims to
|
||||
// automate that fix and have it present in all future builds
|
||||
let mut contents = String::new();
|
||||
|
||||
let mut bash_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(format!("{}/feroxbuster.bash", outdir))
|
||||
.expect("Couldn't open bash completion script");
|
||||
|
||||
bash_file
|
||||
.read_to_string(&mut contents)
|
||||
.expect("Couldn't read bash completion script");
|
||||
|
||||
contents = contents.replace("default feroxbuster", "default -o plusdirs feroxbuster");
|
||||
|
||||
bash_file
|
||||
.seek(SeekFrom::Start(0))
|
||||
.expect("Couldn't seek to position 0 in bash completion script");
|
||||
|
||||
bash_file
|
||||
.write_all(contents.as_bytes())
|
||||
.expect("Couldn't write updated bash completion script to disk");
|
||||
|
||||
// hunter0x8 let me know that when installing via cargo, it would be nice if we dropped a
|
||||
// config file during the build process. The following code will place an example config in
|
||||
// the user's configuration directory
|
||||
// - linux: $XDG_CONFIG_HOME or $HOME/.config
|
||||
// - macOS: $HOME/Library/Application Support
|
||||
// - windows: {FOLDERID_RoamingAppData}
|
||||
let mut config_dir = dirs::config_dir().expect("Couldn't resolve user's config directory");
|
||||
config_dir = config_dir.join("feroxbuster"); // $HOME/.config/feroxbuster
|
||||
|
||||
if !config_dir.exists() {
|
||||
// recursively create the feroxbuster directory and all of its parent components if
|
||||
// they are missing
|
||||
if !config_dir.exists() {
|
||||
// recursively create the feroxbuster directory and all of its parent components if
|
||||
// they are missing
|
||||
if create_dir_all(&config_dir).is_err() {
|
||||
// only copy the config file when we're not running in the CI/CD pipeline
|
||||
// which fails with permission denied
|
||||
eprintln!("Couldn't create one or more directories needed to copy the config file");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hard-coding config name here to not rely on the crate we're building, if DEFAULT_CONFIG_NAME
|
||||
// ever changes, this will need to be updated
|
||||
let config_file = config_dir.join("ferox-config.toml");
|
||||
|
||||
if !config_file.exists() {
|
||||
// config file doesn't exist, add it to the config directory
|
||||
if copy("ferox-config.toml.example", config_file).is_err() {
|
||||
eprintln!("Couldn't copy example config into config directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
0
docs/.nojekyll
Normal file
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,15 +16,29 @@
|
||||
# replay_proxy = "http://127.0.0.1:8081"
|
||||
# replay_codes = [200, 302]
|
||||
# verbosity = 1
|
||||
# parallel = 8
|
||||
# scan_limit = 6
|
||||
# rate_limit = 250
|
||||
# quiet = true
|
||||
# silent = true
|
||||
# auto_tune = true
|
||||
# auto_bail = true
|
||||
# json = true
|
||||
# output = "/targets/ellingson_mineral_company/gibson.txt"
|
||||
# debug_log = "/var/log/find-the-derp.log"
|
||||
# user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
|
||||
# random_agent = false
|
||||
# redirects = true
|
||||
# insecure = true
|
||||
# collect_words = true
|
||||
# collect_backups = true
|
||||
# collect_extensions = true
|
||||
# extensions = ["php", "html"]
|
||||
# dont_collect = ["png", "gif", "jpg", "jpeg"]
|
||||
# methods = ["GET", "POST"]
|
||||
# data = [11, 12, 13, 14, 15]
|
||||
# url_denylist = ["http://dont-scan.me", "https://also-not.me"]
|
||||
# regex_denylist = ["/deny.*"]
|
||||
# no_recursion = true
|
||||
# add_slash = true
|
||||
# stdin = true
|
||||
@@ -33,6 +47,7 @@
|
||||
# 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"]]
|
||||
|
||||
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/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)"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,81 +15,105 @@ _feroxbuster() {
|
||||
|
||||
local context curcontext="$curcontext" state line
|
||||
_arguments "${_arguments_options[@]}" \
|
||||
'-w+[Path to the wordlist]' \
|
||||
'--wordlist=[Path to the wordlist]' \
|
||||
'*-u+[The target URL(s) (required, unless --stdin used)]' \
|
||||
'*--url=[The target URL(s) (required, unless --stdin used)]' \
|
||||
'-t+[Number of concurrent threads (default: 50)]' \
|
||||
'--threads=[Number of concurrent threads (default: 50)]' \
|
||||
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \
|
||||
'--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \
|
||||
'-T+[Number of seconds before a request times out (default: 7)]' \
|
||||
'--timeout=[Number of seconds before a request times out (default: 7)]' \
|
||||
'-p+[Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)]' \
|
||||
'--proxy=[Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)]' \
|
||||
'-P+[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \
|
||||
'--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \
|
||||
'*-R+[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \
|
||||
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \
|
||||
'*-s+[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \
|
||||
'*--status-codes=[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \
|
||||
'-o+[Output file to write results to (use w/ --json for JSON entries)]' \
|
||||
'--output=[Output file to write results to (use w/ --json for JSON entries)]' \
|
||||
'(-u --url)--resume-from=[State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)]' \
|
||||
'--debug-log=[Output file to write log entries (use w/ --json for JSON entries)]' \
|
||||
'-a+[Sets the User-Agent (default: feroxbuster/VERSION)]' \
|
||||
'--user-agent=[Sets the User-Agent (default: feroxbuster/VERSION)]' \
|
||||
'*-x+[File extension(s) to search for (ex: -x php -x pdf js)]' \
|
||||
'*--extensions=[File extension(s) to search for (ex: -x php -x pdf js)]' \
|
||||
'*-H+[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \
|
||||
'*--headers=[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \
|
||||
'*-Q+[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \
|
||||
'*--query=[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \
|
||||
'*-S+[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \
|
||||
'*--filter-size=[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \
|
||||
'*-X+[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \
|
||||
'*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \
|
||||
'*-W+[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \
|
||||
'*--filter-words=[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \
|
||||
'*-N+[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \
|
||||
'*--filter-lines=[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \
|
||||
'*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \
|
||||
'*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \
|
||||
'-L+[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \
|
||||
'--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \
|
||||
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]' \
|
||||
'*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
|
||||
'*--verbosity[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
|
||||
'-q[Only print URLs; Don'\''t print status codes, response size, running config, etc...]' \
|
||||
'--quiet[Only print URLs; Don'\''t print status codes, response size, running config, etc...]' \
|
||||
'--json[Emit JSON logs to --output and --debug-log instead of normal text]' \
|
||||
'-D[Don'\''t auto-filter wildcard responses]' \
|
||||
'--dont-filter[Don'\''t auto-filter wildcard responses]' \
|
||||
'-r[Follow redirects]' \
|
||||
'--redirects[Follow redirects]' \
|
||||
'-k[Disables TLS certificate validation]' \
|
||||
'--insecure[Disables TLS certificate validation]' \
|
||||
'-u+[The target URL (required, unless \[--stdin || --resume-from\] used)]:URL:_urls' \
|
||||
'--url=[The target URL (required, unless \[--stdin || --resume-from\] used)]:URL:_urls' \
|
||||
'(-u --url)--resume-from=[State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)]:STATE_FILE:_files' \
|
||||
'-p+[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]:PROXY:_urls' \
|
||||
'--proxy=[Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)]:PROXY:_urls' \
|
||||
'-P+[Send only unfiltered requests through a Replay Proxy, instead of all requests]:REPLAY_PROXY:_urls' \
|
||||
'--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]:REPLAY_PROXY:_urls' \
|
||||
'*-R+[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]:REPLAY_CODE: ' \
|
||||
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]:REPLAY_CODE: ' \
|
||||
'-a+[Sets the User-Agent (default: feroxbuster/2.6.4)]:USER_AGENT: ' \
|
||||
'--user-agent=[Sets the User-Agent (default: feroxbuster/2.6.4)]:USER_AGENT: ' \
|
||||
'*-x+[File extension(s) to search for (ex: -x php -x pdf js)]:FILE_EXTENSION: ' \
|
||||
'*--extensions=[File extension(s) to search for (ex: -x php -x pdf js)]:FILE_EXTENSION: ' \
|
||||
'*-m+[Which HTTP request method(s) should be sent (default: GET)]:HTTP_METHODS: ' \
|
||||
'*--methods=[Which HTTP request method(s) should be sent (default: GET)]:HTTP_METHODS: ' \
|
||||
'--data=[Request'\''s Body; can read data from a file if input starts with an @ (ex: @post.bin)]:DATA: ' \
|
||||
'*-H+[Specify HTTP headers to be used in each request (ex: -H Header:val -H '\''stuff: things'\'')]:HEADER: ' \
|
||||
'*--headers=[Specify HTTP headers to be used in each request (ex: -H Header:val -H '\''stuff: things'\'')]:HEADER: ' \
|
||||
'*-b+[Specify HTTP cookies to be used in each request (ex: -b stuff=things)]:COOKIE: ' \
|
||||
'*--cookies=[Specify HTTP cookies to be used in each request (ex: -b stuff=things)]:COOKIE: ' \
|
||||
'*-Q+[Request'\''s URL query parameters (ex: -Q token=stuff -Q secret=key)]:QUERY: ' \
|
||||
'*--query=[Request'\''s URL query parameters (ex: -Q token=stuff -Q secret=key)]:QUERY: ' \
|
||||
'*--dont-scan=[URL(s) or Regex Pattern(s) to exclude from recursion/scans]:URL: ' \
|
||||
'*-S+[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]:SIZE: ' \
|
||||
'*--filter-size=[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]:SIZE: ' \
|
||||
'*-X+[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]:REGEX: ' \
|
||||
'*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]:REGEX: ' \
|
||||
'*-W+[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]:WORDS: ' \
|
||||
'*--filter-words=[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]:WORDS: ' \
|
||||
'*-N+[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]:LINES: ' \
|
||||
'*--filter-lines=[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]:LINES: ' \
|
||||
'*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
|
||||
'*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]:STATUS_CODE: ' \
|
||||
'*--filter-similar-to=[Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)]:UNWANTED_PAGE:_urls' \
|
||||
'*-s+[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]:STATUS_CODE: ' \
|
||||
'*--status-codes=[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]:STATUS_CODE: ' \
|
||||
'-T+[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
|
||||
'--timeout=[Number of seconds before a client'\''s request times out (default: 7)]:SECONDS: ' \
|
||||
'-t+[Number of concurrent threads (default: 50)]:THREADS: ' \
|
||||
'--threads=[Number of concurrent threads (default: 50)]:THREADS: ' \
|
||||
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]:RECURSION_DEPTH: ' \
|
||||
'--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]:RECURSION_DEPTH: ' \
|
||||
'-L+[Limit total number of concurrent scans (default: 0, i.e. no limit)]:SCAN_LIMIT: ' \
|
||||
'--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]:SCAN_LIMIT: ' \
|
||||
'--parallel=[Run parallel feroxbuster instances (one child process per url passed via stdin)]:PARALLEL_SCANS: ' \
|
||||
'(--auto-tune)--rate-limit=[Limit number of requests per second (per directory) (default: 0, i.e. no limit)]:RATE_LIMIT: ' \
|
||||
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]:TIME_SPEC: ' \
|
||||
'-w+[Path to the wordlist]:FILE:_files' \
|
||||
'--wordlist=[Path to the wordlist]:FILE:_files' \
|
||||
'*-I+[File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)]:FILE_EXTENSION: ' \
|
||||
'*--dont-collect=[File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)]:FILE_EXTENSION: ' \
|
||||
'-o+[Output file to write results to (use w/ --json for JSON entries)]:FILE:_files' \
|
||||
'--output=[Output file to write results to (use w/ --json for JSON entries)]:FILE:_files' \
|
||||
'--debug-log=[Output file to write log entries (use w/ --json for JSON entries)]:FILE:_files' \
|
||||
'-h[Print help information]' \
|
||||
'--help[Print help information]' \
|
||||
'-V[Print version information]' \
|
||||
'--version[Print version information]' \
|
||||
'(-u --url)--stdin[Read url(s) from STDIN]' \
|
||||
'(-p --proxy -k --insecure --burp-replay)--burp[Set --proxy to http://127.0.0.1:8080 and set --insecure to true]' \
|
||||
'(-P --replay-proxy -k --insecure)--burp-replay[Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true]' \
|
||||
'--smart[Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true]' \
|
||||
'--thorough[Use the same settings as --smart and set --collect-extensions to true]' \
|
||||
'-A[Use a random User-Agent]' \
|
||||
'--random-agent[Use a random User-Agent]' \
|
||||
'-f[Append / to each request'\''s URL]' \
|
||||
'--add-slash[Append / to each request'\''s URL]' \
|
||||
'-r[Allow client to follow redirects]' \
|
||||
'--redirects[Allow client to follow redirects]' \
|
||||
'-k[Disables TLS certificate validation in the client]' \
|
||||
'--insecure[Disables TLS certificate validation in the client]' \
|
||||
'-n[Do not scan recursively]' \
|
||||
'--no-recursion[Do not scan recursively]' \
|
||||
'(-x --extensions)-f[Append / to each request]' \
|
||||
'(-x --extensions)--add-slash[Append / to each request]' \
|
||||
'(-u --url)--stdin[Read url(s) from STDIN]' \
|
||||
'-e[Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)]' \
|
||||
'--extract-links[Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)]' \
|
||||
'-h[Prints help information]' \
|
||||
'--help[Prints help information]' \
|
||||
'-V[Prints version information]' \
|
||||
'--version[Prints version information]' \
|
||||
'-e[Extract links from response body (html, javascript, etc...); make new requests based on findings]' \
|
||||
'--extract-links[Extract links from response body (html, javascript, etc...); make new requests based on findings]' \
|
||||
'(--auto-bail)--auto-tune[Automatically lower scan rate when an excessive amount of errors are encountered]' \
|
||||
'--auto-bail[Automatically stop scanning when an excessive amount of errors are encountered]' \
|
||||
'-D[Don'\''t auto-filter wildcard responses]' \
|
||||
'--dont-filter[Don'\''t auto-filter wildcard responses]' \
|
||||
'-E[Automatically discover extensions and add them to --extensions (unless they'\''re in --dont-collect)]' \
|
||||
'--collect-extensions[Automatically discover extensions and add them to --extensions (unless they'\''re in --dont-collect)]' \
|
||||
'-B[Automatically request likely backup extensions for "found" urls]' \
|
||||
'--collect-backups[Automatically request likely backup extensions for "found" urls]' \
|
||||
'-g[Automatically discover important words from within responses and add them to the wordlist]' \
|
||||
'--collect-words[Automatically discover important words from within responses and add them to the wordlist]' \
|
||||
'(--silent)*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
|
||||
'(--silent)*--verbosity[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
|
||||
'(-q --quiet)--silent[Only print URLs + turn off logging (good for piping a list of urls to other commands)]' \
|
||||
'-q[Hide progress bars and banner (good for tmux windows w/ notifications)]' \
|
||||
'--quiet[Hide progress bars and banner (good for tmux windows w/ notifications)]' \
|
||||
'--json[Emit JSON logs to --output and --debug-log instead of normal text]' \
|
||||
'--no-state[Disable state output file (*.state)]' \
|
||||
&& ret=0
|
||||
|
||||
}
|
||||
|
||||
(( $+functions[_feroxbuster_commands] )) ||
|
||||
_feroxbuster_commands() {
|
||||
local commands; commands=(
|
||||
|
||||
)
|
||||
local commands; commands=()
|
||||
_describe -t commands 'feroxbuster commands' commands "$@"
|
||||
}
|
||||
|
||||
_feroxbuster "$@"
|
||||
_feroxbuster "$@"
|
||||
|
||||
@@ -12,7 +12,8 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
|
||||
$element = $commandElements[$i]
|
||||
if ($element -isnot [StringConstantExpressionAst] -or
|
||||
$element.StringConstantType -ne [StringConstantType]::BareWord -or
|
||||
$element.Value.StartsWith('-')) {
|
||||
$element.Value.StartsWith('-') -or
|
||||
$element.Value -eq $wordToComplete) {
|
||||
break
|
||||
}
|
||||
$element.Value
|
||||
@@ -20,36 +21,29 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
|
||||
|
||||
$completions = @(switch ($command) {
|
||||
'feroxbuster' {
|
||||
[CompletionResult]::new('-w', 'w', [CompletionResultType]::ParameterName, 'Path to the wordlist')
|
||||
[CompletionResult]::new('--wordlist', 'wordlist', [CompletionResultType]::ParameterName, 'Path to the wordlist')
|
||||
[CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The target URL(s) (required, unless --stdin used)')
|
||||
[CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The target URL(s) (required, unless --stdin used)')
|
||||
[CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
|
||||
[CompletionResult]::new('--threads', 'threads', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
|
||||
[CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
|
||||
[CompletionResult]::new('--depth', 'depth', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
|
||||
[CompletionResult]::new('-T', 'T', [CompletionResultType]::ParameterName, 'Number of seconds before a request times out (default: 7)')
|
||||
[CompletionResult]::new('--timeout', 'timeout', [CompletionResultType]::ParameterName, 'Number of seconds before a request times out (default: 7)')
|
||||
[CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)')
|
||||
[CompletionResult]::new('--proxy', 'proxy', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)')
|
||||
[CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from] used)')
|
||||
[CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from] used)')
|
||||
[CompletionResult]::new('--resume-from', 'resume-from', [CompletionResultType]::ParameterName, 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)')
|
||||
[CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)')
|
||||
[CompletionResult]::new('--proxy', 'proxy', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)')
|
||||
[CompletionResult]::new('-P', 'P', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
|
||||
[CompletionResult]::new('--replay-proxy', 'replay-proxy', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
|
||||
[CompletionResult]::new('-R', 'R', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
|
||||
[CompletionResult]::new('--replay-codes', 'replay-codes', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
|
||||
[CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)')
|
||||
[CompletionResult]::new('--status-codes', 'status-codes', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)')
|
||||
[CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
|
||||
[CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
|
||||
[CompletionResult]::new('--resume-from', 'resume-from', [CompletionResultType]::ParameterName, 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)')
|
||||
[CompletionResult]::new('--debug-log', 'debug-log', [CompletionResultType]::ParameterName, 'Output file to write log entries (use w/ --json for JSON entries)')
|
||||
[CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/VERSION)')
|
||||
[CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/VERSION)')
|
||||
[CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.6.4)')
|
||||
[CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.6.4)')
|
||||
[CompletionResult]::new('-x', 'x', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)')
|
||||
[CompletionResult]::new('--extensions', 'extensions', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)')
|
||||
[CompletionResult]::new('-H', 'H', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')')
|
||||
[CompletionResult]::new('--headers', 'headers', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')')
|
||||
[CompletionResult]::new('-Q', 'Q', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)')
|
||||
[CompletionResult]::new('--query', 'query', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)')
|
||||
[CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Which HTTP request method(s) should be sent (default: GET)')
|
||||
[CompletionResult]::new('--methods', 'methods', [CompletionResultType]::ParameterName, 'Which HTTP request method(s) should be sent (default: GET)')
|
||||
[CompletionResult]::new('--data', 'data', [CompletionResultType]::ParameterName, 'Request''s Body; can read data from a file if input starts with an @ (ex: @post.bin)')
|
||||
[CompletionResult]::new('-H', 'H', [CompletionResultType]::ParameterName, 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')')
|
||||
[CompletionResult]::new('--headers', 'headers', [CompletionResultType]::ParameterName, 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')')
|
||||
[CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)')
|
||||
[CompletionResult]::new('--cookies', 'cookies', [CompletionResultType]::ParameterName, 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)')
|
||||
[CompletionResult]::new('-Q', 'Q', [CompletionResultType]::ParameterName, 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)')
|
||||
[CompletionResult]::new('--query', 'query', [CompletionResultType]::ParameterName, 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)')
|
||||
[CompletionResult]::new('--dont-scan', 'dont-scan', [CompletionResultType]::ParameterName, 'URL(s) or Regex Pattern(s) to exclude from recursion/scans')
|
||||
[CompletionResult]::new('-S', 'S', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
|
||||
[CompletionResult]::new('--filter-size', 'filter-size', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
|
||||
[CompletionResult]::new('-X', 'X', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')')
|
||||
@@ -60,31 +54,65 @@ Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
|
||||
[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('-v', 'v', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
|
||||
[CompletionResult]::new('--verbosity', 'verbosity', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
|
||||
[CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Only print URLs; Don''t print status codes, response size, running config, etc...')
|
||||
[CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Only print URLs; Don''t print status codes, response size, running config, etc...')
|
||||
[CompletionResult]::new('--json', 'json', [CompletionResultType]::ParameterName, 'Emit JSON logs to --output and --debug-log instead of normal text')
|
||||
[CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
|
||||
[CompletionResult]::new('--dont-filter', 'dont-filter', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
|
||||
[CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Follow redirects')
|
||||
[CompletionResult]::new('--redirects', 'redirects', [CompletionResultType]::ParameterName, 'Follow redirects')
|
||||
[CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation')
|
||||
[CompletionResult]::new('--insecure', 'insecure', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation')
|
||||
[CompletionResult]::new('-w', 'w', [CompletionResultType]::ParameterName, 'Path to the wordlist')
|
||||
[CompletionResult]::new('--wordlist', 'wordlist', [CompletionResultType]::ParameterName, 'Path to the wordlist')
|
||||
[CompletionResult]::new('-I', 'I', [CompletionResultType]::ParameterName, 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)')
|
||||
[CompletionResult]::new('--dont-collect', 'dont-collect', [CompletionResultType]::ParameterName, 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)')
|
||||
[CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
|
||||
[CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
|
||||
[CompletionResult]::new('--debug-log', 'debug-log', [CompletionResultType]::ParameterName, 'Output file to write log entries (use w/ --json for JSON entries)')
|
||||
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help information')
|
||||
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help information')
|
||||
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information')
|
||||
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information')
|
||||
[CompletionResult]::new('--stdin', 'stdin', [CompletionResultType]::ParameterName, 'Read url(s) from STDIN')
|
||||
[CompletionResult]::new('--burp', 'burp', [CompletionResultType]::ParameterName, 'Set --proxy to http://127.0.0.1:8080 and set --insecure to true')
|
||||
[CompletionResult]::new('--burp-replay', 'burp-replay', [CompletionResultType]::ParameterName, 'Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true')
|
||||
[CompletionResult]::new('--smart', 'smart', [CompletionResultType]::ParameterName, 'Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true')
|
||||
[CompletionResult]::new('--thorough', 'thorough', [CompletionResultType]::ParameterName, 'Use the same settings as --smart and set --collect-extensions to true')
|
||||
[CompletionResult]::new('-A', 'A', [CompletionResultType]::ParameterName, 'Use a random User-Agent')
|
||||
[CompletionResult]::new('--random-agent', 'random-agent', [CompletionResultType]::ParameterName, 'Use a random User-Agent')
|
||||
[CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Append / to each request''s URL')
|
||||
[CompletionResult]::new('--add-slash', 'add-slash', [CompletionResultType]::ParameterName, 'Append / to each request''s URL')
|
||||
[CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Allow client to follow redirects')
|
||||
[CompletionResult]::new('--redirects', 'redirects', [CompletionResultType]::ParameterName, 'Allow client to follow redirects')
|
||||
[CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation in the client')
|
||||
[CompletionResult]::new('--insecure', 'insecure', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation in the client')
|
||||
[CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Do not scan recursively')
|
||||
[CompletionResult]::new('--no-recursion', 'no-recursion', [CompletionResultType]::ParameterName, 'Do not scan recursively')
|
||||
[CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Append / to each request')
|
||||
[CompletionResult]::new('--add-slash', 'add-slash', [CompletionResultType]::ParameterName, 'Append / to each request')
|
||||
[CompletionResult]::new('--stdin', 'stdin', [CompletionResultType]::ParameterName, 'Read url(s) from STDIN')
|
||||
[CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)')
|
||||
[CompletionResult]::new('--extract-links', 'extract-links', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)')
|
||||
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Prints help information')
|
||||
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Prints help information')
|
||||
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Prints version information')
|
||||
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Prints version information')
|
||||
[CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings')
|
||||
[CompletionResult]::new('--extract-links', 'extract-links', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings')
|
||||
[CompletionResult]::new('--auto-tune', 'auto-tune', [CompletionResultType]::ParameterName, 'Automatically lower scan rate when an excessive amount of errors are encountered')
|
||||
[CompletionResult]::new('--auto-bail', 'auto-bail', [CompletionResultType]::ParameterName, 'Automatically stop scanning when an excessive amount of errors are encountered')
|
||||
[CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
|
||||
[CompletionResult]::new('--dont-filter', 'dont-filter', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
|
||||
[CompletionResult]::new('-E', 'E', [CompletionResultType]::ParameterName, 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)')
|
||||
[CompletionResult]::new('--collect-extensions', 'collect-extensions', [CompletionResultType]::ParameterName, 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)')
|
||||
[CompletionResult]::new('-B', 'B', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls')
|
||||
[CompletionResult]::new('--collect-backups', 'collect-backups', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls')
|
||||
[CompletionResult]::new('-g', 'g', [CompletionResultType]::ParameterName, 'Automatically discover important words from within responses and add them to the wordlist')
|
||||
[CompletionResult]::new('--collect-words', 'collect-words', [CompletionResultType]::ParameterName, 'Automatically discover important words from within responses and add them to the wordlist')
|
||||
[CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
|
||||
[CompletionResult]::new('--verbosity', 'verbosity', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
|
||||
[CompletionResult]::new('--silent', 'silent', [CompletionResultType]::ParameterName, 'Only print URLs + turn off logging (good for piping a list of urls to other commands)')
|
||||
[CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)')
|
||||
[CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Hide progress bars and banner (good for tmux windows w/ notifications)')
|
||||
[CompletionResult]::new('--json', 'json', [CompletionResultType]::ParameterName, 'Emit JSON logs to --output and --debug-log instead of normal text')
|
||||
[CompletionResult]::new('--no-state', 'no-state', [CompletionResultType]::ParameterName, 'Disable state output file (*.state)')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,10 +9,9 @@ _feroxbuster() {
|
||||
for i in ${COMP_WORDS[@]}
|
||||
do
|
||||
case "${i}" in
|
||||
feroxbuster)
|
||||
"$1")
|
||||
cmd="feroxbuster"
|
||||
;;
|
||||
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
@@ -20,90 +19,17 @@ _feroxbuster() {
|
||||
|
||||
case "${cmd}" in
|
||||
feroxbuster)
|
||||
opts=" -v -q -D -r -k -n -f -e -h -V -w -u -t -d -T -p -P -R -s -o -a -x -H -Q -S -X -W -N -C -L --verbosity --quiet --json --dont-filter --redirects --insecure --no-recursion --add-slash --stdin --extract-links --help --version --wordlist --url --threads --depth --timeout --proxy --replay-proxy --replay-codes --status-codes --output --resume-from --debug-log --user-agent --extensions --headers --query --filter-size --filter-regex --filter-words --filter-lines --filter-status --scan-limit --time-limit "
|
||||
opts="-h -V -u -p -P -R -a -A -x -m -H -b -Q -f -S -X -W -N -C -s -T -r -k -t -n -d -e -L -w -D -E -B -g -I -v -q -o --help --version --url --stdin --resume-from --burp --burp-replay --smart --thorough --proxy --replay-proxy --replay-codes --user-agent --random-agent --extensions --methods --data --headers --cookies --query --add-slash --dont-scan --filter-size --filter-regex --filter-words --filter-lines --filter-status --filter-similar-to --status-codes --timeout --redirects --insecure --threads --no-recursion --depth --extract-links --scan-limit --parallel --rate-limit --time-limit --wordlist --auto-tune --auto-bail --dont-filter --collect-extensions --collect-backups --collect-words --dont-collect --verbosity --silent --quiet --json --output --debug-log --no-state"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
|
||||
--wordlist)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-w)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--url)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-u)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--threads)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-t)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--depth)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-d)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--timeout)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-T)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--proxy)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-p)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--replay-proxy)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-P)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--replay-codes)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-R)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--status-codes)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-s)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
-u)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -111,7 +37,27 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--debug-log)
|
||||
--proxy)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-p)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--replay-proxy)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-P)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--replay-codes)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-R)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -119,7 +65,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-a)
|
||||
-a)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -127,7 +73,19 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-x)
|
||||
-x)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--methods)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-m)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--data)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -135,7 +93,15 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-H)
|
||||
-H)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--cookies)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-b)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -143,7 +109,11 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-Q)
|
||||
-Q)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--dont-scan)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -151,7 +121,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-S)
|
||||
-S)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -159,7 +129,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-X)
|
||||
-X)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -167,7 +137,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-W)
|
||||
-W)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -175,7 +145,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-N)
|
||||
-N)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -183,7 +153,43 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-C)
|
||||
-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
|
||||
;;
|
||||
@@ -191,7 +197,15 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-L)
|
||||
-L)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--parallel)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--rate-limit)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -199,6 +213,34 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--wordlist)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-w)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--dont-collect)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-I)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--debug-log)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
@@ -206,8 +248,7 @@ _feroxbuster() {
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _feroxbuster -o bashdefault -o default feroxbuster
|
||||
complete -F _feroxbuster -o bashdefault -o default -o plusdirs feroxbuster
|
||||
|
||||
116
shell_completions/feroxbuster.elv
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
use builtin;
|
||||
use str;
|
||||
|
||||
set edit:completion:arg-completer[feroxbuster] = {|@words|
|
||||
fn spaces {|n|
|
||||
builtin:repeat $n ' ' | str:join ''
|
||||
}
|
||||
fn cand {|text desc|
|
||||
edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc
|
||||
}
|
||||
var command = 'feroxbuster'
|
||||
for word $words[1..-1] {
|
||||
if (str:has-prefix $word '-') {
|
||||
break
|
||||
}
|
||||
set command = $command';'$word
|
||||
}
|
||||
var completions = [
|
||||
&'feroxbuster'= {
|
||||
cand -u 'The target URL (required, unless [--stdin || --resume-from] used)'
|
||||
cand --url 'The target URL (required, unless [--stdin || --resume-from] used)'
|
||||
cand --resume-from 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)'
|
||||
cand -p 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)'
|
||||
cand --proxy 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)'
|
||||
cand -P 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
|
||||
cand --replay-proxy 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
|
||||
cand -R 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
|
||||
cand --replay-codes 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
|
||||
cand -a 'Sets the User-Agent (default: feroxbuster/2.6.4)'
|
||||
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.6.4)'
|
||||
cand -x 'File extension(s) to search for (ex: -x php -x pdf js)'
|
||||
cand --extensions 'File extension(s) to search for (ex: -x php -x pdf js)'
|
||||
cand -m 'Which HTTP request method(s) should be sent (default: GET)'
|
||||
cand --methods 'Which HTTP request method(s) should be sent (default: GET)'
|
||||
cand --data 'Request''s Body; can read data from a file if input starts with an @ (ex: @post.bin)'
|
||||
cand -H 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')'
|
||||
cand --headers 'Specify HTTP headers to be used in each request (ex: -H Header:val -H ''stuff: things'')'
|
||||
cand -b 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)'
|
||||
cand --cookies 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)'
|
||||
cand -Q 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)'
|
||||
cand --query 'Request''s URL query parameters (ex: -Q token=stuff -Q secret=key)'
|
||||
cand --dont-scan 'URL(s) or Regex Pattern(s) to exclude from recursion/scans'
|
||||
cand -S 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
|
||||
cand --filter-size 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
|
||||
cand -X 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')'
|
||||
cand --filter-regex 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')'
|
||||
cand -W 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
|
||||
cand --filter-words 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
|
||||
cand -N 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
|
||||
cand --filter-lines 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
|
||||
cand -C 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
|
||||
cand --filter-status 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
|
||||
cand --filter-similar-to 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)'
|
||||
cand -s 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)'
|
||||
cand --status-codes 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)'
|
||||
cand -T 'Number of seconds before a client''s request times out (default: 7)'
|
||||
cand --timeout 'Number of seconds before a client''s request times out (default: 7)'
|
||||
cand -t 'Number of concurrent threads (default: 50)'
|
||||
cand --threads 'Number of concurrent threads (default: 50)'
|
||||
cand -d 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)'
|
||||
cand --depth 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)'
|
||||
cand -L 'Limit total number of concurrent scans (default: 0, i.e. no limit)'
|
||||
cand --scan-limit 'Limit total number of concurrent scans (default: 0, i.e. no limit)'
|
||||
cand --parallel 'Run parallel feroxbuster instances (one child process per url passed via stdin)'
|
||||
cand --rate-limit 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)'
|
||||
cand --time-limit 'Limit total run time of all scans (ex: --time-limit 10m)'
|
||||
cand -w 'Path to the wordlist'
|
||||
cand --wordlist 'Path to the wordlist'
|
||||
cand -I 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)'
|
||||
cand --dont-collect 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)'
|
||||
cand -o 'Output file to write results to (use w/ --json for JSON entries)'
|
||||
cand --output 'Output file to write results to (use w/ --json for JSON entries)'
|
||||
cand --debug-log 'Output file to write log entries (use w/ --json for JSON entries)'
|
||||
cand -h 'Print help information'
|
||||
cand --help 'Print help information'
|
||||
cand -V 'Print version information'
|
||||
cand --version 'Print version information'
|
||||
cand --stdin 'Read url(s) from STDIN'
|
||||
cand --burp 'Set --proxy to http://127.0.0.1:8080 and set --insecure to true'
|
||||
cand --burp-replay 'Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true'
|
||||
cand --smart 'Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true'
|
||||
cand --thorough 'Use the same settings as --smart and set --collect-extensions to true'
|
||||
cand -A 'Use a random User-Agent'
|
||||
cand --random-agent 'Use a random User-Agent'
|
||||
cand -f 'Append / to each request''s URL'
|
||||
cand --add-slash 'Append / to each request''s URL'
|
||||
cand -r 'Allow client to follow redirects'
|
||||
cand --redirects 'Allow client to follow redirects'
|
||||
cand -k 'Disables TLS certificate validation in the client'
|
||||
cand --insecure 'Disables TLS certificate validation in the client'
|
||||
cand -n 'Do not scan recursively'
|
||||
cand --no-recursion 'Do not scan recursively'
|
||||
cand -e 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
|
||||
cand --extract-links 'Extract links from response body (html, javascript, etc...); make new requests based on findings'
|
||||
cand --auto-tune 'Automatically lower scan rate when an excessive amount of errors are encountered'
|
||||
cand --auto-bail 'Automatically stop scanning when an excessive amount of errors are encountered'
|
||||
cand -D 'Don''t auto-filter wildcard responses'
|
||||
cand --dont-filter 'Don''t auto-filter wildcard responses'
|
||||
cand -E 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)'
|
||||
cand --collect-extensions 'Automatically discover extensions and add them to --extensions (unless they''re in --dont-collect)'
|
||||
cand -B 'Automatically request likely backup extensions for "found" urls'
|
||||
cand --collect-backups 'Automatically request likely backup extensions for "found" urls'
|
||||
cand -g 'Automatically discover important words from within responses and add them to the wordlist'
|
||||
cand --collect-words 'Automatically discover important words from within responses and add them to the wordlist'
|
||||
cand -v 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)'
|
||||
cand --verbosity 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)'
|
||||
cand --silent 'Only print URLs + turn off logging (good for piping a list of urls to other commands)'
|
||||
cand -q 'Hide progress bars and banner (good for tmux windows w/ notifications)'
|
||||
cand --quiet 'Hide progress bars and banner (good for tmux windows w/ notifications)'
|
||||
cand --json 'Emit JSON logs to --output and --debug-log instead of normal text'
|
||||
cand --no-state 'Disable state output file (*.state)'
|
||||
}
|
||||
]
|
||||
$completions[$command]
|
||||
}
|
||||
@@ -3,7 +3,7 @@ complete -c feroxbuster -n "__fish_use_subcommand" -s u -l url -d 'The target UR
|
||||
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://host:port)'
|
||||
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)'
|
||||
@@ -12,19 +12,30 @@ complete -c feroxbuster -n "__fish_use_subcommand" -l resume-from -d 'State file
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l debug-log -d 'Output file to write log entries (use w/ --json for JSON entries)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/VERSION)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s x -l extensions -d 'File extension(s) to search for (ex: -x php -x pdf js)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s m -l methods -d 'HTTP request method(s) (default: GET)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l data -d 'HTTP Body data; can read data from a file if input starts with an @ (ex: @post.bin)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l dont-scan -d 'URL(s) or Regex Pattern(s) to exclude from recursion/scans'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s H -l headers -d 'Specify HTTP headers (ex: -H Header:val \'stuff: things\')'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s b -l cookies -d 'Specify HTTP cookies (ex: -b stuff=things)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s Q -l query -d 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s S -l filter-size -d 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s X -l filter-regex -d 'Filter out messages via regular expression matching on the response\'s body (ex: -X \'^ignore me$\')'
|
||||
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" -s q -l quiet -d 'Only print URLs; Don\'t print status codes, response size, running config, etc...'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l silent -d 'Only print URLs + turn off logging (good for piping a list of urls to other commands)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s q -l quiet -d 'Hide progress bars and banner (good for tmux windows w/ notifications)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l auto-tune -d 'Automatically lower scan rate when an excessive amount of errors are encountered'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l auto-bail -d 'Automatically stop scanning when an excessive amount of errors are encountered'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l json -d 'Emit JSON logs to --output and --debug-log instead of normal text'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s D -l dont-filter -d 'Don\'t auto-filter wildcard responses'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s A -l random-agent -d 'Use a random User-Agent'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s r -l redirects -d 'Follow redirects'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s k -l insecure -d 'Disables TLS certificate validation'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s n -l no-recursion -d 'Do not scan recursively'
|
||||
|
||||
714
src/banner.rs
@@ -1,714 +0,0 @@
|
||||
use crate::config::{Configuration, CONFIGURATION};
|
||||
use crate::utils::{make_request, status_colorizer};
|
||||
use console::{style, Emoji};
|
||||
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
|
||||
}
|
||||
|
||||
/// Simple wrapper for emoji or fallback when terminal doesn't support emoji
|
||||
fn format_emoji(emoji: &str) -> String {
|
||||
let width = console::measure_text_width(emoji);
|
||||
let pad_len = width * width;
|
||||
let pad = format!("{:<pad_len$}", "\u{0020}", pad_len = pad_len);
|
||||
Emoji(emoji, &pad).to_string()
|
||||
}
|
||||
|
||||
/// Prints the banner to stdout.
|
||||
///
|
||||
/// Only prints those settings which are either always present, or passed in by the user.
|
||||
pub async fn initialize<W>(targets: &[String], config: &Configuration, version: &str, mut writer: W)
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
let artwork = format!(
|
||||
r#"
|
||||
___ ___ __ __ __ __ __ ___
|
||||
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
|
||||
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
|
||||
by Ben "epi" Risher {} ver: {}"#,
|
||||
Emoji("🤓", &format!("{:<2}", "\u{0020}")),
|
||||
version
|
||||
);
|
||||
let status = needs_update(&CONFIGURATION.client, UPDATE_URL, version).await;
|
||||
|
||||
let top = "───────────────────────────┬──────────────────────";
|
||||
let addl_section = "──────────────────────────────────────────────────";
|
||||
let bottom = "───────────────────────────┴──────────────────────";
|
||||
|
||||
writeln!(&mut writer, "{}", artwork).unwrap_or_default();
|
||||
writeln!(&mut writer, "{}", top).unwrap_or_default();
|
||||
|
||||
// begin with always printed items
|
||||
for target in targets {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🎯"), "Target Url", target)
|
||||
)
|
||||
.unwrap_or_default(); // 🎯
|
||||
}
|
||||
|
||||
let mut codes = vec![];
|
||||
|
||||
for code in &config.status_codes {
|
||||
codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🚀"), "Threads", config.threads)
|
||||
)
|
||||
.unwrap_or_default(); // 🚀
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("📖"), "Wordlist", config.wordlist)
|
||||
)
|
||||
.unwrap_or_default(); // 📖
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
format_emoji("🆗"),
|
||||
"Status Codes",
|
||||
format!("[{}]", codes.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🆗
|
||||
|
||||
if !config.filter_status.is_empty() {
|
||||
// exception here for optional print due to me wanting the allows and denys to be printed
|
||||
// one after the other
|
||||
let mut code_filters = vec![];
|
||||
|
||||
for code in &config.filter_status {
|
||||
code_filters.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
format_emoji("🗑"),
|
||||
"Status Code Filters",
|
||||
format!("[{}]", code_filters.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🗑
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💥"), "Timeout (secs)", config.timeout)
|
||||
)
|
||||
.unwrap_or_default(); // 💥
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🦡"), "User-Agent", config.user_agent)
|
||||
)
|
||||
.unwrap_or_default(); // 🦡
|
||||
|
||||
// followed by the maybe printed or variably displayed values
|
||||
if !config.config.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💉"), "Config File", config.config)
|
||||
)
|
||||
.unwrap_or_default(); // 💉
|
||||
}
|
||||
|
||||
if !config.proxy.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💎"), "Proxy", config.proxy)
|
||||
)
|
||||
.unwrap_or_default(); // 💎
|
||||
}
|
||||
|
||||
if !config.replay_proxy.is_empty() {
|
||||
// i include replay codes logic here because in config.rs, replay codes are set to the
|
||||
// value in status codes, meaning it's never empty
|
||||
|
||||
let mut replay_codes = vec![];
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🎥"), "Replay Proxy", config.replay_proxy)
|
||||
)
|
||||
.unwrap_or_default(); // 🎥
|
||||
|
||||
for code in &config.replay_codes {
|
||||
replay_codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
format_emoji("📼"),
|
||||
"Replay Proxy Codes",
|
||||
format!("[{}]", replay_codes.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 📼
|
||||
}
|
||||
|
||||
if !config.headers.is_empty() {
|
||||
for (name, value) in &config.headers {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🤯"), "Header", name, value)
|
||||
)
|
||||
.unwrap_or_default(); // 🤯
|
||||
}
|
||||
}
|
||||
|
||||
if !config.filter_size.is_empty() {
|
||||
for filter in &config.filter_size {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💢"), "Size Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
}
|
||||
|
||||
for filter in &config.filter_word_count {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💢"), "Word Count Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
for filter in &config.filter_line_count {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💢"), "Line Count Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
for filter in &config.filter_regex {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💢"), "Regex Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
if config.extract_links {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🔎"), "Extract Links", config.extract_links)
|
||||
)
|
||||
.unwrap_or_default(); // 🔎
|
||||
}
|
||||
|
||||
if config.json {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🧔"), "JSON Output", config.json)
|
||||
)
|
||||
.unwrap_or_default(); // 🧔
|
||||
}
|
||||
|
||||
if !config.queries.is_empty() {
|
||||
for query in &config.queries {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
format_emoji("🤔"),
|
||||
"Query Parameter",
|
||||
format!("{}={}", query.0, query.1)
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🤔
|
||||
}
|
||||
}
|
||||
|
||||
if !config.output.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("💾"), "Output File", config.output)
|
||||
)
|
||||
.unwrap_or_default(); // 💾
|
||||
}
|
||||
|
||||
if !config.debug_log.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🪲"), "Debugging Log", config.debug_log)
|
||||
)
|
||||
.unwrap_or_default(); // 🪲
|
||||
}
|
||||
|
||||
if !config.extensions.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
format_emoji("💲"),
|
||||
"Extensions",
|
||||
format!("[{}]", config.extensions.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 💲
|
||||
}
|
||||
|
||||
if config.insecure {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🔓"), "Insecure", config.insecure)
|
||||
)
|
||||
.unwrap_or_default(); // 🔓
|
||||
}
|
||||
|
||||
if config.redirects {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("📍"), "Follow Redirects", config.redirects)
|
||||
)
|
||||
.unwrap_or_default(); // 📍
|
||||
}
|
||||
|
||||
if config.dont_filter {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🤪"), "Filter Wildcards", !config.dont_filter)
|
||||
)
|
||||
.unwrap_or_default(); // 🤪
|
||||
}
|
||||
|
||||
match config.verbosity {
|
||||
//speaker medium volume (increasing with verbosity to loudspeaker)
|
||||
1 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🔈"), "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔈
|
||||
}
|
||||
2 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🔉"), "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔉
|
||||
}
|
||||
3 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🔊"), "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔊
|
||||
}
|
||||
4 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("📢"), "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 📢
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if config.add_slash {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🪓"), "Add Slash", config.add_slash)
|
||||
)
|
||||
.unwrap_or_default(); // 🪓
|
||||
}
|
||||
|
||||
if !config.no_recursion {
|
||||
if config.depth == 0 {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🔃"), "Recursion Depth", "INFINITE")
|
||||
)
|
||||
.unwrap_or_default(); // 🔃
|
||||
} else {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🔃"), "Recursion Depth", config.depth)
|
||||
)
|
||||
.unwrap_or_default(); // 🔃
|
||||
}
|
||||
} else {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🚫"), "Do Not Recurse", config.no_recursion)
|
||||
)
|
||||
.unwrap_or_default(); // 🚫
|
||||
}
|
||||
|
||||
if CONFIGURATION.scan_limit > 0 {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
format_emoji("🦥"),
|
||||
"Concurrent Scan Limit",
|
||||
config.scan_limit
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🦥
|
||||
}
|
||||
|
||||
if !CONFIGURATION.time_limit.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(format_emoji("🕖"), "Time Limit", config.time_limit)
|
||||
)
|
||||
.unwrap_or_default(); // 🕖
|
||||
}
|
||||
|
||||
if matches!(status, UpdateStatus::OutOfDate) {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
format_emoji("🎉"),
|
||||
"New Version Available",
|
||||
"https://github.com/epi052/feroxbuster/releases/latest"
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🎉
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", bottom).unwrap_or_default();
|
||||
// ⏯
|
||||
writeln!(
|
||||
&mut writer,
|
||||
" {} Press [{}] to {}|{} your scan",
|
||||
format_emoji("⏯"),
|
||||
style("ENTER").yellow(),
|
||||
style("pause").red(),
|
||||
style("resume").green()
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
writeln!(&mut writer, "{}", addl_section).unwrap_or_default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::VERSION;
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::MockServer;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::stderr;
|
||||
use std::time::Duration;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[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 = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
|
||||
});
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.1.0").await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(result, UpdateStatus::UpToDate));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url to needs_update that returns a newer version than current
|
||||
async fn banner_needs_update_returns_out_of_date() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
|
||||
});
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(result, UpdateStatus::OutOfDate));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url that times out
|
||||
async fn banner_needs_update_returns_unknown_on_timeout() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200)
|
||||
.body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.delay(Duration::from_secs(8));
|
||||
});
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url with bad json response
|
||||
async fn banner_needs_update_returns_unknown_on_bad_json_response() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200).body("not json");
|
||||
});
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url with json response that lacks the tag_name field
|
||||
async fn banner_needs_update_returns_unknown_on_json_without_correct_tag() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/latest");
|
||||
then.status(200)
|
||||
.body("{\"no tag_name\": \"doesn't exist\"}");
|
||||
});
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
}
|
||||
674
src/banner/container.rs
Normal file
@@ -0,0 +1,674 @@
|
||||
use super::entry::BannerEntry;
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
event_handlers::Handles,
|
||||
utils::{logged_request, status_colorizer},
|
||||
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, VERSION,
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use console::{style, Emoji};
|
||||
use reqwest::Url;
|
||||
use serde_json::Value;
|
||||
use std::{io::Write, sync::Arc};
|
||||
|
||||
/// Url used to query github's api; specifically used to look for the latest tagged release name
|
||||
pub const UPDATE_URL: &str = "https://api.github.com/repos/epi052/feroxbuster/releases/latest";
|
||||
|
||||
/// Simple enum to hold three different update states
|
||||
#[derive(Debug)]
|
||||
pub(super) enum UpdateStatus {
|
||||
/// this version and latest release are the same
|
||||
UpToDate,
|
||||
|
||||
/// this version and latest release are not the same
|
||||
OutOfDate,
|
||||
|
||||
/// some error occurred during version check
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Banner object, contains multiple BannerEntry's and knows how to display itself
|
||||
pub struct Banner {
|
||||
/// all live targets
|
||||
targets: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.status_codes
|
||||
status_codes: BannerEntry,
|
||||
|
||||
/// represents Configuration.filter_status
|
||||
filter_status: BannerEntry,
|
||||
|
||||
/// represents Configuration.threads
|
||||
threads: BannerEntry,
|
||||
|
||||
/// represents Configuration.wordlist
|
||||
wordlist: BannerEntry,
|
||||
|
||||
/// represents Configuration.timeout
|
||||
timeout: BannerEntry,
|
||||
|
||||
/// represents Configuration.user_agent
|
||||
user_agent: BannerEntry,
|
||||
|
||||
/// represents Configuration.random_agent
|
||||
random_agent: BannerEntry,
|
||||
|
||||
/// represents Configuration.config
|
||||
config: BannerEntry,
|
||||
|
||||
/// represents Configuration.proxy
|
||||
proxy: BannerEntry,
|
||||
|
||||
/// represents Configuration.replay_proxy
|
||||
replay_proxy: BannerEntry,
|
||||
|
||||
/// represents Configuration.replay_codes
|
||||
replay_codes: BannerEntry,
|
||||
|
||||
/// represents Configuration.headers
|
||||
headers: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_size
|
||||
filter_size: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_similar
|
||||
filter_similar: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_word_count
|
||||
filter_word_count: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_line_count
|
||||
filter_line_count: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.filter_regex
|
||||
filter_regex: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.extract_links
|
||||
extract_links: BannerEntry,
|
||||
|
||||
/// represents Configuration.json
|
||||
json: BannerEntry,
|
||||
|
||||
/// represents Configuration.output
|
||||
output: BannerEntry,
|
||||
|
||||
/// represents Configuration.debug_log
|
||||
debug_log: BannerEntry,
|
||||
|
||||
/// represents Configuration.extensions
|
||||
extensions: BannerEntry,
|
||||
|
||||
/// represents Configuration.methods
|
||||
methods: BannerEntry,
|
||||
|
||||
/// represents Configuration.data
|
||||
data: BannerEntry,
|
||||
|
||||
/// represents Configuration.insecure
|
||||
insecure: BannerEntry,
|
||||
|
||||
/// represents Configuration.redirects
|
||||
redirects: BannerEntry,
|
||||
|
||||
/// represents Configuration.dont_filter
|
||||
dont_filter: BannerEntry,
|
||||
|
||||
/// represents Configuration.queries
|
||||
queries: Vec<BannerEntry>,
|
||||
|
||||
/// represents Configuration.verbosity
|
||||
verbosity: BannerEntry,
|
||||
|
||||
/// represents Configuration.add_slash
|
||||
add_slash: BannerEntry,
|
||||
|
||||
/// represents Configuration.no_recursion
|
||||
no_recursion: BannerEntry,
|
||||
|
||||
/// represents Configuration.scan_limit
|
||||
scan_limit: BannerEntry,
|
||||
|
||||
/// represents Configuration.time_limit
|
||||
time_limit: BannerEntry,
|
||||
|
||||
/// represents Configuration.rate_limit
|
||||
rate_limit: BannerEntry,
|
||||
|
||||
/// represents Configuration.parallel
|
||||
parallel: BannerEntry,
|
||||
|
||||
/// represents Configuration.auto_tune
|
||||
auto_tune: BannerEntry,
|
||||
|
||||
/// represents Configuration.auto_bail
|
||||
auto_bail: BannerEntry,
|
||||
|
||||
/// represents Configuration.url_denylist
|
||||
url_denylist: Vec<BannerEntry>,
|
||||
|
||||
/// current version of feroxbuster
|
||||
pub(super) version: String,
|
||||
|
||||
/// whether or not there is a known new version
|
||||
pub(super) update_status: UpdateStatus,
|
||||
|
||||
/// represents Configuration.collect_extensions
|
||||
collect_extensions: BannerEntry,
|
||||
|
||||
/// represents Configuration.dont_collect
|
||||
dont_collect: BannerEntry,
|
||||
|
||||
/// represents Configuration.collect_backups
|
||||
collect_backups: BannerEntry,
|
||||
|
||||
/// represents Configuration.collect_words
|
||||
collect_words: BannerEntry,
|
||||
}
|
||||
|
||||
/// implementation of Banner
|
||||
impl Banner {
|
||||
/// Create a new Banner from a Configuration and live targets
|
||||
pub fn new(tgts: &[String], config: &Configuration) -> Self {
|
||||
let mut targets = Vec::new();
|
||||
let mut url_denylist = Vec::new();
|
||||
let mut code_filters = Vec::new();
|
||||
let mut replay_codes = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
let mut filter_size = Vec::new();
|
||||
let mut filter_similar = Vec::new();
|
||||
let mut filter_word_count = Vec::new();
|
||||
let mut filter_line_count = Vec::new();
|
||||
let mut filter_regex = Vec::new();
|
||||
let mut queries = Vec::new();
|
||||
|
||||
for target in tgts {
|
||||
targets.push(BannerEntry::new("🎯", "Target Url", target));
|
||||
}
|
||||
|
||||
for denied_url in &config.url_denylist {
|
||||
url_denylist.push(BannerEntry::new(
|
||||
"🚫",
|
||||
"Don't Scan Url",
|
||||
denied_url.as_str(),
|
||||
));
|
||||
}
|
||||
|
||||
for denied_regex in &config.regex_denylist {
|
||||
url_denylist.push(BannerEntry::new(
|
||||
"🚫",
|
||||
"Don't Scan Regex",
|
||||
denied_regex.as_str(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut codes = vec![];
|
||||
for code in &config.status_codes {
|
||||
codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
let status_codes =
|
||||
BannerEntry::new("👌", "Status Codes", &format!("[{}]", codes.join(", ")));
|
||||
|
||||
for code in &config.filter_status {
|
||||
code_filters.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
let filter_status = BannerEntry::new(
|
||||
"💢",
|
||||
"Status Code Filters",
|
||||
&format!("[{}]", code_filters.join(", ")),
|
||||
);
|
||||
|
||||
for code in &config.replay_codes {
|
||||
replay_codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
let replay_codes = BannerEntry::new(
|
||||
"📼",
|
||||
"Replay Proxy Codes",
|
||||
&format!("[{}]", replay_codes.join(", ")),
|
||||
);
|
||||
|
||||
for (name, value) in &config.headers {
|
||||
headers.push(BannerEntry::new(
|
||||
"🤯",
|
||||
"Header",
|
||||
&format!("{}: {}", name, value),
|
||||
));
|
||||
}
|
||||
|
||||
for filter in &config.filter_size {
|
||||
filter_size.push(BannerEntry::new("💢", "Size Filter", &filter.to_string()));
|
||||
}
|
||||
|
||||
for filter in &config.filter_similar {
|
||||
filter_similar.push(BannerEntry::new("💢", "Similarity Filter", filter));
|
||||
}
|
||||
|
||||
for filter in &config.filter_word_count {
|
||||
filter_word_count.push(BannerEntry::new(
|
||||
"💢",
|
||||
"Word Count Filter",
|
||||
&filter.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for filter in &config.filter_line_count {
|
||||
filter_line_count.push(BannerEntry::new(
|
||||
"💢",
|
||||
"Line Count Filter",
|
||||
&filter.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for filter in &config.filter_regex {
|
||||
filter_regex.push(BannerEntry::new("💢", "Regex Filter", filter));
|
||||
}
|
||||
|
||||
for query in &config.queries {
|
||||
queries.push(BannerEntry::new(
|
||||
"🤔",
|
||||
"Query Parameter",
|
||||
&format!("{}={}", query.0, query.1),
|
||||
));
|
||||
}
|
||||
|
||||
let volume = ["🔈", "🔉", "🔊", "📢"];
|
||||
let verbosity = if let 1..=4 = config.verbosity {
|
||||
//speaker medium volume (increasing with verbosity to loudspeaker)
|
||||
BannerEntry::new(
|
||||
volume[config.verbosity as usize - 1],
|
||||
"Verbosity",
|
||||
&config.verbosity.to_string(),
|
||||
)
|
||||
} else {
|
||||
BannerEntry::default()
|
||||
};
|
||||
|
||||
let no_recursion = if !config.no_recursion {
|
||||
let depth = if config.depth == 0 {
|
||||
"INFINITE".to_string()
|
||||
} else {
|
||||
config.depth.to_string()
|
||||
};
|
||||
|
||||
BannerEntry::new("🔃", "Recursion Depth", &depth)
|
||||
} else {
|
||||
BannerEntry::new("🚫", "Do Not Recurse", &config.no_recursion.to_string())
|
||||
};
|
||||
|
||||
let scan_limit = BannerEntry::new(
|
||||
"🦥",
|
||||
"Concurrent Scan Limit",
|
||||
&config.scan_limit.to_string(),
|
||||
);
|
||||
|
||||
let replay_proxy = BannerEntry::new("🎥", "Replay Proxy", &config.replay_proxy);
|
||||
let auto_tune = BannerEntry::new("🎶", "Auto Tune", &config.auto_tune.to_string());
|
||||
let auto_bail = BannerEntry::new("🪣", "Auto Bail", &config.auto_bail.to_string());
|
||||
let cfg = BannerEntry::new("💉", "Config File", &config.config);
|
||||
let proxy = BannerEntry::new("💎", "Proxy", &config.proxy);
|
||||
let threads = BannerEntry::new("🚀", "Threads", &config.threads.to_string());
|
||||
let wordlist = BannerEntry::new("📖", "Wordlist", &config.wordlist);
|
||||
let timeout = BannerEntry::new("💥", "Timeout (secs)", &config.timeout.to_string());
|
||||
let user_agent = BannerEntry::new("🦡", "User-Agent", &config.user_agent);
|
||||
let random_agent = BannerEntry::new("🦡", "User-Agent", "Random");
|
||||
let extract_links =
|
||||
BannerEntry::new("🔎", "Extract Links", &config.extract_links.to_string());
|
||||
let json = BannerEntry::new("🧔", "JSON Output", &config.json.to_string());
|
||||
let output = BannerEntry::new("💾", "Output File", &config.output);
|
||||
let debug_log = BannerEntry::new("🪲", "Debugging Log", &config.debug_log);
|
||||
let extensions = BannerEntry::new(
|
||||
"💲",
|
||||
"Extensions",
|
||||
&format!("[{}]", config.extensions.join(", ")),
|
||||
);
|
||||
let methods = BannerEntry::new(
|
||||
"🏁",
|
||||
"HTTP methods",
|
||||
&format!("[{}]", config.methods.join(", ")),
|
||||
);
|
||||
|
||||
let dont_collect = if config.dont_collect == DEFAULT_IGNORED_EXTENSIONS {
|
||||
// default has 30+ extensions, just trim it up
|
||||
BannerEntry::new(
|
||||
"💸",
|
||||
"Ignored Extensions",
|
||||
"[Images, Movies, Audio, etc...]",
|
||||
)
|
||||
} else {
|
||||
BannerEntry::new(
|
||||
"💸",
|
||||
"Ignored Extensions",
|
||||
&format!("[{}]", config.dont_collect.join(", ")),
|
||||
)
|
||||
};
|
||||
|
||||
let offset = std::cmp::min(config.data.len(), 30);
|
||||
let data = String::from_utf8(config.data[..offset].to_vec())
|
||||
.unwrap_or_else(|_err| {
|
||||
format!(
|
||||
"{:x?} ...",
|
||||
&config.data[..std::cmp::min(config.data.len(), 13)]
|
||||
)
|
||||
})
|
||||
.replace('\n', " ")
|
||||
.replace('\r', "");
|
||||
let data = BannerEntry::new("💣", "HTTP Body", &data);
|
||||
let insecure = BannerEntry::new("🔓", "Insecure", &config.insecure.to_string());
|
||||
let redirects = BannerEntry::new("📍", "Follow Redirects", &config.redirects.to_string());
|
||||
let dont_filter =
|
||||
BannerEntry::new("🤪", "Filter Wildcards", &(!config.dont_filter).to_string());
|
||||
let add_slash = BannerEntry::new("🪓", "Add Slash", &config.add_slash.to_string());
|
||||
let time_limit = BannerEntry::new("🕖", "Time Limit", &config.time_limit);
|
||||
let parallel = BannerEntry::new("🛤", "Parallel Scans", &config.parallel.to_string());
|
||||
let rate_limit =
|
||||
BannerEntry::new("🚧", "Requests per Second", &config.rate_limit.to_string());
|
||||
let collect_extensions = BannerEntry::new(
|
||||
"💰",
|
||||
"Collect Extensions",
|
||||
&config.collect_extensions.to_string(),
|
||||
);
|
||||
let collect_backups =
|
||||
BannerEntry::new("🏦", "Collect Backups", &config.collect_backups.to_string());
|
||||
|
||||
let collect_words =
|
||||
BannerEntry::new("🤑", "Collect Words", &config.collect_words.to_string());
|
||||
|
||||
Self {
|
||||
targets,
|
||||
status_codes,
|
||||
threads,
|
||||
wordlist,
|
||||
filter_status,
|
||||
timeout,
|
||||
user_agent,
|
||||
random_agent,
|
||||
auto_bail,
|
||||
auto_tune,
|
||||
proxy,
|
||||
replay_codes,
|
||||
replay_proxy,
|
||||
headers,
|
||||
filter_size,
|
||||
filter_similar,
|
||||
filter_word_count,
|
||||
filter_line_count,
|
||||
filter_regex,
|
||||
extract_links,
|
||||
parallel,
|
||||
json,
|
||||
queries,
|
||||
output,
|
||||
debug_log,
|
||||
extensions,
|
||||
methods,
|
||||
data,
|
||||
insecure,
|
||||
dont_filter,
|
||||
redirects,
|
||||
verbosity,
|
||||
add_slash,
|
||||
no_recursion,
|
||||
rate_limit,
|
||||
scan_limit,
|
||||
time_limit,
|
||||
url_denylist,
|
||||
collect_extensions,
|
||||
collect_backups,
|
||||
collect_words,
|
||||
dont_collect,
|
||||
config: cfg,
|
||||
version: VERSION.to_string(),
|
||||
update_status: UpdateStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// get a fancy header for the banner
|
||||
fn header(&self) -> String {
|
||||
let artwork = format!(
|
||||
r#"
|
||||
___ ___ __ __ __ __ __ ___
|
||||
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
|
||||
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
|
||||
by Ben "epi" Risher {} ver: {}"#,
|
||||
Emoji("🤓", &format!("{:<2}", "\u{0020}")),
|
||||
self.version
|
||||
);
|
||||
|
||||
let top = "───────────────────────────┬──────────────────────";
|
||||
|
||||
format!("{}\n{}", artwork, top)
|
||||
}
|
||||
|
||||
/// get a fancy footer for the banner
|
||||
fn footer(&self) -> String {
|
||||
let addl_section = "──────────────────────────────────────────────────";
|
||||
let bottom = "───────────────────────────┴──────────────────────";
|
||||
|
||||
let instructions = format!(
|
||||
" 🏁 Press [{}] to use the {}™",
|
||||
style("ENTER").yellow(),
|
||||
style("Scan Management Menu").bright().yellow(),
|
||||
);
|
||||
|
||||
format!("{}\n{}\n{}", bottom, instructions, addl_section)
|
||||
}
|
||||
|
||||
/// Makes a request to the given url, expecting to receive a JSON response that contains a field
|
||||
/// named `tag_name` that holds a value representing the latest tagged release of this tool.
|
||||
///
|
||||
/// ex: v1.1.0
|
||||
pub async fn check_for_updates(&mut self, url: &str, handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: needs_update({}, {:?})", url, handles);
|
||||
|
||||
let api_url = Url::parse(url)?;
|
||||
|
||||
let result = logged_request(&api_url, DEFAULT_METHOD, None, handles.clone()).await?;
|
||||
let body = result.text().await?;
|
||||
|
||||
let json_response: Value = serde_json::from_str(&body)?;
|
||||
|
||||
let latest_version = match json_response["tag_name"].as_str() {
|
||||
Some(tag) => tag.trim_start_matches('v'),
|
||||
None => {
|
||||
bail!("JSON has no tag_name: {}", json_response);
|
||||
}
|
||||
};
|
||||
|
||||
// if we've gotten this far, we have a string in the form of X.X.X where X is a number
|
||||
// all that's left is to compare the current version with the version found above
|
||||
|
||||
if latest_version == self.version {
|
||||
// there's really only two possible outcomes if we accept that the tag conforms to
|
||||
// the X.X.X pattern:
|
||||
// 1. the version strings match, meaning we're up to date
|
||||
// 2. the version strings do not match, meaning we're out of date
|
||||
//
|
||||
// except for developers working on this code, nobody should ever be in a situation
|
||||
// where they have a version greater than the latest tagged release
|
||||
self.update_status = UpdateStatus::UpToDate;
|
||||
} else {
|
||||
self.update_status = UpdateStatus::OutOfDate;
|
||||
}
|
||||
|
||||
log::trace!("exit: check_for_updates -> {:?}", self.update_status);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// display the banner on Write writer
|
||||
pub fn print_to<W>(&self, mut writer: W, config: Arc<Configuration>) -> Result<()>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
writeln!(&mut writer, "{}", self.header())?;
|
||||
|
||||
// begin with always printed items
|
||||
for target in &self.targets {
|
||||
writeln!(&mut writer, "{}", target)?;
|
||||
}
|
||||
|
||||
for denied_url in &self.url_denylist {
|
||||
writeln!(&mut writer, "{}", denied_url)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.threads)?;
|
||||
writeln!(&mut writer, "{}", self.wordlist)?;
|
||||
writeln!(&mut writer, "{}", self.status_codes)?;
|
||||
|
||||
if !config.filter_status.is_empty() {
|
||||
// exception here for an optional print in the middle of always printed values is due
|
||||
// to me wanting the allows and denys to be printed one after the other
|
||||
writeln!(&mut writer, "{}", self.filter_status)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.timeout)?;
|
||||
|
||||
if config.random_agent {
|
||||
writeln!(&mut writer, "{}", self.random_agent)?;
|
||||
} else {
|
||||
writeln!(&mut writer, "{}", self.user_agent)?;
|
||||
}
|
||||
|
||||
// followed by the maybe printed or variably displayed values
|
||||
if !config.config.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.config)?;
|
||||
}
|
||||
|
||||
if !config.proxy.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.proxy)?;
|
||||
}
|
||||
|
||||
if !config.replay_proxy.is_empty() {
|
||||
// i include replay codes logic here because in config.rs, replay codes are set to the
|
||||
// value in status codes, meaning it's never empty
|
||||
writeln!(&mut writer, "{}", self.replay_proxy)?;
|
||||
writeln!(&mut writer, "{}", self.replay_codes)?;
|
||||
}
|
||||
|
||||
for header in &self.headers {
|
||||
writeln!(&mut writer, "{}", header)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_size {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_similar {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_word_count {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_line_count {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
for filter in &self.filter_regex {
|
||||
writeln!(&mut writer, "{}", filter)?;
|
||||
}
|
||||
|
||||
if config.extract_links {
|
||||
writeln!(&mut writer, "{}", self.extract_links)?;
|
||||
}
|
||||
|
||||
if config.json {
|
||||
writeln!(&mut writer, "{}", self.json)?;
|
||||
}
|
||||
|
||||
for query in &self.queries {
|
||||
writeln!(&mut writer, "{}", query)?;
|
||||
}
|
||||
|
||||
if !config.output.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.output)?;
|
||||
}
|
||||
|
||||
if !config.debug_log.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.debug_log)?;
|
||||
}
|
||||
|
||||
if !config.extensions.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.extensions)?;
|
||||
}
|
||||
|
||||
if config.collect_extensions {
|
||||
// dont-collect is active only when collect-extensions is used
|
||||
writeln!(&mut writer, "{}", self.collect_extensions)?;
|
||||
writeln!(&mut writer, "{}", self.dont_collect)?;
|
||||
}
|
||||
|
||||
if config.collect_backups {
|
||||
writeln!(&mut writer, "{}", self.collect_backups)?;
|
||||
}
|
||||
|
||||
if config.collect_words {
|
||||
writeln!(&mut writer, "{}", self.collect_words)?;
|
||||
}
|
||||
|
||||
if !config.methods.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.methods)?;
|
||||
}
|
||||
|
||||
if !config.data.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.data)?;
|
||||
}
|
||||
|
||||
if config.insecure {
|
||||
writeln!(&mut writer, "{}", self.insecure)?;
|
||||
}
|
||||
|
||||
if config.auto_bail {
|
||||
writeln!(&mut writer, "{}", self.auto_bail)?;
|
||||
}
|
||||
if config.auto_tune {
|
||||
writeln!(&mut writer, "{}", self.auto_tune)?;
|
||||
}
|
||||
|
||||
if config.redirects {
|
||||
writeln!(&mut writer, "{}", self.redirects)?;
|
||||
}
|
||||
|
||||
if config.dont_filter {
|
||||
writeln!(&mut writer, "{}", self.dont_filter)?;
|
||||
}
|
||||
|
||||
if let 1..=4 = config.verbosity {
|
||||
writeln!(&mut writer, "{}", self.verbosity)?;
|
||||
}
|
||||
|
||||
if config.add_slash {
|
||||
writeln!(&mut writer, "{}", self.add_slash)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.no_recursion)?;
|
||||
|
||||
if config.scan_limit > 0 {
|
||||
writeln!(&mut writer, "{}", self.scan_limit)?;
|
||||
}
|
||||
|
||||
if config.parallel > 0 {
|
||||
writeln!(&mut writer, "{}", self.parallel)?;
|
||||
}
|
||||
|
||||
if config.rate_limit > 0 {
|
||||
writeln!(&mut writer, "{}", self.rate_limit)?;
|
||||
}
|
||||
|
||||
if !config.time_limit.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.time_limit)?;
|
||||
}
|
||||
|
||||
if matches!(self.update_status, UpdateStatus::OutOfDate) {
|
||||
let update = BannerEntry::new(
|
||||
"🎉",
|
||||
"New Version Available",
|
||||
"https://github.com/epi052/feroxbuster/releases/latest",
|
||||
);
|
||||
writeln!(&mut writer, "{}", update)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.footer())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
57
src/banner/entry.rs
Normal file
@@ -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,68 +13,32 @@ pub fn initialize(
|
||||
insecure: bool,
|
||||
headers: &HashMap<String, String>,
|
||||
proxy: Option<&str>,
|
||||
) -> Client {
|
||||
) -> Result<Client> {
|
||||
let policy = if redirects {
|
||||
Policy::limited(10)
|
||||
} else {
|
||||
Policy::none()
|
||||
};
|
||||
|
||||
// try_into returns infallible as its error, unwrap is safe here
|
||||
let header_map: HeaderMap = headers.try_into().unwrap();
|
||||
let header_map: HeaderMap = headers.try_into()?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::new(timeout, 0))
|
||||
.user_agent(user_agent)
|
||||
.danger_accept_invalid_certs(insecure)
|
||||
.default_headers(header_map)
|
||||
.redirect(policy);
|
||||
.redirect(policy)
|
||||
.http1_title_case_headers();
|
||||
|
||||
let client = match proxy {
|
||||
// a proxy is specified, need to add it to the client
|
||||
Some(some_proxy) => {
|
||||
if !some_proxy.is_empty() {
|
||||
// it's not an empty string
|
||||
match Proxy::all(some_proxy) {
|
||||
Ok(proxy_obj) => client.proxy(proxy_obj),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {} {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("Client::initialize"),
|
||||
e
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
panic!();
|
||||
#[cfg(not(test))]
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client // Some("") was used?
|
||||
}
|
||||
}
|
||||
// no proxy specified
|
||||
None => client,
|
||||
};
|
||||
|
||||
match client.build() {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {} {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("Client::build"),
|
||||
e
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
panic!();
|
||||
#[cfg(not(test))]
|
||||
exit(1);
|
||||
if let Some(some_proxy) = proxy {
|
||||
if !some_proxy.is_empty() {
|
||||
// it's not an empty string; set the proxy
|
||||
let proxy_obj = Proxy::all(some_proxy)?;
|
||||
return Ok(client.proxy(proxy_obj).build()?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(client.build()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -88,7 +50,7 @@ mod tests {
|
||||
/// create client with a bad proxy, expect panic
|
||||
fn client_with_bad_proxy() {
|
||||
let headers = HashMap::new();
|
||||
initialize(0, "stuff", true, false, &headers, Some("not a valid proxy"));
|
||||
initialize(0, "stuff", true, false, &headers, Some("not a valid proxy")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -96,6 +58,6 @@ mod tests {
|
||||
fn client_with_good_proxy() {
|
||||
let headers = HashMap::new();
|
||||
let proxy = "http://127.0.0.1:8080";
|
||||
initialize(0, "stuff", true, true, &headers, Some(proxy));
|
||||
initialize(0, "stuff", true, true, &headers, Some(proxy)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
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};
|
||||
497
src/config/tests.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
use super::utils::*;
|
||||
use super::*;
|
||||
use crate::{traits::FeroxSerialize, DEFAULT_CONFIG_NAME};
|
||||
use regex::Regex;
|
||||
use reqwest::Url;
|
||||
use std::{collections::HashMap, fs::write};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// creates a dummy configuration file for testing
|
||||
fn setup_config_test() -> Configuration {
|
||||
let data = r#"
|
||||
wordlist = "/some/path"
|
||||
status_codes = [201, 301, 401]
|
||||
replay_codes = [201, 301]
|
||||
threads = 40
|
||||
timeout = 5
|
||||
proxy = "http://127.0.0.1:8080"
|
||||
replay_proxy = "http://127.0.0.1:8081"
|
||||
quiet = true
|
||||
silent = true
|
||||
auto_tune = true
|
||||
auto_bail = true
|
||||
verbosity = 1
|
||||
scan_limit = 6
|
||||
parallel = 14
|
||||
rate_limit = 250
|
||||
time_limit = "10m"
|
||||
output = "/some/otherpath"
|
||||
debug_log = "/yet/anotherpath"
|
||||
resume_from = "/some/state/file"
|
||||
redirects = true
|
||||
insecure = true
|
||||
collect_backups = true
|
||||
collect_extensions = true
|
||||
collect_words = true
|
||||
extensions = ["html", "php", "js"]
|
||||
dont_collect = ["png", "gif", "jpg", "jpeg"]
|
||||
methods = ["GET", "PUT", "DELETE"]
|
||||
data = [31, 32, 33, 34]
|
||||
url_denylist = ["http://dont-scan.me", "https://also-not.me"]
|
||||
regex_denylist = ["/deny.*"]
|
||||
headers = {stuff = "things", mostuff = "mothings"}
|
||||
queries = [["name","value"], ["rick", "astley"]]
|
||||
no_recursion = true
|
||||
add_slash = true
|
||||
stdin = true
|
||||
dont_filter = true
|
||||
extract_links = true
|
||||
json = true
|
||||
save_state = false
|
||||
depth = 1
|
||||
filter_size = [4120]
|
||||
filter_regex = ["^ignore me$"]
|
||||
filter_similar = ["https://somesite.com/soft404"]
|
||||
filter_word_count = [994, 992]
|
||||
filter_line_count = [34]
|
||||
filter_status = [201]
|
||||
"#;
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let file = tmp_dir.path().join(DEFAULT_CONFIG_NAME);
|
||||
write(&file, data).unwrap();
|
||||
Configuration::parse_config(file).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that all default config values meet expectations
|
||||
fn default_configuration() {
|
||||
let config = Configuration::default();
|
||||
assert_eq!(config.wordlist, wordlist());
|
||||
assert_eq!(config.proxy, String::new());
|
||||
assert_eq!(config.target_url, String::new());
|
||||
assert_eq!(config.time_limit, String::new());
|
||||
assert_eq!(config.resume_from, String::new());
|
||||
assert_eq!(config.debug_log, String::new());
|
||||
assert_eq!(config.config, String::new());
|
||||
assert_eq!(config.replay_proxy, String::new());
|
||||
assert_eq!(config.status_codes, status_codes());
|
||||
assert_eq!(config.replay_codes, config.status_codes);
|
||||
assert!(config.replay_client.is_none());
|
||||
assert_eq!(config.threads, threads());
|
||||
assert_eq!(config.depth, depth());
|
||||
assert_eq!(config.timeout, timeout());
|
||||
assert_eq!(config.verbosity, 0);
|
||||
assert_eq!(config.scan_limit, 0);
|
||||
assert!(!config.silent);
|
||||
assert!(!config.quiet);
|
||||
assert_eq!(config.output_level, OutputLevel::Default);
|
||||
assert!(!config.dont_filter);
|
||||
assert!(!config.auto_tune);
|
||||
assert!(!config.auto_bail);
|
||||
assert_eq!(config.requester_policy, RequesterPolicy::Default);
|
||||
assert!(!config.no_recursion);
|
||||
assert!(!config.random_agent);
|
||||
assert!(!config.json);
|
||||
assert!(config.save_state);
|
||||
assert!(!config.stdin);
|
||||
assert!(!config.add_slash);
|
||||
assert!(!config.redirects);
|
||||
assert!(!config.extract_links);
|
||||
assert!(!config.insecure);
|
||||
assert!(!config.collect_extensions);
|
||||
assert!(!config.collect_backups);
|
||||
assert!(!config.collect_words);
|
||||
assert!(config.regex_denylist.is_empty());
|
||||
assert_eq!(config.queries, Vec::new());
|
||||
assert_eq!(config.filter_size, Vec::<u64>::new());
|
||||
assert_eq!(config.extensions, Vec::<String>::new());
|
||||
assert_eq!(config.methods, vec!["GET"]);
|
||||
assert_eq!(config.data, Vec::<u8>::new());
|
||||
assert_eq!(config.url_denylist, Vec::<Url>::new());
|
||||
assert_eq!(config.dont_collect, ignored_extensions());
|
||||
assert_eq!(config.filter_regex, Vec::<String>::new());
|
||||
assert_eq!(config.filter_similar, Vec::<String>::new());
|
||||
assert_eq!(config.filter_word_count, Vec::<usize>::new());
|
||||
assert_eq!(config.filter_line_count, Vec::<usize>::new());
|
||||
assert_eq!(config.filter_status, Vec::<u16>::new());
|
||||
assert_eq!(config.headers, HashMap::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_wordlist() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.wordlist, "/some/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_debug_log() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.debug_log, "/yet/anotherpath");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_status_codes() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.status_codes, vec![201, 301, 401]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_replay_codes() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.replay_codes, vec![201, 301]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_threads() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.threads, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_depth() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.depth, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_scan_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.scan_limit, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_parallel() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.parallel, 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_rate_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.rate_limit, 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_timeout() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.timeout, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_proxy() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.proxy, "http://127.0.0.1:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_replay_proxy() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.replay_proxy, "http://127.0.0.1:8081");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_silent() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.silent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_quiet() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.quiet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_json() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_auto_bail() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.auto_bail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_auto_tune() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.auto_tune);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_verbosity() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.verbosity, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_output() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.output, "/some/otherpath");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_redirects() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.redirects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_insecure() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.insecure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_no_recursion() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.no_recursion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_stdin() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.stdin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_dont_filter() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.dont_filter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_add_slash() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.add_slash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_extract_links() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.extract_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_collect_extensions() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.collect_extensions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_collect_backups() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.collect_backups);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_collect_words() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.collect_words);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_extensions() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.extensions, vec!["html", "php", "js"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_dont_collect() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.dont_collect, vec!["png", "gif", "jpg", "jpeg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_methods() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.methods, vec!["GET", "PUT", "DELETE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_data() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.data, vec![31, 32, 33, 34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_regex_denylist() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(
|
||||
config.regex_denylist[0].as_str(),
|
||||
Regex::new("/deny.*").unwrap().as_str()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_url_denylist() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(
|
||||
config.url_denylist,
|
||||
vec![
|
||||
Url::parse("http://dont-scan.me").unwrap(),
|
||||
Url::parse("https://also-not.me").unwrap(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_regex() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_regex, vec!["^ignore me$"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_similar() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_similar, vec!["https://somesite.com/soft404"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_size() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_size, vec![4120]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_word_count() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_word_count, vec![994, 992]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_line_count() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_line_count, vec![34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_filter_status() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.filter_status, vec![201]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_save_state() {
|
||||
let config = setup_config_test();
|
||||
assert!(!config.save_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_time_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.time_limit, "10m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_resume_from() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.resume_from, "/some/state/file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the values parsed are correct
|
||||
fn config_reads_headers() {
|
||||
let config = setup_config_test();
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("stuff".to_string(), "things".to_string());
|
||||
headers.insert("mostuff".to_string(), "mothings".to_string());
|
||||
assert_eq!(config.headers, headers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the values parsed are correct
|
||||
fn config_reads_queries() {
|
||||
let config = setup_config_test();
|
||||
let queries = vec![
|
||||
("name".to_string(), "value".to_string()),
|
||||
("rick".to_string(), "astley".to_string()),
|
||||
];
|
||||
assert_eq!(config.queries, queries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_default_not_random_agent() {
|
||||
let config = setup_config_test();
|
||||
assert!(!config.random_agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
/// test that an error message is printed and panic is called when report_and_exit is called
|
||||
fn config_report_and_exit_works() {
|
||||
report_and_exit("some message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_str method of Configuration
|
||||
fn as_str_returns_string_with_newline() {
|
||||
let config = Configuration::new().unwrap();
|
||||
let config_str = config.as_str();
|
||||
println!("{}", config_str);
|
||||
assert!(config_str.starts_with("Configuration {"));
|
||||
assert!(config_str.ends_with("}\n"));
|
||||
assert!(config_str.contains("replay_codes:"));
|
||||
assert!(config_str.contains("client: Client {"));
|
||||
assert!(config_str.contains("user_agent: \"feroxbuster"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_json method of Configuration
|
||||
fn as_json_returns_json_representation_of_configuration_with_newline() {
|
||||
let mut config = Configuration::new().unwrap();
|
||||
config.timeout = 12;
|
||||
config.depth = 2;
|
||||
let config_str = config.as_json().unwrap();
|
||||
let json: Configuration = serde_json::from_str(&config_str).unwrap();
|
||||
assert_eq!(json.config, config.config);
|
||||
assert_eq!(json.wordlist, config.wordlist);
|
||||
assert_eq!(json.replay_codes, config.replay_codes);
|
||||
assert_eq!(json.timeout, config.timeout);
|
||||
assert_eq!(json.depth, config.depth);
|
||||
}
|
||||
195
src/config/utils.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use crate::{
|
||||
utils::{module_colorizer, status_colorizer},
|
||||
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
use std::process::exit;
|
||||
|
||||
/// simple helper to clean up some code reuse below; panics under test / exits in prod
|
||||
pub(super) fn report_and_exit(err: &str) -> ! {
|
||||
eprintln!(
|
||||
"{} {}: {}",
|
||||
status_colorizer("ERROR"),
|
||||
module_colorizer("Configuration::new"),
|
||||
err
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
panic!();
|
||||
#[cfg(not(test))]
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// functions timeout, threads, status_codes, user_agent, wordlist, save_state, and depth are used to provide
|
||||
// defaults in the event that a ferox-config.toml is found but one or more of the values below
|
||||
// aren't listed in the config. This way, we get the correct defaults upon Deserialization
|
||||
|
||||
/// default Configuration type for use in json output
|
||||
pub(super) fn serialized_type() -> String {
|
||||
String::from("configuration")
|
||||
}
|
||||
|
||||
/// default timeout value
|
||||
pub(super) fn timeout() -> u64 {
|
||||
7
|
||||
}
|
||||
|
||||
/// default save_state value
|
||||
pub(super) fn save_state() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// default threads value
|
||||
pub(super) fn threads() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
/// default status codes
|
||||
pub(super) fn status_codes() -> Vec<u16> {
|
||||
DEFAULT_STATUS_CODES
|
||||
.iter()
|
||||
.map(|code| code.as_u16())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// default HTTP Method
|
||||
pub(super) fn methods() -> Vec<String> {
|
||||
vec![DEFAULT_METHOD.to_owned()]
|
||||
}
|
||||
|
||||
/// default extensions to ignore while auto-collecting
|
||||
pub(super) fn ignored_extensions() -> Vec<String> {
|
||||
DEFAULT_IGNORED_EXTENSIONS
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// default wordlist
|
||||
pub(super) fn wordlist() -> String {
|
||||
String::from(DEFAULT_WORDLIST)
|
||||
}
|
||||
|
||||
/// default user-agent
|
||||
pub(super) fn user_agent() -> String {
|
||||
format!("feroxbuster/{}", VERSION)
|
||||
}
|
||||
|
||||
/// default recursion depth
|
||||
pub(super) fn depth() -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
/// enum representing the three possible states for informational output (not logging verbosity)
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum OutputLevel {
|
||||
/// normal scan, no --quiet|--silent
|
||||
Default,
|
||||
|
||||
/// quiet scan, print some information, but not all (new in versions >= 2.0.0)
|
||||
Quiet,
|
||||
|
||||
/// silent scan, only print urls (used to be --quiet in versions 1.x.x)
|
||||
Silent,
|
||||
}
|
||||
|
||||
/// implement a default for OutputLevel
|
||||
impl Default for OutputLevel {
|
||||
/// return Default
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// given the current settings for quiet and silent, determine output_level (DRY helper)
|
||||
pub fn determine_output_level(quiet: bool, silent: bool) -> OutputLevel {
|
||||
if quiet && silent {
|
||||
// user COULD have both as true in config file, take the more quiet of the two
|
||||
OutputLevel::Silent
|
||||
} else if quiet {
|
||||
OutputLevel::Quiet
|
||||
} else if silent {
|
||||
OutputLevel::Silent
|
||||
} else {
|
||||
OutputLevel::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// represents actions the Requester should take in certain situations
|
||||
#[derive(Debug, PartialEq, Copy, Clone)]
|
||||
pub enum RequesterPolicy {
|
||||
/// automatically try to lower request rate in order to reduce errors
|
||||
AutoTune,
|
||||
|
||||
/// automatically bail at certain error thresholds
|
||||
AutoBail,
|
||||
|
||||
/// just let that junk run super natural
|
||||
Default,
|
||||
}
|
||||
|
||||
/// default implementation for RequesterPolicy
|
||||
impl Default for RequesterPolicy {
|
||||
/// Default as default
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// given the current settings for quiet and silent, determine output_level (DRY helper)
|
||||
pub fn determine_requester_policy(auto_tune: bool, auto_bail: bool) -> RequesterPolicy {
|
||||
if auto_tune && auto_bail {
|
||||
// user COULD have both as true in config file, take the more aggressive of the two
|
||||
RequesterPolicy::AutoBail
|
||||
} else if auto_tune {
|
||||
RequesterPolicy::AutoTune
|
||||
} else if auto_bail {
|
||||
RequesterPolicy::AutoBail
|
||||
} else {
|
||||
RequesterPolicy::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// test determine_output_level returns higher of the two levels if both given values are true
|
||||
fn determine_output_level_returns_correct_results() {
|
||||
let mut level = determine_output_level(true, true);
|
||||
assert_eq!(level, OutputLevel::Silent);
|
||||
|
||||
level = determine_output_level(false, true);
|
||||
assert_eq!(level, OutputLevel::Silent);
|
||||
|
||||
level = determine_output_level(false, false);
|
||||
assert_eq!(level, OutputLevel::Default);
|
||||
|
||||
level = determine_output_level(true, false);
|
||||
assert_eq!(level, OutputLevel::Quiet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test determine_requester_policy returns higher of the two levels if both given values are true
|
||||
fn determine_requester_policy_returns_correct_results() {
|
||||
let mut level = determine_requester_policy(true, true);
|
||||
assert_eq!(level, RequesterPolicy::AutoBail);
|
||||
|
||||
level = determine_requester_policy(false, true);
|
||||
assert_eq!(level, RequesterPolicy::AutoBail);
|
||||
|
||||
level = determine_requester_policy(false, false);
|
||||
assert_eq!(level, RequesterPolicy::Default);
|
||||
|
||||
level = determine_requester_policy(true, false);
|
||||
assert_eq!(level, RequesterPolicy::AutoTune);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
/// report_and_exit should panic/exit when called
|
||||
fn report_and_exit_panics_under_test() {
|
||||
report_and_exit("test");
|
||||
}
|
||||
}
|
||||
81
src/event_handlers/command.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use tokio::sync::oneshot::Sender;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::{
|
||||
message::FeroxMessage,
|
||||
statistics::{StatError, StatField},
|
||||
traits::FeroxFilter,
|
||||
};
|
||||
|
||||
/// Protocol definition for updating an event handler via mpsc
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
/// Add one to the total number of requests
|
||||
AddRequest,
|
||||
|
||||
/// Add one to the proper field(s) based on the given `StatError`
|
||||
AddError(StatError),
|
||||
|
||||
/// Add one to the proper field(s) based on the given `StatusCode`
|
||||
AddStatus(StatusCode),
|
||||
|
||||
/// Create the progress bar (`BarType::Total`) that is updated from the stats thread
|
||||
CreateBar,
|
||||
|
||||
/// Add to a `Stats` field that corresponds to the given `StatField` by the given `usize` value
|
||||
AddToUsizeField(StatField, usize),
|
||||
|
||||
/// Subtract from a `Stats` field that corresponds to the given `StatField` by the given `usize` value
|
||||
SubtractFromUsizeField(StatField, usize),
|
||||
|
||||
/// Update a `Stats` field that corresponds to the given `StatField` by the given `f64` value
|
||||
AddToF64Field(StatField, f64),
|
||||
|
||||
/// Save a `Stats` object to disk using `reporter::get_cached_file_handle`
|
||||
Save,
|
||||
|
||||
/// Load a `Stats` object from disk
|
||||
LoadStats(String),
|
||||
|
||||
/// Add a `FeroxFilter` implementor to `FilterHandler`'s instance of `FeroxFilters`
|
||||
AddFilter(Box<dyn FeroxFilter>),
|
||||
|
||||
/// Remove a set of `FeroxFilter` implementors from `FeroxFilters` by index
|
||||
RemoveFilters(Vec<usize>),
|
||||
|
||||
/// Send a `FeroxResponse` to the output handler for reporting
|
||||
Report(Box<FeroxResponse>),
|
||||
|
||||
/// Send a group of urls to be scanned (only used for the urls passed in explicitly by the user)
|
||||
ScanInitialUrls(Vec<String>),
|
||||
|
||||
/// Send a single url to be scanned (presumably added from the interactive menu)
|
||||
ScanNewUrl(String),
|
||||
|
||||
/// Determine whether or not recursion is appropriate, given a FeroxResponse, if so start a scan
|
||||
TryRecursion(Box<FeroxResponse>),
|
||||
|
||||
/// Send a pointer to the wordlist to the recursion handler
|
||||
UpdateWordlist(Arc<Vec<String>>),
|
||||
|
||||
/// Instruct the ScanHandler to join on all known scans, use sender to notify main when done
|
||||
JoinTasks(Sender<bool>),
|
||||
|
||||
/// Command used to test that a spawned task succeeded in initialization
|
||||
Ping,
|
||||
|
||||
/// Just receive a sender and reply, used for slowing down the main thread
|
||||
Sync(Sender<bool>),
|
||||
|
||||
/// Notify event handler that a new extension has been seen
|
||||
AddDiscoveredExtension(String),
|
||||
|
||||
/// Write an arbitrary string to disk
|
||||
WriteToDisk(Box<FeroxMessage>),
|
||||
|
||||
/// Break out of the (infinite) mpsc receive loop
|
||||
Exit,
|
||||
}
|
||||
182
src/event_handlers/container.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use super::*;
|
||||
use crate::config::Configuration;
|
||||
use crate::event_handlers::scans::ScanHandle;
|
||||
use crate::scan_manager::FeroxScans;
|
||||
use crate::Joiner;
|
||||
#[cfg(test)]
|
||||
use crate::{filters::FeroxFilters, statistics::Stats, Command};
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
#[cfg(test)]
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Simple container for multiple JoinHandles
|
||||
pub struct Tasks {
|
||||
/// JoinHandle for terminal handler
|
||||
pub terminal: Joiner,
|
||||
|
||||
/// JoinHandle for statistics handler
|
||||
pub stats: Joiner,
|
||||
|
||||
/// JoinHandle for filters handler
|
||||
pub filters: Joiner,
|
||||
|
||||
/// JoinHandle for scans handler
|
||||
pub scans: Joiner,
|
||||
}
|
||||
|
||||
/// Tasks implementation
|
||||
impl Tasks {
|
||||
/// Given JoinHandles for terminal, statistics, and filters create a new Tasks object
|
||||
pub fn new(terminal: Joiner, stats: Joiner, filters: Joiner, scans: Joiner) -> Self {
|
||||
Self {
|
||||
terminal,
|
||||
stats,
|
||||
filters,
|
||||
scans,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for the different *Handles that will be shared across modules
|
||||
pub struct Handles {
|
||||
/// Handle for statistics
|
||||
pub stats: StatsHandle,
|
||||
|
||||
/// Handle for filters
|
||||
pub filters: FiltersHandle,
|
||||
|
||||
/// Handle for output (terminal/file)
|
||||
pub output: TermOutHandle,
|
||||
|
||||
/// Handle for Configuration
|
||||
pub config: Arc<Configuration>,
|
||||
|
||||
/// Handle for recursion
|
||||
pub scans: RwLock<Option<ScanHandle>>,
|
||||
|
||||
/// Pointer to the list of words generated from reading in the wordlist
|
||||
pub wordlist: Arc<Vec<String>>,
|
||||
}
|
||||
|
||||
/// implementation of Handles
|
||||
impl Handles {
|
||||
/// Given a StatsHandle, FiltersHandle, and OutputHandle, create a Handles object
|
||||
pub fn new(
|
||||
stats: StatsHandle,
|
||||
filters: FiltersHandle,
|
||||
output: TermOutHandle,
|
||||
config: Arc<Configuration>,
|
||||
wordlist: Arc<Vec<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
stats,
|
||||
filters,
|
||||
output,
|
||||
config,
|
||||
scans: RwLock::new(None),
|
||||
wordlist,
|
||||
}
|
||||
}
|
||||
|
||||
/// create a Handles object suitable for unit testing (non-functional)
|
||||
#[cfg(test)]
|
||||
pub fn for_testing(
|
||||
scanned_urls: Option<Arc<FeroxScans>>,
|
||||
config: Option<Arc<Configuration>>,
|
||||
) -> (Self, UnboundedReceiver<Command>) {
|
||||
let configuration = config.unwrap_or_else(|| Arc::new(Configuration::new().unwrap()));
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let terminal_handle = TermOutHandle::new(tx.clone(), tx.clone());
|
||||
let stats_handle = StatsHandle::new(Arc::new(Stats::new(configuration.json)), tx.clone());
|
||||
let filters_handle = FiltersHandle::new(Arc::new(FeroxFilters::default()), tx.clone());
|
||||
let wordlist = Arc::new(vec![String::from("this_is_a_test")]);
|
||||
let handles = Self::new(
|
||||
stats_handle,
|
||||
filters_handle,
|
||||
terminal_handle,
|
||||
configuration,
|
||||
wordlist,
|
||||
);
|
||||
if let Some(sh) = scanned_urls {
|
||||
let scan_handle = ScanHandle::new(sh, tx);
|
||||
handles.set_scan_handle(scan_handle);
|
||||
}
|
||||
(handles, rx)
|
||||
}
|
||||
|
||||
/// Set the ScanHandle object
|
||||
pub fn set_scan_handle(&self, handle: ScanHandle) {
|
||||
if let Ok(mut guard) = self.scans.write() {
|
||||
if guard.is_none() {
|
||||
let _ = std::mem::replace(&mut *guard, Some(handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to easily send a Command over the (locked) underlying CommandSender object
|
||||
pub fn send_scan_command(&self, command: Command) -> Result<()> {
|
||||
if let Ok(guard) = self.scans.read().as_ref() {
|
||||
if let Some(handle) = guard.as_ref() {
|
||||
handle.send(command)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Could not get underlying CommandSender object")
|
||||
}
|
||||
|
||||
/// wrapper to reach into `FeroxScans` and yank out the length of `collected_extensions`
|
||||
pub fn num_collected_extensions(&self) -> usize {
|
||||
if !self.config.collect_extensions {
|
||||
// if --collect-extensions wasn't used, simply return 0 and forego unlocking
|
||||
return 0;
|
||||
}
|
||||
|
||||
self.collected_extensions().len()
|
||||
}
|
||||
|
||||
/// wrapper to reach into `FeroxScans` and yank out the length of `collected_extensions`
|
||||
pub fn collected_extensions(&self) -> HashSet<String> {
|
||||
if let Ok(scans) = self.ferox_scans() {
|
||||
if let Ok(extensions) = scans.collected_extensions.read() {
|
||||
return extensions.clone();
|
||||
}
|
||||
}
|
||||
|
||||
HashSet::new()
|
||||
}
|
||||
|
||||
/// number of words in the wordlist, multiplied by `expected_num_requests_multiplier`
|
||||
pub fn expected_num_requests_per_dir(&self) -> usize {
|
||||
let num_words = self.wordlist.len();
|
||||
let multiplier = self.expected_num_requests_multiplier();
|
||||
multiplier * num_words
|
||||
}
|
||||
|
||||
/// number of extensions plus the number of request method types plus any dynamically collected
|
||||
/// extensions
|
||||
pub fn expected_num_requests_multiplier(&self) -> usize {
|
||||
let multiplier = self.config.extensions.len()
|
||||
+ self.config.methods.len()
|
||||
+ self.num_collected_extensions();
|
||||
|
||||
// methods should always have at least 1 member, likely making this .max call unneeded
|
||||
// but leaving it for 'just in case' reasons
|
||||
multiplier.max(1)
|
||||
}
|
||||
|
||||
/// Helper to easily get the (locked) underlying FeroxScans object
|
||||
pub fn ferox_scans(&self) -> Result<Arc<FeroxScans>> {
|
||||
if let Ok(guard) = self.scans.read().as_ref() {
|
||||
if let Some(handle) = guard.as_ref() {
|
||||
return Ok(handle.data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Could not get underlying FeroxScans")
|
||||
}
|
||||
}
|
||||
144
src/event_handlers/filters.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use super::*;
|
||||
use crate::filters::EmptyFilter;
|
||||
use crate::{filters::FeroxFilters, CommandSender, FeroxChannel, Joiner};
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{
|
||||
mpsc::{self, UnboundedReceiver},
|
||||
oneshot,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for filters transmitter and FeroxFilters object
|
||||
pub struct FiltersHandle {
|
||||
/// FeroxFilters object used across modules to track active filters
|
||||
pub data: Arc<FeroxFilters>,
|
||||
|
||||
/// transmitter used to update `data`
|
||||
pub tx: CommandSender,
|
||||
}
|
||||
|
||||
/// implementation of FiltersHandle
|
||||
impl FiltersHandle {
|
||||
/// Given an Arc-wrapped FeroxFilters and CommandSender, create a new FiltersHandle
|
||||
pub fn new(data: Arc<FeroxFilters>, tx: CommandSender) -> Self {
|
||||
Self { data, tx }
|
||||
}
|
||||
|
||||
/// Send the given Command over `tx`
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.tx.send(command)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync the handle with the handler
|
||||
pub async fn sync(&self) -> Result<()> {
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.send(Command::Sync(tx))?;
|
||||
rx.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// event handler for updating a single data structure of all active filters
|
||||
#[derive(Debug)]
|
||||
pub struct FiltersHandler {
|
||||
/// collection of FeroxFilters
|
||||
data: Arc<FeroxFilters>,
|
||||
|
||||
/// Receiver half of mpsc from which `Command`s are processed
|
||||
receiver: UnboundedReceiver<Command>,
|
||||
}
|
||||
|
||||
/// implementation of event handler for filters
|
||||
impl FiltersHandler {
|
||||
/// create new event handler
|
||||
pub fn new(data: Arc<FeroxFilters>, receiver: UnboundedReceiver<Command>) -> Self {
|
||||
Self { data, receiver }
|
||||
}
|
||||
|
||||
/// Initialize new `FeroxFilters` and the sc side of an mpsc channel that is responsible for
|
||||
/// updates to the aforementioned object.
|
||||
pub fn initialize() -> (Joiner, FiltersHandle) {
|
||||
log::trace!("enter: initialize");
|
||||
|
||||
let data = Arc::new(FeroxFilters::default());
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let mut handler = Self::new(data.clone(), rx);
|
||||
|
||||
let task = tokio::spawn(async move { handler.start().await });
|
||||
|
||||
let event_handle = FiltersHandle::new(data, tx);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
|
||||
|
||||
(task, event_handle)
|
||||
}
|
||||
|
||||
/// Start a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives `Command` and acts accordingly
|
||||
pub async fn start(&mut self) -> Result<()> {
|
||||
log::trace!("enter: start({:?})", self);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::AddFilter(filter) => {
|
||||
if filter.as_any().downcast_ref::<EmptyFilter>().is_none() {
|
||||
// don't add an empty filter
|
||||
self.data.push(filter)?;
|
||||
}
|
||||
}
|
||||
Command::RemoveFilters(mut indices) => self.data.remove(&mut indices),
|
||||
Command::Sync(sender) => {
|
||||
log::debug!("filters: {:?}", self);
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::Exit => break,
|
||||
_ => {} // no other commands needed for FilterHandler
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::filters::WordsFilter;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn empty_filter_skipped() {
|
||||
let data = Arc::new(FeroxFilters::default());
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let mut handler = FiltersHandler::new(data.clone(), rx);
|
||||
|
||||
let event_handle = FiltersHandle::new(data, tx);
|
||||
|
||||
let _task = tokio::spawn(async move { handler.start().await });
|
||||
|
||||
event_handle
|
||||
.send(Command::AddFilter(Box::new(EmptyFilter {})))
|
||||
.unwrap();
|
||||
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
event_handle.send(Command::Sync(tx)).unwrap();
|
||||
rx.await.unwrap();
|
||||
|
||||
assert!(event_handle.data.filters.read().unwrap().is_empty());
|
||||
|
||||
event_handle
|
||||
.send(Command::AddFilter(Box::new(WordsFilter { word_count: 1 })))
|
||||
.unwrap();
|
||||
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
event_handle.send(Command::Sync(tx)).unwrap();
|
||||
rx.await.unwrap();
|
||||
|
||||
assert_eq!(event_handle.data.filters.read().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
145
src/event_handlers/inputs.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
progress::PROGRESS_PRINTER,
|
||||
scan_manager::{FeroxState, PAUSE_SCAN},
|
||||
scanner::RESPONSES,
|
||||
statistics::StatError,
|
||||
utils::slugify_filename,
|
||||
utils::{open_file, write_to},
|
||||
SLEEP_DURATION,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use console::style;
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// Atomic boolean flag, used to determine whether or not the terminal input handler should exit
|
||||
pub static SCAN_COMPLETE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Container for filters transmitter and FeroxFilters object
|
||||
pub struct TermInputHandler {
|
||||
/// handles to other handlers
|
||||
handles: Arc<Handles>,
|
||||
}
|
||||
|
||||
/// implementation of event handler for terminal input
|
||||
///
|
||||
/// kicks off the following handlers related to terminal input:
|
||||
/// ctrl+c handler that saves scan state to disk
|
||||
/// enter handler that listens for enter during scans to drop into interactive scan management menu
|
||||
impl TermInputHandler {
|
||||
/// Create new event handler
|
||||
pub fn new(handles: Arc<Handles>) -> Self {
|
||||
Self { handles }
|
||||
}
|
||||
|
||||
/// Initialize the sigint and enter handlers that are responsible for handling initial user
|
||||
/// interaction during scans
|
||||
pub fn initialize(handles: Arc<Handles>) {
|
||||
log::trace!("enter: initialize({:?})", handles);
|
||||
|
||||
let handler = Self::new(handles);
|
||||
handler.start();
|
||||
|
||||
log::trace!("exit: initialize");
|
||||
}
|
||||
|
||||
/// wrapper around sigint_handler and enter_handler
|
||||
fn start(&self) {
|
||||
tokio::task::spawn_blocking(Self::enter_handler);
|
||||
|
||||
if self.handles.config.save_state {
|
||||
// start the ctrl+c handler
|
||||
let cloned = self.handles.clone();
|
||||
|
||||
let result = ctrlc::set_handler(move || {
|
||||
let _ = Self::sigint_handler(cloned.clone());
|
||||
});
|
||||
|
||||
if result.is_err() {
|
||||
log::warn!("Could not set Ctrl+c handler; scan state will not be saved");
|
||||
self.handles
|
||||
.stats
|
||||
.send(Command::AddError(StatError::Other))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the current state of the program to disk (if save_state is true) and then exits
|
||||
pub fn sigint_handler(handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: sigint_handler({:?})", handles);
|
||||
|
||||
let filename = if !handles.config.target_url.is_empty() {
|
||||
// target url populated
|
||||
slugify_filename(&handles.config.target_url, "ferox", "state")
|
||||
} else {
|
||||
// stdin used
|
||||
slugify_filename("stdin", "ferox", "state")
|
||||
};
|
||||
|
||||
let warning = format!(
|
||||
"🚨 Caught {} 🚨 saving scan state to {} ...",
|
||||
style("ctrl+c").yellow(),
|
||||
filename
|
||||
);
|
||||
|
||||
PROGRESS_PRINTER.println(warning);
|
||||
|
||||
let state = FeroxState::new(
|
||||
handles.ferox_scans()?,
|
||||
handles.config.clone(),
|
||||
&RESPONSES,
|
||||
handles.stats.data.clone(),
|
||||
handles.filters.data.clone(),
|
||||
);
|
||||
|
||||
let state_file = open_file(&filename);
|
||||
|
||||
let mut buffered_file = state_file?;
|
||||
write_to(&state, &mut buffered_file, true)?;
|
||||
|
||||
log::trace!("exit: sigint_handler (end of program)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
/// Handles specific key events triggered by the user over stdin
|
||||
fn enter_handler() {
|
||||
// todo eventually move away from atomics, the blocking recv is the problem
|
||||
log::trace!("enter: start_enter_handler");
|
||||
|
||||
loop {
|
||||
if PAUSE_SCAN.load(Ordering::Relaxed) {
|
||||
// if the scan is already paused, we don't want this event poller fighting the user
|
||||
// over stdin
|
||||
sleep(Duration::from_millis(SLEEP_DURATION));
|
||||
} else if event::poll(Duration::from_millis(SLEEP_DURATION)).unwrap_or(false) {
|
||||
// It's guaranteed that the `read()` won't block when the `poll()`
|
||||
// function returns `true`
|
||||
|
||||
if let Ok(key_pressed) = event::read() {
|
||||
// ignore any other keys
|
||||
if key_pressed == Event::Key(KeyCode::Enter.into()) {
|
||||
// if the user presses Enter, set PAUSE_SCAN to true. The interactive menu
|
||||
// will be triggered and will handle setting PAUSE_SCAN to false
|
||||
PAUSE_SCAN.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Timeout expired and no `Event` is available; use the timeout to check SCAN_COMPLETE
|
||||
if SCAN_COMPLETE.load(Ordering::Relaxed) {
|
||||
// scan has been marked complete by main, time to exit the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("exit: start_enter_handler");
|
||||
}
|
||||
}
|
||||
16
src/event_handlers/mod.rs
Normal file
@@ -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};
|
||||
557
src/event_handlers/outputs.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
use super::Command::AddToUsizeField;
|
||||
use super::*;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use futures::future::{BoxFuture, FutureExt};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::statistics::StatField::TotalExpected;
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
progress::PROGRESS_PRINTER,
|
||||
response::FeroxResponse,
|
||||
scanner::RESPONSES,
|
||||
send_command, skip_fail,
|
||||
statistics::StatField::ResourcesDiscovered,
|
||||
traits::FeroxSerialize,
|
||||
utils::{ferox_print, fmt_err, make_request, open_file, write_to},
|
||||
CommandReceiver, CommandSender, Joiner,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
/// Simple enum for semantic clarity around calling expectations for `process_response`
|
||||
enum ProcessResponseCall {
|
||||
/// call should allow recursion
|
||||
Recursive,
|
||||
|
||||
/// call should not allow recursion
|
||||
NotRecursive,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for terminal output transmitter
|
||||
pub struct TermOutHandle {
|
||||
/// Transmitter that sends to the TermOutHandler handler
|
||||
pub tx: CommandSender,
|
||||
|
||||
/// Transmitter that sends to the FileOutHandler handler
|
||||
pub tx_file: CommandSender,
|
||||
}
|
||||
|
||||
/// implementation of OutputHandle
|
||||
impl TermOutHandle {
|
||||
/// Given a CommandSender, create a new OutputHandle
|
||||
pub fn new(tx: CommandSender, tx_file: CommandSender) -> Self {
|
||||
Self { tx, tx_file }
|
||||
}
|
||||
|
||||
/// Send the given Command over `tx`
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.tx.send(command)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync the handle with the handler
|
||||
pub async fn sync(&self, send_to_file: bool) -> Result<()> {
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.send(Command::Sync(tx))?;
|
||||
|
||||
if send_to_file {
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.tx_file.send(Command::Sync(tx))?;
|
||||
rx.await?;
|
||||
}
|
||||
|
||||
rx.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Event handler for files
|
||||
pub struct FileOutHandler {
|
||||
/// file output handler's receiver
|
||||
receiver: CommandReceiver,
|
||||
|
||||
/// pointer to "global" configuration struct
|
||||
config: Arc<Configuration>,
|
||||
}
|
||||
|
||||
impl FileOutHandler {
|
||||
/// Given a file tx/rx pair along with a filename and awaitable task, create
|
||||
/// a FileOutHandler
|
||||
fn new(rx: CommandReceiver, config: Arc<Configuration>) -> Self {
|
||||
Self {
|
||||
receiver: rx,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives responses from the terminal handler and writes them to disk
|
||||
async fn start(&mut self, tx_stats: CommandSender) -> Result<()> {
|
||||
log::trace!("enter: start_file_handler({:?})", tx_stats);
|
||||
|
||||
let mut file = open_file(&self.config.output)?;
|
||||
|
||||
log::info!("Writing scan results to {}", self.config.output);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::Report(response) => {
|
||||
skip_fail!(write_to(&*response, &mut file, self.config.json));
|
||||
}
|
||||
Command::WriteToDisk(message) => {
|
||||
// todo consider making report accept dyn FeroxSerialize; would mean adding
|
||||
// as_any/box_eq/PartialEq to the trait and then adding them to the
|
||||
// implementing structs
|
||||
skip_fail!(write_to(&*message, &mut file, self.config.json));
|
||||
}
|
||||
Command::Exit => {
|
||||
break;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
skip_fail!(sender.send(true));
|
||||
}
|
||||
_ => {} // no more needed
|
||||
}
|
||||
}
|
||||
|
||||
// close the file before we tell statistics to save current data to the same file
|
||||
drop(file);
|
||||
|
||||
send_command!(tx_stats, Command::Save);
|
||||
|
||||
log::trace!("exit: start_file_handler");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Event handler for terminal
|
||||
pub struct TermOutHandler {
|
||||
/// terminal output handler's receiver
|
||||
receiver: CommandReceiver,
|
||||
|
||||
/// file handler
|
||||
tx_file: CommandSender,
|
||||
|
||||
/// optional file handler task
|
||||
file_task: Option<Joiner>,
|
||||
|
||||
/// pointer to "global" configuration struct
|
||||
config: Arc<Configuration>,
|
||||
}
|
||||
|
||||
/// implementation of TermOutHandler
|
||||
impl TermOutHandler {
|
||||
/// Given a terminal receiver along with a file transmitter and filename, create
|
||||
/// an OutputHandler
|
||||
fn new(
|
||||
receiver: CommandReceiver,
|
||||
tx_file: CommandSender,
|
||||
file_task: Option<Joiner>,
|
||||
config: Arc<Configuration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
tx_file,
|
||||
file_task,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates all required output handlers (terminal, file) and updates the given Handles/Tasks
|
||||
pub fn initialize(
|
||||
config: Arc<Configuration>,
|
||||
tx_stats: CommandSender,
|
||||
) -> (Joiner, TermOutHandle) {
|
||||
log::trace!("enter: initialize({:?}, {:?})", config, tx_stats);
|
||||
|
||||
let (tx_term, rx_term) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, rx_file) = mpsc::unbounded_channel::<Command>();
|
||||
|
||||
let mut file_handler = FileOutHandler::new(rx_file, config.clone());
|
||||
|
||||
let tx_stats_clone = tx_stats.clone();
|
||||
|
||||
let file_task = if !config.output.is_empty() {
|
||||
// -o used, need to spawn the thread for writing to disk
|
||||
Some(tokio::spawn(async move {
|
||||
file_handler.start(tx_stats_clone).await
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut term_handler = Self::new(rx_term, tx_file.clone(), file_task, config);
|
||||
let term_task = tokio::spawn(async move { term_handler.start(tx_stats).await });
|
||||
|
||||
let event_handle = TermOutHandle::new(tx_term, tx_file);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", term_task, event_handle);
|
||||
|
||||
(term_task, event_handle)
|
||||
}
|
||||
|
||||
/// Start a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives `Command` and acts accordingly
|
||||
async fn start(&mut self, tx_stats: CommandSender) -> Result<()> {
|
||||
log::trace!("enter: start({:?})", tx_stats);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::Report(resp) => {
|
||||
self.process_response(tx_stats.clone(), resp, ProcessResponseCall::Recursive)
|
||||
.await?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::Exit => {
|
||||
if self.file_task.is_some() && self.tx_file.send(Command::Exit).is_ok() {
|
||||
self.file_task.as_mut().unwrap().await??; // wait for death
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {} // no more commands needed
|
||||
}
|
||||
}
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// upon receiving a `FeroxResponse` from the mpsc, handle printing, sending to the replay
|
||||
/// proxy, checking for backups of the `FeroxResponse`'s url, and tracking the response.
|
||||
fn process_response(
|
||||
&self,
|
||||
tx_stats: CommandSender,
|
||||
mut resp: Box<FeroxResponse>,
|
||||
call_type: ProcessResponseCall,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
log::trace!("enter: process_response({:?}, {:?})", resp, call_type);
|
||||
|
||||
async move {
|
||||
let contains_sentry = self.config.status_codes.contains(&resp.status().as_u16());
|
||||
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
|
||||
let should_process_response = contains_sentry && unknown_sentry;
|
||||
|
||||
if should_process_response {
|
||||
// print to stdout
|
||||
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
|
||||
|
||||
send_command!(tx_stats, AddToUsizeField(ResourcesDiscovered, 1));
|
||||
|
||||
if self.file_task.is_some() {
|
||||
// -o used, need to send the report to be written out to disk
|
||||
self.tx_file
|
||||
.send(Command::Report(resp.clone()))
|
||||
.with_context(|| {
|
||||
fmt_err(&format!("Could not send {} to file handler", resp))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
log::trace!("report complete: {}", resp.url());
|
||||
|
||||
if self.config.replay_client.is_some() && should_process_response {
|
||||
// replay proxy specified/client created and this response's status code is one that
|
||||
// should be replayed; not using logged_request due to replay proxy client
|
||||
let data = if self.config.data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.config.data.as_slice())
|
||||
};
|
||||
|
||||
make_request(
|
||||
self.config.replay_client.as_ref().unwrap(),
|
||||
resp.url(),
|
||||
resp.method().as_str(),
|
||||
data,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Could not replay request through replay proxy")?;
|
||||
}
|
||||
|
||||
if self.config.collect_backups
|
||||
&& should_process_response
|
||||
&& matches!(call_type, ProcessResponseCall::Recursive)
|
||||
{
|
||||
// --collect-backups was used; the response is one we care about, and the function
|
||||
// call came from the loop in `.start` (i.e. recursive was specified
|
||||
let backup_urls = self.generate_backup_urls(&resp).await;
|
||||
|
||||
// need to manually adjust stats
|
||||
send_command!(tx_stats, AddToUsizeField(TotalExpected, backup_urls.len()));
|
||||
|
||||
for backup_url in &backup_urls {
|
||||
let backup_response = make_request(
|
||||
&self.config.client,
|
||||
backup_url,
|
||||
resp.method().as_str(),
|
||||
None,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Could not request backup of {}", resp.url().as_str())
|
||||
})?;
|
||||
|
||||
let ferox_response = FeroxResponse::from(
|
||||
backup_response,
|
||||
resp.url().as_str(),
|
||||
resp.method().as_str(),
|
||||
resp.output_level,
|
||||
)
|
||||
.await;
|
||||
|
||||
self.process_response(
|
||||
tx_stats.clone(),
|
||||
Box::new(ferox_response),
|
||||
ProcessResponseCall::NotRecursive,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if should_process_response {
|
||||
// add response to RESPONSES for serialization in case of ctrl+c
|
||||
// placed all by its lonesome like this so that RESPONSES can take ownership
|
||||
// of the FeroxResponse
|
||||
|
||||
// before ownership is transferred, there's no real reason to keep the body anymore
|
||||
// so we can free that piece of data, reducing memory usage
|
||||
resp.drop_text();
|
||||
|
||||
RESPONSES.insert(*resp);
|
||||
}
|
||||
log::trace!("exit: process_response");
|
||||
Ok(())
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
/// internal helper to stay DRY
|
||||
fn add_new_url_to_vec(&self, url: &Url, new_name: &str, urls: &mut Vec<Url>) {
|
||||
if let Ok(joined) = url.join(new_name) {
|
||||
urls.push(joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// given a `FeroxResponse`, generate either 6 or 7 urls that are likely backups of the
|
||||
/// original.
|
||||
///
|
||||
/// example:
|
||||
/// original: LICENSE.txt
|
||||
/// backups:
|
||||
/// - LICENSE.txt~
|
||||
/// - LICENSE.txt.bak
|
||||
/// - LICENSE.txt.bak2
|
||||
/// - LICENSE.txt.old
|
||||
/// - LICENSE.txt.1
|
||||
/// - LICENSE.bak
|
||||
/// - .LICENSE.txt.swp
|
||||
async fn generate_backup_urls(&self, response: &FeroxResponse) -> Vec<Url> {
|
||||
log::trace!("enter: generate_backup_urls({:?})", response);
|
||||
|
||||
let mut urls = vec![];
|
||||
let url = response.url();
|
||||
|
||||
// confirmed safe: see src/response.rs for comments
|
||||
let filename = url.path_segments().unwrap().last().unwrap();
|
||||
|
||||
if !filename.is_empty() {
|
||||
// append rules
|
||||
for suffix in ["~", ".bak", ".bak2", ".old", ".1"] {
|
||||
self.add_new_url_to_vec(url, &format!("{}{}", filename, suffix), &mut urls);
|
||||
}
|
||||
|
||||
// vim swap rule
|
||||
self.add_new_url_to_vec(url, &format!(".{}.swp", filename), &mut urls);
|
||||
|
||||
// replace original extension rule
|
||||
let parts: Vec<_> = filename
|
||||
.split('.')
|
||||
// keep things like /.bash_history out of results
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.len() > 1 {
|
||||
// filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
|
||||
self.add_new_url_to_vec(url, &format!("{}.bak", parts.first().unwrap()), &mut urls);
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: generate_backup_urls -> {:?}", urls);
|
||||
urls
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// try to hit struct field coverage of FileOutHandler
|
||||
fn struct_fields_of_file_out_handler() {
|
||||
let (_, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
let foh = FileOutHandler {
|
||||
config,
|
||||
receiver: rx,
|
||||
};
|
||||
println!("{:?}", foh);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// try to hit struct field coverage of TermOutHandler
|
||||
async fn struct_fields_of_term_out_handler() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
};
|
||||
|
||||
println!("{:?}", toh);
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// when the feroxresponse's url contains an extension, there should be 7 urls returned
|
||||
async fn generate_backup_urls_creates_correct_urls_when_extension_present() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"derp.php~",
|
||||
"derp.php.bak",
|
||||
"derp.php.bak2",
|
||||
"derp.php.old",
|
||||
"derp.php.1",
|
||||
".derp.php.swp",
|
||||
"derp.bak",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/derp.php");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let paths: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| url.path_segments().unwrap().last().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(urls.len(), 7);
|
||||
|
||||
for path in paths {
|
||||
assert!(expected.contains(&path));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// when the feroxresponse's url doesn't contain an extension, there should be 6 urls returned
|
||||
async fn generate_backup_urls_creates_correct_urls_when_extension_not_present() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"derp~",
|
||||
"derp.bak",
|
||||
"derp.bak2",
|
||||
"derp.old",
|
||||
"derp.1",
|
||||
".derp.swp",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/derp");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let paths: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| url.path_segments().unwrap().last().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(urls.len(), 6);
|
||||
|
||||
for path in paths {
|
||||
assert!(expected.contains(&path));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test to ensure that backups are requested from the directory in which they were found
|
||||
/// re: issue #513
|
||||
async fn generate_backup_urls_creates_correct_urls_when_not_at_root() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"http://localhost/wordpress/derp.php~",
|
||||
"http://localhost/wordpress/derp.php.bak",
|
||||
"http://localhost/wordpress/derp.php.bak2",
|
||||
"http://localhost/wordpress/derp.php.old",
|
||||
"http://localhost/wordpress/derp.php.1",
|
||||
"http://localhost/wordpress/.derp.php.swp",
|
||||
"http://localhost/wordpress/derp.bak",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/wordpress/derp.php");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let url_strs: Vec<_> = urls.iter().map(|url| url.as_str()).collect();
|
||||
|
||||
assert_eq!(urls.len(), 7);
|
||||
|
||||
for url_str in url_strs {
|
||||
assert!(expected.contains(&url_str));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
}
|
||||
397
src/event_handlers/scans.rs
Normal file
@@ -0,0 +1,397 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use tokio::sync::{mpsc, Semaphore};
|
||||
|
||||
use crate::{
|
||||
response::FeroxResponse,
|
||||
scan_manager::{FeroxScan, FeroxScans, ScanOrder},
|
||||
scanner::FeroxScanner,
|
||||
statistics::StatField::TotalScans,
|
||||
url::FeroxUrl,
|
||||
utils::should_deny_url,
|
||||
CommandReceiver, CommandSender, FeroxChannel, Joiner, SLEEP_DURATION,
|
||||
};
|
||||
|
||||
use super::command::Command::AddToUsizeField;
|
||||
use super::*;
|
||||
use crate::statistics::StatField;
|
||||
use reqwest::Url;
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for recursion transmitter and FeroxScans object
|
||||
pub struct ScanHandle {
|
||||
/// FeroxScans object used across modules to track scans
|
||||
pub data: Arc<FeroxScans>,
|
||||
|
||||
/// transmitter used to update `data`
|
||||
pub tx: CommandSender,
|
||||
}
|
||||
|
||||
/// implementation of RecursionHandle
|
||||
impl ScanHandle {
|
||||
/// Given an Arc-wrapped FeroxScans and CommandSender, create a new RecursionHandle
|
||||
pub fn new(data: Arc<FeroxScans>, tx: CommandSender) -> Self {
|
||||
Self { data, tx }
|
||||
}
|
||||
|
||||
/// Send the given Command over `tx`
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.tx.send(command)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// event handler for updating a single data structure of all FeroxScans
|
||||
#[derive(Debug)]
|
||||
pub struct ScanHandler {
|
||||
/// collection of FeroxScans
|
||||
data: Arc<FeroxScans>,
|
||||
|
||||
/// handles to other handlers needed to kick off a scan while already past main
|
||||
handles: Arc<Handles>,
|
||||
|
||||
/// Receiver half of mpsc from which `Command`s are processed
|
||||
receiver: CommandReceiver,
|
||||
|
||||
/// wordlist (re)used for each scan
|
||||
wordlist: std::sync::Mutex<Option<Arc<Vec<String>>>>,
|
||||
|
||||
/// group of scans that need to be joined
|
||||
tasks: Vec<Arc<FeroxScan>>,
|
||||
|
||||
/// Maximum recursion depth, a depth of 0 is infinite recursion
|
||||
max_depth: usize,
|
||||
|
||||
/// depths associated with the initial targets provided by the user
|
||||
depths: Vec<(String, usize)>,
|
||||
|
||||
/// Bounded semaphore used as a barrier to limit concurrent scans
|
||||
limiter: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
/// implementation of event handler for filters
|
||||
impl ScanHandler {
|
||||
/// create new event handler
|
||||
pub fn new(
|
||||
data: Arc<FeroxScans>,
|
||||
handles: Arc<Handles>,
|
||||
max_depth: usize,
|
||||
receiver: CommandReceiver,
|
||||
) -> Self {
|
||||
let limit = handles.config.scan_limit;
|
||||
let limiter = Semaphore::new(limit);
|
||||
|
||||
if limit == 0 {
|
||||
// scan_limit == 0 means no limit should be imposed... however, scoping the Semaphore
|
||||
// permit is tricky, so as a workaround, we'll add a ridiculous number of permits to
|
||||
// the semaphore (1,152,921,504,606,846,975 to be exact) and call that 'unlimited'
|
||||
|
||||
// note to self: the docs say max is usize::MAX >> 3, however, threads will panic if
|
||||
// that value is used (says adding (1) will overflow the semaphore, even though none
|
||||
// are being added...)
|
||||
limiter.add_permits(usize::MAX >> 4);
|
||||
}
|
||||
|
||||
Self {
|
||||
data,
|
||||
handles,
|
||||
receiver,
|
||||
max_depth,
|
||||
tasks: Vec::new(),
|
||||
depths: Vec::new(),
|
||||
limiter: Arc::new(limiter),
|
||||
wordlist: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the wordlist
|
||||
fn wordlist(&self, wordlist: Arc<Vec<String>>) {
|
||||
if let Ok(mut guard) = self.wordlist.lock() {
|
||||
if guard.is_none() {
|
||||
let _ = std::mem::replace(&mut *guard, Some(wordlist));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize new `FeroxScans` and the sc side of an mpsc channel that is responsible for
|
||||
/// updates to the aforementioned object.
|
||||
pub fn initialize(handles: Arc<Handles>) -> (Joiner, ScanHandle) {
|
||||
log::trace!("enter: initialize");
|
||||
|
||||
let data = Arc::new(FeroxScans::new(handles.config.output_level));
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let max_depth = handles.config.depth;
|
||||
|
||||
let mut handler = Self::new(data.clone(), handles, max_depth, rx);
|
||||
|
||||
let task = tokio::spawn(async move { handler.start().await });
|
||||
|
||||
let event_handle = ScanHandle::new(data, tx);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
|
||||
|
||||
(task, event_handle)
|
||||
}
|
||||
|
||||
/// Start a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives `Command` and acts accordingly
|
||||
pub async fn start(&mut self) -> Result<()> {
|
||||
log::trace!("enter: start({:?})", self);
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::ScanInitialUrls(targets) => {
|
||||
self.ordered_scan_url(targets, ScanOrder::Initial).await?;
|
||||
}
|
||||
Command::ScanNewUrl(target) => {
|
||||
// added as part of interactive menu ability (2.4.1) to add a new scan.
|
||||
// we don't have a way of knowing if they're adding a new url entirely (i.e.
|
||||
// new base url), or simply adding a new sub-directory found some other way.
|
||||
// Since we can't know, we'll start a scan as though we received the scan
|
||||
// from -u | --stdin
|
||||
self.ordered_scan_url(vec![target], ScanOrder::Initial)
|
||||
.await?;
|
||||
}
|
||||
Command::UpdateWordlist(wordlist) => {
|
||||
self.wordlist(wordlist);
|
||||
}
|
||||
Command::JoinTasks(sender) => {
|
||||
let ferox_scans = self.handles.ferox_scans().unwrap_or_default();
|
||||
let limiter_clone = self.limiter.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while ferox_scans.has_active_scans() {
|
||||
tokio::time::sleep(Duration::from_millis(SLEEP_DURATION + 250)).await;
|
||||
}
|
||||
limiter_clone.close();
|
||||
sender.send(true).expect("oneshot channel failed");
|
||||
});
|
||||
}
|
||||
Command::TryRecursion(response) => {
|
||||
self.try_recursion(response).await?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::AddDiscoveredExtension(new_extension) => {
|
||||
// if --collect-extensions was used, AND the new extension isn't in
|
||||
// the --dont-collect list AND it's also not in the --extensions list, AND
|
||||
// we actually added a new extension (i.e. wasn't previously known), add
|
||||
// it to FeroxScans.collected_extensions
|
||||
if self.handles.config.collect_extensions
|
||||
&& !self.handles.config.dont_collect.contains(&new_extension)
|
||||
&& !self.handles.config.extensions.contains(&new_extension)
|
||||
&& self.data.add_discovered_extension(new_extension)
|
||||
{
|
||||
self.update_all_bar_lengths()?;
|
||||
self.handles
|
||||
.stats
|
||||
.send(Command::AddToUsizeField(StatField::ExtensionsCollected, 1))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
_ => {} // no other commands needed for RecursionHandler
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// update all current and future bar lengths
|
||||
///
|
||||
/// updating all bar lengths correctly requires a few different actions on our part.
|
||||
/// - get the current number of requests expected per scan (dynamic when --collect-extensions
|
||||
/// is used)
|
||||
/// - update the overall progress bar via the statistics handler (total expected)
|
||||
/// - update the expected per scan value tracked in the statistics handler
|
||||
/// - update progress bars on each FeroxScan (type::directory) that are running/not-started
|
||||
/// - update progress bar length on FeroxScans (this is used when creating new a FeroxScan and
|
||||
/// determines the new scan's progress bar length)
|
||||
fn update_all_bar_lengths(&self) -> Result<()> {
|
||||
log::trace!("enter: update_all_bar_lengths");
|
||||
|
||||
// current number of requests expected per scan
|
||||
// ExpectedPerScan and TotalExpected are a += action, so we need the wordlist length to
|
||||
// update them while the other updates use expected_num_requests_per_dir
|
||||
let num_words = self.get_wordlist()?.len();
|
||||
let current_expectation = self.handles.expected_num_requests_per_dir() as u64;
|
||||
|
||||
// used in the calculation of bar width down below, see explanation there
|
||||
let divisor = self.handles.expected_num_requests_multiplier() as u64 - 1;
|
||||
|
||||
// add another `wordlist.len` to the expected per scan tracker in the statistics handler
|
||||
self.handles
|
||||
.stats
|
||||
.send(AddToUsizeField(StatField::ExpectedPerScan, num_words))?;
|
||||
|
||||
// since we're adding extensions in the middle of scans (potentially), we need to take
|
||||
// current number of requests into account, new_total will be used as an accumulator
|
||||
// used to increment the overall progress bar
|
||||
let mut new_total = 0;
|
||||
|
||||
if let Ok(ferox_scans) = self.handles.ferox_scans() {
|
||||
// update progress bar length on FeroxScans, which used when creating a new FeroxScan's
|
||||
// progress bar and should mirror the expected_per_scan field on Statistics
|
||||
ferox_scans.set_bar_length(current_expectation);
|
||||
|
||||
if let Ok(scans_guard) = ferox_scans.scans.read() {
|
||||
// update progress bars on each FeroxScan where its scan type is directory and
|
||||
// scan status is either running or not-started
|
||||
for scan in scans_guard.iter() {
|
||||
if scan.is_active() {
|
||||
// current number of words left in the 'to-scan' bin, for example:
|
||||
//
|
||||
// say we have a 2000 word wordlist, have `-x js` on the command line, and
|
||||
// just found `php` as a new extension
|
||||
//
|
||||
// that puts our state at:
|
||||
// - wordlist length: 2000
|
||||
// - total expected: 4000 (original length * 2 for -x js)
|
||||
//
|
||||
// let's assume the current scan has sent 3000 requests so far
|
||||
// that means to get the number of `words` left to send, we need to take
|
||||
// the difference of 4000 and 3000 and then divide that by the current
|
||||
// multiplier (2 in the example)
|
||||
//
|
||||
// (4000 - 3000) / 2 => 500 words left to send
|
||||
//
|
||||
// the remaining 500 words will be sent as 3 variations (word, word.js,
|
||||
// word.php). So, we would then need to increment the bar by 500 to
|
||||
// reflect the dynamism of adding extensions mid-scan.
|
||||
let bar = scan.progress_bar();
|
||||
|
||||
// (4000 - 3000) / 2 => 500 words left to send
|
||||
let length = bar.length();
|
||||
let num_words_left = (length - bar.position()) / divisor;
|
||||
|
||||
// accumulate each bar's increment value for incrementing the total bar
|
||||
new_total += num_words_left;
|
||||
|
||||
bar.inc_length(num_words_left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add the total number of newly expected requests to the overall progress bar
|
||||
// via the statistics handler
|
||||
self.handles.stats.send(AddToUsizeField(
|
||||
StatField::TotalExpected,
|
||||
new_total as usize,
|
||||
))?;
|
||||
}
|
||||
|
||||
log::trace!("exit: update_all_bar_lengths");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to easily get the (locked) underlying wordlist
|
||||
pub fn get_wordlist(&self) -> Result<Arc<Vec<String>>> {
|
||||
if let Ok(guard) = self.wordlist.lock().as_ref() {
|
||||
if let Some(list) = guard.as_ref() {
|
||||
return Ok(list.clone());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Could not get underlying wordlist")
|
||||
}
|
||||
|
||||
/// wrapper around scanning a url to stay DRY
|
||||
async fn ordered_scan_url(&mut self, targets: Vec<String>, order: ScanOrder) -> Result<()> {
|
||||
log::trace!("enter: ordered_scan_url({:?}, {:?})", targets, order);
|
||||
let should_test_deny = !self.handles.config.url_denylist.is_empty()
|
||||
|| !self.handles.config.regex_denylist.is_empty();
|
||||
|
||||
for target in targets {
|
||||
if self.data.contains(&target) && matches!(order, ScanOrder::Latest) {
|
||||
// FeroxScans knows about this url and scan isn't an Initial scan
|
||||
// initial scans are skipped because when resuming from a .state file, the scans
|
||||
// will already be populated in FeroxScans, so we need to not skip kicking off
|
||||
// their scans
|
||||
continue;
|
||||
}
|
||||
|
||||
let scan = if let Some(ferox_scan) = self.data.get_scan_by_url(&target) {
|
||||
ferox_scan // scan already known
|
||||
} else {
|
||||
self.data.add_directory_scan(&target, order).1 // add the new target; return FeroxScan
|
||||
};
|
||||
|
||||
if should_test_deny && should_deny_url(&Url::parse(&target)?, self.handles.clone())? {
|
||||
// response was caught by a user-provided deny list
|
||||
// checking this last, since it's most susceptible to longer runtimes due to what
|
||||
// input is received
|
||||
continue;
|
||||
}
|
||||
|
||||
let list = self.get_wordlist()?;
|
||||
|
||||
log::info!("scan handler received {} - beginning scan", target);
|
||||
|
||||
if matches!(order, ScanOrder::Initial) {
|
||||
// keeps track of the initial targets' scan depths in order to enforce the
|
||||
// maximum recursion depth on any identified sub-directories
|
||||
let url = FeroxUrl::from_string(&target, self.handles.clone());
|
||||
let depth = url.depth().unwrap_or(0);
|
||||
self.depths.push((target.clone(), depth));
|
||||
}
|
||||
|
||||
let scanner = FeroxScanner::new(
|
||||
&target,
|
||||
order,
|
||||
list,
|
||||
self.limiter.clone(),
|
||||
self.handles.clone(),
|
||||
);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(e) = scanner.scan_url().await {
|
||||
log::warn!("{}", e);
|
||||
}
|
||||
});
|
||||
|
||||
self.handles.stats.send(AddToUsizeField(TotalScans, 1))?;
|
||||
|
||||
scan.set_task(task).await?;
|
||||
|
||||
self.tasks.push(scan.clone());
|
||||
}
|
||||
|
||||
log::trace!("exit: ordered_scan_url");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_recursion(&mut self, response: Box<FeroxResponse>) -> Result<()> {
|
||||
log::trace!("enter: try_recursion({:?})", response,);
|
||||
|
||||
if !response.is_directory() {
|
||||
// not a directory, quick exit
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut base_depth = 1_usize;
|
||||
|
||||
for (base_url, base_url_depth) in &self.depths {
|
||||
if response.url().as_str().starts_with(base_url) {
|
||||
base_depth = *base_url_depth;
|
||||
}
|
||||
}
|
||||
|
||||
if response.reached_max_depth(base_depth, self.max_depth, self.handles.clone()) {
|
||||
// at or past recursion depth
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let targets = vec![response.url().to_string()];
|
||||
self.ordered_scan_url(targets, ScanOrder::Latest).await?;
|
||||
|
||||
log::info!("Added new directory to recursive scan: {}", response.url());
|
||||
|
||||
log::trace!("exit: try_recursion");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
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 | StatField::TotalExpected) {
|
||||
self.bar.set_length(self.stats.total_expected() as u64);
|
||||
}
|
||||
}
|
||||
Command::SubtractFromUsizeField(field, value) => {
|
||||
self.stats.subtract_from_usize_field(field, value);
|
||||
|
||||
if matches!(field, StatField::TotalExpected) {
|
||||
self.bar.set_length(self.stats.total_expected() as u64);
|
||||
}
|
||||
}
|
||||
Command::AddToF64Field(field, value) => self.stats.update_f64_field(field, value),
|
||||
Command::CreateBar => {
|
||||
self.bar = add_bar("", self.stats.total_expected() as u64, BarType::Total);
|
||||
}
|
||||
Command::LoadStats(filename) => {
|
||||
self.stats.merge_from(&filename)?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::Exit => break,
|
||||
_ => {} // no more commands needed
|
||||
}
|
||||
}
|
||||
|
||||
self.bar.finish();
|
||||
|
||||
log::debug!("{:#?}", *self.stats);
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wrapper around incrementing the overall scan's progress bar
|
||||
fn increment_bar(&self) {
|
||||
let msg = format!(
|
||||
"{}:{:<7} {}:{:<7}",
|
||||
style("found").green(),
|
||||
self.stats.resources_discovered(),
|
||||
style("errors").red(),
|
||||
self.stats.errors(),
|
||||
);
|
||||
|
||||
self.bar.set_message(&msg);
|
||||
self.bar.inc(1);
|
||||
}
|
||||
|
||||
/// Initialize new `Stats` object and the sc side of an mpsc channel that is responsible for
|
||||
/// updates to the aforementioned object.
|
||||
pub fn initialize(config: Arc<Configuration>) -> (Joiner, StatsHandle) {
|
||||
log::trace!("enter: initialize");
|
||||
|
||||
let data = Arc::new(Stats::new(config.json));
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let mut handler = StatsHandler::new(data.clone(), rx);
|
||||
|
||||
let task = tokio::spawn(async move { handler.start(&config.output).await });
|
||||
|
||||
let event_handle = StatsHandle::new(data, tx);
|
||||
|
||||
log::trace!("exit: initialize -> ({:?}, {:?})", task, event_handle);
|
||||
|
||||
(task, event_handle)
|
||||
}
|
||||
}
|
||||
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::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 = srv.mock(|when, then|{
|
||||
when.method(GET)
|
||||
.path("/some-path");
|
||||
then.status(200)
|
||||
.body("\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"");
|
||||
});
|
||||
|
||||
let client = Client::new();
|
||||
let url = Url::parse(&srv.url("/some-path")).unwrap();
|
||||
|
||||
let 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.hits(), 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 javascript and html links (multiple tags)
|
||||
ResponseBody,
|
||||
|
||||
/// Examine robots.txt (specifically) and extract links
|
||||
RobotsTxt,
|
||||
|
||||
/// Extract all <a> tags from a page
|
||||
DirectoryListing,
|
||||
}
|
||||
|
||||
/// responsible for building an `Extractor`
|
||||
pub struct ExtractorBuilder<'a> {
|
||||
/// Response from which to extract links
|
||||
response: Option<&'a FeroxResponse>,
|
||||
|
||||
/// URL of where to extract links
|
||||
url: String,
|
||||
|
||||
/// Handles object to house the underlying mpsc transmitters
|
||||
handles: Option<Arc<Handles>>,
|
||||
|
||||
/// type of extraction to be performed
|
||||
target: ExtractionTarget,
|
||||
}
|
||||
|
||||
/// ExtractorBuilder implementation
|
||||
impl<'a> Default for ExtractorBuilder<'a> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
response: None,
|
||||
url: "".to_string(),
|
||||
handles: None,
|
||||
target: ExtractionTarget::ResponseBody,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ExtractorBuilder implementation
|
||||
impl<'a> ExtractorBuilder<'a> {
|
||||
/// builder call to set `handles`
|
||||
pub fn handles(&mut self, handles: Arc<Handles>) -> &mut Self {
|
||||
self.handles = Some(handles);
|
||||
self
|
||||
}
|
||||
|
||||
/// builder call to set `url`
|
||||
pub fn url(&mut self, url: &str) -> &mut Self {
|
||||
self.url = url.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// builder call to set `target`
|
||||
pub fn target(&mut self, target: ExtractionTarget) -> &mut Self {
|
||||
self.target = target;
|
||||
self
|
||||
}
|
||||
|
||||
/// builder call to set `response`
|
||||
pub fn response(&mut self, response: &'a FeroxResponse) -> &mut Self {
|
||||
self.response = Some(response);
|
||||
self
|
||||
}
|
||||
|
||||
/// finalize configuration of `ExtractorBuilder` and return an `Extractor`
|
||||
///
|
||||
/// requires either `with_url` or `with_response` to have been used in the build process
|
||||
pub fn build(&self) -> Result<Extractor<'a>> {
|
||||
if (self.url.is_empty() && self.response.is_none()) || self.handles.is_none() {
|
||||
bail!("Extractor requires a URL or a FeroxResponse be specified as well as a Handles object")
|
||||
}
|
||||
|
||||
Ok(Extractor {
|
||||
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
|
||||
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
|
||||
response: if self.response.is_some() {
|
||||
Some(self.response.unwrap())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
url: self.url.to_owned(),
|
||||
handles: self.handles.as_ref().unwrap().clone(),
|
||||
target: self.target,
|
||||
})
|
||||
}
|
||||
}
|
||||
634
src/extractor/container.rs
Normal file
@@ -0,0 +1,634 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
client,
|
||||
event_handlers::{
|
||||
Command,
|
||||
Command::{AddError, AddToUsizeField},
|
||||
Handles,
|
||||
},
|
||||
scan_manager::ScanOrder,
|
||||
statistics::{
|
||||
StatError::Other,
|
||||
StatField::{LinksExtracted, TotalExpected},
|
||||
},
|
||||
url::FeroxUrl,
|
||||
utils::{logged_request, make_request, should_deny_url},
|
||||
ExtractionResult, DEFAULT_METHOD,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use scraper::{Html, Selector};
|
||||
use std::collections::HashSet;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// Whether an active scan is recursive or not
|
||||
#[derive(Debug)]
|
||||
enum RecursionStatus {
|
||||
/// Scan is recursive
|
||||
Recursive,
|
||||
|
||||
/// Scan is not recursive
|
||||
NotRecursive,
|
||||
}
|
||||
|
||||
/// Handles all logic related to extracting links from requested source code
|
||||
#[derive(Debug)]
|
||||
pub struct Extractor<'a> {
|
||||
/// `LINKFINDER_REGEX` as a regex::Regex type
|
||||
pub(super) links_regex: Regex,
|
||||
|
||||
/// `ROBOTS_TXT_REGEX` as a regex::Regex type
|
||||
pub(super) robots_regex: Regex,
|
||||
|
||||
/// Response from which to extract links
|
||||
pub(super) response: Option<&'a FeroxResponse>,
|
||||
|
||||
/// URL of where to extract links
|
||||
pub(super) url: String,
|
||||
|
||||
/// Handles object to house the underlying mpsc transmitters
|
||||
pub(super) handles: Arc<Handles>,
|
||||
|
||||
/// type of extraction to be performed
|
||||
pub(super) target: ExtractionTarget,
|
||||
}
|
||||
|
||||
/// Extractor implementation
|
||||
impl<'a> Extractor<'a> {
|
||||
/// perform extraction from the given target and return any links found
|
||||
pub async fn extract(&self) -> Result<ExtractionResult> {
|
||||
log::trace!(
|
||||
"enter: extract({:?}) (this fn has no associated trace exit msg)",
|
||||
self.target
|
||||
);
|
||||
match self.target {
|
||||
ExtractionTarget::ResponseBody => Ok(self.extract_from_body().await?),
|
||||
ExtractionTarget::RobotsTxt => Ok(self.extract_from_robots().await?),
|
||||
ExtractionTarget::DirectoryListing => Ok(self.extract_from_dir_listing().await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// wrapper around logic that performs the following:
|
||||
/// - parses `url_to_parse`
|
||||
/// - bails if the parsed url doesn't belong to the original host/domain
|
||||
/// - otherwise, calls `add_all_sub_paths` with the parsed result
|
||||
fn parse_url_and_add_subpaths(
|
||||
&self,
|
||||
url_to_parse: &str,
|
||||
original_url: &Url,
|
||||
links: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
log::trace!("enter: parse_url_and_add_subpaths({:?})", links);
|
||||
|
||||
match Url::parse(url_to_parse) {
|
||||
Ok(absolute) => {
|
||||
if absolute.domain() != original_url.domain()
|
||||
|| absolute.host() != original_url.host()
|
||||
{
|
||||
// domains/ips are not the same, don't scan things that aren't part of the original
|
||||
// target url
|
||||
bail!("parsed url does not belong to original domain/host");
|
||||
}
|
||||
|
||||
if self.add_all_sub_paths(absolute.path(), links).is_err() {
|
||||
log::warn!("could not add sub-paths from {} to {:?}", absolute, links);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// this is the expected error that happens when we try to parse a url fragment
|
||||
// ex: Url::parse("/login") -> Err("relative URL without a base")
|
||||
// while this is technically an error, these are good results for us
|
||||
if e.to_string().contains("relative URL without a base") {
|
||||
if self.add_all_sub_paths(url_to_parse, links).is_err() {
|
||||
log::warn!(
|
||||
"could not add sub-paths from {} to {:?}",
|
||||
url_to_parse,
|
||||
links
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// unexpected error has occurred
|
||||
log::warn!("Could not parse given url: {}", e);
|
||||
self.handles.stats.send(AddError(Other)).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: parse_url_and_add_subpaths");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// given a set of links from a normal http body response, task the request handler to make
|
||||
/// the requests
|
||||
pub async fn request_links(&mut self, links: HashSet<String>) -> Result<()> {
|
||||
log::trace!("enter: request_links({:?})", links);
|
||||
|
||||
if links.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let recursive = if self.handles.config.no_recursion {
|
||||
RecursionStatus::NotRecursive
|
||||
} else {
|
||||
RecursionStatus::Recursive
|
||||
};
|
||||
|
||||
let scanned_urls = self.handles.ferox_scans()?;
|
||||
self.update_stats(links.len())?;
|
||||
|
||||
for link in links {
|
||||
let mut resp = match self.request_link(&link).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// filter if necessary
|
||||
if self
|
||||
.handles
|
||||
.filters
|
||||
.data
|
||||
.should_filter_response(&resp, self.handles.stats.tx.clone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// request and report assumed file
|
||||
if resp.is_file() || !resp.is_directory() {
|
||||
log::debug!("Extracted File: {}", resp);
|
||||
|
||||
scanned_urls.add_file_scan(resp.url().as_str(), ScanOrder::Latest);
|
||||
|
||||
if self.handles.config.collect_extensions {
|
||||
resp.parse_extension(self.handles.clone())?;
|
||||
}
|
||||
|
||||
if let Err(e) = resp.send_report(self.handles.output.tx.clone()) {
|
||||
log::warn!("Could not send FeroxResponse to output handler: {}", e);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(recursive, RecursionStatus::Recursive) {
|
||||
log::debug!("Extracted Directory: {}", resp);
|
||||
|
||||
if !resp.url().as_str().ends_with('/')
|
||||
&& (resp.status().is_success()
|
||||
|| matches!(resp.status(), &StatusCode::FORBIDDEN))
|
||||
{
|
||||
// if the url doesn't end with a /
|
||||
// and the response code is either a 2xx or 403
|
||||
|
||||
// since all of these are 2xx or 403, recursion is only attempted if the
|
||||
// url ends in a /. I am actually ok with adding the slash and not
|
||||
// adding it, as both have merit. Leaving it in for now to see how
|
||||
// things turn out (current as of: v1.1.0)
|
||||
resp.set_url(&format!("{}/", resp.url()));
|
||||
}
|
||||
|
||||
self.handles
|
||||
.send_scan_command(Command::TryRecursion(Box::new(resp)))?;
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
self.handles.send_scan_command(Command::Sync(tx))?;
|
||||
rx.await?;
|
||||
}
|
||||
}
|
||||
log::trace!("exit: request_links");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// wrapper around link extraction via html attributes
|
||||
fn extract_all_links_from_html_tags(
|
||||
&self,
|
||||
resp_url: &Url,
|
||||
links: &mut HashSet<String>,
|
||||
html: &Html,
|
||||
) {
|
||||
self.extract_links_by_attr(resp_url, links, html, "a", "href");
|
||||
self.extract_links_by_attr(resp_url, links, html, "img", "src");
|
||||
self.extract_links_by_attr(resp_url, links, html, "form", "action");
|
||||
self.extract_links_by_attr(resp_url, links, html, "script", "src");
|
||||
self.extract_links_by_attr(resp_url, links, html, "iframe", "src");
|
||||
self.extract_links_by_attr(resp_url, links, html, "div", "src");
|
||||
self.extract_links_by_attr(resp_url, links, html, "frame", "src");
|
||||
self.extract_links_by_attr(resp_url, links, html, "embed", "src");
|
||||
}
|
||||
|
||||
/// Given the body of a `reqwest::Response`, perform the following actions
|
||||
/// - parse the body for links using the linkfinder regex
|
||||
/// - for every link found take its url path and parse each sub-path
|
||||
/// - example: Response contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// with a base url of http://localhost, the following urls would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
fn extract_all_links_from_javascript(
|
||||
&self,
|
||||
response_body: &str,
|
||||
response_url: &Url,
|
||||
links: &mut HashSet<String>,
|
||||
) {
|
||||
log::trace!(
|
||||
"enter: extract_all_links_from_javascript(html body..., {}, {:?})",
|
||||
response_url.as_str(),
|
||||
links
|
||||
);
|
||||
|
||||
for capture in self.links_regex.captures_iter(response_body) {
|
||||
// remove single & double quotes from both ends of the capture
|
||||
// capture[0] is the entire match, additional capture groups start at [1]
|
||||
let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
|
||||
|
||||
if self
|
||||
.parse_url_and_add_subpaths(link, response_url, links)
|
||||
.is_err()
|
||||
{
|
||||
// purposely not logging the error here, due to the frequency with which it gets hit
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: extract_all_links_from_javascript");
|
||||
}
|
||||
|
||||
/// take a url fragment like homepage/assets/img/icons/handshake.svg and
|
||||
/// incrementally add
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
fn add_all_sub_paths(&self, url_path: &str, links: &mut HashSet<String>) -> Result<()> {
|
||||
log::trace!("enter: add_all_sub_paths({}, {:?})", url_path, links);
|
||||
|
||||
for sub_path in self.get_sub_paths_from_path(url_path) {
|
||||
self.add_link_to_set_of_links(&sub_path, links)?;
|
||||
}
|
||||
|
||||
log::trace!("exit: add_all_sub_paths");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// given a url path, trim whitespace, remove slashes, and queries/fragments; return the
|
||||
/// normalized string
|
||||
pub(super) fn normalize_url_path(&self, path: &str) -> String {
|
||||
log::trace!("enter: normalize_url_path({})", path);
|
||||
|
||||
// remove whitespace and leading '/'
|
||||
let path_str: String = path
|
||||
.trim()
|
||||
.trim_start_matches('/')
|
||||
.chars()
|
||||
.filter(|char| !char.is_whitespace())
|
||||
.collect();
|
||||
|
||||
// snippets from rfc-3986:
|
||||
//
|
||||
// foo://example.com:8042/over/there?name=ferret#nose
|
||||
// \_/ \______________/\_________/ \_________/ \__/
|
||||
// | | | | |
|
||||
// scheme authority path query fragment
|
||||
//
|
||||
// The path component is terminated
|
||||
// by the first question mark ("?") or number sign ("#") character, or
|
||||
// by the end of the URI.
|
||||
//
|
||||
// The query component is indicated by the first question
|
||||
// mark ("?") character and terminated by a number sign ("#") character
|
||||
// or by the end of the URI.
|
||||
let (path_str, _discarded) = path_str
|
||||
.split_once('?')
|
||||
// if there isn't a '?', try to remove a fragment
|
||||
.unwrap_or_else(|| {
|
||||
// if there isn't a '#', return (original, empty)
|
||||
path_str.split_once('#').unwrap_or((&path_str, ""))
|
||||
});
|
||||
|
||||
log::trace!("exit: normalize_url_path -> {}", path_str);
|
||||
path_str.into()
|
||||
}
|
||||
|
||||
/// Iterate over a given path, return a list of every sub-path found
|
||||
///
|
||||
/// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// the following fragments would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
pub(super) fn get_sub_paths_from_path(&self, path: &str) -> Vec<String> {
|
||||
log::trace!("enter: get_sub_paths_from_path({})", path);
|
||||
let mut paths = vec![];
|
||||
|
||||
let normalized_path = self.normalize_url_path(path);
|
||||
|
||||
// filter out any empty strings caused by .split
|
||||
let mut parts: Vec<&str> = normalized_path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let length = parts.len();
|
||||
|
||||
for i in 0..length {
|
||||
// iterate over all parts of the path
|
||||
if parts.is_empty() {
|
||||
// pop left us with an empty vector, we're done
|
||||
break;
|
||||
}
|
||||
|
||||
let mut possible_path = parts.join("/");
|
||||
|
||||
if possible_path.is_empty() {
|
||||
// .join can result in an empty string, which we don't need, ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
// this isn't the last index of the parts array
|
||||
// ex: /buried/misc/stupidfile.php
|
||||
// this block skips the file but sees all parent folders
|
||||
possible_path = format!("{}/", possible_path);
|
||||
}
|
||||
|
||||
paths.push(possible_path); // good sub-path found
|
||||
parts.pop(); // use .pop() to remove the last part of the path and continue iteration
|
||||
}
|
||||
|
||||
log::trace!("exit: get_sub_paths_from_path -> {:?}", paths);
|
||||
paths
|
||||
}
|
||||
|
||||
/// simple helper to stay DRY, tries to join a url + fragment and add it to the `links` HashSet
|
||||
pub(super) fn add_link_to_set_of_links(
|
||||
&self,
|
||||
link: &str,
|
||||
links: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
log::trace!("enter: add_link_to_set_of_links({}, {:?})", link, links);
|
||||
|
||||
let old_url = match self.target {
|
||||
ExtractionTarget::ResponseBody | ExtractionTarget::DirectoryListing => {
|
||||
self.response.unwrap().url().clone()
|
||||
}
|
||||
ExtractionTarget::RobotsTxt => match Url::parse(&self.url) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
bail!("Could not parse {}: {}", self.url, e);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let new_url = old_url
|
||||
.join(link)
|
||||
.with_context(|| format!("Could not join {} with {}", old_url, link))?;
|
||||
|
||||
links.insert(new_url.to_string());
|
||||
|
||||
log::trace!("exit: add_link_to_set_of_links");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wrapper around link extraction logic
|
||||
/// - create a new Url object based on cli options/args
|
||||
/// - check if the new Url has already been seen/scanned -> None
|
||||
/// - make a request to the new Url ? -> Some(response) : None
|
||||
pub(super) async fn request_link(&self, url: &str) -> Result<FeroxResponse> {
|
||||
log::trace!("enter: request_link({})", url);
|
||||
|
||||
let ferox_url = FeroxUrl::from_string(url, self.handles.clone());
|
||||
|
||||
// create a url based on the given command line options
|
||||
let new_url = ferox_url.format("", None)?;
|
||||
|
||||
let scanned_urls = self.handles.ferox_scans()?;
|
||||
|
||||
if scanned_urls.get_scan_by_url(&new_url.to_string()).is_some() {
|
||||
//we've seen the url before and don't need to scan again
|
||||
log::trace!("exit: request_link -> None");
|
||||
bail!("previously seen url");
|
||||
}
|
||||
|
||||
if (!self.handles.config.url_denylist.is_empty()
|
||||
|| !self.handles.config.regex_denylist.is_empty())
|
||||
&& should_deny_url(&new_url, self.handles.clone())?
|
||||
{
|
||||
// can't allow a denied url to be requested
|
||||
bail!(
|
||||
"prevented request to {} due to {:?} || {:?}",
|
||||
url,
|
||||
self.handles.config.url_denylist,
|
||||
self.handles.config.regex_denylist,
|
||||
);
|
||||
}
|
||||
|
||||
// make the request and store the response
|
||||
let new_response =
|
||||
logged_request(&new_url, DEFAULT_METHOD, None, self.handles.clone()).await?;
|
||||
|
||||
let new_ferox_response = FeroxResponse::from(
|
||||
new_response,
|
||||
url,
|
||||
DEFAULT_METHOD,
|
||||
self.handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
|
||||
log::trace!("exit: request_link -> {:?}", new_ferox_response);
|
||||
|
||||
Ok(new_ferox_response)
|
||||
}
|
||||
|
||||
/// Entry point to perform link extraction from robots.txt
|
||||
///
|
||||
/// `base_url` can have paths and subpaths, however robots.txt will be requested from the
|
||||
/// root of the url
|
||||
/// given the url:
|
||||
/// http://localhost/stuff/things
|
||||
/// this function requests:
|
||||
/// http://localhost/robots.txt
|
||||
pub(super) async fn extract_from_robots(&self) -> Result<ExtractionResult> {
|
||||
log::trace!("enter: extract_robots_txt");
|
||||
|
||||
let mut result: HashSet<_> = ExtractionResult::new();
|
||||
|
||||
// request
|
||||
let response = self.make_extract_request("/robots.txt").await?;
|
||||
let body = response.text();
|
||||
|
||||
for capture in self.robots_regex.captures_iter(body) {
|
||||
if let Some(new_path) = capture.name("url_path") {
|
||||
let mut new_url = Url::parse(&self.url)?;
|
||||
|
||||
new_url.set_path(new_path.as_str());
|
||||
|
||||
if self.add_all_sub_paths(new_url.path(), &mut result).is_err() {
|
||||
log::warn!("could not add sub-paths from {} to {:?}", new_url, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: extract_robots_txt -> {:?}", result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// outer-most wrapper for parsing html response bodies in search of additional content.
|
||||
/// performs the following high-level steps:
|
||||
/// - requests the page, if necessary
|
||||
/// - checks the page to see if directory listing is enabled and sucks up all the links, if so
|
||||
/// - uses the linkfinder regex to grab links from embedded javascript/javascript files
|
||||
/// - extracts many different types of link sources from the html itself
|
||||
pub(super) async fn extract_from_body(&self) -> Result<ExtractionResult> {
|
||||
log::trace!("enter: extract_from_body");
|
||||
|
||||
let mut result = ExtractionResult::new();
|
||||
|
||||
let response = self.response.unwrap();
|
||||
let resp_url = response.url();
|
||||
let body = response.text();
|
||||
let html = Html::parse_document(body);
|
||||
|
||||
// extract links from html tags/attributes and embedded javascript
|
||||
self.extract_all_links_from_html_tags(resp_url, &mut result, &html);
|
||||
self.extract_all_links_from_javascript(body, resp_url, &mut result);
|
||||
|
||||
log::trace!("exit: extract_from_body -> {:?}", result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// parses html response bodies in search of <a> tags.
|
||||
///
|
||||
/// the assumption is that directory listing is turned on and this extraction target simply
|
||||
/// scoops up all the links for the given directory. The test to detect a directory listing
|
||||
/// is located in `HeuristicTests`
|
||||
pub async fn extract_from_dir_listing(&self) -> Result<ExtractionResult> {
|
||||
log::trace!("enter: extract_from_dir_listing");
|
||||
|
||||
let mut result = ExtractionResult::new();
|
||||
|
||||
let response = self.response.unwrap();
|
||||
let html = Html::parse_document(response.text());
|
||||
|
||||
self.extract_links_by_attr(response.url(), &mut result, &html, "a", "href");
|
||||
|
||||
log::trace!("exit: extract_from_dir_listing -> {:?}", result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// simple helper to get html links by tag/attribute and add it to the `links` HashSet
|
||||
fn extract_links_by_attr(
|
||||
&self,
|
||||
resp_url: &Url,
|
||||
links: &mut HashSet<String>,
|
||||
html: &Html,
|
||||
html_tag: &str,
|
||||
html_attr: &str,
|
||||
) {
|
||||
log::trace!("enter: extract_links_by_attr");
|
||||
|
||||
let selector = Selector::parse(html_tag).unwrap();
|
||||
|
||||
let tags = html
|
||||
.select(&selector)
|
||||
.filter(|a| a.value().attrs().any(|attr| attr.0 == html_attr));
|
||||
|
||||
for tag in tags {
|
||||
if let Some(link) = tag.value().attr(html_attr) {
|
||||
log::debug!("Parsed link \"{}\" from {}", link, resp_url.as_str());
|
||||
|
||||
if self
|
||||
.parse_url_and_add_subpaths(link, resp_url, links)
|
||||
.is_err()
|
||||
{
|
||||
log::debug!("link didn't belong to the target domain/host: {}", link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: extract_links_by_attr");
|
||||
}
|
||||
|
||||
/// helper function that simply requests at <location> on the given url's base url
|
||||
///
|
||||
/// example:
|
||||
/// http://localhost/api/users -> http://localhost/<location>
|
||||
pub(super) async fn make_extract_request(&self, location: &str) -> Result<FeroxResponse> {
|
||||
log::trace!("enter: make_extract_request");
|
||||
|
||||
// need late binding here to avoid 'creates a temporary which is freed...' in the
|
||||
// `let ... if` below to avoid cloning the client out of config
|
||||
let mut client = Client::new();
|
||||
|
||||
if location == "/robots.txt" {
|
||||
// more often than not, domain/robots.txt will redirect to www.domain/robots.txt or something
|
||||
// similar; to account for that, create a client that will follow redirects, regardless of
|
||||
// what the user specified for the scanning client. Other than redirects, it will respect
|
||||
// all other user specified settings
|
||||
let follow_redirects = true;
|
||||
|
||||
let proxy = if self.handles.config.proxy.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.handles.config.proxy.as_str())
|
||||
};
|
||||
|
||||
client = client::initialize(
|
||||
self.handles.config.timeout,
|
||||
&self.handles.config.user_agent,
|
||||
follow_redirects,
|
||||
self.handles.config.insecure,
|
||||
&self.handles.config.headers,
|
||||
proxy,
|
||||
)?;
|
||||
}
|
||||
|
||||
let client = if location != "/robots.txt" {
|
||||
&self.handles.config.client
|
||||
} else {
|
||||
&client
|
||||
};
|
||||
|
||||
let mut url = Url::parse(&self.url)?;
|
||||
url.set_path(location); // overwrite existing path
|
||||
|
||||
// purposefully not using logged_request here due to using the special client
|
||||
let response = make_request(
|
||||
client,
|
||||
&url,
|
||||
DEFAULT_METHOD,
|
||||
None,
|
||||
self.handles.config.output_level,
|
||||
&self.handles.config,
|
||||
self.handles.stats.tx.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ferox_response = FeroxResponse::from(
|
||||
response,
|
||||
&self.url,
|
||||
DEFAULT_METHOD,
|
||||
self.handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
// note: don't call parse_extension here. If we call it here, it gets called on robots.txt
|
||||
|
||||
log::trace!("exit: make_extract_request -> {}", ferox_response);
|
||||
Ok(ferox_response)
|
||||
}
|
||||
|
||||
/// update total number of links extracted and expected responses
|
||||
fn update_stats(&self, num_links: usize) -> Result<()> {
|
||||
let multiplier = self.handles.expected_num_requests_multiplier();
|
||||
|
||||
self.handles
|
||||
.stats
|
||||
.send(AddToUsizeField(LinksExtracted, num_links))?;
|
||||
self.handles
|
||||
.stats
|
||||
.send(AddToUsizeField(TotalExpected, num_links * multiplier))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
13
src/extractor/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! extract links from html source and robots.txt
|
||||
mod builder;
|
||||
mod container;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use self::builder::ExtractionTarget;
|
||||
pub use self::builder::ExtractorBuilder;
|
||||
pub use self::container::Extractor;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use regex::Regex;
|
||||
use std::sync::Arc;
|
||||
398
src/extractor/tests.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
use super::builder::{LINKFINDER_REGEX, ROBOTS_TXT_REGEX};
|
||||
use super::*;
|
||||
use crate::config::{Configuration, OutputLevel};
|
||||
use crate::scan_manager::ScanOrder;
|
||||
use crate::{
|
||||
event_handlers::Handles, scan_manager::FeroxScans, utils::make_request, Command, FeroxChannel,
|
||||
DEFAULT_METHOD,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use httpmock::{Method::GET, MockServer};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use std::collections::HashSet;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
lazy_static! {
|
||||
/// Extractor for testing robots.txt
|
||||
static ref ROBOTS_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::RobotsTxt, Arc::new(FeroxScans::default()));
|
||||
|
||||
/// Extractor for testing response bodies
|
||||
static ref BODY_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::ResponseBody, Arc::new(FeroxScans::default()));
|
||||
|
||||
/// Extractor for testing paring html
|
||||
static ref PARSEHTML_EXT: Extractor<'static> = setup_extractor(ExtractionTarget::DirectoryListing, Arc::new(FeroxScans::default()));
|
||||
|
||||
/// FeroxResponse for Extractor
|
||||
static ref RESPONSE: FeroxResponse = get_test_response();
|
||||
}
|
||||
|
||||
/// constructor for the default FeroxResponse used during testing
|
||||
fn get_test_response() -> FeroxResponse {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_text("nulla pharetra diam sit amet nisl suscipit adipiscing bibendum est");
|
||||
resp
|
||||
}
|
||||
|
||||
/// creates a single extractor that can be used to test standalone functions
|
||||
fn setup_extractor(target: ExtractionTarget, scanned_urls: Arc<FeroxScans>) -> Extractor<'static> {
|
||||
let mut builder = ExtractorBuilder::default();
|
||||
|
||||
let builder = match target {
|
||||
ExtractionTarget::ResponseBody => builder
|
||||
.target(ExtractionTarget::ResponseBody)
|
||||
.response(&RESPONSE),
|
||||
ExtractionTarget::RobotsTxt => builder
|
||||
.url("http://localhost")
|
||||
.target(ExtractionTarget::RobotsTxt),
|
||||
ExtractionTarget::DirectoryListing => builder
|
||||
.url("http://localhost")
|
||||
.target(ExtractionTarget::DirectoryListing),
|
||||
};
|
||||
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
let handles = Arc::new(Handles::for_testing(Some(scanned_urls), Some(config)).0);
|
||||
|
||||
builder.handles(handles).build().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 4 sub paths and that all are
|
||||
/// in the expected array
|
||||
fn extractor_get_sub_paths_from_path_with_multiple_paths() {
|
||||
let path = "homepage/assets/img/icons/handshake.svg";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec![
|
||||
"homepage/",
|
||||
"homepage/assets/",
|
||||
"homepage/assets/img/",
|
||||
"homepage/assets/img/icons/",
|
||||
"homepage/assets/img/icons/handshake.svg",
|
||||
];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 2 sub paths and that all are
|
||||
/// in the expected array. the fragment is wrapped in slashes to ensure no empty strings are
|
||||
/// returned
|
||||
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
|
||||
let path = "/homepage/assets/";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec!["homepage/", "homepage/assets"];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, no forward slashes are
|
||||
/// included
|
||||
fn extractor_get_sub_paths_from_path_with_only_a_word() {
|
||||
let path = "homepage";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed
|
||||
fn extractor_get_sub_paths_from_path_with_an_absolute_word() {
|
||||
let path = "/homepage";
|
||||
let r_paths = ROBOTS_EXT.get_sub_paths_from_path(path);
|
||||
let b_paths = BODY_EXT.get_sub_paths_from_path(path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(r_paths.len(), expected.len());
|
||||
assert_eq!(b_paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert!(r_paths.contains(&expected_path.to_string()));
|
||||
assert!(b_paths.contains(&expected_path.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that an ExtractorBuilder without a FeroxResponse and without a URL bails
|
||||
fn extractor_builder_bails_when_neither_required_field_is_set() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.url("")
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.handles(handles)
|
||||
.build();
|
||||
|
||||
assert!(extractor.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Extractor with a non-base url bails
|
||||
fn extractor_with_non_base_url_bails() -> Result<()> {
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "admin";
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.url("\\\\\\")
|
||||
.handles(handles)
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.build()?;
|
||||
|
||||
let result = extractor.add_link_to_set_of_links(link, &mut links);
|
||||
|
||||
assert!(result.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that a full url and fragment are joined correctly, then added to the given list
|
||||
/// i.e. the happy path
|
||||
fn extractor_add_link_to_set_of_links_happy_path() {
|
||||
let mut r_links = HashSet::<String>::new();
|
||||
let r_link = "admin";
|
||||
let mut b_links = HashSet::<String>::new();
|
||||
let b_link = "shmadmin";
|
||||
|
||||
assert_eq!(r_links.len(), 0);
|
||||
ROBOTS_EXT
|
||||
.add_link_to_set_of_links(r_link, &mut r_links)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(r_links.len(), 1);
|
||||
assert!(r_links.contains("http://localhost/admin"));
|
||||
|
||||
assert_eq!(b_links.len(), 0);
|
||||
|
||||
BODY_EXT
|
||||
.add_link_to_set_of_links(b_link, &mut b_links)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(b_links.len(), 1);
|
||||
assert!(b_links.contains("http://localhost/shmadmin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that an invalid path fragment doesn't add anything to the set of links
|
||||
fn extractor_add_link_to_set_of_links_with_non_base_url() {
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "\\\\\\\\";
|
||||
assert_eq!(links.len(), 0);
|
||||
assert!(ROBOTS_EXT
|
||||
.add_link_to_set_of_links(link, &mut links)
|
||||
.is_err());
|
||||
assert!(BODY_EXT.add_link_to_set_of_links(link, &mut links).is_err());
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test for filtering queries and fragments
|
||||
fn normalize_url_path_filters_queries_and_fragments() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.url("doesnt matter")
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.handles(handles)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let test_strings = [
|
||||
"over/there?name=ferret#nose",
|
||||
"over/there?name=ferret",
|
||||
"over/there#nose",
|
||||
"over/there",
|
||||
"over/there?name#nose",
|
||||
"over/there?name",
|
||||
" over/there?name=ferret#nose ",
|
||||
"over/there?name=ferret ",
|
||||
" over/there#nose",
|
||||
];
|
||||
test_strings.iter().for_each(|&ts| {
|
||||
let normed = extractor.normalize_url_path(ts);
|
||||
assert_eq!(normed, "over/there");
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// use make_request to generate a Response, and use the Response to test get_links;
|
||||
/// the response will contain an absolute path to a domain that is not part of the scanned
|
||||
/// domain; expect an empty set returned
|
||||
async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain() -> Result<()> {
|
||||
let (tx_stats, _): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/some-path");
|
||||
then.status(200).body(
|
||||
"\"http://definitely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"",
|
||||
);
|
||||
});
|
||||
|
||||
let client = Client::new();
|
||||
let url = Url::parse(&srv.url("/some-path")).unwrap();
|
||||
let config = Configuration::new().unwrap();
|
||||
|
||||
let response = make_request(
|
||||
&client,
|
||||
&url,
|
||||
DEFAULT_METHOD,
|
||||
None,
|
||||
OutputLevel::Default,
|
||||
&config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handles, _rx) = Handles::for_testing(None, None);
|
||||
|
||||
let handles = Arc::new(handles);
|
||||
let ferox_response =
|
||||
FeroxResponse::from(response, &srv.url(""), DEFAULT_METHOD, OutputLevel::Default).await;
|
||||
|
||||
let extractor = Extractor {
|
||||
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
|
||||
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
|
||||
response: Some(&ferox_response),
|
||||
url: String::new(),
|
||||
target: ExtractionTarget::ResponseBody,
|
||||
handles: handles.clone(),
|
||||
};
|
||||
|
||||
let links = extractor.extract_from_body().await?;
|
||||
|
||||
assert!(links.is_empty());
|
||||
assert_eq!(mock.hits(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test that /robots.txt is correctly requested given a base url (happy path)
|
||||
async fn request_robots_txt_without_proxy() -> Result<()> {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/robots.txt");
|
||||
then.status(200).body("this is a test");
|
||||
});
|
||||
|
||||
let extractor = Extractor {
|
||||
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
|
||||
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
|
||||
response: None,
|
||||
url: srv.url("/api/users/stuff/things"),
|
||||
target: ExtractionTarget::RobotsTxt,
|
||||
handles,
|
||||
};
|
||||
|
||||
let resp = extractor.make_extract_request("/robots.txt").await?;
|
||||
|
||||
assert!(matches!(resp.status(), &StatusCode::OK));
|
||||
println!("{}", resp);
|
||||
assert_eq!(resp.content_length(), 14);
|
||||
assert_eq!(mock.hits(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test that /robots.txt is correctly requested given a base url (happy path) when a proxy is used
|
||||
async fn request_robots_txt_with_proxy() -> Result<()> {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let mut config = Configuration::new()?;
|
||||
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/robots.txt");
|
||||
then.status(200).body("this is also a test");
|
||||
});
|
||||
|
||||
// note: the proxy doesn't actually do anything other than hit a different code branch
|
||||
// in this unit test; it would however have an effect on an integration test
|
||||
config.proxy = srv.url("/ima-proxy");
|
||||
config.no_recursion = true;
|
||||
|
||||
let extractor = ExtractorBuilder::default()
|
||||
.url(&srv.url("/api/different/path"))
|
||||
.target(ExtractionTarget::RobotsTxt)
|
||||
.handles(handles)
|
||||
.build()?;
|
||||
|
||||
let resp = extractor.make_extract_request("/robots.txt").await?;
|
||||
|
||||
assert!(matches!(resp.status(), &StatusCode::OK));
|
||||
assert_eq!(resp.content_length(), 19);
|
||||
assert_eq!(mock.hits(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// request_link's happy path, expect back a FeroxResponse
|
||||
async fn request_link_happy_path() -> Result<()> {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/login.php");
|
||||
then.status(200).body("this is a test");
|
||||
});
|
||||
|
||||
let r_resp = ROBOTS_EXT.request_link(&srv.url("/login.php")).await?;
|
||||
let b_resp = BODY_EXT.request_link(&srv.url("/login.php")).await?;
|
||||
|
||||
assert!(matches!(r_resp.status(), &StatusCode::OK));
|
||||
assert!(matches!(b_resp.status(), &StatusCode::OK));
|
||||
assert_eq!(r_resp.content_length(), 14);
|
||||
assert_eq!(b_resp.content_length(), 14);
|
||||
assert_eq!(mock.hits(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// request_link should bail in the event that the url is already in scanned_urls
|
||||
async fn request_link_bails_on_seen_url() -> Result<()> {
|
||||
let url = "/unique-for-this-test.php";
|
||||
let srv = MockServer::start();
|
||||
let served = srv.url(url);
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path(url);
|
||||
then.status(200)
|
||||
.body("this is a unique test, don't reuse the endpoint");
|
||||
});
|
||||
|
||||
let scans = Arc::new(FeroxScans::default());
|
||||
scans.add_file_scan(&served, ScanOrder::Latest);
|
||||
|
||||
let robots = setup_extractor(ExtractionTarget::RobotsTxt, scans.clone());
|
||||
let body = setup_extractor(ExtractionTarget::ResponseBody, scans);
|
||||
|
||||
let r_resp = robots.request_link(&served).await;
|
||||
let b_resp = body.request_link(&served).await;
|
||||
|
||||
assert!(r_resp.is_err());
|
||||
assert!(b_resp.is_err());
|
||||
assert_eq!(mock.hits(), 0); // function exits before requests can happen
|
||||
Ok(())
|
||||
}
|
||||
422
src/filters.rs
@@ -1,422 +0,0 @@
|
||||
use crate::config::CONFIGURATION;
|
||||
use crate::utils::get_url_path_length;
|
||||
use crate::FeroxResponse;
|
||||
use regex::Regex;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
// references:
|
||||
// https://dev.to/magnusstrale/rust-trait-objects-in-a-vector-non-trivial-4co5
|
||||
// https://stackoverflow.com/questions/25339603/how-to-test-for-equality-between-trait-objects
|
||||
|
||||
/// FeroxFilter trait; represents different types of possible filters that can be applied to
|
||||
/// responses
|
||||
pub trait FeroxFilter: Debug + Send + Sync {
|
||||
/// Determine whether or not this particular filter should be applied or not
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool;
|
||||
|
||||
/// delegates to the FeroxFilter-implementing type which gives us the actual type of self
|
||||
fn box_eq(&self, other: &dyn Any) -> bool;
|
||||
|
||||
/// gives us `other` as Any in box_eq
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
/// implementation of PartialEq, necessary long-form due to "trait cannot be made into an object"
|
||||
/// error when attempting to derive PartialEq on the trait itself
|
||||
impl PartialEq for Box<dyn FeroxFilter> {
|
||||
/// Perform a comparison of two implementors of the FeroxFilter trait
|
||||
fn eq(&self, other: &Box<dyn FeroxFilter>) -> bool {
|
||||
self.box_eq(other.as_any())
|
||||
}
|
||||
}
|
||||
|
||||
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
|
||||
///
|
||||
/// `dynamic` is the size of the response that will later be combined with the length
|
||||
/// of the path of the url requested and used to determine interesting pages from custom
|
||||
/// 404s where the requested url is reflected back in the response
|
||||
///
|
||||
/// `size` is size of the response that should be included with filters passed via runtime
|
||||
/// configuration and any static wildcard lengths.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct WildcardFilter {
|
||||
/// size of the response that will later be combined with the length of the path of the url
|
||||
/// requested
|
||||
pub dynamic: u64,
|
||||
|
||||
/// size of the response that should be included with filters passed via runtime configuration
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for WildcardFilter
|
||||
impl FeroxFilter for WildcardFilter {
|
||||
/// Examine size, dynamic, and content_len to determine whether or not the response received
|
||||
/// is a wildcard response and therefore should be filtered out
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
// quick return if dont_filter is set
|
||||
if CONFIGURATION.dont_filter {
|
||||
// --dont-filter applies specifically to wildcard filters, it is not a 100% catch all
|
||||
// for not filtering anything. As such, it should live in the implementation of
|
||||
// a wildcard filter
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.size > 0 && self.size == response.content_length() {
|
||||
// static wildcard size found during testing
|
||||
// size isn't default, size equals response length, and auto-filter is on
|
||||
log::debug!("static wildcard: filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.dynamic > 0 {
|
||||
// dynamic wildcard offset found during testing
|
||||
|
||||
// I'm about to manually split this url path instead of using reqwest::Url's
|
||||
// builtin parsing. The reason is that they call .split() on the url path
|
||||
// except that I don't want an empty string taking up the last index in the
|
||||
// event that the url ends with a forward slash. It's ugly enough to be split
|
||||
// into its own function for readability.
|
||||
let url_len = get_url_path_length(&response.url());
|
||||
|
||||
if url_len + self.dynamic == response.content_length() {
|
||||
log::debug!("dynamic wildcard: filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one WildcardFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out status codes specified using
|
||||
/// -C|--filter-status
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct StatusCodeFilter {
|
||||
/// Status code that should not be displayed to the user
|
||||
pub filter_code: u16,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for StatusCodeFilter
|
||||
impl FeroxFilter for StatusCodeFilter {
|
||||
/// Check `filter_code` against what was passed in via -C|--filter-status
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
if response.status().as_u16() == self.filter_code {
|
||||
log::debug!(
|
||||
"filtered out {} based on --filter-status of {}",
|
||||
response.url(),
|
||||
self.filter_code
|
||||
);
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one StatusCodeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of lines
|
||||
/// in a Response body; specified using -N|--filter-lines
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct LinesFilter {
|
||||
/// Number of lines in a Response's body that should be filtered
|
||||
pub line_count: usize,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for LinesFilter
|
||||
impl FeroxFilter for LinesFilter {
|
||||
/// Check `line_count` against what was passed in via -N|--filter-lines
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.line_count() == self.line_count;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one LinesFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of words
|
||||
/// in a Response body; specified using -W|--filter-words
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct WordsFilter {
|
||||
/// Number of words in a Response's body that should be filtered
|
||||
pub word_count: usize,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for WordsFilter
|
||||
impl FeroxFilter for WordsFilter {
|
||||
/// Check `word_count` against what was passed in via -W|--filter-words
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.word_count() == self.word_count;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one WordsFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the length of a
|
||||
/// Response body; specified using -S|--filter-size
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct SizeFilter {
|
||||
/// Overall length of a Response's body that should be filtered
|
||||
pub content_length: u64,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for SizeFilter
|
||||
impl FeroxFilter for SizeFilter {
|
||||
/// Check `content_length` against what was passed in via -S|--filter-size
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.content_length() == self.content_length;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one SizeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on a given regular
|
||||
/// expression; specified using -X|--filter-regex
|
||||
#[derive(Debug)]
|
||||
pub struct RegexFilter {
|
||||
/// Regular expression to be applied to the response body for filtering, compiled
|
||||
pub compiled: Regex,
|
||||
|
||||
/// Regular expression as passed in on the command line, not compiled
|
||||
pub raw_string: String,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for RegexFilter
|
||||
impl FeroxFilter for RegexFilter {
|
||||
/// Check `expression` against the response body, if the expression matches, the response
|
||||
/// should be filtered out
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = self.compiled.is_match(response.text());
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one SizeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// PartialEq implementation for RegexFilter
|
||||
impl PartialEq for RegexFilter {
|
||||
/// Simple comparison of the raw string passed in via the command line
|
||||
fn eq(&self, other: &RegexFilter) -> bool {
|
||||
self.raw_string == other.raw_string
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use reqwest::Url;
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn lines_filter_as_any() {
|
||||
let filter = LinesFilter { line_count: 1 };
|
||||
|
||||
assert_eq!(filter.line_count, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn words_filter_as_any() {
|
||||
let filter = WordsFilter { word_count: 1 };
|
||||
|
||||
assert_eq!(filter.word_count, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn size_filter_as_any() {
|
||||
let filter = SizeFilter { content_length: 1 };
|
||||
|
||||
assert_eq!(filter.content_length, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn status_code_filter_as_any() {
|
||||
let filter = StatusCodeFilter { filter_code: 200 };
|
||||
|
||||
assert_eq!(filter.filter_code, 200);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn regex_filter_as_any() {
|
||||
let raw = r".*\.txt$";
|
||||
let compiled = Regex::new(raw).unwrap();
|
||||
let filter = RegexFilter {
|
||||
compiled,
|
||||
raw_string: raw.to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(filter.raw_string, r".*\.txt$");
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where static logic matches
|
||||
fn wildcard_should_filter_when_static_wildcard_found() {
|
||||
let resp = FeroxResponse {
|
||||
text: String::new(),
|
||||
wildcard: true,
|
||||
url: Url::parse("http://localhost").unwrap(),
|
||||
content_length: 100,
|
||||
word_count: 50,
|
||||
line_count: 25,
|
||||
headers: reqwest::header::HeaderMap::new(),
|
||||
status: reqwest::StatusCode::OK,
|
||||
};
|
||||
|
||||
let filter = WildcardFilter {
|
||||
size: 100,
|
||||
dynamic: 0,
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where dynamic logic matches
|
||||
fn wildcard_should_filter_when_dynamic_wildcard_found() {
|
||||
let resp = FeroxResponse {
|
||||
text: String::new(),
|
||||
wildcard: true,
|
||||
url: Url::parse("http://localhost/stuff").unwrap(),
|
||||
content_length: 100,
|
||||
word_count: 50,
|
||||
line_count: 25,
|
||||
headers: reqwest::header::HeaderMap::new(),
|
||||
status: reqwest::StatusCode::OK,
|
||||
};
|
||||
|
||||
let filter = WildcardFilter {
|
||||
size: 0,
|
||||
dynamic: 95,
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on RegexFilter where regex matches body
|
||||
fn regexfilter_should_filter_when_regex_matches_on_response_body() {
|
||||
let resp = FeroxResponse {
|
||||
text: String::from("im a body response hurr durr!"),
|
||||
wildcard: false,
|
||||
url: Url::parse("http://localhost/stuff").unwrap(),
|
||||
content_length: 100,
|
||||
word_count: 50,
|
||||
line_count: 25,
|
||||
headers: reqwest::header::HeaderMap::new(),
|
||||
status: reqwest::StatusCode::OK,
|
||||
};
|
||||
|
||||
let raw = r"response...rr";
|
||||
|
||||
let filter = RegexFilter {
|
||||
raw_string: raw.to_string(),
|
||||
compiled: Regex::new(raw).unwrap(),
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
}
|
||||
130
src/filters/container.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{ser::SerializeSeq, Serialize, Serializer};
|
||||
|
||||
use crate::{
|
||||
event_handlers::Command::AddToUsizeField, response::FeroxResponse,
|
||||
statistics::StatField::WildcardsFiltered, CommandSender,
|
||||
};
|
||||
|
||||
use super::{
|
||||
FeroxFilter, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
|
||||
WildcardFilter, WordsFilter,
|
||||
};
|
||||
|
||||
/// Container around a collection of `FeroxFilters`s
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeroxFilters {
|
||||
/// collection of `FeroxFilters`
|
||||
pub filters: RwLock<Vec<Box<dyn FeroxFilter>>>,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter collection
|
||||
impl FeroxFilters {
|
||||
/// add a single FeroxFilter to the collection
|
||||
pub fn push(&self, filter: Box<dyn FeroxFilter>) -> Result<()> {
|
||||
if let Ok(mut guard) = self.filters.write() {
|
||||
if guard.contains(&filter) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
guard.push(filter)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// remove items from the underlying collection by their index
|
||||
///
|
||||
/// note: indexes passed in should be index-to-remove+1. This is built for the scan mgt menu
|
||||
/// so indexes aren't 0-based whehn the user enters them.
|
||||
///
|
||||
pub fn remove(&self, indices: &mut [usize]) {
|
||||
// since we're removing by index, indices must be sorted and then reversed.
|
||||
// this allows us to iterate over the collection from the rear, allowing any shifting
|
||||
// of the vector to happen on sections that we no longer care about, as we're moving
|
||||
// in the opposite direction
|
||||
indices.sort_unstable();
|
||||
indices.reverse();
|
||||
|
||||
if let Ok(mut guard) = self.filters.write() {
|
||||
for index in indices {
|
||||
// numbering of the menu starts at 1, so we'll need to reduce the index by 1
|
||||
// to account for that. if they've provided 0 as an offset, we'll set the
|
||||
// result to a gigantic number and skip it in the loop with a bounds check
|
||||
let reduced_idx = index.checked_sub(1).unwrap_or(usize::MAX);
|
||||
|
||||
// check if number provided is out of range
|
||||
if reduced_idx >= guard.len() {
|
||||
// usize can't be negative, just need to handle exceeding bounds
|
||||
continue;
|
||||
}
|
||||
|
||||
guard.remove(reduced_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple helper to stay DRY; determines whether or not a given `FeroxResponse` should be reported
|
||||
/// to the user or not.
|
||||
pub fn should_filter_response(
|
||||
&self,
|
||||
response: &FeroxResponse,
|
||||
tx_stats: CommandSender,
|
||||
) -> bool {
|
||||
if let Ok(filters) = self.filters.read() {
|
||||
for filter in filters.iter() {
|
||||
// wildcard.should_filter goes here
|
||||
if filter.should_filter_response(response) {
|
||||
if filter.as_any().downcast_ref::<WildcardFilter>().is_some() {
|
||||
tx_stats
|
||||
.send(AddToUsizeField(WildcardsFiltered, 1))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FeroxFilters {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
if let Ok(guard) = self.filters.read() {
|
||||
let mut seq = serializer.serialize_seq(Some(guard.len()))?;
|
||||
|
||||
for filter in &*guard {
|
||||
if let Some(line_filter) = filter.as_any().downcast_ref::<LinesFilter>() {
|
||||
seq.serialize_element(line_filter).unwrap_or_default();
|
||||
} else if let Some(word_filter) = filter.as_any().downcast_ref::<WordsFilter>() {
|
||||
seq.serialize_element(word_filter).unwrap_or_default();
|
||||
} else if let Some(size_filter) = filter.as_any().downcast_ref::<SizeFilter>() {
|
||||
seq.serialize_element(size_filter).unwrap_or_default();
|
||||
} else if let Some(status_filter) =
|
||||
filter.as_any().downcast_ref::<StatusCodeFilter>()
|
||||
{
|
||||
seq.serialize_element(status_filter).unwrap_or_default();
|
||||
} else if let Some(regex_filter) = filter.as_any().downcast_ref::<RegexFilter>() {
|
||||
seq.serialize_element(regex_filter).unwrap_or_default();
|
||||
} else if let Some(similarity_filter) =
|
||||
filter.as_any().downcast_ref::<SimilarityFilter>()
|
||||
{
|
||||
seq.serialize_element(similarity_filter).unwrap_or_default();
|
||||
} else if let Some(wildcard_filter) =
|
||||
filter.as_any().downcast_ref::<WildcardFilter>()
|
||||
{
|
||||
seq.serialize_element(wildcard_filter).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
seq.end()
|
||||
} else {
|
||||
// if for some reason we can't unlock the mutex, just write an empty list
|
||||
let seq = serializer.serialize_seq(Some(0))?;
|
||||
seq.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/filters/empty.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
|
||||
/// Dummy filter for internal shenanigans
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct EmptyFilter {}
|
||||
|
||||
impl FeroxFilter for EmptyFilter {
|
||||
/// `EmptyFilter` always returns false
|
||||
fn should_filter_response(&self, _response: &FeroxResponse) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one EmptyFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
71
src/filters/init.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use super::{
|
||||
utils::create_similarity_filter, LinesFilter, RegexFilter, SizeFilter, StatusCodeFilter,
|
||||
WordsFilter,
|
||||
};
|
||||
use crate::{event_handlers::Handles, skip_fail, utils::fmt_err, Command::AddFilter};
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// add all user-supplied filters to the (already started) filters handler
|
||||
pub async fn initialize(handles: Arc<Handles>) -> Result<()> {
|
||||
// add any status code filters to filters handler's FeroxFilters (-C|--filter-status)
|
||||
for code_filter in &handles.config.filter_status {
|
||||
let filter = StatusCodeFilter {
|
||||
filter_code: *code_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any line count filters to filters handler's FeroxFilters (-N|--filter-lines)
|
||||
for lines_filter in &handles.config.filter_line_count {
|
||||
let filter = LinesFilter {
|
||||
line_count: *lines_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any line count filters to filters handler's FeroxFilters (-W|--filter-words)
|
||||
for words_filter in &handles.config.filter_word_count {
|
||||
let filter = WordsFilter {
|
||||
word_count: *words_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any line count filters to filters handler's FeroxFilters (-S|--filter-size)
|
||||
for size_filter in &handles.config.filter_size {
|
||||
let filter = SizeFilter {
|
||||
content_length: *size_filter,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any regex filters to filters handler's FeroxFilters (-X|--filter-regex)
|
||||
for regex_filter in &handles.config.filter_regex {
|
||||
let raw = regex_filter;
|
||||
let compiled = skip_fail!(Regex::new(raw));
|
||||
|
||||
let filter = RegexFilter {
|
||||
raw_string: raw.to_owned(),
|
||||
compiled,
|
||||
};
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
// add any similarity filters to filters handler's FeroxFilters (--filter-similar-to)
|
||||
for similarity_filter in &handles.config.filter_similar {
|
||||
let filter = skip_fail!(create_similarity_filter(similarity_filter, handles.clone()).await);
|
||||
|
||||
let boxed_filter = Box::new(filter);
|
||||
skip_fail!(handles.filters.send(AddFilter(boxed_filter)));
|
||||
}
|
||||
|
||||
handles.filters.sync().await?;
|
||||
Ok(())
|
||||
}
|
||||
33
src/filters/lines.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of lines
|
||||
/// in a Response body; specified using -N|--filter-lines
|
||||
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LinesFilter {
|
||||
/// Number of lines in a Response's body that should be filtered
|
||||
pub line_count: usize,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for LinesFilter
|
||||
impl FeroxFilter for LinesFilter {
|
||||
/// Check `line_count` against what was passed in via -N|--filter-lines
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.line_count() == self.line_count;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one LinesFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
33
src/filters/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! contains all of feroxbuster's filters
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::traits::{FeroxFilter, FeroxSerialize};
|
||||
|
||||
pub use self::container::FeroxFilters;
|
||||
pub(crate) use self::empty::EmptyFilter;
|
||||
pub use self::init::initialize;
|
||||
pub use self::lines::LinesFilter;
|
||||
pub use self::regex::RegexFilter;
|
||||
pub use self::similarity::SimilarityFilter;
|
||||
pub use self::size::SizeFilter;
|
||||
pub use self::status_code::StatusCodeFilter;
|
||||
pub(crate) use self::utils::{create_similarity_filter, filter_lookup};
|
||||
pub use self::wildcard::WildcardFilter;
|
||||
pub use self::words::WordsFilter;
|
||||
|
||||
mod wildcard;
|
||||
mod status_code;
|
||||
mod words;
|
||||
mod lines;
|
||||
mod size;
|
||||
mod regex;
|
||||
mod similarity;
|
||||
mod container;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod init;
|
||||
mod utils;
|
||||
mod empty;
|
||||
56
src/filters/regex.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::*;
|
||||
use ::regex::Regex;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on a given regular
|
||||
/// expression; specified using -X|--filter-regex
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegexFilter {
|
||||
/// Regular expression to be applied to the response body for filtering, compiled
|
||||
#[serde(with = "serde_regex")]
|
||||
pub compiled: Regex,
|
||||
|
||||
/// Regular expression as passed in on the command line, not compiled
|
||||
pub raw_string: String,
|
||||
}
|
||||
|
||||
impl Default for RegexFilter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
compiled: Regex::new("").unwrap(),
|
||||
raw_string: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for RegexFilter
|
||||
impl FeroxFilter for RegexFilter {
|
||||
/// Check `expression` against the response body, if the expression matches, the response
|
||||
/// should be filtered out
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = self.compiled.is_match(response.text());
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one SizeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// PartialEq implementation for RegexFilter
|
||||
impl PartialEq for RegexFilter {
|
||||
/// Simple comparison of the raw string passed in via the command line
|
||||
fn eq(&self, other: &RegexFilter) -> bool {
|
||||
self.raw_string == other.raw_string
|
||||
}
|
||||
}
|
||||
43
src/filters/similarity.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use super::*;
|
||||
use fuzzyhash::FuzzyHash;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the similarity of a
|
||||
/// Response body with a known response; specified using --filter-similar-to
|
||||
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SimilarityFilter {
|
||||
/// Hash of Response's body to be used during similarity comparison
|
||||
pub hash: String,
|
||||
|
||||
/// Percentage of similarity at which a page is determined to be a near-duplicate of another
|
||||
pub threshold: u32,
|
||||
|
||||
/// Url originally requested for the similarity filter
|
||||
pub original_url: String,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for SimilarityFilter
|
||||
impl FeroxFilter for SimilarityFilter {
|
||||
/// Check `FeroxResponse::text` against what was requested from the site passed in via
|
||||
/// --filter-similar-to
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
let other = FuzzyHash::new(&response.text());
|
||||
|
||||
if let Ok(result) = FuzzyHash::compare(&self.hash, &other.to_string()) {
|
||||
return result >= self.threshold;
|
||||
}
|
||||
|
||||
// couldn't hash the response, don't filter
|
||||
log::warn!("Could not hash body from {}", response.as_str());
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one SimilarityFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
33
src/filters/size.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the length of a
|
||||
/// Response body; specified using -S|--filter-size
|
||||
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SizeFilter {
|
||||
/// Overall length of a Response's body that should be filtered
|
||||
pub content_length: u64,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for SizeFilter
|
||||
impl FeroxFilter for SizeFilter {
|
||||
/// Check `content_length` against what was passed in via -S|--filter-size
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.content_length() == self.content_length;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one SizeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
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, Serialize, Deserialize)]
|
||||
pub struct StatusCodeFilter {
|
||||
/// Status code that should not be displayed to the user
|
||||
pub filter_code: u16,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for StatusCodeFilter
|
||||
impl FeroxFilter for StatusCodeFilter {
|
||||
/// Check `filter_code` against what was passed in via -C|--filter-status
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
if response.status().as_u16() == self.filter_code {
|
||||
log::debug!(
|
||||
"filtered out {} based on --filter-status of {}",
|
||||
response.url(),
|
||||
self.filter_code
|
||||
);
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one StatusCodeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
269
src/filters/tests.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use super::*;
|
||||
use ::fuzzyhash::FuzzyHash;
|
||||
use ::regex::Regex;
|
||||
|
||||
#[test]
|
||||
/// simply test the default values for wildcardfilter, expect 0, 0
|
||||
fn wildcard_filter_default() {
|
||||
let wcf = WildcardFilter::default();
|
||||
assert_eq!(wcf.size, u64::MAX);
|
||||
assert_eq!(wcf.dynamic, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn wildcard_filter_as_any() {
|
||||
let filter = WildcardFilter::default();
|
||||
let filter2 = WildcardFilter::default();
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<WildcardFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn lines_filter_as_any() {
|
||||
let filter = LinesFilter { line_count: 1 };
|
||||
let filter2 = LinesFilter { line_count: 1 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.line_count, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn words_filter_as_any() {
|
||||
let filter = WordsFilter { word_count: 1 };
|
||||
let filter2 = WordsFilter { word_count: 1 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.word_count, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn size_filter_as_any() {
|
||||
let filter = SizeFilter { content_length: 1 };
|
||||
let filter2 = SizeFilter { content_length: 1 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.content_length, 1);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn status_code_filter_as_any() {
|
||||
let filter = StatusCodeFilter { filter_code: 200 };
|
||||
let filter2 = StatusCodeFilter { filter_code: 200 };
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.filter_code, 200);
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn regex_filter_as_any() {
|
||||
let raw = r".*\.txt$";
|
||||
let compiled = Regex::new(raw).unwrap();
|
||||
let compiled2 = Regex::new(raw).unwrap();
|
||||
let filter = RegexFilter {
|
||||
compiled,
|
||||
raw_string: raw.to_string(),
|
||||
};
|
||||
let filter2 = RegexFilter {
|
||||
compiled: compiled2,
|
||||
raw_string: raw.to_string(),
|
||||
};
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.raw_string, r".*\.txt$");
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where static logic matches
|
||||
fn wildcard_should_filter_when_static_wildcard_found() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost");
|
||||
resp.set_text(
|
||||
"pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus faucibus",
|
||||
);
|
||||
|
||||
let filter = WildcardFilter {
|
||||
size: 83,
|
||||
dynamic: 0,
|
||||
dont_filter: false,
|
||||
method: "GET".to_owned(),
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where static logic matches but response length is 0
|
||||
fn wildcard_should_filter_when_static_wildcard_len_is_zero() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost");
|
||||
|
||||
// default WildcardFilter is used in the code that executes when response.content_length() == 0
|
||||
let filter = WildcardFilter::new(false);
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where dynamic logic matches
|
||||
fn wildcard_should_filter_when_dynamic_wildcard_found() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost/stuff");
|
||||
resp.set_text("pellentesque diam volutpat commodo sed egestas egestas fringilla");
|
||||
|
||||
let filter = WildcardFilter {
|
||||
size: 0,
|
||||
dynamic: 59, // content-length - 5 (len('stuff'))
|
||||
dont_filter: false,
|
||||
method: "GET".to_owned(),
|
||||
};
|
||||
|
||||
println!("resp: {:?}: filter: {:?}", resp, filter);
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on RegexFilter where regex matches body
|
||||
fn regexfilter_should_filter_when_regex_matches_on_response_body() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_url("http://localhost/stuff");
|
||||
resp.set_text("im a body response hurr durr!");
|
||||
|
||||
let raw = r"response...rr";
|
||||
|
||||
let filter = RegexFilter {
|
||||
raw_string: raw.to_string(),
|
||||
compiled: Regex::new(raw).unwrap(),
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// a few simple tests for similarity filter
|
||||
fn similarity_filter_is_accurate() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_url("http://localhost/stuff");
|
||||
resp.set_text("sitting");
|
||||
|
||||
let mut filter = SimilarityFilter {
|
||||
hash: FuzzyHash::new("kitten").to_string(),
|
||||
threshold: 95,
|
||||
original_url: "".to_string(),
|
||||
};
|
||||
|
||||
// kitten/sitting is 57% similar, so a threshold of 95 should not be filtered
|
||||
assert!(!filter.should_filter_response(&resp));
|
||||
|
||||
resp.set_text("");
|
||||
filter.hash = String::new();
|
||||
filter.threshold = 100;
|
||||
|
||||
// two empty strings are the same, however ssdeep doesn't accept empty strings, expect false
|
||||
assert!(!filter.should_filter_response(&resp));
|
||||
|
||||
resp.set_text("some data to hash for the purposes of running a test");
|
||||
filter.hash = FuzzyHash::new("some data to hash for the purposes of running a te").to_string();
|
||||
filter.threshold = 17;
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn similarity_filter_as_any() {
|
||||
let filter = SimilarityFilter {
|
||||
hash: String::from("stuff"),
|
||||
threshold: 95,
|
||||
original_url: "".to_string(),
|
||||
};
|
||||
|
||||
let filter2 = SimilarityFilter {
|
||||
hash: String::from("stuff"),
|
||||
threshold: 95,
|
||||
original_url: "".to_string(),
|
||||
};
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(filter.hash, "stuff");
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test correctness of FeroxFilters::remove
|
||||
fn remove_function_works_as_expected() {
|
||||
let data = FeroxFilters::default();
|
||||
assert!(data.filters.read().unwrap().is_empty());
|
||||
|
||||
(0..8).for_each(|i| {
|
||||
data.push(Box::new(WordsFilter { word_count: i })).unwrap();
|
||||
});
|
||||
|
||||
// remove removes index-1 from the vec, zero is skipped, and out-of-bounds indices are skipped
|
||||
data.remove(&mut [0]);
|
||||
assert_eq!(data.filters.read().unwrap().len(), 8);
|
||||
|
||||
data.remove(&mut [10000]);
|
||||
assert_eq!(data.filters.read().unwrap().len(), 8);
|
||||
|
||||
// removing 0, 2, 4
|
||||
data.remove(&mut [1, 3, 5]);
|
||||
|
||||
assert_eq!(data.filters.read().unwrap().len(), 5);
|
||||
|
||||
let expected = vec![
|
||||
WordsFilter { word_count: 1 },
|
||||
WordsFilter { word_count: 3 },
|
||||
WordsFilter { word_count: 5 },
|
||||
WordsFilter { word_count: 6 },
|
||||
WordsFilter { word_count: 7 },
|
||||
];
|
||||
|
||||
for filter in data.filters.read().unwrap().iter() {
|
||||
let downcast = filter.as_any().downcast_ref::<WordsFilter>().unwrap();
|
||||
assert!(expected.contains(downcast));
|
||||
}
|
||||
}
|
||||
204
src/filters/utils.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use super::FeroxFilter;
|
||||
use super::SimilarityFilter;
|
||||
use crate::event_handlers::Handles;
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::utils::logged_request;
|
||||
use crate::{DEFAULT_METHOD, SIMILARITY_THRESHOLD};
|
||||
use anyhow::Result;
|
||||
use fuzzyhash::FuzzyHash;
|
||||
use regex::Regex;
|
||||
use reqwest::Url;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// wrapper around logic necessary to create a SimilarityFilter
|
||||
///
|
||||
/// - parses given url
|
||||
/// - makes request to the parsed url
|
||||
/// - gathers extensions from the url, if configured to do so
|
||||
/// - computes hash of response body
|
||||
/// - creates filter with hash
|
||||
pub(crate) async fn create_similarity_filter(
|
||||
similarity_filter: &str,
|
||||
handles: Arc<Handles>,
|
||||
) -> Result<SimilarityFilter> {
|
||||
// url as-is based on input, ignores user-specified url manipulation options (add-slash etc)
|
||||
let url = Url::parse(similarity_filter)?;
|
||||
|
||||
// attempt to request the given url
|
||||
let resp = logged_request(&url, DEFAULT_METHOD, None, handles.clone()).await?;
|
||||
|
||||
// if successful, create a filter based on the response's body
|
||||
let mut fr = FeroxResponse::from(
|
||||
resp,
|
||||
similarity_filter,
|
||||
DEFAULT_METHOD,
|
||||
handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
|
||||
if handles.config.collect_extensions {
|
||||
fr.parse_extension(handles.clone())?;
|
||||
}
|
||||
|
||||
// hash the response body and store the resulting hash in the filter object
|
||||
let hash = FuzzyHash::new(&fr.text()).to_string();
|
||||
|
||||
Ok(SimilarityFilter {
|
||||
hash,
|
||||
threshold: SIMILARITY_THRESHOLD,
|
||||
original_url: similarity_filter.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// used in conjunction with the Scan Management Menu
|
||||
///
|
||||
/// when a user uses the n[ew-filter] command in the menu, the two params are passed here for
|
||||
/// processing.
|
||||
///
|
||||
/// an example command may be `new-filter lines 40`. `lines` and `40` are passed here as &str's
|
||||
///
|
||||
/// once here, the type and value are used to create an appropriate FeroxFilter. If anything
|
||||
/// goes wrong during creation, None is returned.
|
||||
pub(crate) fn filter_lookup(filter_type: &str, filter_value: &str) -> Option<Box<dyn FeroxFilter>> {
|
||||
match filter_type {
|
||||
"status" => {
|
||||
if let Ok(parsed) = filter_value.parse() {
|
||||
return Some(Box::new(super::StatusCodeFilter {
|
||||
filter_code: parsed,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"lines" => {
|
||||
if let Ok(parsed) = filter_value.parse() {
|
||||
return Some(Box::new(super::LinesFilter { line_count: parsed }));
|
||||
}
|
||||
}
|
||||
"size" => {
|
||||
if let Ok(parsed) = filter_value.parse() {
|
||||
return Some(Box::new(super::SizeFilter {
|
||||
content_length: parsed,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"words" => {
|
||||
if let Ok(parsed) = filter_value.parse() {
|
||||
return Some(Box::new(super::WordsFilter { word_count: parsed }));
|
||||
}
|
||||
}
|
||||
"regex" => {
|
||||
if let Ok(parsed) = Regex::new(filter_value) {
|
||||
return Some(Box::new(super::RegexFilter {
|
||||
compiled: parsed,
|
||||
raw_string: filter_value.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
"similarity" => {
|
||||
return Some(Box::new(SimilarityFilter {
|
||||
hash: String::new(),
|
||||
threshold: SIMILARITY_THRESHOLD,
|
||||
original_url: filter_value.to_string(),
|
||||
}));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Configuration;
|
||||
use crate::filters::{LinesFilter, RegexFilter, SizeFilter, StatusCodeFilter, WordsFilter};
|
||||
use crate::scan_manager::FeroxScans;
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::MockServer;
|
||||
|
||||
#[test]
|
||||
/// filter_lookup returns correct filters
|
||||
fn filter_lookup_returns_correct_filters() {
|
||||
let filter = filter_lookup("status", "200").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
|
||||
&StatusCodeFilter { filter_code: 200 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("lines", "10").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
|
||||
&LinesFilter { line_count: 10 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("size", "20").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
|
||||
&SizeFilter { content_length: 20 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("words", "30").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
|
||||
&WordsFilter { word_count: 30 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("regex", "stuff.*").unwrap();
|
||||
let compiled = Regex::new("stuff.*").unwrap();
|
||||
let raw_string = String::from("stuff.*");
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
|
||||
&RegexFilter {
|
||||
compiled,
|
||||
raw_string
|
||||
}
|
||||
);
|
||||
|
||||
let filter = filter_lookup("similarity", "http://localhost").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
|
||||
&SimilarityFilter {
|
||||
hash: String::new(),
|
||||
threshold: SIMILARITY_THRESHOLD,
|
||||
original_url: "http://localhost".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
assert!(filter_lookup("non-existent", "").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// ensure create_similarity_filter correctness of return value and side-effects
|
||||
async fn create_similarity_filter_is_correct() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/");
|
||||
then.status(200).body("this is a test");
|
||||
});
|
||||
|
||||
let scans = FeroxScans::default();
|
||||
let config = Configuration {
|
||||
collect_extensions: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (test_handles, _) = Handles::for_testing(Some(Arc::new(scans)), Some(Arc::new(config)));
|
||||
|
||||
let handles = Arc::new(test_handles);
|
||||
|
||||
let filter = create_similarity_filter(&srv.url("/"), handles.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
|
||||
assert_eq!(
|
||||
filter,
|
||||
SimilarityFilter {
|
||||
hash: "3:YKEpn:Yfp".to_string(),
|
||||
threshold: SIMILARITY_THRESHOLD,
|
||||
original_url: srv.url("/")
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
118
src/filters/wildcard.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use super::*;
|
||||
use crate::{url::FeroxUrl, DEFAULT_METHOD};
|
||||
|
||||
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
|
||||
///
|
||||
/// `dynamic` is the size of the response that will later be combined with the length
|
||||
/// of the path of the url requested and used to determine interesting pages from custom
|
||||
/// 404s where the requested url is reflected back in the response
|
||||
///
|
||||
/// `size` is size of the response that should be included with filters passed via runtime
|
||||
/// configuration and any static wildcard lengths.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WildcardFilter {
|
||||
/// size of the response that will later be combined with the length of the path of the url
|
||||
/// requested
|
||||
pub dynamic: u64,
|
||||
|
||||
/// size of the response that should be included with filters passed via runtime configuration
|
||||
pub size: u64,
|
||||
|
||||
/// method used in request that should be included with filters passed via runtime configuration
|
||||
pub method: String,
|
||||
|
||||
/// whether or not the user passed -D on the command line
|
||||
pub(super) dont_filter: bool,
|
||||
}
|
||||
|
||||
/// implementation of WildcardFilter
|
||||
impl WildcardFilter {
|
||||
/// given a boolean representing whether -D was used or not, create a new WildcardFilter
|
||||
pub fn new(dont_filter: bool) -> Self {
|
||||
Self {
|
||||
dont_filter,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// implement default that populates both values with u64::MAX
|
||||
impl Default for WildcardFilter {
|
||||
/// populate both values with u64::MAX
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dont_filter: false,
|
||||
size: u64::MAX,
|
||||
method: DEFAULT_METHOD.to_owned(),
|
||||
dynamic: u64::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for WildcardFilter
|
||||
impl FeroxFilter for WildcardFilter {
|
||||
/// Examine size, dynamic, and content_len to determine whether or not the response received
|
||||
/// is a wildcard response and therefore should be filtered out
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
// quick return if dont_filter is set
|
||||
if self.dont_filter {
|
||||
// --dont-filter applies specifically to wildcard filters, it is not a 100% catch all
|
||||
// for not filtering anything. As such, it should live in the implementation of
|
||||
// a wildcard filter
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.size != u64::MAX
|
||||
&& self.size == response.content_length()
|
||||
&& self.method == response.method().as_str()
|
||||
{
|
||||
// static wildcard size found during testing
|
||||
// size isn't default, size equals response length, and auto-filter is on
|
||||
log::debug!("static wildcard: filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.size == u64::MAX
|
||||
&& response.content_length() == 0
|
||||
&& self.method == response.method().as_str()
|
||||
{
|
||||
// static wildcard size found during testing
|
||||
// but response length was zero; pointed out by @Tib3rius
|
||||
log::debug!("static wildcard: filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.dynamic != u64::MAX {
|
||||
// dynamic wildcard offset found during testing
|
||||
|
||||
// I'm about to manually split this url path instead of using reqwest::Url's
|
||||
// builtin parsing. The reason is that they call .split() on the url path
|
||||
// except that I don't want an empty string taking up the last index in the
|
||||
// event that the url ends with a forward slash. It's ugly enough to be split
|
||||
// into its own function for readability.
|
||||
let url_len = FeroxUrl::path_length_of_url(response.url());
|
||||
|
||||
if url_len + self.dynamic == response.content_length() {
|
||||
log::debug!("dynamic wildcard: filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one WildcardFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
33
src/filters/words.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
/// Simple implementor of FeroxFilter; used to filter out responses based on the number of words
|
||||
/// in a Response body; specified using -W|--filter-words
|
||||
#[derive(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WordsFilter {
|
||||
/// Number of words in a Response's body that should be filtered
|
||||
pub word_count: usize,
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for WordsFilter
|
||||
impl FeroxFilter for WordsFilter {
|
||||
/// Check `word_count` against what was passed in via -W|--filter-words
|
||||
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
|
||||
log::trace!("enter: should_filter_response({:?} {})", self, response);
|
||||
|
||||
let result = response.word_count() == self.word_count;
|
||||
|
||||
log::trace!("exit: should_filter_response -> {}", result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare one WordsFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>().map_or(false, |a| self == a)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1,247 +1,424 @@
|
||||
use crate::{
|
||||
config::{CONFIGURATION, PROGRESS_PRINTER},
|
||||
filters::WildcardFilter,
|
||||
scanner::should_filter_response,
|
||||
utils::{ferox_print, format_url, get_url_path_length, make_request, status_colorizer},
|
||||
FeroxResponse,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use console::style;
|
||||
use indicatif::ProgressBar;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use scraper::{Html, Selector};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::message::FeroxMessage;
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
event_handlers::{Command, Handles},
|
||||
filters::WildcardFilter,
|
||||
progress::PROGRESS_PRINTER,
|
||||
response::FeroxResponse,
|
||||
skip_fail,
|
||||
url::FeroxUrl,
|
||||
utils::{ferox_print, fmt_err, logged_request, status_colorizer},
|
||||
DEFAULT_METHOD,
|
||||
};
|
||||
|
||||
/// length of a standard UUID, used when determining wildcard responses
|
||||
const UUID_LENGTH: u64 = 32;
|
||||
|
||||
/// Simple helper to return a uuid, formatted as lowercase without hyphens
|
||||
///
|
||||
/// `length` determines the number of uuids to string together. Each uuid
|
||||
/// is 32 characters long. So, a length of 1 return a 32 character string,
|
||||
/// a length of 2 returns a 64 character string, and so on...
|
||||
fn unique_string(length: usize) -> String {
|
||||
log::trace!("enter: unique_string({})", length);
|
||||
let mut ids = vec![];
|
||||
|
||||
for _ in 0..length {
|
||||
ids.push(Uuid::new_v4().to_simple().to_string());
|
||||
}
|
||||
|
||||
let unique_id = ids.join("");
|
||||
|
||||
log::trace!("exit: unique_string -> {}", unique_id);
|
||||
unique_id
|
||||
/// wrapper around ugly string formatting
|
||||
macro_rules! format_template {
|
||||
($template:expr, $method:expr, $length:expr) => {
|
||||
format!(
|
||||
$template,
|
||||
status_colorizer("WLD"),
|
||||
$method,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
style("auto-filtering").yellow(),
|
||||
style($length).cyan(),
|
||||
style("--dont-filter").yellow()
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Tests the given url to see if it issues a wildcard response
|
||||
///
|
||||
/// In the event that url returns a wildcard response, a
|
||||
/// [WildcardFilter](struct.WildcardFilter.html) is created and returned to the caller.
|
||||
pub async fn wildcard_test(
|
||||
target_url: &str,
|
||||
bar: ProgressBar,
|
||||
tx_term: UnboundedSender<FeroxResponse>,
|
||||
) -> Option<WildcardFilter> {
|
||||
log::trace!(
|
||||
"enter: wildcard_test({:?}, {:?}, {:?})",
|
||||
target_url,
|
||||
bar,
|
||||
tx_term
|
||||
);
|
||||
/// enum representing the different servers that `parse_html` can detect when directory listing is
|
||||
/// enabled
|
||||
#[derive(Copy, Debug, Clone)]
|
||||
pub enum DirListingType {
|
||||
/// apache server, detected by `Index of /`
|
||||
Apache,
|
||||
|
||||
if CONFIGURATION.dont_filter {
|
||||
// early return, dont_filter scans don't need tested
|
||||
log::trace!("exit: wildcard_test -> None");
|
||||
return None;
|
||||
/// tomcat/python server, detected by `Directory Listing for /`
|
||||
TomCatOrPython,
|
||||
|
||||
/// ASP.NET server, detected by `Directory Listing -- /`
|
||||
AspDotNet,
|
||||
|
||||
// /// IIS/Azure server, detected by `HOST_NAME - /` (not currently used)
|
||||
// IIS_AZURE,
|
||||
/// variant that represents the absence of directory listing
|
||||
None,
|
||||
}
|
||||
|
||||
/// Wrapper around the results of running a directory listing detection against a target web page
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirListingResult {
|
||||
/// type of server where directory listing was detected
|
||||
/// i.e. https://portswigger.net/kb/issues/00600100_directory-listing
|
||||
pub dir_list_type: Option<DirListingType>,
|
||||
|
||||
/// the `FeroxResponse` generated during detection
|
||||
pub response: FeroxResponse,
|
||||
}
|
||||
|
||||
/// container for heuristics related info
|
||||
pub struct HeuristicTests {
|
||||
/// Handles object for event handler interaction
|
||||
handles: Arc<Handles>,
|
||||
}
|
||||
|
||||
/// HeuristicTests implementation
|
||||
impl HeuristicTests {
|
||||
/// create a new HeuristicTests struct
|
||||
pub fn new(handles: Arc<Handles>) -> Self {
|
||||
Self { handles }
|
||||
}
|
||||
|
||||
let tx_clone_one = tx_term.clone();
|
||||
let tx_clone_two = tx_term.clone();
|
||||
/// Simple helper to return a uuid, formatted as lowercase without hyphens
|
||||
///
|
||||
/// `length` determines the number of uuids to string together. Each uuid
|
||||
/// is 32 characters long. So, a length of 1 return a 32 character string,
|
||||
/// a length of 2 returns a 64 character string, and so on...
|
||||
fn unique_string(&self, length: usize) -> String {
|
||||
log::trace!("enter: unique_string({})", length);
|
||||
let mut ids = vec![];
|
||||
|
||||
if let Some(ferox_response) = make_wildcard_request(&target_url, 1, tx_clone_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, tx_clone_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!(
|
||||
"{} {:>9} {:>9} {:>9} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n",
|
||||
status_colorizer("WLD"),
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
style("auto-filtering").yellow(),
|
||||
style(wc_length - url_len).cyan(),
|
||||
style("--dont-filter").yellow()
|
||||
);
|
||||
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
let msg = format_template!("{} {:>8} {:>9} {:>9} {:>9} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n", method, wildcard.dynamic);
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
}
|
||||
} else if wc_length == wc2_length {
|
||||
wildcard.size = wc_length;
|
||||
|
||||
if !CONFIGURATION.quiet {
|
||||
let msg = format!(
|
||||
"{} {:>9} {:>9} {:>9} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n",
|
||||
status_colorizer("WLD"),
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
style("auto-filtering").yellow(),
|
||||
style(wc_length).cyan(),
|
||||
style("--dont-filter").yellow()
|
||||
);
|
||||
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
let msg = format_template!("{} {:>8} {:>9} {:>9} {:>9} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n", method, wildcard.size);
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
}
|
||||
}
|
||||
|
||||
self.send_filter(wildcard)?;
|
||||
}
|
||||
|
||||
log::trace!("exit: wildcard_test");
|
||||
Ok(2)
|
||||
}
|
||||
|
||||
/// Generates a uuid and appends it to the given target url. The reasoning is that the randomly
|
||||
/// generated unique string should not exist on and be served by the target web server.
|
||||
///
|
||||
/// Once the unique url is created, the request is sent to the server. If the server responds
|
||||
/// back with a valid status code, the response is considered to be a wildcard response. If that
|
||||
/// wildcard response has a 3xx status code, that redirection location is displayed to the user.
|
||||
async fn make_wildcard_request(
|
||||
&self,
|
||||
target: &FeroxUrl,
|
||||
method: &str,
|
||||
data: Option<&[u8]>,
|
||||
length: usize,
|
||||
) -> Result<FeroxResponse> {
|
||||
log::trace!("enter: make_wildcard_request({}, {})", target, length);
|
||||
|
||||
let unique_str = self.unique_string(length);
|
||||
|
||||
// To take care of slash when needed
|
||||
let slash = if self.handles.config.add_slash {
|
||||
Some("/")
|
||||
} else {
|
||||
bar.inc(2);
|
||||
}
|
||||
|
||||
log::trace!("exit: wildcard_test -> Some({:?})", wildcard);
|
||||
return Some(wildcard);
|
||||
}
|
||||
|
||||
log::trace!("exit: wildcard_test -> None");
|
||||
None
|
||||
}
|
||||
|
||||
/// Generates a uuid and appends it to the given target url. The reasoning is that the randomly
|
||||
/// generated unique string should not exist on and be served by the target web server.
|
||||
///
|
||||
/// Once the unique url is created, the request is sent to the server. If the server responds
|
||||
/// back with a valid status code, the response is considered to be a wildcard response. If that
|
||||
/// wildcard response has a 3xx status code, that redirection location is displayed to the user.
|
||||
async fn make_wildcard_request(
|
||||
target_url: &str,
|
||||
length: usize,
|
||||
tx_file: UnboundedSender<FeroxResponse>,
|
||||
) -> Option<FeroxResponse> {
|
||||
log::trace!(
|
||||
"enter: make_wildcard_request({}, {}, {:?})",
|
||||
target_url,
|
||||
length,
|
||||
tx_file
|
||||
);
|
||||
|
||||
let unique_str = unique_string(length);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
match make_request(&CONFIGURATION.client, &nonexistent.to_owned()).await {
|
||||
Ok(response) => {
|
||||
if CONFIGURATION
|
||||
.status_codes
|
||||
.contains(&response.status().as_u16())
|
||||
{
|
||||
// found a wildcard response
|
||||
let mut ferox_response = FeroxResponse::from(response, true).await;
|
||||
ferox_response.wildcard = true;
|
||||
|
||||
if !CONFIGURATION.quiet
|
||||
&& !should_filter_response(&ferox_response)
|
||||
&& tx_file.send(ferox_response.clone()).is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
log::trace!("exit: make_wildcard_request -> {}", ferox_response);
|
||||
return Some(ferox_response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{}", e);
|
||||
log::trace!("exit: make_wildcard_request -> None");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
log::trace!("exit: make_wildcard_request -> None");
|
||||
None
|
||||
}
|
||||
|
||||
/// Simply tries to connect to all given sites before starting to scan
|
||||
///
|
||||
/// In the event that no sites can be reached, the program will exit.
|
||||
///
|
||||
/// Any urls that are found to be alive are returned to the caller.
|
||||
pub async fn connectivity_test(target_urls: &[String]) -> 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;
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
match make_request(&CONFIGURATION.client, &request).await {
|
||||
Ok(_) => {
|
||||
good_urls.push(target_url.to_owned());
|
||||
let nonexistent_url = target.format(&unique_str, slash)?;
|
||||
|
||||
let response = logged_request(
|
||||
&nonexistent_url.to_owned(),
|
||||
method,
|
||||
data,
|
||||
self.handles.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if self
|
||||
.handles
|
||||
.config
|
||||
.status_codes
|
||||
.contains(&response.status().as_u16())
|
||||
{
|
||||
// found a wildcard response
|
||||
|
||||
let mut ferox_response = FeroxResponse::from(
|
||||
response,
|
||||
&target.target,
|
||||
method,
|
||||
self.handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
ferox_response.set_wildcard(true);
|
||||
|
||||
if self
|
||||
.handles
|
||||
.filters
|
||||
.data
|
||||
.should_filter_response(&ferox_response, self.handles.stats.tx.clone())
|
||||
{
|
||||
bail!("filtered response")
|
||||
}
|
||||
Err(e) => {
|
||||
if !CONFIGURATION.quiet {
|
||||
ferox_print(
|
||||
&format!("Could not connect to {}, skipping...", target_url),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
let boxed = Box::new(ferox_response.clone());
|
||||
self.handles.output.send(Command::Report(boxed))?;
|
||||
}
|
||||
|
||||
log::trace!("exit: make_wildcard_request -> {}", ferox_response);
|
||||
return Ok(ferox_response);
|
||||
}
|
||||
|
||||
log::trace!("exit: make_wildcard_request -> Err");
|
||||
bail!("uninteresting status code")
|
||||
}
|
||||
|
||||
/// Simply tries to connect to all given sites before starting to scan
|
||||
///
|
||||
/// In the event that no sites can be reached, the program will exit.
|
||||
///
|
||||
/// Any urls that are found to be alive are returned to the caller.
|
||||
pub async fn connectivity(&self, target_urls: &[String]) -> Result<Vec<String>> {
|
||||
log::trace!("enter: connectivity_test({:?})", target_urls);
|
||||
|
||||
let mut good_urls = vec![];
|
||||
|
||||
for target_url in target_urls {
|
||||
let url = FeroxUrl::from_string(target_url, self.handles.clone());
|
||||
let request = skip_fail!(url.format("", None));
|
||||
|
||||
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
good_urls.push(target_url.to_owned());
|
||||
}
|
||||
Err(e) => {
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
if e.to_string().contains(":SSL") {
|
||||
ferox_print(
|
||||
&format!("Could not connect to {} due to SSL errors (run with -k to ignore), skipping...", target_url),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
} else {
|
||||
ferox_print(
|
||||
&format!("Could not connect to {}, skipping...", target_url),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
}
|
||||
}
|
||||
log::warn!("{}", e);
|
||||
}
|
||||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if good_urls.is_empty() {
|
||||
bail!("Could not connect to any target provided");
|
||||
}
|
||||
|
||||
log::trace!("exit: connectivity_test -> {:?}", good_urls);
|
||||
Ok(good_urls)
|
||||
}
|
||||
|
||||
if good_urls.is_empty() {
|
||||
log::error!("Could not connect to any target provided, exiting.");
|
||||
/// heuristic designed to detect when a server has directory listing enabled
|
||||
pub async fn directory_listing(&self, target_url: &str) -> Result<Option<DirListingResult>> {
|
||||
log::trace!("enter: directory_listing({})", target_url);
|
||||
|
||||
let tgt = if !target_url.ends_with('/') {
|
||||
// if left unchanged, this function would be called against redirects that point to
|
||||
// valid directories for most, if not all, directories beyond the initial urls.
|
||||
// so, instead of `directory_listing("http://localhost") -> None` we get
|
||||
// `directory_listing("http://localhost/") -> Some(DirListingResult)` if there is
|
||||
// directory listing beyond the redirect
|
||||
format!("{}/", target_url)
|
||||
} else {
|
||||
target_url.to_string()
|
||||
};
|
||||
|
||||
let url = FeroxUrl::from_string(&tgt, self.handles.clone());
|
||||
let request = url.format("", None)?;
|
||||
|
||||
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await?;
|
||||
|
||||
let ferox_response = FeroxResponse::from(
|
||||
result,
|
||||
&url.target,
|
||||
DEFAULT_METHOD,
|
||||
self.handles.config.output_level,
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = ferox_response.text();
|
||||
let html = Html::parse_document(body);
|
||||
|
||||
let dirlist_type = self.detect_directory_listing(&html);
|
||||
|
||||
if dirlist_type.is_some() {
|
||||
// folks that run things and step away/rely on logs need to be notified of directory
|
||||
// listing, since they won't see the message on the bar; bastardizing FeroxMessage
|
||||
// for ease of implementation. This could use a bit of polish at some point.
|
||||
let msg = format!(
|
||||
"detected directory listing: {} ({:?})",
|
||||
target_url,
|
||||
dirlist_type.unwrap()
|
||||
);
|
||||
let ferox_msg = FeroxMessage {
|
||||
kind: "log".to_string(),
|
||||
message: msg.clone(),
|
||||
level: "MSG".to_string(),
|
||||
time_offset: 0.0,
|
||||
module: "feroxbuster::heuristics".to_string(),
|
||||
};
|
||||
self.handles
|
||||
.output
|
||||
.tx_file
|
||||
.send(Command::WriteToDisk(Box::new(ferox_msg)))
|
||||
.unwrap_or_default();
|
||||
|
||||
log::info!("{}", msg);
|
||||
|
||||
let result = DirListingResult {
|
||||
dir_list_type: dirlist_type,
|
||||
response: ferox_response,
|
||||
};
|
||||
|
||||
log::trace!("exit: directory_listing -> {:?}", result);
|
||||
return Ok(Some(result));
|
||||
}
|
||||
|
||||
log::trace!("exit: directory_listing -> None");
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
log::trace!("exit: connectivity_test -> {:?}", good_urls);
|
||||
/// Directory listing heuristic detection, uses <title> tag to make its determination. When
|
||||
/// the inner html of <title> matches one of the following, a `DirListingType` is returned.
|
||||
/// - apache: `Index of /`
|
||||
/// - tomcat/python: `Directory Listing for /`
|
||||
/// - ASP.NET: `Directory Listing -- /`
|
||||
/// - <host> - /: iis, azure, skipping due to loose heuristic
|
||||
fn detect_directory_listing(&self, html: &Html) -> Option<DirListingType> {
|
||||
log::trace!("enter: detect_directory_listing(html body...)");
|
||||
|
||||
good_urls
|
||||
let title_selector = Selector::parse("title").expect("couldn't parse title selector");
|
||||
|
||||
for t in html.select(&title_selector) {
|
||||
let title = t.inner_html().to_lowercase();
|
||||
|
||||
let dirlist_type = if title.contains("directory listing for /") {
|
||||
Some(DirListingType::TomCatOrPython)
|
||||
} else if title.contains("index of /") {
|
||||
Some(DirListingType::Apache)
|
||||
} else if title.contains("directory listing -- /") {
|
||||
Some(DirListingType::AspDotNet)
|
||||
} else {
|
||||
// IIS_AZURE purposely skipped for now
|
||||
None
|
||||
};
|
||||
|
||||
if dirlist_type.is_some() {
|
||||
log::trace!("exit: detect_directory_listing -> {:?}", dirlist_type);
|
||||
return dirlist_type;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: detect_directory_listing -> None");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -251,16 +428,57 @@ mod tests {
|
||||
#[test]
|
||||
/// request a unique string of 32bytes * a value returns correct result
|
||||
fn heuristics_unique_string_returns_correct_length() {
|
||||
let (handles, _) = Handles::for_testing(None, None);
|
||||
let tester = HeuristicTests::new(Arc::new(handles));
|
||||
for i in 0..10 {
|
||||
assert_eq!(unique_string(i).len(), i * 32);
|
||||
assert_eq!(tester.unique_string(i).len(), i * 32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// simply test the default values for wildcardfilter, expect 0, 0
|
||||
fn heuristics_wildcardfilter_dafaults() {
|
||||
let wcf = WildcardFilter::default();
|
||||
assert_eq!(wcf.size, 0);
|
||||
assert_eq!(wcf.dynamic, 0);
|
||||
/// `detect_directory_listing` correctly identifies tomcat/python instances
|
||||
fn detect_directory_listing_finds_tomcat_python() {
|
||||
let html = "<title>directory listing for /</title>";
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(matches!(
|
||||
dirlist_type.unwrap(),
|
||||
DirListingType::TomCatOrPython
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `detect_directory_listing` correctly identifies apache instances
|
||||
fn detect_directory_listing_finds_apache() {
|
||||
let html = "<title>index of /</title>";
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(matches!(dirlist_type.unwrap(), DirListingType::Apache));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `detect_directory_listing` correctly identifies ASP.NET instances
|
||||
fn detect_directory_listing_finds_asp_dot_net() {
|
||||
let html = "<title>directory listing -- /</title>";
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(matches!(dirlist_type.unwrap(), DirListingType::AspDotNet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `detect_directory_listing` returns None when heuristic doesn't match
|
||||
fn detect_directory_listing_returns_none_as_default() {
|
||||
let html = "<title>derp listing -- /</title>";
|
||||
let parsed = Html::parse_document(html);
|
||||
let handles = Handles::for_testing(None, None);
|
||||
let heuristics = HeuristicTests::new(Arc::new(handles.0));
|
||||
let dirlist_type = heuristics.detect_directory_listing(&parsed);
|
||||
assert!(dirlist_type.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
585
src/lib.rs
@@ -1,55 +1,66 @@
|
||||
#![deny(clippy::all)]
|
||||
#![allow(clippy::mutex_atomic)]
|
||||
use anyhow::Result;
|
||||
use reqwest::StatusCode;
|
||||
use std::collections::HashSet;
|
||||
use tokio::{
|
||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
use crate::event_handlers::Command;
|
||||
|
||||
pub mod banner;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod extractor;
|
||||
mod client;
|
||||
pub mod event_handlers;
|
||||
pub mod filters;
|
||||
pub mod heuristics;
|
||||
pub mod logger;
|
||||
pub mod parser;
|
||||
mod parser;
|
||||
pub mod progress;
|
||||
pub mod reporter;
|
||||
pub mod scan_manager;
|
||||
pub mod scanner;
|
||||
pub mod statistics;
|
||||
mod traits;
|
||||
pub mod utils;
|
||||
mod extractor;
|
||||
mod macros;
|
||||
mod url;
|
||||
mod response;
|
||||
mod message;
|
||||
mod nlp;
|
||||
|
||||
use crate::utils::{get_url_path_length, status_colorizer};
|
||||
use console::{style, Color};
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use reqwest::{header::HeaderMap, Response, StatusCode, Url};
|
||||
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
use std::str::FromStr;
|
||||
use std::{error, fmt};
|
||||
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
/// Alias for tokio::sync::mpsc::UnboundedSender<Command>
|
||||
pub(crate) type CommandSender = UnboundedSender<Command>;
|
||||
|
||||
/// Generic Result type to ease error handling in async contexts
|
||||
pub type FeroxResult<T> = std::result::Result<T, Box<dyn error::Error + Send + Sync + 'static>>;
|
||||
/// Alias for tokio::sync::mpsc::UnboundedSender<Command>
|
||||
pub(crate) type CommandReceiver = UnboundedReceiver<Command>;
|
||||
|
||||
/// Simple Error implementation to allow for custom error returns
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeroxError {
|
||||
/// fancy string that can be printed via Display
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl error::Error for FeroxError {}
|
||||
|
||||
impl fmt::Display for FeroxError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", &self.message)
|
||||
}
|
||||
}
|
||||
/// Alias for tokio::task::JoinHandle<anyhow::Result<()>>
|
||||
pub(crate) type Joiner = JoinHandle<Result<()>>;
|
||||
|
||||
/// Generic mpsc::unbounded_channel type to tidy up some code
|
||||
pub type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>);
|
||||
pub(crate) type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>);
|
||||
|
||||
/// Wrapper around the results of performing any kind of extraction against a target web page
|
||||
pub(crate) type ExtractionResult = HashSet<String>;
|
||||
|
||||
/// Version pulled from Cargo.toml at compile time
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Maximum number of file descriptors that can be opened during a scan
|
||||
pub const DEFAULT_OPEN_FILE_LIMIT: usize = 8192;
|
||||
pub const DEFAULT_OPEN_FILE_LIMIT: u64 = 8192;
|
||||
|
||||
/// Default value used to determine near-duplicate web pages (equivalent to 95%)
|
||||
pub const SIMILARITY_THRESHOLD: u32 = 95;
|
||||
|
||||
/// Default set of extensions to Ignore when auto-collecting extensions during scans
|
||||
pub(crate) const DEFAULT_IGNORED_EXTENSIONS: [&str; 38] = [
|
||||
"tif", "tiff", "ico", "cur", "bmp", "webp", "svg", "png", "jpg", "jpeg", "jfif", "gif", "avif",
|
||||
"apng", "pjpeg", "pjp", "mov", "wav", "mpg", "mpeg", "mp3", "mp4", "m4a", "m4p", "m4v", "ogg",
|
||||
"webm", "ogv", "oga", "flac", "aac", "3gp", "css", "zip", "xls", "xml", "gz", "tgz",
|
||||
];
|
||||
|
||||
/// Default wordlist to use when `-w|--wordlist` isn't specified and not `wordlist` isn't set
|
||||
/// in a [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file.
|
||||
@@ -60,7 +71,10 @@ pub const DEFAULT_WORDLIST: &str =
|
||||
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
|
||||
|
||||
/// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan
|
||||
pub static SLEEP_DURATION: u64 = 500;
|
||||
pub(crate) const SLEEP_DURATION: u64 = 500;
|
||||
|
||||
/// The percentage of requests as errors it takes to be deemed too high
|
||||
pub const HIGH_ERROR_RATIO: f64 = 0.90;
|
||||
|
||||
/// Default list of status codes to report
|
||||
///
|
||||
@@ -73,7 +87,8 @@ pub static SLEEP_DURATION: u64 = 500;
|
||||
/// * 401 Unauthorized
|
||||
/// * 403 Forbidden
|
||||
/// * 405 Method Not Allowed
|
||||
pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
|
||||
/// * 500 Internal Server Error
|
||||
pub const DEFAULT_STATUS_CODES: [StatusCode; 10] = [
|
||||
StatusCode::OK,
|
||||
StatusCode::NO_CONTENT,
|
||||
StatusCode::MOVED_PERMANENTLY,
|
||||
@@ -83,449 +98,31 @@ pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::FORBIDDEN,
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
];
|
||||
|
||||
/// Default method for requests
|
||||
pub(crate) const DEFAULT_METHOD: &str = "GET";
|
||||
|
||||
/// Default filename for config file settings
|
||||
///
|
||||
/// Expected location is in the same directory as the feroxbuster binary.
|
||||
pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml";
|
||||
|
||||
/// FeroxSerialize trait; represents different types that are Serialize and also implement
|
||||
/// as_str / as_json methods
|
||||
pub trait FeroxSerialize: Serialize {
|
||||
/// Return a String representation of the object, generally the human readable version of the
|
||||
/// implementor
|
||||
fn as_str(&self) -> String;
|
||||
|
||||
/// Return an NDJSON representation of the object
|
||||
fn as_json(&self) -> String;
|
||||
}
|
||||
|
||||
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeroxResponse {
|
||||
/// The final `Url` of this `FeroxResponse`
|
||||
url: Url,
|
||||
|
||||
/// The `StatusCode` of this `FeroxResponse`
|
||||
status: StatusCode,
|
||||
|
||||
/// The full response text
|
||||
text: String,
|
||||
|
||||
/// The content-length of this response, if known
|
||||
content_length: u64,
|
||||
|
||||
/// The number of lines contained in the body of this response, if known
|
||||
line_count: usize,
|
||||
|
||||
/// The number of words contained in the body of this response, if known
|
||||
word_count: usize,
|
||||
|
||||
/// The `Headers` of this `FeroxResponse`
|
||||
headers: HeaderMap,
|
||||
|
||||
/// Wildcard response status
|
||||
wildcard: bool,
|
||||
}
|
||||
|
||||
/// Implement Display for FeroxResponse
|
||||
impl fmt::Display for FeroxResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"FeroxResponse {{ url: {}, status: {}, content-length: {} }}",
|
||||
self.url(),
|
||||
self.status(),
|
||||
self.content_length()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `FeroxResponse` implementation
|
||||
impl FeroxResponse {
|
||||
/// Get the `StatusCode` of this `FeroxResponse`
|
||||
pub fn status(&self) -> &StatusCode {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get the final `Url` of this `FeroxResponse`.
|
||||
pub fn url(&self) -> &Url {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Get the full response text
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Get the `Headers` of this `FeroxResponse`
|
||||
pub fn headers(&self) -> &HeaderMap {
|
||||
&self.headers
|
||||
}
|
||||
|
||||
/// Get the content-length of this response, if known
|
||||
pub fn content_length(&self) -> u64 {
|
||||
self.content_length
|
||||
}
|
||||
|
||||
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
|
||||
pub fn set_url(&mut self, url: &str) {
|
||||
match Url::parse(&url) {
|
||||
Ok(url) => {
|
||||
self.url = url;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not parse {} into a Url: {}", url, e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Make a reasonable guess at whether the response is a file or not
|
||||
///
|
||||
/// Examines the last part of a path to determine if it has an obvious extension
|
||||
/// i.e. http://localhost/some/path/stuff.js where stuff.js indicates a file
|
||||
///
|
||||
/// Additionally, inspects query parameters, as they're also often indicative of a file
|
||||
pub fn is_file(&self) -> bool {
|
||||
let has_extension = match self.url.path_segments() {
|
||||
Some(path) => {
|
||||
if let Some(last) = path.last() {
|
||||
last.contains('.') // last segment has some sort of extension, probably
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
self.url.query_pairs().count() > 0 || has_extension
|
||||
}
|
||||
|
||||
/// Returns line count of the response text.
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.line_count
|
||||
}
|
||||
|
||||
/// Returns word count of the response text.
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.word_count
|
||||
}
|
||||
|
||||
/// Create a new `FeroxResponse` from the given `Response`
|
||||
pub async fn from(response: Response, read_body: bool) -> Self {
|
||||
let url = response.url().clone();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let content_length = response.content_length().unwrap_or(0);
|
||||
|
||||
let text = if read_body {
|
||||
// .text() consumes the response, must be called last
|
||||
// additionally, --extract-links is currently the only place we use the body of the
|
||||
// response, so we forego the processing if not performing extraction
|
||||
match response.text().await {
|
||||
// await the response's body
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::error!("Could not parse body from response: {}", e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let line_count = text.lines().count();
|
||||
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
|
||||
|
||||
FeroxResponse {
|
||||
url,
|
||||
status,
|
||||
content_length,
|
||||
text,
|
||||
headers,
|
||||
line_count,
|
||||
word_count,
|
||||
wildcard: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement FeroxSerialusize::from(ize for FeroxRespons)e
|
||||
impl FeroxSerialize for FeroxResponse {
|
||||
/// Simple wrapper around create_report_string
|
||||
fn as_str(&self) -> String {
|
||||
let lines = self.line_count().to_string();
|
||||
let words = self.word_count().to_string();
|
||||
let chars = self.content_length().to_string();
|
||||
let status = self.status().as_str();
|
||||
let wild_status = status_colorizer("WLD");
|
||||
|
||||
if self.wildcard {
|
||||
// response is a wildcard, special messages abound when this is the case...
|
||||
|
||||
// create the base message
|
||||
let mut message = format!(
|
||||
"{} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
|
||||
wild_status,
|
||||
lines,
|
||||
words,
|
||||
chars,
|
||||
status_colorizer(&status),
|
||||
self.url(),
|
||||
get_url_path_length(&self.url())
|
||||
);
|
||||
|
||||
if self.status().is_redirection() {
|
||||
// when it's a redirect, show where it goes, if possible
|
||||
if let Some(next_loc) = self.headers().get("Location") {
|
||||
let next_loc_str = next_loc.to_str().unwrap_or("Unknown");
|
||||
|
||||
let redirect_msg = format!(
|
||||
"{} {:>9} {:>9} {:>9} {} redirects to => {}\n",
|
||||
wild_status,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
self.url(),
|
||||
next_loc_str
|
||||
);
|
||||
|
||||
message.push_str(&redirect_msg);
|
||||
}
|
||||
}
|
||||
|
||||
// base message + redirection message (if appropriate)
|
||||
message
|
||||
} else {
|
||||
// not a wildcard, just create a normal entry
|
||||
utils::create_report_string(
|
||||
self.status.as_str(),
|
||||
&lines,
|
||||
&words,
|
||||
&chars,
|
||||
self.url().as_str(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an NDJSON representation of the FeroxResponse
|
||||
///
|
||||
/// (expanded for clarity)
|
||||
/// ex:
|
||||
/// {
|
||||
/// "type":"response",
|
||||
/// "url":"https://localhost.com/images",
|
||||
/// "path":"/images",
|
||||
/// "status":301,
|
||||
/// "content_length":179,
|
||||
/// "line_count":10,
|
||||
/// "word_count":16,
|
||||
/// "headers":{
|
||||
/// "x-content-type-options":"nosniff",
|
||||
/// "strict-transport-security":"max-age=31536000; includeSubDomains",
|
||||
/// "x-frame-options":"SAMEORIGIN",
|
||||
/// "connection":"keep-alive",
|
||||
/// "server":"nginx/1.16.1",
|
||||
/// "content-type":"text/html; charset=UTF-8",
|
||||
/// "referrer-policy":"origin-when-cross-origin",
|
||||
/// "content-security-policy":"default-src 'none'",
|
||||
/// "access-control-allow-headers":"X-Requested-With",
|
||||
/// "x-xss-protection":"1; mode=block",
|
||||
/// "content-length":"179",
|
||||
/// "date":"Mon, 23 Nov 2020 15:33:24 GMT",
|
||||
/// "location":"/images/",
|
||||
/// "access-control-allow-origin":"https://localhost.com"
|
||||
/// }
|
||||
/// }\n
|
||||
fn as_json(&self) -> String {
|
||||
if let Ok(mut json) = serde_json::to_string(&self) {
|
||||
json.push('\n');
|
||||
json
|
||||
} else {
|
||||
format!("{{\"error\":\"could not convert {} to json\"}}", self.url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxResponse
|
||||
impl Serialize for FeroxResponse {
|
||||
/// Function that handles serialization of a FeroxResponse to NDJSON
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut headers = HashMap::new();
|
||||
let mut state = serializer.serialize_struct("FeroxResponse", 7)?;
|
||||
|
||||
// need to convert the HeaderMap to a HashMap in order to pass it to the serializer
|
||||
for (key, value) in &self.headers {
|
||||
let k = key.as_str().to_owned();
|
||||
let v = String::from_utf8_lossy(value.as_bytes());
|
||||
headers.insert(k, v);
|
||||
}
|
||||
|
||||
state.serialize_field("type", "response")?;
|
||||
state.serialize_field("url", self.url.as_str())?;
|
||||
state.serialize_field("path", self.url.path())?;
|
||||
state.serialize_field("wildcard", &self.wildcard)?;
|
||||
state.serialize_field("status", &self.status.as_u16())?;
|
||||
state.serialize_field("content_length", &self.content_length)?;
|
||||
state.serialize_field("line_count", &self.line_count)?;
|
||||
state.serialize_field("word_count", &self.word_count)?;
|
||||
state.serialize_field("headers", &headers)?;
|
||||
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize implementation for FeroxResponse
|
||||
impl<'de> Deserialize<'de> for FeroxResponse {
|
||||
/// Deserialize a FeroxResponse from a serde_json::Value
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut response = Self {
|
||||
url: Url::parse("http://localhost").unwrap(),
|
||||
status: StatusCode::OK,
|
||||
text: String::new(),
|
||||
content_length: 0,
|
||||
headers: HeaderMap::new(),
|
||||
wildcard: false,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
};
|
||||
|
||||
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
|
||||
|
||||
for (key, value) in &map {
|
||||
match key.as_str() {
|
||||
"url" => {
|
||||
if let Some(url) = value.as_str() {
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
response.url = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
"status" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
if let Ok(smaller) = u16::try_from(num) {
|
||||
if let Ok(status) = StatusCode::from_u16(smaller) {
|
||||
response.status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_length" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.content_length = num;
|
||||
}
|
||||
}
|
||||
"line_count" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.line_count = num.try_into().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"word_count" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.word_count = num.try_into().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"headers" => {
|
||||
let mut headers = HeaderMap::<HeaderValue>::default();
|
||||
|
||||
if let Some(map_headers) = value.as_object() {
|
||||
for (h_key, h_value) in map_headers {
|
||||
let h_value_str = h_value.as_str().unwrap_or("");
|
||||
let h_name = HeaderName::from_str(h_key)
|
||||
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
|
||||
let h_value_parsed = HeaderValue::from_str(h_value_str)
|
||||
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
|
||||
headers.insert(h_name, h_value_parsed);
|
||||
}
|
||||
}
|
||||
|
||||
response.headers = headers;
|
||||
}
|
||||
"wildcard" => {
|
||||
if let Some(result) = value.as_bool() {
|
||||
response.wildcard = result;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
/// Representation of a log entry, can be represented as a human readable string or JSON
|
||||
pub struct FeroxMessage {
|
||||
#[serde(rename = "type")]
|
||||
/// Name of this type of struct, used for serialization, i.e. `{"type":"log"}`
|
||||
kind: String,
|
||||
|
||||
/// The log message
|
||||
pub message: String,
|
||||
|
||||
/// The log level
|
||||
pub level: String,
|
||||
|
||||
/// The number of seconds elapsed since the scan started
|
||||
pub time_offset: f32,
|
||||
|
||||
/// The module from which log::* was called
|
||||
pub module: String,
|
||||
}
|
||||
|
||||
/// Implementation of FeroxMessage
|
||||
impl FeroxSerialize for FeroxMessage {
|
||||
/// Create an NDJSON representation of the log message
|
||||
///
|
||||
/// (expanded for clarity)
|
||||
/// ex:
|
||||
/// {
|
||||
/// "type": "log",
|
||||
/// "message": "Sent https://localhost/api to file handler",
|
||||
/// "level": "DEBUG",
|
||||
/// "time_offset": 0.86333454,
|
||||
/// "module": "feroxbuster::reporter"
|
||||
/// }\n
|
||||
fn as_json(&self) -> String {
|
||||
if let Ok(mut json) = serde_json::to_string(&self) {
|
||||
json.push('\n');
|
||||
json
|
||||
} else {
|
||||
String::from("{\"error\":\"could not convert to json\"}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a string representation of the log message
|
||||
///
|
||||
/// ex: 301 10l 16w 173c https://localhost/api
|
||||
fn as_str(&self) -> String {
|
||||
let (level_name, level_color) = match self.level.as_str() {
|
||||
"ERROR" => ("ERR", Color::Red),
|
||||
"WARN" => ("WRN", Color::Red),
|
||||
"INFO" => ("INF", Color::Cyan),
|
||||
"DEBUG" => ("DBG", Color::Yellow),
|
||||
"TRACE" => ("TRC", Color::Magenta),
|
||||
"WILDCARD" => ("WLD", Color::Cyan),
|
||||
_ => ("UNK", Color::White),
|
||||
};
|
||||
|
||||
format!(
|
||||
"{} {:10.03} {} {}\n",
|
||||
style(level_name).bg(level_color).black(),
|
||||
style(self.time_offset).dim(),
|
||||
self.module,
|
||||
style(&self.message).dim(),
|
||||
)
|
||||
}
|
||||
}
|
||||
/// User agents to select from when random agent is being used
|
||||
pub const USER_AGENTS: [&str; 12] = [
|
||||
"Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (Windows Phone 10.0; Android 6.0.1; Microsoft; RM-1152) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Mobile Safari/537.36 Edge/15.15254",
|
||||
"Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
|
||||
"Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9",
|
||||
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1",
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
||||
"Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -551,46 +148,4 @@ mod tests {
|
||||
fn default_version() {
|
||||
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_str method of FeroxMessage
|
||||
fn ferox_message_as_str_returns_string_with_newline() {
|
||||
let message = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "INFO".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
let message_str = message.as_str();
|
||||
|
||||
assert!(message_str.contains("INF"));
|
||||
assert!(message_str.contains("1.000"));
|
||||
assert!(message_str.contains("utils"));
|
||||
assert!(message_str.contains("message"));
|
||||
assert!(message_str.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_json method of FeroxMessage
|
||||
fn ferox_message_as_json_returns_json_representation_of_ferox_message_with_newline() {
|
||||
let message = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "INFO".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
|
||||
let message_str = message.as_json();
|
||||
|
||||
let error_margin = f32::EPSILON;
|
||||
|
||||
let json: FeroxMessage = serde_json::from_str(&message_str).unwrap();
|
||||
assert_eq!(json.module, message.module);
|
||||
assert_eq!(json.message, message.message);
|
||||
assert!((json.time_offset - message.time_offset).abs() < error_margin);
|
||||
assert_eq!(json.level, message.level);
|
||||
assert_eq!(json.kind, message.kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
use crate::{
|
||||
config::{CONFIGURATION, PROGRESS_PRINTER},
|
||||
reporter::safe_file_write,
|
||||
utils::open_file,
|
||||
FeroxMessage, FeroxSerialize,
|
||||
};
|
||||
use env_logger::Builder;
|
||||
use std::env;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::BufWriter;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use env_logger::Builder;
|
||||
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
message::FeroxMessage,
|
||||
progress::PROGRESS_PRINTER,
|
||||
traits::FeroxSerialize,
|
||||
utils::{fmt_err, write_to},
|
||||
};
|
||||
|
||||
/// Create a customized instance of
|
||||
/// [env_logger::Logger](https://docs.rs/env_logger/latest/env_logger/struct.Logger.html)
|
||||
/// with timer offset/color and set the log level based on `verbosity`
|
||||
pub fn initialize(verbosity: u8) {
|
||||
pub fn initialize(config: Arc<Configuration>) -> Result<()> {
|
||||
// use occurrences of -v on commandline to or verbosity = N in feroxconfig.toml to set
|
||||
// log level for the application; respects already specified RUST_LOG environment variable
|
||||
match env::var("RUST_LOG") {
|
||||
Ok(_) => {} // RUST_LOG found, don't override
|
||||
Err(_) => {
|
||||
// only set log level based on verbosity when RUST_LOG variable doesn't exist
|
||||
match verbosity {
|
||||
match config.verbosity {
|
||||
0 => (),
|
||||
1 => env::set_var("RUST_LOG", "warn"),
|
||||
2 => env::set_var("RUST_LOG", "info"),
|
||||
@@ -31,12 +38,22 @@ pub fn initialize(verbosity: u8) {
|
||||
let start = Instant::now();
|
||||
let mut builder = Builder::from_default_env();
|
||||
|
||||
let debug_file = open_file(&CONFIGURATION.debug_log);
|
||||
let file = if !config.debug_log.is_empty() {
|
||||
let f = OpenOptions::new() // std fs
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&config.debug_log)
|
||||
.with_context(|| fmt_err(&format!("Could not open {}", &config.debug_log)))?;
|
||||
|
||||
let mut writer = BufWriter::new(f);
|
||||
|
||||
if let Some(buffered_file) = debug_file.clone() {
|
||||
// write out the configuration to the debug file if it exists
|
||||
safe_file_write(&*CONFIGURATION, buffered_file, CONFIGURATION.json);
|
||||
}
|
||||
write_to(&*config, &mut writer, config.json)?;
|
||||
|
||||
Some(Arc::new(RwLock::new(writer)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
builder
|
||||
.format(move |_, record| {
|
||||
@@ -50,11 +67,15 @@ pub fn initialize(verbosity: u8) {
|
||||
|
||||
PROGRESS_PRINTER.println(&log_entry.as_str());
|
||||
|
||||
if let Some(buffered_file) = debug_file.clone() {
|
||||
safe_file_write(&log_entry, buffered_file, CONFIGURATION.json);
|
||||
if let Some(buffered_file) = file.clone() {
|
||||
if let Ok(mut unlocked) = buffered_file.write() {
|
||||
let _ = write_to(&log_entry, &mut unlocked, config.json);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
23
src/macros.rs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
633
src/main.rs
@@ -1,90 +1,68 @@
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
use feroxbuster::progress::add_bar;
|
||||
use std::io::stdin;
|
||||
use std::{
|
||||
env::args,
|
||||
fs::{create_dir, remove_file, File},
|
||||
io::{stderr, BufRead, BufReader},
|
||||
ops::Index,
|
||||
path::Path,
|
||||
process::Command,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use futures::StreamExt;
|
||||
use tokio::{
|
||||
io,
|
||||
sync::{oneshot, Semaphore},
|
||||
};
|
||||
use tokio_util::codec::{FramedRead, LinesCodec};
|
||||
|
||||
use feroxbuster::scan_manager::ScanType;
|
||||
use feroxbuster::{
|
||||
banner,
|
||||
config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER},
|
||||
heuristics, logger, reporter,
|
||||
scan_manager::{self, PAUSE_SCAN},
|
||||
scanner::{self, scan_url, RESPONSES, SCANNED_URLS},
|
||||
utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer},
|
||||
FeroxError, FeroxResponse, FeroxResult, FeroxSerialize, 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::convert::TryInto;
|
||||
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();
|
||||
// this empty string ensures that we call Requester::request with the base url, i.e.
|
||||
// `http://localhost/` instead of going straight into `http://localhost/WORD.EXT`.
|
||||
// for vanilla scans, it doesn't matter all that much, but it can be a significant difference
|
||||
// when `-e` is used, depending on the content at the base url.
|
||||
let mut words = vec![String::from("")];
|
||||
|
||||
for line in reader.lines() {
|
||||
let result = line?;
|
||||
|
||||
if result.starts_with('#') || result.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
words.insert(result);
|
||||
line.map(|result| {
|
||||
if !result.starts_with('#') && !result.is_empty() {
|
||||
words.push(result);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
log::trace!(
|
||||
@@ -96,81 +74,50 @@ 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<FeroxResponse>,
|
||||
) -> FeroxResult<()> {
|
||||
log::trace!("enter: scan({:?}, {:?}, {:?})", targets, tx_term, tx_file);
|
||||
// cloning an Arc is cheap (it's basically a pointer into the heap)
|
||||
// so that will allow for cheap/safe sharing of a single wordlist across multi-target scans
|
||||
// as well as additional directories found as part of recursion
|
||||
let words =
|
||||
tokio::spawn(async move { get_unique_words_from_wordlist(&CONFIGURATION.wordlist) })
|
||||
.await??;
|
||||
async fn scan(targets: Vec<String>, handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: scan({:?}, {:?})", targets, handles);
|
||||
|
||||
if words.len() == 0 {
|
||||
let mut err = FeroxError::default();
|
||||
err.message = format!("Did not find any words in {}", CONFIGURATION.wordlist);
|
||||
return Err(Box::new(err));
|
||||
let scanned_urls = handles.ferox_scans()?;
|
||||
|
||||
handles.send_scan_command(UpdateWordlist(handles.wordlist.clone()))?;
|
||||
|
||||
scanner::initialize(handles.wordlist.len(), handles.clone()).await?;
|
||||
|
||||
// at this point, the stat thread's progress bar can be created; things that needed to happen
|
||||
// first:
|
||||
// - 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?;
|
||||
}
|
||||
|
||||
scanner::initialize(words.len(), &CONFIGURATION);
|
||||
|
||||
if CONFIGURATION.resumed {
|
||||
if let Ok(scans) = SCANNED_URLS.scans.lock() {
|
||||
for scan in scans.iter() {
|
||||
if let Ok(locked_scan) = scan.lock() {
|
||||
if locked_scan.complete {
|
||||
// these scans are complete, and just need to be shown to the user
|
||||
let pb = add_bar(
|
||||
&locked_scan.url,
|
||||
words.len().try_into().unwrap_or_default(),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
pb.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(responses) = RESPONSES.responses.read() {
|
||||
for response in responses.iter() {
|
||||
PROGRESS_PRINTER.println(response.as_str());
|
||||
}
|
||||
}
|
||||
if handles.config.resumed {
|
||||
// display what has already been completed
|
||||
scanned_urls.print_known_responses();
|
||||
scanned_urls.print_completed_bars(handles.wordlist.len())?;
|
||||
}
|
||||
|
||||
let mut tasks = vec![];
|
||||
log::debug!("sending {:?} to be scanned as initial targets", targets);
|
||||
handles.send_scan_command(ScanInitialUrls(targets))?;
|
||||
|
||||
for target in targets {
|
||||
let word_clone = words.clone();
|
||||
let term_clone = tx_term.clone();
|
||||
let file_clone = tx_file.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let base_depth = get_current_depth(&target);
|
||||
scan_url(&target, word_clone, base_depth, term_clone, file_clone).await;
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// drive execution of all accumulated futures
|
||||
futures::future::join_all(tasks).await;
|
||||
log::trace!("exit: scan");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get targets from either commandline or stdin, pass them back to the caller as a Result<Vec>
|
||||
async fn get_targets() -> FeroxResult<Vec<String>> {
|
||||
log::trace!("enter: get_targets");
|
||||
async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
|
||||
log::trace!("enter: get_targets({:?})", handles);
|
||||
|
||||
let mut targets = vec![];
|
||||
|
||||
if CONFIGURATION.stdin {
|
||||
if handles.config.stdin {
|
||||
// got targets from stdin, i.e. cat sites | ./feroxbuster ...
|
||||
// just need to read the targets from stdin and spawn a future for each target found
|
||||
let stdin = io::stdin(); // tokio's stdin, not std
|
||||
@@ -179,24 +126,47 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
|
||||
while let Some(line) = reader.next().await {
|
||||
targets.push(line?);
|
||||
}
|
||||
} else if CONFIGURATION.resumed {
|
||||
} else if handles.config.resumed {
|
||||
// resume-from can't be used with --url, and --stdin is marked false for every resumed
|
||||
// scan, making it mutually exclusive from either of the other two options
|
||||
if let Ok(scans) = SCANNED_URLS.scans.lock() {
|
||||
let ferox_scans = handles.ferox_scans()?;
|
||||
|
||||
if let Ok(scans) = ferox_scans.scans.read() {
|
||||
for scan in scans.iter() {
|
||||
// SCANNED_URLS gets deserialized scans added to it at program start if --resume-from
|
||||
// ferox_scans gets deserialized scans added to it at program start if --resume-from
|
||||
// is used, so scans that aren't marked complete still need to be scanned
|
||||
if let Ok(locked_scan) = scan.lock() {
|
||||
if locked_scan.complete {
|
||||
// this one's already done, ignore it
|
||||
continue;
|
||||
}
|
||||
targets.push(locked_scan.url.to_owned());
|
||||
if scan.is_complete() || matches!(scan.scan_type, ScanType::File) {
|
||||
// this one's already done, or it's not a directory, ignore it
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.push(scan.url().to_owned());
|
||||
}
|
||||
};
|
||||
} else {
|
||||
targets.push(handles.config.target_url.clone());
|
||||
}
|
||||
|
||||
// remove footgun that arises if a --dont-scan value matches on a base url
|
||||
for target in &targets {
|
||||
for denier in &handles.config.regex_denylist {
|
||||
if denier.is_match(target) {
|
||||
bail!(
|
||||
"The regex '{}' matches {}; the scan will never start",
|
||||
denier,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
for denier in &handles.config.url_denylist {
|
||||
if denier.as_str().trim_end_matches('/') == target.trim_end_matches('/') {
|
||||
bail!(
|
||||
"The url '{}' matches {}; the scan will never start",
|
||||
denier,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
targets.push(CONFIGURATION.target_url.clone());
|
||||
}
|
||||
|
||||
log::trace!("exit: get_targets -> {:?}", targets);
|
||||
@@ -206,7 +176,7 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
|
||||
|
||||
/// async main called from real main, broken out in this way to allow for some synchronous code
|
||||
/// to be executed before bringing the tokio runtime online
|
||||
async fn wrapped_main() {
|
||||
async fn wrapped_main(config: Arc<Configuration>) -> Result<()> {
|
||||
// join can only be called once, otherwise it causes the thread to panic
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// ok, lazy_static! uses (unsurprisingly in retrospect) a lazy loading model where the
|
||||
@@ -220,12 +190,47 @@ async fn wrapped_main() {
|
||||
PROGRESS_BAR.join().unwrap();
|
||||
});
|
||||
|
||||
if !CONFIGURATION.time_limit.is_empty() {
|
||||
// cloning an Arc is cheap (it's basically a pointer into the heap)
|
||||
// so that will allow for cheap/safe sharing of a single wordlist across multi-target scans
|
||||
// as well as additional directories found as part of recursion
|
||||
let words = get_unique_words_from_wordlist(&config.wordlist)?;
|
||||
|
||||
if words.len() <= 1 {
|
||||
// the check is now <= 1 due to the initial empty string added in 2.6.0
|
||||
// 1 -> empty wordlist
|
||||
// 0 -> error
|
||||
bail!("Did not find any words in {}", config.wordlist);
|
||||
}
|
||||
|
||||
// spawn all event handlers, expect back a JoinHandle and a *Handle to the specific event
|
||||
let (stats_task, stats_handle) = StatsHandler::initialize(config.clone());
|
||||
let (filters_task, filters_handle) = FiltersHandler::initialize();
|
||||
let (out_task, out_handle) =
|
||||
TermOutHandler::initialize(config.clone(), stats_handle.tx.clone());
|
||||
|
||||
// bundle up all the disparate handles and JoinHandles (tasks)
|
||||
let handles = Arc::new(Handles::new(
|
||||
stats_handle,
|
||||
filters_handle,
|
||||
out_handle,
|
||||
config.clone(),
|
||||
words,
|
||||
));
|
||||
|
||||
let (scan_task, scan_handle) = ScanHandler::initialize(handles.clone());
|
||||
|
||||
handles.set_scan_handle(scan_handle); // must be done after Handles initialization
|
||||
|
||||
filters::initialize(handles.clone()).await?; // send user-supplied filters to the handler
|
||||
|
||||
// create new Tasks object, each of these handles is one that will be joined on later
|
||||
let tasks = Tasks::new(out_task, stats_task, filters_task, scan_task);
|
||||
|
||||
if !config.time_limit.is_empty() {
|
||||
// --time-limit value not an empty string, need to kick off the thread that enforces
|
||||
// the limit
|
||||
tokio::spawn(async move {
|
||||
scan_manager::start_max_time_thread(&CONFIGURATION.time_limit).await
|
||||
});
|
||||
let time_handles = handles.clone();
|
||||
tokio::spawn(async move { scan_manager::start_max_time_thread(time_handles).await });
|
||||
}
|
||||
|
||||
// can't trace main until after logger is initialized and the above task is started
|
||||
@@ -234,106 +239,239 @@ async fn wrapped_main() {
|
||||
// spawn a thread that listens for keyboard input on stdin, when a user presses enter
|
||||
// the input handler will toggle PAUSE_SCAN, which in turn is used to pause and resume
|
||||
// scans that are already running
|
||||
tokio::task::spawn_blocking(terminal_input_handler);
|
||||
// also starts ctrl+c handler
|
||||
TermInputHandler::initialize(handles.clone());
|
||||
|
||||
let save_output = !CONFIGURATION.output.is_empty(); // was -o used?
|
||||
if config.resumed {
|
||||
let scanned_urls = handles.ferox_scans()?;
|
||||
let from_here = config.resume_from.clone();
|
||||
|
||||
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, handles.clone())?;
|
||||
|
||||
// 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<FeroxResponse>,
|
||||
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);
|
||||
@@ -343,23 +481,66 @@ async fn clean_up(
|
||||
PROGRESS_PRINTER.finish();
|
||||
|
||||
log::trace!("exit: clean_up");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// setup logging based on the number of -v's used
|
||||
logger::initialize(CONFIGURATION.verbosity);
|
||||
fn main() -> Result<()> {
|
||||
let config = Arc::new(Configuration::new().with_context(|| "Could not create Configuration")?);
|
||||
|
||||
if CONFIGURATION.save_state {
|
||||
// start the ctrl+c handler
|
||||
scan_manager::initialize();
|
||||
// setup logging based on the number of -v's used
|
||||
if matches!(
|
||||
config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
// don't log on --silent
|
||||
logger::initialize(config.clone())?;
|
||||
}
|
||||
|
||||
// this function uses rlimit, which is not supported on windows
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
set_open_file_limit(DEFAULT_OPEN_FILE_LIMIT);
|
||||
|
||||
if let Ok(mut runtime) = tokio::runtime::Runtime::new() {
|
||||
let future = wrapped_main();
|
||||
runtime.block_on(future);
|
||||
if let Ok(runtime) = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
let future = wrapped_main(config.clone());
|
||||
if let Err(e) = runtime.block_on(future) {
|
||||
eprintln!("{}", e);
|
||||
|
||||
// the code below is to facilitate testing tests/test_banner entries. Since it's an
|
||||
// integration test, normal test detection (cfg!(test), etc...) won't work. So, in
|
||||
// the tests themselves, we pass
|
||||
// `--wordlist /definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676`
|
||||
// and look for that here to print the banner.
|
||||
//
|
||||
// this change became a necessity once we moved wordlist parsing out of `scan` and into
|
||||
// `wrapped_main`.
|
||||
if e.to_string()
|
||||
.contains("/definitely/doesnt/exist/0cd7fed0-47f4-4b18-a1b0-ac39708c1676")
|
||||
{
|
||||
// support the handful of tests that use `--stdin`
|
||||
let targets: Vec<_> = if config.stdin {
|
||||
stdin().lock().lines().map(|tgt| tgt.unwrap()).collect()
|
||||
} else {
|
||||
vec!["http://localhost".to_string()]
|
||||
};
|
||||
|
||||
// print the banner to stderr
|
||||
let std_stderr = stderr(); // std::io::stderr
|
||||
let banner = Banner::new(&targets, &config);
|
||||
if !config.quiet && !config.silent {
|
||||
banner.print_to(std_stderr, config).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// if we've encountered an error before clean_up can be called (i.e. a wordlist error)
|
||||
// we need to at least spin-down the progress bar
|
||||
PROGRESS_PRINTER.finish();
|
||||
};
|
||||
}
|
||||
|
||||
log::trace!("exit: main");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
148
src/message.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use anyhow::Context;
|
||||
use console::{style, Color};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::traits::FeroxSerialize;
|
||||
use crate::utils::fmt_err;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug)]
|
||||
/// Representation of a log entry, can be represented as a human readable string or JSON
|
||||
pub struct FeroxMessage {
|
||||
#[serde(rename = "type")]
|
||||
/// Name of this type of struct, used for serialization, i.e. `{"type":"log"}`
|
||||
pub(crate) kind: String,
|
||||
|
||||
/// The log message
|
||||
pub(crate) message: String,
|
||||
|
||||
/// The log level
|
||||
pub(crate) level: String,
|
||||
|
||||
/// The number of seconds elapsed since the scan started
|
||||
pub(crate) time_offset: f32,
|
||||
|
||||
/// The module from which log::* was called
|
||||
pub(crate) module: String,
|
||||
}
|
||||
|
||||
/// Implementation of FeroxMessage
|
||||
impl FeroxSerialize for FeroxMessage {
|
||||
/// Create a string representation of the log message
|
||||
///
|
||||
/// ex: 301 10l 16w 173c https://localhost/api
|
||||
fn as_str(&self) -> String {
|
||||
let (level_name, level_color) = match self.level.as_str() {
|
||||
"ERROR" => ("ERR", Color::Red),
|
||||
"WARN" => ("WRN", Color::Red),
|
||||
"INFO" => ("INF", Color::Cyan),
|
||||
"DEBUG" => ("DBG", Color::Yellow),
|
||||
"TRACE" => ("TRC", Color::Magenta),
|
||||
"WILDCARD" => ("WLD", Color::Cyan),
|
||||
_ => ("MSG", Color::White),
|
||||
};
|
||||
|
||||
format!(
|
||||
"{} {:10.03} {} {}\n",
|
||||
style(level_name).bg(level_color).black(),
|
||||
style(self.time_offset).dim(),
|
||||
self.module,
|
||||
style(&self.message).dim(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an NDJSON representation of the log message
|
||||
///
|
||||
/// (expanded for clarity)
|
||||
/// ex:
|
||||
/// {
|
||||
/// "type": "log",
|
||||
/// "message": "Sent https://localhost/api to file handler",
|
||||
/// "level": "DEBUG",
|
||||
/// "time_offset": 0.86333454,
|
||||
/// "module": "feroxbuster::reporter"
|
||||
/// }\n
|
||||
fn as_json(&self) -> anyhow::Result<String> {
|
||||
let mut json = serde_json::to_string(&self).with_context(|| {
|
||||
fmt_err(&format!(
|
||||
"Could not convert {}:{} to JSON",
|
||||
self.level, self.message
|
||||
))
|
||||
})?;
|
||||
json.push('\n');
|
||||
Ok(json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// test as_str method of FeroxMessage
|
||||
fn ferox_message_as_str_returns_string_with_newline() {
|
||||
let message = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "INFO".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
let message_str = message.as_str();
|
||||
|
||||
assert!(message_str.contains("INF"));
|
||||
assert!(message_str.contains("1.000"));
|
||||
assert!(message_str.contains("utils"));
|
||||
assert!(message_str.contains("message"));
|
||||
assert!(message_str.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test as_json method of FeroxMessage
|
||||
fn ferox_message_as_json_returns_json_representation_of_ferox_message_with_newline() {
|
||||
let message = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "INFO".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
|
||||
let message_str = message.as_json().unwrap();
|
||||
|
||||
let error_margin = f32::EPSILON;
|
||||
|
||||
let json: FeroxMessage = serde_json::from_str(&message_str).unwrap();
|
||||
assert_eq!(json.module, message.module);
|
||||
assert_eq!(json.message, message.message);
|
||||
assert!((json.time_offset - message.time_offset).abs() < error_margin);
|
||||
assert_eq!(json.level, message.level);
|
||||
assert_eq!(json.kind, message.kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test defaults for coverage
|
||||
fn message_defaults() {
|
||||
let msg = FeroxMessage::default();
|
||||
assert_eq!(msg.level, String::new());
|
||||
assert_eq!(msg.kind, String::new());
|
||||
assert_eq!(msg.message, String::new());
|
||||
assert_eq!(msg.module, String::new());
|
||||
assert!(msg.time_offset < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure WILDCARD messages serialize to WLD and anything not known to UNK
|
||||
fn message_as_str_edges() {
|
||||
let mut msg = FeroxMessage {
|
||||
message: "message".to_string(),
|
||||
module: "utils".to_string(),
|
||||
time_offset: 1.0,
|
||||
level: "WILDCARD".to_string(),
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
assert!(console::strip_ansi_codes(&msg.as_str()).starts_with("WLD"));
|
||||
|
||||
msg.level = "UNKNOWN".to_string();
|
||||
assert!(console::strip_ansi_codes(&msg.as_str()).starts_with("MSG"));
|
||||
}
|
||||
}
|
||||
334
src/nlp/constants.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
lazy_static! {
|
||||
/// regular expression to match on words with numbers, underscores, and hyphens
|
||||
pub(super) static ref BOUNDED_WORD_REGEX: Regex = Regex::new(r"\b[a-zA-Z0-9_-]+\b").unwrap();
|
||||
}
|
||||
|
||||
/// collection of stop words from spaCy with small modifications
|
||||
pub(super) static STOP_WORDS: [&str; 323] = [
|
||||
"'d",
|
||||
"'ll",
|
||||
"'m",
|
||||
"'re",
|
||||
"'s",
|
||||
"'ve",
|
||||
"a",
|
||||
"about",
|
||||
"above",
|
||||
"across",
|
||||
"after",
|
||||
"afterwards",
|
||||
"again",
|
||||
"against",
|
||||
"almost",
|
||||
"alone",
|
||||
"along",
|
||||
"already",
|
||||
"also",
|
||||
"although",
|
||||
"always",
|
||||
"am",
|
||||
"among",
|
||||
"amongst",
|
||||
"amount",
|
||||
"an",
|
||||
"and",
|
||||
"another",
|
||||
"any",
|
||||
"anyhow",
|
||||
"anyone",
|
||||
"anything",
|
||||
"anyway",
|
||||
"anywhere",
|
||||
"are",
|
||||
"around",
|
||||
"as",
|
||||
"at",
|
||||
"back",
|
||||
"be",
|
||||
"became",
|
||||
"because",
|
||||
"become",
|
||||
"becomes",
|
||||
"becoming",
|
||||
"been",
|
||||
"before",
|
||||
"beforehand",
|
||||
"behind",
|
||||
"being",
|
||||
"below",
|
||||
"beside",
|
||||
"besides",
|
||||
"between",
|
||||
"beyond",
|
||||
"both",
|
||||
"bottom",
|
||||
"but",
|
||||
"by",
|
||||
"ca",
|
||||
"call",
|
||||
"can",
|
||||
"cannot",
|
||||
"could",
|
||||
"did",
|
||||
"do",
|
||||
"does",
|
||||
"doing",
|
||||
"done",
|
||||
"down",
|
||||
"due",
|
||||
"during",
|
||||
"each",
|
||||
"eight",
|
||||
"either",
|
||||
"eleven",
|
||||
"else",
|
||||
"elsewhere",
|
||||
"empty",
|
||||
"enough",
|
||||
"even",
|
||||
"ever",
|
||||
"every",
|
||||
"everyone",
|
||||
"everything",
|
||||
"everywhere",
|
||||
"except",
|
||||
"few",
|
||||
"fifteen",
|
||||
"fifty",
|
||||
"first",
|
||||
"five",
|
||||
"for",
|
||||
"former",
|
||||
"formerly",
|
||||
"forty",
|
||||
"four",
|
||||
"from",
|
||||
"front",
|
||||
"full",
|
||||
"further",
|
||||
"get",
|
||||
"got",
|
||||
"give",
|
||||
"go",
|
||||
"had",
|
||||
"has",
|
||||
"have",
|
||||
"he",
|
||||
"hence",
|
||||
"her",
|
||||
"here",
|
||||
"hereafter",
|
||||
"hereby",
|
||||
"herein",
|
||||
"hereupon",
|
||||
"hers",
|
||||
"herself",
|
||||
"him",
|
||||
"himself",
|
||||
"his",
|
||||
"how",
|
||||
"however",
|
||||
"hundred",
|
||||
"i",
|
||||
"if",
|
||||
"in",
|
||||
"indeed",
|
||||
"into",
|
||||
"is",
|
||||
"it",
|
||||
"its",
|
||||
"itself",
|
||||
"just",
|
||||
"keep",
|
||||
"last",
|
||||
"latter",
|
||||
"latterly",
|
||||
"least",
|
||||
"less",
|
||||
"made",
|
||||
"make",
|
||||
"many",
|
||||
"may",
|
||||
"me",
|
||||
"meanwhile",
|
||||
"might",
|
||||
"mine",
|
||||
"more",
|
||||
"moreover",
|
||||
"most",
|
||||
"mostly",
|
||||
"move",
|
||||
"much",
|
||||
"must",
|
||||
"my",
|
||||
"myself",
|
||||
"n't",
|
||||
"name",
|
||||
"namely",
|
||||
"neither",
|
||||
"never",
|
||||
"nevertheless",
|
||||
"next",
|
||||
"nine",
|
||||
"no",
|
||||
"nobody",
|
||||
"none",
|
||||
"noone",
|
||||
"nor",
|
||||
"not",
|
||||
"nothing",
|
||||
"now",
|
||||
"nowhere",
|
||||
"n\u{2018}t",
|
||||
"n\u{2019}t",
|
||||
"of",
|
||||
"off",
|
||||
"often",
|
||||
"on",
|
||||
"once",
|
||||
"one",
|
||||
"only",
|
||||
"onto",
|
||||
"or",
|
||||
"other",
|
||||
"others",
|
||||
"otherwise",
|
||||
"our",
|
||||
"ours",
|
||||
"ourselves",
|
||||
"out",
|
||||
"over",
|
||||
"own",
|
||||
"part",
|
||||
"per",
|
||||
"perhaps",
|
||||
"please",
|
||||
"put",
|
||||
"quite",
|
||||
"rather",
|
||||
"re",
|
||||
"really",
|
||||
"regarding",
|
||||
"same",
|
||||
"say",
|
||||
"see",
|
||||
"seem",
|
||||
"seemed",
|
||||
"seeming",
|
||||
"seems",
|
||||
"serious",
|
||||
"several",
|
||||
"she",
|
||||
"should",
|
||||
"side",
|
||||
"since",
|
||||
"six",
|
||||
"sixty",
|
||||
"so",
|
||||
"some",
|
||||
"somehow",
|
||||
"someone",
|
||||
"something",
|
||||
"sometime",
|
||||
"sometimes",
|
||||
"somewhere",
|
||||
"still",
|
||||
"such",
|
||||
"take",
|
||||
"ten",
|
||||
"than",
|
||||
"that",
|
||||
"the",
|
||||
"their",
|
||||
"them",
|
||||
"themselves",
|
||||
"then",
|
||||
"thence",
|
||||
"there",
|
||||
"thereafter",
|
||||
"thereby",
|
||||
"therefore",
|
||||
"therein",
|
||||
"thereupon",
|
||||
"these",
|
||||
"they",
|
||||
"third",
|
||||
"this",
|
||||
"those",
|
||||
"though",
|
||||
"three",
|
||||
"through",
|
||||
"throughout",
|
||||
"thru",
|
||||
"thus",
|
||||
"to",
|
||||
"together",
|
||||
"too",
|
||||
"toward",
|
||||
"towards",
|
||||
"twelve",
|
||||
"twenty",
|
||||
"two",
|
||||
"under",
|
||||
"unless",
|
||||
"until",
|
||||
"up",
|
||||
"upon",
|
||||
"used",
|
||||
"using",
|
||||
"various",
|
||||
"very",
|
||||
"via",
|
||||
"was",
|
||||
"we",
|
||||
"well",
|
||||
"were",
|
||||
"what",
|
||||
"whatever",
|
||||
"when",
|
||||
"whence",
|
||||
"whenever",
|
||||
"where",
|
||||
"whereafter",
|
||||
"whereas",
|
||||
"whereby",
|
||||
"wherein",
|
||||
"whereupon",
|
||||
"wherever",
|
||||
"whether",
|
||||
"which",
|
||||
"while",
|
||||
"whither",
|
||||
"who",
|
||||
"whoever",
|
||||
"whole",
|
||||
"whom",
|
||||
"whose",
|
||||
"why",
|
||||
"will",
|
||||
"with",
|
||||
"within",
|
||||
"without",
|
||||
"would",
|
||||
"yet",
|
||||
"you",
|
||||
"your",
|
||||
"yours",
|
||||
"yourself",
|
||||
"yourselves",
|
||||
"\u{2018}d",
|
||||
"\u{2018}ll",
|
||||
"\u{2018}m",
|
||||
"\u{2018}re",
|
||||
"\u{2018}s",
|
||||
"\u{2018}ve",
|
||||
"\u{2019}d",
|
||||
"\u{2019}ll",
|
||||
"\u{2019}m",
|
||||
"\u{2019}re",
|
||||
"\u{2019}s",
|
||||
"\u{2019}ve",
|
||||
];
|
||||
223
src/nlp/document.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use super::term::{Term, TermMetaData};
|
||||
use super::utils::preprocess;
|
||||
use scraper::{Html, Node, Selector};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// data container representing a single document, in the nlp sense
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Document {
|
||||
/// collection of `Term`s and their associated metadata
|
||||
terms: HashMap<Term, TermMetaData>,
|
||||
|
||||
/// number of terms contained within the document
|
||||
number_of_terms: usize,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// create a new `Document` from the given string
|
||||
pub(super) fn new(text: &str) -> Self {
|
||||
let mut document = Self::default();
|
||||
|
||||
let processed = preprocess(text);
|
||||
|
||||
document.number_of_terms += processed.len();
|
||||
|
||||
for normalized in processed {
|
||||
if normalized.len() > 2 {
|
||||
document.add_term(&normalized)
|
||||
}
|
||||
}
|
||||
document
|
||||
}
|
||||
|
||||
/// add a `Term` to the document if it's not already tracked, otherwise increment the number
|
||||
/// of times the term has been seen
|
||||
fn add_term(&mut self, word: &str) {
|
||||
let term = Term::new(word);
|
||||
|
||||
let metadata = self.terms.entry(term).or_insert_with(TermMetaData::new);
|
||||
*metadata.count_mut() += 1;
|
||||
}
|
||||
|
||||
/// create a new `Document` from the given HTML string
|
||||
pub(crate) fn from_html(raw_html: &str) -> Self {
|
||||
let selector = Selector::parse("body").unwrap();
|
||||
|
||||
let html = Html::parse_document(raw_html);
|
||||
|
||||
let text = html
|
||||
.select(&selector)
|
||||
.next()
|
||||
.unwrap()
|
||||
.descendants()
|
||||
.filter_map(|node| {
|
||||
if !node.value().is_text() && !node.value().is_comment() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// have a Text||Comment node, trim whitespace to test for all whitespace stuff
|
||||
let trimmed = if node.value().is_text() {
|
||||
node.value().as_text().unwrap().text.trim()
|
||||
} else {
|
||||
node.value().as_comment().unwrap().comment.trim()
|
||||
};
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// found a non-empty Text||Comment node, need to check its parent to determine if
|
||||
// it's a <script>||<style> tag. We're assuming text within a script||style tag is
|
||||
// uninteresting
|
||||
|
||||
let parent = node.parent().unwrap().value();
|
||||
|
||||
if !parent.is_element() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// parent is an Element node, see if it's a <script> or <style>
|
||||
|
||||
if let Node::Element(element) = parent {
|
||||
if element.name() == "script" || element.name() == "style" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// at this point, we have a non-empty Text element with a non-script|style parent;
|
||||
// now we can return the trimmed up string
|
||||
return Some(format!("{} ", trimmed));
|
||||
}
|
||||
|
||||
// not an Element node
|
||||
None
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
// call `new` to push the parsed html through the pre-processing pipeline and process all
|
||||
// the words
|
||||
Self::new(&text)
|
||||
}
|
||||
|
||||
/// Log normalized weighting scheme for term frequency
|
||||
pub(super) fn term_frequency(&self, term: &Term) -> f32 {
|
||||
if let Some(metadata) = self.terms.get(term) {
|
||||
metadata.count() as f32 / self.number_of_terms() as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// immutable reference to the collection of terms and their metadata
|
||||
pub(super) fn terms(&self) -> &HashMap<Term, TermMetaData> {
|
||||
&self.terms
|
||||
}
|
||||
|
||||
/// number of terms the current document knows about
|
||||
fn number_of_terms(&self) -> usize {
|
||||
self.number_of_terms
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// `Document::new` should preprocess text and generate a hashmap of `Term, TermMetadata`
|
||||
fn nlp_document_creation_from_text() {
|
||||
let doc = Document::new("The air quality in Singapore got worse on Wednesday.");
|
||||
|
||||
let expected_terms = ["air", "quality", "singapore", "worse", "wednesday"];
|
||||
|
||||
for expected in expected_terms {
|
||||
let term = Term::new(expected);
|
||||
assert!(doc.terms().contains_key(&term));
|
||||
assert_eq!(doc.number_of_terms, 5);
|
||||
assert_eq!(doc.terms().get(&term).unwrap().count(), 1);
|
||||
|
||||
// since term frequencies aren't calculated on `new`, document frequency is zero in
|
||||
// addition to the empty term_frequencies slice
|
||||
let empty: &[f32] = &[];
|
||||
assert_eq!(doc.terms().get(&term).unwrap().term_frequencies(), empty);
|
||||
assert_eq!(doc.terms().get(&term).unwrap().document_frequency(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `Document::new` should preprocess html and generate a hashmap of `Term, TermMetadata`
|
||||
fn nlp_document_creation_from_html() {
|
||||
let empty = Document::from_html("<html></html>");
|
||||
assert_eq!(empty.number_of_terms, 0);
|
||||
|
||||
let other_empty = Document::from_html("<html><body><p></p></body></html>");
|
||||
assert_eq!(other_empty.number_of_terms, 0);
|
||||
|
||||
let third_empty = Document::from_html("<!DOCTYPE html><html><!DOCTYPE html><p></p></html>");
|
||||
assert_eq!(third_empty.number_of_terms, 0);
|
||||
|
||||
// p tag for is_text check and comment for is_comment
|
||||
let doc = Document::from_html(
|
||||
"<html><body><p>The air quality in Singapore.</p><!--got worse on Wednesday--></body></html>",
|
||||
);
|
||||
|
||||
let expected_terms = ["air", "quality", "singapore", "worse", "wednesday"];
|
||||
|
||||
for expected in expected_terms {
|
||||
let term = Term::new(expected);
|
||||
assert_eq!(doc.number_of_terms, 5);
|
||||
assert!(doc.terms().contains_key(&term));
|
||||
assert_eq!(doc.terms().get(&term).unwrap().count(), 1);
|
||||
|
||||
// since term frequencies aren't calculated on `new`, document frequency is zero in
|
||||
// addition to the empty term_frequencies slice
|
||||
let empty: &[f32] = &[];
|
||||
assert_eq!(doc.terms().get(&term).unwrap().term_frequencies(), empty);
|
||||
assert_eq!(doc.terms().get(&term).unwrap().document_frequency(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// simple check of the `term_frequency` function's return value
|
||||
fn term_frequency_validation() {
|
||||
let doc = Document::new("The air quality in Singapore got worse on Wednesday. Air Jordan.");
|
||||
|
||||
let air_freq = doc.term_frequency(&Term::new("air"));
|
||||
|
||||
let abs_diff = (air_freq - 0.2857143).abs();
|
||||
assert!(abs_diff <= f32::EPSILON);
|
||||
|
||||
let non_existent = doc.term_frequency(&Term::new("derpatronic"));
|
||||
assert_eq!(non_existent, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test accessors for correctness
|
||||
fn document_accessor_test() {
|
||||
let doc = Document::new("The air quality in Singapore got worse on Wednesday.");
|
||||
let keys = doc.terms().keys().map(|key| key.raw()).collect::<Vec<_>>();
|
||||
|
||||
let expected = ["air", "quality", "singapore", "worse", "wednesday"];
|
||||
|
||||
assert_eq!(doc.number_of_terms(), 5);
|
||||
|
||||
for key in keys {
|
||||
assert!(expected.contains(&key));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure words in script/style tags aren't processed
|
||||
fn document_creation_skips_script_and_style_tags() {
|
||||
let html = "<body><script>The air quality</script><style>in Singapore</style><p>got worse on Wednesday.</p></body>";
|
||||
let doc = Document::from_html(html);
|
||||
let keys = doc.terms().keys().map(|key| key.raw()).collect::<Vec<_>>();
|
||||
|
||||
let expected = ["worse", "wednesday"];
|
||||
|
||||
assert_eq!(doc.number_of_terms(), 2);
|
||||
|
||||
for key in keys {
|
||||
assert!(expected.contains(&key));
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/nlp/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! small stand-alone tf-idf library, specifically designed for use in feroxbuster
|
||||
|
||||
mod constants;
|
||||
mod document;
|
||||
mod model;
|
||||
mod term;
|
||||
mod utils;
|
||||
|
||||
pub(crate) use self::document::Document;
|
||||
pub(crate) use self::model::TfIdf;
|
||||
185
src/nlp/model.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use super::document::Document;
|
||||
use super::term::{Term, TermMetaData};
|
||||
use super::utils::{inverse_document_frequency, tf_idf_score};
|
||||
use std::borrow::{Borrow, BorrowMut};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// data container for the TF-IDF model
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TfIdf {
|
||||
/// collection of `Term`s and their associated metadata
|
||||
terms: HashMap<Term, TermMetaData>,
|
||||
|
||||
/// number of documents processed by the model
|
||||
num_documents: usize,
|
||||
}
|
||||
|
||||
impl TfIdf {
|
||||
/// create an empty TF-IDF model; must be populated with `add_document` prior to use
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// accessor method for the collection of `Term`s and `TermMetaData`
|
||||
fn terms(&self) -> &HashMap<Term, TermMetaData> {
|
||||
self.terms.borrow()
|
||||
}
|
||||
|
||||
/// accessor method for the number of `Document`s the model has processed
|
||||
pub(crate) fn num_documents(&self) -> usize {
|
||||
self.num_documents
|
||||
}
|
||||
|
||||
/// add a `Document` to the model
|
||||
pub(crate) fn add_document(&mut self, document: Document) {
|
||||
// increment number of docs seen, since we don't preserve the document itself; this needs
|
||||
// to happen before calls to `self.inverse_document_frequency`, as it relies on the count
|
||||
// being up to date
|
||||
self.num_documents += 1;
|
||||
|
||||
for (term, doc_metadata) in document.terms().iter() {
|
||||
// an incoming `Term` from a `Document` only has a valid `count` for that particular
|
||||
// document; need to get the term frequency while both are known/valid
|
||||
let term_frequency = document.term_frequency(term);
|
||||
|
||||
let metadata = self
|
||||
.terms
|
||||
.entry(term.clone())
|
||||
.or_insert_with(|| doc_metadata.to_owned());
|
||||
|
||||
metadata.term_frequencies_mut().push(term_frequency);
|
||||
}
|
||||
}
|
||||
|
||||
/// (re)-calculate tf-idf scores for all terms, given the current number of documents
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// old tf-idf scores are removed during calculations to keep new `Term`s at the same relative
|
||||
/// level as new ones WRT corpus size
|
||||
pub(crate) fn calculate_tf_idf_scores(&mut self) {
|
||||
for metadata in self.terms.borrow_mut().values_mut() {
|
||||
let num_frequencies = metadata.term_frequencies().len();
|
||||
|
||||
let mut to_add = Vec::with_capacity(num_frequencies);
|
||||
|
||||
for frequency in metadata.term_frequencies() {
|
||||
let idf = inverse_document_frequency(
|
||||
self.num_documents as f32,
|
||||
metadata.document_frequency() as f32,
|
||||
);
|
||||
|
||||
let score = tf_idf_score(*frequency, idf);
|
||||
to_add.push(score);
|
||||
}
|
||||
|
||||
let average: f32 = to_add.iter().sum::<f32>() / to_add.len() as f32;
|
||||
|
||||
*metadata.tf_idf_score_mut() = average;
|
||||
}
|
||||
}
|
||||
|
||||
/// select all terms with a non-zero tf-idf score
|
||||
pub(crate) fn all_words(&self) -> Vec<String> {
|
||||
self.terms()
|
||||
.iter()
|
||||
.filter(|(_, metadata)| metadata.tf_idf_score() > 0.0)
|
||||
.map(|(term, _)| term.raw().to_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// helper for this test suite
|
||||
fn get_score(word: &str, model: &TfIdf) -> f32 {
|
||||
model.terms().get(&Term::new(word)).unwrap().tf_idf_score()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given the example data at https://remykarem.github.io/tfidf-demo/, ensure the model
|
||||
/// produces the same results
|
||||
fn model_generates_expected_tf_idf_scores() {
|
||||
let one = "Air quality in the sunny island improved gradually throughout Wednesday.";
|
||||
let two =
|
||||
"Air quality in Singapore on Wednesday continued to get worse as haze hit the island.";
|
||||
let three = "The air quality in Singapore is monitored through a network of air monitoring stations located in different parts of the island";
|
||||
let four = "The air quality in Singapore got worse on Wednesday.";
|
||||
|
||||
let docs = [one, two, three, four];
|
||||
let mut model = TfIdf::new();
|
||||
|
||||
for doc in docs.iter() {
|
||||
let d = Document::new(doc);
|
||||
model.add_document(d);
|
||||
}
|
||||
|
||||
assert_eq!(model.terms().len(), 19);
|
||||
|
||||
model.calculate_tf_idf_scores();
|
||||
|
||||
assert_eq!(get_score("quality", &model), 0.0);
|
||||
assert_eq!(get_score("air", &model), 0.0);
|
||||
assert_eq!(get_score("wednesday", &model), 0.018906077);
|
||||
assert_eq!(get_score("island", &model), 0.014047348);
|
||||
assert_eq!(get_score("singapore", &model), 0.016427131);
|
||||
assert_eq!(get_score("sunny", &model), 0.08600858);
|
||||
assert_eq!(get_score("monitoring", &model), 0.05017167);
|
||||
assert_eq!(get_score("stations", &model), 0.05017167);
|
||||
assert_eq!(get_score("parts", &model), 0.05017167);
|
||||
assert_eq!(get_score("haze", &model), 0.06689556);
|
||||
assert_eq!(get_score("hit", &model), 0.06689556);
|
||||
assert_eq!(get_score("worse", &model), 0.04682689);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given the example data at https://remykarem.github.io/tfidf-demo/, ensure the model
|
||||
/// produces the same results
|
||||
fn select_n_words_grabs_correct_words() {
|
||||
let one = "Air quality in the sunny island improved gradually throughout Wednesday.";
|
||||
let two =
|
||||
"Air quality in Singapore on Wednesday continued to get worse as haze hit the island.";
|
||||
let three = "The air quality in Singapore is monitored through a network of air monitoring stations located in different parts of the island";
|
||||
let four = "The air quality in Singapore got worse on Wednesday.";
|
||||
|
||||
let docs = [one, two, three, four];
|
||||
let mut model = TfIdf::new();
|
||||
|
||||
for doc in docs.iter() {
|
||||
let d = Document::new(doc);
|
||||
model.add_document(d);
|
||||
}
|
||||
|
||||
assert_eq!(model.num_documents(), 4);
|
||||
|
||||
model.calculate_tf_idf_scores();
|
||||
|
||||
let non_zero_words = model.all_words();
|
||||
|
||||
[
|
||||
"gradually",
|
||||
"network",
|
||||
"hit",
|
||||
"located",
|
||||
"continued",
|
||||
"island",
|
||||
"worse",
|
||||
"monitored",
|
||||
"monitoring",
|
||||
"haze",
|
||||
"different",
|
||||
"stations",
|
||||
"sunny",
|
||||
"singapore",
|
||||
"improved",
|
||||
"parts",
|
||||
"wednesday",
|
||||
]
|
||||
.iter()
|
||||
.for_each(|word| {
|
||||
assert!(non_zero_words.contains(&word.to_string()));
|
||||
});
|
||||
}
|
||||
}
|
||||
105
src/nlp/term.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::borrow::BorrowMut;
|
||||
|
||||
/// single word term for text processing
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Default, Clone)]
|
||||
pub(crate) struct Term {
|
||||
/// underlying string that the term represents
|
||||
raw: String,
|
||||
}
|
||||
|
||||
impl Term {
|
||||
/// given a word, create a new `Term`
|
||||
pub(super) fn new(word: &str) -> Self {
|
||||
Self {
|
||||
raw: word.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// return a reference to the underlying string
|
||||
pub(super) fn raw(&self) -> &str {
|
||||
&self.raw
|
||||
}
|
||||
}
|
||||
|
||||
/// metadata to be associated with a `Term`
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(super) struct TermMetaData {
|
||||
/// number of times the associated `Term` was seen in a single document
|
||||
count: u32,
|
||||
|
||||
/// collection of term frequencies for the associated `Term`
|
||||
term_frequencies: Vec<f32>,
|
||||
|
||||
/// tf-idf score for the associated `Term`
|
||||
tf_idf_score: f32,
|
||||
}
|
||||
|
||||
impl TermMetaData {
|
||||
/// create a new metadata container
|
||||
pub(super) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// number of times a `Term` has appeared in any `Document` within the corpus
|
||||
pub(super) fn document_frequency(&self) -> usize {
|
||||
self.term_frequencies().len()
|
||||
}
|
||||
|
||||
/// mutable reference to the collection of term frequencies
|
||||
pub(super) fn term_frequencies_mut(&mut self) -> &mut Vec<f32> {
|
||||
self.term_frequencies.borrow_mut()
|
||||
}
|
||||
|
||||
/// immutable reference to the collection of term frequencies
|
||||
pub(super) fn term_frequencies(&self) -> &[f32] {
|
||||
&self.term_frequencies
|
||||
}
|
||||
|
||||
/// mutable reference to the number of times a `Term` was seen in a particular `Document`
|
||||
pub(super) fn count_mut(&mut self) -> &mut u32 {
|
||||
self.count.borrow_mut()
|
||||
}
|
||||
|
||||
/// number of times a `Term` was seen in a particular `Document`
|
||||
pub(super) fn count(&self) -> u32 {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// mutable reference to the term's tf-idf score
|
||||
pub(super) fn tf_idf_score_mut(&mut self) -> &mut f32 {
|
||||
self.tf_idf_score.borrow_mut()
|
||||
}
|
||||
|
||||
/// immutable reference to the term's tf-idf score
|
||||
pub(super) fn tf_idf_score(&self) -> f32 {
|
||||
self.tf_idf_score
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// test accessors for correctness
|
||||
fn nlp_term_accessor_test() {
|
||||
let term = Term::new("stuff");
|
||||
assert_eq!(term.raw(), "stuff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test accessors for correctness
|
||||
fn nlp_term_metadata_accessor_test() {
|
||||
let mut metadata = TermMetaData::new();
|
||||
|
||||
*metadata.count_mut() += 1;
|
||||
assert_eq!(metadata.count(), 1);
|
||||
|
||||
metadata.term_frequencies_mut().push(1.0);
|
||||
assert_eq!(metadata.document_frequency(), 1);
|
||||
assert_eq!(metadata.term_frequencies().first().unwrap(), &1.0);
|
||||
|
||||
*metadata.tf_idf_score_mut() = 1.0_f32;
|
||||
assert_eq!(metadata.tf_idf_score(), 1.0);
|
||||
}
|
||||
}
|
||||
158
src/nlp/utils.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use super::constants::{BOUNDED_WORD_REGEX, STOP_WORDS};
|
||||
use regex::Captures;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// pre-processing pipeline wrapper that removes punctuation, normalizes word case (utf-8 included)
|
||||
/// to lowercase, and remove stop words
|
||||
pub(super) fn preprocess(text: &str) -> Vec<String> {
|
||||
let text = remove_punctuation(text);
|
||||
let text = normalize_case(text);
|
||||
let text = remove_stop_words(&text);
|
||||
|
||||
text.split_whitespace()
|
||||
.map(|word| word.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// optimized version of `str::to_lowercase`
|
||||
fn normalize_case<'a, S: Into<Cow<'a, str>>>(input: S) -> Cow<'a, str> {
|
||||
let input = input.into();
|
||||
|
||||
let first = input.find(char::is_uppercase);
|
||||
|
||||
if let Some(first_idx) = first {
|
||||
let mut output = String::from(&input[..first_idx]);
|
||||
output.reserve(input.len() - first_idx);
|
||||
|
||||
for c in input[first_idx..].chars() {
|
||||
if c.is_uppercase() {
|
||||
output.push(c.to_lowercase().next().unwrap())
|
||||
} else {
|
||||
output.push(c)
|
||||
}
|
||||
}
|
||||
|
||||
Cow::Owned(output)
|
||||
} else {
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
/// remove ascii and some utf-8 punctuation characters from the given string
|
||||
fn remove_punctuation(text: &str) -> String {
|
||||
// non-separator type chars can be replaced with an empty string, while separators are replaced
|
||||
// with a space. This attempts to keep things like
|
||||
// 'aboutblogfaqcontactpresstermslexicondisclosure' from happening
|
||||
text.replace(
|
||||
[
|
||||
'!', '\\', '"', '#', '$', '%', '&', '(', ')', '*', '+', ':', ';', '<', '=', '>', '?',
|
||||
'@', '[', ']', '^', '{', '}', '|', '~', ',', '\'', '“', '”', '’', '‘', '’', '‘',
|
||||
],
|
||||
"",
|
||||
)
|
||||
.replace(['/', '–', '—', '.'], " ")
|
||||
}
|
||||
|
||||
/// remove stop words from the given string
|
||||
fn remove_stop_words(text: &str) -> String {
|
||||
BOUNDED_WORD_REGEX
|
||||
.replace_all(text, |caps: &Captures| {
|
||||
let word = &caps[0];
|
||||
if !STOP_WORDS.contains(&word) {
|
||||
word.to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// calculate inverse document frequency
|
||||
pub(super) fn inverse_document_frequency(num_docs: f32, doc_frequency: f32) -> f32 {
|
||||
f32::log10(num_docs / doc_frequency)
|
||||
}
|
||||
|
||||
/// calculate term frequency-inverse document frequency (tf-idf)
|
||||
pub(super) fn tf_idf_score(term_frequency: f32, idf: f32) -> f32 {
|
||||
term_frequency * idf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// ensure all expected punctuation characters are removed
|
||||
fn test_remove_punctuation() {
|
||||
let tester = "!\\\"#$%&()*+/:;<=>?@[]^{}|~,.'“”’‘–—\n‘’";
|
||||
// the `" \n"` is because of the things like / getting replaced with a space
|
||||
assert_eq!(remove_punctuation(tester), " \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure uppercase characters are swapped to lowercase
|
||||
fn test_normalize_case() {
|
||||
let tester = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
assert_eq!(normalize_case(tester), "abcdefghijklmnopqrstuvwxyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure all stop words are removed from the list of stopwords ... intestuous
|
||||
fn test_remove_stopwords() {
|
||||
let all_words = STOP_WORDS
|
||||
.iter()
|
||||
.map(|&word| word.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let removed = remove_stop_words(&all_words).replace(' ', "");
|
||||
|
||||
// the remaining chars are from the contraction-based stop words
|
||||
assert_eq!(removed, "'d'll'm''s'ven'tn‘tn’t‘d‘ll‘m‘‘s‘ve’d’ll’m’’s’ve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure preprocess
|
||||
fn test_preprocess_results() {
|
||||
let tester = "WHY are Y'all YELLing?";
|
||||
assert_eq!(&preprocess(tester), &["yall", "yelling"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure our calculations conform to the example provided at the link below
|
||||
///
|
||||
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
|
||||
///
|
||||
/// Consider a document containing 100 words wherein the word cat appears 3 times.
|
||||
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
|
||||
/// million documents and the word cat appears in one thousand of these. Then, the inverse
|
||||
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
|
||||
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
|
||||
fn idf_returns_expected_value() {
|
||||
let num_docs = 10_000_000_f32;
|
||||
let num_occurrences = 1_000_f32;
|
||||
let abs_diff = (inverse_document_frequency(num_docs, num_occurrences) - 4.0).abs();
|
||||
|
||||
assert!(abs_diff <= f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure our calculations conform to the example provided at the link below
|
||||
///
|
||||
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
|
||||
///
|
||||
/// Consider a document containing 100 words wherein the word cat appears 3 times.
|
||||
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
|
||||
/// million documents and the word cat appears in one thousand of these. Then, the inverse
|
||||
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
|
||||
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
|
||||
fn tf_idf_returns_expected_value() {
|
||||
let term_freq = 0.03_f32;
|
||||
let num_docs = 10_000_000_f32;
|
||||
let num_occurrences = 1_000_f32;
|
||||
let idf = inverse_document_frequency(num_docs, num_occurrences);
|
||||
let abs_diff = (tf_idf_score(term_freq, idf) - 0.12).abs();
|
||||
|
||||
assert!(abs_diff <= f32::EPSILON);
|
||||
}
|
||||
}
|
||||
869
src/parser.rs
@@ -1,6 +1,10 @@
|
||||
use clap::{App, Arg, ArgGroup};
|
||||
use clap::{
|
||||
crate_authors, crate_description, crate_name, crate_version, Arg, ArgGroup, Command, ValueHint,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use std::env;
|
||||
use std::process;
|
||||
|
||||
lazy_static! {
|
||||
/// Regex used to validate values passed to --time-limit
|
||||
@@ -12,323 +16,638 @@ lazy_static! {
|
||||
/// - 1d
|
||||
pub static ref TIMESPEC_REGEX: Regex =
|
||||
Regex::new(r"^(?i)(?P<n>\d+)(?P<m>[smdh])$").expect("Could not compile regex");
|
||||
|
||||
/// help string for user agent, your guess is as good as mine as to why this is required...
|
||||
static ref DEFAULT_USER_AGENT: String = format!(
|
||||
"Sets the User-Agent (default: feroxbuster/{})",
|
||||
crate_version!()
|
||||
);
|
||||
}
|
||||
|
||||
/// Create and return an instance of [clap::App](https://docs.rs/clap/latest/clap/struct.App.html), i.e. the Command Line Interface's configuration
|
||||
pub fn initialize() -> App<'static, 'static> {
|
||||
App::new("feroxbuster")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.author("Ben 'epi' Risher (@epi052)")
|
||||
.about("A fast, simple, recursive content discovery tool written in Rust")
|
||||
pub fn initialize() -> Command<'static> {
|
||||
let app = Command::new(crate_name!())
|
||||
.version(crate_version!())
|
||||
.author(crate_authors!())
|
||||
.about(crate_description!());
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - target selection
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::with_name("wordlist")
|
||||
.short("w")
|
||||
.long("wordlist")
|
||||
.value_name("FILE")
|
||||
.help("Path to the wordlist")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("url")
|
||||
.short("u")
|
||||
Arg::new("url")
|
||||
.short('u')
|
||||
.long("url")
|
||||
.required_unless_one(&["stdin", "resume_from"])
|
||||
.required_unless_present_any(&["stdin", "resume_from"])
|
||||
.help_heading("Target selection")
|
||||
.value_name("URL")
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help("The target URL(s) (required, unless --stdin used)"),
|
||||
.use_value_delimiter(true)
|
||||
.value_hint(ValueHint::Url)
|
||||
.help("The target URL (required, unless [--stdin || --resume-from] used)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("threads")
|
||||
.short("t")
|
||||
.long("threads")
|
||||
.value_name("THREADS")
|
||||
.takes_value(true)
|
||||
.help("Number of concurrent threads (default: 50)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("depth")
|
||||
.short("d")
|
||||
.long("depth")
|
||||
.value_name("RECURSION_DEPTH")
|
||||
.takes_value(true)
|
||||
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("timeout")
|
||||
.short("T")
|
||||
.long("timeout")
|
||||
.value_name("SECONDS")
|
||||
.takes_value(true)
|
||||
.help("Number of seconds before a request times out (default: 7)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("verbosity")
|
||||
.short("v")
|
||||
.long("verbosity")
|
||||
.takes_value(false)
|
||||
.multiple(true)
|
||||
.help("Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably too much)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("proxy")
|
||||
.short("p")
|
||||
.long("proxy")
|
||||
.takes_value(true)
|
||||
.value_name("PROXY")
|
||||
.help(
|
||||
"Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("replay_proxy")
|
||||
.short("P")
|
||||
.long("replay-proxy")
|
||||
.takes_value(true)
|
||||
.value_name("REPLAY_PROXY")
|
||||
.help(
|
||||
"Send only unfiltered requests through a Replay Proxy, instead of all requests",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("replay_codes")
|
||||
.short("R")
|
||||
.long("replay-codes")
|
||||
.value_name("REPLAY_CODE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.requires("replay_proxy")
|
||||
.help(
|
||||
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("status_codes")
|
||||
.short("s")
|
||||
.long("status-codes")
|
||||
.value_name("STATUS_CODE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("quiet")
|
||||
.short("q")
|
||||
.long("quiet")
|
||||
.takes_value(false)
|
||||
.help("Only print URLs; Don't print status codes, response size, running config, etc...")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("json")
|
||||
.long("json")
|
||||
.takes_value(false)
|
||||
.requires("output_files")
|
||||
.help("Emit JSON logs to --output and --debug-log instead of normal text")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("dont_filter")
|
||||
.short("D")
|
||||
.long("dont-filter")
|
||||
.takes_value(false)
|
||||
.help("Don't auto-filter wildcard responses")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("output")
|
||||
.short("o")
|
||||
.long("output")
|
||||
.value_name("FILE")
|
||||
.help("Output file to write results to (use w/ --json for JSON entries)")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("resume_from")
|
||||
.long("resume-from")
|
||||
.value_name("STATE_FILE")
|
||||
.help("State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)")
|
||||
.conflicts_with("url")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("debug_log")
|
||||
.long("debug-log")
|
||||
.value_name("FILE")
|
||||
.help("Output file to write log entries (use w/ --json for JSON entries)")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user_agent")
|
||||
.short("a")
|
||||
.long("user-agent")
|
||||
.value_name("USER_AGENT")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
"Sets the User-Agent (default: feroxbuster/VERSION)"
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("redirects")
|
||||
.short("r")
|
||||
.long("redirects")
|
||||
.takes_value(false)
|
||||
.help("Follow redirects")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("insecure")
|
||||
.short("k")
|
||||
.long("insecure")
|
||||
.takes_value(false)
|
||||
.help("Disables TLS certificate validation")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("extensions")
|
||||
.short("x")
|
||||
.long("extensions")
|
||||
.value_name("FILE_EXTENSION")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"File extension(s) to search for (ex: -x php -x pdf js)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("headers")
|
||||
.short("H")
|
||||
.long("headers")
|
||||
.value_name("HEADER")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"Specify HTTP headers (ex: -H Header:val 'stuff: things')",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("queries")
|
||||
.short("Q")
|
||||
.long("query")
|
||||
.value_name("QUERY")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.help(
|
||||
"Specify URL query parameters (ex: -Q token=stuff -Q secret=key)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("no_recursion")
|
||||
.short("n")
|
||||
.long("no-recursion")
|
||||
.takes_value(false)
|
||||
.help("Do not scan recursively")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("add_slash")
|
||||
.short("f")
|
||||
.long("add-slash")
|
||||
.takes_value(false)
|
||||
.conflicts_with("extensions")
|
||||
.help("Append / to each request")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("stdin")
|
||||
Arg::new("stdin")
|
||||
.long("stdin")
|
||||
.help_heading("Target selection")
|
||||
.takes_value(false)
|
||||
.help("Read url(s) from STDIN")
|
||||
.conflicts_with("url")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_size")
|
||||
.short("S")
|
||||
Arg::new("resume_from")
|
||||
.long("resume-from")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.value_name("STATE_FILE")
|
||||
.help_heading("Target selection")
|
||||
.help("State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)")
|
||||
.conflicts_with("url")
|
||||
.takes_value(true),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - composite settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("burp")
|
||||
.long("burp")
|
||||
.help_heading("Composite settings")
|
||||
.conflicts_with_all(&["proxy", "insecure", "burp_replay"])
|
||||
.help("Set --proxy to http://127.0.0.1:8080 and set --insecure to true"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("burp_replay")
|
||||
.long("burp-replay")
|
||||
.help_heading("Composite settings")
|
||||
.conflicts_with_all(&["replay_proxy", "insecure"])
|
||||
.help("Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("smart")
|
||||
.long("smart")
|
||||
.help_heading("Composite settings")
|
||||
.help("Set --extract-links, --auto-tune, --collect-words, and --collect-backups to true"),
|
||||
).arg(
|
||||
Arg::new("thorough")
|
||||
.long("thorough")
|
||||
.help_heading("Composite settings")
|
||||
.help("Use the same settings as --smart and set --collect-extensions to true"),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - proxy settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("proxy")
|
||||
.short('p')
|
||||
.long("proxy")
|
||||
.takes_value(true)
|
||||
.value_name("PROXY")
|
||||
.value_hint(ValueHint::Url)
|
||||
.help_heading("Proxy settings")
|
||||
.help(
|
||||
"Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("replay_proxy")
|
||||
.short('P')
|
||||
.long("replay-proxy")
|
||||
.takes_value(true)
|
||||
.value_hint(ValueHint::Url)
|
||||
.value_name("REPLAY_PROXY")
|
||||
.help_heading("Proxy settings")
|
||||
.help(
|
||||
"Send only unfiltered requests through a Replay Proxy, instead of all requests",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("replay_codes")
|
||||
.short('R')
|
||||
.long("replay-codes")
|
||||
.value_name("REPLAY_CODE")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.requires("replay_proxy")
|
||||
.help_heading("Proxy settings")
|
||||
.help(
|
||||
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
|
||||
),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - request settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("user_agent")
|
||||
.short('a')
|
||||
.long("user-agent")
|
||||
.value_name("USER_AGENT")
|
||||
.takes_value(true)
|
||||
.help_heading("Request settings")
|
||||
.help(&**DEFAULT_USER_AGENT),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("random_agent")
|
||||
.short('A')
|
||||
.long("random-agent")
|
||||
.takes_value(false)
|
||||
.help_heading("Request settings")
|
||||
.help("Use a random User-Agent"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("extensions")
|
||||
.short('x')
|
||||
.long("extensions")
|
||||
.value_name("FILE_EXTENSION")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"File extension(s) to search for (ex: -x php -x pdf js)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("methods")
|
||||
.short('m')
|
||||
.long("methods")
|
||||
.value_name("HTTP_METHODS")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Which HTTP request method(s) should be sent (default: GET)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("data")
|
||||
.long("data")
|
||||
.value_name("DATA")
|
||||
.takes_value(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Request's Body; can read data from a file if input starts with an @ (ex: @post.bin)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("headers")
|
||||
.short('H')
|
||||
.long("headers")
|
||||
.value_name("HEADER")
|
||||
.takes_value(true)
|
||||
.help_heading("Request settings")
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help(
|
||||
"Specify HTTP headers to be used in each request (ex: -H Header:val -H 'stuff: things')",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("cookies")
|
||||
.short('b')
|
||||
.long("cookies")
|
||||
.value_name("COOKIE")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Specify HTTP cookies to be used in each request (ex: -b stuff=things)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("queries")
|
||||
.short('Q')
|
||||
.long("query")
|
||||
.value_name("QUERY")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Request settings")
|
||||
.help(
|
||||
"Request's URL query parameters (ex: -Q token=stuff -Q secret=key)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("add_slash")
|
||||
.short('f')
|
||||
.long("add-slash")
|
||||
.help_heading("Request settings")
|
||||
.takes_value(false)
|
||||
.help("Append / to each request's URL")
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - request filters
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app.arg(
|
||||
Arg::new("url_denylist")
|
||||
.long("dont-scan")
|
||||
.value_name("URL")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Request filters")
|
||||
.help("URL(s) or Regex Pattern(s) to exclude from recursion/scans"),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - response filters
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("filter_size")
|
||||
.short('S')
|
||||
.long("filter-size")
|
||||
.value_name("SIZE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_regex")
|
||||
.short("X")
|
||||
Arg::new("filter_regex")
|
||||
.short('X')
|
||||
.long("filter-regex")
|
||||
.value_name("REGEX")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages via regular expression matching on the response's body (ex: -X '^ignore me$')",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_words")
|
||||
.short("W")
|
||||
Arg::new("filter_words")
|
||||
.short('W')
|
||||
.long("filter-words")
|
||||
.value_name("WORDS")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages of a particular word count (ex: -W 312 -W 91,82)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_lines")
|
||||
.short("N")
|
||||
Arg::new("filter_lines")
|
||||
.short('N')
|
||||
.long("filter-lines")
|
||||
.value_name("LINES")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out messages of a particular line count (ex: -N 20 -N 31,30)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("filter_status")
|
||||
.short("C")
|
||||
Arg::new("filter_status")
|
||||
.short('C')
|
||||
.long("filter-status")
|
||||
.value_name("STATUS_CODE")
|
||||
.takes_value(true)
|
||||
.multiple(true)
|
||||
.use_delimiter(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out status codes (deny list) (ex: -C 200 -C 401)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("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_value_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("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_value_delimiter(true)
|
||||
.help_heading("Response filters")
|
||||
.help(
|
||||
"Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)",
|
||||
),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - client settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("timeout")
|
||||
.short('T')
|
||||
.long("timeout")
|
||||
.value_name("SECONDS")
|
||||
.takes_value(true)
|
||||
.help_heading("Client settings")
|
||||
.help("Number of seconds before a client's request times out (default: 7)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("redirects")
|
||||
.short('r')
|
||||
.long("redirects")
|
||||
.takes_value(false)
|
||||
.help_heading("Client settings")
|
||||
.help("Allow client to follow redirects"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("insecure")
|
||||
.short('k')
|
||||
.long("insecure")
|
||||
.takes_value(false)
|
||||
.help_heading("Client settings")
|
||||
.help("Disables TLS certificate validation in the client"),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - scan settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("threads")
|
||||
.short('t')
|
||||
.long("threads")
|
||||
.value_name("THREADS")
|
||||
.takes_value(true)
|
||||
.help_heading("Scan settings")
|
||||
.help("Number of concurrent threads (default: 50)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("no_recursion")
|
||||
.short('n')
|
||||
.long("no-recursion")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Do not scan recursively"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("depth")
|
||||
.short('d')
|
||||
.long("depth")
|
||||
.value_name("RECURSION_DEPTH")
|
||||
.takes_value(true)
|
||||
.help_heading("Scan settings")
|
||||
.help("Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)"),
|
||||
).arg(
|
||||
Arg::new("extract_links")
|
||||
.short('e')
|
||||
.long("extract-links")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Extract links from response body (html, javascript, etc...); make new requests based on findings")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("scan_limit")
|
||||
.short('L')
|
||||
.long("scan-limit")
|
||||
.value_name("SCAN_LIMIT")
|
||||
.takes_value(true)
|
||||
.help_heading("Scan settings")
|
||||
.help("Limit total number of concurrent scans (default: 0, i.e. no limit)")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("time_limit")
|
||||
Arg::new("parallel")
|
||||
.long("parallel")
|
||||
.value_name("PARALLEL_SCANS")
|
||||
.takes_value(true)
|
||||
.requires("stdin")
|
||||
.help_heading("Scan settings")
|
||||
.help("Run parallel feroxbuster instances (one child process per url passed via stdin)")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("rate_limit")
|
||||
.long("rate-limit")
|
||||
.value_name("RATE_LIMIT")
|
||||
.takes_value(true)
|
||||
.conflicts_with("auto_tune")
|
||||
.help_heading("Scan settings")
|
||||
.help("Limit number of requests per second (per directory) (default: 0, i.e. no limit)")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("time_limit")
|
||||
.long("time-limit")
|
||||
.value_name("TIME_SPEC")
|
||||
.takes_value(true)
|
||||
.validator(valid_time_spec)
|
||||
.help_heading("Scan settings")
|
||||
.help("Limit total run time of all scans (ex: --time-limit 10m)")
|
||||
)
|
||||
.group(ArgGroup::with_name("output_files")
|
||||
.args(&["debug_log", "output"])
|
||||
.multiple(true)
|
||||
.arg(
|
||||
Arg::new("wordlist")
|
||||
.short('w')
|
||||
.long("wordlist")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.value_name("FILE")
|
||||
.help("Path to the wordlist")
|
||||
.help_heading("Scan settings")
|
||||
.takes_value(true),
|
||||
).arg(
|
||||
Arg::new("auto_tune")
|
||||
.long("auto-tune")
|
||||
.takes_value(false)
|
||||
.conflicts_with("auto_bail")
|
||||
.help_heading("Scan settings")
|
||||
.help("Automatically lower scan rate when an excessive amount of errors are encountered")
|
||||
)
|
||||
.after_help(r#"NOTE:
|
||||
.arg(
|
||||
Arg::new("auto_bail")
|
||||
.long("auto-bail")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Automatically stop scanning when an excessive amount of errors are encountered")
|
||||
).arg(
|
||||
Arg::new("dont_filter")
|
||||
.short('D')
|
||||
.long("dont-filter")
|
||||
.takes_value(false)
|
||||
.help_heading("Scan settings")
|
||||
.help("Don't auto-filter wildcard responses")
|
||||
).arg(
|
||||
Arg::new("collect_extensions")
|
||||
.short('E')
|
||||
.long("collect-extensions")
|
||||
.takes_value(false)
|
||||
.help_heading("Dynamic collection settings")
|
||||
.help("Automatically discover extensions and add them to --extensions (unless they're in --dont-collect)")
|
||||
).arg(
|
||||
Arg::new("collect_backups")
|
||||
.short('B')
|
||||
.long("collect-backups")
|
||||
.takes_value(false)
|
||||
.help_heading("Dynamic collection settings")
|
||||
.help("Automatically request likely backup extensions for \"found\" urls")
|
||||
).arg(
|
||||
Arg::new("collect_words")
|
||||
.short('g')
|
||||
.long("collect-words")
|
||||
.takes_value(false)
|
||||
.help_heading("Dynamic collection settings")
|
||||
.help("Automatically discover important words from within responses and add them to the wordlist")
|
||||
).arg(
|
||||
Arg::new("dont_collect")
|
||||
.short('I')
|
||||
.long("dont-collect")
|
||||
.value_name("FILE_EXTENSION")
|
||||
.takes_value(true)
|
||||
.multiple_values(true)
|
||||
.multiple_occurrences(true)
|
||||
.use_value_delimiter(true)
|
||||
.help_heading("Dynamic collection settings")
|
||||
.help(
|
||||
"File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)",
|
||||
),
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - output settings
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let app = app
|
||||
.arg(
|
||||
Arg::new("verbosity")
|
||||
.short('v')
|
||||
.long("verbosity")
|
||||
.takes_value(false)
|
||||
.multiple_occurrences(true)
|
||||
.conflicts_with("silent")
|
||||
.help_heading("Output settings")
|
||||
.help("Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v's is probably too much)"),
|
||||
).arg(
|
||||
Arg::new("silent")
|
||||
.long("silent")
|
||||
.takes_value(false)
|
||||
.conflicts_with("quiet")
|
||||
.help_heading("Output settings")
|
||||
.help("Only print URLs + turn off logging (good for piping a list of urls to other commands)")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("quiet")
|
||||
.short('q')
|
||||
.long("quiet")
|
||||
.takes_value(false)
|
||||
.help_heading("Output settings")
|
||||
.help("Hide progress bars and banner (good for tmux windows w/ notifications)")
|
||||
)
|
||||
|
||||
.arg(
|
||||
Arg::new("json")
|
||||
.long("json")
|
||||
.takes_value(false)
|
||||
.requires("output_files")
|
||||
.help_heading("Output settings")
|
||||
.help("Emit JSON logs to --output and --debug-log instead of normal text")
|
||||
).arg(
|
||||
Arg::new("output")
|
||||
.short('o')
|
||||
.long("output")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.value_name("FILE")
|
||||
.help_heading("Output settings")
|
||||
.help("Output file to write results to (use w/ --json for JSON entries)")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("debug_log")
|
||||
.long("debug-log")
|
||||
.value_name("FILE")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.help_heading("Output settings")
|
||||
.help("Output file to write log entries (use w/ --json for JSON entries)")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("no_state")
|
||||
.long("no-state")
|
||||
.takes_value(false)
|
||||
.help_heading("Output settings")
|
||||
.help("Disable state output file (*.state)")
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// group - miscellaneous
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
let mut app = app
|
||||
.group(
|
||||
ArgGroup::new("output_files")
|
||||
.args(&["debug_log", "output"])
|
||||
.multiple(true),
|
||||
)
|
||||
.after_long_help(EPILOGUE);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// end parser
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
for arg in env::args() {
|
||||
// secure-77 noticed that when an incorrect flag/option is used, the short help message is printed
|
||||
// which is fine, but if you add -h|--help, it still errors out on the bad flag/option,
|
||||
// never showing the full help message. This code addresses that behavior
|
||||
if arg == "--help" {
|
||||
app.print_long_help().unwrap();
|
||||
println!(); // just a newline to mirror original --help output
|
||||
process::exit(0);
|
||||
} else if arg == "-h" {
|
||||
// same for -h, just shorter
|
||||
app.print_help().unwrap();
|
||||
println!();
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Validate that a string is formatted as a number followed by s, m, h, or d (10d, 30s, etc...)
|
||||
fn valid_time_spec(time_spec: &str) -> Result<(), String> {
|
||||
match TIMESPEC_REGEX.is_match(time_spec) {
|
||||
true => Ok(()),
|
||||
false => {
|
||||
let msg = format!(
|
||||
"Expected a non-negative, whole number followed by s, m, h, or d (case insensitive); received {}",
|
||||
time_spec
|
||||
);
|
||||
Err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EPILOGUE: &str = r#"NOTE:
|
||||
Options that take multiple values are very flexible. Consider the following ways of specifying
|
||||
extensions:
|
||||
./feroxbuster -u http://127.1 -x pdf -x js,html -x php txt json,docx
|
||||
@@ -346,7 +665,7 @@ EXAMPLES:
|
||||
./feroxbuster -u http://[::1] --no-recursion -vv
|
||||
|
||||
Read urls from STDIN; pipe only resulting urls out to another tool
|
||||
cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
|
||||
cat targets | ./feroxbuster --stdin --silent -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
|
||||
|
||||
Proxy traffic through Burp
|
||||
./feroxbuster -u http://127.1 --insecure --proxy http://127.0.0.1:8080
|
||||
@@ -361,30 +680,28 @@ EXAMPLES:
|
||||
./feroxbuster -u http://127.1 --extract-links
|
||||
|
||||
Ludicrous speed... go!
|
||||
./feroxbuster -u http://127.1 -t 200
|
||||
"#)
|
||||
}
|
||||
|
||||
/// Validate that a string is formatted as a number followed by s, m, h, or d (10d, 30s, etc...)
|
||||
fn valid_time_spec(time_spec: String) -> Result<(), String> {
|
||||
match TIMESPEC_REGEX.is_match(&time_spec) {
|
||||
true => Ok(()),
|
||||
false => {
|
||||
let msg = format!(
|
||||
"Expected a non-negative, whole number followed by s, m, h, or d (case insensitive); received {}",
|
||||
time_spec
|
||||
);
|
||||
Err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
./feroxbuster -u http://127.1 -threads 200
|
||||
|
||||
Limit to a total of 60 active requests at any given time (threads * scan limit)
|
||||
./feroxbuster -u http://127.1 --threads 30 --scan-limit 2
|
||||
|
||||
Send all 200/302 responses to a proxy (only proxy requests/responses you care about)
|
||||
./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
|
||||
|
||||
Abort or reduce scan speed to individual directory scans when too many errors have occurred
|
||||
./feroxbuster -u http://127.1 --auto-bail
|
||||
./feroxbuster -u http://127.1 --auto-tune
|
||||
|
||||
Examples and demonstrations of all features
|
||||
https://epi052.github.io/feroxbuster-docs/docs/examples/
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// initalize parser, expect a clap::App returned
|
||||
/// initialize parser, expect a clap::App returned
|
||||
fn parser_initialize_gives_defaults() {
|
||||
let app = initialize();
|
||||
assert_eq!(app.get_name(), "feroxbuster");
|
||||
@@ -397,29 +714,29 @@ mod tests {
|
||||
/// that i didn't hose up the regex. Going to consolidate them into a single test
|
||||
fn validate_valid_time_spec_validation() {
|
||||
let float_rejected = "1.4m";
|
||||
assert!(valid_time_spec(float_rejected.into()).is_err());
|
||||
assert!(valid_time_spec(float_rejected).is_err());
|
||||
|
||||
let negative_rejected = "-1m";
|
||||
assert!(valid_time_spec(negative_rejected.into()).is_err());
|
||||
assert!(valid_time_spec(negative_rejected).is_err());
|
||||
|
||||
let only_number_rejected = "1";
|
||||
assert!(valid_time_spec(only_number_rejected.into()).is_err());
|
||||
assert!(valid_time_spec(only_number_rejected).is_err());
|
||||
|
||||
let only_measurement_rejected = "m";
|
||||
assert!(valid_time_spec(only_measurement_rejected.into()).is_err());
|
||||
assert!(valid_time_spec(only_measurement_rejected).is_err());
|
||||
|
||||
for accepted_measurement in &["s", "m", "h", "d", "S", "M", "H", "D"] {
|
||||
// all upper/lowercase should be good
|
||||
assert!(valid_time_spec(format!("1{}", *accepted_measurement)).is_ok());
|
||||
assert!(valid_time_spec(&format!("1{}", *accepted_measurement)).is_ok());
|
||||
}
|
||||
|
||||
let leading_space_rejected = " 14m";
|
||||
assert!(valid_time_spec(leading_space_rejected.into()).is_err());
|
||||
assert!(valid_time_spec(leading_space_rejected).is_err());
|
||||
|
||||
let trailing_space_rejected = "14m ";
|
||||
assert!(valid_time_spec(trailing_space_rejected.into()).is_err());
|
||||
assert!(valid_time_spec(trailing_space_rejected).is_err());
|
||||
|
||||
let space_between_rejected = "1 4m";
|
||||
assert!(valid_time_spec(space_between_rejected.into()).is_err());
|
||||
assert!(valid_time_spec(space_between_rejected).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
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, hide_per_sec: bool) -> ProgressBar {
|
||||
let style = if hidden || CONFIGURATION.quiet {
|
||||
ProgressStyle::default_bar().template("")
|
||||
} else if hide_per_sec {
|
||||
ProgressStyle::default_bar()
|
||||
.template(&format!(
|
||||
"[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}}",
|
||||
"-"
|
||||
))
|
||||
.progress_chars("#>-")
|
||||
} 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
|
||||
}
|
||||
@@ -35,16 +64,19 @@ mod tests {
|
||||
#[test]
|
||||
/// hit all code branches for add_bar
|
||||
fn add_bar_with_all_configurations() {
|
||||
let p1 = add_bar("prefix", 2, true, false); // hidden
|
||||
let p2 = add_bar("prefix", 2, false, true); // no per second field
|
||||
let p3 = add_bar("prefix", 2, false, false); // normal bar
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
232
src/reporter.rs
@@ -1,232 +0,0 @@
|
||||
use crate::{
|
||||
config::{CONFIGURATION, PROGRESS_PRINTER},
|
||||
scanner::RESPONSES,
|
||||
utils::{ferox_print, make_request, open_file},
|
||||
FeroxChannel, FeroxResponse, FeroxSerialize,
|
||||
};
|
||||
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<FeroxResponse>,
|
||||
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<FeroxResponse> = 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<FeroxResponse>,
|
||||
save_output: bool,
|
||||
) {
|
||||
log::trace!(
|
||||
"enter: spawn_terminal_reporter({:?}, {:?}, {})",
|
||||
resp_chan,
|
||||
file_chan,
|
||||
save_output
|
||||
);
|
||||
|
||||
while let Some(mut resp) = resp_chan.recv().await {
|
||||
log::trace!("received {} on reporting channel", resp.url());
|
||||
|
||||
let contains_sentry = CONFIGURATION.status_codes.contains(&resp.status().as_u16());
|
||||
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
|
||||
let should_process_response = contains_sentry && unknown_sentry;
|
||||
|
||||
if should_process_response {
|
||||
// print to stdout
|
||||
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
|
||||
|
||||
if save_output {
|
||||
// -o used, need to send the report to be written out to disk
|
||||
match file_chan.send(resp.clone()) {
|
||||
Ok(_) => {
|
||||
log::debug!("Sent {} to file handler", resp.url());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not send {} to file handler: {}", resp.url(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log::trace!("report complete: {}", resp.url());
|
||||
|
||||
if CONFIGURATION.replay_client.is_some() && should_process_response {
|
||||
// replay proxy specified/client created and this response's status code is one that
|
||||
// should be replayed
|
||||
match make_request(CONFIGURATION.replay_client.as_ref().unwrap(), &resp.url()).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_process_response {
|
||||
// add response to RESPONSES for serialization in case of ctrl+c
|
||||
// placed all by its lonesome like this so that RESPONSES can take ownership
|
||||
// of the FeroxResponse
|
||||
|
||||
// before ownership is transferred, there's no real reason to keep the body anymore
|
||||
// so we can free that piece of data, reducing memory usage
|
||||
resp.text = String::new();
|
||||
|
||||
RESPONSES.insert(resp);
|
||||
}
|
||||
}
|
||||
log::trace!("exit: spawn_terminal_reporter");
|
||||
}
|
||||
|
||||
/// Spawn a single consumer task (sc side of mpsc)
|
||||
///
|
||||
/// The consumer simply receives responses and writes them to the given output file if they meet
|
||||
/// the given reporting criteria
|
||||
async fn spawn_file_reporter(
|
||||
mut report_channel: UnboundedReceiver<FeroxResponse>,
|
||||
output_file: &str,
|
||||
) {
|
||||
let buffered_file = match get_cached_file_handle(&CONFIGURATION.output) {
|
||||
Some(file) => file,
|
||||
None => {
|
||||
log::trace!("exit: spawn_file_reporter");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::trace!(
|
||||
"enter: spawn_file_reporter({:?}, {})",
|
||||
report_channel,
|
||||
output_file
|
||||
);
|
||||
|
||||
log::info!("Writing scan results to {}", output_file);
|
||||
|
||||
while let Some(response) = report_channel.recv().await {
|
||||
safe_file_write(&response, buffered_file.clone(), CONFIGURATION.json);
|
||||
}
|
||||
|
||||
log::trace!("exit: spawn_file_reporter");
|
||||
}
|
||||
|
||||
/// Given a string and a reference to a locked buffered file, write the contents and flush
|
||||
/// the buffer to disk.
|
||||
pub fn safe_file_write<T>(
|
||||
value: &T,
|
||||
locked_file: Arc<RwLock<io::BufWriter<fs::File>>>,
|
||||
convert_to_json: bool,
|
||||
) where
|
||||
T: FeroxSerialize,
|
||||
{
|
||||
// note to future self: adding logging of anything other than error to this function
|
||||
// is a bad idea. we call this function while processing records generated by the logger.
|
||||
// If we then call log::... while already processing some logging output, it results in
|
||||
// the second log entry being injected into the first.
|
||||
|
||||
let contents = if convert_to_json {
|
||||
value.as_json()
|
||||
} else {
|
||||
value.as_str()
|
||||
};
|
||||
|
||||
let contents = strip_ansi_codes(&contents);
|
||||
|
||||
if let Ok(mut handle) = locked_file.write() {
|
||||
// write lock acquired
|
||||
match handle.write(contents.as_bytes()) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("could not write report to disk: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match handle.flush() {
|
||||
// this function is used within async functions/loops, so i'm flushing so that in
|
||||
// the event of a ctrl+c or w/e results seen so far are saved instead of left lying
|
||||
// around in the buffer
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("error writing to file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
/// asserts that an empty string for a filename returns None
|
||||
fn reporter_get_cached_file_handle_without_filename_returns_none() {
|
||||
let _used = get_cached_file_handle(&"").unwrap();
|
||||
}
|
||||
}
|
||||
783
src/response.rs
Normal file
@@ -0,0 +1,783 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
convert::{TryFrom, TryInto},
|
||||
fmt,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use console::style;
|
||||
use reqwest::{
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
Method, Response, StatusCode, Url,
|
||||
};
|
||||
use serde::ser::SerializeStruct;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
event_handlers::{Command, Handles},
|
||||
traits::FeroxSerialize,
|
||||
url::FeroxUrl,
|
||||
utils::{self, fmt_err, status_colorizer},
|
||||
CommandSender,
|
||||
};
|
||||
|
||||
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeroxResponse {
|
||||
/// The final `Url` of this `FeroxResponse`
|
||||
url: Url,
|
||||
|
||||
/// The original url from which the final `Url` was derived
|
||||
original_url: String,
|
||||
|
||||
/// The `StatusCode` of this `FeroxResponse`
|
||||
status: StatusCode,
|
||||
|
||||
/// The HTTP Request `Method` of this `FeroxResponse`
|
||||
method: Method,
|
||||
|
||||
/// The full response text
|
||||
text: String,
|
||||
|
||||
/// The content-length of this response, if known
|
||||
content_length: u64,
|
||||
|
||||
/// The number of lines contained in the body of this response, if known
|
||||
line_count: usize,
|
||||
|
||||
/// The number of words contained in the body of this response, if known
|
||||
word_count: usize,
|
||||
|
||||
/// The `Headers` of this `FeroxResponse`
|
||||
headers: HeaderMap,
|
||||
|
||||
/// Wildcard response status
|
||||
wildcard: bool,
|
||||
|
||||
/// whether the user passed --quiet|--silent on the command line
|
||||
pub(crate) output_level: OutputLevel,
|
||||
|
||||
/// Url's file extension, if one exists
|
||||
pub(crate) extension: Option<String>,
|
||||
}
|
||||
|
||||
/// implement Default trait for FeroxResponse
|
||||
impl Default for FeroxResponse {
|
||||
/// return a default reqwest::Url and then normal defaults after that
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: Url::parse("http://localhost").unwrap(),
|
||||
original_url: "".to_string(),
|
||||
status: Default::default(),
|
||||
method: Method::default(),
|
||||
text: "".to_string(),
|
||||
content_length: 0,
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
headers: Default::default(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
extension: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement Display for FeroxResponse
|
||||
impl fmt::Display for FeroxResponse {
|
||||
/// formatter for Display
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"FeroxResponse {{ url: {}, method: {}, status: {}, content-length: {} }}",
|
||||
self.url(),
|
||||
self.method(),
|
||||
self.status(),
|
||||
self.content_length()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `FeroxResponse` implementation
|
||||
impl FeroxResponse {
|
||||
/// Get the `StatusCode` of this `FeroxResponse`
|
||||
pub fn status(&self) -> &StatusCode {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get the `Method` of this `FeroxResponse`
|
||||
pub fn method(&self) -> &Method {
|
||||
&self.method
|
||||
}
|
||||
|
||||
/// Get the `wildcard` of this `FeroxResponse`
|
||||
pub fn wildcard(&self) -> bool {
|
||||
self.wildcard
|
||||
}
|
||||
|
||||
/// Get the final `Url` of this `FeroxResponse`.
|
||||
pub fn url(&self) -> &Url {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Get the full response text
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Get the `Headers` of this `FeroxResponse`
|
||||
pub fn headers(&self) -> &HeaderMap {
|
||||
&self.headers
|
||||
}
|
||||
|
||||
/// Get the content-length of this response, if known
|
||||
pub fn content_length(&self) -> u64 {
|
||||
self.content_length
|
||||
}
|
||||
|
||||
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
|
||||
pub fn set_url(&mut self, url: &str) {
|
||||
match Url::parse(url) {
|
||||
Ok(url) => {
|
||||
self.url = url;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not parse {} into a Url: {}", url, e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// set `wildcard` attribute
|
||||
pub fn set_wildcard(&mut self, is_wildcard: bool) {
|
||||
self.wildcard = is_wildcard;
|
||||
}
|
||||
|
||||
/// set `text` attribute; update words/lines/content_length
|
||||
#[cfg(test)]
|
||||
pub fn set_text(&mut self, text: &str) {
|
||||
self.text = String::from(text);
|
||||
self.content_length = self.text.len() as u64;
|
||||
self.line_count = self.text.lines().count();
|
||||
self.word_count = self
|
||||
.text
|
||||
.lines()
|
||||
.map(|s| s.split_whitespace().count())
|
||||
.sum();
|
||||
}
|
||||
|
||||
/// free the `text` data, reducing memory usage
|
||||
pub fn drop_text(&mut self) {
|
||||
self.text = String::new();
|
||||
}
|
||||
|
||||
/// Make a reasonable guess at whether the response is a file or not
|
||||
///
|
||||
/// Examines the last part of a path to determine if it has an obvious extension
|
||||
/// i.e. http://localhost/some/path/stuff.js where stuff.js indicates a file
|
||||
///
|
||||
/// Additionally, inspects query parameters, as they're also often indicative of a file
|
||||
pub fn is_file(&self) -> bool {
|
||||
let has_extension = match self.url.path_segments() {
|
||||
Some(path) => {
|
||||
if let Some(last) = path.last() {
|
||||
last.contains('.') // last segment has some sort of extension, probably
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
self.url.query_pairs().count() > 0 || has_extension
|
||||
}
|
||||
|
||||
/// Returns line count of the response text.
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.line_count
|
||||
}
|
||||
|
||||
/// Returns word count of the response text.
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.word_count
|
||||
}
|
||||
|
||||
/// Create a new `FeroxResponse` from the given `Response`
|
||||
pub async fn from(
|
||||
response: Response,
|
||||
original_url: &str,
|
||||
method: &str,
|
||||
output_level: OutputLevel,
|
||||
) -> Self {
|
||||
let url = response.url().clone();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let content_length = response.content_length().unwrap_or(0);
|
||||
|
||||
// .text() consumes the response, must be called last
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.with_context(|| "Could not parse body from response")
|
||||
.unwrap_or_default();
|
||||
|
||||
let line_count = text.lines().count();
|
||||
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
|
||||
|
||||
FeroxResponse {
|
||||
url,
|
||||
original_url: original_url.to_string(),
|
||||
status,
|
||||
method: Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET),
|
||||
content_length,
|
||||
text,
|
||||
headers,
|
||||
line_count,
|
||||
word_count,
|
||||
output_level,
|
||||
wildcard: false,
|
||||
extension: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// if --collect-extensions is used, examine the response's url and grab the file's extension
|
||||
/// if one is available to be grabbed. If an extension is found, send it to the ScanHandler
|
||||
/// for further processing
|
||||
pub(crate) fn parse_extension(&mut self, handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: parse_extension");
|
||||
|
||||
if !handles.config.collect_extensions {
|
||||
// early return, --collect-extensions not used
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// path_segments:
|
||||
// Return None for cannot-be-a-base URLs.
|
||||
// When Some is returned, the iterator always contains at least one string
|
||||
// (which may be empty).
|
||||
//
|
||||
// meaning: the two unwraps here are fine, the worst outcome is an empty string
|
||||
let filename = self.url.path_segments().unwrap().last().unwrap();
|
||||
|
||||
if !filename.is_empty() {
|
||||
// non-empty string, try to get extension
|
||||
let parts: Vec<_> = filename
|
||||
.split('.')
|
||||
// keep things like /.bash_history from becoming an extension
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.len() > 1 {
|
||||
// filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
|
||||
self.extension = Some(parts.last().unwrap().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extension) = &self.extension {
|
||||
if handles
|
||||
.config
|
||||
.status_codes
|
||||
.contains(&self.status().as_u16())
|
||||
{
|
||||
// only add extensions to those responses that pass our checks; filtered out
|
||||
// status codes are handled by should_filter, but we need to still check against
|
||||
// the allow list for what we want to keep
|
||||
#[cfg(test)]
|
||||
handles
|
||||
.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))
|
||||
.unwrap_or_default();
|
||||
#[cfg(not(test))]
|
||||
handles.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))?;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: parse_extension");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function that determines if the configured maximum recursion depth has been reached
|
||||
///
|
||||
/// Essentially looks at the Url path and determines how many directories are present in the
|
||||
/// given Url
|
||||
pub(crate) fn reached_max_depth(
|
||||
&self,
|
||||
base_depth: usize,
|
||||
max_depth: usize,
|
||||
handles: Arc<Handles>,
|
||||
) -> bool {
|
||||
log::trace!(
|
||||
"enter: reached_max_depth({}, {}, {:?})",
|
||||
base_depth,
|
||||
max_depth,
|
||||
handles
|
||||
);
|
||||
|
||||
if max_depth == 0 {
|
||||
// early return, as 0 means recurse forever; no additional processing needed
|
||||
log::trace!("exit: reached_max_depth -> false");
|
||||
return false;
|
||||
}
|
||||
let url = FeroxUrl::from_url(&self.url, handles);
|
||||
let depth = url.depth().unwrap_or_default(); // 0 on error
|
||||
|
||||
if depth - base_depth >= max_depth {
|
||||
return true;
|
||||
}
|
||||
|
||||
log::trace!("exit: reached_max_depth -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Helper function to determine suitability for recursion
|
||||
///
|
||||
/// handles 2xx and 3xx responses by either checking if the url ends with a / (2xx)
|
||||
/// or if the Location header is present and matches the base url + / (3xx)
|
||||
pub fn is_directory(&self) -> bool {
|
||||
log::trace!("enter: is_directory({})", self);
|
||||
|
||||
if self.status().is_redirection() {
|
||||
// status code is 3xx
|
||||
match self.headers().get("Location") {
|
||||
// and has a Location header
|
||||
Some(loc) => {
|
||||
// get absolute redirect Url based on the already known base url
|
||||
log::debug!("Location header: {:?}", loc);
|
||||
|
||||
if let Ok(loc_str) = loc.to_str() {
|
||||
if let Ok(abs_url) = self.url().join(loc_str) {
|
||||
if format!("{}/", self.url()) == abs_url.as_str() {
|
||||
// if current response's Url + / == the absolute redirection
|
||||
// location, we've found a directory suitable for recursion
|
||||
log::debug!(
|
||||
"found directory suitable for recursion: {}",
|
||||
self.url()
|
||||
);
|
||||
log::trace!("exit: is_directory -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::debug!("expected Location header, but none was found: {}", self);
|
||||
log::trace!("exit: is_directory -> false");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if self.status().is_success() || matches!(self.status(), &StatusCode::FORBIDDEN) {
|
||||
// status code is 2xx or 403, need to check if it ends in /
|
||||
|
||||
if self.url().as_str().ends_with('/') {
|
||||
log::debug!("{} is directory suitable for recursion", self.url());
|
||||
log::trace!("exit: is_directory -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: is_directory -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Simple helper to send a `FeroxResponse` over the tx side of an `mpsc::unbounded_channel`
|
||||
pub fn send_report(self, report_sender: CommandSender) -> Result<()> {
|
||||
log::trace!("enter: send_report({:?}", report_sender);
|
||||
|
||||
report_sender.send(Command::Report(Box::new(self)))?;
|
||||
|
||||
log::trace!("exit: send_report");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement FeroxSerialize for FeroxResponse
|
||||
impl FeroxSerialize for FeroxResponse {
|
||||
/// Simple wrapper around create_report_string
|
||||
fn as_str(&self) -> String {
|
||||
let lines = self.line_count().to_string();
|
||||
let words = self.word_count().to_string();
|
||||
let chars = self.content_length().to_string();
|
||||
let status = self.status().as_str();
|
||||
let method = self.method().as_str();
|
||||
let wild_status = status_colorizer("WLD");
|
||||
|
||||
let mut url_with_redirect = match (
|
||||
self.status().is_redirection(),
|
||||
self.headers().get("Location").is_some(),
|
||||
) {
|
||||
(true, true) => {
|
||||
// redirect with Location header, show where it goes if possible
|
||||
let loc = self
|
||||
.headers()
|
||||
.get("Location")
|
||||
.unwrap() // known Some() already
|
||||
.to_str()
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
// prettify the redirect target
|
||||
let loc = style(loc).yellow();
|
||||
|
||||
format!("{} => {loc}", self.url())
|
||||
}
|
||||
_ => {
|
||||
// no redirect, just use the normal url
|
||||
self.url().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
if self.wildcard && matches!(self.output_level, OutputLevel::Default | OutputLevel::Quiet) {
|
||||
// --silent was not used and response is a wildcard, special messages abound when
|
||||
// this is the case...
|
||||
|
||||
// create the base message
|
||||
let mut message = format!(
|
||||
"{} {:>8} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
|
||||
wild_status,
|
||||
method,
|
||||
lines,
|
||||
words,
|
||||
chars,
|
||||
status_colorizer(status),
|
||||
self.url(),
|
||||
FeroxUrl::path_length_of_url(&self.url)
|
||||
);
|
||||
|
||||
if self.status().is_redirection() {
|
||||
// initial wildcard messages are wordy enough, put the redirect by itself
|
||||
url_with_redirect = format!(
|
||||
"{} {:>9} {:>9} {:>9} {}\n",
|
||||
wild_status, "-", "-", "-", url_with_redirect
|
||||
);
|
||||
|
||||
// base message + redirection message (either empty string or redir msg)
|
||||
message.push_str(&url_with_redirect);
|
||||
}
|
||||
|
||||
message
|
||||
} else {
|
||||
// not a wildcard, just create a normal entry
|
||||
utils::create_report_string(
|
||||
self.status.as_str(),
|
||||
method,
|
||||
&lines,
|
||||
&words,
|
||||
&chars,
|
||||
&url_with_redirect,
|
||||
self.output_level,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an NDJSON representation of the FeroxResponse
|
||||
///
|
||||
/// (expanded for clarity)
|
||||
/// ex:
|
||||
/// {
|
||||
/// "type":"response",
|
||||
/// "url":"https://localhost.com/images",
|
||||
/// "path":"/images",
|
||||
/// "status":301,
|
||||
/// "content_length":179,
|
||||
/// "line_count":10,
|
||||
/// "word_count":16,
|
||||
/// "headers":{
|
||||
/// "x-content-type-options":"nosniff",
|
||||
/// "strict-transport-security":"max-age=31536000; includeSubDomains",
|
||||
/// "x-frame-options":"SAMEORIGIN",
|
||||
/// "connection":"keep-alive",
|
||||
/// "server":"nginx/1.16.1",
|
||||
/// "content-type":"text/html; charset=UTF-8",
|
||||
/// "referrer-policy":"origin-when-cross-origin",
|
||||
/// "content-security-policy":"default-src 'none'",
|
||||
/// "access-control-allow-headers":"X-Requested-With",
|
||||
/// "x-xss-protection":"1; mode=block",
|
||||
/// "content-length":"179",
|
||||
/// "date":"Mon, 23 Nov 2020 15:33:24 GMT",
|
||||
/// "location":"/images/",
|
||||
/// "access-control-allow-origin":"https://localhost.com"
|
||||
/// }
|
||||
/// }\n
|
||||
fn as_json(&self) -> anyhow::Result<String> {
|
||||
let mut json = serde_json::to_string(&self)
|
||||
.with_context(|| fmt_err(&format!("Could not convert {} to JSON", self.url())))?;
|
||||
json.push('\n');
|
||||
Ok(json)
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxResponse
|
||||
impl Serialize for FeroxResponse {
|
||||
/// Function that handles serialization of a FeroxResponse to NDJSON
|
||||
fn serialize<S>(&self, serializer: S) -> anyhow::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut headers = HashMap::new();
|
||||
let mut state = serializer.serialize_struct("FeroxResponse", 8)?;
|
||||
|
||||
// need to convert the HeaderMap to a HashMap in order to pass it to the serializer
|
||||
for (key, value) in &self.headers {
|
||||
let k = key.as_str().to_owned();
|
||||
let v = String::from_utf8_lossy(value.as_bytes());
|
||||
headers.insert(k, v);
|
||||
}
|
||||
|
||||
state.serialize_field("type", "response")?;
|
||||
state.serialize_field("url", self.url.as_str())?;
|
||||
state.serialize_field("original_url", self.original_url.as_str())?;
|
||||
state.serialize_field("path", self.url.path())?;
|
||||
state.serialize_field("wildcard", &self.wildcard)?;
|
||||
state.serialize_field("status", &self.status.as_u16())?;
|
||||
state.serialize_field("method", &self.method.as_str())?;
|
||||
state.serialize_field("content_length", &self.content_length)?;
|
||||
state.serialize_field("line_count", &self.line_count)?;
|
||||
state.serialize_field("word_count", &self.word_count)?;
|
||||
state.serialize_field("headers", &headers)?;
|
||||
state.serialize_field(
|
||||
"extension",
|
||||
self.extension.as_ref().unwrap_or(&String::new()),
|
||||
)?;
|
||||
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize implementation for FeroxResponse
|
||||
impl<'de> Deserialize<'de> for FeroxResponse {
|
||||
/// Deserialize a FeroxResponse from a serde_json::Value
|
||||
fn deserialize<D>(deserializer: D) -> anyhow::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut response = Self {
|
||||
url: Url::parse("http://localhost").unwrap(),
|
||||
original_url: String::new(),
|
||||
status: StatusCode::OK,
|
||||
method: Method::GET,
|
||||
text: String::new(),
|
||||
content_length: 0,
|
||||
headers: HeaderMap::new(),
|
||||
wildcard: false,
|
||||
output_level: Default::default(),
|
||||
line_count: 0,
|
||||
word_count: 0,
|
||||
extension: None,
|
||||
};
|
||||
|
||||
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
|
||||
|
||||
for (key, value) in &map {
|
||||
match key.as_str() {
|
||||
"url" => {
|
||||
if let Some(url) = value.as_str() {
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
response.url = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
"original_url" => {
|
||||
if let Some(og_url) = value.as_str() {
|
||||
response.original_url = String::from(og_url);
|
||||
}
|
||||
}
|
||||
"status" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
if let Ok(smaller) = u16::try_from(num) {
|
||||
if let Ok(status) = StatusCode::from_u16(smaller) {
|
||||
response.status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"method" => {
|
||||
if let Some(method) = value.as_str() {
|
||||
response.method = Method::from_bytes(method.as_bytes()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"content_length" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.content_length = num;
|
||||
}
|
||||
}
|
||||
"line_count" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.line_count = num.try_into().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"word_count" => {
|
||||
if let Some(num) = value.as_u64() {
|
||||
response.word_count = num.try_into().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
"headers" => {
|
||||
let mut headers = HeaderMap::<HeaderValue>::default();
|
||||
|
||||
if let Some(map_headers) = value.as_object() {
|
||||
for (h_key, h_value) in map_headers {
|
||||
let h_value_str = h_value.as_str().unwrap_or("");
|
||||
let h_name = HeaderName::from_str(h_key)
|
||||
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
|
||||
let h_value_parsed = HeaderValue::from_str(h_value_str)
|
||||
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
|
||||
headers.insert(h_name, h_value_parsed);
|
||||
}
|
||||
}
|
||||
|
||||
response.headers = headers;
|
||||
}
|
||||
"wildcard" => {
|
||||
if let Some(result) = value.as_bool() {
|
||||
response.wildcard = result;
|
||||
}
|
||||
}
|
||||
"extension" => {
|
||||
if let Some(result) = value.as_str() {
|
||||
response.extension = Some(result.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Configuration;
|
||||
use std::default::Default;
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
|
||||
fn reached_max_depth_returns_early_on_zero() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
let result = response.reached_max_depth(0, 0, handles);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url depth equal to max depth, expect true
|
||||
fn reached_max_depth_current_depth_equals_max() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let url = Url::parse("http://localhost/one/two").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(0, 2, handles);
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url dpeth less than max depth, expect false
|
||||
fn reached_max_depth_current_depth_less_than_max() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(0, 2, handles);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url of 2, base depth of 2, and max depth of 2, expect false
|
||||
fn reached_max_depth_base_depth_equals_max_depth() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost/one/two").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(2, 2, handles);
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call reached_max_depth with url depth greater than max depth, expect true
|
||||
fn reached_max_depth_current_greater_than_max() {
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
let url = Url::parse("http://localhost/one/two/three").unwrap();
|
||||
let response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = response.reached_max_depth(0, 2, handles);
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// simple case of a single extension gets parsed correctly and stored on the `FeroxResponse`
|
||||
fn parse_extension_finds_simple_extension() {
|
||||
let config = Configuration {
|
||||
collect_extensions: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (handles, _) = Handles::for_testing(None, Some(Arc::new(config)));
|
||||
|
||||
let url = Url::parse("http://localhost/derp.js").unwrap();
|
||||
|
||||
let mut response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
response.parse_extension(Arc::new(handles)).unwrap();
|
||||
|
||||
assert_eq!(response.extension, Some(String::from("js")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// hidden files shouldn't be parsed as extensions, i.e. `/.bash_history`
|
||||
fn parse_extension_ignores_hidden_files() {
|
||||
let config = Configuration {
|
||||
collect_extensions: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (handles, _) = Handles::for_testing(None, Some(Arc::new(config)));
|
||||
|
||||
let url = Url::parse("http://localhost/.bash_history").unwrap();
|
||||
|
||||
let mut response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
response.parse_extension(Arc::new(handles)).unwrap();
|
||||
|
||||
assert_eq!(response.extension, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// `parse_extension` should return immediately if `--collect-extensions` isn't used
|
||||
fn parse_extension_early_returns_based_on_config() {
|
||||
let (handles, _) = Handles::for_testing(None, None);
|
||||
|
||||
let url = Url::parse("http://localhost/derp.js").unwrap();
|
||||
|
||||
let mut response = FeroxResponse {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
response.parse_extension(Arc::new(handles)).unwrap();
|
||||
|
||||
assert_eq!(response.extension, None);
|
||||
}
|
||||
}
|
||||
1070
src/scan_manager.rs
307
src/scan_manager/menu.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use crate::filters::filter_lookup;
|
||||
use crate::progress::PROGRESS_BAR;
|
||||
use crate::traits::FeroxFilter;
|
||||
use console::{measure_text_width, pad_str, style, Alignment, Term};
|
||||
use indicatif::ProgressDrawTarget;
|
||||
use regex::Regex;
|
||||
|
||||
/// Data container for a command entered by the user interactively
|
||||
#[derive(Debug)]
|
||||
pub enum MenuCmd {
|
||||
/// user wants to add a url to be scanned
|
||||
AddUrl(String),
|
||||
|
||||
/// user wants to cancel one or more active scans
|
||||
Cancel(Vec<usize>, bool),
|
||||
|
||||
/// user wants to create a new filter
|
||||
AddFilter(Box<dyn FeroxFilter>),
|
||||
|
||||
/// user wants to remove one or more active filters
|
||||
RemoveFilter(Vec<usize>),
|
||||
}
|
||||
|
||||
/// Data container for a command result to be used internally by the ferox_scanner
|
||||
#[derive(Debug)]
|
||||
pub enum MenuCmdResult {
|
||||
/// Url to be added to the scan queue
|
||||
Url(String),
|
||||
|
||||
/// Number of scans that were actually cancelled, can be 0
|
||||
NumCancelled(usize),
|
||||
|
||||
/// Filter to be added to current list of `FeroxFilters`
|
||||
Filter(Box<dyn FeroxFilter>),
|
||||
}
|
||||
|
||||
/// Interactive scan cancellation menu
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Menu {
|
||||
/// header: name surrounded by separators
|
||||
header: String,
|
||||
|
||||
/// footer: instructions surrounded by separators
|
||||
footer: String,
|
||||
|
||||
/// unicode line border, matched to longest displayed line
|
||||
border: String,
|
||||
|
||||
/// target for output
|
||||
pub(super) term: Term,
|
||||
}
|
||||
|
||||
/// Implementation of Menu
|
||||
impl Menu {
|
||||
/// Creates new Menu
|
||||
pub(super) fn new() -> Self {
|
||||
let separator = "─".to_string();
|
||||
|
||||
let name = format!(
|
||||
"{} {} {}",
|
||||
"💀",
|
||||
style("Scan Management Menu").bright().yellow(),
|
||||
"💀"
|
||||
);
|
||||
|
||||
let add_cmd = format!(
|
||||
" {}[{}] NEW_URL (ex: {} http://localhost)\n",
|
||||
style("a").green(),
|
||||
style("dd").green(),
|
||||
style("add").green()
|
||||
);
|
||||
|
||||
let canx_cmd = format!(
|
||||
" {}[{}] [-f] SCAN_ID[-SCAN_ID[,...]] (ex: {} 1-4,8,9-13 or {} -f 3)\n",
|
||||
style("c").red(),
|
||||
style("ancel").red(),
|
||||
style("cancel").red(),
|
||||
style("c").red(),
|
||||
);
|
||||
|
||||
let new_filter_cmd = format!(
|
||||
" {}[{}] FILTER_TYPE FILTER_VALUE (ex: {} lines 40)\n",
|
||||
style("n").green(),
|
||||
style("ew-filter").green(),
|
||||
style("n").green(),
|
||||
);
|
||||
|
||||
let valid_filters = format!(
|
||||
" FILTER_TYPEs: {}, {}, {}, {}, {}, {}\n",
|
||||
style("status").yellow(),
|
||||
style("lines").yellow(),
|
||||
style("size").yellow(),
|
||||
style("words").yellow(),
|
||||
style("regex").yellow(),
|
||||
style("similarity").yellow()
|
||||
);
|
||||
|
||||
let rm_filter_cmd = format!(
|
||||
" {}[{}] FILTER_ID[-FILTER_ID[,...]] (ex: {} 1-4,8,9-13 or {} 3)",
|
||||
style("r").red(),
|
||||
style("m-filter").red(),
|
||||
style("rm-filter").red(),
|
||||
style("r").red(),
|
||||
);
|
||||
|
||||
let mut commands = format!("{}:\n", style("Commands").bright().blue());
|
||||
commands.push_str(&add_cmd);
|
||||
commands.push_str(&canx_cmd);
|
||||
commands.push_str(&new_filter_cmd);
|
||||
commands.push_str(&valid_filters);
|
||||
commands.push_str(&rm_filter_cmd);
|
||||
|
||||
let longest = measure_text_width(&canx_cmd).max(measure_text_width(&name));
|
||||
|
||||
let border = separator.repeat(longest);
|
||||
|
||||
let padded_name = pad_str(&name, longest, Alignment::Center, None);
|
||||
|
||||
let header = format!("{}\n{}\n{}", border, padded_name, border);
|
||||
let footer = format!("{}\n{}", commands, border);
|
||||
|
||||
Self {
|
||||
header,
|
||||
footer,
|
||||
border,
|
||||
term: Term::stderr(),
|
||||
}
|
||||
}
|
||||
|
||||
/// print menu header
|
||||
pub(super) fn print_header(&self) {
|
||||
self.println(&self.header);
|
||||
}
|
||||
|
||||
/// print menu unicode border line
|
||||
pub(super) fn print_border(&self) {
|
||||
self.println(&self.border);
|
||||
}
|
||||
|
||||
/// print menu footer
|
||||
pub(super) fn print_footer(&self) {
|
||||
self.println(&self.footer);
|
||||
}
|
||||
|
||||
/// set PROGRESS_BAR bar target to hidden
|
||||
pub(super) fn hide_progress_bars(&self) {
|
||||
PROGRESS_BAR.set_draw_target(ProgressDrawTarget::hidden());
|
||||
}
|
||||
|
||||
/// set PROGRESS_BAR bar target to hidden
|
||||
pub(super) fn show_progress_bars(&self) {
|
||||
PROGRESS_BAR.set_draw_target(ProgressDrawTarget::stdout());
|
||||
}
|
||||
|
||||
/// Wrapper around console's Term::clear_screen and flush
|
||||
pub(super) fn clear_screen(&self) {
|
||||
self.term.clear_screen().unwrap_or_default();
|
||||
self.term.flush().unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Wrapper around console's Term::write_line
|
||||
pub(super) fn println(&self, msg: &str) {
|
||||
self.term.write_line(msg).unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Helper for parsing a usize from a str
|
||||
fn str_to_usize(&self, value: &str) -> usize {
|
||||
if value.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
value
|
||||
.trim()
|
||||
.to_string()
|
||||
.parse::<usize>()
|
||||
.unwrap_or_else(|e| {
|
||||
self.println(&format!("Found non-numeric input: {}: {:?}", e, value));
|
||||
0
|
||||
})
|
||||
}
|
||||
|
||||
/// split a comma delimited string into vec of usizes
|
||||
pub(super) fn split_to_nums(&self, line: &str) -> Vec<usize> {
|
||||
let mut nums = Vec::new();
|
||||
let values = line.split(',');
|
||||
|
||||
for mut value in values {
|
||||
value = value.trim();
|
||||
|
||||
if value.contains('-') {
|
||||
// range of two values, needs further processing
|
||||
|
||||
let range: Vec<usize> = value
|
||||
.split('-')
|
||||
.map(|s| self.str_to_usize(s))
|
||||
.filter(|m| *m != 0)
|
||||
.collect();
|
||||
|
||||
if range.len() != 2 {
|
||||
// expecting [1, 4] or similar, if a 0 was used, we'd be left with a vec of size 1
|
||||
self.println(&format!("Found invalid range of scans: {}", value));
|
||||
continue;
|
||||
}
|
||||
|
||||
(range[0]..=range[1]).for_each(|n| {
|
||||
// iterate from lower to upper bound and add all interim values, skipping
|
||||
// any already known
|
||||
if !nums.contains(&n) {
|
||||
nums.push(n)
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let value = self.str_to_usize(value);
|
||||
|
||||
if value != 0 && !nums.contains(&value) {
|
||||
// the zeroth scan is always skipped, skip already known values
|
||||
nums.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nums
|
||||
}
|
||||
|
||||
/// get input from the user and translate it to a `MenuCmd`
|
||||
pub(super) fn get_command_input_from_user(&self, line: &str) -> Option<MenuCmd> {
|
||||
let line = line.trim(); // normalize input if there are leading spaces
|
||||
|
||||
match line.chars().next().unwrap_or('_').to_ascii_lowercase() {
|
||||
'c' => {
|
||||
// cancel command; start by determining if -f was used
|
||||
let force = line.contains("-f");
|
||||
|
||||
// then remove c[ancel] from the command so it can be passed to the number
|
||||
// splitter
|
||||
let re = Regex::new(r"^[cC][ancelANCEL]*").unwrap();
|
||||
let line = line.replace("-f", "");
|
||||
let line = re.replace(&line, "").to_string();
|
||||
|
||||
Some(MenuCmd::Cancel(self.split_to_nums(&line), force))
|
||||
}
|
||||
'a' => {
|
||||
// add command
|
||||
// similar to cancel, we need to remove the a[dd] substring, the rest should be
|
||||
// a url
|
||||
let re = Regex::new(r"^[aA][dD]*").unwrap();
|
||||
let line = re.replace(line, "").to_string().trim().to_string();
|
||||
|
||||
Some(MenuCmd::AddUrl(line))
|
||||
}
|
||||
'n' => {
|
||||
// new filter command
|
||||
let mut line = line.split_whitespace();
|
||||
line.next(); // 'n' or 'new-filter'
|
||||
|
||||
if let Some(filter_type) = line.next() {
|
||||
// have a string in the filter_type position
|
||||
if let Some(filter_value) = line.next() {
|
||||
// have a string in the filter_value position
|
||||
if let Some(result) = filter_lookup(filter_type, filter_value) {
|
||||
// lookup was successful, return the new filter
|
||||
return Some(MenuCmd::AddFilter(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
'r' => {
|
||||
// remove filter command
|
||||
|
||||
// remove r[m-filter] from the command so it can be passed to the number
|
||||
// splitter
|
||||
let re = Regex::new(r"^[rR][mfilterMFILTER-]*").unwrap();
|
||||
// we don't respect a -f or lack thereof in this command, but in case the user
|
||||
// doesn't realize / thinks its the same as cancel -f, just remove it
|
||||
let line = line.replace("-f", "");
|
||||
let line = re.replace(&line, "").to_string();
|
||||
|
||||
let indices = self.split_to_nums(&line);
|
||||
|
||||
Some(MenuCmd::RemoveFilter(indices))
|
||||
}
|
||||
_ => {
|
||||
// invalid input
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a url, confirm with user that we should cancel
|
||||
pub(super) fn confirm_cancellation(&self, url: &str) -> char {
|
||||
self.println(&format!(
|
||||
"You sure you wanna cancel this scan: {}? [Y/n]",
|
||||
url
|
||||
));
|
||||
|
||||
self.term.read_char().unwrap_or('n')
|
||||
}
|
||||
}
|
||||
|
||||
/// Default implementation for Menu
|
||||
impl Default for Menu {
|
||||
/// return Menu::new as default
|
||||
fn default() -> Menu {
|
||||
Menu::new()
|
||||
}
|
||||
}
|
||||
18
src/scan_manager/mod.rs
Normal file
@@ -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() && response.method() == other.method() {
|
||||
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 scan_type: ScanType,
|
||||
|
||||
/// The order in which the scan was received
|
||||
pub(crate) scan_order: ScanOrder,
|
||||
|
||||
/// Number of requests to populate the progress bar with
|
||||
pub(super) num_requests: u64,
|
||||
|
||||
/// Status of this scan
|
||||
pub status: Mutex<ScanStatus>,
|
||||
|
||||
/// The spawned tokio task performing this scan (uses tokio::sync::Mutex)
|
||||
pub(super) task: sync::Mutex<Option<JoinHandle<()>>>,
|
||||
|
||||
/// The progress bar associated with this scan
|
||||
pub(super) progress_bar: Mutex<Option<ProgressBar>>,
|
||||
|
||||
/// whether or not the user passed --silent|--quiet on the command line
|
||||
pub(super) output_level: OutputLevel,
|
||||
|
||||
/// tracker for overall number of 403s seen by the FeroxScan instance
|
||||
pub(super) status_403s: AtomicUsize,
|
||||
|
||||
/// tracker for overall number of 429s seen by the FeroxScan instance
|
||||
pub(super) status_429s: AtomicUsize,
|
||||
|
||||
/// tracker for total number of errors encountered by the FeroxScan instance
|
||||
pub(super) errors: AtomicUsize,
|
||||
|
||||
/// tracker for the time at which this scan was started
|
||||
pub(super) start_time: Instant,
|
||||
}
|
||||
|
||||
/// Default implementation for FeroxScan
|
||||
impl Default for FeroxScan {
|
||||
/// Create a default FeroxScan, populates ID with a new UUID
|
||||
fn default() -> Self {
|
||||
let new_id = Uuid::new_v4().to_simple().to_string();
|
||||
|
||||
FeroxScan {
|
||||
id: new_id,
|
||||
task: sync::Mutex::new(None), // tokio mutex
|
||||
status: Mutex::new(ScanStatus::default()),
|
||||
num_requests: 0,
|
||||
scan_order: ScanOrder::Latest,
|
||||
url: String::new(),
|
||||
progress_bar: Mutex::new(None),
|
||||
scan_type: ScanType::File,
|
||||
output_level: Default::default(),
|
||||
errors: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
status_403s: Default::default(),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of FeroxScan
|
||||
impl FeroxScan {
|
||||
/// Stop a currently running scan
|
||||
pub async fn abort(&self) -> Result<()> {
|
||||
log::trace!("enter: abort");
|
||||
|
||||
match self.task.try_lock() {
|
||||
Ok(mut guard) => {
|
||||
if let Some(task) = std::mem::replace(&mut *guard, None) {
|
||||
log::trace!("aborting {:?}", self);
|
||||
task.abort();
|
||||
self.set_status(ScanStatus::Cancelled)?;
|
||||
self.stop_progress_bar();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not acquire lock to abort scan (we're already waiting for its results): {:?} {}", self, e);
|
||||
}
|
||||
}
|
||||
log::trace!("exit: abort");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// getter for url
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// small wrapper to set the JoinHandle
|
||||
pub async fn set_task(&self, task: JoinHandle<()>) -> Result<()> {
|
||||
let mut guard = self.task.lock().await;
|
||||
let _ = std::mem::replace(&mut *guard, Some(task));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// small wrapper to set ScanStatus
|
||||
pub fn set_status(&self, status: ScanStatus) -> Result<()> {
|
||||
if let Ok(mut guard) = self.status.lock() {
|
||||
let _ = std::mem::replace(&mut *guard, status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple helper to call .finish on the scan's progress bar
|
||||
pub(super) fn stop_progress_bar(&self) {
|
||||
if let Ok(guard) = self.progress_bar.lock() {
|
||||
if guard.is_some() {
|
||||
(*guard).as_ref().unwrap().finish_at_current_pos()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple helper get a progress bar
|
||||
pub fn progress_bar(&self) -> ProgressBar {
|
||||
match self.progress_bar.lock() {
|
||||
Ok(mut guard) => {
|
||||
if guard.is_some() {
|
||||
(*guard).as_ref().unwrap().clone()
|
||||
} else {
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Default,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => BarType::Hidden,
|
||||
};
|
||||
|
||||
let pb = add_bar(&self.url, self.num_requests, bar_type);
|
||||
pb.reset_elapsed();
|
||||
|
||||
let _ = std::mem::replace(&mut *guard, Some(pb.clone()));
|
||||
|
||||
pb
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("Could not unlock progress bar on {:?}", self);
|
||||
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Default,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => BarType::Hidden,
|
||||
};
|
||||
|
||||
let pb = add_bar(&self.url, self.num_requests, bar_type);
|
||||
pb.reset_elapsed();
|
||||
|
||||
pb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a URL and ProgressBar, create a new FeroxScan, wrap it in an Arc and return it
|
||||
pub fn new(
|
||||
url: &str,
|
||||
scan_type: ScanType,
|
||||
scan_order: ScanOrder,
|
||||
num_requests: u64,
|
||||
output_level: OutputLevel,
|
||||
pb: Option<ProgressBar>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
url: url.to_string(),
|
||||
scan_type,
|
||||
scan_order,
|
||||
num_requests,
|
||||
output_level,
|
||||
progress_bar: Mutex::new(pb),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark the scan as complete and stop the scan's progress bar
|
||||
pub fn finish(&self) -> Result<()> {
|
||||
self.set_status(ScanStatus::Complete)?;
|
||||
self.stop_progress_bar();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// small wrapper to inspect ScanType and ScanStatus to see if a Directory scan is running or
|
||||
/// in the queue to be run
|
||||
pub fn is_active(&self) -> bool {
|
||||
if let Ok(guard) = self.status.lock() {
|
||||
return matches!(
|
||||
(self.scan_type, *guard),
|
||||
(ScanType::Directory, ScanStatus::Running)
|
||||
| (ScanType::Directory, ScanStatus::NotStarted)
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// small wrapper to inspect ScanStatus and see if it's Complete
|
||||
pub fn is_complete(&self) -> bool {
|
||||
if let Ok(guard) = self.status.lock() {
|
||||
return matches!(*guard, ScanStatus::Complete);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// await a task's completion, similar to a thread's join; perform necessary bookkeeping
|
||||
pub async fn join(&self) {
|
||||
log::trace!("enter join({:?})", self);
|
||||
let mut guard = self.task.lock().await;
|
||||
|
||||
if guard.is_some() {
|
||||
if let Some(task) = std::mem::replace(&mut *guard, None) {
|
||||
task.await.unwrap();
|
||||
self.set_status(ScanStatus::Complete)
|
||||
.unwrap_or_else(|e| log::warn!("Could not mark scan complete: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit join({:?})", self);
|
||||
}
|
||||
/// increment the value in question by 1
|
||||
pub(crate) fn add_403(&self) {
|
||||
self.status_403s.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// increment the value in question by 1
|
||||
pub(crate) fn add_429(&self) {
|
||||
self.status_429s.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// increment the value in question by 1
|
||||
pub(crate) fn add_error(&self) {
|
||||
self.errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// simple wrapper to call the appropriate getter based on the given PolicyTrigger
|
||||
pub fn num_errors(&self, trigger: PolicyTrigger) -> usize {
|
||||
match trigger {
|
||||
PolicyTrigger::Status403 => self.status_403s(),
|
||||
PolicyTrigger::Status429 => self.status_429s(),
|
||||
PolicyTrigger::Errors => self.errors(),
|
||||
}
|
||||
}
|
||||
|
||||
/// return the number of errors seen by this scan
|
||||
fn errors(&self) -> usize {
|
||||
self.errors.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// return the number of 403s seen by this scan
|
||||
fn status_403s(&self) -> usize {
|
||||
self.status_403s.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// return the number of 429s seen by this scan
|
||||
fn status_429s(&self) -> usize {
|
||||
self.status_429s.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// return the number of requests per second performed by this scan's scanner
|
||||
pub fn requests_per_second(&self) -> u64 {
|
||||
if !self.is_active() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let reqs = self.requests();
|
||||
let seconds = self.start_time.elapsed().as_secs();
|
||||
|
||||
reqs.checked_div(seconds).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// return the number of requests performed by this scan's scanner
|
||||
pub fn requests(&self) -> u64 {
|
||||
self.progress_bar().position()
|
||||
}
|
||||
}
|
||||
|
||||
/// Display implementation
|
||||
impl fmt::Display for FeroxScan {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let status = if let Ok(guard) = self.status.lock() {
|
||||
match *guard {
|
||||
ScanStatus::NotStarted => style("not started").bright().blue(),
|
||||
ScanStatus::Complete => style("complete").green(),
|
||||
ScanStatus::Cancelled => style("cancelled").red(),
|
||||
ScanStatus::Running => style("running").bright().yellow(),
|
||||
}
|
||||
} else {
|
||||
style("unknown").red()
|
||||
};
|
||||
|
||||
write!(f, "{:12} {}", status, self.url)
|
||||
}
|
||||
}
|
||||
|
||||
/// PartialEq implementation; uses FeroxScan.id for comparison
|
||||
impl PartialEq for FeroxScan {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxScan
|
||||
impl Serialize for FeroxScan {
|
||||
/// Function that handles serialization of a FeroxScan
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("FeroxScan", 4)?;
|
||||
|
||||
state.serialize_field("id", &self.id)?;
|
||||
state.serialize_field("url", &self.url)?;
|
||||
state.serialize_field("scan_type", &self.scan_type)?;
|
||||
state.serialize_field("status", &self.status)?;
|
||||
state.serialize_field("num_requests", &self.num_requests)?;
|
||||
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize implementation for FeroxScan
|
||||
impl<'de> Deserialize<'de> for FeroxScan {
|
||||
/// Deserialize a FeroxScan from a serde_json::Value
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut scan = Self::default();
|
||||
|
||||
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
|
||||
|
||||
for (key, value) in &map {
|
||||
match key.as_str() {
|
||||
"id" => {
|
||||
if let Some(id) = value.as_str() {
|
||||
scan.id = id.to_string();
|
||||
}
|
||||
}
|
||||
"scan_type" => {
|
||||
if let Some(scan_type) = value.as_str() {
|
||||
scan.scan_type = match scan_type {
|
||||
"File" => ScanType::File,
|
||||
"Directory" => ScanType::Directory,
|
||||
_ => ScanType::File,
|
||||
}
|
||||
}
|
||||
}
|
||||
"status" => {
|
||||
if let Some(status) = value.as_str() {
|
||||
scan.status = Mutex::new(match status {
|
||||
"NotStarted" => ScanStatus::NotStarted,
|
||||
"Running" => ScanStatus::Running,
|
||||
"Complete" => ScanStatus::Complete,
|
||||
"Cancelled" => ScanStatus::Cancelled,
|
||||
_ => ScanStatus::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
"url" => {
|
||||
if let Some(url) = value.as_str() {
|
||||
scan.url = url.to_string();
|
||||
}
|
||||
}
|
||||
"num_requests" => {
|
||||
if let Some(num_requests) = value.as_u64() {
|
||||
scan.num_requests = num_requests;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scan)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple enum used to flag a `FeroxScan` as likely a directory or file
|
||||
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
|
||||
pub enum ScanType {
|
||||
/// Just a file being requested
|
||||
File,
|
||||
|
||||
/// A an entire directory that might be scanned
|
||||
Directory,
|
||||
}
|
||||
|
||||
/// Default implementation for ScanType
|
||||
impl Default for ScanType {
|
||||
/// Return ScanType::File as default
|
||||
fn default() -> Self {
|
||||
Self::File
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
|
||||
/// Simple enum to represent a scan's current status ([in]complete, cancelled)
|
||||
pub enum ScanStatus {
|
||||
/// Scan hasn't started yet
|
||||
NotStarted,
|
||||
|
||||
/// Scan finished normally
|
||||
Complete,
|
||||
|
||||
/// Scan was cancelled by the user
|
||||
Cancelled,
|
||||
|
||||
/// Scan has started, but hasn't finished, nor been cancelled
|
||||
Running,
|
||||
}
|
||||
|
||||
/// Default implementation for ScanStatus
|
||||
impl Default for ScanStatus {
|
||||
/// Default variant for ScanStatus is NotStarted
|
||||
fn default() -> Self {
|
||||
Self::NotStarted
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread::sleep;
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[test]
|
||||
/// ensure that num_errors returns the correct values for the given PolicyTrigger
|
||||
///
|
||||
/// covers tests for add_[403,429,error] and the related getters in addition to num_errors
|
||||
fn num_errors_returns_correct_values() {
|
||||
let scan = FeroxScan::new(
|
||||
"http://localhost",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
1000,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
|
||||
scan.add_error();
|
||||
scan.add_403();
|
||||
scan.add_403();
|
||||
scan.add_429();
|
||||
scan.add_429();
|
||||
scan.add_429();
|
||||
|
||||
assert_eq!(scan.num_errors(PolicyTrigger::Errors), 1);
|
||||
assert_eq!(scan.num_errors(PolicyTrigger::Status403), 2);
|
||||
assert_eq!(scan.num_errors(PolicyTrigger::Status429), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure that requests_per_second returns the correct values
|
||||
fn requests_per_second_returns_correct_values() {
|
||||
let scan = FeroxScan {
|
||||
id: "".to_string(),
|
||||
url: "".to_string(),
|
||||
scan_type: ScanType::Directory,
|
||||
scan_order: ScanOrder::Initial,
|
||||
num_requests: 0,
|
||||
status: Mutex::new(ScanStatus::Running),
|
||||
task: Default::default(),
|
||||
progress_bar: Mutex::new(None),
|
||||
output_level: Default::default(),
|
||||
status_403s: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
errors: Default::default(),
|
||||
start_time: Instant::now(),
|
||||
};
|
||||
|
||||
let pb = scan.progress_bar();
|
||||
pb.set_position(100);
|
||||
|
||||
sleep(Duration::new(1, 0));
|
||||
|
||||
let req_sec = scan.requests_per_second();
|
||||
|
||||
assert_eq!(req_sec, 100);
|
||||
|
||||
scan.finish().unwrap();
|
||||
assert_eq!(scan.requests_per_second(), 0);
|
||||
}
|
||||
}
|
||||
693
src/scan_manager/scan_container.rs
Normal file
@@ -0,0 +1,693 @@
|
||||
use super::scan::ScanType;
|
||||
use super::*;
|
||||
use crate::event_handlers::Handles;
|
||||
use crate::filters::{
|
||||
EmptyFilter, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
|
||||
WildcardFilter, WordsFilter,
|
||||
};
|
||||
use crate::traits::FeroxFilter;
|
||||
use crate::Command::AddFilter;
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
progress::PROGRESS_PRINTER,
|
||||
progress::{add_bar, BarType},
|
||||
scan_manager::{MenuCmd, MenuCmdResult},
|
||||
scanner::RESPONSES,
|
||||
traits::FeroxSerialize,
|
||||
Command, SLEEP_DURATION,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use console::style;
|
||||
use reqwest::StatusCode;
|
||||
use serde::{ser::SerializeSeq, Serialize, Serializer};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
convert::TryInto,
|
||||
fs::File,
|
||||
io::BufReader,
|
||||
ops::Index,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
Arc, Mutex, RwLock,
|
||||
},
|
||||
thread::sleep,
|
||||
};
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
/// Single atomic number that gets incremented once, used to track first thread to interact with
|
||||
/// when pausing a scan
|
||||
static INTERACTIVE_BARRIER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Atomic boolean flag, used to determine whether or not a scan should pause or resume
|
||||
pub static PAUSE_SCAN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Container around a locked hashset of `FeroxScan`s, adds wrappers for insertion and searching
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeroxScans {
|
||||
/// Internal structure: locked hashset of `FeroxScan`s
|
||||
pub scans: RwLock<Vec<Arc<FeroxScan>>>,
|
||||
|
||||
/// menu used for providing a way for users to cancel a scan
|
||||
menu: Menu,
|
||||
|
||||
/// number of requests expected per scan (mirrors the same on Stats); used for initializing
|
||||
/// progress bars and feroxscans
|
||||
bar_length: Mutex<u64>,
|
||||
|
||||
/// whether or not the user passed --silent|--quiet on the command line
|
||||
output_level: OutputLevel,
|
||||
|
||||
/// vector of extensions discovered and collected during scans
|
||||
pub(crate) collected_extensions: RwLock<HashSet<String>>,
|
||||
}
|
||||
|
||||
/// Serialize implementation for FeroxScans
|
||||
///
|
||||
/// purposefully skips menu attribute
|
||||
impl Serialize for FeroxScans {
|
||||
/// Function that handles serialization of FeroxScans
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self.scans.read() {
|
||||
Ok(scans) => {
|
||||
let mut seq = serializer.serialize_seq(Some(scans.len() + 1))?;
|
||||
|
||||
for scan in scans.iter() {
|
||||
seq.serialize_element(&*scan).unwrap_or_default();
|
||||
}
|
||||
seq.end()
|
||||
}
|
||||
Err(_) => {
|
||||
// if for some reason we can't unlock the RwLock, just write an empty list
|
||||
let seq = serializer.serialize_seq(Some(0))?;
|
||||
seq.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of `FeroxScans`
|
||||
impl FeroxScans {
|
||||
/// given an OutputLevel, create a new FeroxScans object
|
||||
pub fn new(output_level: OutputLevel) -> Self {
|
||||
Self {
|
||||
output_level,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a `FeroxScan` to the internal container
|
||||
///
|
||||
/// If the internal container did NOT contain the scan, true is returned; else false
|
||||
pub fn insert(&self, scan: Arc<FeroxScan>) -> bool {
|
||||
// If the container did contain the scan, set sentry to false
|
||||
// If the container did not contain the scan, set sentry to true
|
||||
let sentry = !self.contains(&scan.url);
|
||||
|
||||
if sentry {
|
||||
// can't update the internal container while the scan itself is locked, so first
|
||||
// lock the scan and check the container for the scan's presence, then add if
|
||||
// not found
|
||||
match self.scans.write() {
|
||||
Ok(mut scans) => {
|
||||
scans.push(scan);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("FeroxScans' container's mutex is poisoned: {}", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sentry
|
||||
}
|
||||
|
||||
/// load serialized FeroxScan(s) and any previously collected extensions into this FeroxScans
|
||||
pub fn add_serialized_scans(&self, filename: &str, handles: Arc<Handles>) -> Result<()> {
|
||||
log::trace!("enter: add_serialized_scans({})", filename);
|
||||
let file = File::open(filename)?;
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
let state: serde_json::Value = serde_json::from_reader(reader)?;
|
||||
|
||||
if let Some(scans) = state.get("scans") {
|
||||
if let Some(arr_scans) = scans.as_array() {
|
||||
for scan in arr_scans {
|
||||
let mut deser_scan: FeroxScan =
|
||||
serde_json::from_value(scan.clone()).unwrap_or_default();
|
||||
|
||||
// FeroxScans gets -q value from config as usual; the FeroxScans themselves
|
||||
// rely on that value being passed in. If the user starts a scan without -q
|
||||
// and resumes the scan but adds -q, FeroxScan will not have the proper value
|
||||
// without the line below
|
||||
deser_scan.output_level = self.output_level;
|
||||
|
||||
self.insert(Arc::new(deser_scan));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extensions) = state.get("collected_extensions") {
|
||||
if let Some(arr_exts) = extensions.as_array() {
|
||||
if let Ok(mut guard) = self.collected_extensions.write() {
|
||||
for ext in arr_exts {
|
||||
let deser_ext: String =
|
||||
serde_json::from_value(ext.clone()).unwrap_or_default();
|
||||
|
||||
guard.insert(deser_ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(filters) = state.get("filters") {
|
||||
if let Some(arr_filters) = filters.as_array() {
|
||||
for filter in arr_filters {
|
||||
let final_filter: Box<dyn FeroxFilter> = if let Ok(deserialized) =
|
||||
serde_json::from_value::<RegexFilter>(filter.clone())
|
||||
{
|
||||
Box::new(deserialized)
|
||||
} else if let Ok(deserialized) =
|
||||
serde_json::from_value::<WordsFilter>(filter.clone())
|
||||
{
|
||||
Box::new(deserialized)
|
||||
} else if let Ok(deserialized) =
|
||||
serde_json::from_value::<WildcardFilter>(filter.clone())
|
||||
{
|
||||
Box::new(deserialized)
|
||||
} else if let Ok(deserialized) =
|
||||
serde_json::from_value::<SizeFilter>(filter.clone())
|
||||
{
|
||||
Box::new(deserialized)
|
||||
} else if let Ok(deserialized) =
|
||||
serde_json::from_value::<LinesFilter>(filter.clone())
|
||||
{
|
||||
Box::new(deserialized)
|
||||
} else if let Ok(deserialized) =
|
||||
serde_json::from_value::<SimilarityFilter>(filter.clone())
|
||||
{
|
||||
Box::new(deserialized)
|
||||
} else if let Ok(deserialized) =
|
||||
serde_json::from_value::<StatusCodeFilter>(filter.clone())
|
||||
{
|
||||
Box::new(deserialized)
|
||||
} else {
|
||||
Box::new(EmptyFilter {})
|
||||
};
|
||||
|
||||
handles
|
||||
.filters
|
||||
.send(AddFilter(final_filter))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: add_serialized_scans");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple check for whether or not a FeroxScan is contained within the inner container based
|
||||
/// on the given URL
|
||||
pub fn contains(&self, url: &str) -> bool {
|
||||
if let Ok(scans) = self.scans.read() {
|
||||
for scan in scans.iter() {
|
||||
if scan.url == url {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find and return a `FeroxScan` based on the given URL
|
||||
pub fn get_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for scan in guard.iter() {
|
||||
if scan.url == url {
|
||||
return Some(scan.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_base_scan_by_url(&self, url: &str) -> Option<Arc<FeroxScan>> {
|
||||
log::trace!("enter: get_base_scan_by_url({})", url);
|
||||
|
||||
// rmatch_indices returns tuples in index, match form, i.e. (10, "/")
|
||||
// with the furthest-right match in the first position in the vector
|
||||
let matches: Vec<_> = url.rmatch_indices('/').collect();
|
||||
|
||||
// iterate from the furthest right matching index and check the given url from the
|
||||
// start to the furthest-right '/' character. compare that slice to the urls associated
|
||||
// with directory scans and return the first match, since it should be the 'deepest'
|
||||
// match.
|
||||
// Example:
|
||||
// url: http://shmocalhost/src/release/examples/stuff.php
|
||||
// scans:
|
||||
// http://shmocalhost/src/statistics
|
||||
// http://shmocalhost/src/banner
|
||||
// http://shmocalhost/src/release
|
||||
// http://shmocalhost/src/release/examples
|
||||
//
|
||||
// returns: http://shmocalhost/src/release/examples
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for (idx, _) in &matches {
|
||||
for scan in guard.iter() {
|
||||
let slice = url.index(0..*idx);
|
||||
if slice == scan.url || format!("{}/", slice).as_str() == scan.url {
|
||||
log::trace!("enter: get_base_scan_by_url -> {}", scan);
|
||||
return Some(scan.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("enter: get_base_scan_by_url -> None");
|
||||
None
|
||||
}
|
||||
/// add one to either 403 or 429 tracker in the scan related to the given url
|
||||
pub fn increment_status_code(&self, url: &str, code: StatusCode) {
|
||||
if let Some(scan) = self.get_base_scan_by_url(url) {
|
||||
match code {
|
||||
StatusCode::TOO_MANY_REQUESTS => {
|
||||
scan.add_429();
|
||||
}
|
||||
StatusCode::FORBIDDEN => {
|
||||
scan.add_403();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// add one to either 403 or 429 tracker in the scan related to the given url
|
||||
pub fn increment_error(&self, url: &str) {
|
||||
if let Some(scan) = self.get_base_scan_by_url(url) {
|
||||
scan.add_error();
|
||||
}
|
||||
}
|
||||
|
||||
/// Print all FeroxScans of type Directory
|
||||
///
|
||||
/// Example:
|
||||
/// 0: complete https://10.129.45.20
|
||||
/// 9: complete https://10.129.45.20/images
|
||||
/// 10: complete https://10.129.45.20/assets
|
||||
pub async fn display_scans(&self) {
|
||||
let scans = {
|
||||
// written this way in order to grab the vector and drop the lock immediately
|
||||
// otherwise the spawned task that this is a part of is no longer Send due to
|
||||
// the scan.task.lock().await below while the lock is held (RwLock is not Send)
|
||||
self.scans
|
||||
.read()
|
||||
.expect("Could not acquire lock in display_scans")
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut printed = 0;
|
||||
|
||||
for (i, scan) in scans.iter().enumerate() {
|
||||
if matches!(scan.scan_order, ScanOrder::Initial) || scan.task.try_lock().is_err() {
|
||||
// original target passed in via either -u or --stdin
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(scan.scan_type, ScanType::Directory) {
|
||||
if printed == 0 {
|
||||
self.menu
|
||||
.println(&format!("{}:", style("Scans").bright().blue()));
|
||||
}
|
||||
// we're only interested in displaying directory scans, as those are
|
||||
// the only ones that make sense to be stopped
|
||||
let scan_msg = format!("{:3}: {}", i, scan);
|
||||
self.menu.println(&scan_msg);
|
||||
printed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if printed > 0 {
|
||||
self.menu.print_border();
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a list of indexes, cancel their associated FeroxScans
|
||||
async fn cancel_scans(&self, indexes: Vec<usize>, force: bool) -> usize {
|
||||
let menu_pause_duration = Duration::from_millis(SLEEP_DURATION);
|
||||
|
||||
let mut num_cancelled = 0_usize;
|
||||
|
||||
for num in indexes {
|
||||
let selected = match self.scans.read() {
|
||||
Ok(u_scans) => {
|
||||
// check if number provided is out of range
|
||||
if num >= u_scans.len() {
|
||||
// usize can't be negative, just need to handle exceeding bounds
|
||||
self.menu
|
||||
.println(&format!("The number {} is not a valid choice.", num));
|
||||
sleep(menu_pause_duration);
|
||||
continue;
|
||||
}
|
||||
u_scans.index(num).clone()
|
||||
}
|
||||
Err(..) => continue,
|
||||
};
|
||||
|
||||
let input = if force {
|
||||
'y'
|
||||
} else {
|
||||
self.menu.confirm_cancellation(&selected.url)
|
||||
};
|
||||
|
||||
if input == 'y' || input == '\n' {
|
||||
self.menu.println(&format!("Stopping {}...", selected.url));
|
||||
|
||||
selected
|
||||
.abort()
|
||||
.await
|
||||
.unwrap_or_else(|e| log::warn!("Could not cancel task: {}", e));
|
||||
|
||||
let pb = selected.progress_bar();
|
||||
num_cancelled += pb.length() as usize - pb.position() as usize
|
||||
} else {
|
||||
self.menu.println("Ok, doing nothing...");
|
||||
}
|
||||
|
||||
sleep(menu_pause_duration);
|
||||
}
|
||||
|
||||
num_cancelled
|
||||
}
|
||||
|
||||
fn display_filters(&self, handles: Arc<Handles>) {
|
||||
let mut printed = 0;
|
||||
|
||||
if let Ok(guard) = handles.filters.data.filters.read() {
|
||||
for (i, filter) in guard.iter().enumerate() {
|
||||
if i == 0 {
|
||||
self.menu
|
||||
.println(&format!("{}:", style("Filters").bright().blue()));
|
||||
}
|
||||
|
||||
let filter_msg = format!("{:3}: {}", i + 1, filter);
|
||||
self.menu.println(&filter_msg);
|
||||
printed += 1;
|
||||
}
|
||||
|
||||
if printed > 0 {
|
||||
self.menu.print_border();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CLI menu that allows for interactive cancellation of recursed-into directories
|
||||
async fn interactive_menu(&self, handles: Arc<Handles>) -> Option<MenuCmdResult> {
|
||||
self.menu.hide_progress_bars();
|
||||
self.menu.clear_screen();
|
||||
self.menu.print_header();
|
||||
self.display_scans().await;
|
||||
self.display_filters(handles.clone());
|
||||
self.menu.print_footer();
|
||||
|
||||
let menu_cmd = if let Ok(line) = self.menu.term.read_line() {
|
||||
self.menu.get_command_input_from_user(&line)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = match menu_cmd {
|
||||
Some(MenuCmd::Cancel(indices, should_force)) => {
|
||||
// cancel the things
|
||||
let num_cancelled = self.cancel_scans(indices, should_force).await;
|
||||
Some(MenuCmdResult::NumCancelled(num_cancelled))
|
||||
}
|
||||
Some(MenuCmd::AddUrl(url)) => Some(MenuCmdResult::Url(url)),
|
||||
Some(MenuCmd::AddFilter(filter)) => Some(MenuCmdResult::Filter(filter)),
|
||||
Some(MenuCmd::RemoveFilter(indices)) => {
|
||||
handles
|
||||
.filters
|
||||
.send(Command::RemoveFilters(indices))
|
||||
.unwrap_or_default();
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
self.menu.clear_screen();
|
||||
self.menu.show_progress_bars();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// prints all known responses that the scanner has already seen
|
||||
pub fn print_known_responses(&self) {
|
||||
if let Ok(mut responses) = RESPONSES.responses.write() {
|
||||
for response in responses.iter_mut() {
|
||||
if self.output_level != response.output_level {
|
||||
// set the output_level prior to printing the response to ensure that the
|
||||
// response's setting aligns with the overall configuration (since we're
|
||||
// calling this from a resumed state)
|
||||
response.output_level = self.output_level;
|
||||
}
|
||||
PROGRESS_PRINTER.println(response.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// if a resumed scan is already complete, display a completed progress bar to the user
|
||||
pub fn print_completed_bars(&self, bar_length: usize) -> Result<()> {
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Message,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => return Ok(()), // fast exit when --silent was used
|
||||
};
|
||||
|
||||
if let Ok(scans) = self.scans.read() {
|
||||
for scan in scans.iter() {
|
||||
if scan.is_complete() {
|
||||
// these scans are complete, and just need to be shown to the user
|
||||
let pb = add_bar(
|
||||
&scan.url,
|
||||
bar_length.try_into().unwrap_or_default(),
|
||||
bar_type,
|
||||
);
|
||||
pb.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forced the calling thread into a busy loop
|
||||
///
|
||||
/// Every `SLEEP_DURATION` milliseconds, the function examines the result stored in `PAUSE_SCAN`
|
||||
///
|
||||
/// When the value stored in `PAUSE_SCAN` becomes `false`, the function returns, exiting the busy
|
||||
/// loop
|
||||
pub async fn pause(
|
||||
&self,
|
||||
get_user_input: bool,
|
||||
handles: Arc<Handles>,
|
||||
) -> Option<MenuCmdResult> {
|
||||
// function uses tokio::time, not std
|
||||
|
||||
// local testing showed a pretty slow increase (less than linear) in CPU usage as # of
|
||||
// concurrent scans rose when SLEEP_DURATION was set to 500, using that as the default for now
|
||||
let mut interval = time::interval(time::Duration::from_millis(SLEEP_DURATION));
|
||||
let mut command_result = None;
|
||||
|
||||
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 0 {
|
||||
INTERACTIVE_BARRIER.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if get_user_input {
|
||||
command_result = self.interactive_menu(handles).await;
|
||||
PAUSE_SCAN.store(false, Ordering::Relaxed);
|
||||
self.print_known_responses();
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
// first tick happens immediately, all others wait the specified duration
|
||||
interval.tick().await;
|
||||
|
||||
if !PAUSE_SCAN.load(Ordering::Acquire) {
|
||||
// PAUSE_SCAN is false, so we can exit the busy loop
|
||||
|
||||
if INTERACTIVE_BARRIER.load(Ordering::Relaxed) == 1 {
|
||||
INTERACTIVE_BARRIER.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
log::trace!("exit: pause_scan -> {:?}", command_result);
|
||||
return command_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// set the bar length of FeroxScans
|
||||
pub fn set_bar_length(&self, bar_length: u64) {
|
||||
if let Ok(mut guard) = self.bar_length.lock() {
|
||||
*guard = bar_length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans`
|
||||
///
|
||||
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
|
||||
///
|
||||
/// Also return a reference to the new `FeroxScan`
|
||||
pub(super) fn add_scan(
|
||||
&self,
|
||||
url: &str,
|
||||
scan_type: ScanType,
|
||||
scan_order: ScanOrder,
|
||||
) -> (bool, Arc<FeroxScan>) {
|
||||
let bar_length = if let Ok(guard) = self.bar_length.lock() {
|
||||
*guard
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let bar = match scan_type {
|
||||
ScanType::Directory => {
|
||||
let bar_type = match self.output_level {
|
||||
OutputLevel::Default => BarType::Default,
|
||||
OutputLevel::Quiet => BarType::Quiet,
|
||||
OutputLevel::Silent => BarType::Hidden,
|
||||
};
|
||||
|
||||
let progress_bar = add_bar(url, bar_length, bar_type);
|
||||
|
||||
progress_bar.reset_elapsed();
|
||||
|
||||
Some(progress_bar)
|
||||
}
|
||||
ScanType::File => None,
|
||||
};
|
||||
|
||||
let ferox_scan = FeroxScan::new(
|
||||
url,
|
||||
scan_type,
|
||||
scan_order,
|
||||
bar_length,
|
||||
self.output_level,
|
||||
bar,
|
||||
);
|
||||
|
||||
// If the set did not contain the scan, true is returned.
|
||||
// If the set did contain the scan, false is returned.
|
||||
let response = self.insert(ferox_scan.clone());
|
||||
|
||||
(response, ferox_scan)
|
||||
}
|
||||
|
||||
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a Directory Scan
|
||||
///
|
||||
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
|
||||
///
|
||||
/// Also return a reference to the new `FeroxScan`
|
||||
pub fn add_directory_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
|
||||
self.add_scan(url, ScanType::Directory, scan_order)
|
||||
}
|
||||
|
||||
/// Given a url, create a new `FeroxScan` and add it to `FeroxScans` as a File Scan
|
||||
///
|
||||
/// If `FeroxScans` did not already contain the scan, return true; otherwise return false
|
||||
///
|
||||
/// Also return a reference to the new `FeroxScan`
|
||||
pub fn add_file_scan(&self, url: &str, scan_order: ScanOrder) -> (bool, Arc<FeroxScan>) {
|
||||
self.add_scan(url, ScanType::File, scan_order)
|
||||
}
|
||||
|
||||
/// small helper to determine whether any scans are active or not
|
||||
pub fn has_active_scans(&self) -> bool {
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for scan in guard.iter() {
|
||||
if scan.is_active() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Retrieve all active scans
|
||||
pub fn get_active_scans(&self) -> Vec<Arc<FeroxScan>> {
|
||||
let mut scans = vec![];
|
||||
|
||||
if let Ok(guard) = self.scans.read() {
|
||||
for scan in guard.iter() {
|
||||
if !scan.is_active() {
|
||||
continue;
|
||||
}
|
||||
scans.push(scan.clone());
|
||||
}
|
||||
}
|
||||
scans
|
||||
}
|
||||
|
||||
/// given an extension, add it to `collected_extensions` if all constraints are met
|
||||
/// returns `true` if an extension was added, `false` otherwise
|
||||
pub fn add_discovered_extension(&self, extension: String) -> bool {
|
||||
log::trace!("enter: add_discovered_extension({})", extension);
|
||||
let mut extension_added = false;
|
||||
|
||||
// note: the filter by --dont-collect happens in the event handler, since it has access
|
||||
// to a Handles object form which it can check the config value. additionally, the check
|
||||
// against --extensions is performed there for the same reason
|
||||
|
||||
if let Ok(extensions) = self.collected_extensions.read() {
|
||||
// quicker to allow most to read and return and then reopen for write if necessary
|
||||
if extensions.contains(&extension) {
|
||||
return extension_added;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut extensions) = self.collected_extensions.write() {
|
||||
log::info!("discovered new extension: {}", extension);
|
||||
extensions.insert(extension);
|
||||
extension_added = true;
|
||||
}
|
||||
|
||||
log::trace!("exit: add_discovered_extension -> {}", extension_added);
|
||||
extension_added
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// unknown extension should be added to collected_extensions
|
||||
fn unknown_extension_is_added_to_collected_extensions() {
|
||||
let scans = FeroxScans::new(OutputLevel::Default);
|
||||
|
||||
assert_eq!(0, scans.collected_extensions.read().unwrap().len());
|
||||
|
||||
let added = scans.add_discovered_extension(String::from("js"));
|
||||
|
||||
assert!(added);
|
||||
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// known extension should not be added to collected_extensions
|
||||
fn known_extension_is_added_to_collected_extensions() {
|
||||
let scans = FeroxScans::new(OutputLevel::Default);
|
||||
scans
|
||||
.collected_extensions
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(String::from("js"));
|
||||
|
||||
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
|
||||
|
||||
let added = scans.add_discovered_extension(String::from("js"));
|
||||
|
||||
assert!(!added);
|
||||
assert_eq!(1, scans.collected_extensions.read().unwrap().len());
|
||||
}
|
||||
}
|
||||
69
src/scan_manager/state.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use super::*;
|
||||
use crate::filters::FeroxFilters;
|
||||
use crate::{config::Configuration, statistics::Stats, traits::FeroxSerialize, utils::fmt_err};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Data container for (de)?serialization of multiple items
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct FeroxState {
|
||||
/// Known scans
|
||||
scans: Arc<FeroxScans>,
|
||||
|
||||
/// Current running config
|
||||
config: Arc<Configuration>,
|
||||
|
||||
/// Known responses
|
||||
responses: &'static FeroxResponses,
|
||||
|
||||
/// Gathered statistics
|
||||
statistics: Arc<Stats>,
|
||||
|
||||
/// collected extensions
|
||||
collected_extensions: HashSet<String>,
|
||||
|
||||
/// runtime filters, as they may differ from original config
|
||||
filters: Arc<FeroxFilters>,
|
||||
}
|
||||
|
||||
/// implementation of FeroxState
|
||||
impl FeroxState {
|
||||
/// create new FeroxState object
|
||||
pub fn new(
|
||||
scans: Arc<FeroxScans>,
|
||||
config: Arc<Configuration>,
|
||||
responses: &'static FeroxResponses,
|
||||
statistics: Arc<Stats>,
|
||||
filters: Arc<FeroxFilters>,
|
||||
) -> Self {
|
||||
let collected_extensions = match scans.collected_extensions.read() {
|
||||
Ok(extensions) => extensions.clone(),
|
||||
Err(_) => HashSet::new(),
|
||||
};
|
||||
|
||||
Self {
|
||||
scans,
|
||||
config,
|
||||
responses,
|
||||
statistics,
|
||||
collected_extensions,
|
||||
filters,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FeroxSerialize implementation for FeroxState
|
||||
impl FeroxSerialize for FeroxState {
|
||||
/// Simply return debug format of FeroxState to satisfy as_str
|
||||
fn as_str(&self) -> String {
|
||||
format!("{:?}", self)
|
||||
}
|
||||
|
||||
/// Simple call to produce a JSON string using the given FeroxState
|
||||
fn as_json(&self) -> Result<String> {
|
||||
serde_json::to_string(&self)
|
||||
.with_context(|| fmt_err("Could not convert scan's running state to JSON"))
|
||||
}
|
||||
}
|
||||
777
src/scan_manager/tests.rs
Normal file
@@ -0,0 +1,777 @@
|
||||
use super::*;
|
||||
use crate::filters::{
|
||||
FeroxFilters, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
|
||||
WordsFilter,
|
||||
};
|
||||
use crate::{
|
||||
config::{Configuration, OutputLevel},
|
||||
event_handlers::Handles,
|
||||
response::FeroxResponse,
|
||||
scanner::RESPONSES,
|
||||
statistics::Stats,
|
||||
traits::FeroxSerialize,
|
||||
SIMILARITY_THRESHOLD, SLEEP_DURATION, VERSION,
|
||||
};
|
||||
use indicatif::ProgressBar;
|
||||
use predicates::prelude::*;
|
||||
use regex::Regex;
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
use std::thread::sleep;
|
||||
use std::time::Instant;
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
#[test]
|
||||
/// test that ScanType's default is File
|
||||
fn default_scantype_is_file() {
|
||||
match ScanType::default() {
|
||||
ScanType::File => {}
|
||||
ScanType::Directory => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// tests that pause_scan pauses execution and releases execution when PAUSE_SCAN is toggled
|
||||
/// the spinner used during the test has had .finish_and_clear called on it, meaning that
|
||||
/// a new one will be created, taking the if branch within the function
|
||||
async fn scanner_pause_scan_with_finished_spinner() {
|
||||
let now = time::Instant::now();
|
||||
let urls = FeroxScans::default();
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
PAUSE_SCAN.store(true, Ordering::Relaxed);
|
||||
|
||||
let expected = time::Duration::from_secs(2);
|
||||
|
||||
tokio::spawn(async move {
|
||||
time::sleep(expected).await;
|
||||
PAUSE_SCAN.store(false, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
urls.pause(false, handles).await;
|
||||
|
||||
assert!(now.elapsed() > expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add an unknown url to the hashset, expect true
|
||||
fn add_url_to_list_of_scanned_urls_with_unknown_url() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://unknown_url";
|
||||
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add a known url to the hashset, with a trailing slash, expect false
|
||||
fn add_url_to_list_of_scanned_urls_with_known_url() {
|
||||
let urls = FeroxScans::default();
|
||||
let pb = ProgressBar::new(1);
|
||||
let url = "http://unknown_url/";
|
||||
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb),
|
||||
);
|
||||
|
||||
assert!(urls.insert(scan));
|
||||
|
||||
let (result, _scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// stop_progress_bar should stop the progress bar
|
||||
fn stop_progress_bar_stops_bar() {
|
||||
let pb = ProgressBar::new(1);
|
||||
let url = "http://unknown_url/";
|
||||
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb),
|
||||
);
|
||||
|
||||
assert!(!scan
|
||||
.progress_bar
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.is_finished());
|
||||
|
||||
scan.stop_progress_bar();
|
||||
|
||||
assert!(scan
|
||||
.progress_bar
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.is_finished());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// add a known url to the hashset, without a trailing slash, expect false
|
||||
fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://unknown_url";
|
||||
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::File,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(urls.insert(scan));
|
||||
|
||||
let (result, _scan) = urls.add_scan(url, ScanType::File, ScanOrder::Latest);
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// just increasing coverage, no real expectations
|
||||
async fn call_display_scans() {
|
||||
let urls = FeroxScans::default();
|
||||
let pb = ProgressBar::new(1);
|
||||
let pb_two = ProgressBar::new(2);
|
||||
let url = "http://unknown_url/";
|
||||
let url_two = "http://unknown_url/fa";
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb),
|
||||
);
|
||||
let scan_two = FeroxScan::new(
|
||||
url_two,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
pb_two.length(),
|
||||
OutputLevel::Default,
|
||||
Some(pb_two),
|
||||
);
|
||||
|
||||
scan_two.finish().unwrap(); // one complete, one incomplete
|
||||
scan_two
|
||||
.set_task(tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(SLEEP_DURATION));
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(urls.insert(scan));
|
||||
assert!(urls.insert(scan_two));
|
||||
|
||||
urls.display_scans().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure that PartialEq compares FeroxScan.id fields
|
||||
fn partial_eq_compares_the_id_field() {
|
||||
let url = "http://unknown_url/";
|
||||
let scan = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let scan_two = FeroxScan::new(
|
||||
url,
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!scan.eq(&scan_two));
|
||||
|
||||
let scan_two = scan.clone();
|
||||
|
||||
assert!(scan.eq(&scan_two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// show that a new progress bar is created if one doesn't exist
|
||||
fn ferox_scan_get_progress_bar_when_none_is_set() {
|
||||
let scan = FeroxScan::default();
|
||||
|
||||
assert!(scan.progress_bar.lock().unwrap().is_none()); // no pb exists
|
||||
|
||||
let pb = scan.progress_bar();
|
||||
|
||||
assert!(scan.progress_bar.lock().unwrap().is_some()); // new pb created
|
||||
assert!(!pb.is_finished()) // not finished
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a JSON entry representing a FeroxScan, test that it deserializes into the proper type
|
||||
/// with the right attributes
|
||||
fn ferox_scan_deserialize() {
|
||||
let fs_json = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Directory","status":"Complete"}"#;
|
||||
let fs_json_two = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"Cancelled"}"#;
|
||||
let fs_json_three = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","status":"","num_requests":42}"#;
|
||||
|
||||
let fs: FeroxScan = serde_json::from_str(fs_json).unwrap();
|
||||
let fs_two: FeroxScan = serde_json::from_str(fs_json_two).unwrap();
|
||||
let fs_three: FeroxScan = serde_json::from_str(fs_json_three).unwrap();
|
||||
assert_eq!(fs.url, "https://spiritanimal.com");
|
||||
|
||||
match fs.scan_type {
|
||||
ScanType::Directory => {}
|
||||
ScanType::File => {
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
match fs_two.scan_type {
|
||||
ScanType::Directory => {
|
||||
panic!();
|
||||
}
|
||||
ScanType::File => {}
|
||||
}
|
||||
|
||||
match *fs.progress_bar.lock().unwrap() {
|
||||
None => {}
|
||||
Some(_) => {
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
assert!(matches!(*fs.status.lock().unwrap(), ScanStatus::Complete));
|
||||
assert!(matches!(
|
||||
*fs_two.status.lock().unwrap(),
|
||||
ScanStatus::Cancelled
|
||||
));
|
||||
assert!(matches!(
|
||||
*fs_three.status.lock().unwrap(),
|
||||
ScanStatus::NotStarted
|
||||
));
|
||||
assert_eq!(fs_three.num_requests, 42);
|
||||
assert_eq!(fs.id, "057016a14769414aac9a7a62707598cb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxScan, test that it serializes into the proper JSON entry
|
||||
fn ferox_scan_serialize() {
|
||||
let fs = FeroxScan::new(
|
||||
"https://spiritanimal.com",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let fs_json = format!(
|
||||
r#"{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}"#,
|
||||
fs.id
|
||||
);
|
||||
assert_eq!(fs_json, serde_json::to_string(&*fs).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxScans, test that it serializes into the proper JSON entry
|
||||
fn ferox_scans_serialize() {
|
||||
let ferox_scan = FeroxScan::new(
|
||||
"https://spiritanimal.com",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let ferox_scans = FeroxScans::default();
|
||||
let ferox_scans_json = format!(
|
||||
r#"[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","status":"NotStarted","num_requests":0}}]"#,
|
||||
ferox_scan.id
|
||||
);
|
||||
ferox_scans.scans.write().unwrap().push(ferox_scan);
|
||||
assert_eq!(
|
||||
ferox_scans_json,
|
||||
serde_json::to_string(&ferox_scans).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxResponses, test that it serializes into the proper JSON entry
|
||||
fn ferox_responses_serialize() {
|
||||
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
|
||||
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
|
||||
|
||||
let responses = FeroxResponses::default();
|
||||
responses.insert(response);
|
||||
// responses has a response now
|
||||
|
||||
// serialized should be a list of responses
|
||||
let expected = format!("[{}]", json_response);
|
||||
|
||||
let serialized = serde_json::to_string(&responses).unwrap();
|
||||
assert_eq!(expected, serialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a FeroxResponse, test that it serializes into the proper JSON entry
|
||||
fn ferox_response_serialize_and_deserialize() {
|
||||
// deserialize
|
||||
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","original_url":"https://nerdcore.com","path":"/css","wildcard":true,"status":301,"method":"GET","content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
|
||||
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
|
||||
|
||||
assert_eq!(response.url().as_str(), "https://nerdcore.com/css");
|
||||
assert_eq!(response.url().path(), "/css");
|
||||
assert!(response.wildcard());
|
||||
assert_eq!(response.status().as_u16(), 301);
|
||||
assert_eq!(response.content_length(), 173);
|
||||
assert_eq!(response.line_count(), 10);
|
||||
assert_eq!(response.word_count(), 16);
|
||||
assert_eq!(response.headers().get("server").unwrap(), "nginx/1.16.1");
|
||||
|
||||
// serialize, however, this can fail when headers are out of order
|
||||
let new_json = serde_json::to_string(&response).unwrap();
|
||||
assert_eq!(json_response, new_json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test FeroxSerialize implementation of FeroxState
|
||||
fn feroxstates_feroxserialize_implementation() {
|
||||
let ferox_scan = FeroxScan::new(
|
||||
"https://spiritanimal.com",
|
||||
ScanType::Directory,
|
||||
ScanOrder::Latest,
|
||||
0,
|
||||
OutputLevel::Default,
|
||||
None,
|
||||
);
|
||||
let ferox_scans = FeroxScans::default();
|
||||
let saved_id = ferox_scan.id.clone();
|
||||
|
||||
ferox_scans.insert(ferox_scan);
|
||||
|
||||
ferox_scans
|
||||
.collected_extensions
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(String::from("php"));
|
||||
|
||||
let mut config = Configuration::new().unwrap();
|
||||
|
||||
config.collect_extensions = true;
|
||||
|
||||
let stats = Arc::new(Stats::new(config.json));
|
||||
|
||||
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"},"extension":""}"#;
|
||||
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
|
||||
RESPONSES.insert(response);
|
||||
|
||||
let filters = FeroxFilters::default();
|
||||
filters
|
||||
.push(Box::new(StatusCodeFilter { filter_code: 100 }))
|
||||
.unwrap();
|
||||
filters
|
||||
.push(Box::new(WordsFilter { word_count: 200 }))
|
||||
.unwrap();
|
||||
filters
|
||||
.push(Box::new(SizeFilter {
|
||||
content_length: 300,
|
||||
}))
|
||||
.unwrap();
|
||||
filters
|
||||
.push(Box::new(LinesFilter { line_count: 400 }))
|
||||
.unwrap();
|
||||
filters
|
||||
.push(Box::new(RegexFilter {
|
||||
raw_string: ".*".to_string(),
|
||||
compiled: Regex::new(".*").unwrap(),
|
||||
}))
|
||||
.unwrap();
|
||||
filters
|
||||
.push(Box::new(SimilarityFilter {
|
||||
hash: "3:YKEpn:Yfp".to_string(),
|
||||
threshold: SIMILARITY_THRESHOLD,
|
||||
original_url: "http://localhost:12345/".to_string(),
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let ferox_state = FeroxState::new(
|
||||
Arc::new(ferox_scans),
|
||||
Arc::new(config),
|
||||
&RESPONSES,
|
||||
stats,
|
||||
Arc::new(filters),
|
||||
);
|
||||
|
||||
let expected_strs = predicates::str::contains("scans: FeroxScans").and(
|
||||
predicate::str::contains("config: Configuration")
|
||||
.and(predicate::str::contains("responses: FeroxResponses"))
|
||||
.and(predicate::str::contains("nerdcore.com"))
|
||||
.and(predicate::str::contains("/css"))
|
||||
.and(predicate::str::contains("https://spiritanimal.com"))
|
||||
.and(predicate::str::contains("php")),
|
||||
);
|
||||
|
||||
assert!(expected_strs.eval(&ferox_state.as_str()));
|
||||
|
||||
let json_state = ferox_state.as_json().unwrap();
|
||||
|
||||
println!("echo '{}'|jq", json_state); // for debugging, if the test fails, can see what's going on
|
||||
|
||||
for expected in [
|
||||
r#""scans""#,
|
||||
&format!(r#""id":"{}""#, saved_id),
|
||||
r#""url":"https://spiritanimal.com""#,
|
||||
r#""scan_type":"Directory""#,
|
||||
r#""status":"NotStarted""#,
|
||||
r#""num_requests":0"#,
|
||||
r#""config""#,
|
||||
r#""type":"configuration""#,
|
||||
r#""wordlist":"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt""#,
|
||||
r#""config""#,
|
||||
r#""proxy":"""#,
|
||||
r#""replay_proxy":"""#,
|
||||
r#""target_url":"""#,
|
||||
r#""status_codes":[200,204,301,302,307,308,401,403,405,500]"#,
|
||||
r#""replay_codes":[200,204,301,302,307,308,401,403,405,500]"#,
|
||||
r#""filter_status":[]"#,
|
||||
r#""threads":50"#,
|
||||
r#""timeout":7"#,
|
||||
r#""verbosity":0"#,
|
||||
r#""silent":false"#,
|
||||
r#""quiet":false"#,
|
||||
r#""auto_bail":false"#,
|
||||
r#""auto_tune":false"#,
|
||||
r#""json":false"#,
|
||||
r#""output":"""#,
|
||||
r#""debug_log":"""#,
|
||||
&format!(r#""user_agent":"feroxbuster/{}""#, VERSION),
|
||||
r#""random_agent":false"#,
|
||||
r#""redirects":false"#,
|
||||
r#""insecure":false"#,
|
||||
r#""extensions":[]"#,
|
||||
r#""methods":["GET"],"#,
|
||||
r#""data":[]"#,
|
||||
r#""headers""#,
|
||||
r#""queries":[]"#,
|
||||
r#""no_recursion":false"#,
|
||||
r#""extract_links":false"#,
|
||||
r#""add_slash":false"#,
|
||||
r#""stdin":false"#,
|
||||
r#""depth":4"#,
|
||||
r#""scan_limit":0"#,
|
||||
r#""parallel":0"#,
|
||||
r#""rate_limit":0"#,
|
||||
r#""filter_size":[]"#,
|
||||
r#""filter_line_count":[]"#,
|
||||
r#""filter_word_count":[]"#,
|
||||
r#""filter_regex":[]"#,
|
||||
r#""dont_filter":false"#,
|
||||
r#""resumed":false"#,
|
||||
r#""resume_from":"""#,
|
||||
r#""save_state":false"#,
|
||||
r#""time_limit":"""#,
|
||||
r#""filter_similar":[]"#,
|
||||
r#""url_denylist":[]"#,
|
||||
r#""responses""#,
|
||||
r#""type":"response""#,
|
||||
r#""url":"https://nerdcore.com/css""#,
|
||||
r#""path":"/css""#,
|
||||
r#""wildcard":true"#,
|
||||
r#""status":301"#,
|
||||
r#""method":"GET""#,
|
||||
r#""content_length":173"#,
|
||||
r#""line_count":10"#,
|
||||
r#""word_count":16"#,
|
||||
r#""headers""#,
|
||||
r#""server":"nginx/1.16.1"#,
|
||||
r#""collect_extensions":true"#,
|
||||
r#""collect_backups":false"#,
|
||||
r#""collect_words":false"#,
|
||||
r#""filters":[{"filter_code":100},{"word_count":200},{"content_length":300},{"line_count":400},{"compiled":".*","raw_string":".*"},{"hash":"3:YKEpn:Yfp","threshold":95,"original_url":"http://localhost:12345/"}]"#,
|
||||
r#""collected_extensions":["php"]"#,
|
||||
r#""dont_collect":["tif","tiff","ico","cur","bmp","webp","svg","png","jpg","jpeg","jfif","gif","avif","apng","pjpeg","pjp","mov","wav","mpg","mpeg","mp3","mp4","m4a","m4p","m4v","ogg","webm","ogv","oga","flac","aac","3gp","css","zip","xls","xml","gz","tgz"]"#,
|
||||
]
|
||||
.iter()
|
||||
{
|
||||
assert!(
|
||||
predicates::str::contains(*expected).eval(&json_state)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[should_panic]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// call start_max_time_thread with a valid timespec, expect a panic, but only after a certain
|
||||
/// number of seconds
|
||||
async fn start_max_time_thread_panics_after_delay() {
|
||||
let now = time::Instant::now();
|
||||
let delay = time::Duration::new(3, 0);
|
||||
|
||||
let config = Configuration {
|
||||
time_limit: String::from("3s"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
|
||||
|
||||
start_max_time_thread(handles).await;
|
||||
|
||||
assert!(now.elapsed() > delay);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// call start_max_time_thread with a timespec that's too large to be parsed correctly, expect
|
||||
/// immediate return and no panic, as the sigint handler is never called
|
||||
async fn start_max_time_thread_returns_immediately_with_too_large_input() {
|
||||
let now = time::Instant::now();
|
||||
let delay = time::Duration::new(1, 0);
|
||||
let config = Configuration {
|
||||
time_limit: String::from("18446744073709551616m"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let handles = Arc::new(Handles::for_testing(None, Some(Arc::new(config))).0);
|
||||
|
||||
// pub const MAX: usize = usize::MAX; // 18_446_744_073_709_551_615usize
|
||||
start_max_time_thread(handles).await; // can't fit in dest u64
|
||||
|
||||
assert!(now.elapsed() < delay); // assuming function call will take less than 1second
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// coverage for FeroxScan's Display implementation
|
||||
fn feroxscan_display() {
|
||||
let scan = FeroxScan {
|
||||
id: "".to_string(),
|
||||
url: String::from("http://localhost"),
|
||||
scan_order: ScanOrder::Latest,
|
||||
scan_type: Default::default(),
|
||||
num_requests: 0,
|
||||
start_time: Instant::now(),
|
||||
output_level: OutputLevel::Default,
|
||||
status_403s: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
status: Default::default(),
|
||||
task: tokio::sync::Mutex::new(None),
|
||||
progress_bar: std::sync::Mutex::new(None),
|
||||
errors: Default::default(),
|
||||
};
|
||||
|
||||
let not_started = format!("{}", scan);
|
||||
|
||||
assert!(predicate::str::contains("not started")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(¬_started));
|
||||
|
||||
scan.set_status(ScanStatus::Complete).unwrap();
|
||||
let complete = format!("{}", scan);
|
||||
assert!(predicate::str::contains("complete")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(&complete));
|
||||
|
||||
scan.set_status(ScanStatus::Cancelled).unwrap();
|
||||
let cancelled = format!("{}", scan);
|
||||
assert!(predicate::str::contains("cancelled")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(&cancelled));
|
||||
|
||||
scan.set_status(ScanStatus::Running).unwrap();
|
||||
let running = format!("{}", scan);
|
||||
assert!(predicate::str::contains("running")
|
||||
.and(predicate::str::contains("localhost"))
|
||||
.eval(&running));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// call FeroxScan::abort, ensure status becomes cancelled
|
||||
async fn ferox_scan_abort() {
|
||||
let scan = FeroxScan {
|
||||
id: "".to_string(),
|
||||
url: String::from("http://localhost"),
|
||||
scan_order: ScanOrder::Latest,
|
||||
scan_type: Default::default(),
|
||||
num_requests: 0,
|
||||
start_time: Instant::now(),
|
||||
output_level: OutputLevel::Default,
|
||||
status_403s: Default::default(),
|
||||
status_429s: Default::default(),
|
||||
status: std::sync::Mutex::new(ScanStatus::Running),
|
||||
task: tokio::sync::Mutex::new(Some(tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(SLEEP_DURATION * 2));
|
||||
}))),
|
||||
progress_bar: std::sync::Mutex::new(None),
|
||||
errors: Default::default(),
|
||||
};
|
||||
|
||||
scan.abort().await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
*scan.status.lock().unwrap(),
|
||||
ScanStatus::Cancelled
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// call a few menu functions for coverage's sake
|
||||
///
|
||||
/// there's not a trivial way to test these programmatically (at least i'm too lazy rn to do it)
|
||||
/// and their correctness can be verified easily manually; just calling for now
|
||||
fn menu_print_header_and_footer() {
|
||||
let menu = Menu::new();
|
||||
let menu_cmd_1 = MenuCmd::AddUrl(String::from("http://localhost"));
|
||||
let menu_cmd_2 = MenuCmd::Cancel(vec![0], false);
|
||||
let menu_cmd_res_1 = MenuCmdResult::Url(String::from("http://localhost"));
|
||||
let menu_cmd_res_2 = MenuCmdResult::NumCancelled(2);
|
||||
println!(
|
||||
"{:?}{:?}{:?}{:?}",
|
||||
menu_cmd_1, menu_cmd_2, menu_cmd_res_1, menu_cmd_res_2
|
||||
);
|
||||
menu.clear_screen();
|
||||
menu.print_header();
|
||||
menu.print_footer();
|
||||
menu.hide_progress_bars();
|
||||
menu.show_progress_bars();
|
||||
}
|
||||
|
||||
/// ensure command parsing from user input results int he correct MenuCmd returned
|
||||
#[test]
|
||||
fn menu_get_command_input_from_user_returns_cancel() {
|
||||
let menu = Menu::new();
|
||||
|
||||
for (idx, cmd) in ["cancel", "Cancel", "c", "C"].iter().enumerate() {
|
||||
let force = idx % 2 == 0;
|
||||
|
||||
let full_cmd = if force {
|
||||
format!("{} -f {}\n", cmd, idx)
|
||||
} else {
|
||||
format!("{} {}\n", cmd, idx)
|
||||
};
|
||||
|
||||
let result = menu.get_command_input_from_user(&full_cmd).unwrap();
|
||||
|
||||
assert!(matches!(result, MenuCmd::Cancel(_, _)));
|
||||
|
||||
if let MenuCmd::Cancel(canx_list, ret_force) = result {
|
||||
if idx == 0 {
|
||||
assert!(canx_list.is_empty());
|
||||
} else {
|
||||
assert_eq!(canx_list, vec![idx]);
|
||||
}
|
||||
assert_eq!(force, ret_force);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ensure command parsing from user input results int he correct MenuCmd returned
|
||||
#[test]
|
||||
fn menu_get_command_input_from_user_returns_add() {
|
||||
let menu = Menu::new();
|
||||
|
||||
for cmd in ["add", "Addd", "a", "A", "None"] {
|
||||
let test_url = "http://happyfuntimes.commmm";
|
||||
let full_cmd = format!("{} {}\n", cmd, test_url);
|
||||
|
||||
if cmd != "None" {
|
||||
let result = menu.get_command_input_from_user(&full_cmd).unwrap();
|
||||
assert!(matches!(result, MenuCmd::AddUrl(_)));
|
||||
|
||||
if let MenuCmd::AddUrl(url) = result {
|
||||
assert_eq!(url, test_url);
|
||||
}
|
||||
} else {
|
||||
assert!(menu.get_command_input_from_user(&full_cmd).is_none());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure spaces are trimmed and numbers are returned from split_to_nums
|
||||
fn split_to_nums_is_correct() {
|
||||
let menu = Menu::new();
|
||||
|
||||
let nums = menu.split_to_nums("1, 3, 4, 7 - 12, 10-10, 10-11, 9-12, 12-6, -1, 4-");
|
||||
|
||||
assert_eq!(nums, vec![1, 3, 4, 7, 8, 9, 10, 11, 12]);
|
||||
assert_eq!(menu.split_to_nums("9-12"), vec![9, 10, 11, 12]);
|
||||
assert!(menu.split_to_nums("-12").is_empty());
|
||||
assert!(menu.split_to_nums("12-").is_empty());
|
||||
assert!(menu.split_to_nums("\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a deep url, find the correct scan
|
||||
fn get_base_scan_by_url_finds_correct_scan() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://localhost";
|
||||
let url1 = "http://localhost/stuff";
|
||||
let url2 = "http://shlocalhost/stuff/things";
|
||||
let url3 = "http://shlocalhost/stuff/things/mostuff";
|
||||
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
let (_, scan1) = urls.add_scan(url1, ScanType::Directory, ScanOrder::Latest);
|
||||
let (_, scan2) = urls.add_scan(url2, ScanType::Directory, ScanOrder::Latest);
|
||||
let (_, scan3) = urls.add_scan(url3, ScanType::Directory, ScanOrder::Latest);
|
||||
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://localhost/things.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan.id
|
||||
);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://localhost/stuff/things.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan1.id
|
||||
);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://shlocalhost/stuff/things/mostuff.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan2.id
|
||||
);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://shlocalhost/stuff/things/mostuff/mothings.php")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan3.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a shallow url without a trailing slash, find the correct scan
|
||||
fn get_base_scan_by_url_finds_correct_scan_without_trailing_slash() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://localhost";
|
||||
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://localhost/BKPMiherrortBPKcw")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// given a shallow url with a trailing slash, find the correct scan
|
||||
fn get_base_scan_by_url_finds_correct_scan_with_trailing_slash() {
|
||||
let urls = FeroxScans::default();
|
||||
let url = "http://127.0.0.1:41971/";
|
||||
let (_, scan) = urls.add_scan(url, ScanType::Directory, ScanOrder::Latest);
|
||||
assert_eq!(
|
||||
urls.get_base_scan_by_url("http://127.0.0.1:41971/BKPMiherrortBPKcw")
|
||||
.unwrap()
|
||||
.id,
|
||||
scan.id
|
||||
);
|
||||
}
|
||||