Compare commits
1304 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f608c505f | ||
|
|
def88cc529 | ||
|
|
a4f873269b | ||
|
|
449e301915 | ||
|
|
93bd25fe2f | ||
|
|
877fdddbf3 | ||
|
|
0b7e232546 | ||
|
|
aff367101d | ||
|
|
0d536a0d1a | ||
|
|
a9dc872071 | ||
|
|
33fe6350bc | ||
|
|
1f7214f617 | ||
|
|
8fae4f136b | ||
|
|
f4092e947c | ||
|
|
3fe21b22ae | ||
|
|
29b8a4a9a0 | ||
|
|
a9ff23be84 | ||
|
|
1b576fc7e6 | ||
|
|
e321a4e0e6 | ||
|
|
5ccc190de6 | ||
|
|
af3dcdf6a2 | ||
|
|
d4fd06418b | ||
|
|
3b0e530fb4 | ||
|
|
64113b8da4 | ||
|
|
e827fd02ad | ||
|
|
988fa744b5 | ||
|
|
56d0ebaa59 | ||
|
|
0cee8d4a7d | ||
|
|
34a9eb236b | ||
|
|
5acaf47db4 | ||
|
|
c0243475e4 | ||
|
|
08b3534c33 | ||
|
|
30877cadb8 | ||
|
|
05d550c7f8 | ||
|
|
a0a836695f | ||
|
|
4f959f926d | ||
|
|
4b613b716c | ||
|
|
24617a63ac | ||
|
|
5bbbcc87b0 | ||
|
|
fd58223d24 | ||
|
|
1206ca835e | ||
|
|
8daada6690 | ||
|
|
8599c87174 | ||
|
|
4f83b30424 | ||
|
|
f96466d5f0 | ||
|
|
36081fd6eb | ||
|
|
a8d8b655a5 | ||
|
|
ae0bcfab14 | ||
|
|
a8dac70ba1 | ||
|
|
53162bae85 | ||
|
|
98b2268aa9 | ||
|
|
762bfc4e78 | ||
|
|
b44c52f0ea | ||
|
|
27061eb1b5 | ||
|
|
49e54f5722 | ||
|
|
bb01fadd5a | ||
|
|
70ae679b50 | ||
|
|
01da38fa6d | ||
|
|
22586f3835 | ||
|
|
0510cb91aa | ||
|
|
4663ec4cea | ||
|
|
e8a98a54d8 | ||
|
|
fa42c72ac5 | ||
|
|
4ce77b5012 | ||
|
|
72c09854fc | ||
|
|
17a3d8af9f | ||
|
|
b67f1399b3 | ||
|
|
57db4adb69 | ||
|
|
87b6589f51 | ||
|
|
f36897431e | ||
|
|
3c89721f54 | ||
|
|
9193614f3c | ||
|
|
8eb41f40a0 | ||
|
|
f3d6d185cd | ||
|
|
df7b6ab6f9 | ||
|
|
22bed3c9e7 | ||
|
|
fe0f7d6f3c | ||
|
|
3b0d787ca7 | ||
|
|
eba35b205e | ||
|
|
ecdd1bce81 | ||
|
|
0771407939 | ||
|
|
2ea6b97c86 | ||
|
|
9ff0253deb | ||
|
|
423889b142 | ||
|
|
595665cc04 | ||
|
|
a583e2ff38 | ||
|
|
539851e3e8 | ||
|
|
c1e7c5ff59 | ||
|
|
38a1ed3f63 | ||
|
|
0d55fe2502 | ||
|
|
a714825d09 | ||
|
|
d805e46474 | ||
|
|
fe71f288e3 | ||
|
|
a38a0444fe | ||
|
|
2938094c73 | ||
|
|
55c67358d6 | ||
|
|
c3c6fc6753 | ||
|
|
a28ff857ca | ||
|
|
6c0fe90909 | ||
|
|
bc486ac8d3 | ||
|
|
fa9d42554f | ||
|
|
b78dbe6cc4 | ||
|
|
29f616f51a | ||
|
|
c1ba5cf942 | ||
|
|
e3ec3aee3a | ||
|
|
52db396aa9 | ||
|
|
e1066cd5c7 | ||
|
|
d90ee38aad | ||
|
|
a3501ac494 | ||
|
|
23827a1d45 | ||
|
|
a2b04b2b5e | ||
|
|
362633bc63 | ||
|
|
08c5b2bf67 | ||
|
|
ccef4fd713 | ||
|
|
4afe0cf95c | ||
|
|
564686bc5a | ||
|
|
83f90529e9 | ||
|
|
ad49320968 | ||
|
|
70946ad916 | ||
|
|
fd5c5af5fa | ||
|
|
ff32aba1db | ||
|
|
cbf028a8ac | ||
|
|
8bf80f4eda | ||
|
|
7c2d09cc22 | ||
|
|
0fb682c121 | ||
|
|
bcfd8b6eef | ||
|
|
1c9235a56b | ||
|
|
4d787f08d0 | ||
|
|
0c7520f5ee | ||
|
|
83b55aaf10 | ||
|
|
aea64324f7 | ||
|
|
8d0614b1a5 | ||
|
|
d19cf58af3 | ||
|
|
bd44bacf95 | ||
|
|
2bf5dc5e6f | ||
|
|
e5fadde073 | ||
|
|
ac3fdb1975 | ||
|
|
ff40549140 | ||
|
|
0cd25eedfc | ||
|
|
328d1d2ec9 | ||
|
|
68cc6bc748 | ||
|
|
f44f320a49 | ||
|
|
0965379b9a | ||
|
|
4afbf77631 | ||
|
|
5385ce5e99 | ||
|
|
c8a50d9c0c | ||
|
|
a3c887f2d7 | ||
|
|
e094dab4a4 | ||
|
|
c307e6d56d | ||
|
|
d27cb57d66 | ||
|
|
372f7c5cd4 | ||
|
|
4986ebdaae | ||
|
|
4198a019d3 | ||
|
|
3b8c6f6ba9 | ||
|
|
39f8259f31 | ||
|
|
3f5ff1ad3e | ||
|
|
12206e668f | ||
|
|
1796e4eeb2 | ||
|
|
70a8d0f5df | ||
|
|
11831a3ab5 | ||
|
|
9356b058eb | ||
|
|
df490f6224 | ||
|
|
4079551c96 | ||
|
|
6d0658a635 | ||
|
|
890519f39c | ||
|
|
abf18b0481 | ||
|
|
409844ed05 | ||
|
|
1cf37e38a2 | ||
|
|
9876759606 | ||
|
|
4150b61a42 | ||
|
|
16d34bbee0 | ||
|
|
f1fd2fc379 | ||
|
|
3dd070a0db | ||
|
|
a3dc6c97a0 | ||
|
|
ec78ec3049 | ||
|
|
960536e918 | ||
|
|
fdae9aa9d6 | ||
|
|
5c73c3fb23 | ||
|
|
02ef6d7e3f | ||
|
|
3378246820 | ||
|
|
692db93048 | ||
|
|
233cf99907 | ||
|
|
8cd9918b76 | ||
|
|
66bcbfc2f2 | ||
|
|
8b127c0093 | ||
|
|
94de58d855 | ||
|
|
2b95b7be69 | ||
|
|
e77c1314b1 | ||
|
|
1ced3b5d77 | ||
|
|
b5472f5341 | ||
|
|
ea81600850 | ||
|
|
4f679592b8 | ||
|
|
b375893461 | ||
|
|
e110f86f39 | ||
|
|
c7498a7695 | ||
|
|
f973baaba8 | ||
|
|
148982cdc4 | ||
|
|
5d96658c79 | ||
|
|
46d00507b0 | ||
|
|
d561e59ec9 | ||
|
|
b786578c03 | ||
|
|
bd54ad0087 | ||
|
|
d98c6a7457 | ||
|
|
c493d001b5 | ||
|
|
bd4566fa7b | ||
|
|
8fbf9d0274 | ||
|
|
d6b10c6476 | ||
|
|
a5e845864c | ||
|
|
b02358678b | ||
|
|
1b8fdcec17 | ||
|
|
92cc2ab448 | ||
|
|
0b0e08ae4f | ||
|
|
25762395b1 | ||
|
|
55b4034bd0 | ||
|
|
ffa409ca3d | ||
|
|
bb4a335299 | ||
|
|
1e0ec5c833 | ||
|
|
b5fa6b149e | ||
|
|
04a43a0892 | ||
|
|
8a72e498e6 | ||
|
|
2987a84776 | ||
|
|
8add5599fb | ||
|
|
9f557329eb | ||
|
|
c04bf4a703 | ||
|
|
03e8625c6e | ||
|
|
5d6b85fe12 | ||
|
|
771041d225 | ||
|
|
b5debed322 | ||
|
|
30407cd338 | ||
|
|
ba4b26f2cd | ||
|
|
4fdf558936 | ||
|
|
2ffb0df516 | ||
|
|
10260f9db7 | ||
|
|
4067be2f82 | ||
|
|
7cb9c1c914 | ||
|
|
99cbd657a5 | ||
|
|
703da383a7 | ||
|
|
aa83e40c4f | ||
|
|
a77c436e04 | ||
|
|
c3455d123e | ||
|
|
6431f01f12 | ||
|
|
2d381e7e05 | ||
|
|
7d26f368f5 | ||
|
|
36970896ca | ||
|
|
39a75f0608 | ||
|
|
ab8537beeb | ||
|
|
9e907d37d5 | ||
|
|
19e0a7f48b | ||
|
|
5e93da0a65 | ||
|
|
fd0f31705d | ||
|
|
2704e33178 | ||
|
|
8392f6d26b | ||
|
|
ca43a767d2 | ||
|
|
291ccedba3 | ||
|
|
6d01bc8ec4 | ||
|
|
94aafccf8a | ||
|
|
8dd8871ae5 | ||
|
|
ad0df8ccd3 | ||
|
|
31cdba64e4 | ||
|
|
584fc940cd | ||
|
|
5252587e65 | ||
|
|
43116f9aab | ||
|
|
aec083ea58 | ||
|
|
52d08e504d | ||
|
|
a254574ce7 | ||
|
|
6cb7c8e342 | ||
|
|
98670f367f | ||
|
|
68913c9950 | ||
|
|
5901c75187 | ||
|
|
8499901bfe | ||
|
|
69dcb38360 | ||
|
|
eb8b70668d | ||
|
|
f0702794b0 | ||
|
|
367dcdbd72 | ||
|
|
4b7a25c13b | ||
|
|
339189ff13 | ||
|
|
ed701c13b0 | ||
|
|
e034734df4 | ||
|
|
73e2558404 | ||
|
|
eb7ad68c01 | ||
|
|
c61688f984 | ||
|
|
6c96589ca5 | ||
|
|
0437c2baac | ||
|
|
0d689942eb | ||
|
|
74a1a8d597 | ||
|
|
729d88a724 | ||
|
|
ad38b56473 | ||
|
|
655364d9bd | ||
|
|
ac7f59cd3f | ||
|
|
0d64d28fe6 | ||
|
|
89c29600c7 | ||
|
|
96375e7734 | ||
|
|
3531b8c74b | ||
|
|
e8f4438a52 | ||
|
|
02b25dc553 | ||
|
|
551cf065f3 | ||
|
|
c81885cf5e | ||
|
|
6a3d250e3b | ||
|
|
259fbcca74 | ||
|
|
f3c9f8ed20 | ||
|
|
be400ce971 | ||
|
|
b62c76bce3 | ||
|
|
990a471d71 | ||
|
|
ec47d6f934 | ||
|
|
da509bd208 | ||
|
|
8568b340a9 | ||
|
|
7c9d8f529d | ||
|
|
6d47b4b68b | ||
|
|
be3290572e | ||
|
|
4f13fd7974 | ||
|
|
57b8117015 | ||
|
|
7b4900fa07 | ||
|
|
d1e47b0025 | ||
|
|
98612e2256 | ||
|
|
f08023b813 | ||
|
|
98254e3cac | ||
|
|
46cc64325f | ||
|
|
fc034f0720 | ||
|
|
ef4a597cb1 | ||
|
|
bb6b12d168 | ||
|
|
21a9de2d39 | ||
|
|
7d8f3b0305 | ||
|
|
6b3fe48b4f | ||
|
|
7a79000d96 | ||
|
|
d164034d3e | ||
|
|
e4dc7da756 | ||
|
|
6090cefa4f | ||
|
|
ec05644854 | ||
|
|
567f927884 | ||
|
|
176a6a6426 | ||
|
|
c99f6146e3 | ||
|
|
fb34817509 | ||
|
|
0c8e5d51f0 | ||
|
|
ac24e507ac | ||
|
|
808c749f63 | ||
|
|
b1f5ed507b | ||
|
|
79edc42b17 | ||
|
|
1b223b0867 | ||
|
|
0c6d5193a9 | ||
|
|
c637355796 | ||
|
|
a114cc8f85 | ||
|
|
c8503faf02 | ||
|
|
cbbd642510 | ||
|
|
2c8e9bace9 | ||
|
|
f4fe8c0544 | ||
|
|
73109483fe | ||
|
|
aee33012b1 | ||
|
|
eab95e0435 | ||
|
|
acb2f42f69 | ||
|
|
ac20b213ec | ||
|
|
201873d7ac | ||
|
|
9678b8f31c | ||
|
|
20a826fc0f | ||
|
|
56b78a4e04 | ||
|
|
4b6bf3645d | ||
|
|
6fd201b717 | ||
|
|
5f39d71fe8 | ||
|
|
c23850208b | ||
|
|
d5605efb08 | ||
|
|
5b8d3f5661 | ||
|
|
ce7f3b79b8 | ||
|
|
c9c63bebd0 | ||
|
|
1f60e06247 | ||
|
|
04e3ad69cc | ||
|
|
fd5b1f6f25 | ||
|
|
a9dde3f7e1 | ||
|
|
7a9ee39941 | ||
|
|
6befae1a93 | ||
|
|
e6b422b92a | ||
|
|
fb4bfa27fd | ||
|
|
2795ec4e72 | ||
|
|
e9d283bc59 | ||
|
|
3a128df2fc | ||
|
|
38f1b917c4 | ||
|
|
afa7d6804c | ||
|
|
28c3e25eeb | ||
|
|
55e22467ce | ||
|
|
bbfaddaedd | ||
|
|
53e3420efd | ||
|
|
d390bbc12d | ||
|
|
0c3b91855a | ||
|
|
48f5362f5f | ||
|
|
24514faf9e | ||
|
|
a424057166 | ||
|
|
7d483b6edd | ||
|
|
1a717e878d | ||
|
|
e35a6dda9f | ||
|
|
f3b2193b2f | ||
|
|
07a7ac652e | ||
|
|
f51993cde0 | ||
|
|
9093ffb92a | ||
|
|
d550448229 | ||
|
|
492665154e | ||
|
|
c14e617076 | ||
|
|
6bb748af17 | ||
|
|
863ea089cc | ||
|
|
ad3091a7db | ||
|
|
b2bdea71dd | ||
|
|
f478700b86 | ||
|
|
1f2aad5e52 | ||
|
|
3a6a61cc24 | ||
|
|
0311a846b3 | ||
|
|
3066efa848 | ||
|
|
a8fae65d63 | ||
|
|
970886a68b | ||
|
|
494eed81e8 | ||
|
|
c8a577b1e7 | ||
|
|
ccb10c1c68 | ||
|
|
20ab0aade3 | ||
|
|
02ad0b1d85 | ||
|
|
09aad922c1 | ||
|
|
697f947bfa | ||
|
|
d300d68737 | ||
|
|
c2c6854db4 | ||
|
|
63be575d89 | ||
|
|
0d25fda11e | ||
|
|
b0341c2432 | ||
|
|
62352db152 | ||
|
|
3090edc49c | ||
|
|
85d686d1aa | ||
|
|
17138f4ef7 | ||
|
|
1d30b7db31 | ||
|
|
4c0d3c91a0 | ||
|
|
96fc6b232a | ||
|
|
9b306aad34 | ||
|
|
10eee184d0 | ||
|
|
986161f05f | ||
|
|
4a19dbfd7d | ||
|
|
b8ceeaff0f | ||
|
|
d04e58036e | ||
|
|
d1a74207f4 | ||
|
|
03a36f0b60 | ||
|
|
f2d9269643 | ||
|
|
bba7cba02e | ||
|
|
fffd1e5c82 | ||
|
|
3c6da0f782 | ||
|
|
5ecd937c0e | ||
|
|
9f6221daf6 | ||
|
|
af49fd8e62 | ||
|
|
d1daefd8ba | ||
|
|
3e8255d5b7 | ||
|
|
5af18e83d8 | ||
|
|
d1d0757d56 | ||
|
|
f5f9344a81 | ||
|
|
fd52e39188 | ||
|
|
22377dc9a3 | ||
|
|
1cf7dff734 | ||
|
|
7c9eb900b7 | ||
|
|
8480b3cc2c | ||
|
|
9d29142046 | ||
|
|
38c194b222 | ||
|
|
72dc14bf3d | ||
|
|
9a7c690c17 | ||
|
|
de4514e381 | ||
|
|
2be8aaf2bf | ||
|
|
4db3a0b056 | ||
|
|
a9ef7f180f | ||
|
|
ac7cb5d6b6 | ||
|
|
f8e18abb48 | ||
|
|
1d805aca5a | ||
|
|
fa09266804 | ||
|
|
15592c3dfd | ||
|
|
53c171aeb5 | ||
|
|
5167d24c4b | ||
|
|
81ff62c53d | ||
|
|
433f68458e | ||
|
|
d32720a90a | ||
|
|
0ceef975e6 | ||
|
|
6d7d6c4e7b | ||
|
|
5f21953bc1 | ||
|
|
6906ac0ee8 | ||
|
|
cafe766d9e | ||
|
|
98d0d177df | ||
|
|
23e10833d0 | ||
|
|
7f51d0f7bf | ||
|
|
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 | ||
|
|
e5934cef1f | ||
|
|
1b49c5dfe9 | ||
|
|
47c384e2ec | ||
|
|
8d5a0c590e | ||
|
|
6b04bc6757 | ||
|
|
baa996356c | ||
|
|
9241b3c748 |
976
.all-contributorsrc
Normal file
@@ -0,0 +1,976 @@
|
||||
{
|
||||
"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",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "moscowchill",
|
||||
"name": "mchill",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/72578879?v=4",
|
||||
"profile": "https://github.com/moscowchill",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "BitThr3at",
|
||||
"name": "Naman",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/45028933?v=4",
|
||||
"profile": "http://BitThr3at.github.io",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sicks3c",
|
||||
"name": "Ayoub Elaich",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/32225186?v=4",
|
||||
"profile": "https://github.com/Sicks3c",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "HenryHoggard",
|
||||
"name": "Henry",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1208121?v=4",
|
||||
"profile": "https://github.com/HenryHoggard",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "SleepiPanda",
|
||||
"name": "SleepiPanda",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6428561?v=4",
|
||||
"profile": "https://github.com/SleepiPanda",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "uBadRequest",
|
||||
"name": "Bad Requests",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/47282747?v=4",
|
||||
"profile": "https://github.com/uBadRequest",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dnaka91",
|
||||
"name": "Dominik Nakamura",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36804488?v=4",
|
||||
"profile": "https://home.dnaka91.rocks",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hunter0x8",
|
||||
"name": "Muhammad Ahsan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/46222314?v=4",
|
||||
"profile": "https://github.com/hunter0x8",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "cortantief",
|
||||
"name": "cortantief",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/34527333?v=4",
|
||||
"profile": "https://github.com/cortantief",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dsaxton",
|
||||
"name": "Daniel Saxton",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2658661?v=4",
|
||||
"profile": "https://github.com/dsaxton",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "n0kovo",
|
||||
"name": "n0kovo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/16690056?v=4",
|
||||
"profile": "https://github.com/n0kovo",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "justinsteven",
|
||||
"name": "Justin Steven",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1893909?v=4",
|
||||
"profile": "https://ring0.lol",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "7047payloads",
|
||||
"name": "7047payloads",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/95562424?v=4",
|
||||
"profile": "https://github.com/7047payloads",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "unkn0wnsyst3m",
|
||||
"name": "unkn0wnsyst3m",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/21272239?v=4",
|
||||
"profile": "https://github.com/unkn0wnsyst3m",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "its0x08",
|
||||
"name": "0x08",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/15280042?v=4",
|
||||
"profile": "https://ironwort.me/",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "MD-Levitan",
|
||||
"name": "kusok",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12116508?v=4",
|
||||
"profile": "https://github.com/MD-Levitan",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "godylockz",
|
||||
"name": "godylockz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/81207744?v=4",
|
||||
"profile": "https://github.com/godylockz",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "0dayCTF",
|
||||
"name": "Ryan Montgomery",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/44453666?v=4",
|
||||
"profile": "http://ryanmontgomery.me",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ippsec",
|
||||
"name": "ippsec",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/24677271?v=4",
|
||||
"profile": "https://github.com/IppSec",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "gtjamesa",
|
||||
"name": "James",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2078364?v=4",
|
||||
"profile": "https://github.com/gtjamesa",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jhaddix",
|
||||
"name": "Jason Haddix",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3488554?v=4",
|
||||
"profile": "https://twitter.com/Jhaddix",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ThisLimn0",
|
||||
"name": "Limn0",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/67125885?v=4",
|
||||
"profile": "https://github.com/ThisLimn0",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "0xdf223",
|
||||
"name": "0xdf",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/76954092?v=4",
|
||||
"profile": "https://github.com/0xdf223",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Flangyver",
|
||||
"name": "Flangyver",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/59575870?v=4",
|
||||
"profile": "https://github.com/Flangyver",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "DonatoReis",
|
||||
"name": "PeakyBlinder",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/93531354?v=4",
|
||||
"profile": "https://github.com/DonatoReis",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "postmodern",
|
||||
"name": "Postmodern",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12671?v=4",
|
||||
"profile": "https://postmodern.github.io/",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "herrcykel",
|
||||
"name": "O",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1936757?v=4",
|
||||
"profile": "https://github.com/herrcykel",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "udoprog",
|
||||
"name": "John-John Tedro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/111092?v=4",
|
||||
"profile": "http://udoprog.github.io/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kmanc",
|
||||
"name": "kmanc",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/14863147?v=4",
|
||||
"profile": "https://github.com/kmanc",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hakdogpinas",
|
||||
"name": "hakdogpinas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/71529469?v=4",
|
||||
"profile": "https://github.com/hakdogpinas",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "duokebei",
|
||||
"name": "多可悲",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/75022552?v=4",
|
||||
"profile": "https://github.com/duokebei",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "aidanhall34",
|
||||
"name": "Aidan Hall",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/58670593?v=4",
|
||||
"profile": "https://blog.ah34.net/",
|
||||
"contributions": [
|
||||
"code",
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "joaociocca",
|
||||
"name": "João Ciocca",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6473725?v=4",
|
||||
"profile": "https://hachyderm.io/@JohnnyCiocca",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "f3rn0s",
|
||||
"name": "f3rn0s",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1351279?v=4",
|
||||
"profile": "https://github.com/f3rn0s",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "pich4ya",
|
||||
"name": "LongCat",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2099767?v=4",
|
||||
"profile": "https://sth.sh",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "xaeroborg",
|
||||
"name": "xaeroborg",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/33274680?v=4",
|
||||
"profile": "https://github.com/xaeroborg",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Luoooio",
|
||||
"name": "Luoooio",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/26653157?v=4",
|
||||
"profile": "https://github.com/Luoooio",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "aancw",
|
||||
"name": "Aan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6284204?v=4",
|
||||
"profile": "https://petruknisme.com",
|
||||
"contributions": [
|
||||
"code",
|
||||
"infra",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "imBigo",
|
||||
"name": "Simon",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54672433?v=4",
|
||||
"profile": "https://github.com/imBigo",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "acut3",
|
||||
"name": "Nicolas Christin",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/17295243?v=4",
|
||||
"profile": "https://acut3.github.io/",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "DrorDvash",
|
||||
"name": "DrDv",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8413651?v=4",
|
||||
"profile": "https://github.com/DrorDvash",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "aroly",
|
||||
"name": "Antoine Roly",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1257705?v=4",
|
||||
"profile": "https://github.com/aroly",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lavafroth",
|
||||
"name": "Himadri Bhattacharjee",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/107522312?v=4",
|
||||
"profile": "http://lavafroth.is-a.dev",
|
||||
"contributions": [
|
||||
"code",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "AkechiShiro",
|
||||
"name": "Samy Lahfa",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/14914796?v=4",
|
||||
"profile": "https://github.com/AkechiShiro",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sectroyer",
|
||||
"name": "sectroyer",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6706818?v=4",
|
||||
"profile": "https://github.com/sectroyer",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ktecv2000",
|
||||
"name": "ktecv2000",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/19836003?v=4",
|
||||
"profile": "https://medium.com/@b3rm1nG",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "andreademurtas",
|
||||
"name": "Andrea De Murtas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/56048157?v=4",
|
||||
"profile": "http://untrue.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sawmj",
|
||||
"name": "sawmj",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30024085?v=4",
|
||||
"profile": "https://github.com/sawmj",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "devx00",
|
||||
"name": "Zach Hanson",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6897405?v=4",
|
||||
"profile": "https://github.com/devx00",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ocervell",
|
||||
"name": "Olivier Cervello",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/9629314?v=4",
|
||||
"profile": "https://github.com/ocervell",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "RavySena",
|
||||
"name": "RavySena",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/67729597?v=4",
|
||||
"profile": "https://github.com/RavySena",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "stuhlmann",
|
||||
"name": "Florian Stuhlmann",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/11061864?v=4",
|
||||
"profile": "https://github.com/stuhlmann",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Mister7F",
|
||||
"name": "Mister7F",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/35213773?v=4",
|
||||
"profile": "https://github.com/Mister7F",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "manugramm",
|
||||
"name": "manugramm",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/145961515?v=4",
|
||||
"profile": "https://github.com/manugramm",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ArthurMuraro",
|
||||
"name": "ArthurMuraro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/73059809?v=4",
|
||||
"profile": "https://github.com/ArthurMuraro",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "amiremami",
|
||||
"name": "Shadow",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/15929497?v=4",
|
||||
"profile": "https://github.com/amiremami",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dirhamgithub",
|
||||
"name": "dirhamgithub",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/115349974?v=4",
|
||||
"profile": "https://github.com/dirhamgithub",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "FieldOfRice",
|
||||
"name": "FieldOfRice",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/85353?v=4",
|
||||
"profile": "https://github.com/FieldOfRice",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "NotoriousRebel",
|
||||
"name": "Matt",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36310667?v=4",
|
||||
"profile": "https://github.com/NotoriousRebel",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "tritoke",
|
||||
"name": "Sam Leonard",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/34941249?v=4",
|
||||
"profile": "https://github.com/tritoke",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "rew1nter",
|
||||
"name": "Rewinter",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/64508791?v=4",
|
||||
"profile": "https://github.com/rew1nter",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "deadloot",
|
||||
"name": "deadloot",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/92878901?v=4",
|
||||
"profile": "https://github.com/deadloot",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Spidle",
|
||||
"name": "Spidle",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/90011249?v=4",
|
||||
"profile": "https://github.com/Spidle",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "JulianGR",
|
||||
"name": "Julián Gómez",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/53094530?v=4",
|
||||
"profile": "https://github.com/JulianGR",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"infra",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "soutzis",
|
||||
"name": "Petros",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/25797286?v=4",
|
||||
"profile": "https://github.com/soutzis",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sitiom",
|
||||
"name": "Ryan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/56180050?v=4",
|
||||
"profile": "https://github.com/sitiom",
|
||||
"contributions": [
|
||||
"infra",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "wikamp-collaborator",
|
||||
"name": "wikamp-collaborator",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/147445097?v=4",
|
||||
"profile": "https://github.com/wikamp-collaborator",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "L1-0",
|
||||
"name": "Lino",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/123986259?v=4",
|
||||
"profile": "http://lino.codes",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sa7mon",
|
||||
"name": "Dan Salmon",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3712226?v=4",
|
||||
"profile": "https://danthesalmon.com",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "swordfish0x0",
|
||||
"name": "swordfish0x0",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/21209130?v=4",
|
||||
"profile": "https://github.com/swordfish0x0",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "libklein",
|
||||
"name": "Patrick Klein",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/42714034?v=4",
|
||||
"profile": "https://github.com/libklein",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Raymond-JV",
|
||||
"name": "Raymond",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/23642921?v=4",
|
||||
"profile": "https://github.com/Raymond-JV",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "zer0x64",
|
||||
"name": "zer0x64",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/17575242?v=4",
|
||||
"profile": "https://github.com/zer0x64",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "zar3bski",
|
||||
"name": "zar3bski",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/22128014?v=4",
|
||||
"profile": "https://zar3bski.com",
|
||||
"contributions": [
|
||||
"code",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "karanabe",
|
||||
"name": "karanabe",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/152078880?v=4",
|
||||
"profile": "https://github.com/karanabe",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "h121h",
|
||||
"name": "h121h",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/616758?v=4",
|
||||
"profile": "https://github.com/h121h",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "s0i37",
|
||||
"name": "s0i37",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/22872513?v=4",
|
||||
"profile": "https://github.com/s0i37",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "wilco375",
|
||||
"name": "Wilco",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7385023?v=4",
|
||||
"profile": "https://github.com/wilco375",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "HenriBom",
|
||||
"name": "HenriBom",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/46447744?v=4",
|
||||
"profile": "https://github.com/HenriBom",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "0x7274",
|
||||
"name": "R̝͖̱͖͕̤̰̯͙ͫ͒̀ͮȁ̤͔̝̘̪̻͕̝̖ͧͪͤu̗̠̜̩̗͇͑̀ͣ̃͂̔͂c̫͔͚̲̬̓̂̿͌̿͊̐͗h͚̲̤̟͓̟̥̊ͬͪ̏̍̍ T̟̜̞͉͙̙ͣ́ͪ͗̓̇ͭo͍̰͎̼͓̟̽ͧ̓̉ͬ̐͐b͇̖̳̫̰̗̭͍ͧ̄̄̌̈i̙̪̤̝̟͓̹̋̽͋̀ͧ̒a͕̭̱͎̪̦̤ͤ͊̊̑ͣ̄s̪̯͖̰̯͍ͫ̋͑̄ͭͅͅ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/85586890?v=4",
|
||||
"profile": "https://github.com/0x7274",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "4FunAndProfit",
|
||||
"name": "4FunAndProfit",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/174417079?v=4",
|
||||
"profile": "https://github.com/4FunAndProfit",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lidorelias3",
|
||||
"name": "lidorelias3",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/41958137?v=4",
|
||||
"profile": "https://github.com/lidorelias3",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "feroxbuster",
|
||||
"projectOwner": "epi052",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true,
|
||||
"commitConvention": "angular",
|
||||
"commitType": "docs"
|
||||
}
|
||||
5
.cargo/config.toml
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
|
||||
9
.github/pull_request_template.md
vendored
@@ -7,16 +7,19 @@ 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/)
|
||||
|
||||
## 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
|
||||
|
||||
197
.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,88 +24,178 @@ 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-linux-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-linux-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: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libssl-dev pkg-config
|
||||
- uses: actions-rs/toolchain@v1
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
with:
|
||||
toolchain: stable
|
||||
target: ${{ matrix.target }}
|
||||
override: true
|
||||
- uses: actions-rs/cargo@v1
|
||||
env:
|
||||
PKG_CONFIG_PATH: ${{ matrix.pkg_config_path }}
|
||||
OPENSSL_DIR: /usr/lib/ssl
|
||||
with:
|
||||
use-cross: true
|
||||
command: build
|
||||
args: --release --target=${{ matrix.target }}
|
||||
- name: Strip symbols from binary
|
||||
run: |
|
||||
strip -s ${{ matrix.path }}
|
||||
target: ${{ matrix.target }}
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- name: Build tar.gz for homebrew installs
|
||||
if: matrix.type == 'ubuntu-x64'
|
||||
run: |
|
||||
tar czf ${{ matrix.name }}.tar.gz -C target/x86_64-unknown-linux-musl/release feroxbuster
|
||||
- uses: actions/upload-artifact@v2
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ matrix.path }}
|
||||
- uses: actions/upload-artifact@v2
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.type == 'ubuntu-x64'
|
||||
with:
|
||||
name: ${{ matrix.name }}.tar.gz
|
||||
path: ${{ matrix.name }}.tar.gz
|
||||
|
||||
build-debug:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install System Dependencies
|
||||
run: |
|
||||
env
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libssl-dev pkg-config musl-tools
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Build the project
|
||||
env:
|
||||
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig
|
||||
OPENSSL_DIR: /usr/lib/ssl
|
||||
run: cargo build --target=x86_64-unknown-linux-musl
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: x86_64-linux-debug-feroxbuster
|
||||
path: target/x86_64-unknown-linux-musl/debug/feroxbuster
|
||||
|
||||
build-debug-windows:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Build the project
|
||||
run: cargo build --target=x86_64-pc-windows-msvc
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: x86_64-windows-debug-feroxbuster.exe
|
||||
path: target\x86_64-pc-windows-msvc\debug\feroxbuster.exe
|
||||
|
||||
build-deb:
|
||||
needs: [build-nix]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install cargo-deb
|
||||
run: cargo install -f cargo-deb
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1
|
||||
with:
|
||||
packages: musl-tools # provides musl-gcc
|
||||
version: 1.0
|
||||
- name: Install musl toolchain
|
||||
run: rustup target add x86_64-unknown-linux-musl
|
||||
- name: Deb Build
|
||||
uses: ebbflow-io/cargo-deb-amd64-ubuntu@1.0
|
||||
run: cargo deb --target=x86_64-unknown-linux-musl
|
||||
- name: Upload Deb Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: feroxbuster_amd64.deb
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-apple-darwin
|
||||
override: true
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
use-cross: true
|
||||
command: build
|
||||
args: --release --target=x86_64-apple-darwin
|
||||
- name: Strip symbols from binary
|
||||
run: |
|
||||
strip -u -r target/x86_64-apple-darwin/release/feroxbuster
|
||||
target: x86_64-apple-darwin
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- name: Build tar.gz for homebrew installs
|
||||
run: |
|
||||
tar czf x86_64-macos-feroxbuster.tar.gz -C target/x86_64-apple-darwin/release feroxbuster
|
||||
- uses: actions/upload-artifact@v2
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: x86_64-macos-feroxbuster
|
||||
path: target/x86_64-apple-darwin/release/feroxbuster
|
||||
- uses: actions/upload-artifact@v2
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: x86_64-macos-feroxbuster.tar.gz
|
||||
path: x86_64-macos-feroxbuster.tar.gz
|
||||
|
||||
build-macos-aarch64:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: macos-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
with:
|
||||
command: build
|
||||
target: aarch64-apple-darwin
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- name: Build tar.gz for homebrew installs
|
||||
run: |
|
||||
tar czf aarch64-macos-feroxbuster.tar.gz -C target/aarch64-apple-darwin/release feroxbuster
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aarch64-macos-feroxbuster
|
||||
path: target/aarch64-apple-darwin/release/feroxbuster
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aarch64-macos-feroxbuster.tar.gz
|
||||
path: aarch64-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]
|
||||
@@ -119,19 +211,18 @@ jobs:
|
||||
name: x86-windows-feroxbuster.exe
|
||||
path: target\i686-pc-windows-msvc\release\feroxbuster.exe
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
with:
|
||||
toolchain: stable
|
||||
target: ${{ matrix.target }}
|
||||
override: true
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
use-cross: true
|
||||
command: build
|
||||
args: --release --target=${{ matrix.target }}
|
||||
- uses: actions/upload-artifact@v2
|
||||
target: ${{ matrix.target }}
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ matrix.path }}
|
||||
|
||||
|
||||
63
.github/workflows/check.yml
vendored
@@ -7,58 +7,45 @@ jobs:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: check
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo check
|
||||
|
||||
test:
|
||||
name: Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Install latest nextest release
|
||||
uses: taiki-e/install-action@nextest
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Test with latest nextest release
|
||||
run: cargo nextest run --all-features --all-targets --retries 4 --no-fail-fast
|
||||
|
||||
fmt:
|
||||
name: Rust fmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
- run: rustup component add rustfmt
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
components: rustfmt
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
- run: rustup component add clippy
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --all-targets --all-features -- -D warnings -A clippy::deref_addrof
|
||||
components: clippy
|
||||
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
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@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/feroxbuster:latest
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
39
.github/workflows/coverage.yml
vendored
@@ -3,34 +3,23 @@ on: [push]
|
||||
name: Code Coverage Pipeline
|
||||
|
||||
jobs:
|
||||
upload-coverage:
|
||||
coverage:
|
||||
name: LLVM Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions-rs/toolchain@v1
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly
|
||||
override: true
|
||||
- uses: actions-rs/cargo@v1
|
||||
components: llvm-tools-preview
|
||||
- name: Install cargo-llvm-cov and cargo-nextest
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
command: clean
|
||||
- uses: actions-rs/cargo@v1
|
||||
tool: cargo-nextest,cargo-llvm-cov
|
||||
- name: Generate code coverage
|
||||
run: cargo llvm-cov nextest --all-features --no-fail-fast --lcov --retries 4 --output-path lcov.info
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
command: test
|
||||
args: --all-features --no-fail-fast
|
||||
env:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
RUSTFLAGS: '-Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort'
|
||||
RUSTDOCFLAGS: '-Cpanic=abort'
|
||||
- uses: actions-rs/grcov@v0.1
|
||||
- name: Convert lcov to xml
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py
|
||||
chmod +x lcov_cobertura.py
|
||||
./lcov_cobertura.py ./lcov.info
|
||||
- uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
name: codecov-umbrella
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
21
.github/workflows/winget.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Publish to Winget
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Tag name of release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal2009/winget-releaser@main
|
||||
with:
|
||||
identifier: epi052.feroxbuster
|
||||
installers-regex: '-windows-feroxbuster\.exe\.zip$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
release-tag: ${{ inputs.tag_name || github.event.release.tag_name || github.ref_name }}
|
||||
16
.gitignore
vendored
@@ -3,16 +3,15 @@
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# jetbrains metadata folder
|
||||
.idea/
|
||||
|
||||
# vscode metadata folder
|
||||
.vscode/
|
||||
|
||||
# personal feroxbuster config for testing
|
||||
ferox-config.toml
|
||||
|
||||
@@ -25,3 +24,12 @@ 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*
|
||||
|
||||
# ignore choco_package generated nupkg
|
||||
/choco_package/*.nupkg
|
||||
|
||||
1
.rustfmt.toml
Normal file
@@ -0,0 +1 @@
|
||||
reorder_modules = false
|
||||
@@ -76,35 +76,35 @@ Now that you have a copy of your fork, there is work you will need to do to keep
|
||||
|
||||
Do this prior to every time you create a branch for a PR:
|
||||
|
||||
1. Make sure you are on the `master` branch
|
||||
1. Make sure you are on the `main` branch
|
||||
|
||||
> ```sh
|
||||
> $ git status
|
||||
> On branch master
|
||||
> Your branch is up-to-date with 'origin/master'.
|
||||
> On branch main
|
||||
> Your branch is up-to-date with 'origin/main'.
|
||||
> ```
|
||||
|
||||
> If your aren't on `master`, resolve outstanding files and commits and checkout the `master` branch
|
||||
> If your aren't on `main`, resolve outstanding files and commits and checkout the `main` branch
|
||||
|
||||
> ```sh
|
||||
> $ git checkout master
|
||||
> $ git checkout main
|
||||
> ```
|
||||
|
||||
2. Do a pull with rebase against `upstream`
|
||||
|
||||
> ```sh
|
||||
> $ git pull --rebase upstream master
|
||||
> $ git pull --rebase upstream main
|
||||
> ```
|
||||
|
||||
> This will pull down all of the changes to the official master branch, without making an additional commit in your local repo.
|
||||
> This will pull down all of the changes to the official main branch, without making an additional commit in your local repo.
|
||||
|
||||
3. (_Optional_) Force push your updated master branch to your GitHub fork
|
||||
3. (_Optional_) Force push your updated main branch to your GitHub fork
|
||||
|
||||
> ```sh
|
||||
> $ git push origin master --force
|
||||
> $ git push origin main --force
|
||||
> ```
|
||||
|
||||
> This will overwrite the master branch of your fork.
|
||||
> This will overwrite the main branch of your fork.
|
||||
|
||||
### Creating a branch
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -182,14 +182,17 @@ Test coverage can be checked using [grcov](https://github.com/mozilla/grcov). I
|
||||
|
||||
```sh
|
||||
cargo install grcov
|
||||
rustup component add llvm-tools
|
||||
rustup install nightly
|
||||
rustup default nightly
|
||||
export CARGO_INCREMENTAL=0
|
||||
export RUSTFLAGS="-Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort"
|
||||
export RUSTFLAGS="-Cinstrument-coverage -Clink-dead-code -Ccodegen-units=1 -Coverflow-checks=off"
|
||||
export LLVM_PROFILE_FILE="target/debug/coverage/profraw/feroxbuster-%p-%m.profraw"
|
||||
export RUSTDOCFLAGS="-Cpanic=abort"
|
||||
rm -r target/debug/coverage/profraw
|
||||
cargo build
|
||||
cargo test
|
||||
grcov ./target/debug/ -s . -t html --llvm --branch --ignore-not-existing -o ./target/debug/coverage/
|
||||
grcov . --source-dir . --keep-only "src/*" --binary-path ./target/debug/ -t html --branch --ignore-not-existing -o ./target/debug/coverage/
|
||||
firefox target/debug/coverage/index.html
|
||||
```
|
||||
|
||||
@@ -214,20 +217,20 @@ GitHub has a good guide on how to contribute to open source [here](https://opens
|
||||
|
||||
##### Editing via your local fork
|
||||
|
||||
1. Perform the maintenance step of rebasing `master`
|
||||
2. Ensure you're on the `master` branch using `git status`:
|
||||
1. Perform the maintenance step of rebasing `main`
|
||||
2. Ensure you're on the `main` branch using `git status`:
|
||||
|
||||
```sh
|
||||
$ git status
|
||||
On branch master
|
||||
Your branch is up-to-date with 'origin/master'.
|
||||
On branch main
|
||||
Your branch is up-to-date with 'origin/main'.
|
||||
|
||||
nothing to commit, working directory clean
|
||||
```
|
||||
|
||||
1. If you're not on master or your working directory is not clean, resolve
|
||||
any outstanding files/commits and checkout master `git checkout master`
|
||||
2. Create a branch off of `master` with git: `git checkout -B
|
||||
1. If you're not on main or your working directory is not clean, resolve
|
||||
any outstanding files/commits and checkout main `git checkout main`
|
||||
2. Create a branch off of `main` with git: `git checkout -B
|
||||
branch/name-here`
|
||||
3. Edit your file(s) locally with the editor of your choice
|
||||
4. Check your `git status` to see unstaged files
|
||||
@@ -239,8 +242,8 @@ nothing to commit, working directory clean
|
||||
8. Push your commits to your GitHub Fork: `git push -u origin branch/name-here`
|
||||
9. Once the edits have been committed, you will be prompted to create a pull
|
||||
request on your fork's GitHub page
|
||||
10. By default, all pull requests should be against the `master` branch
|
||||
11. Submit a pull request from your branch to feroxbuster's `master` branch
|
||||
10. By default, all pull requests should be against the `main` branch
|
||||
11. Submit a pull request from your branch to feroxbuster's `main` branch
|
||||
12. The title (also called the subject) of your PR should be descriptive of your
|
||||
changes and succinctly indicate what is being fixed
|
||||
- Examples: `Add test cases for Unicode support`; `Correct typo in overview documentation`
|
||||
|
||||
4099
Cargo.lock
generated
Normal file
107
Cargo.toml
@@ -1,14 +1,20 @@
|
||||
[package]
|
||||
name = "feroxbuster"
|
||||
version = "1.10.0"
|
||||
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
|
||||
version = "2.13.0"
|
||||
authors = ["Ben 'epi' Risher (@epi052)"]
|
||||
license = "MIT"
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/epi052/feroxbuster"
|
||||
repository = "https://github.com/epi052/feroxbuster"
|
||||
description = "A fast, simple, recursive content discovery tool."
|
||||
categories = ["command-line-utilities"]
|
||||
keywords = ["pentest", "enumeration", "url-bruteforce", "content-discovery", "web"]
|
||||
keywords = [
|
||||
"pentest",
|
||||
"enumeration",
|
||||
"url-bruteforce",
|
||||
"content-discovery",
|
||||
"web",
|
||||
]
|
||||
exclude = [".github/*", "img/*", "check-coverage.sh"]
|
||||
build = "build.rs"
|
||||
|
||||
@@ -16,37 +22,55 @@ build = "build.rs"
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[build-dependencies]
|
||||
clap = "2.33"
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
clap = { version = "4.5", features = ["wrap_help", "cargo"] }
|
||||
clap_complete = "4.5"
|
||||
regex = "1.11"
|
||||
lazy_static = "1.5"
|
||||
dirs = "5.0"
|
||||
|
||||
[dependencies]
|
||||
futures = { version = "0.3"}
|
||||
tokio = { version = "0.2", features = ["full"] }
|
||||
tokio-util = {version = "0.3", features = ["codec"]}
|
||||
scraper = "0.19"
|
||||
futures = "0.3"
|
||||
tokio = { version = "1.47", features = ["full"] }
|
||||
tokio-util = { version = "0.7", 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"] }
|
||||
env_logger = "0.11"
|
||||
reqwest = { version = "0.12", features = ["socks", "native-tls-alpn"] }
|
||||
# uses feature unification to add 'serde' to reqwest::Url
|
||||
url = { version = "2.5", features = ["serde"] }
|
||||
serde_regex = "1.1"
|
||||
clap = { version = "4.5", features = ["wrap_help", "cargo"] }
|
||||
lazy_static = "1.5"
|
||||
toml = "0.8"
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "0.8", features = ["v4"] }
|
||||
indicatif = "0.15"
|
||||
console = "0.12"
|
||||
uuid = { version = "1.17", features = ["v4"] }
|
||||
indicatif = { version = "0.17.11" }
|
||||
console = "0.15"
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
dirs = "3.0"
|
||||
regex = "1"
|
||||
crossterm = "0.18"
|
||||
rlimit = "0.5"
|
||||
ctrlc = "3.1"
|
||||
dirs = "5.0"
|
||||
regex = "1.11"
|
||||
crossterm = "0.27"
|
||||
rlimit = "0.10"
|
||||
ctrlc = "3.4"
|
||||
anyhow = "1.0"
|
||||
leaky-bucket = "1.1"
|
||||
gaoya = "0.2"
|
||||
# 0.37+ relies on the broken version of indicatif and forces
|
||||
# the broken version to be used regardless of the version
|
||||
# specified above
|
||||
self_update = { version = "0.40", features = [
|
||||
"archive-tar",
|
||||
"compression-flate2",
|
||||
"archive-zip",
|
||||
"compression-zip-deflate",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.1"
|
||||
httpmock = "0.4.5"
|
||||
assert_cmd = "1.0.1"
|
||||
predicates = "1.0.5"
|
||||
tempfile = "3.20"
|
||||
httpmock = "0.7"
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
@@ -58,6 +82,29 @@ section = "utility"
|
||||
license-file = ["LICENSE", "4"]
|
||||
conf-files = ["/etc/feroxbuster/ferox-config.toml"]
|
||||
assets = [
|
||||
["target/release/feroxbuster", "/usr/bin/", "755"],
|
||||
["ferox-config.toml.example", "/etc/feroxbuster/ferox-config.toml", "644"],
|
||||
[
|
||||
"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",
|
||||
],
|
||||
]
|
||||
|
||||
25
Dockerfile
@@ -1,14 +1,23 @@
|
||||
FROM alpine:latest
|
||||
FROM alpine:3.17.1 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 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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
FROM alpine:3.17.1 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"]
|
||||
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 epi
|
||||
Copyright (c) 2020-2023 epi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
80
Makefile
Normal file
@@ -0,0 +1,80 @@
|
||||
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 debian
|
||||
touch debian/cargo.config
|
||||
cp debian/cargo.config .cargo/config.toml
|
||||
cargo build $(ARGS)
|
||||
|
||||
$(TARGET)/$(BIN).1.gz: $(TARGET)/$(BIN)
|
||||
help2man --no-info $< | gzip -c > $@.partial
|
||||
mv $@.partial $@
|
||||
53
Makefile.toml
Normal file
@@ -0,0 +1,53 @@
|
||||
# composite tasks
|
||||
[tasks.upgrade]
|
||||
dependencies = ["upgrade-deps", "update"]
|
||||
|
||||
[tasks.check]
|
||||
dependencies = ["fmt", "clippy", "test"]
|
||||
|
||||
# cleaning
|
||||
[tasks.clean-state]
|
||||
script = """
|
||||
rm ferox-*.state
|
||||
"""
|
||||
|
||||
# dependency management
|
||||
[tasks.upgrade-deps]
|
||||
command = "cargo"
|
||||
args = ["upgrade", "--exclude", "self_update"]
|
||||
|
||||
[tasks.update]
|
||||
command = "cargo"
|
||||
args = ["update"]
|
||||
|
||||
# clippy / lint
|
||||
[tasks.clippy]
|
||||
clear = true
|
||||
script = """
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
"""
|
||||
|
||||
[tasks.fmt]
|
||||
clear = true
|
||||
script = """
|
||||
cargo fmt --all
|
||||
"""
|
||||
|
||||
# tests
|
||||
[tasks.test]
|
||||
clear = true
|
||||
dependencies = ["test-local", "test-remote"]
|
||||
|
||||
[tasks.test-remote]
|
||||
condition = { env_set = ["CI"] }
|
||||
clear = true
|
||||
script = """
|
||||
cargo nextest run --all-features --all-targets --retries 4 --no-fail-fast
|
||||
"""
|
||||
|
||||
[tasks.test-local]
|
||||
condition = { env_not_set = ["CI"] }
|
||||
clear = true
|
||||
script = """
|
||||
cargo nextest run --all-features --all-targets --no-fail-fast --run-ignored all --retries 4
|
||||
"""
|
||||
889
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/actions/workflow/status/epi052/feroxbuster/.github/workflows/check.yml?branch=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,80 +45,72 @@
|
||||
<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
|
||||
|
||||
Install to a particular directory
|
||||
```
|
||||
curl -sL https://raw.githubusercontent.com/epi052/feroxbuster/master/install-nix.sh | bash
|
||||
curl -sL https://raw.githubusercontent.com/epi052/feroxbuster/main/install-nix.sh | bash -s $HOME/.local/bin
|
||||
```
|
||||
|
||||
#### Windows x86
|
||||
Install to current working directory
|
||||
```
|
||||
curl -sL https://raw.githubusercontent.com/epi052/feroxbuster/main/install-nix.sh | bash
|
||||
```
|
||||
|
||||
#### MacOS via Homebrew
|
||||
|
||||
```
|
||||
https://github.com/epi052/feroxbuster/releases/latest/download/x86-windows-feroxbuster.exe.zip
|
||||
Expand-Archive .\feroxbuster.zip
|
||||
.\feroxbuster\feroxbuster.exe -V
|
||||
brew install feroxbuster
|
||||
```
|
||||
|
||||
#### Windows x86_64
|
||||
@@ -121,313 +121,35 @@ Expand-Archive .\feroxbuster.zip
|
||||
.\feroxbuster\feroxbuster.exe -V
|
||||
```
|
||||
|
||||
### Snap Install
|
||||
|
||||
Install using `snap`
|
||||
#### Windows via Winget
|
||||
|
||||
```
|
||||
sudo snap install feroxbuster
|
||||
winget install epi052.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`
|
||||
#### Windows via Chocolatey
|
||||
|
||||
```
|
||||
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
|
||||
choco install feroxbuster
|
||||
```
|
||||
|
||||
🐧 [Linux](https://github.com/TGotwig/homebrew-linux-feroxbuster/blob/main/feroxbuster.rb)
|
||||
#### All others
|
||||
|
||||
```shell
|
||||
brew tap tgotwig/linux-feroxbuster
|
||||
brew install feroxbuster
|
||||
```
|
||||
Please refer the the [documentation](https://epi052.github.io/feroxbuster-docs/docs/).
|
||||
|
||||
### Cargo Install
|
||||
|
||||
`feroxbuster` is published on crates.io, making it easy to install if you already have rust installed on your system.
|
||||
### Updating feroxbuster (new in v2.9.1)
|
||||
|
||||
```
|
||||
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
|
||||
./feroxbuster --update
|
||||
```
|
||||
|
||||
## 🧰 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 +157,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 +175,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 +184,181 @@ 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`
|
||||
### Set the Content-Type of the body automatically with --data-json --data-urlencoded
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 --extract-links
|
||||
./feroxbuster -u http://127.1 --data-json '{"some": "payload"}'
|
||||
./feroxbuster -u http://127.1 --data-json @payload.json
|
||||
./feroxbuster -u http://127.1 --data-urlencoded 'some=payload'
|
||||
./feroxbuster -u http://127.1 --data-urlencoded @file.payload
|
||||
```
|
||||
|
||||
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>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://io.fi"><img src="https://avatars.githubusercontent.com/u/5235109?v=4?s=100" width="100px;" alt="Joona Hoikkala"/><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" valign="top" width="14.28%"><a href="https://github.com/jsav0"><img src="https://avatars.githubusercontent.com/u/20546041?v=4?s=100" width="100px;" alt="J Savage"/><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" valign="top" width="14.28%"><a href="http://www.tgotwig.dev"><img src="https://avatars.githubusercontent.com/u/30773779?v=4?s=100" width="100px;" alt="Thomas Gotwig"/><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" valign="top" width="14.28%"><a href="https://github.com/spikecodes"><img src="https://avatars.githubusercontent.com/u/19519553?v=4?s=100" width="100px;" alt="Spike"/><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" valign="top" width="14.28%"><a href="https://github.com/evanrichter"><img src="https://avatars.githubusercontent.com/u/330292?v=4?s=100" width="100px;" alt="Evan Richter"/><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" valign="top" width="14.28%"><a href="https://github.com/mzpqnxow"><img src="https://avatars.githubusercontent.com/u/8016228?v=4?s=100" width="100px;" alt="AG"/><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" valign="top" width="14.28%"><a href="https://n-thumann.de/"><img src="https://avatars.githubusercontent.com/u/46975855?v=4?s=100" width="100px;" alt="Nicolas Thumann"/><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" valign="top" width="14.28%"><a href="https://github.com/tomtastic"><img src="https://avatars.githubusercontent.com/u/302127?v=4?s=100" width="100px;" alt="Tom Matthews"/><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" valign="top" width="14.28%"><a href="https://github.com/bsysop"><img src="https://avatars.githubusercontent.com/u/9998303?v=4?s=100" width="100px;" alt="bsysop"/><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" valign="top" width="14.28%"><a href="http://bpsizemore.me"><img src="https://avatars.githubusercontent.com/u/11645898?v=4?s=100" width="100px;" alt="Brian Sizemore"/><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" valign="top" width="14.28%"><a href="https://pwn.by/noraj"><img src="https://avatars.githubusercontent.com/u/16578570?v=4?s=100" width="100px;" alt="Alexandre ZANNI"/><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" valign="top" width="14.28%"><a href="https://github.com/craig"><img src="https://avatars.githubusercontent.com/u/99729?v=4?s=100" width="100px;" alt="Craig"/><br /><sub><b>Craig</b></sub></a><br /><a href="#infra-craig" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.reddit.com/u/EONRaider"><img src="https://avatars.githubusercontent.com/u/15611424?v=4?s=100" width="100px;" alt="EONRaider"/><br /><sub><b>EONRaider</b></sub></a><br /><a href="#infra-EONRaider" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wtwver"><img src="https://avatars.githubusercontent.com/u/53866088?v=4?s=100" width="100px;" alt="wtwver"/><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" valign="top" width="14.28%"><a href="https://tib3rius.com"><img src="https://avatars.githubusercontent.com/u/48113936?v=4?s=100" width="100px;" alt="Tib3rius"/><br /><sub><b>Tib3rius</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ATib3rius" title="Bug reports">🐛</a> <a href="#ideas-Tib3rius" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xdf"><img src="https://avatars.githubusercontent.com/u/1489045?v=4?s=100" width="100px;" alt="0xdf"/><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" valign="top" width="14.28%"><a href="http://secure77.de"><img src="https://avatars.githubusercontent.com/u/31564517?v=4?s=100" width="100px;" alt="secure-77"/><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" valign="top" width="14.28%"><a href="https://github.com/sbrun"><img src="https://avatars.githubusercontent.com/u/7712154?v=4?s=100" width="100px;" alt="Sophie Brun"/><br /><sub><b>Sophie Brun</b></sub></a><br /><a href="#infra-sbrun" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/black-A"><img src="https://avatars.githubusercontent.com/u/30686803?v=4?s=100" width="100px;" alt="black-A"/><br /><sub><b>black-A</b></sub></a><br /><a href="#ideas-black-A" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dinosn"><img src="https://avatars.githubusercontent.com/u/3851678?v=4?s=100" width="100px;" alt="Nicolas Krassas"/><br /><sub><b>Nicolas Krassas</b></sub></a><br /><a href="#ideas-dinosn" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/N0ur5"><img src="https://avatars.githubusercontent.com/u/24260009?v=4?s=100" width="100px;" alt="N0ur5"/><br /><sub><b>N0ur5</b></sub></a><br /><a href="#ideas-N0ur5" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/issues?q=author%3AN0ur5" title="Bug reports">🐛</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/moscowchill"><img src="https://avatars.githubusercontent.com/u/72578879?v=4?s=100" width="100px;" alt="mchill"/><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" valign="top" width="14.28%"><a href="http://BitThr3at.github.io"><img src="https://avatars.githubusercontent.com/u/45028933?v=4?s=100" width="100px;" alt="Naman"/><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" valign="top" width="14.28%"><a href="https://github.com/Sicks3c"><img src="https://avatars.githubusercontent.com/u/32225186?v=4?s=100" width="100px;" alt="Ayoub Elaich"/><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" valign="top" width="14.28%"><a href="https://github.com/HenryHoggard"><img src="https://avatars.githubusercontent.com/u/1208121?v=4?s=100" width="100px;" alt="Henry"/><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" valign="top" width="14.28%"><a href="https://github.com/SleepiPanda"><img src="https://avatars.githubusercontent.com/u/6428561?v=4?s=100" width="100px;" alt="SleepiPanda"/><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" valign="top" width="14.28%"><a href="https://github.com/uBadRequest"><img src="https://avatars.githubusercontent.com/u/47282747?v=4?s=100" width="100px;" alt="Bad Requests"/><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" valign="top" width="14.28%"><a href="https://home.dnaka91.rocks"><img src="https://avatars.githubusercontent.com/u/36804488?v=4?s=100" width="100px;" alt="Dominik Nakamura"/><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" valign="top" width="14.28%"><a href="https://github.com/hunter0x8"><img src="https://avatars.githubusercontent.com/u/46222314?v=4?s=100" width="100px;" alt="Muhammad Ahsan"/><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" valign="top" width="14.28%"><a href="https://github.com/cortantief"><img src="https://avatars.githubusercontent.com/u/34527333?v=4?s=100" width="100px;" alt="cortantief"/><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" valign="top" width="14.28%"><a href="https://github.com/dsaxton"><img src="https://avatars.githubusercontent.com/u/2658661?v=4?s=100" width="100px;" alt="Daniel Saxton"/><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" valign="top" width="14.28%"><a href="https://github.com/n0kovo"><img src="https://avatars.githubusercontent.com/u/16690056?v=4?s=100" width="100px;" alt="n0kovo"/><br /><sub><b>n0kovo</b></sub></a><br /><a href="#ideas-n0kovo" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/issues?q=author%3An0kovo" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://ring0.lol"><img src="https://avatars.githubusercontent.com/u/1893909?v=4?s=100" width="100px;" alt="Justin Steven"/><br /><sub><b>Justin Steven</b></sub></a><br /><a href="#ideas-justinsteven" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/7047payloads"><img src="https://avatars.githubusercontent.com/u/95562424?v=4?s=100" width="100px;" alt="7047payloads"/><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" valign="top" width="14.28%"><a href="https://github.com/unkn0wnsyst3m"><img src="https://avatars.githubusercontent.com/u/21272239?v=4?s=100" width="100px;" alt="unkn0wnsyst3m"/><br /><sub><b>unkn0wnsyst3m</b></sub></a><br /><a href="#ideas-unkn0wnsyst3m" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://ironwort.me/"><img src="https://avatars.githubusercontent.com/u/15280042?v=4?s=100" width="100px;" alt="0x08"/><br /><sub><b>0x08</b></sub></a><br /><a href="#ideas-its0x08" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/MD-Levitan"><img src="https://avatars.githubusercontent.com/u/12116508?v=4?s=100" width="100px;" alt="kusok"/><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" valign="top" width="14.28%"><a href="https://github.com/godylockz"><img src="https://avatars.githubusercontent.com/u/81207744?v=4?s=100" width="100px;" alt="godylockz"/><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" valign="top" width="14.28%"><a href="http://ryanmontgomery.me"><img src="https://avatars.githubusercontent.com/u/44453666?v=4?s=100" width="100px;" alt="Ryan Montgomery"/><br /><sub><b>Ryan Montgomery</b></sub></a><br /><a href="#ideas-0dayCTF" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/IppSec"><img src="https://avatars.githubusercontent.com/u/24677271?v=4?s=100" width="100px;" alt="ippsec"/><br /><sub><b>ippsec</b></sub></a><br /><a href="#ideas-ippsec" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gtjamesa"><img src="https://avatars.githubusercontent.com/u/2078364?v=4?s=100" width="100px;" alt="James"/><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" valign="top" width="14.28%"><a href="https://twitter.com/Jhaddix"><img src="https://avatars.githubusercontent.com/u/3488554?v=4?s=100" width="100px;" alt="Jason Haddix"/><br /><sub><b>Jason Haddix</b></sub></a><br /><a href="#ideas-jhaddix" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/issues?q=author%3Ajhaddix" title="Bug reports">🐛</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThisLimn0"><img src="https://avatars.githubusercontent.com/u/67125885?v=4?s=100" width="100px;" alt="Limn0"/><br /><sub><b>Limn0</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AThisLimn0" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xdf223"><img src="https://avatars.githubusercontent.com/u/76954092?v=4?s=100" width="100px;" alt="0xdf"/><br /><sub><b>0xdf</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3A0xdf223" title="Bug reports">🐛</a> <a href="#ideas-0xdf223" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Flangyver"><img src="https://avatars.githubusercontent.com/u/59575870?v=4?s=100" width="100px;" alt="Flangyver"/><br /><sub><b>Flangyver</b></sub></a><br /><a href="#ideas-Flangyver" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DonatoReis"><img src="https://avatars.githubusercontent.com/u/93531354?v=4?s=100" width="100px;" alt="PeakyBlinder"/><br /><sub><b>PeakyBlinder</b></sub></a><br /><a href="#ideas-DonatoReis" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://postmodern.github.io/"><img src="https://avatars.githubusercontent.com/u/12671?v=4?s=100" width="100px;" alt="Postmodern"/><br /><sub><b>Postmodern</b></sub></a><br /><a href="#ideas-postmodern" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/herrcykel"><img src="https://avatars.githubusercontent.com/u/1936757?v=4?s=100" width="100px;" alt="O"/><br /><sub><b>O</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=herrcykel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://udoprog.github.io/"><img src="https://avatars.githubusercontent.com/u/111092?v=4?s=100" width="100px;" alt="John-John Tedro"/><br /><sub><b>John-John Tedro</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=udoprog" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kmanc"><img src="https://avatars.githubusercontent.com/u/14863147?v=4?s=100" width="100px;" alt="kmanc"/><br /><sub><b>kmanc</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Akmanc" title="Bug reports">🐛</a> <a href="https://github.com/epi052/feroxbuster/commits?author=kmanc" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hakdogpinas"><img src="https://avatars.githubusercontent.com/u/71529469?v=4?s=100" width="100px;" alt="hakdogpinas"/><br /><sub><b>hakdogpinas</b></sub></a><br /><a href="#ideas-hakdogpinas" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/duokebei"><img src="https://avatars.githubusercontent.com/u/75022552?v=4?s=100" width="100px;" alt="多可悲"/><br /><sub><b>多可悲</b></sub></a><br /><a href="#ideas-duokebei" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://blog.ah34.net/"><img src="https://avatars.githubusercontent.com/u/58670593?v=4?s=100" width="100px;" alt="Aidan Hall"/><br /><sub><b>Aidan Hall</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=aidanhall34" title="Code">💻</a> <a href="#infra-aidanhall34" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://hachyderm.io/@JohnnyCiocca"><img src="https://avatars.githubusercontent.com/u/6473725?v=4?s=100" width="100px;" alt="João Ciocca"/><br /><sub><b>João Ciocca</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Ajoaociocca" title="Bug reports">🐛</a> <a href="#ideas-joaociocca" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/f3rn0s"><img src="https://avatars.githubusercontent.com/u/1351279?v=4?s=100" width="100px;" alt="f3rn0s"/><br /><sub><b>f3rn0s</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Af3rn0s" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://sth.sh"><img src="https://avatars.githubusercontent.com/u/2099767?v=4?s=100" width="100px;" alt="LongCat"/><br /><sub><b>LongCat</b></sub></a><br /><a href="#ideas-pich4ya" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xaeroborg"><img src="https://avatars.githubusercontent.com/u/33274680?v=4?s=100" width="100px;" alt="xaeroborg"/><br /><sub><b>xaeroborg</b></sub></a><br /><a href="#ideas-xaeroborg" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Luoooio"><img src="https://avatars.githubusercontent.com/u/26653157?v=4?s=100" width="100px;" alt="Luoooio"/><br /><sub><b>Luoooio</b></sub></a><br /><a href="#ideas-Luoooio" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://petruknisme.com"><img src="https://avatars.githubusercontent.com/u/6284204?v=4?s=100" width="100px;" alt="Aan"/><br /><sub><b>Aan</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=aancw" title="Code">💻</a> <a href="#infra-aancw" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-aancw" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/imBigo"><img src="https://avatars.githubusercontent.com/u/54672433?v=4?s=100" width="100px;" alt="Simon"/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AimBigo" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://acut3.github.io/"><img src="https://avatars.githubusercontent.com/u/17295243?v=4?s=100" width="100px;" alt="Nicolas Christin"/><br /><sub><b>Nicolas Christin</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Aacut3" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DrorDvash"><img src="https://avatars.githubusercontent.com/u/8413651?v=4?s=100" width="100px;" alt="DrDv"/><br /><sub><b>DrDv</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3ADrorDvash" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aroly"><img src="https://avatars.githubusercontent.com/u/1257705?v=4?s=100" width="100px;" alt="Antoine Roly"/><br /><sub><b>Antoine Roly</b></sub></a><br /><a href="#ideas-aroly" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://lavafroth.is-a.dev"><img src="https://avatars.githubusercontent.com/u/107522312?v=4?s=100" width="100px;" alt="Himadri Bhattacharjee"/><br /><sub><b>Himadri Bhattacharjee</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=lavafroth" title="Code">💻</a> <a href="#ideas-lavafroth" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AkechiShiro"><img src="https://avatars.githubusercontent.com/u/14914796?v=4?s=100" width="100px;" alt="Samy Lahfa"/><br /><sub><b>Samy Lahfa</b></sub></a><br /><a href="#ideas-AkechiShiro" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sectroyer"><img src="https://avatars.githubusercontent.com/u/6706818?v=4?s=100" width="100px;" alt="sectroyer"/><br /><sub><b>sectroyer</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asectroyer" title="Bug reports">🐛</a> <a href="#ideas-sectroyer" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://medium.com/@b3rm1nG"><img src="https://avatars.githubusercontent.com/u/19836003?v=4?s=100" width="100px;" alt="ktecv2000"/><br /><sub><b>ktecv2000</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Aktecv2000" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://untrue.me"><img src="https://avatars.githubusercontent.com/u/56048157?v=4?s=100" width="100px;" alt="Andrea De Murtas"/><br /><sub><b>Andrea De Murtas</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=andreademurtas" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sawmj"><img src="https://avatars.githubusercontent.com/u/30024085?v=4?s=100" width="100px;" alt="sawmj"/><br /><sub><b>sawmj</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asawmj" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/devx00"><img src="https://avatars.githubusercontent.com/u/6897405?v=4?s=100" width="100px;" alt="Zach Hanson"/><br /><sub><b>Zach Hanson</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Adevx00" title="Bug reports">🐛</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ocervell"><img src="https://avatars.githubusercontent.com/u/9629314?v=4?s=100" width="100px;" alt="Olivier Cervello"/><br /><sub><b>Olivier Cervello</b></sub></a><br /><a href="#ideas-ocervell" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RavySena"><img src="https://avatars.githubusercontent.com/u/67729597?v=4?s=100" width="100px;" alt="RavySena"/><br /><sub><b>RavySena</b></sub></a><br /><a href="#ideas-RavySena" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/stuhlmann"><img src="https://avatars.githubusercontent.com/u/11061864?v=4?s=100" width="100px;" alt="Florian Stuhlmann"/><br /><sub><b>Florian Stuhlmann</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Astuhlmann" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Mister7F"><img src="https://avatars.githubusercontent.com/u/35213773?v=4?s=100" width="100px;" alt="Mister7F"/><br /><sub><b>Mister7F</b></sub></a><br /><a href="#ideas-Mister7F" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/manugramm"><img src="https://avatars.githubusercontent.com/u/145961515?v=4?s=100" width="100px;" alt="manugramm"/><br /><sub><b>manugramm</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Amanugramm" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ArthurMuraro"><img src="https://avatars.githubusercontent.com/u/73059809?v=4?s=100" width="100px;" alt="ArthurMuraro"/><br /><sub><b>ArthurMuraro</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AArthurMuraro" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/amiremami"><img src="https://avatars.githubusercontent.com/u/15929497?v=4?s=100" width="100px;" alt="Shadow"/><br /><sub><b>Shadow</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Aamiremami" title="Bug reports">🐛</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dirhamgithub"><img src="https://avatars.githubusercontent.com/u/115349974?v=4?s=100" width="100px;" alt="dirhamgithub"/><br /><sub><b>dirhamgithub</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Adirhamgithub" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/FieldOfRice"><img src="https://avatars.githubusercontent.com/u/85353?v=4?s=100" width="100px;" alt="FieldOfRice"/><br /><sub><b>FieldOfRice</b></sub></a><br /><a href="#infra-FieldOfRice" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/NotoriousRebel"><img src="https://avatars.githubusercontent.com/u/36310667?v=4?s=100" width="100px;" alt="Matt"/><br /><sub><b>Matt</b></sub></a><br /><a href="#ideas-NotoriousRebel" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tritoke"><img src="https://avatars.githubusercontent.com/u/34941249?v=4?s=100" width="100px;" alt="Sam Leonard"/><br /><sub><b>Sam Leonard</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=tritoke" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rew1nter"><img src="https://avatars.githubusercontent.com/u/64508791?v=4?s=100" width="100px;" alt="Rewinter"/><br /><sub><b>Rewinter</b></sub></a><br /><a href="#ideas-rew1nter" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/deadloot"><img src="https://avatars.githubusercontent.com/u/92878901?v=4?s=100" width="100px;" alt="deadloot"/><br /><sub><b>deadloot</b></sub></a><br /><a href="#ideas-deadloot" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Spidle"><img src="https://avatars.githubusercontent.com/u/90011249?v=4?s=100" width="100px;" alt="Spidle"/><br /><sub><b>Spidle</b></sub></a><br /><a href="#ideas-Spidle" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JulianGR"><img src="https://avatars.githubusercontent.com/u/53094530?v=4?s=100" width="100px;" alt="Julián Gómez"/><br /><sub><b>Julián Gómez</b></sub></a><br /><a href="#ideas-JulianGR" title="Ideas, Planning, & Feedback">🤔</a> <a href="#infra-JulianGR" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=JulianGR" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/soutzis"><img src="https://avatars.githubusercontent.com/u/25797286?v=4?s=100" width="100px;" alt="Petros"/><br /><sub><b>Petros</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Asoutzis" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sitiom"><img src="https://avatars.githubusercontent.com/u/56180050?v=4?s=100" width="100px;" alt="Ryan"/><br /><sub><b>Ryan</b></sub></a><br /><a href="#infra-sitiom" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/epi052/feroxbuster/commits?author=sitiom" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wikamp-collaborator"><img src="https://avatars.githubusercontent.com/u/147445097?v=4?s=100" width="100px;" alt="wikamp-collaborator"/><br /><sub><b>wikamp-collaborator</b></sub></a><br /><a href="#ideas-wikamp-collaborator" title="Ideas, Planning, & Feedback">🤔</a> <a href="#infra-wikamp-collaborator" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://lino.codes"><img src="https://avatars.githubusercontent.com/u/123986259?v=4?s=100" width="100px;" alt="Lino"/><br /><sub><b>Lino</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AL1-0" title="Bug reports">🐛</a> <a href="#ideas-L1-0" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://danthesalmon.com"><img src="https://avatars.githubusercontent.com/u/3712226?v=4?s=100" width="100px;" alt="Dan Salmon"/><br /><sub><b>Dan Salmon</b></sub></a><br /><a href="#ideas-sa7mon" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/swordfish0x0"><img src="https://avatars.githubusercontent.com/u/21209130?v=4?s=100" width="100px;" alt="swordfish0x0"/><br /><sub><b>swordfish0x0</b></sub></a><br /><a href="#ideas-swordfish0x0" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/libklein"><img src="https://avatars.githubusercontent.com/u/42714034?v=4?s=100" width="100px;" alt="Patrick Klein"/><br /><sub><b>Patrick Klein</b></sub></a><br /><a href="#ideas-libklein" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Raymond-JV"><img src="https://avatars.githubusercontent.com/u/23642921?v=4?s=100" width="100px;" alt="Raymond"/><br /><sub><b>Raymond</b></sub></a><br /><a href="#ideas-Raymond-JV" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zer0x64"><img src="https://avatars.githubusercontent.com/u/17575242?v=4?s=100" width="100px;" alt="zer0x64"/><br /><sub><b>zer0x64</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=zer0x64" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://zar3bski.com"><img src="https://avatars.githubusercontent.com/u/22128014?v=4?s=100" width="100px;" alt="zar3bski"/><br /><sub><b>zar3bski</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=zar3bski" title="Code">💻</a> <a href="#ideas-zar3bski" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/karanabe"><img src="https://avatars.githubusercontent.com/u/152078880?v=4?s=100" width="100px;" alt="karanabe"/><br /><sub><b>karanabe</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/commits?author=karanabe" title="Documentation">📖</a> <a href="https://github.com/epi052/feroxbuster/commits?author=karanabe" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/h121h"><img src="https://avatars.githubusercontent.com/u/616758?v=4?s=100" width="100px;" alt="h121h"/><br /><sub><b>h121h</b></sub></a><br /><a href="#ideas-h121h" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0i37"><img src="https://avatars.githubusercontent.com/u/22872513?v=4?s=100" width="100px;" alt="s0i37"/><br /><sub><b>s0i37</b></sub></a><br /><a href="#ideas-s0i37" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wilco375"><img src="https://avatars.githubusercontent.com/u/7385023?v=4?s=100" width="100px;" alt="Wilco"/><br /><sub><b>Wilco</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3Awilco375" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/HenriBom"><img src="https://avatars.githubusercontent.com/u/46447744?v=4?s=100" width="100px;" alt="HenriBom"/><br /><sub><b>HenriBom</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3AHenriBom" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0x7274"><img src="https://avatars.githubusercontent.com/u/85586890?v=4?s=100" width="100px;" alt="R̝͖̱͖͕̤̰̯͙ͫ͒̀ͮȁ̤͔̝̘̪̻͕̝̖ͧͪͤu̗̠̜̩̗͇͑̀ͣ̃͂̔͂c̫͔͚̲̬̓̂̿͌̿͊̐͗h͚̲̤̟͓̟̥̊ͬͪ̏̍̍ T̟̜̞͉͙̙ͣ́ͪ͗̓̇ͭo͍̰͎̼͓̟̽ͧ̓̉ͬ̐͐b͇̖̳̫̰̗̭͍ͧ̄̄̌̈i̙̪̤̝̟͓̹̋̽͋̀ͧ̒a͕̭̱͎̪̦̤ͤ͊̊̑ͣ̄s̪̯͖̰̯͍ͫ̋͑̄ͭͅͅ"/><br /><sub><b>R̝͖̱͖͕̤̰̯͙ͫ͒̀ͮȁ̤͔̝̘̪̻͕̝̖ͧͪͤu̗̠̜̩̗͇͑̀ͣ̃͂̔͂c̫͔͚̲̬̓̂̿͌̿͊̐͗h͚̲̤̟͓̟̥̊ͬͪ̏̍̍ T̟̜̞͉͙̙ͣ́ͪ͗̓̇ͭo͍̰͎̼͓̟̽ͧ̓̉ͬ̐͐b͇̖̳̫̰̗̭͍ͧ̄̄̌̈i̙̪̤̝̟͓̹̋̽͋̀ͧ̒a͕̭̱͎̪̦̤ͤ͊̊̑ͣ̄s̪̯͖̰̯͍ͫ̋͑̄ͭͅͅ</b></sub></a><br /><a href="https://github.com/epi052/feroxbuster/issues?q=author%3A0x7274" title="Bug reports">🐛</a> <a href="#ideas-0x7274" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/epi052/feroxbuster/commits?author=0x7274" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/4FunAndProfit"><img src="https://avatars.githubusercontent.com/u/174417079?v=4?s=100" width="100px;" alt="4FunAndProfit"/><br /><sub><b>4FunAndProfit</b></sub></a><br /><a href="#ideas-4FunAndProfit" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lidorelias3"><img src="https://avatars.githubusercontent.com/u/41958137?v=4?s=100" width="100px;" alt="lidorelias3"/><br /><sub><b>lidorelias3</b></sub></a><br /><a href="#ideas-lidorelias3" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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!
|
||||
|
||||
73
build.rs
@@ -1,17 +1,80 @@
|
||||
extern crate clap;
|
||||
use std::fs::{copy, create_dir_all, OpenOptions};
|
||||
use std::io::{Read, Seek, Write};
|
||||
|
||||
use clap::Shell;
|
||||
use clap_complete::{generate_to, shells};
|
||||
|
||||
include!("src/parser.rs");
|
||||
|
||||
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::Fish, &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!("{outdir}/feroxbuster.bash"))
|
||||
.expect("Couldn't open bash completion script");
|
||||
|
||||
bash_file
|
||||
.read_to_string(&mut contents)
|
||||
.expect("Couldn't read bash completion script");
|
||||
|
||||
contents = contents.replace("default feroxbuster", "default -o plusdirs feroxbuster");
|
||||
|
||||
bash_file
|
||||
.rewind()
|
||||
.expect("Couldn't seek to position 0 in bash completion script");
|
||||
|
||||
bash_file
|
||||
.write_all(contents.as_bytes())
|
||||
.expect("Couldn't write updated bash completion script to disk");
|
||||
|
||||
// hunter0x8 let me know that when installing via cargo, it would be nice if we dropped a
|
||||
// config file during the build process. The following code will place an example config in
|
||||
// the user's configuration directory
|
||||
// - linux: $XDG_CONFIG_HOME or $HOME/.config
|
||||
// - macOS: $HOME/Library/Application Support
|
||||
// - windows: {FOLDERID_RoamingAppData}
|
||||
let mut config_dir = dirs::config_dir().expect("Couldn't resolve user's config directory");
|
||||
config_dir = config_dir.join("feroxbuster"); // $HOME/.config/feroxbuster
|
||||
|
||||
if !config_dir.exists() {
|
||||
// recursively create the feroxbuster directory and all of its parent components if
|
||||
// they are missing
|
||||
if create_dir_all(&config_dir).is_err() {
|
||||
// only copy the config file when we're not running in the CI/CD pipeline
|
||||
// which fails with permission denied
|
||||
eprintln!("Couldn't create one or more directories needed to copy the config file");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// hard-coding config name here to not rely on the crate we're building, if DEFAULT_CONFIG_NAME
|
||||
// ever changes, this will need to be updated
|
||||
let config_file = config_dir.join("ferox-config.toml");
|
||||
|
||||
if !config_file.exists() {
|
||||
// config file doesn't exist, add it to the config directory
|
||||
if copy("ferox-config.toml.example", config_file).is_err() {
|
||||
eprintln!("Couldn't copy example config into config directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
79
choco_package/feroxbuster.nuspec
Normal file
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>feroxbuster</id>
|
||||
<version>2.8.0</version>
|
||||
<packageSourceUrl>https://github.com/epi052/feroxbuster/releases/</packageSourceUrl>
|
||||
<owners>epi052</owners>
|
||||
<title>feroxbuster (Install)</title>
|
||||
<authors>epi052</authors>
|
||||
<projectUrl>https://github.com/epi052/feroxbuster</projectUrl>
|
||||
<iconUrl>https://rawcdn.githack.com/epi052/feroxbuster/2d381e7e057ce60c580b324dd36c9abaf30c2ec7/img/logo/logo.png</iconUrl>
|
||||
<copyright>2020-2023</copyright>
|
||||
<licenseUrl>https://github.com/epi052/feroxbuster/blob/main/LICENSE</licenseUrl>
|
||||
<requireLicenseAcceptance>true</requireLicenseAcceptance>
|
||||
<projectSourceUrl>https://github.com/epi052/feroxbuster</projectSourceUrl>
|
||||
<docsUrl>https://epi052.github.io/feroxbuster-docs/docs/</docsUrl>
|
||||
<!--<mailingListUrl></mailingListUrl>-->
|
||||
<bugTrackerUrl>https://github.com/epi052/feroxbuster/issues</bugTrackerUrl>
|
||||
<tags>content-discovery pentesting-tool url-bruteforcer</tags>
|
||||
<summary>A simple, fast, recursive content discovery tool written in Rust</summary>
|
||||
<description>
|
||||
A simple, fast, recursive content discovery tool written in Rust
|
||||
[](https://github.com/epi052/feroxbuster)
|
||||
|
||||
## 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.
|
||||
|
||||
## What's it do tho?
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
This section will cover the minimum amount of information to get up and running with feroxbuster. Please refer the the [documentation](https://epi052.github.io/feroxbuster-docs/docs/), as it's much more comprehensive.
|
||||
|
||||
### Installation
|
||||
|
||||
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.
|
||||
|
||||
#### All others Docs
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
./feroxbuster -u http://127.1 -x pdf -x js,html -x php txt json,docx
|
||||
```
|
||||
|
||||
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.
|
||||
</description>
|
||||
<!-- <releaseNotes>__REPLACE_OR_REMOVE__MarkDown_Okay</releaseNotes> -->
|
||||
</metadata>
|
||||
<files>
|
||||
<!-- this section controls what actually gets packaged into the Chocolatey package -->
|
||||
<file src="tools\**" target="tools" />
|
||||
</files>
|
||||
</package>
|
||||
26
choco_package/legal/LICENSE.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
From: https://github.com/epi052/feroxbuster/blob/main/LICENSE
|
||||
|
||||
LICENSE
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2023 epi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
5
choco_package/legal/VERIFICATION.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
VERIFICATION
|
||||
|
||||
checksum -t sha512 -f .\x86-windows-feroxbuster.exe.zip
|
||||
checksum -t sha512 -f .\x86_64-windows-feroxbuster.exe.zip
|
||||
27
choco_package/tools/chocolateyinstall.ps1
Normal file
@@ -0,0 +1,27 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
|
||||
$version = '2.8.0'
|
||||
$url = "https://github.com/epi052/feroxbuster/releases/download/v$version/x86-windows-feroxbuster.exe.zip"
|
||||
$url64 = "https://github.com/epi052/feroxbuster/releases/download/v$version/x86_64-windows-feroxbuster.exe.zip"
|
||||
|
||||
$packageArgs = @{
|
||||
packageName = $env:ChocolateyPackageName
|
||||
unzipLocation = $toolsDir
|
||||
fileType = 'exe' #only one of these: exe, msi, msu
|
||||
url = $url
|
||||
url64bit = $url64
|
||||
#file = $fileLocation
|
||||
|
||||
softwareName = 'feroxbuster*'
|
||||
|
||||
# Checksums are now required as of 0.10.0.
|
||||
# To determine checksums, you can get that from the original site if provided.
|
||||
# You can also use checksum.exe (choco install checksum) and use it
|
||||
# e.g. checksum -t sha256 -f path\to\file
|
||||
checksum = 'e5cac59c737260233903a17706a68bac11fe0d7a15169e1c5a9637cc221e7230fd6ddbfc1a7243833dde6472ad053c033449ca8338164654f7354363da54ba88'
|
||||
checksumType = 'sha512'
|
||||
checksum64 = 'cce58d6eacef7e12c31076f5a00fee9742a4e3fdfc69d807d98736200e50469f77359978e137ecafd87b14460845c65c6808d1f8b23ae561f7e7c637e355dee3'
|
||||
checksumType64= 'sha512'
|
||||
}
|
||||
Install-ChocolateyZipPackage @packageArgs # https://docs.chocolatey.org/en-us/create/functions/install-chocolateyzippackage
|
||||
47
choco_package/tools/chocolateyuninstall.ps1
Normal file
@@ -0,0 +1,47 @@
|
||||
$ErrorActionPreference = 'Stop' # stop on all errors
|
||||
$packageArgs = @{
|
||||
packageName = $env:ChocolateyPackageName
|
||||
softwareName = 'feroxbuster*' #part or all of the Display Name as you see it in Programs and Features. It should be enough to be unique
|
||||
fileType = 'exe' #only one of these: MSI or EXE (ignore MSU for now)
|
||||
}
|
||||
|
||||
# Get-UninstallRegistryKey is new to 0.9.10, if supporting 0.9.9.x and below,
|
||||
# take a dependency on "chocolatey-core.extension" in your nuspec file.
|
||||
# This is only a fuzzy search if $softwareName includes '*'. Otherwise it is
|
||||
# exact. In the case of versions in key names, we recommend removing the version
|
||||
# and using '*'.
|
||||
[array]$key = Get-UninstallRegistryKey -SoftwareName $packageArgs['softwareName']
|
||||
|
||||
if ($key.Count -eq 1) {
|
||||
$key | % {
|
||||
$packageArgs['file'] = "$($_.UninstallString)" #NOTE: You may need to split this if it contains spaces, see below
|
||||
|
||||
if ($packageArgs['fileType'] -eq 'MSI') {
|
||||
# The Product Code GUID is all that should be passed for MSI, and very
|
||||
# FIRST, because it comes directly after /x, which is already set in the
|
||||
# Uninstall-ChocolateyPackage msiargs (facepalm).
|
||||
$packageArgs['silentArgs'] = "$($_.PSChildName) $($packageArgs['silentArgs'])"
|
||||
|
||||
# Don't pass anything for file, it is ignored for msi (facepalm number 2)
|
||||
# Alternatively if you need to pass a path to an msi, determine that and
|
||||
# use it instead of the above in silentArgs, still very first
|
||||
$packageArgs['file'] = ''
|
||||
} else {
|
||||
# NOTES:
|
||||
# - You probably will need to sanitize $packageArgs['file'] as it comes from the registry and could be in a variety of fun but unusable formats
|
||||
# - Split args from exe in $packageArgs['file'] and pass those args through $packageArgs['silentArgs'] or ignore them
|
||||
# - Ensure you don't pass double quotes in $file (aka $packageArgs['file']) - otherwise you will get "Illegal characters in path when you attempt to run this"
|
||||
# - Review the code for auto-uninstaller for all of the fun things it does in sanitizing - https://github.com/chocolatey/choco/blob/bfe351b7d10c798014efe4bfbb100b171db25099/src/chocolatey/infrastructure.app/services/AutomaticUninstallerService.cs#L142-L192
|
||||
}
|
||||
|
||||
Uninstall-ChocolateyPackage @packageArgs
|
||||
}
|
||||
} elseif ($key.Count -eq 0) {
|
||||
Write-Warning "$packageName has already been uninstalled by other means."
|
||||
} elseif ($key.Count -gt 1) {
|
||||
Write-Warning "$($key.Count) matches found!"
|
||||
Write-Warning "To prevent accidental data loss, no programs will be uninstalled."
|
||||
Write-Warning "Please alert package maintainer the following keys were matched:"
|
||||
$key | % {Write-Warning "- $($_.DisplayName)"}
|
||||
}
|
||||
|
||||
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,28 +16,57 @@
|
||||
# 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"]
|
||||
# any subdomain of a domain provided to scope is implicitly allowed also.
|
||||
# so things like "api.other.com" and "sub.third.com" would also be considered
|
||||
# in-scope given the example config below.
|
||||
# scope = ["example.com", "other.com", "third.com"]
|
||||
# regex_denylist = ["/deny.*"]
|
||||
# no_recursion = true
|
||||
# add_slash = true
|
||||
# stdin = true
|
||||
# dont_filter = true
|
||||
# extract_links = true
|
||||
# depth = 1
|
||||
# limit_bars = 3
|
||||
# force_recursion = true
|
||||
# filter_size = [5174]
|
||||
# filter_regex = ["^ignore me$"]
|
||||
# filter_similar = ["https://somesite.com/soft404"]
|
||||
# filter_word_count = [993]
|
||||
# filter_line_count = [35, 36]
|
||||
# queries = [["name","value"], ["rick", "astley"]]
|
||||
# save_state = false
|
||||
# time_limit = "10m"
|
||||
# server_certs = ["/some/cert.pem", "/some/other/cert.pem"]
|
||||
# client_cert = "/some/client/cert.pem"
|
||||
# client_key = "/some/client/key.pem"
|
||||
# request_file = "/some/raw/request/file"
|
||||
# protocol = "http"
|
||||
# scan_dir_listings = true
|
||||
# unique = true
|
||||
# response_size_limit = 4194304
|
||||
|
||||
# headers can be specified on multiple lines or as an inline table
|
||||
#
|
||||
@@ -48,6 +77,7 @@
|
||||
# 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
|
||||
#
|
||||
# If you want to use [headers], UNCOMMENT the line below
|
||||
# [headers]
|
||||
# stuff = "things"
|
||||
# more = "headers"
|
||||
|
||||
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/logo/logo.png
Normal file
|
After Width: | Height: | Size: 2.0 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,65 @@
|
||||
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!"
|
||||
INSTALL_DIR="${1:-$(pwd)}"
|
||||
|
||||
echo "[+] Installing feroxbuster to ${INSTALL_DIR}!"
|
||||
|
||||
which unzip &>/dev/null
|
||||
if [ "$?" != "0" ]; then
|
||||
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" -d "${INSTALL_DIR}" >/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" -d "${INSTALL_DIR}" >/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" -d "${INSTALL_DIR}" >/dev/null
|
||||
rm "$LIN64_ZIP"
|
||||
fi
|
||||
|
||||
if [[ "$(fc-list NotoColorEmoji | wc -l)" -gt 0 ]]; 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)"
|
||||
|
||||
|
||||
chmod +x "${INSTALL_DIR}/feroxbuster"
|
||||
|
||||
echo "[+] Installed feroxbuster"
|
||||
echo " [-] path: ${INSTALL_DIR}/feroxbuster"
|
||||
echo " [-] version: $(${INSTALL_DIR}/feroxbuster -V | awk '{print $2}')"
|
||||
|
||||
@@ -14,82 +14,126 @@ _feroxbuster() {
|
||||
fi
|
||||
|
||||
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]' \
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'-u+[The target URL (required, unless \[--stdin || --resume-from || --request-file\] used)]:URL:_urls' \
|
||||
'--url=[The target URL (required, unless \[--stdin || --resume-from || --request-file\] 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' \
|
||||
'(-u --url)--request-file=[Raw HTTP request file to use as a template for all requests]:REQUEST_FILE:_files' \
|
||||
'(--data --data-json)--data-urlencoded=[Set -H '\''Content-Type\: application/x-www-form-urlencoded'\'', --data to <data-urlencoded> (supports @file) and -m to POST]:DATA:_default' \
|
||||
'(--data --data-urlencoded)--data-json=[Set -H '\''Content-Type\: application/json'\'', --data to <data-json> (supports @file) and -m to POST]:DATA:_default' \
|
||||
'-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:_default' \
|
||||
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default\: --status-codes value)]:REPLAY_CODE:_default' \
|
||||
'-a+[Sets the User-Agent (default\: feroxbuster/2.13.0)]:USER_AGENT:_default' \
|
||||
'--user-agent=[Sets the User-Agent (default\: feroxbuster/2.13.0)]:USER_AGENT:_default' \
|
||||
'*-x+[File extension(s) to search for (ex\: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex\: @ext.txt)]:FILE_EXTENSION:_default' \
|
||||
'*--extensions=[File extension(s) to search for (ex\: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex\: @ext.txt)]:FILE_EXTENSION:_default' \
|
||||
'*-m+[Which HTTP request method(s) should be sent (default\: GET)]:HTTP_METHODS:_default' \
|
||||
'*--methods=[Which HTTP request method(s) should be sent (default\: GET)]:HTTP_METHODS:_default' \
|
||||
'--data=[Request'\''s Body; can read data from a file if input starts with an @ (ex\: @post.bin)]:DATA:_default' \
|
||||
'*-H+[Specify HTTP headers to be used in each request (ex\: -H Header\:val -H '\''stuff\: things'\'')]:HEADER:_default' \
|
||||
'*--headers=[Specify HTTP headers to be used in each request (ex\: -H Header\:val -H '\''stuff\: things'\'')]:HEADER:_default' \
|
||||
'*-b+[Specify HTTP cookies to be used in each request (ex\: -b stuff=things)]:COOKIE:_default' \
|
||||
'*--cookies=[Specify HTTP cookies to be used in each request (ex\: -b stuff=things)]:COOKIE:_default' \
|
||||
'*-Q+[Request'\''s URL query parameters (ex\: -Q token=stuff -Q secret=key)]:QUERY:_default' \
|
||||
'*--query=[Request'\''s URL query parameters (ex\: -Q token=stuff -Q secret=key)]:QUERY:_default' \
|
||||
'--protocol=[Specify the protocol to use when targeting via --request-file or --url with domain only (default\: https)]:PROTOCOL:_default' \
|
||||
'*--dont-scan=[URL(s) or Regex Pattern(s) to exclude from recursion/scans]:URL:_default' \
|
||||
'*--scope=[Additional domains/URLs to consider in-scope for scanning (in addition to current domain)]:URL:_default' \
|
||||
'*-S+[Filter out messages of a particular size (ex\: -S 5120 -S 4927,1970)]:SIZE:_default' \
|
||||
'*--filter-size=[Filter out messages of a particular size (ex\: -S 5120 -S 4927,1970)]:SIZE:_default' \
|
||||
'*-X+[Filter out messages via regular expression matching on the response'\''s body/headers (ex\: -X '\''^ignore me\$'\'')]:REGEX:_default' \
|
||||
'*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body/headers (ex\: -X '\''^ignore me\$'\'')]:REGEX:_default' \
|
||||
'*-W+[Filter out messages of a particular word count (ex\: -W 312 -W 91,82)]:WORDS:_default' \
|
||||
'*--filter-words=[Filter out messages of a particular word count (ex\: -W 312 -W 91,82)]:WORDS:_default' \
|
||||
'*-N+[Filter out messages of a particular line count (ex\: -N 20 -N 31,30)]:LINES:_default' \
|
||||
'*--filter-lines=[Filter out messages of a particular line count (ex\: -N 20 -N 31,30)]:LINES:_default' \
|
||||
'(-s --status-codes)*-C+[Filter out status codes (deny list) (ex\: -C 200 -C 401)]:STATUS_CODE:_default' \
|
||||
'(-s --status-codes)*--filter-status=[Filter out status codes (deny list) (ex\: -C 200 -C 401)]:STATUS_CODE:_default' \
|
||||
'*--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\: All Status Codes)]:STATUS_CODE:_default' \
|
||||
'*--status-codes=[Status Codes to include (allow list) (default\: All Status Codes)]:STATUS_CODE:_default' \
|
||||
'-T+[Number of seconds before a client'\''s request times out (default\: 7)]:SECONDS:_default' \
|
||||
'--timeout=[Number of seconds before a client'\''s request times out (default\: 7)]:SECONDS:_default' \
|
||||
'--server-certs=[Add custom root certificate(s) for servers with unknown certificates]:PEM|DER:_files' \
|
||||
'--client-cert=[Add a PEM encoded certificate for mutual authentication (mTLS)]:PEM:_files' \
|
||||
'--client-key=[Add a PEM encoded private key for mutual authentication (mTLS)]:PEM:_files' \
|
||||
'-t+[Number of concurrent threads (default\: 50)]:THREADS:_default' \
|
||||
'--threads=[Number of concurrent threads (default\: 50)]:THREADS:_default' \
|
||||
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default\: 4)]:RECURSION_DEPTH:_default' \
|
||||
'--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default\: 4)]:RECURSION_DEPTH:_default' \
|
||||
'-L+[Limit total number of concurrent scans (default\: 0, i.e. no limit)]:SCAN_LIMIT:_default' \
|
||||
'--scan-limit=[Limit total number of concurrent scans (default\: 0, i.e. no limit)]:SCAN_LIMIT:_default' \
|
||||
'(-v --verbosity -u --url)--parallel=[Run parallel feroxbuster instances (one child process per url passed via stdin)]:PARALLEL_SCANS:_default' \
|
||||
'(--auto-tune)--rate-limit=[Limit number of requests per second (per directory) (default\: 0, i.e. no limit)]:RATE_LIMIT:_default' \
|
||||
'--response-size-limit=[Limit size of response body to read in bytes (default\: 4MB)]:BYTES:_default' \
|
||||
'--time-limit=[Limit total run time of all scans (ex\: --time-limit 10m)]:TIME_SPEC:_default' \
|
||||
'-w+[Path or URL of the wordlist]:FILE:_files' \
|
||||
'--wordlist=[Path or URL of the wordlist]:FILE:_files' \
|
||||
'-B+[Automatically request likely backup extensions for "found" urls (default\: ~, .bak, .bak2, .old, .1)]' \
|
||||
'--collect-backups=[Automatically request likely backup extensions for "found" urls (default\: ~, .bak, .bak2, .old, .1)]' \
|
||||
'*-I+[File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)]:FILE_EXTENSION:_default' \
|
||||
'*--dont-collect=[File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)]:FILE_EXTENSION:_default' \
|
||||
'-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' \
|
||||
'--limit-bars=[Number of directory scan bars to show at any given time (default\: no limit)]:NUM_BARS_TO_SHOW:_default' \
|
||||
'(-u --url)--stdin[Read url(s) from STDIN]' \
|
||||
'(-p --proxy -k --insecure --burp-replay)--burp[Set --proxy to http\://127.0.0.1\:8080 and set --insecure to true]' \
|
||||
'(-P --replay-proxy -k --insecure)--burp-replay[Set --replay-proxy to http\://127.0.0.1\:8080 and set --insecure to true]' \
|
||||
'(--rate-limit --auto-bail)--smart[Set --auto-tune, --collect-words, and --collect-backups to true]' \
|
||||
'(--rate-limit --auto-bail)--thorough[Use the same settings as --smart and set --collect-extensions and --scan-dir-listings 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]' \
|
||||
'--unique[Only show unique responses]' \
|
||||
'-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]' \
|
||||
'(-n --no-recursion)--force-recursion[Force recursion attempts on all '\''found'\'' endpoints (still respects recursion depth)]' \
|
||||
'-e[Extract links from response body (html, javascript, etc...); make new requests based on findings (default\: true)]' \
|
||||
'--extract-links[Extract links from response body (html, javascript, etc...); make new requests based on findings (default\: true)]' \
|
||||
'--dont-extract-links[Don'\''t extract links from response body (html, javascript, etc...)]' \
|
||||
'(--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)]' \
|
||||
'-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]' \
|
||||
'--scan-dir-listings[Force scans to recurse into directory listings]' \
|
||||
'(--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 (or JSON w/ --json) + 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)]' \
|
||||
'-U[Update feroxbuster to the latest version]' \
|
||||
'--update[Update feroxbuster to the latest version]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
'-V[Print version]' \
|
||||
'--version[Print version]' \
|
||||
&& ret=0
|
||||
|
||||
}
|
||||
|
||||
(( $+functions[_feroxbuster_commands] )) ||
|
||||
_feroxbuster_commands() {
|
||||
local commands; commands=(
|
||||
|
||||
)
|
||||
local commands; commands=()
|
||||
_describe -t commands 'feroxbuster commands' commands "$@"
|
||||
}
|
||||
|
||||
_feroxbuster "$@"
|
||||
if [ "$funcstack[1]" = "_feroxbuster" ]; then
|
||||
_feroxbuster "$@"
|
||||
else
|
||||
compdef _feroxbuster feroxbuster
|
||||
fi
|
||||
|
||||
@@ -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,71 +21,114 @@ 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('-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('-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('-S', 'S', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
|
||||
[CompletionResult]::new('--filter-size', 'filter-size', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
|
||||
[CompletionResult]::new('-X', 'X', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')')
|
||||
[CompletionResult]::new('--filter-regex', 'filter-regex', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')')
|
||||
[CompletionResult]::new('-W', 'W', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
|
||||
[CompletionResult]::new('--filter-words', 'filter-words', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
|
||||
[CompletionResult]::new('-N', 'N', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
|
||||
[CompletionResult]::new('--filter-lines', 'filter-lines', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
|
||||
[CompletionResult]::new('-C', 'C', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
|
||||
[CompletionResult]::new('--filter-status', 'filter-status', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
|
||||
[CompletionResult]::new('-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('--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('-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('-u', '-u', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from || --request-file] used)')
|
||||
[CompletionResult]::new('--url', '--url', [CompletionResultType]::ParameterName, 'The target URL (required, unless [--stdin || --resume-from || --request-file] 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('--request-file', '--request-file', [CompletionResultType]::ParameterName, 'Raw HTTP request file to use as a template for all requests')
|
||||
[CompletionResult]::new('--data-urlencoded', '--data-urlencoded', [CompletionResultType]::ParameterName, 'Set -H ''Content-Type: application/x-www-form-urlencoded'', --data to <data-urlencoded> (supports @file) and -m to POST')
|
||||
[CompletionResult]::new('--data-json', '--data-json', [CompletionResultType]::ParameterName, 'Set -H ''Content-Type: application/json'', --data to <data-json> (supports @file) and -m to POST')
|
||||
[CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)')
|
||||
[CompletionResult]::new('--proxy', '--proxy', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)')
|
||||
[CompletionResult]::new('-P', '-P ', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
|
||||
[CompletionResult]::new('--replay-proxy', '--replay-proxy', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
|
||||
[CompletionResult]::new('-R', '-R ', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
|
||||
[CompletionResult]::new('--replay-codes', '--replay-codes', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
|
||||
[CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.13.0)')
|
||||
[CompletionResult]::new('--user-agent', '--user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/2.13.0)')
|
||||
[CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)')
|
||||
[CompletionResult]::new('--extensions', '--extensions', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)')
|
||||
[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('--protocol', '--protocol', [CompletionResultType]::ParameterName, 'Specify the protocol to use when targeting via --request-file or --url with domain only (default: https)')
|
||||
[CompletionResult]::new('--dont-scan', '--dont-scan', [CompletionResultType]::ParameterName, 'URL(s) or Regex Pattern(s) to exclude from recursion/scans')
|
||||
[CompletionResult]::new('--scope', '--scope', [CompletionResultType]::ParameterName, 'Additional domains/URLs to consider in-scope for scanning (in addition to current domain)')
|
||||
[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/headers (ex: -X ''^ignore me$'')')
|
||||
[CompletionResult]::new('--filter-regex', '--filter-regex', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body/headers (ex: -X ''^ignore me$'')')
|
||||
[CompletionResult]::new('-W', '-W ', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
|
||||
[CompletionResult]::new('--filter-words', '--filter-words', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
|
||||
[CompletionResult]::new('-N', '-N ', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
|
||||
[CompletionResult]::new('--filter-lines', '--filter-lines', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
|
||||
[CompletionResult]::new('-C', '-C ', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
|
||||
[CompletionResult]::new('--filter-status', '--filter-status', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
|
||||
[CompletionResult]::new('--filter-similar-to', '--filter-similar-to', [CompletionResultType]::ParameterName, 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)')
|
||||
[CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: All Status Codes)')
|
||||
[CompletionResult]::new('--status-codes', '--status-codes', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: All Status Codes)')
|
||||
[CompletionResult]::new('-T', '-T ', [CompletionResultType]::ParameterName, 'Number of seconds before a client''s request times out (default: 7)')
|
||||
[CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'Number of seconds before a client''s request times out (default: 7)')
|
||||
[CompletionResult]::new('--server-certs', '--server-certs', [CompletionResultType]::ParameterName, 'Add custom root certificate(s) for servers with unknown certificates')
|
||||
[CompletionResult]::new('--client-cert', '--client-cert', [CompletionResultType]::ParameterName, 'Add a PEM encoded certificate for mutual authentication (mTLS)')
|
||||
[CompletionResult]::new('--client-key', '--client-key', [CompletionResultType]::ParameterName, 'Add a PEM encoded private key for mutual authentication (mTLS)')
|
||||
[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('--response-size-limit', '--response-size-limit', [CompletionResultType]::ParameterName, 'Limit size of response body to read in bytes (default: 4MB)')
|
||||
[CompletionResult]::new('--time-limit', '--time-limit', [CompletionResultType]::ParameterName, 'Limit total run time of all scans (ex: --time-limit 10m)')
|
||||
[CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'Path or URL of the wordlist')
|
||||
[CompletionResult]::new('--wordlist', '--wordlist', [CompletionResultType]::ParameterName, 'Path or URL of the wordlist')
|
||||
[CompletionResult]::new('-B', '-B ', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls (default: ~, .bak, .bak2, .old, .1)')
|
||||
[CompletionResult]::new('--collect-backups', '--collect-backups', [CompletionResultType]::ParameterName, 'Automatically request likely backup extensions for "found" urls (default: ~, .bak, .bak2, .old, .1)')
|
||||
[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('--limit-bars', '--limit-bars', [CompletionResultType]::ParameterName, 'Number of directory scan bars to show at any given time (default: no limit)')
|
||||
[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 --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 and --scan-dir-listings 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('--unique', '--unique', [CompletionResultType]::ParameterName, 'Only show unique responses')
|
||||
[CompletionResult]::new('-r', '-r', [CompletionResultType]::ParameterName, 'Allow client to follow redirects')
|
||||
[CompletionResult]::new('--redirects', '--redirects', [CompletionResultType]::ParameterName, 'Allow client to follow redirects')
|
||||
[CompletionResult]::new('-k', '-k', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation in the client')
|
||||
[CompletionResult]::new('--insecure', '--insecure', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation in the client')
|
||||
[CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'Do not scan recursively')
|
||||
[CompletionResult]::new('--no-recursion', '--no-recursion', [CompletionResultType]::ParameterName, 'Do not scan recursively')
|
||||
[CompletionResult]::new('--force-recursion', '--force-recursion', [CompletionResultType]::ParameterName, 'Force recursion attempts on all ''found'' endpoints (still respects recursion depth)')
|
||||
[CompletionResult]::new('-e', '-e', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: true)')
|
||||
[CompletionResult]::new('--extract-links', '--extract-links', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: true)')
|
||||
[CompletionResult]::new('--dont-extract-links', '--dont-extract-links', [CompletionResultType]::ParameterName, 'Don''t extract links from response body (html, javascript, etc...)')
|
||||
[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('-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('--scan-dir-listings', '--scan-dir-listings', [CompletionResultType]::ParameterName, 'Force scans to recurse into directory listings')
|
||||
[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 (or JSON w/ --json) + 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)')
|
||||
[CompletionResult]::new('-U', '-U ', [CompletionResultType]::ParameterName, 'Update feroxbuster to the latest version')
|
||||
[CompletionResult]::new('--update', '--update', [CompletionResultType]::ParameterName, 'Update feroxbuster to the latest version')
|
||||
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
|
||||
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
|
||||
[CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version')
|
||||
[CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
_feroxbuster() {
|
||||
local i cur prev opts cmds
|
||||
local i cur prev opts cmd
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
cur="$2"
|
||||
else
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
fi
|
||||
prev="$3"
|
||||
cmd=""
|
||||
opts=""
|
||||
|
||||
for i in ${COMP_WORDS[@]}
|
||||
for i in "${COMP_WORDS[@]:0:COMP_CWORD}"
|
||||
do
|
||||
case "${i}" in
|
||||
feroxbuster)
|
||||
case "${cmd},${i}" in
|
||||
",$1")
|
||||
cmd="feroxbuster"
|
||||
;;
|
||||
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
@@ -20,50 +23,55 @@ _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="-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 -U -h -V --url --stdin --resume-from --request-file --burp --burp-replay --data-urlencoded --data-json --smart --thorough --proxy --replay-proxy --replay-codes --user-agent --random-agent --extensions --methods --data --headers --cookies --query --add-slash --protocol --dont-scan --scope --filter-size --filter-regex --filter-words --filter-lines --filter-status --filter-similar-to --status-codes --unique --timeout --redirects --insecure --server-certs --client-cert --client-key --threads --no-recursion --depth --force-recursion --extract-links --dont-extract-links --scan-limit --parallel --rate-limit --response-size-limit --time-limit --wordlist --auto-tune --auto-bail --dont-filter --collect-extensions --collect-backups --collect-words --dont-collect --scan-dir-listings --verbosity --silent --quiet --json --output --debug-log --no-state --limit-bars --update --help --version"
|
||||
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)
|
||||
-u)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--threads)
|
||||
--resume-from)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
--request-file)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
--data-urlencoded)
|
||||
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)
|
||||
--data-json)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -71,7 +79,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-p)
|
||||
-p)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -79,7 +87,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-P)
|
||||
-P)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -87,31 +95,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-R)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--status-codes)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-s)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--resume-from)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--debug-log)
|
||||
-R)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -119,7 +103,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-a)
|
||||
-a)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -127,7 +111,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 +131,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 +147,19 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-Q)
|
||||
-Q)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--protocol)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--dont-scan)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--scope)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -151,7 +167,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-S)
|
||||
-S)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -159,7 +175,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-X)
|
||||
-X)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -167,7 +183,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-W)
|
||||
-W)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -175,7 +191,7 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-N)
|
||||
-N)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -183,7 +199,88 @@ _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
|
||||
;;
|
||||
--server-certs)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
--client-cert)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
--client-key)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
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 +288,19 @@ _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
|
||||
;;
|
||||
--response-size-limit)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
@@ -199,6 +308,101 @@ _feroxbuster() {
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--wordlist)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
-w)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
--collect-backups)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-B)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--dont-collect)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-I)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
--debug-log)
|
||||
local oldifs
|
||||
if [ -n "${IFS+x}" ]; then
|
||||
oldifs="$IFS"
|
||||
fi
|
||||
IFS=$'\n'
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
if [ -n "${oldifs+x}" ]; then
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
|
||||
compopt -o filenames
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
--limit-bars)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
@@ -206,8 +410,11 @@ _feroxbuster() {
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _feroxbuster -o bashdefault -o default feroxbuster
|
||||
if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
|
||||
complete -F _feroxbuster -o nosort -o bashdefault -o default -o plusdirs feroxbuster
|
||||
else
|
||||
complete -F _feroxbuster -o bashdefault -o default -o plusdirs feroxbuster
|
||||
fi
|
||||
|
||||
132
shell_completions/feroxbuster.elv
Normal file
@@ -0,0 +1,132 @@
|
||||
|
||||
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 || --request-file] used)'
|
||||
cand --url 'The target URL (required, unless [--stdin || --resume-from || --request-file] used)'
|
||||
cand --resume-from 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)'
|
||||
cand --request-file 'Raw HTTP request file to use as a template for all requests'
|
||||
cand --data-urlencoded 'Set -H ''Content-Type: application/x-www-form-urlencoded'', --data to <data-urlencoded> (supports @file) and -m to POST'
|
||||
cand --data-json 'Set -H ''Content-Type: application/json'', --data to <data-json> (supports @file) and -m to POST'
|
||||
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.13.0)'
|
||||
cand --user-agent 'Sets the User-Agent (default: feroxbuster/2.13.0)'
|
||||
cand -x 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)'
|
||||
cand --extensions 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)'
|
||||
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 --protocol 'Specify the protocol to use when targeting via --request-file or --url with domain only (default: https)'
|
||||
cand --dont-scan 'URL(s) or Regex Pattern(s) to exclude from recursion/scans'
|
||||
cand --scope 'Additional domains/URLs to consider in-scope for scanning (in addition to current domain)'
|
||||
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/headers (ex: -X ''^ignore me$'')'
|
||||
cand --filter-regex 'Filter out messages via regular expression matching on the response''s body/headers (ex: -X ''^ignore me$'')'
|
||||
cand -W 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
|
||||
cand --filter-words 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
|
||||
cand -N 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
|
||||
cand --filter-lines 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
|
||||
cand -C 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
|
||||
cand --filter-status 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
|
||||
cand --filter-similar-to 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)'
|
||||
cand -s 'Status Codes to include (allow list) (default: All Status Codes)'
|
||||
cand --status-codes 'Status Codes to include (allow list) (default: All Status Codes)'
|
||||
cand -T 'Number of seconds before a client''s request times out (default: 7)'
|
||||
cand --timeout 'Number of seconds before a client''s request times out (default: 7)'
|
||||
cand --server-certs 'Add custom root certificate(s) for servers with unknown certificates'
|
||||
cand --client-cert 'Add a PEM encoded certificate for mutual authentication (mTLS)'
|
||||
cand --client-key 'Add a PEM encoded private key for mutual authentication (mTLS)'
|
||||
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 --response-size-limit 'Limit size of response body to read in bytes (default: 4MB)'
|
||||
cand --time-limit 'Limit total run time of all scans (ex: --time-limit 10m)'
|
||||
cand -w 'Path or URL of the wordlist'
|
||||
cand --wordlist 'Path or URL of the wordlist'
|
||||
cand -B 'Automatically request likely backup extensions for "found" urls (default: ~, .bak, .bak2, .old, .1)'
|
||||
cand --collect-backups 'Automatically request likely backup extensions for "found" urls (default: ~, .bak, .bak2, .old, .1)'
|
||||
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 --limit-bars 'Number of directory scan bars to show at any given time (default: no limit)'
|
||||
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 --auto-tune, --collect-words, and --collect-backups to true'
|
||||
cand --thorough 'Use the same settings as --smart and set --collect-extensions and --scan-dir-listings 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 --unique 'Only show unique responses'
|
||||
cand -r 'Allow client to follow redirects'
|
||||
cand --redirects 'Allow client to follow redirects'
|
||||
cand -k 'Disables TLS certificate validation in the client'
|
||||
cand --insecure 'Disables TLS certificate validation in the client'
|
||||
cand -n 'Do not scan recursively'
|
||||
cand --no-recursion 'Do not scan recursively'
|
||||
cand --force-recursion 'Force recursion attempts on all ''found'' endpoints (still respects recursion depth)'
|
||||
cand -e 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: true)'
|
||||
cand --extract-links 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: true)'
|
||||
cand --dont-extract-links 'Don''t extract links from response body (html, javascript, etc...)'
|
||||
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 -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 --scan-dir-listings 'Force scans to recurse into directory listings'
|
||||
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 (or JSON w/ --json) + turn off logging (good for piping a list of urls to other commands)'
|
||||
cand -q 'Hide progress bars and banner (good for tmux windows w/ notifications)'
|
||||
cand --quiet 'Hide progress bars and banner (good for tmux windows w/ notifications)'
|
||||
cand --json 'Emit JSON logs to --output and --debug-log instead of normal text'
|
||||
cand --no-state 'Disable state output file (*.state)'
|
||||
cand -U 'Update feroxbuster to the latest version'
|
||||
cand --update 'Update feroxbuster to the latest version'
|
||||
cand -h 'Print help (see more with ''--help'')'
|
||||
cand --help 'Print help (see more with ''--help'')'
|
||||
cand -V 'Print version'
|
||||
cand --version 'Print version'
|
||||
}
|
||||
]
|
||||
$completions[$command]
|
||||
}
|
||||
@@ -1,35 +1,70 @@
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s w -l wordlist -d 'Path to the wordlist'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s u -l url -d 'The target URL(s) (required, unless --stdin used)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s t -l threads -d 'Number of concurrent threads (default: 50)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s d -l depth -d 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s T -l timeout -d 'Number of seconds before a request times out (default: 7)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s p -l proxy -d 'Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s P -l replay-proxy -d 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s R -l replay-codes -d 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s s -l status-codes -d 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s o -l output -d 'Output file to write results to (use w/ --json for JSON entries)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l resume-from -d 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l debug-log -d 'Output file to write log entries (use w/ --json for JSON entries)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/VERSION)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s x -l extensions -d 'File extension(s) to search for (ex: -x php -x pdf js)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s H -l headers -d 'Specify HTTP headers (ex: -H Header:val \'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" -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 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 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 r -l redirects -d 'Follow redirects'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s k -l insecure -d 'Disables TLS certificate validation'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s n -l no-recursion -d 'Do not scan recursively'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s f -l add-slash -d 'Append / to each request'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -l stdin -d 'Read url(s) from STDIN'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s e -l extract-links -d 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s h -l help -d 'Prints help information'
|
||||
complete -c feroxbuster -n "__fish_use_subcommand" -s V -l version -d 'Prints version information'
|
||||
complete -c feroxbuster -s u -l url -d 'The target URL (required, unless [--stdin || --resume-from || --request-file] used)' -r -f
|
||||
complete -c feroxbuster -l resume-from -d 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)' -r -F
|
||||
complete -c feroxbuster -l request-file -d 'Raw HTTP request file to use as a template for all requests' -r -F
|
||||
complete -c feroxbuster -l data-urlencoded -d 'Set -H \'Content-Type: application/x-www-form-urlencoded\', --data to <data-urlencoded> (supports @file) and -m to POST' -r
|
||||
complete -c feroxbuster -l data-json -d 'Set -H \'Content-Type: application/json\', --data to <data-json> (supports @file) and -m to POST' -r
|
||||
complete -c feroxbuster -s p -l proxy -d 'Proxy to use for requests (ex: http(s)://host:port, socks5(h)://host:port)' -r -f
|
||||
complete -c feroxbuster -s P -l replay-proxy -d 'Send only unfiltered requests through a Replay Proxy, instead of all requests' -r -f
|
||||
complete -c feroxbuster -s R -l replay-codes -d 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)' -r
|
||||
complete -c feroxbuster -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/2.13.0)' -r
|
||||
complete -c feroxbuster -s x -l extensions -d 'File extension(s) to search for (ex: -x php -x pdf js); reads values (newline-separated) from file if input starts with an @ (ex: @ext.txt)' -r
|
||||
complete -c feroxbuster -s m -l methods -d 'Which HTTP request method(s) should be sent (default: GET)' -r
|
||||
complete -c feroxbuster -l data -d 'Request\'s Body; can read data from a file if input starts with an @ (ex: @post.bin)' -r
|
||||
complete -c feroxbuster -s H -l headers -d 'Specify HTTP headers to be used in each request (ex: -H Header:val -H \'stuff: things\')' -r
|
||||
complete -c feroxbuster -s b -l cookies -d 'Specify HTTP cookies to be used in each request (ex: -b stuff=things)' -r
|
||||
complete -c feroxbuster -s Q -l query -d 'Request\'s URL query parameters (ex: -Q token=stuff -Q secret=key)' -r
|
||||
complete -c feroxbuster -l protocol -d 'Specify the protocol to use when targeting via --request-file or --url with domain only (default: https)' -r
|
||||
complete -c feroxbuster -l dont-scan -d 'URL(s) or Regex Pattern(s) to exclude from recursion/scans' -r
|
||||
complete -c feroxbuster -l scope -d 'Additional domains/URLs to consider in-scope for scanning (in addition to current domain)' -r
|
||||
complete -c feroxbuster -s S -l filter-size -d 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)' -r
|
||||
complete -c feroxbuster -s X -l filter-regex -d 'Filter out messages via regular expression matching on the response\'s body/headers (ex: -X \'^ignore me$\')' -r
|
||||
complete -c feroxbuster -s W -l filter-words -d 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)' -r
|
||||
complete -c feroxbuster -s N -l filter-lines -d 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)' -r
|
||||
complete -c feroxbuster -s C -l filter-status -d 'Filter out status codes (deny list) (ex: -C 200 -C 401)' -r
|
||||
complete -c feroxbuster -l filter-similar-to -d 'Filter out pages that are similar to the given page (ex. --filter-similar-to http://site.xyz/soft404)' -r -f
|
||||
complete -c feroxbuster -s s -l status-codes -d 'Status Codes to include (allow list) (default: All Status Codes)' -r
|
||||
complete -c feroxbuster -s T -l timeout -d 'Number of seconds before a client\'s request times out (default: 7)' -r
|
||||
complete -c feroxbuster -l server-certs -d 'Add custom root certificate(s) for servers with unknown certificates' -r -F
|
||||
complete -c feroxbuster -l client-cert -d 'Add a PEM encoded certificate for mutual authentication (mTLS)' -r -F
|
||||
complete -c feroxbuster -l client-key -d 'Add a PEM encoded private key for mutual authentication (mTLS)' -r -F
|
||||
complete -c feroxbuster -s t -l threads -d 'Number of concurrent threads (default: 50)' -r
|
||||
complete -c feroxbuster -s d -l depth -d 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)' -r
|
||||
complete -c feroxbuster -s L -l scan-limit -d 'Limit total number of concurrent scans (default: 0, i.e. no limit)' -r
|
||||
complete -c feroxbuster -l parallel -d 'Run parallel feroxbuster instances (one child process per url passed via stdin)' -r
|
||||
complete -c feroxbuster -l rate-limit -d 'Limit number of requests per second (per directory) (default: 0, i.e. no limit)' -r
|
||||
complete -c feroxbuster -l response-size-limit -d 'Limit size of response body to read in bytes (default: 4MB)' -r
|
||||
complete -c feroxbuster -l time-limit -d 'Limit total run time of all scans (ex: --time-limit 10m)' -r
|
||||
complete -c feroxbuster -s w -l wordlist -d 'Path or URL of the wordlist' -r -F
|
||||
complete -c feroxbuster -s B -l collect-backups -d 'Automatically request likely backup extensions for "found" urls (default: ~, .bak, .bak2, .old, .1)' -r
|
||||
complete -c feroxbuster -s I -l dont-collect -d 'File extension(s) to Ignore while collecting extensions (only used with --collect-extensions)' -r
|
||||
complete -c feroxbuster -s o -l output -d 'Output file to write results to (use w/ --json for JSON entries)' -r -F
|
||||
complete -c feroxbuster -l debug-log -d 'Output file to write log entries (use w/ --json for JSON entries)' -r -F
|
||||
complete -c feroxbuster -l limit-bars -d 'Number of directory scan bars to show at any given time (default: no limit)' -r
|
||||
complete -c feroxbuster -l stdin -d 'Read url(s) from STDIN'
|
||||
complete -c feroxbuster -l burp -d 'Set --proxy to http://127.0.0.1:8080 and set --insecure to true'
|
||||
complete -c feroxbuster -l burp-replay -d 'Set --replay-proxy to http://127.0.0.1:8080 and set --insecure to true'
|
||||
complete -c feroxbuster -l smart -d 'Set --auto-tune, --collect-words, and --collect-backups to true'
|
||||
complete -c feroxbuster -l thorough -d 'Use the same settings as --smart and set --collect-extensions and --scan-dir-listings to true'
|
||||
complete -c feroxbuster -s A -l random-agent -d 'Use a random User-Agent'
|
||||
complete -c feroxbuster -s f -l add-slash -d 'Append / to each request\'s URL'
|
||||
complete -c feroxbuster -l unique -d 'Only show unique responses'
|
||||
complete -c feroxbuster -s r -l redirects -d 'Allow client to follow redirects'
|
||||
complete -c feroxbuster -s k -l insecure -d 'Disables TLS certificate validation in the client'
|
||||
complete -c feroxbuster -s n -l no-recursion -d 'Do not scan recursively'
|
||||
complete -c feroxbuster -l force-recursion -d 'Force recursion attempts on all \'found\' endpoints (still respects recursion depth)'
|
||||
complete -c feroxbuster -s e -l extract-links -d 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: true)'
|
||||
complete -c feroxbuster -l dont-extract-links -d 'Don\'t extract links from response body (html, javascript, etc...)'
|
||||
complete -c feroxbuster -l auto-tune -d 'Automatically lower scan rate when an excessive amount of errors are encountered'
|
||||
complete -c feroxbuster -l auto-bail -d 'Automatically stop scanning when an excessive amount of errors are encountered'
|
||||
complete -c feroxbuster -s D -l dont-filter -d 'Don\'t auto-filter wildcard responses'
|
||||
complete -c feroxbuster -s E -l collect-extensions -d 'Automatically discover extensions and add them to --extensions (unless they\'re in --dont-collect)'
|
||||
complete -c feroxbuster -s g -l collect-words -d 'Automatically discover important words from within responses and add them to the wordlist'
|
||||
complete -c feroxbuster -l scan-dir-listings -d 'Force scans to recurse into directory listings'
|
||||
complete -c feroxbuster -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 -l silent -d 'Only print URLs (or JSON w/ --json) + turn off logging (good for piping a list of urls to other commands)'
|
||||
complete -c feroxbuster -s q -l quiet -d 'Hide progress bars and banner (good for tmux windows w/ notifications)'
|
||||
complete -c feroxbuster -l json -d 'Emit JSON logs to --output and --debug-log instead of normal text'
|
||||
complete -c feroxbuster -l no-state -d 'Disable state output file (*.state)'
|
||||
complete -c feroxbuster -s U -l update -d 'Update feroxbuster to the latest version'
|
||||
complete -c feroxbuster -s h -l help -d 'Print help (see more with \'--help\')'
|
||||
complete -c feroxbuster -s V -l version -d 'Print version'
|
||||
|
||||
708
src/banner.rs
@@ -1,708 +0,0 @@
|
||||
use crate::config::{Configuration, CONFIGURATION};
|
||||
use crate::utils::{make_request, status_colorizer};
|
||||
use console::style;
|
||||
use reqwest::{Client, Url};
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
|
||||
/// macro helper to abstract away repetitive string formatting
|
||||
macro_rules! format_banner_entry_helper {
|
||||
// \u{0020} -> unicode space
|
||||
// \u{2502} -> vertical box drawing character, i.e. │
|
||||
($rune:expr, $name:expr, $value:expr, $indent:expr, $col_width:expr) => {
|
||||
format!(
|
||||
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<col_w$}\u{2502}\u{0020}{}",
|
||||
$rune,
|
||||
$name,
|
||||
$value,
|
||||
indent = $indent,
|
||||
col_w = $col_width
|
||||
)
|
||||
};
|
||||
($rune:expr, $name:expr, $value:expr, $value2:expr, $indent:expr, $col_width:expr) => {
|
||||
format!(
|
||||
"\u{0020}{:\u{0020}<indent$}{:\u{0020}<col_w$}\u{2502}\u{0020}{}:\u{0020}{}",
|
||||
$rune,
|
||||
$name,
|
||||
$value,
|
||||
$value2,
|
||||
indent = $indent,
|
||||
col_w = $col_width
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// macro that wraps another macro helper to abstract away repetitive string formatting
|
||||
macro_rules! format_banner_entry {
|
||||
// 4 -> unicode emoji padding width
|
||||
// 22 -> column width (when unicode rune is 4 bytes wide, 23 when it's 3)
|
||||
// hardcoded since macros don't allow let statements
|
||||
($rune:expr, $name:expr, $value:expr) => {
|
||||
format_banner_entry_helper!($rune, $name, $value, 3, 22)
|
||||
};
|
||||
($rune:expr, $name:expr, $value1:expr, $value2:expr) => {
|
||||
format_banner_entry_helper!($rune, $name, $value1, $value2, 3, 22)
|
||||
};
|
||||
}
|
||||
|
||||
/// Url used to query github's api; specifically used to look for the latest tagged release name
|
||||
const UPDATE_URL: &str = "https://api.github.com/repos/epi052/feroxbuster/releases/latest";
|
||||
|
||||
/// Simple enum to hold three different update states
|
||||
#[derive(Debug)]
|
||||
enum UpdateStatus {
|
||||
/// this version and latest release are the same
|
||||
UpToDate,
|
||||
|
||||
/// this version and latest release are not the same
|
||||
OutOfDate,
|
||||
|
||||
/// some error occurred during version check
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Makes a request to the given url, expecting to receive a JSON response that contains a field
|
||||
/// named `tag_name` that holds a value representing the latest tagged release of this tool.
|
||||
///
|
||||
/// ex: v1.1.0
|
||||
///
|
||||
/// Returns `UpdateStatus`
|
||||
async fn needs_update(client: &Client, url: &str, bin_version: &str) -> UpdateStatus {
|
||||
log::trace!("enter: needs_update({:?}, {})", client, url);
|
||||
|
||||
let unknown = UpdateStatus::Unknown;
|
||||
|
||||
let api_url = match Url::parse(url) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
return unknown;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(response) = make_request(&client, &api_url).await {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
let json_response: Value = serde_json::from_str(&body).unwrap_or_default();
|
||||
|
||||
if json_response.is_null() {
|
||||
// unwrap_or_default above should result in a null value for the json_response variable
|
||||
log::error!("Could not parse JSON from response body");
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
return unknown;
|
||||
}
|
||||
|
||||
let latest_version = match json_response["tag_name"].as_str() {
|
||||
Some(tag) => tag.trim_start_matches('v'),
|
||||
None => {
|
||||
log::error!("Could not get version field from JSON response");
|
||||
log::debug!("{}", json_response);
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
return unknown;
|
||||
}
|
||||
};
|
||||
|
||||
// if we've gotten this far, we have a string in the form of X.X.X where X is a number
|
||||
// all that's left is to compare the current version with the version found above
|
||||
|
||||
return if latest_version == bin_version {
|
||||
// there's really only two possible outcomes if we accept that the tag conforms to
|
||||
// the X.X.X pattern:
|
||||
// 1. the version strings match, meaning we're up to date
|
||||
// 2. the version strings do not match, meaning we're out of date
|
||||
//
|
||||
// except for developers working on this code, nobody should ever be in a situation
|
||||
// where they have a version greater than the latest tagged release
|
||||
log::trace!("exit: needs_update -> UpdateStatus::UpToDate");
|
||||
UpdateStatus::UpToDate
|
||||
} else {
|
||||
log::trace!("exit: needs_update -> UpdateStatus::OutOfDate");
|
||||
UpdateStatus::OutOfDate
|
||||
};
|
||||
}
|
||||
|
||||
log::trace!("exit: needs_update -> {:?}", unknown);
|
||||
unknown
|
||||
}
|
||||
|
||||
/// Prints the banner to stdout.
|
||||
///
|
||||
/// Only prints those settings which are either always present, or passed in by the user.
|
||||
pub async fn initialize<W>(targets: &[String], config: &Configuration, version: &str, mut writer: W)
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
let artwork = format!(
|
||||
r#"
|
||||
___ ___ __ __ __ __ __ ___
|
||||
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
|
||||
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
|
||||
by Ben "epi" Risher {} ver: {}"#,
|
||||
'\u{1F913}', version
|
||||
);
|
||||
|
||||
let status = needs_update(&CONFIGURATION.client, UPDATE_URL, version).await;
|
||||
|
||||
let top = "───────────────────────────┬──────────────────────";
|
||||
let addl_section = "──────────────────────────────────────────────────";
|
||||
let bottom = "───────────────────────────┴──────────────────────";
|
||||
|
||||
writeln!(&mut writer, "{}", artwork).unwrap_or_default();
|
||||
writeln!(&mut writer, "{}", top).unwrap_or_default();
|
||||
|
||||
// begin with always printed items
|
||||
for target in targets {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F3af}", "Target Url", target)
|
||||
)
|
||||
.unwrap_or_default(); // 🎯
|
||||
}
|
||||
|
||||
let mut codes = vec![];
|
||||
|
||||
for code in &config.status_codes {
|
||||
codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F680}", "Threads", config.threads)
|
||||
)
|
||||
.unwrap_or_default(); // 🚀
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4d6}", "Wordlist", config.wordlist)
|
||||
)
|
||||
.unwrap_or_default(); // 📖
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1F197}",
|
||||
"Status Codes",
|
||||
format!("[{}]", codes.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🆗
|
||||
|
||||
if !config.filter_status.is_empty() {
|
||||
// exception here for optional print due to me wanting the allows and denys to be printed
|
||||
// one after the other
|
||||
let mut code_filters = vec![];
|
||||
|
||||
for code in &config.filter_status {
|
||||
code_filters.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f5d1}",
|
||||
"Status Code Filters",
|
||||
format!("[{}]", code_filters.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🗑
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a5}", "Timeout (secs)", config.timeout)
|
||||
)
|
||||
.unwrap_or_default(); // 💥
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F9a1}", "User-Agent", config.user_agent)
|
||||
)
|
||||
.unwrap_or_default(); // 🦡
|
||||
|
||||
// followed by the maybe printed or variably displayed values
|
||||
if !config.config.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f489}", "Config File", config.config)
|
||||
)
|
||||
.unwrap_or_default(); // 💉
|
||||
}
|
||||
|
||||
if !config.proxy.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f48e}", "Proxy", config.proxy)
|
||||
)
|
||||
.unwrap_or_default(); // 💎
|
||||
}
|
||||
|
||||
if !config.replay_proxy.is_empty() {
|
||||
// i include replay codes logic here because in config.rs, replay codes are set to the
|
||||
// value in status codes, meaning it's never empty
|
||||
|
||||
let mut replay_codes = vec![];
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f3a5}", "Replay Proxy", config.replay_proxy)
|
||||
)
|
||||
.unwrap_or_default(); // 🎥
|
||||
|
||||
for code in &config.replay_codes {
|
||||
replay_codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f4fc}",
|
||||
"Replay Proxy Codes",
|
||||
format!("[{}]", replay_codes.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 📼
|
||||
}
|
||||
|
||||
if !config.headers.is_empty() {
|
||||
for (name, value) in &config.headers {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f92f}", "Header", name, value)
|
||||
)
|
||||
.unwrap_or_default(); // 🤯
|
||||
}
|
||||
}
|
||||
|
||||
if !config.filter_size.is_empty() {
|
||||
for filter in &config.filter_size {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a2}", "Size Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
}
|
||||
|
||||
for filter in &config.filter_word_count {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a2}", "Word Count Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
for filter in &config.filter_line_count {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a2}", "Line Count Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
for filter in &config.filter_regex {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4a2}", "Regex Filter", filter)
|
||||
)
|
||||
.unwrap_or_default(); // 💢
|
||||
}
|
||||
|
||||
if config.extract_links {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F50E}", "Extract Links", config.extract_links)
|
||||
)
|
||||
.unwrap_or_default(); // 🔎
|
||||
}
|
||||
|
||||
if config.json {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1F9d4}", "JSON Output", config.json)
|
||||
)
|
||||
.unwrap_or_default(); // 🧔
|
||||
}
|
||||
|
||||
if !config.queries.is_empty() {
|
||||
for query in &config.queries {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f914}",
|
||||
"Query Parameter",
|
||||
format!("{}={}", query.0, query.1)
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🤔
|
||||
}
|
||||
}
|
||||
|
||||
if !config.output.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4be}", "Output File", config.output)
|
||||
)
|
||||
.unwrap_or_default(); // 💾
|
||||
}
|
||||
|
||||
if !config.debug_log.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1fab2}", "Debugging Log", config.debug_log)
|
||||
)
|
||||
.unwrap_or_default(); // 🪲
|
||||
}
|
||||
|
||||
if !config.extensions.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f4b2}",
|
||||
"Extensions",
|
||||
format!("[{}]", config.extensions.join(", "))
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 💲
|
||||
}
|
||||
|
||||
if config.insecure {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f513}", "Insecure", config.insecure)
|
||||
)
|
||||
.unwrap_or_default(); // 🔓
|
||||
}
|
||||
|
||||
if config.redirects {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4cd}", "Follow Redirects", config.redirects)
|
||||
)
|
||||
.unwrap_or_default(); // 📍
|
||||
}
|
||||
|
||||
if config.dont_filter {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f92a}", "Filter Wildcards", !config.dont_filter)
|
||||
)
|
||||
.unwrap_or_default(); // 🤪
|
||||
}
|
||||
|
||||
match config.verbosity {
|
||||
//speaker medium volume (increasing with verbosity to loudspeaker)
|
||||
1 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f508}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔈
|
||||
}
|
||||
2 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f509}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔉
|
||||
}
|
||||
3 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f50a}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 🔊
|
||||
}
|
||||
4 => {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f4e2}", "Verbosity", config.verbosity)
|
||||
)
|
||||
.unwrap_or_default(); // 📢
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if config.add_slash {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1fa93}", "Add Slash", config.add_slash)
|
||||
)
|
||||
.unwrap_or_default(); // 🪓
|
||||
}
|
||||
|
||||
if !config.no_recursion {
|
||||
if config.depth == 0 {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f503}", "Recursion Depth", "INFINITE")
|
||||
)
|
||||
.unwrap_or_default(); // 🔃
|
||||
} else {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f503}", "Recursion Depth", config.depth)
|
||||
)
|
||||
.unwrap_or_default(); // 🔃
|
||||
}
|
||||
} else {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f6ab}", "Do Not Recurse", config.no_recursion)
|
||||
)
|
||||
.unwrap_or_default(); // 🚫
|
||||
}
|
||||
|
||||
if CONFIGURATION.scan_limit > 0 {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f9a5}", "Concurrent Scan Limit", config.scan_limit)
|
||||
)
|
||||
.unwrap_or_default(); // 🦥
|
||||
}
|
||||
|
||||
if !CONFIGURATION.time_limit.is_empty() {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!("\u{1f556}", "Time Limit", config.time_limit)
|
||||
)
|
||||
.unwrap_or_default(); // 🕖
|
||||
}
|
||||
|
||||
if matches!(status, UpdateStatus::OutOfDate) {
|
||||
writeln!(
|
||||
&mut writer,
|
||||
"{}",
|
||||
format_banner_entry!(
|
||||
"\u{1f389}",
|
||||
"New Version Available",
|
||||
"https://github.com/epi052/feroxbuster/releases/latest"
|
||||
)
|
||||
)
|
||||
.unwrap_or_default(); // 🎉
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", bottom).unwrap_or_default();
|
||||
// ⏯
|
||||
writeln!(
|
||||
&mut writer,
|
||||
" \u{23ef} Press [{}] to {}|{} your scan",
|
||||
style("ENTER").yellow(),
|
||||
style("pause").red(),
|
||||
style("resume").green()
|
||||
)
|
||||
.unwrap_or_default();
|
||||
writeln!(&mut writer, "{}", addl_section).unwrap_or_default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::VERSION;
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::{Mock, MockServer};
|
||||
use std::fs::read_to_string;
|
||||
use std::io::stderr;
|
||||
use std::time::Duration;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit no execution of targets for loop in banner
|
||||
async fn banner_intialize_without_targets() {
|
||||
let config = Configuration::default();
|
||||
initialize(&[], &config, VERSION, stderr()).await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit no execution of statuscode for loop in banner
|
||||
async fn banner_intialize_without_status_codes() {
|
||||
let mut config = Configuration::default();
|
||||
config.status_codes = vec![];
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
VERSION,
|
||||
stderr(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit an empty config file
|
||||
async fn banner_intialize_without_config_file() {
|
||||
let mut config = Configuration::default();
|
||||
config.config = String::new();
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
VERSION,
|
||||
stderr(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to hit an empty config file
|
||||
async fn banner_intialize_without_queries() {
|
||||
let mut config = Configuration::default();
|
||||
config.queries = vec![(String::new(), String::new())];
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
VERSION,
|
||||
stderr(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test to show that a new version is available for download
|
||||
async fn banner_intialize_with_mismatched_version() {
|
||||
let config = Configuration::default();
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
initialize(
|
||||
&[String::from("http://localhost")],
|
||||
&config,
|
||||
"mismatched-version",
|
||||
&file,
|
||||
)
|
||||
.await;
|
||||
let contents = read_to_string(file.path()).unwrap();
|
||||
println!("contents: {}", contents);
|
||||
assert!(contents.contains("New Version Available"));
|
||||
assert!(contents.contains("https://github.com/epi052/feroxbuster/releases/latest"));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test that
|
||||
async fn banner_needs_update_returns_unknown_with_bad_url() {
|
||||
let result = needs_update(&CONFIGURATION.client, &"", VERSION).await;
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url to needs_update
|
||||
async fn banner_needs_update_returns_up_to_date() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.1.0").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::UpToDate));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url to needs_update that returns a newer version than current
|
||||
async fn banner_needs_update_returns_out_of_date() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::OutOfDate));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url that times out
|
||||
async fn banner_needs_update_returns_unknown_on_timeout() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"tag_name\":\"v1.1.0\"}")
|
||||
.return_with_delay(Duration::from_secs(8))
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url with bad json response
|
||||
async fn banner_needs_update_returns_unknown_on_bad_json_response() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("not json")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// test return value of good url with json response that lacks the tag_name field
|
||||
async fn banner_needs_update_returns_unknown_on_json_without_correct_tag() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/latest")
|
||||
.return_status(200)
|
||||
.return_body("{\"no tag_name\": \"doesn't exist\"}")
|
||||
.create_on(&srv);
|
||||
|
||||
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
assert!(matches!(result, UpdateStatus::Unknown));
|
||||
}
|
||||
}
|
||||
854
src/banner/container.rs
Normal file
@@ -0,0 +1,854 @@
|
||||
use super::entry::BannerEntry;
|
||||
use crate::{
|
||||
client,
|
||||
config::Configuration,
|
||||
event_handlers::Handles,
|
||||
utils::{make_request, parse_url_with_raw_path, status_colorizer},
|
||||
DEFAULT_IGNORED_EXTENSIONS, DEFAULT_METHOD, DEFAULT_STATUS_CODES, VERSION,
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use console::{style, Emoji};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
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.client_key
|
||||
client_key: BannerEntry,
|
||||
|
||||
/// represents Configuration.client_cert
|
||||
client_cert: BannerEntry,
|
||||
|
||||
/// represents Configuration.server_certs
|
||||
server_certs: 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>,
|
||||
|
||||
/// represents Configuration.scope
|
||||
scope: Vec<BannerEntry>,
|
||||
|
||||
/// current version of feroxbuster
|
||||
pub(super) version: String,
|
||||
|
||||
/// whether or not there is a known new version
|
||||
pub(super) update_status: UpdateStatus,
|
||||
|
||||
/// represents Configuration.collect_extensions
|
||||
collect_extensions: BannerEntry,
|
||||
|
||||
/// represents Configuration.dont_collect
|
||||
dont_collect: BannerEntry,
|
||||
|
||||
/// represents Configuration.collect_backups
|
||||
collect_backups: BannerEntry,
|
||||
|
||||
/// represents Configuration.collect_words
|
||||
collect_words: BannerEntry,
|
||||
|
||||
/// represents Configuration.collect_words
|
||||
force_recursion: BannerEntry,
|
||||
|
||||
/// represents Configuration.protocol
|
||||
protocol: BannerEntry,
|
||||
|
||||
/// represents Configuration.scan_dir_listings
|
||||
scan_dir_listings: BannerEntry,
|
||||
|
||||
/// represents Configuration.limit_bars
|
||||
limit_bars: BannerEntry,
|
||||
|
||||
/// represents Configuration.unique
|
||||
unique: BannerEntry,
|
||||
|
||||
/// represents Configuration.response_size_limit
|
||||
response_size_limit: 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 scope = 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(),
|
||||
));
|
||||
}
|
||||
|
||||
for scope_url in &config.scope {
|
||||
let value = match scope_url.host() {
|
||||
Some(host) => host.to_string(),
|
||||
None => scope_url.as_str().to_string(),
|
||||
};
|
||||
|
||||
scope.push(BannerEntry::new("🚩", "In-Scope Url", &value));
|
||||
}
|
||||
|
||||
// the +2 is for the 2 experimental status codes we add to the default list manually
|
||||
let status_codes = if config.status_codes.len() == DEFAULT_STATUS_CODES.len() + 2 {
|
||||
let all_str = format!(
|
||||
"{} {} {}{}",
|
||||
style("All").cyan(),
|
||||
style("Status").green(),
|
||||
style("Codes").yellow(),
|
||||
style("!").red()
|
||||
);
|
||||
BannerEntry::new("👌", "Status Codes", &all_str)
|
||||
} else {
|
||||
let mut codes = vec![];
|
||||
|
||||
for code in &config.status_codes {
|
||||
codes.push(status_colorizer(&code.to_string()))
|
||||
}
|
||||
|
||||
BannerEntry::new("👌", "Status Codes", &format!("[{}]", codes.join(", ")))
|
||||
};
|
||||
|
||||
for code in &config.filter_status {
|
||||
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 protocol = if config.protocol.to_lowercase() == "http" {
|
||||
BannerEntry::new("🔓", "Default Protocol", &config.protocol)
|
||||
} else {
|
||||
BannerEntry::new("🔒", "Default Protocol", &config.protocol)
|
||||
};
|
||||
|
||||
let scan_limit = BannerEntry::new(
|
||||
"🦥",
|
||||
"Concurrent Scan Limit",
|
||||
&config.scan_limit.to_string(),
|
||||
);
|
||||
|
||||
let force_recursion =
|
||||
BannerEntry::new("🤘", "Force Recursion", &config.force_recursion.to_string());
|
||||
let replay_proxy = BannerEntry::new("🎥", "Replay Proxy", &config.replay_proxy);
|
||||
let auto_tune = BannerEntry::new("🎶", "Auto Tune", &config.auto_tune.to_string());
|
||||
let auto_bail = BannerEntry::new("🙅", "Auto Bail", &config.auto_bail.to_string());
|
||||
let scan_dir_listings = BannerEntry::new(
|
||||
"📂",
|
||||
"Scan Dir Listings",
|
||||
&config.scan_dir_listings.to_string(),
|
||||
);
|
||||
let cfg = BannerEntry::new("💉", "Config File", &config.config);
|
||||
let proxy = BannerEntry::new("💎", "Proxy", &config.proxy);
|
||||
let server_certs = BannerEntry::new(
|
||||
"🏅",
|
||||
"Server Certificates",
|
||||
&format!("[{}]", config.server_certs.join(", ")),
|
||||
);
|
||||
let client_cert = BannerEntry::new("🏅", "Client Certificate", &config.client_cert);
|
||||
let client_key = BannerEntry::new("🔑", "Client Key", &config.client_key);
|
||||
let threads = BannerEntry::new("🚀", "Threads", &config.threads.to_string());
|
||||
let limit_bars =
|
||||
BannerEntry::new("📊", "Limit Dir Scan Bars", &config.limit_bars.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());
|
||||
|
||||
let unique = BannerEntry::new("🎲", "Unique Responses", &config.unique.to_string());
|
||||
|
||||
let response_size_limit = BannerEntry::new(
|
||||
"📏",
|
||||
"Response Size Limit",
|
||||
&format!("{} bytes", config.response_size_limit),
|
||||
);
|
||||
|
||||
Self {
|
||||
targets,
|
||||
status_codes,
|
||||
threads,
|
||||
wordlist,
|
||||
filter_status,
|
||||
timeout,
|
||||
user_agent,
|
||||
random_agent,
|
||||
auto_bail,
|
||||
auto_tune,
|
||||
proxy,
|
||||
client_cert,
|
||||
client_key,
|
||||
server_certs,
|
||||
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,
|
||||
force_recursion,
|
||||
time_limit,
|
||||
url_denylist,
|
||||
scope,
|
||||
collect_extensions,
|
||||
collect_backups,
|
||||
collect_words,
|
||||
dont_collect,
|
||||
config: cfg,
|
||||
scan_dir_listings,
|
||||
protocol,
|
||||
limit_bars,
|
||||
unique,
|
||||
response_size_limit,
|
||||
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!("{artwork}\n{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!("{bottom}\n{instructions}\n{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 = parse_url_with_raw_path(url)?;
|
||||
|
||||
// we don't want to leak sensitive header info / include auth headers
|
||||
// with the github api request, so we'll build a client specifically
|
||||
// for this task. thanks to @stuhlmann for the suggestion!
|
||||
let headers = HashMap::new();
|
||||
let client_cert = if handles.config.client_cert.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(handles.config.client_cert.as_str())
|
||||
};
|
||||
let client_key = if handles.config.client_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(handles.config.client_key.as_str())
|
||||
};
|
||||
let proxy = if handles.config.proxy.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(handles.config.proxy.as_str())
|
||||
};
|
||||
let client_config = client::ClientConfig {
|
||||
timeout: handles.config.timeout,
|
||||
user_agent: "feroxbuster-update-check",
|
||||
redirects: handles.config.redirects,
|
||||
insecure: handles.config.insecure,
|
||||
headers: &headers,
|
||||
proxy,
|
||||
server_certs: Some(&handles.config.server_certs),
|
||||
client_cert,
|
||||
client_key,
|
||||
scope: &handles.config.scope,
|
||||
};
|
||||
let client = client::initialize(client_config)?;
|
||||
let level = handles.config.output_level;
|
||||
let tx_stats = handles.stats.tx.clone();
|
||||
|
||||
let result = make_request(
|
||||
&client,
|
||||
&api_url,
|
||||
DEFAULT_METHOD,
|
||||
None,
|
||||
level,
|
||||
&handles.config,
|
||||
tx_stats,
|
||||
)
|
||||
.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}")?;
|
||||
}
|
||||
|
||||
for scoped_url in &self.scope {
|
||||
writeln!(&mut writer, "{scoped_url}")?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.threads)?;
|
||||
writeln!(&mut writer, "{}", self.wordlist)?;
|
||||
|
||||
if config.filter_status.is_empty() {
|
||||
// -C and -s are mutually exclusive, and -s meaning changes when -C is used
|
||||
// so only print one or the other
|
||||
writeln!(&mut writer, "{}", self.status_codes)?;
|
||||
} else {
|
||||
writeln!(&mut writer, "{}", self.filter_status)?;
|
||||
}
|
||||
|
||||
writeln!(&mut writer, "{}", self.timeout)?;
|
||||
|
||||
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.request_file.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.protocol)?;
|
||||
}
|
||||
|
||||
if config.limit_bars > 0 {
|
||||
writeln!(&mut writer, "{}", self.limit_bars)?;
|
||||
}
|
||||
|
||||
if !config.config.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.config)?;
|
||||
}
|
||||
|
||||
if !config.proxy.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.proxy)?;
|
||||
}
|
||||
|
||||
if !config.client_cert.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.client_cert)?;
|
||||
}
|
||||
|
||||
if !config.client_key.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.client_key)?;
|
||||
}
|
||||
|
||||
if !config.server_certs.is_empty() {
|
||||
writeln!(&mut writer, "{}", self.server_certs)?;
|
||||
}
|
||||
|
||||
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.scan_dir_listings {
|
||||
writeln!(&mut writer, "{}", self.scan_dir_listings)?;
|
||||
}
|
||||
|
||||
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.force_recursion {
|
||||
writeln!(&mut writer, "{}", self.force_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 config.unique {
|
||||
writeln!(&mut writer, "{}", self.unique)?;
|
||||
}
|
||||
|
||||
if config.response_size_limit != 4194304 {
|
||||
writeln!(&mut writer, "{}", self.response_size_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));
|
||||
}
|
||||
342
src/client.rs
@@ -1,82 +1,132 @@
|
||||
use crate::utils::{module_colorizer, status_colorizer};
|
||||
use crate::url::UrlExt;
|
||||
use anyhow::{Context, 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::path::Path;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
/// Create and return an instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
|
||||
pub fn initialize(
|
||||
timeout: u64,
|
||||
user_agent: &str,
|
||||
redirects: bool,
|
||||
insecure: bool,
|
||||
headers: &HashMap<String, String>,
|
||||
proxy: Option<&str>,
|
||||
) -> Client {
|
||||
let policy = if redirects {
|
||||
/// Configuration struct for initializing a reqwest client
|
||||
pub struct ClientConfig<'a, I>
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: AsRef<Path> + std::fmt::Debug,
|
||||
{
|
||||
/// The timeout for requests in seconds
|
||||
pub timeout: u64,
|
||||
/// The User-Agent string to use for requests
|
||||
pub user_agent: &'a str,
|
||||
/// Whether to follow redirects
|
||||
pub redirects: bool,
|
||||
/// Whether to allow insecure connections
|
||||
pub insecure: bool,
|
||||
/// Headers to include in requests
|
||||
pub headers: &'a HashMap<String, String>,
|
||||
/// Proxy server to use for requests
|
||||
pub proxy: Option<&'a str>,
|
||||
/// Server certificates to use for requests
|
||||
pub server_certs: Option<I>,
|
||||
/// Client certificate to use for requests
|
||||
pub client_cert: Option<&'a str>,
|
||||
/// Client key to use for requests
|
||||
pub client_key: Option<&'a str>,
|
||||
/// scope for redirect handling
|
||||
pub scope: &'a [Url],
|
||||
}
|
||||
|
||||
/// Create a redirect policy based on the provided config
|
||||
fn create_redirect_policy<I>(config: &ClientConfig<'_, I>) -> Policy
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: AsRef<Path> + std::fmt::Debug,
|
||||
{
|
||||
// old behavior set Policy::limited(10) if redirects were enabled
|
||||
// and Policy::none() if they were not. New policy behavior is
|
||||
// scope-aware when redirects are enabled and scope is provided.
|
||||
|
||||
if config.redirects && config.scope.is_empty() {
|
||||
// scope should never be empty, so this should never be hit, just a fallback
|
||||
Policy::limited(10)
|
||||
} else if config.redirects {
|
||||
// create a custom policy that checks scope for each redirect
|
||||
let scoped_urls = config.scope.to_vec();
|
||||
|
||||
Policy::custom(move |attempt| {
|
||||
let redirect_url = attempt.url();
|
||||
|
||||
if redirect_url.is_in_scope(&scoped_urls) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Policy::none()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// try_into returns infallible as its error, unwrap is safe here
|
||||
let header_map: HeaderMap = headers.try_into().unwrap();
|
||||
/// Create and return an instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
|
||||
/// with optional scope-aware redirect handling
|
||||
pub fn initialize<I>(config: ClientConfig<'_, I>) -> Result<Client>
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: AsRef<Path> + std::fmt::Debug,
|
||||
{
|
||||
let policy = create_redirect_policy(&config);
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::new(timeout, 0))
|
||||
.user_agent(user_agent)
|
||||
.danger_accept_invalid_certs(insecure)
|
||||
let header_map: HeaderMap = config.headers.try_into()?;
|
||||
|
||||
let mut client = Client::builder()
|
||||
.timeout(Duration::new(config.timeout, 0))
|
||||
.user_agent(config.user_agent)
|
||||
.danger_accept_invalid_certs(config.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) = config.proxy {
|
||||
if !some_proxy.is_empty() {
|
||||
// it's not an empty string; set the proxy
|
||||
let proxy_obj = Proxy::all(some_proxy)?;
|
||||
// just add the proxy to the client
|
||||
// don't build and return it just yet
|
||||
client = client.proxy(proxy_obj);
|
||||
}
|
||||
}
|
||||
|
||||
for cert_path in config.server_certs.into_iter().flatten() {
|
||||
let buf = std::fs::read(&cert_path)?;
|
||||
|
||||
let cert = match reqwest::Certificate::from_pem(&buf) {
|
||||
Ok(cert) => cert,
|
||||
Err(err) => reqwest::Certificate::from_der(&buf).with_context(|| {
|
||||
format!(
|
||||
"{:?} does not contain a valid PEM or DER certificate\n{}",
|
||||
&cert_path, err
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
client = client.add_root_certificate(cert);
|
||||
}
|
||||
|
||||
if let (Some(cert_path), Some(key_path)) = (config.client_cert, config.client_key) {
|
||||
if !cert_path.is_empty() && !key_path.is_empty() {
|
||||
let cert = std::fs::read(cert_path)?;
|
||||
let key = std::fs::read(key_path)?;
|
||||
|
||||
let identity = reqwest::Identity::from_pkcs8_pem(&cert, &key).with_context(|| {
|
||||
format!(
|
||||
"either {cert_path} or {key_path} are invalid; expecting PEM encoded certificate and key")
|
||||
})?;
|
||||
|
||||
client = client.identity(identity);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(client.build()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -88,7 +138,19 @@ 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"));
|
||||
let client_config = ClientConfig {
|
||||
timeout: 0,
|
||||
user_agent: "stuff",
|
||||
redirects: true,
|
||||
insecure: false,
|
||||
headers: &headers,
|
||||
proxy: Some("not a valid proxy"),
|
||||
server_certs: Option::<Vec<String>>::None,
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &Vec::new(),
|
||||
};
|
||||
initialize(client_config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -96,6 +158,154 @@ 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));
|
||||
let client_config = ClientConfig {
|
||||
timeout: 0,
|
||||
user_agent: "stuff",
|
||||
redirects: true,
|
||||
insecure: true,
|
||||
headers: &headers,
|
||||
proxy: Some(proxy),
|
||||
server_certs: Option::<Vec<String>>::None,
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &Vec::new(),
|
||||
};
|
||||
initialize(client_config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// create client with a server cert in pem format, expect no error
|
||||
fn client_with_valid_server_pem() {
|
||||
let headers = HashMap::new();
|
||||
let server_certs = vec!["tests/mutual-auth/certs/server/server.crt.1".to_string()];
|
||||
let client_config = ClientConfig {
|
||||
timeout: 0,
|
||||
user_agent: "stuff",
|
||||
redirects: true,
|
||||
insecure: true,
|
||||
headers: &headers,
|
||||
proxy: None,
|
||||
server_certs: Some(server_certs),
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &Vec::new(),
|
||||
};
|
||||
initialize(client_config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// create client with a server cert in der format, expect no error
|
||||
fn client_with_valid_server_der() {
|
||||
let headers = HashMap::new();
|
||||
let server_certs = vec!["tests/mutual-auth/certs/server/server.der".to_string()];
|
||||
let client_config = ClientConfig {
|
||||
timeout: 0,
|
||||
user_agent: "stuff",
|
||||
redirects: true,
|
||||
insecure: true,
|
||||
headers: &headers,
|
||||
proxy: None,
|
||||
server_certs: Some(server_certs),
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &Vec::new(),
|
||||
};
|
||||
initialize(client_config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// create client with two server certs (pem and der), expect no error
|
||||
fn client_with_valid_server_pem_and_der() {
|
||||
let headers = HashMap::new();
|
||||
let server_certs = vec![
|
||||
"tests/mutual-auth/certs/server/server.crt.1".to_string(),
|
||||
"tests/mutual-auth/certs/server/server.der".to_string(),
|
||||
];
|
||||
|
||||
println!("{}", std::env::current_dir().unwrap().display());
|
||||
|
||||
let client_config = ClientConfig {
|
||||
timeout: 0,
|
||||
user_agent: "stuff",
|
||||
redirects: true,
|
||||
insecure: true,
|
||||
headers: &headers,
|
||||
proxy: None,
|
||||
server_certs: Some(server_certs),
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &Vec::new(),
|
||||
};
|
||||
initialize(client_config).unwrap();
|
||||
}
|
||||
|
||||
/// create client with invalid certificate, expect panic
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn client_with_invalid_server_cert() {
|
||||
let headers = HashMap::new();
|
||||
let server_certs = vec!["tests/mutual-auth/certs/client/client.key".to_string()];
|
||||
let client_config = ClientConfig {
|
||||
timeout: 0,
|
||||
user_agent: "stuff",
|
||||
redirects: true,
|
||||
insecure: true,
|
||||
headers: &headers,
|
||||
proxy: None,
|
||||
server_certs: Some(server_certs),
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &Vec::new(),
|
||||
};
|
||||
initialize(client_config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that scope-aware client can be created with valid parameters
|
||||
fn initialize_with_scope_creates_client() {
|
||||
let headers = HashMap::new();
|
||||
let scope = vec![
|
||||
Url::parse("https://api.example.com").unwrap(),
|
||||
Url::parse("https://cdn.example.com").unwrap(),
|
||||
];
|
||||
|
||||
let client_config = ClientConfig {
|
||||
timeout: 5,
|
||||
user_agent: "test-agent",
|
||||
redirects: true,
|
||||
insecure: false,
|
||||
headers: &headers,
|
||||
proxy: None,
|
||||
server_certs: Option::<Vec<String>>::None,
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &scope,
|
||||
};
|
||||
let client = initialize(client_config);
|
||||
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that scope-aware client works without scope (should use default behavior)
|
||||
fn initialize_with_scope_empty_scope() {
|
||||
let headers = HashMap::new();
|
||||
let scope = vec![];
|
||||
|
||||
let client_config = ClientConfig {
|
||||
timeout: 5,
|
||||
user_agent: "test-agent",
|
||||
redirects: true,
|
||||
insecure: false,
|
||||
headers: &headers,
|
||||
proxy: None,
|
||||
server_certs: Option::<Vec<String>>::None,
|
||||
client_cert: None,
|
||||
client_key: None,
|
||||
scope: &scope,
|
||||
};
|
||||
let client = initialize(client_config);
|
||||
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
1202
src/config.rs
1525
src/config/container.rs
Normal file
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};
|
||||
614
src/config/tests.rs
Normal file
@@ -0,0 +1,614 @@
|
||||
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"]
|
||||
scope = ["http://example.com", "https://other.com"]
|
||||
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 = false
|
||||
json = true
|
||||
save_state = false
|
||||
depth = 1
|
||||
limit_bars = 3
|
||||
protocol = "http"
|
||||
request_file = "/some/request/file"
|
||||
scan_dir_listings = true
|
||||
force_recursion = true
|
||||
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]
|
||||
server_certs = ["/some/cert.pem", "/some/other/cert.pem"]
|
||||
client_cert = "/some/client/cert.pem"
|
||||
client_key = "/some/client/key.pem"
|
||||
backup_extensions = [".save"]
|
||||
unique = true
|
||||
response_size_limit = 8388608
|
||||
"#;
|
||||
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_eq!(config.limit_bars, 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.force_recursion);
|
||||
assert!(!config.redirects);
|
||||
assert!(config.extract_links);
|
||||
assert!(!config.insecure);
|
||||
assert!(!config.collect_extensions);
|
||||
assert!(!config.collect_backups);
|
||||
assert!(!config.collect_words);
|
||||
assert!(!config.scan_dir_listings);
|
||||
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.scope, 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());
|
||||
assert_eq!(config.server_certs, Vec::<String>::new());
|
||||
assert_eq!(config.client_cert, String::new());
|
||||
assert_eq!(config.client_key, String::new());
|
||||
assert_eq!(config.backup_extensions, backup_extensions());
|
||||
assert_eq!(config.protocol, request_protocol());
|
||||
assert_eq!(config.request_file, String::new());
|
||||
assert!(!config.unique);
|
||||
assert_eq!(config.response_size_limit, 4194304); // 4MB
|
||||
}
|
||||
|
||||
#[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_force_recursion() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.force_recursion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_quiet() {
|
||||
let config = setup_config_test();
|
||||
assert!(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_limit_bars() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.limit_bars, 3);
|
||||
}
|
||||
|
||||
#[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_scope() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(
|
||||
config.scope,
|
||||
vec![
|
||||
Url::parse("http://example.com").unwrap(),
|
||||
Url::parse("https://other.com").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_scan_dir_listings() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.scan_dir_listings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_protocol() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.protocol, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_request_file() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.request_file, String::new());
|
||||
}
|
||||
|
||||
#[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 value parsed is correct
|
||||
fn config_reads_server_certs() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(
|
||||
config.server_certs,
|
||||
["/some/cert.pem", "/some/other/cert.pem"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_backup_extensions() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.backup_extensions, [".save"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_client_cert() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.client_cert, "/some/client/cert.pem");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_client_key() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.client_key, "/some/client/key.pem");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_unique() {
|
||||
let config = setup_config_test();
|
||||
assert!(config.unique);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// parse the test config and see that the value parsed is correct
|
||||
fn config_reads_response_size_limit() {
|
||||
let config = setup_config_test();
|
||||
assert_eq!(config.response_size_limit, 8388608); // 8MB as set in setup_config_test
|
||||
}
|
||||
1348
src/config/utils.rs
Normal file
101
src/event_handlers/command.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use tokio::sync::oneshot::Sender;
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::{
|
||||
event_handlers::Handles,
|
||||
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
|
||||
///
|
||||
/// the u64 value is the offset at which to start the progress bar (can be 0)
|
||||
CreateBar(u64),
|
||||
|
||||
/// 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,
|
||||
|
||||
/// Give a handler access to an Arc<Handles> instance after the handler has
|
||||
/// already been initialized
|
||||
AddHandles(Arc<Handles>),
|
||||
|
||||
/// inform the Stats object about which targets are being scanned
|
||||
UpdateTargets(Vec<String>),
|
||||
|
||||
/// query the Stats handler about the position of the overall progress bar
|
||||
QueryOverallBarEta(Sender<Duration>),
|
||||
|
||||
/// Add permits to the scan limiter (semaphore)
|
||||
AddScanPermits(usize),
|
||||
|
||||
/// Subtract permits from the scan limiter (semaphore)
|
||||
SubtractScanPermits(usize),
|
||||
}
|
||||
186
src/event_handlers/container.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
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() {
|
||||
guard.replace(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 mut multiplier = self.config.extensions.len().max(1);
|
||||
|
||||
if multiplier > 1 {
|
||||
// when we have more than one extension, we need to account for the fact that we'll
|
||||
// be making a request for each extension and the base word (e.g. /foo.html and /foo)
|
||||
multiplier += 1;
|
||||
}
|
||||
|
||||
multiplier *= self.config.methods.len().max(1) * self.num_collected_extensions().max(1);
|
||||
|
||||
multiplier
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
178
src/event_handlers/inputs.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
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::{
|
||||
env::temp_dir,
|
||||
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:?})");
|
||||
|
||||
// check for STATE_FILENAME env var first; credit to Tobias Rauch for the idea
|
||||
let filename = if let Ok(path) = std::env::var("STATE_FILENAME") {
|
||||
path
|
||||
} else 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(),
|
||||
);
|
||||
|
||||
// User didn't set the --no-state flag (so saved_state is still the default true)
|
||||
if handles.config.save_state {
|
||||
let Ok(mut state_file) = open_file(&filename) else {
|
||||
// couldn't open the file, let the user know we're going to try again
|
||||
let error = format!(
|
||||
"❌ Could not save {}, falling back to {}",
|
||||
filename,
|
||||
temp_dir().to_string_lossy()
|
||||
);
|
||||
PROGRESS_PRINTER.println(error);
|
||||
|
||||
let temp_filename = temp_dir().join(&filename);
|
||||
|
||||
let Ok(mut state_file) = open_file(&temp_filename.to_string_lossy()) else {
|
||||
// couldn't open the fallback file, let the user know
|
||||
let error = format!("❌❌ Could not save {temp_filename:?}, giving up...");
|
||||
PROGRESS_PRINTER.println(error);
|
||||
|
||||
log::trace!("exit: sigint_handler (failed to write)");
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
write_to(&state, &mut state_file, true)?;
|
||||
|
||||
let msg = format!("✅ Saved scan state to {temp_filename:?}");
|
||||
PROGRESS_PRINTER.println(msg);
|
||||
|
||||
log::trace!("exit: sigint_handler (saved to temp folder)");
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
write_to(&state, &mut state_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};
|
||||
611
src/event_handlers/outputs.rs
Normal file
@@ -0,0 +1,611 @@
|
||||
use super::Command::AddToUsizeField;
|
||||
use super::*;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use futures::future::{BoxFuture, FutureExt};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
filters::SimilarityFilter,
|
||||
progress::PROGRESS_PRINTER,
|
||||
response::FeroxResponse,
|
||||
scanner::RESPONSES,
|
||||
send_command, skip_fail,
|
||||
statistics::StatField::{ResourcesDiscovered, TotalExpected},
|
||||
traits::FeroxSerialize,
|
||||
utils::{ferox_print, fmt_err, make_request, open_file, write_to},
|
||||
CommandReceiver, CommandSender, Joiner, UNIQUE_DISTANCE,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
write_to(&*self.config, &mut file, self.config.json)?;
|
||||
|
||||
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>,
|
||||
|
||||
/// handles instance
|
||||
handles: Option<Arc<Handles>>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
handles: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) => {
|
||||
if let Err(err) = self
|
||||
.process_response(tx_stats.clone(), resp, ProcessResponseCall::Recursive)
|
||||
.await
|
||||
{
|
||||
log::warn!("{err}");
|
||||
}
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::AddHandles(handles) => {
|
||||
self.handles = Some(handles);
|
||||
}
|
||||
Command::Exit => {
|
||||
if self.file_task.is_some() && self.tx_file.send(Command::Exit).is_ok() {
|
||||
self.file_task.as_mut().unwrap().await??; // wait for death
|
||||
}
|
||||
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 = if !self.config.filter_status.is_empty() {
|
||||
// -C was used, meaning -s was not and we should ignore the defaults
|
||||
// https://github.com/epi052/feroxbuster/issues/535
|
||||
// -C indicates that we should filter that status code, but allow all others
|
||||
!self.config.filter_status.contains(&resp.status().as_u16())
|
||||
} else {
|
||||
// -C wasn't used, so, we defer to checking the -s values
|
||||
self.config.status_codes.contains(&resp.status().as_u16())
|
||||
};
|
||||
|
||||
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
|
||||
let should_process_response = contains_sentry && unknown_sentry;
|
||||
|
||||
if should_process_response {
|
||||
// print to stdout
|
||||
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
|
||||
|
||||
send_command!(tx_stats, AddToUsizeField(ResourcesDiscovered, 1));
|
||||
|
||||
if self.file_task.is_some() {
|
||||
// -o used, need to send the report to be written out to disk
|
||||
self.tx_file
|
||||
.send(Command::Report(resp.clone()))
|
||||
.with_context(|| {
|
||||
fmt_err(&format!("Could not send {resp} to file handler"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
log::trace!("report complete: {}", resp.url());
|
||||
|
||||
if self.config.replay_client.is_some() && should_process_response {
|
||||
// replay proxy specified/client created and this response's status code is one that
|
||||
// should be replayed; not using logged_request due to replay proxy client
|
||||
let data = if self.config.data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.config.data.as_slice())
|
||||
};
|
||||
|
||||
make_request(
|
||||
self.config.replay_client.as_ref().unwrap(),
|
||||
resp.url(),
|
||||
resp.method().as_str(),
|
||||
data,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Could not replay request through replay proxy")?;
|
||||
}
|
||||
|
||||
if self.config.collect_backups
|
||||
&& should_process_response
|
||||
&& matches!(call_type, ProcessResponseCall::Recursive)
|
||||
{
|
||||
// --collect-backups was used; the response is one we care about, and the function
|
||||
// call came from the loop in `.start` (i.e. recursive was specified)
|
||||
let backup_urls = self.generate_backup_urls(&resp).await;
|
||||
|
||||
// need to manually adjust stats
|
||||
send_command!(tx_stats, AddToUsizeField(TotalExpected, backup_urls.len()));
|
||||
|
||||
for backup_url in &backup_urls {
|
||||
let backup_response = make_request(
|
||||
&self.config.client,
|
||||
backup_url,
|
||||
resp.method().as_str(),
|
||||
None,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Could not request backup of {}", resp.url().as_str())
|
||||
})?;
|
||||
|
||||
let ferox_response = FeroxResponse::from(
|
||||
backup_response,
|
||||
resp.url().as_str(),
|
||||
resp.method().as_str(),
|
||||
resp.output_level,
|
||||
self.config.response_size_limit,
|
||||
)
|
||||
.await;
|
||||
|
||||
let Some(handles) = self.handles.as_ref() else {
|
||||
// shouldn't ever happen, but we'll log and return early if it does
|
||||
log::error!("handles were unexpectedly None, this shouldn't happen");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if handles
|
||||
.filters
|
||||
.data
|
||||
.should_filter_response(&ferox_response, tx_stats.clone())
|
||||
{
|
||||
// response was filtered for one reason or another, don't process it
|
||||
continue;
|
||||
}
|
||||
|
||||
if handles.config.unique {
|
||||
let mut unique_filter = SimilarityFilter::from(&ferox_response);
|
||||
unique_filter.cutoff = UNIQUE_DISTANCE;
|
||||
handles.filters.data.push(Box::new(unique_filter))?;
|
||||
}
|
||||
|
||||
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().next_back().unwrap();
|
||||
|
||||
if !filename.is_empty() {
|
||||
// append rules
|
||||
for suffix in &self.config.backup_extensions {
|
||||
self.add_new_url_to_vec(url, &format!("{filename}{suffix}"), &mut urls);
|
||||
}
|
||||
|
||||
// vim swap rule
|
||||
self.add_new_url_to_vec(url, &format!(".{filename}.swp"), &mut urls);
|
||||
|
||||
// replace original extension rule
|
||||
let parts: Vec<_> = filename
|
||||
.split('.')
|
||||
// keep things like /.bash_history out of results
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.len() > 1 {
|
||||
// filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
|
||||
self.add_new_url_to_vec(url, &format!("{}.bak", parts.first().unwrap()), &mut urls);
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: generate_backup_urls -> {urls:?}");
|
||||
urls
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::event_handlers::Command;
|
||||
|
||||
#[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 handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
handles: Some(handles),
|
||||
};
|
||||
|
||||
println!("{toh:?}");
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// when the feroxresponse's url contains an extension, there should be 7 urls returned
|
||||
async fn generate_backup_urls_creates_correct_urls_when_extension_present() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
handles: Some(handles),
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"derp.php~",
|
||||
"derp.php.bak",
|
||||
"derp.php.bak2",
|
||||
"derp.php.old",
|
||||
"derp.php.1",
|
||||
".derp.php.swp",
|
||||
"derp.bak",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/derp.php");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let paths: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| url.path_segments().unwrap().next_back().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(urls.len(), 7);
|
||||
|
||||
for path in paths {
|
||||
assert!(expected.contains(&path));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// when the feroxresponse's url doesn't contain an extension, there should be 6 urls returned
|
||||
async fn generate_backup_urls_creates_correct_urls_when_extension_not_present() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
handles: Some(handles),
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"derp~",
|
||||
"derp.bak",
|
||||
"derp.bak2",
|
||||
"derp.old",
|
||||
"derp.1",
|
||||
".derp.swp",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/derp");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let paths: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| url.path_segments().unwrap().next_back().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(urls.len(), 6);
|
||||
|
||||
for path in paths {
|
||||
assert!(expected.contains(&path));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// test to ensure that backups are requested from the directory in which they were found
|
||||
/// re: issue #513
|
||||
async fn generate_backup_urls_creates_correct_urls_when_not_at_root() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
let handles = Arc::new(Handles::for_testing(None, None).0);
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
handles: Some(handles),
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"http://localhost/wordpress/derp.php~",
|
||||
"http://localhost/wordpress/derp.php.bak",
|
||||
"http://localhost/wordpress/derp.php.bak2",
|
||||
"http://localhost/wordpress/derp.php.old",
|
||||
"http://localhost/wordpress/derp.php.1",
|
||||
"http://localhost/wordpress/.derp.php.swp",
|
||||
"http://localhost/wordpress/derp.bak",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/wordpress/derp.php");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let url_strs: Vec<_> = urls.iter().map(|url| url.as_str()).collect();
|
||||
|
||||
assert_eq!(urls.len(), 7);
|
||||
|
||||
for url_str in url_strs {
|
||||
assert!(expected.contains(&url_str));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
}
|
||||
490
src/event_handlers/scans.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
response::FeroxResponse,
|
||||
scan_manager::{FeroxScan, FeroxScans, ScanOrder},
|
||||
scanner::{FeroxScanner, RESPONSES},
|
||||
statistics::StatField::TotalScans,
|
||||
sync::DynamicSemaphore,
|
||||
url::FeroxUrl,
|
||||
utils::should_deny_url,
|
||||
CommandReceiver, CommandSender, FeroxChannel, Joiner, SLEEP_DURATION,
|
||||
};
|
||||
|
||||
use super::command::Command::AddToUsizeField;
|
||||
use super::*;
|
||||
use crate::statistics::StatField;
|
||||
use crate::utils::parse_url_with_raw_path;
|
||||
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<DynamicSemaphore>,
|
||||
}
|
||||
|
||||
/// 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 = DynamicSemaphore::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.increase_capacity(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() {
|
||||
guard.replace(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,
|
||||
handles.config.limit_bars,
|
||||
));
|
||||
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();
|
||||
}
|
||||
}
|
||||
Command::AddScanPermits(value) => {
|
||||
let current = self.limiter.current_capacity();
|
||||
|
||||
self.limiter.increase_capacity(current + value);
|
||||
|
||||
log::debug!(
|
||||
"increased scan permits to {} (was {current})",
|
||||
current + value
|
||||
);
|
||||
}
|
||||
Command::SubtractScanPermits(value) => {
|
||||
let current = self.limiter.current_capacity();
|
||||
let new_capacity = current.saturating_sub(value);
|
||||
|
||||
self.limiter.reduce_capacity(new_capacity);
|
||||
|
||||
log::debug!("decreased scan permits to {new_capacity} (was {current})");
|
||||
}
|
||||
_ => {} // 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(0)?.len();
|
||||
let current_expectation = self.handles.expected_num_requests_per_dir() as u64;
|
||||
|
||||
// used in the calculation of bar width down below, see explanation there
|
||||
let divisor = (self.handles.expected_num_requests_multiplier() as u64 - 1).max(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().unwrap_or(1);
|
||||
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, offset: usize) -> Result<Arc<Vec<String>>> {
|
||||
if let Ok(guard) = self.wordlist.lock().as_ref() {
|
||||
if let Some(list) = guard.as_ref() {
|
||||
return if offset > 0 {
|
||||
Ok(Arc::new(list[offset..].to_vec()))
|
||||
} else {
|
||||
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, self.handles.clone())
|
||||
.1 // add the new target; return FeroxScan
|
||||
};
|
||||
|
||||
if should_test_deny
|
||||
&& should_deny_url(&parse_url_with_raw_path(&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 divisor = self.handles.expected_num_requests_multiplier();
|
||||
|
||||
let list = if divisor > 1 && scan.requests() > 0 {
|
||||
// if there were extensions provided and/or more than a single method used, and some
|
||||
// number of requests have already been sent, we need to adjust the offset into the
|
||||
// wordlist to ensure we don't index out of bounds
|
||||
|
||||
let adjusted = scan.requests_made_so_far() as f64 / (divisor as f64 - 1.0).max(1.0);
|
||||
self.get_wordlist(adjusted as usize)?
|
||||
} else {
|
||||
self.get_wordlist(scan.requests_made_so_far() as usize)?
|
||||
};
|
||||
|
||||
log::info!("scan handler received {target} - beginning scan");
|
||||
|
||||
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 !self.handles.config.force_recursion && !response.is_directory() {
|
||||
// not a directory and --force-recursion wasn't used, quick exit
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut base_depth = 1_usize;
|
||||
|
||||
for (base_url, base_url_depth) in &self.depths {
|
||||
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(());
|
||||
}
|
||||
|
||||
if let Ok(responses) = RESPONSES.responses.read() {
|
||||
for maybe_wild in responses.iter() {
|
||||
if !maybe_wild.wildcard() || !maybe_wild.is_directory() {
|
||||
// if the stored response isn't a wildcard, skip it
|
||||
// if the stored response isn't a directory, skip it
|
||||
// we're only interested in preventing recursion into wildcard directories
|
||||
continue;
|
||||
}
|
||||
|
||||
if maybe_wild.method() != response.method() {
|
||||
// methods don't match, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// methods match and is a directory wildcard
|
||||
// need to check the wildcard's parent directory
|
||||
// for equality with the incoming response's parent directory
|
||||
//
|
||||
// if the parent directories match, we need to prevent recursion
|
||||
// into the wildcard directory
|
||||
|
||||
match (
|
||||
maybe_wild.url().path_segments(),
|
||||
response.url().path_segments(),
|
||||
) {
|
||||
// both urls must have path segments
|
||||
(Some(mut maybe_wild_segments), Some(mut response_segments)) => {
|
||||
match (
|
||||
maybe_wild_segments.nth_back(1),
|
||||
response_segments.nth_back(1),
|
||||
) {
|
||||
// both urls must have at least 2 path segments, the next to last being the parent
|
||||
(Some(maybe_wild_parent), Some(response_parent)) => {
|
||||
if maybe_wild_parent == response_parent {
|
||||
// the parent directories match, so we need to prevent recursion
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// we couldn't get the parent directory, so we'll skip this
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// we couldn't get the path segments, so we'll skip this
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let targets = vec![response.url().to_string()];
|
||||
self.ordered_scan_url(targets, ScanOrder::Latest).await?;
|
||||
|
||||
log::info!("Added new directory to recursive scan: {}", response.url());
|
||||
|
||||
log::trace!("exit: try_recursion");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
183
src/event_handlers/statistics.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
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(offset) => {
|
||||
self.bar = add_bar("", self.stats.total_expected() as u64, BarType::Total);
|
||||
self.bar.set_position(offset);
|
||||
}
|
||||
Command::LoadStats(filename) => {
|
||||
self.stats.merge_from(&filename)?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
}
|
||||
Command::QueryOverallBarEta(sender) => {
|
||||
sender.send(self.bar.eta()).unwrap_or_default();
|
||||
}
|
||||
Command::UpdateTargets(targets) => {
|
||||
self.stats.update_targets(targets);
|
||||
}
|
||||
Command::Exit => break,
|
||||
_ => {} // no more commands needed
|
||||
}
|
||||
}
|
||||
|
||||
self.bar.finish();
|
||||
|
||||
log::info!("{:#?}", *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);
|
||||
|
||||
if self.bar.position() < self.stats.total_expected() as u64 {
|
||||
// don't run off the end when we're a few requests over the expected total
|
||||
// due to the heuristics tests
|
||||
self.bar.inc(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize new `Stats` object and the sc side of an mpsc channel that is responsible for
|
||||
/// updates to the aforementioned object.
|
||||
pub fn initialize(config: Arc<Configuration>) -> (Joiner, StatsHandle) {
|
||||
log::trace!("enter: initialize");
|
||||
|
||||
let data = Arc::new(Stats::new(config.json));
|
||||
let (tx, rx): FeroxChannel<Command> = mpsc::unbounded_channel();
|
||||
|
||||
let mut handler = StatsHandler::new(data.clone(), rx);
|
||||
|
||||
let task = tokio::spawn(async move { handler.start(&config.output).await });
|
||||
|
||||
let event_handle = StatsHandle::new(data, tx);
|
||||
|
||||
log::trace!("exit: initialize -> ({task:?}, {event_handle:?})");
|
||||
|
||||
(task, event_handle)
|
||||
}
|
||||
}
|
||||
269
src/extractor.rs
@@ -1,269 +0,0 @@
|
||||
use crate::FeroxResponse;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use reqwest::Url;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Regular expression used in [LinkFinder](https://github.com/GerbenJavado/LinkFinder)
|
||||
///
|
||||
/// Incorporates change from this [Pull Request](https://github.com/GerbenJavado/LinkFinder/pull/66/files)
|
||||
const LINKFINDER_REGEX: &str = r#"(?:"|')(((?:[a-zA-Z]{1,10}://|//)[^"'/]{1,}\.[a-zA-Z]{2,}[^"']{0,})|((?:/|\.\./|\./)[^"'><,;| *()(%%$^/\\\[\]][^"'><,;|()]{1,})|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{1,}\.(?:[a-zA-Z]{1,4}|action)(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{3,}(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-.]{1,}\.(?:php|asp|aspx|jsp|json|action|html|js|txt|xml)(?:[\?|#][^"|']{0,}|)))(?:"|')"#;
|
||||
|
||||
lazy_static! {
|
||||
/// `LINKFINDER_REGEX` as a regex::Regex type
|
||||
static ref REGEX: Regex = Regex::new(LINKFINDER_REGEX).unwrap();
|
||||
}
|
||||
|
||||
/// Iterate over a given path, return a list of every sub-path found
|
||||
///
|
||||
/// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// the following fragments would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
fn get_sub_paths_from_path(path: &str) -> Vec<String> {
|
||||
log::trace!("enter: get_sub_paths_from_path({})", path);
|
||||
let mut paths = vec![];
|
||||
|
||||
// filter out any empty strings caused by .split
|
||||
let mut parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
let length = parts.len();
|
||||
|
||||
for _ in 0..length {
|
||||
// iterate over all parts of the path
|
||||
if parts.is_empty() {
|
||||
// pop left us with an empty vector, we're done
|
||||
break;
|
||||
}
|
||||
|
||||
let possible_path = parts.join("/");
|
||||
|
||||
if possible_path.is_empty() {
|
||||
// .join can result in an empty string, which we don't need, ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.push(possible_path); // good sub-path found
|
||||
parts.pop(); // use .pop() to remove the last part of the path and continue iteration
|
||||
}
|
||||
|
||||
log::trace!("exit: get_sub_paths_from_path -> {:?}", paths);
|
||||
paths
|
||||
}
|
||||
|
||||
/// simple helper to stay DRY, trys to join a url + fragment and add it to the `links` HashSet
|
||||
fn add_link_to_set_of_links(link: &str, url: &Url, links: &mut HashSet<String>) {
|
||||
log::trace!(
|
||||
"enter: add_link_to_set_of_links({}, {}, {:?})",
|
||||
link,
|
||||
url.to_string(),
|
||||
links
|
||||
);
|
||||
match url.join(&link) {
|
||||
Ok(new_url) => {
|
||||
links.insert(new_url.to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not join given url to the base url: {}", e);
|
||||
}
|
||||
}
|
||||
log::trace!("exit: add_link_to_set_of_links");
|
||||
}
|
||||
|
||||
/// Given a `reqwest::Response`, perform the following actions
|
||||
/// - parse the response's text for links using the linkfinder regex
|
||||
/// - for every link found take its url path and parse each sub-path
|
||||
/// - example: Response contains a link fragment `homepage/assets/img/icons/handshake.svg`
|
||||
/// with a base url of http://localhost, the following urls would be returned:
|
||||
/// - homepage/assets/img/icons/handshake.svg
|
||||
/// - homepage/assets/img/icons/
|
||||
/// - homepage/assets/img/
|
||||
/// - homepage/assets/
|
||||
/// - homepage/
|
||||
pub async fn get_links(response: &FeroxResponse) -> HashSet<String> {
|
||||
log::trace!("enter: get_links({})", response.url().as_str());
|
||||
|
||||
let mut links = HashSet::<String>::new();
|
||||
|
||||
let body = response.text();
|
||||
|
||||
for capture in REGEX.captures_iter(&body) {
|
||||
// remove single & double quotes from both ends of the capture
|
||||
// capture[0] is the entire match, additional capture groups start at [1]
|
||||
let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
|
||||
|
||||
match Url::parse(link) {
|
||||
Ok(absolute) => {
|
||||
if absolute.domain() != response.url().domain()
|
||||
|| absolute.host() != response.url().host()
|
||||
{
|
||||
// domains/ips are not the same, don't scan things that aren't part of the original
|
||||
// target url
|
||||
continue;
|
||||
}
|
||||
|
||||
for sub_path in get_sub_paths_from_path(absolute.path()) {
|
||||
// take a url fragment like homepage/assets/img/icons/handshake.svg and
|
||||
// incrementally add
|
||||
// - homepage/assets/img/icons/
|
||||
// - homepage/assets/img/
|
||||
// - homepage/assets/
|
||||
// - homepage/
|
||||
log::debug!("Adding {} to {:?}", sub_path, links);
|
||||
add_link_to_set_of_links(&sub_path, &response.url(), &mut links);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// this is the expected error that happens when we try to parse a url fragment
|
||||
// ex: Url::parse("/login") -> Err("relative URL without a base")
|
||||
// while this is technically an error, these are good results for us
|
||||
if e.to_string().contains("relative URL without a base") {
|
||||
for sub_path in get_sub_paths_from_path(link) {
|
||||
// incrementally save all sub-paths that led to the relative url's resource
|
||||
log::debug!("Adding {} to {:?}", sub_path, links);
|
||||
add_link_to_set_of_links(&sub_path, &response.url(), &mut links);
|
||||
}
|
||||
} else {
|
||||
// unexpected error has occurred
|
||||
log::error!("Could not parse given url: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: get_links -> {:?}", links);
|
||||
links
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::make_request;
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::{Mock, MockServer};
|
||||
use reqwest::Client;
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 4 sub paths and that all are
|
||||
/// in the expected array
|
||||
fn extractor_get_sub_paths_from_path_with_multiple_paths() {
|
||||
let path = "homepage/assets/img/icons/handshake.svg";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec![
|
||||
"homepage",
|
||||
"homepage/assets",
|
||||
"homepage/assets/img",
|
||||
"homepage/assets/img/icons",
|
||||
"homepage/assets/img/icons/handshake.svg",
|
||||
];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 2 sub paths and that all are
|
||||
/// in the expected array. the fragment is wrapped in slashes to ensure no empty strings are
|
||||
/// returned
|
||||
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
|
||||
let path = "/homepage/assets/";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec!["homepage", "homepage/assets"];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, no forward slashes are
|
||||
/// included
|
||||
fn extractor_get_sub_paths_from_path_with_only_a_word() {
|
||||
let path = "homepage";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed
|
||||
fn extractor_get_sub_paths_from_path_with_an_absolute_word() {
|
||||
let path = "/homepage";
|
||||
let paths = get_sub_paths_from_path(&path);
|
||||
let expected = vec!["homepage"];
|
||||
|
||||
assert_eq!(paths.len(), expected.len());
|
||||
for expected_path in expected {
|
||||
assert_eq!(paths.contains(&expected_path.to_string()), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that a full url and fragment are joined correctly, then added to the given list
|
||||
/// i.e. the happy path
|
||||
fn extractor_add_link_to_set_of_links_happy_path() {
|
||||
let url = Url::parse("https://localhost").unwrap();
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "admin";
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
add_link_to_set_of_links(link, &url, &mut links);
|
||||
|
||||
assert_eq!(links.len(), 1);
|
||||
assert!(links.contains("https://localhost/admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that an invalid path fragment doesn't add anything to the set of links
|
||||
fn extractor_add_link_to_set_of_links_with_non_base_url() {
|
||||
let url = Url::parse("https://localhost").unwrap();
|
||||
let mut links = HashSet::<String>::new();
|
||||
let link = "\\\\\\\\";
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
add_link_to_set_of_links(link, &url, &mut links);
|
||||
|
||||
assert_eq!(links.len(), 0);
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(core_threads = 1)]
|
||||
/// use make_request to generate a Response, and use the Response to test get_links;
|
||||
/// the response will contain an absolute path to a domain that is not part of the scanned
|
||||
/// domain; expect an empty set returned
|
||||
async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = Mock::new()
|
||||
.expect_method(GET)
|
||||
.expect_path("/some-path")
|
||||
.return_status(200)
|
||||
.return_body("\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"")
|
||||
.create_on(&srv);
|
||||
|
||||
let client = Client::new();
|
||||
let url = Url::parse(&srv.url("/some-path")).unwrap();
|
||||
|
||||
let response = make_request(&client, &url).await.unwrap();
|
||||
|
||||
let ferox_response = FeroxResponse::from(response, true).await;
|
||||
|
||||
let links = get_links(&ferox_response).await;
|
||||
|
||||
assert!(links.is_empty());
|
||||
|
||||
assert_eq!(mock.times_called(), 1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
149
src/extractor/builder.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use super::*;
|
||||
use crate::event_handlers::Handles;
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Regular expression used in [LinkFinder](https://github.com/GerbenJavado/LinkFinder)
|
||||
///
|
||||
/// updated on 8 August 2025 to commit 1debac5dace4724fd6187c06f133578dae51c86f
|
||||
///
|
||||
/// NOTE: the ` ? or # mark with parameters` lines need to have the # character escaped as `\#`
|
||||
/// to avoid being interpreted as a comment by the Rust compiler
|
||||
pub(super) const LINKFINDER_REGEX: &str = r#"(?x)
|
||||
(?:"|') # Start newline delimiter
|
||||
|
||||
(
|
||||
((?:[a-zA-Z]{1,10}://|//) # Match a scheme [a-Z]*1-10 or //
|
||||
[^"'/]{1,}\. # Match a domainname (any character + dot)
|
||||
[a-zA-Z]{2,}[^"']{0,}) # The domainextension and/or path
|
||||
|
||||
|
|
||||
|
||||
((?:/|\.\./|\./) # Start with /,../,./
|
||||
[^"'><,;| *()(%%$^/\\\[\]] # Next character can't be...
|
||||
[^"'><,;|()]{1,}) # Rest of the characters can't be
|
||||
|
||||
|
|
||||
|
||||
([a-zA-Z0-9_\-/]{1,}/ # Relative endpoint with /
|
||||
[a-zA-Z0-9_\-/.]{1,} # Resource name
|
||||
\.(?:[a-zA-Z]{1,4}|action) # Rest + extension (length 1-4 or action)
|
||||
(?:[\?|\#][^"|']{0,}|)) # ? or # mark with parameters
|
||||
|
||||
|
|
||||
|
||||
([a-zA-Z0-9_\-/]{1,}/ # REST API (no extension) with /
|
||||
[a-zA-Z0-9_\-/]{3,} # Proper REST endpoints usually have 3+ chars
|
||||
(?:[\?|\#][^"|']{0,}|)) # ? or # mark with parameters
|
||||
|
||||
|
|
||||
|
||||
([a-zA-Z0-9_\-]{1,} # filename
|
||||
\.(?:php|asp|aspx|jsp|json|
|
||||
action|html|js|txt|xml) # . + extension
|
||||
(?:[\?|\#][^"|']{0,}|)) # ? or # mark with parameters
|
||||
|
||||
)
|
||||
|
||||
(?:"|') # End newline delimiter
|
||||
"#;
|
||||
|
||||
/// 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)
|
||||
|
||||
/// Regular expression to filter bad characters from extracted url paths
|
||||
///
|
||||
/// ref: https://www.rfc-editor.org/rfc/rfc3986#section-2
|
||||
pub(super) const URL_CHARS_REGEX: &str = r#"["<>\\^`{|} ]"#;
|
||||
|
||||
/// Which type of extraction should be performed
|
||||
#[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(),
|
||||
url_regex: Regex::new(URL_CHARS_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,
|
||||
})
|
||||
}
|
||||
}
|
||||
723
src/extractor/container.rs
Normal file
@@ -0,0 +1,723 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
client,
|
||||
event_handlers::{
|
||||
Command::{AddError, AddToUsizeField},
|
||||
Handles,
|
||||
},
|
||||
filters::SimilarityFilter,
|
||||
scan_manager::ScanOrder,
|
||||
statistics::{
|
||||
StatError::Other,
|
||||
StatField::{LinksExtracted, TotalExpected},
|
||||
},
|
||||
url::{FeroxUrl, UrlExt},
|
||||
utils::{
|
||||
logged_request, make_request, parse_url_with_raw_path, send_try_recursion_command,
|
||||
should_deny_url,
|
||||
},
|
||||
ExtractionResult, DEFAULT_METHOD, UNIQUE_DISTANCE,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use futures::StreamExt;
|
||||
use reqwest::{Client, Response, StatusCode, Url};
|
||||
use scraper::{Html, Selector};
|
||||
use std::{borrow::Cow, collections::HashSet};
|
||||
|
||||
/// 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(url: &str, handles: Arc<Handles>) -> Result<Response> {
|
||||
log::trace!("enter: request_link({url})");
|
||||
|
||||
let ferox_url = FeroxUrl::from_string(url, handles.clone());
|
||||
|
||||
// create a url based on the given command line options
|
||||
let new_url = ferox_url.format("", None)?;
|
||||
|
||||
let scanned_urls = handles.ferox_scans()?;
|
||||
|
||||
if scanned_urls.get_scan_by_url(new_url.as_ref()).is_some() {
|
||||
//we've seen the url before and don't need to scan again
|
||||
log::trace!("exit: request_link -> None");
|
||||
bail!("previously seen url");
|
||||
}
|
||||
|
||||
if (!handles.config.url_denylist.is_empty() || !handles.config.regex_denylist.is_empty())
|
||||
&& should_deny_url(&new_url, handles.clone())?
|
||||
{
|
||||
// can't allow a denied url to be requested
|
||||
bail!(
|
||||
"prevented request to {} due to {:?} || {:?}",
|
||||
url,
|
||||
handles.config.url_denylist,
|
||||
handles.config.regex_denylist,
|
||||
);
|
||||
}
|
||||
|
||||
// make the request and store the response
|
||||
let new_response = logged_request(&new_url, DEFAULT_METHOD, None, handles.clone()).await?;
|
||||
|
||||
log::trace!("exit: request_link -> {new_response:?}");
|
||||
|
||||
Ok(new_response)
|
||||
}
|
||||
|
||||
/// Whether an active scan is recursive or not
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
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,
|
||||
|
||||
/// regex to validate a url
|
||||
pub(super) url_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 list of in-scope urls
|
||||
/// - otherwise, calls `add_all_sub_paths` with the parsed result
|
||||
fn parse_url_and_add_subpaths(
|
||||
&self,
|
||||
url_to_parse: &str,
|
||||
links: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
log::trace!("enter: parse_url_and_add_subpaths({links:?})");
|
||||
|
||||
match parse_url_with_raw_path(url_to_parse) {
|
||||
Ok(absolute) => {
|
||||
if !absolute.is_in_scope(&self.handles.config.scope) {
|
||||
// URL is not in scope based on domain/scope configuration
|
||||
bail!("parsed url is not in scope");
|
||||
}
|
||||
|
||||
if self.add_all_sub_paths(absolute.path(), links).is_err() {
|
||||
log::warn!("could not add sub-paths from {absolute} to {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") {
|
||||
// scope for these should be enforced in add_all_sub_paths since
|
||||
// we join the fragment with the base url there and can check
|
||||
// the full Url against scope
|
||||
if self.add_all_sub_paths(url_to_parse, links).is_err() {
|
||||
log::warn!("could not add sub-paths from {url_to_parse} to {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<Option<tokio::task::JoinHandle<()>>> {
|
||||
log::trace!("enter: request_links({links:?})");
|
||||
|
||||
if links.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
self.update_stats(links.len())?;
|
||||
|
||||
// create clones/remove use of self of/from everything the async move block will need to function
|
||||
let cloned_scanned_urls = self.handles.ferox_scans()?;
|
||||
let cloned_handles = self.handles.clone();
|
||||
let cloned_url = self.url.clone();
|
||||
let threads = self.handles.config.threads;
|
||||
let recursive = if self.handles.config.no_recursion {
|
||||
RecursionStatus::NotRecursive
|
||||
} else {
|
||||
RecursionStatus::Recursive
|
||||
};
|
||||
|
||||
let link_request_task = tokio::spawn(async move {
|
||||
let producers = futures::stream::iter(links.into_iter())
|
||||
.map(|link| {
|
||||
// another clone to satisfy the async move block
|
||||
let inner_clone = cloned_handles.clone();
|
||||
|
||||
(
|
||||
tokio::spawn(async move { request_link(&link, inner_clone).await }),
|
||||
cloned_handles.clone(),
|
||||
cloned_scanned_urls.clone(),
|
||||
recursive,
|
||||
cloned_url.clone(),
|
||||
)
|
||||
})
|
||||
.for_each_concurrent(
|
||||
threads,
|
||||
|(join_handle, c_handles, c_scanned_urls, c_recursive, og_url)| async move {
|
||||
match join_handle.await {
|
||||
Ok(Ok(reqwest_response)) => {
|
||||
let mut resp = FeroxResponse::from(
|
||||
reqwest_response,
|
||||
&og_url,
|
||||
DEFAULT_METHOD,
|
||||
c_handles.config.output_level,
|
||||
c_handles.config.response_size_limit,
|
||||
)
|
||||
.await;
|
||||
|
||||
// filter if necessary
|
||||
if c_handles
|
||||
.filters
|
||||
.data
|
||||
.should_filter_response(&resp, c_handles.stats.tx.clone())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if c_handles.config.unique {
|
||||
// if the filter above didn't filter it out, add it as a unique filter
|
||||
let mut unique_filter = SimilarityFilter::from(&resp);
|
||||
unique_filter.cutoff = UNIQUE_DISTANCE;
|
||||
c_handles
|
||||
.filters
|
||||
.data
|
||||
.push(Box::new(unique_filter))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
// request and report assumed file
|
||||
if (resp.is_file() || !resp.is_directory())
|
||||
&& !c_handles.config.force_recursion
|
||||
{
|
||||
log::debug!("Extracted File: {resp}");
|
||||
|
||||
c_scanned_urls.add_file_scan(
|
||||
resp.url().as_str(),
|
||||
ScanOrder::Latest,
|
||||
c_handles.clone(),
|
||||
);
|
||||
|
||||
if c_handles.config.collect_extensions {
|
||||
// no real reason this should fail
|
||||
resp.parse_extension(c_handles.clone()).unwrap();
|
||||
}
|
||||
|
||||
if let Err(e) = resp.send_report(c_handles.output.tx.clone()) {
|
||||
log::warn!(
|
||||
"Could not send FeroxResponse to output handler: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(c_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()));
|
||||
}
|
||||
|
||||
if c_handles.config.filter_status.is_empty() {
|
||||
// -C wasn't used, so -s is the only 'filter' left to account for
|
||||
if c_handles
|
||||
.config
|
||||
.status_codes
|
||||
.contains(&resp.status().as_u16())
|
||||
{
|
||||
send_try_recursion_command(c_handles.clone(), resp)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
}
|
||||
} else {
|
||||
// -C was used, that means the filters above would have removed
|
||||
// those responses, and anything else should be let through
|
||||
send_try_recursion_command(c_handles.clone(), resp)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
log::warn!("Error during link extraction: {err}");
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("JoinError during link extraction: {err}");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// wait for the requests to finish
|
||||
producers.await;
|
||||
});
|
||||
|
||||
log::trace!("exit: request_links");
|
||||
Ok(Some(link_request_task))
|
||||
}
|
||||
|
||||
/// 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");
|
||||
self.extract_links_by_attr(resp_url, links, html, "link", "href");
|
||||
}
|
||||
|
||||
/// Given the body of a `reqwest::Response`, perform the following actions
|
||||
/// - parse the body for links using the linkfinder regex
|
||||
/// - for every link found take its url path and parse each sub-path
|
||||
/// - 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, 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<Cow<_>> = normalized_path
|
||||
.split('/')
|
||||
.map(|s| self.url_regex.replace_all(s, ""))
|
||||
.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 parse_url_with_raw_path(&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 {old_url} with {link}"))?;
|
||||
|
||||
if !new_url.is_in_scope(&self.handles.config.scope) {
|
||||
// URL is not in scope based on domain/scope configuration
|
||||
log::debug!("Skipping {new_url} because it's not in scope");
|
||||
log::trace!("exit: add_link_to_set_of_links");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
links.insert(new_url.to_string());
|
||||
|
||||
log::trace!("exit: add_link_to_set_of_links");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 = parse_url_with_raw_path(&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 {new_url} to {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, 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())
|
||||
};
|
||||
|
||||
let server_certs = &self.handles.config.server_certs;
|
||||
|
||||
let client_cert = if self.handles.config.client_cert.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.handles.config.client_cert.as_str())
|
||||
};
|
||||
|
||||
let client_key = if self.handles.config.client_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.handles.config.client_key.as_str())
|
||||
};
|
||||
|
||||
let client_config = client::ClientConfig {
|
||||
timeout: self.handles.config.timeout,
|
||||
user_agent: &self.handles.config.user_agent,
|
||||
redirects: follow_redirects,
|
||||
insecure: self.handles.config.insecure,
|
||||
headers: &self.handles.config.headers,
|
||||
proxy,
|
||||
server_certs: Some(server_certs),
|
||||
client_cert,
|
||||
client_key,
|
||||
scope: &self.handles.config.scope,
|
||||
};
|
||||
client = client::initialize(client_config)?;
|
||||
}
|
||||
|
||||
let client = if location != "/robots.txt" {
|
||||
&self.handles.config.client
|
||||
} else {
|
||||
&client
|
||||
};
|
||||
|
||||
let mut url = parse_url_with_raw_path(&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,
|
||||
self.handles.config.response_size_limit,
|
||||
)
|
||||
.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;
|
||||
416
src/extractor/tests.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
use super::builder::{LINKFINDER_REGEX, ROBOTS_TXT_REGEX, URL_CHARS_REGEX};
|
||||
use super::container::request_link;
|
||||
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),
|
||||
};
|
||||
|
||||
// need to add scope to the config to allow extracted links to make it through the
|
||||
// full pipeline
|
||||
let mut config = Configuration::new().unwrap();
|
||||
config.scope.push(Url::parse("http://localhost").unwrap());
|
||||
|
||||
let config = Arc::new(config);
|
||||
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,
|
||||
4194304,
|
||||
)
|
||||
.await;
|
||||
|
||||
let extractor = Extractor {
|
||||
links_regex: Regex::new(LINKFINDER_REGEX).unwrap(),
|
||||
robots_regex: Regex::new(ROBOTS_TXT_REGEX).unwrap(),
|
||||
url_regex: Regex::new(URL_CHARS_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(),
|
||||
url_regex: Regex::new(URL_CHARS_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 = request_link(&srv.url("/login.php"), ROBOTS_EXT.handles.clone()).await?;
|
||||
let b_resp = request_link(&srv.url("/login.php"), BODY_EXT.handles.clone()).await?;
|
||||
|
||||
assert!(matches!(r_resp.status(), StatusCode::OK));
|
||||
assert!(matches!(b_resp.status(), StatusCode::OK));
|
||||
assert_eq!(r_resp.content_length().unwrap(), 14);
|
||||
assert_eq!(b_resp.content_length().unwrap(), 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,
|
||||
Arc::new(Handles::for_testing(None, None).0),
|
||||
);
|
||||
|
||||
let robots = setup_extractor(ExtractionTarget::RobotsTxt, scans.clone());
|
||||
let body = setup_extractor(ExtractionTarget::ResponseBody, scans);
|
||||
|
||||
let r_resp = request_link(&served, robots.handles.clone()).await;
|
||||
let b_resp = request_link(&served, body.handles.clone()).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));
|
||||
}
|
||||
}
|
||||
131
src/filters/container.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{ser::SerializeSeq, Serialize, Serializer};
|
||||
|
||||
use crate::response::FeroxResponse;
|
||||
|
||||
use super::{
|
||||
FeroxFilter, LinesFilter, RegexFilter, SimilarityFilter, SizeFilter, StatusCodeFilter,
|
||||
WildcardFilter, WordsFilter,
|
||||
};
|
||||
use crate::{
|
||||
event_handlers::Command::AddToUsizeField, statistics::StatField::WildcardsFiltered,
|
||||
CommandSender,
|
||||
};
|
||||
/// 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) {
|
||||
log::debug!("filtering response due to: {filter:?}");
|
||||
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(wildcard_filter) =
|
||||
filter.as_any().downcast_ref::<WildcardFilter>()
|
||||
{
|
||||
seq.serialize_element(wildcard_filter).unwrap_or_default();
|
||||
} else if let Some(status_filter) =
|
||||
filter.as_any().downcast_ref::<StatusCodeFilter>()
|
||||
{
|
||||
seq.serialize_element(status_filter).unwrap_or_default();
|
||||
} else if let Some(regex_filter) = filter.as_any().downcast_ref::<RegexFilter>() {
|
||||
seq.serialize_element(regex_filter).unwrap_or_default();
|
||||
} else if let Some(similarity_filter) =
|
||||
filter.as_any().downcast_ref::<SimilarityFilter>()
|
||||
{
|
||||
seq.serialize_element(similarity_filter).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
seq.end()
|
||||
} else {
|
||||
// if for some reason we can't unlock the mutex, just write an empty list
|
||||
let seq = serializer.serialize_seq(Some(0))?;
|
||||
seq.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/filters/empty.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
|
||||
/// Dummy filter for internal shenanigans
|
||||
#[derive(Default, Debug, PartialEq, Eq)]
|
||||
pub struct EmptyFilter {}
|
||||
|
||||
impl FeroxFilter for EmptyFilter {
|
||||
/// `EmptyFilter` always returns false
|
||||
fn should_filter_response(&self, _response: &FeroxResponse) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one EmptyFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>() == Some(self)
|
||||
}
|
||||
|
||||
/// 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, Eq, 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>() == Some(self)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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, SIM_HASHER};
|
||||
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 status_code;
|
||||
mod words;
|
||||
mod lines;
|
||||
mod size;
|
||||
mod regex;
|
||||
mod similarity;
|
||||
mod container;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod init;
|
||||
mod utils;
|
||||
mod wildcard;
|
||||
mod empty;
|
||||
60
src/filters/regex.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
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());
|
||||
let other = response.headers().iter().any(|(k, v)| {
|
||||
self.compiled.is_match(k.as_str()) || self.compiled.is_match(v.to_str().unwrap_or(""))
|
||||
});
|
||||
|
||||
let final_result = result || other;
|
||||
log::trace!("exit: should_filter_response -> {final_result}");
|
||||
|
||||
final_result
|
||||
}
|
||||
|
||||
/// Compare one SizeFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>() == Some(self)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
68
src/filters/similarity.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
use crate::nlp::preprocess;
|
||||
use crate::NEAR_DUPLICATE_DISTANCE;
|
||||
use gaoya::simhash::{SimHash, SimHashBits, SimSipHasher64};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
/// single instance of the sip hasher used in similarity filtering
|
||||
pub static ref SIM_HASHER: SimHash<SimSipHasher64, u64, 64> =
|
||||
SimHash::<SimSipHasher64, u64, 64>::new(SimSipHasher64::new(1, 2));
|
||||
}
|
||||
|
||||
/// 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, Eq, Serialize, Deserialize)]
|
||||
pub struct SimilarityFilter {
|
||||
/// Hash of Response's body to be used during similarity comparison
|
||||
pub hash: u64,
|
||||
|
||||
/// Url originally requested for the similarity filter
|
||||
pub original_url: String,
|
||||
|
||||
/// Maximum hamming distance allowed between two signatures
|
||||
pub cutoff: usize,
|
||||
}
|
||||
|
||||
impl SimilarityFilter {
|
||||
/// Create a new SimilarityFilter
|
||||
pub fn new(hash: u64, original_url: String, cutoff: usize) -> Self {
|
||||
Self {
|
||||
hash,
|
||||
original_url,
|
||||
cutoff,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&FeroxResponse> for SimilarityFilter {
|
||||
fn from(response: &FeroxResponse) -> Self {
|
||||
Self::new(
|
||||
SIM_HASHER.create_signature(preprocess(response.text()).iter()),
|
||||
response.url().to_string(),
|
||||
NEAR_DUPLICATE_DISTANCE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = SIM_HASHER.create_signature(preprocess(response.text()).iter());
|
||||
self.hash.hamming_distance(&other) <= self.cutoff
|
||||
}
|
||||
|
||||
/// Compare one SimilarityFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other
|
||||
.downcast_ref::<Self>()
|
||||
.is_some_and(|a| self.hash == a.hash)
|
||||
}
|
||||
|
||||
/// 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, Eq, 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>() == Some(self)
|
||||
}
|
||||
|
||||
/// 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, Eq, 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>() == Some(self)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
290
src/filters/tests.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
use super::*;
|
||||
use crate::nlp::preprocess;
|
||||
use crate::DEFAULT_METHOD;
|
||||
use crate::NEAR_DUPLICATE_DISTANCE;
|
||||
use ::regex::Regex;
|
||||
|
||||
#[test]
|
||||
/// simply test the default values for wildcardfilter
|
||||
fn wildcard_filter_default() {
|
||||
let wcf = WildcardFilter::default();
|
||||
assert_eq!(wcf.content_length, None);
|
||||
assert_eq!(wcf.line_count, None);
|
||||
assert_eq!(wcf.word_count, None);
|
||||
assert_eq!(wcf.method, DEFAULT_METHOD.to_string());
|
||||
assert_eq!(wcf.status_code, 0);
|
||||
assert!(!wcf.dont_filter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// just a simple test to increase code coverage by hitting as_any and the inner value
|
||||
fn wildcard_filter_as_any() {
|
||||
let mut filter = WildcardFilter::default();
|
||||
let filter2 = WildcardFilter::default();
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
assert_eq!(
|
||||
*filter.as_any().downcast_ref::<WildcardFilter>().unwrap(),
|
||||
filter2
|
||||
);
|
||||
|
||||
filter.content_length = Some(1);
|
||||
|
||||
assert_ne!(
|
||||
*filter.as_any().downcast_ref::<WildcardFilter>().unwrap(),
|
||||
filter2
|
||||
);
|
||||
}
|
||||
|
||||
#[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 body =
|
||||
"pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus faucibus";
|
||||
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost");
|
||||
resp.set_text(body);
|
||||
|
||||
let filter = WildcardFilter {
|
||||
content_length: Some(body.len() as u64),
|
||||
line_count: Some(1),
|
||||
word_count: Some(10),
|
||||
method: DEFAULT_METHOD.to_string(),
|
||||
status_code: 200,
|
||||
dont_filter: false,
|
||||
};
|
||||
|
||||
assert!(filter.should_filter_response(&resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test should_filter on WilcardFilter where static logic matches but response length is 0
|
||||
fn wildcard_should_filter_when_static_wildcard_len_is_zero() {
|
||||
let mut resp = FeroxResponse::default();
|
||||
resp.set_wildcard(true);
|
||||
resp.set_url("http://localhost");
|
||||
|
||||
// default WildcardFilter is used in the code that executes when response.content_length() == 0
|
||||
let filter = WildcardFilter {
|
||||
content_length: Some(0),
|
||||
line_count: Some(0),
|
||||
word_count: Some(0),
|
||||
method: DEFAULT_METHOD.to_string(),
|
||||
status_code: 200,
|
||||
dont_filter: false,
|
||||
};
|
||||
|
||||
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 {
|
||||
content_length: None,
|
||||
line_count: None,
|
||||
word_count: Some(8),
|
||||
method: DEFAULT_METHOD.to_string(),
|
||||
status_code: 200,
|
||||
dont_filter: false,
|
||||
};
|
||||
|
||||
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: SIM_HASHER.create_signature(["kitten"].iter()),
|
||||
original_url: "".to_string(),
|
||||
cutoff: NEAR_DUPLICATE_DISTANCE,
|
||||
};
|
||||
|
||||
// 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 = SIM_HASHER.create_signature([""].iter());
|
||||
|
||||
// two empty strings are the same
|
||||
assert!(!filter.should_filter_response(&resp));
|
||||
|
||||
resp.set_text("some data hash purposes running test");
|
||||
filter.hash = SIM_HASHER.create_signature(
|
||||
preprocess("some data to hash for the purposes of running a test").iter(),
|
||||
);
|
||||
|
||||
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: 1,
|
||||
original_url: "".to_string(),
|
||||
cutoff: NEAR_DUPLICATE_DISTANCE,
|
||||
};
|
||||
|
||||
let filter2 = SimilarityFilter {
|
||||
hash: 1,
|
||||
original_url: "".to_string(),
|
||||
cutoff: NEAR_DUPLICATE_DISTANCE,
|
||||
};
|
||||
|
||||
assert!(filter.box_eq(filter2.as_any()));
|
||||
|
||||
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 = [
|
||||
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));
|
||||
}
|
||||
}
|
||||
199
src/filters/utils.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use super::FeroxFilter;
|
||||
use super::SimilarityFilter;
|
||||
use crate::event_handlers::Handles;
|
||||
use crate::response::FeroxResponse;
|
||||
use crate::utils::{logged_request, parse_url_with_raw_path};
|
||||
use crate::DEFAULT_METHOD;
|
||||
use crate::NEAR_DUPLICATE_DISTANCE;
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
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 = parse_url_with_raw_path(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,
|
||||
handles.config.response_size_limit,
|
||||
)
|
||||
.await;
|
||||
|
||||
if handles.config.collect_extensions {
|
||||
fr.parse_extension(handles.clone())?;
|
||||
}
|
||||
|
||||
let filter = SimilarityFilter::from(&fr);
|
||||
|
||||
Ok(filter)
|
||||
}
|
||||
|
||||
/// 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::new(
|
||||
0,
|
||||
filter_value.to_string(),
|
||||
NEAR_DUPLICATE_DISTANCE,
|
||||
)));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Configuration;
|
||||
use crate::filters::{LinesFilter, RegexFilter, SizeFilter, StatusCodeFilter, WordsFilter};
|
||||
use crate::scan_manager::FeroxScans;
|
||||
use httpmock::Method::GET;
|
||||
use httpmock::MockServer;
|
||||
|
||||
#[test]
|
||||
/// filter_lookup returns correct filters
|
||||
fn filter_lookup_returns_correct_filters() {
|
||||
let filter = filter_lookup("status", "200").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<StatusCodeFilter>().unwrap(),
|
||||
&StatusCodeFilter { filter_code: 200 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("lines", "10").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<LinesFilter>().unwrap(),
|
||||
&LinesFilter { line_count: 10 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("size", "20").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<SizeFilter>().unwrap(),
|
||||
&SizeFilter { content_length: 20 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("words", "30").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<WordsFilter>().unwrap(),
|
||||
&WordsFilter { word_count: 30 }
|
||||
);
|
||||
|
||||
let filter = filter_lookup("regex", "stuff.*").unwrap();
|
||||
let compiled = Regex::new("stuff.*").unwrap();
|
||||
let raw_string = String::from("stuff.*");
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<RegexFilter>().unwrap(),
|
||||
&RegexFilter {
|
||||
compiled,
|
||||
raw_string
|
||||
}
|
||||
);
|
||||
|
||||
let filter = filter_lookup("similarity", "http://localhost").unwrap();
|
||||
assert_eq!(
|
||||
filter.as_any().downcast_ref::<SimilarityFilter>().unwrap(),
|
||||
&SimilarityFilter {
|
||||
hash: 0,
|
||||
original_url: "http://localhost".to_string(),
|
||||
cutoff: NEAR_DUPLICATE_DISTANCE,
|
||||
}
|
||||
);
|
||||
|
||||
assert!(filter_lookup("non-existent", "").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// ensure create_similarity_filter correctness of return value and side-effects
|
||||
async fn create_similarity_filter_is_correct() {
|
||||
let srv = MockServer::start();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/");
|
||||
then.status(200).body("this is a test");
|
||||
});
|
||||
|
||||
let scans = FeroxScans::default();
|
||||
let config = Configuration {
|
||||
collect_extensions: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (test_handles, _) = Handles::for_testing(Some(Arc::new(scans)), Some(Arc::new(config)));
|
||||
|
||||
let handles = Arc::new(test_handles);
|
||||
|
||||
let filter = create_similarity_filter(&srv.url("/"), handles.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
|
||||
assert_eq!(
|
||||
filter,
|
||||
SimilarityFilter {
|
||||
hash: 14897447612059286329,
|
||||
original_url: srv.url("/"),
|
||||
cutoff: NEAR_DUPLICATE_DISTANCE,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
180
src/filters/wildcard.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use console::style;
|
||||
|
||||
use super::*;
|
||||
use crate::utils::create_report_string;
|
||||
use crate::{config::OutputLevel, DEFAULT_METHOD};
|
||||
|
||||
/// Data holder for all relevant data needed when auto-filtering out wildcard responses
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WildcardFilter {
|
||||
/// The content-length of this response, if known
|
||||
pub content_length: Option<u64>,
|
||||
|
||||
/// The number of lines contained in the body of this response, if known
|
||||
pub line_count: Option<usize>,
|
||||
|
||||
/// The number of words contained in the body of this response, if known
|
||||
pub word_count: Option<usize>,
|
||||
|
||||
/// method used in request that should be included with filters passed via runtime configuration
|
||||
pub method: String,
|
||||
|
||||
/// the status code returned in the response
|
||||
pub status_code: u16,
|
||||
|
||||
/// whether or not the user passed -D on the command line
|
||||
pub 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 `method` with its default value
|
||||
impl Default for WildcardFilter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content_length: None,
|
||||
line_count: None,
|
||||
word_count: None,
|
||||
method: DEFAULT_METHOD.to_string(),
|
||||
status_code: 0,
|
||||
dont_filter: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// implementation of FeroxFilter for WildcardFilter
|
||||
impl FeroxFilter for WildcardFilter {
|
||||
/// Examine size/words/lines and method 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.method != response.method().as_str() {
|
||||
// method's don't match, so this response should not be filtered out
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.status_code != response.status().as_u16() {
|
||||
// status codes don't match, so this response should not be filtered out
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
return false;
|
||||
}
|
||||
|
||||
// methods and status codes match at this point, just need to check the other fields
|
||||
|
||||
match (self.content_length, self.word_count, self.line_count) {
|
||||
(Some(cl), Some(wc), Some(lc)) => {
|
||||
if cl == response.content_length()
|
||||
&& wc == response.word_count()
|
||||
&& lc == response.line_count()
|
||||
{
|
||||
log::debug!("filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(Some(cl), Some(wc), None) => {
|
||||
if cl == response.content_length() && wc == response.word_count() {
|
||||
log::debug!("filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(Some(cl), None, Some(lc)) => {
|
||||
if cl == response.content_length() && lc == response.line_count() {
|
||||
log::debug!("filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(None, Some(wc), Some(lc)) => {
|
||||
if wc == response.word_count() && lc == response.line_count() {
|
||||
log::debug!("filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(Some(cl), None, None) => {
|
||||
if cl == response.content_length() {
|
||||
log::debug!("filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(None, Some(wc), None) => {
|
||||
if wc == response.word_count() {
|
||||
log::debug!("filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(None, None, Some(lc)) => {
|
||||
if lc == response.line_count() {
|
||||
log::debug!("filtered out {}", response.url());
|
||||
log::trace!("exit: should_filter_response -> true");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(None, None, None) => {
|
||||
unreachable!("wildcard filter without any filters set");
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: should_filter_response -> false");
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare one WildcardFilter to another
|
||||
fn box_eq(&self, other: &dyn Any) -> bool {
|
||||
other.downcast_ref::<Self>() == Some(self)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WildcardFilter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let msg = create_report_string(
|
||||
self.status_code.to_string().as_str(),
|
||||
self.method.as_str(),
|
||||
&self
|
||||
.line_count
|
||||
.map_or_else(|| "-".to_string(), |x| x.to_string()),
|
||||
&self
|
||||
.word_count
|
||||
.map_or_else(|| "-".to_string(), |x| x.to_string()),
|
||||
&self
|
||||
.content_length
|
||||
.map_or_else(|| "-".to_string(), |x| x.to_string()),
|
||||
&format!(
|
||||
"{} found {}-like response and created new filter; toggle off with {}",
|
||||
style("Auto-filtering").bright().green(),
|
||||
style("404").red(),
|
||||
style("--dont-filter").yellow()
|
||||
),
|
||||
OutputLevel::Default,
|
||||
);
|
||||
write!(f, "{msg}")
|
||||
}
|
||||
}
|
||||
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, Eq, 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>() == Some(self)
|
||||
}
|
||||
|
||||
/// Return self as Any for dynamic dispatch purposes
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1,247 +1,603 @@
|
||||
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::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use console::style;
|
||||
use indicatif::ProgressBar;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use futures::future;
|
||||
use scraper::{Html, Selector};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// length of a standard UUID, used when determining wildcard responses
|
||||
const UUID_LENGTH: u64 = 32;
|
||||
use crate::filters::{SimilarityFilter, WildcardFilter};
|
||||
use crate::message::FeroxMessage;
|
||||
use crate::scanner::RESPONSES;
|
||||
use crate::{
|
||||
config::OutputLevel,
|
||||
event_handlers::{Command, Handles},
|
||||
progress::PROGRESS_PRINTER,
|
||||
response::FeroxResponse,
|
||||
skip_fail,
|
||||
url::FeroxUrl,
|
||||
utils::{ferox_print, fmt_err, logged_request},
|
||||
DEFAULT_METHOD,
|
||||
};
|
||||
|
||||
/// 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![];
|
||||
/// 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,
|
||||
|
||||
for _ in 0..length {
|
||||
ids.push(Uuid::new_v4().to_simple().to_string());
|
||||
}
|
||||
/// tomcat/python server, detected by `Directory Listing for /`
|
||||
TomCatOrPython,
|
||||
|
||||
let unique_id = ids.join("");
|
||||
/// ASP.NET server, detected by `Directory Listing -- /`
|
||||
AspDotNet,
|
||||
|
||||
log::trace!("exit: unique_string -> {}", unique_id);
|
||||
unique_id
|
||||
// /// IIS/Azure server, detected by `HOST_NAME - /` (not currently used)
|
||||
// IIS_AZURE,
|
||||
/// variant that represents the absence of directory listing
|
||||
None,
|
||||
}
|
||||
|
||||
/// 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
|
||||
);
|
||||
/// 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>,
|
||||
|
||||
if CONFIGURATION.dont_filter {
|
||||
// early return, dont_filter scans don't need tested
|
||||
log::trace!("exit: wildcard_test -> None");
|
||||
return None;
|
||||
/// the `FeroxResponse` generated during detection
|
||||
pub response: FeroxResponse,
|
||||
}
|
||||
|
||||
/// wrapper around the results of running a wildcard detection against a target web page
|
||||
#[derive(Copy, Debug, Clone)]
|
||||
pub enum WildcardResult {
|
||||
/// variant that represents a wildcard directory
|
||||
WildcardDirectory(usize),
|
||||
|
||||
/// variant that represents the presence of a 404-like response
|
||||
FourOhFourLike(usize),
|
||||
}
|
||||
|
||||
/// container for heuristics related info
|
||||
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().as_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("");
|
||||
|
||||
let wc2_length = resp_two.content_length();
|
||||
log::trace!("exit: unique_string -> {unique_id}");
|
||||
unique_id
|
||||
}
|
||||
|
||||
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());
|
||||
/// 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:?})");
|
||||
|
||||
wildcard.dynamic = wc_length - url_len;
|
||||
let mut good_urls = vec![];
|
||||
|
||||
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()
|
||||
);
|
||||
for target_url in target_urls {
|
||||
let url = FeroxUrl::from_string(target_url, self.handles.clone());
|
||||
let request = skip_fail!(url.format("", None));
|
||||
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
let result = logged_request(&request, DEFAULT_METHOD, None, self.handles.clone()).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
good_urls.push(target_url.to_owned());
|
||||
}
|
||||
} 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()
|
||||
);
|
||||
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
Err(e) => {
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
if e.to_string().contains(":SSL") {
|
||||
let msg = format!("Could not connect to {target_url} due to {} errors (run with {} to ignore), skipping...\n => {}\n",style("SSL").red(), style("--insecure").yellow().bright(), e.root_cause());
|
||||
ferox_print(&msg, &PROGRESS_PRINTER);
|
||||
} else {
|
||||
ferox_print(
|
||||
&format!(
|
||||
"Could not connect to {target_url}, skipping...\n => {}\n",
|
||||
e.root_cause()
|
||||
),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
}
|
||||
}
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if good_urls.is_empty() {
|
||||
bail!("Could not connect to any target provided");
|
||||
}
|
||||
|
||||
log::trace!("exit: connectivity_test -> {good_urls:?}");
|
||||
Ok(good_urls)
|
||||
}
|
||||
|
||||
/// heuristic designed to detect when a server has directory listing enabled
|
||||
pub async fn directory_listing(&self, target_url: &str) -> Result<Option<DirListingResult>> {
|
||||
log::trace!("enter: directory_listing({target_url})");
|
||||
|
||||
let tgt = if !target_url.ends_with('/') {
|
||||
// if left unchanged, this function would be called against redirects that point to
|
||||
// valid directories for most, if not all, directories beyond the initial urls.
|
||||
// so, instead of `directory_listing("http://localhost") -> None` we get
|
||||
// `directory_listing("http://localhost/") -> Some(DirListingResult)` if there is
|
||||
// directory listing beyond the redirect
|
||||
format!("{target_url}/")
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
target_url.to_string()
|
||||
};
|
||||
|
||||
match make_request(&CONFIGURATION.client, &request).await {
|
||||
Ok(_) => {
|
||||
good_urls.push(target_url.to_owned());
|
||||
}
|
||||
Err(e) => {
|
||||
if !CONFIGURATION.quiet {
|
||||
ferox_print(
|
||||
&format!("Could not connect to {}, skipping...", target_url),
|
||||
&PROGRESS_PRINTER,
|
||||
);
|
||||
}
|
||||
log::error!("{}", e);
|
||||
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,
|
||||
self.handles.config.response_size_limit,
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = ferox_response.text();
|
||||
let html = Html::parse_document(body);
|
||||
|
||||
let dirlist_type = self.detect_directory_listing(&html);
|
||||
|
||||
if dirlist_type.is_some() {
|
||||
// folks that run things and step away/rely on logs need to be notified of directory
|
||||
// listing, since they won't see the message on the bar; bastardizing FeroxMessage
|
||||
// for ease of implementation. This could use a bit of polish at some point.
|
||||
let msg = format!(
|
||||
"detected directory listing: {} ({:?})",
|
||||
target_url,
|
||||
dirlist_type.unwrap()
|
||||
);
|
||||
let ferox_msg = FeroxMessage {
|
||||
kind: "log".to_string(),
|
||||
message: msg.clone(),
|
||||
level: "MSG".to_string(),
|
||||
time_offset: 0.0,
|
||||
module: "feroxbuster::heuristics".to_string(),
|
||||
};
|
||||
self.handles
|
||||
.output
|
||||
.tx_file
|
||||
.send(Command::WriteToDisk(Box::new(ferox_msg)))
|
||||
.unwrap_or_default();
|
||||
|
||||
log::info!("{msg}");
|
||||
|
||||
let result = DirListingResult {
|
||||
dir_list_type: dirlist_type,
|
||||
response: ferox_response,
|
||||
};
|
||||
|
||||
log::trace!("exit: directory_listing -> {result:?}");
|
||||
return Ok(Some(result));
|
||||
}
|
||||
|
||||
log::trace!("exit: directory_listing -> None");
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Directory listing heuristic detection, uses <title> tag to make its determination. When
|
||||
/// the inner html of <title> matches one of the following, a `DirListingType` is returned.
|
||||
/// - apache: `Index of /`
|
||||
/// - tomcat/python: `Directory Listing for /`
|
||||
/// - ASP.NET: `Directory Listing -- /`
|
||||
/// - <host> - /: iis, azure, skipping due to loose heuristic
|
||||
fn detect_directory_listing(&self, html: &Html) -> Option<DirListingType> {
|
||||
log::trace!("enter: detect_directory_listing(html body...)");
|
||||
|
||||
let title_selector = Selector::parse("title").expect("couldn't parse title selector");
|
||||
|
||||
for t in html.select(&title_selector) {
|
||||
let title = t.inner_html().to_lowercase();
|
||||
|
||||
let dirlist_type = if title.contains("directory listing for /") {
|
||||
Some(DirListingType::TomCatOrPython)
|
||||
} else if title.contains("index of /") {
|
||||
Some(DirListingType::Apache)
|
||||
} else if title.contains("directory listing -- /") {
|
||||
Some(DirListingType::AspDotNet)
|
||||
} else {
|
||||
// IIS_AZURE purposely skipped for now
|
||||
None
|
||||
};
|
||||
|
||||
if dirlist_type.is_some() {
|
||||
log::trace!("exit: detect_directory_listing -> {dirlist_type:?}");
|
||||
return dirlist_type;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: detect_directory_listing -> None");
|
||||
None
|
||||
}
|
||||
|
||||
if good_urls.is_empty() {
|
||||
log::error!("Could not connect to any target provided, exiting.");
|
||||
/// given a target's base url, attempt to automatically detect its 404 response
|
||||
/// pattern(s), and then set filters that will exclude those patterns from future
|
||||
/// responses
|
||||
pub async fn detect_404_like_responses(
|
||||
&self,
|
||||
target_url: &str,
|
||||
) -> Result<Option<WildcardResult>> {
|
||||
log::trace!("enter: detect_404_like_responses({target_url:?})");
|
||||
|
||||
if self.handles.config.dont_filter {
|
||||
// early return, dont_filter scans don't need tested
|
||||
log::trace!("exit: detect_404_like_responses -> dont_filter is true");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut req_counter = 0;
|
||||
|
||||
let data = if self.handles.config.data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.handles.config.data.as_slice())
|
||||
};
|
||||
|
||||
// To take care of slash when needed
|
||||
let slash = if self.handles.config.add_slash {
|
||||
Some("/")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// no matter what, we want an empty extension for the base case
|
||||
let mut extensions = vec!["".to_string()];
|
||||
|
||||
// and then we want to add any extensions that was specified
|
||||
// or has since been added to the running config
|
||||
for ext in &self.handles.config.extensions {
|
||||
extensions.push(format!(".{ext}"));
|
||||
}
|
||||
|
||||
// for every method, attempt to id its 404 response
|
||||
//
|
||||
// a good example of one where the GET/POST differ is on hackthebox:
|
||||
// - http://prd.m.rendering-api.interface.htb/api
|
||||
//
|
||||
// a good example of one where the heuristics return a 403 and a 404 (apache)
|
||||
// as well as return two different types of 404s based on the file extension
|
||||
// - http://10.10.11.198 (Encoding box in normal labs)
|
||||
//
|
||||
// both methods and extensions can elicit different responses from a given
|
||||
// server, so both are considered when building auto-filter rules
|
||||
for method in self.handles.config.methods.iter() {
|
||||
for extension in extensions.iter() {
|
||||
// build out the 6 paths we'll use
|
||||
let paths = [
|
||||
("", 1),
|
||||
("", 3),
|
||||
(".htaccess", 1),
|
||||
(".htaccess", 3),
|
||||
("admin", 1),
|
||||
("admin", 3),
|
||||
]
|
||||
.map(|(prefix, length)| {
|
||||
format!("{prefix}{}{extension}", self.unique_string(length))
|
||||
});
|
||||
|
||||
// allow all 6 requests to fly asynchronously
|
||||
let responses = future::join_all(paths.into_iter().map(|path| async move {
|
||||
let ferox_url = FeroxUrl::from_string(target_url, self.handles.clone());
|
||||
|
||||
let Ok(nonexistent_url) = ferox_url.format(&path, slash) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// example requests:
|
||||
// - http://localhost/2fc1077836ad43ab98b7a31c2ca28fea
|
||||
// - http://localhost/92969beae6bf4beb855d1622406d87e395c87387a9ad432e8a11245002b709b03cf609d471004154b83bcc1c6ec49f6f
|
||||
// - http://localhost/.htaccessa005a2131e68449aa26e99029c914c09
|
||||
// - http://localhost/.htaccess92969beae6bf4beb855d1622406d87e395c87387a9ad432e8a11245002b709b03cf609d471004154b83bcc1c6ec49f6f
|
||||
// - http://localhost/adminf1d2541e73c44dcb9d1fb7d93334b280
|
||||
// - http://localhost/admin92969beae6bf4beb855d1622406d87e395c87387a9ad432e8a11245002b709b03cf609d471004154b83bcc1c6ec49f6f
|
||||
let Ok(response) =
|
||||
logged_request(&nonexistent_url, method, data, self.handles.clone()).await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if !self
|
||||
.handles
|
||||
.config
|
||||
.status_codes
|
||||
.contains(&response.status().as_u16())
|
||||
{
|
||||
// if the response code isn't one that's accepted via -s values, then skip to the next
|
||||
//
|
||||
// the default value for -s is all status codes, so unless the user says otherwise
|
||||
// this won't fire
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
FeroxResponse::from(
|
||||
response,
|
||||
&ferox_url.target,
|
||||
method,
|
||||
self.handles.config.output_level,
|
||||
self.handles.config.response_size_limit,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}))
|
||||
.await // await gives vector of options containing feroxresponses
|
||||
.into_iter()
|
||||
.flatten() // strip out the none values
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if responses.len() < 2 {
|
||||
// don't have enough responses to make a determination, continue to next method
|
||||
log::debug!("not enough responses to make a determination");
|
||||
continue;
|
||||
}
|
||||
|
||||
// check the responses for similarities on which we can filter, multiple may be returned
|
||||
let Some((wildcard_filters, wildcard_responses)) =
|
||||
self.examine_404_like_responses(&responses)
|
||||
else {
|
||||
// no match was found during analysis of responses
|
||||
log::warn!("no match found for 404 responses");
|
||||
continue;
|
||||
};
|
||||
|
||||
// report to the user, if appropriate
|
||||
if matches!(
|
||||
self.handles.config.output_level,
|
||||
OutputLevel::Default | OutputLevel::Quiet
|
||||
) {
|
||||
// sentry value to control whether or not to print the filter
|
||||
// used because we only want to print the same filter once
|
||||
let mut print_sentry;
|
||||
|
||||
if let Ok(filters) = self.handles.filters.data.filters.read() {
|
||||
for new_wildcard in &wildcard_filters {
|
||||
// reset the sentry for every new wildcard produced by examine_404_like_responses
|
||||
print_sentry = true;
|
||||
|
||||
for other in filters.iter() {
|
||||
if let Some(other_wildcard) =
|
||||
other.as_any().downcast_ref::<WildcardFilter>()
|
||||
{
|
||||
// check the new wildcard against all existing wildcards, if it was added
|
||||
// on the cli or by a previous directory, don't print it
|
||||
if new_wildcard.as_ref() == other_wildcard {
|
||||
print_sentry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we're here, we've found a new wildcard that we didn't previously display, print it
|
||||
if print_sentry {
|
||||
ferox_print(&format!("{new_wildcard}"), &PROGRESS_PRINTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create the new filter
|
||||
for wildcard in wildcard_filters {
|
||||
self.handles.filters.send(Command::AddFilter(wildcard))?;
|
||||
}
|
||||
|
||||
// if we're here, we've detected a 404-like response pattern, and we're already filtering for size/word/line
|
||||
//
|
||||
// in addition, we'll create a similarity filter as a fallback
|
||||
for resp in wildcard_responses {
|
||||
let sim_filter = SimilarityFilter::from(resp);
|
||||
|
||||
self.handles
|
||||
.filters
|
||||
.send(Command::AddFilter(Box::new(sim_filter)))?;
|
||||
|
||||
if resp.is_directory() {
|
||||
// response is either a 3XX with a Location header that matches url + '/'
|
||||
// or it's a 2XX that ends with a '/'
|
||||
// or it's a 403 that ends with a '/'
|
||||
|
||||
// set the wildcard flag to true, so we can check it when preventing
|
||||
// recursion in event_handlers/scans.rs
|
||||
|
||||
// we'd need to clone the response to give ownership to the global list anyway
|
||||
// so we'll also use that clone to set the wildcard flag
|
||||
let mut cloned_resp = resp.clone();
|
||||
|
||||
cloned_resp.set_wildcard(true);
|
||||
|
||||
// add the response to the global list of responses
|
||||
RESPONSES.insert(cloned_resp);
|
||||
|
||||
// function-internal magic number, indicates that we've detected a wildcard directory
|
||||
req_counter += 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: detect_404_like_responses");
|
||||
|
||||
let retval = if req_counter >= 100 {
|
||||
WildcardResult::WildcardDirectory(req_counter)
|
||||
} else {
|
||||
WildcardResult::FourOhFourLike(req_counter)
|
||||
};
|
||||
|
||||
Ok(Some(retval))
|
||||
}
|
||||
|
||||
log::trace!("exit: connectivity_test -> {:?}", good_urls);
|
||||
/// for all responses, group them by status code, then examine chars/words/lines.
|
||||
/// if all responses' respective lengths within a status code grouping match
|
||||
/// each other, we can assume that will remain true for subsequent non-existent urls
|
||||
///
|
||||
/// within a status code grouping, values are examined from most to
|
||||
/// least specific (content length, word count, line count)
|
||||
#[allow(clippy::vec_box)] // the box is needed in the caller and i dont feel like changing it
|
||||
fn examine_404_like_responses<'a>(
|
||||
&self,
|
||||
responses: &'a [FeroxResponse],
|
||||
) -> Option<(Vec<Box<WildcardFilter>>, Vec<&'a FeroxResponse>)> {
|
||||
// aside from word/line/byte counts, additional discriminators are status code
|
||||
// extension, and request method. The request method and extension are handled by
|
||||
// the caller, since they're part of the request and make up the nested for loops
|
||||
// in detect_404_like_responses.
|
||||
//
|
||||
// The status code is handled here, since it's part of the response to catch cases
|
||||
// where we have something like a 403 and a 404
|
||||
|
||||
good_urls
|
||||
let mut size_sentry = true;
|
||||
let mut word_sentry = true;
|
||||
let mut line_sentry = true;
|
||||
|
||||
// returned vec of boxed wildcard filters
|
||||
let mut wildcards = Vec::new();
|
||||
|
||||
// returned vec of ferox responses that are needed for additional
|
||||
// analysis
|
||||
let mut wild_responses = Vec::new();
|
||||
|
||||
// mapping of grouped responses to status code
|
||||
let mut grouped_responses = HashMap::new();
|
||||
|
||||
// iterate over all responses and add each response to its
|
||||
// corresponding status code group
|
||||
for response in responses {
|
||||
grouped_responses
|
||||
.entry(response.status())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(response);
|
||||
}
|
||||
|
||||
// iterate over each grouped response and determine the most specific
|
||||
// filter that can be applied to all responses in the group, i.e.
|
||||
// start from byte count and work 'out' to line count
|
||||
for response_group in grouped_responses.values() {
|
||||
if response_group.len() < 2 {
|
||||
// not enough responses to make a determination
|
||||
continue;
|
||||
}
|
||||
|
||||
let method = response_group[0].method();
|
||||
let status_code = response_group[0].status();
|
||||
let content_length = response_group[0].content_length();
|
||||
let word_count = response_group[0].word_count();
|
||||
let line_count = response_group[0].line_count();
|
||||
|
||||
for response in &response_group[1..] {
|
||||
// if any of the responses differ in length, that particular
|
||||
// response length type is no longer a candidate for filtering
|
||||
if response.content_length() != content_length {
|
||||
size_sentry = false;
|
||||
}
|
||||
|
||||
if response.word_count() != word_count {
|
||||
word_sentry = false;
|
||||
}
|
||||
|
||||
if response.line_count() != line_count {
|
||||
line_sentry = false;
|
||||
}
|
||||
}
|
||||
|
||||
if !size_sentry && !word_sentry && !line_sentry {
|
||||
// none of the response lengths match, so we can't filter on any of them
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut wildcard = WildcardFilter {
|
||||
content_length: None,
|
||||
line_count: None,
|
||||
word_count: None,
|
||||
method: method.to_string(),
|
||||
status_code: status_code.as_u16(),
|
||||
dont_filter: self.handles.config.dont_filter,
|
||||
};
|
||||
|
||||
match (size_sentry, word_sentry, line_sentry) {
|
||||
(true, true, true) => {
|
||||
// all three types of length match, so we can't filter on any of them
|
||||
wildcard.content_length = Some(content_length);
|
||||
wildcard.word_count = Some(word_count);
|
||||
wildcard.line_count = Some(line_count);
|
||||
}
|
||||
(true, true, false) => {
|
||||
// content length and word count match, so we can filter on either
|
||||
wildcard.content_length = Some(content_length);
|
||||
wildcard.word_count = Some(word_count);
|
||||
}
|
||||
(true, false, true) => {
|
||||
// content length and line count match, so we can filter on either
|
||||
wildcard.content_length = Some(content_length);
|
||||
wildcard.line_count = Some(line_count);
|
||||
}
|
||||
(false, true, true) => {
|
||||
// word count and line count match, so we can filter on either
|
||||
wildcard.word_count = Some(word_count);
|
||||
wildcard.line_count = Some(line_count);
|
||||
}
|
||||
(true, false, false) => {
|
||||
// content length matches, so we can filter on that
|
||||
wildcard.content_length = Some(content_length);
|
||||
}
|
||||
(false, true, false) => {
|
||||
// word count matches, so we can filter on that
|
||||
wildcard.word_count = Some(word_count);
|
||||
}
|
||||
(false, false, true) => {
|
||||
// line count matches, so we can filter on that
|
||||
wildcard.line_count = Some(line_count);
|
||||
}
|
||||
(false, false, false) => {
|
||||
// none of the length types match, so we can't filter on any of them
|
||||
unreachable!("no wildcard size matches; handled by the if statement above");
|
||||
}
|
||||
};
|
||||
|
||||
wild_responses.push(response_group[0]);
|
||||
wildcards.push(Box::new(wildcard));
|
||||
}
|
||||
|
||||
Some((wildcards, wild_responses))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -251,16 +607,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());
|
||||
}
|
||||
}
|
||||
|
||||
668
src/lib.rs
@@ -1,531 +1,191 @@
|
||||
#![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;
|
||||
pub mod sync;
|
||||
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 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 set of extensions to search for when auto-collecting backups during scans
|
||||
pub(crate) const DEFAULT_BACKUP_EXTENSIONS: [&str; 5] = ["~", ".bak", ".bak2", ".old", ".1"];
|
||||
|
||||
/// Default wordlist to use when `-w|--wordlist` isn't specified and not `wordlist` isn't set
|
||||
/// in a [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file.
|
||||
///
|
||||
/// defaults to kali's default install location:
|
||||
/// defaults to kali's default install location on linux:
|
||||
/// - `/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt`
|
||||
///
|
||||
/// and to the current directory on windows
|
||||
/// - `.\seclists\Discovery\Web-Content\raft-medium-directories.txt`
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub const DEFAULT_WORDLIST: &str =
|
||||
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const DEFAULT_WORDLIST: &str =
|
||||
".\\SecLists\\Discovery\\Web-Content\\raft-medium-directories.txt";
|
||||
pub const SECONDARY_WORDLIST: &str =
|
||||
"/usr/local/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
|
||||
|
||||
/// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan
|
||||
pub static SLEEP_DURATION: u64 = 500;
|
||||
pub(crate) const SLEEP_DURATION: u64 = 500;
|
||||
|
||||
/// Default list of status codes to report
|
||||
///
|
||||
/// * 200 Ok
|
||||
/// * 204 No Content
|
||||
/// * 301 Moved Permanently
|
||||
/// * 302 Found
|
||||
/// * 307 Temporary Redirect
|
||||
/// * 308 Permanent Redirect
|
||||
/// * 401 Unauthorized
|
||||
/// * 403 Forbidden
|
||||
/// * 405 Method Not Allowed
|
||||
pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
|
||||
/// 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 (all of them)
|
||||
pub const DEFAULT_STATUS_CODES: [StatusCode; 60] = [
|
||||
// all 1XX response codes
|
||||
StatusCode::CONTINUE,
|
||||
StatusCode::SWITCHING_PROTOCOLS,
|
||||
StatusCode::PROCESSING,
|
||||
// all 2XX response codes
|
||||
StatusCode::OK,
|
||||
StatusCode::CREATED,
|
||||
StatusCode::ACCEPTED,
|
||||
StatusCode::NON_AUTHORITATIVE_INFORMATION,
|
||||
StatusCode::NO_CONTENT,
|
||||
StatusCode::RESET_CONTENT,
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
StatusCode::MULTI_STATUS,
|
||||
StatusCode::ALREADY_REPORTED,
|
||||
StatusCode::IM_USED,
|
||||
// all 3XX response codes
|
||||
StatusCode::MULTIPLE_CHOICES,
|
||||
StatusCode::MOVED_PERMANENTLY,
|
||||
StatusCode::FOUND,
|
||||
StatusCode::SEE_OTHER,
|
||||
StatusCode::NOT_MODIFIED,
|
||||
StatusCode::USE_PROXY,
|
||||
StatusCode::TEMPORARY_REDIRECT,
|
||||
StatusCode::PERMANENT_REDIRECT,
|
||||
// all 4XX response codes
|
||||
StatusCode::BAD_REQUEST,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::PAYMENT_REQUIRED,
|
||||
StatusCode::FORBIDDEN,
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
StatusCode::PROXY_AUTHENTICATION_REQUIRED,
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::GONE,
|
||||
StatusCode::LENGTH_REQUIRED,
|
||||
StatusCode::PRECONDITION_FAILED,
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
StatusCode::URI_TOO_LONG,
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
StatusCode::RANGE_NOT_SATISFIABLE,
|
||||
StatusCode::EXPECTATION_FAILED,
|
||||
StatusCode::IM_A_TEAPOT,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
StatusCode::LOCKED,
|
||||
StatusCode::FAILED_DEPENDENCY,
|
||||
StatusCode::UPGRADE_REQUIRED,
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
|
||||
StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
|
||||
// all 5XX response codes
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
StatusCode::HTTP_VERSION_NOT_SUPPORTED,
|
||||
StatusCode::VARIANT_ALSO_NEGOTIATES,
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
StatusCode::LOOP_DETECTED,
|
||||
StatusCode::NOT_EXTENDED,
|
||||
StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
|
||||
];
|
||||
|
||||
/// Default method for requests
|
||||
pub(crate) const DEFAULT_METHOD: &str = "GET";
|
||||
|
||||
/// Default filename for config file settings
|
||||
///
|
||||
/// Expected location is in the same directory as the feroxbuster binary.
|
||||
pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml";
|
||||
/// User agents to select from when random agent is being used
|
||||
pub const USER_AGENTS: [&str; 12] = [
|
||||
"Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (Windows Phone 10.0; Android 6.0.1; Microsoft; RM-1152) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Mobile Safari/537.36 Edge/15.15254",
|
||||
"Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
|
||||
"Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9",
|
||||
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1",
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
||||
"Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)",
|
||||
];
|
||||
|
||||
/// 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;
|
||||
/// maximum hamming distance allowed between two simhash signatures when detecting near-duplicates
|
||||
///
|
||||
/// ref: https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/33026.pdf
|
||||
/// section: 4.1 Choice of Parameters
|
||||
pub(crate) const NEAR_DUPLICATE_DISTANCE: usize = 3;
|
||||
|
||||
/// 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
/// maximum hamming distance allowed between two simhash signatures when unique'ifying responses
|
||||
pub(crate) const UNIQUE_DISTANCE: usize = 1;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -551,46 +211,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| {
|
||||
@@ -48,13 +65,17 @@ pub fn initialize(verbosity: u8) {
|
||||
kind: "log".to_string(),
|
||||
};
|
||||
|
||||
PROGRESS_PRINTER.println(&log_entry.as_str());
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
779
src/main.rs
@@ -1,90 +1,84 @@
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
use feroxbuster::progress::add_bar;
|
||||
use std::{
|
||||
env::{
|
||||
args,
|
||||
consts::{ARCH, OS},
|
||||
},
|
||||
fs::{create_dir, remove_file, File},
|
||||
io::{stderr, BufRead, BufReader},
|
||||
ops::Index,
|
||||
path::Path,
|
||||
process::{exit, Command, Stdio},
|
||||
sync::{atomic::Ordering, Arc},
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use futures::StreamExt;
|
||||
use tokio::{
|
||||
io,
|
||||
sync::{oneshot, Semaphore},
|
||||
};
|
||||
use tokio_util::codec::{FramedRead, LinesCodec};
|
||||
|
||||
use feroxbuster::{
|
||||
banner,
|
||||
config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER},
|
||||
heuristics, logger, reporter,
|
||||
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::{
|
||||
AddHandles, CreateBar, Exit, JoinTasks, LoadStats, ScanInitialUrls, UpdateTargets,
|
||||
UpdateWordlist,
|
||||
},
|
||||
FiltersHandler, Handles, ScanHandler, StatsHandler, Tasks, TermInputHandler,
|
||||
TermOutHandler, SCAN_COMPLETE,
|
||||
},
|
||||
filters, heuristics, logger,
|
||||
progress::PROGRESS_PRINTER,
|
||||
scan_manager::{self, ScanType},
|
||||
scanner,
|
||||
utils::{fmt_err, slugify_filename},
|
||||
SECONDARY_WORDLIST,
|
||||
};
|
||||
#[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;
|
||||
use self_update::cargo_crate_version;
|
||||
|
||||
/// 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>>> {
|
||||
log::trace!("enter: get_unique_words_from_wordlist({})", path);
|
||||
/// Create a Vec of Strings from the given wordlist then stores it inside an Arc
|
||||
fn get_unique_words_from_wordlist(path: &str) -> Result<Arc<Vec<String>>> {
|
||||
log::trace!("enter: get_unique_words_from_wordlist({path})");
|
||||
let mut trimmed_word = false;
|
||||
|
||||
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?;
|
||||
line.map(|result| {
|
||||
if !result.starts_with('#') && !result.is_empty() {
|
||||
if result.starts_with('/') {
|
||||
words.push(result.trim_start_matches('/').to_string());
|
||||
trimmed_word = true;
|
||||
} else {
|
||||
words.push(result);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
if result.starts_with('#') || result.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
words.insert(result);
|
||||
if trimmed_word {
|
||||
log::warn!("Some words in the wordlist started with a leading forward-slash; those words were trimmed (i.e. /word -> word)");
|
||||
}
|
||||
|
||||
log::trace!(
|
||||
@@ -96,81 +90,62 @@ 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()?;
|
||||
|
||||
scanner::initialize(words.len(), &CONFIGURATION);
|
||||
handles.send_scan_command(UpdateWordlist(handles.wordlist.clone()))?;
|
||||
|
||||
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();
|
||||
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) {
|
||||
let mut total_offset = 0;
|
||||
|
||||
if let Ok(guard) = handles.scans.read() {
|
||||
if let Some(handle) = &*guard {
|
||||
if let Ok(scans) = handle.data.scans.read() {
|
||||
for scan in scans.iter() {
|
||||
total_offset += scan.requests_made_so_far();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(responses) = RESPONSES.responses.read() {
|
||||
for response in responses.iter() {
|
||||
PROGRESS_PRINTER.println(response.as_str());
|
||||
}
|
||||
}
|
||||
// only create the bar if no --silent|--quiet
|
||||
handles.stats.send(CreateBar(total_offset))?;
|
||||
|
||||
// blocks until the bar is created / avoids race condition in first two bars
|
||||
handles.stats.sync().await?;
|
||||
}
|
||||
|
||||
let mut tasks = vec![];
|
||||
|
||||
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);
|
||||
if handles.config.resumed {
|
||||
// display what has already been completed
|
||||
scanned_urls.print_known_responses();
|
||||
scanned_urls.print_completed_bars(handles.wordlist.len())?;
|
||||
}
|
||||
|
||||
// drive execution of all accumulated futures
|
||||
futures::future::join_all(tasks).await;
|
||||
log::debug!("sending {targets:?} to be scanned as initial targets");
|
||||
handles.send_scan_command(ScanInitialUrls(targets))?;
|
||||
|
||||
log::trace!("exit: scan");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get targets from either commandline or stdin, pass them back to the caller as a Result<Vec>
|
||||
async fn get_targets() -> FeroxResult<Vec<String>> {
|
||||
log::trace!("enter: get_targets");
|
||||
async fn get_targets(handles: Arc<Handles>) -> Result<Vec<String>> {
|
||||
log::trace!("enter: get_targets({handles:?})");
|
||||
|
||||
let mut targets = vec![];
|
||||
|
||||
if CONFIGURATION.stdin {
|
||||
if handles.config.stdin && handles.config.cached_stdin.is_empty() {
|
||||
// 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,34 +154,69 @@ 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.cached_stdin.is_empty() {
|
||||
// cached_stdin populated from config::container if --stdin was used
|
||||
// keeping the if block above as a failsafe, but i dont think we'll hit it anymore
|
||||
targets = handles.config.cached_stdin.clone();
|
||||
} 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(CONFIGURATION.target_url.clone());
|
||||
targets.push(handles.config.target_url.clone());
|
||||
}
|
||||
|
||||
log::trace!("exit: get_targets -> {:?}", targets);
|
||||
// remove footgun that arises if a --dont-scan value matches on a base url
|
||||
for target in targets.iter_mut() {
|
||||
for denier in &handles.config.regex_denylist {
|
||||
if denier.is_match(target) {
|
||||
bail!(
|
||||
"The regex '{}' matches {}; the scan will never start",
|
||||
denier,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
for denier in &handles.config.url_denylist {
|
||||
if denier.as_str().trim_end_matches('/') == target.trim_end_matches('/') {
|
||||
bail!(
|
||||
"The url '{}' matches {}; the scan will never start",
|
||||
denier,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !target.starts_with("http") {
|
||||
// --url hackerone.com
|
||||
// as of the 2.13.0 update, config::container handles both --url hackerone.com
|
||||
// and urls coming in from --stdin. I think this is dead code now, but leaving
|
||||
// it in just in case
|
||||
*target = format!("{}://{target}", handles.config.protocol);
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: get_targets -> {targets:?}");
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -217,15 +227,122 @@ async fn wrapped_main() {
|
||||
// PROGRESS_PRINTER and PROGRESS_BAR have been used at least once. This call satisfies
|
||||
// that constraint
|
||||
PROGRESS_PRINTER.println("");
|
||||
PROGRESS_BAR.join().unwrap();
|
||||
});
|
||||
|
||||
if !CONFIGURATION.time_limit.is_empty() {
|
||||
// check if update_app is true
|
||||
if config.update_app {
|
||||
match update_app().await {
|
||||
Err(e) => eprintln!("\n[ERROR] {e}"),
|
||||
Ok(self_update::Status::UpToDate(version)) => {
|
||||
eprintln!("\nFeroxbuster {version} is up to date")
|
||||
}
|
||||
Ok(self_update::Status::Updated(version)) => {
|
||||
eprintln!("\nFeroxbuster updated to {version} version")
|
||||
}
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
let words = if config.wordlist.starts_with("http") {
|
||||
// found a url scheme, attempt to download the wordlist
|
||||
let response = config
|
||||
.client
|
||||
.get(&config.wordlist)
|
||||
.send()
|
||||
.await
|
||||
.context(format!(
|
||||
"Unable to download wordlist from remote url: {}",
|
||||
config.wordlist
|
||||
))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// status code isn't a 200, bail
|
||||
bail!(
|
||||
"[{}] Unable to download wordlist from url: {}",
|
||||
response.status().as_str(),
|
||||
config.wordlist
|
||||
);
|
||||
}
|
||||
|
||||
// attempt to get the filename from the url's path
|
||||
let Some(mut path_segments) = response.url().path_segments() else {
|
||||
bail!("Unable to parse path from url: {}", response.url());
|
||||
};
|
||||
|
||||
let Some(filename) = path_segments.next_back() else {
|
||||
bail!(
|
||||
"Unable to parse filename from url's path: {}",
|
||||
response.url().path()
|
||||
);
|
||||
};
|
||||
|
||||
let filename = filename.to_string();
|
||||
|
||||
// read the body and write it to disk, then use existing code to read the wordlist
|
||||
let body = response.text().await?;
|
||||
|
||||
std::fs::write(&filename, body)?;
|
||||
|
||||
get_unique_words_from_wordlist(&filename)?
|
||||
} else {
|
||||
match get_unique_words_from_wordlist(&config.wordlist) {
|
||||
Ok(w) => w,
|
||||
Err(err) => {
|
||||
let secondary = Path::new(SECONDARY_WORDLIST);
|
||||
|
||||
if secondary.exists() {
|
||||
eprintln!("Found wordlist in secondary location");
|
||||
get_unique_words_from_wordlist(SECONDARY_WORDLIST)?
|
||||
} else {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if words.len() <= 1 {
|
||||
// the check is now <= 1 due to the initial empty string added in 2.6.0
|
||||
// 1 -> empty wordlist
|
||||
// 0 -> error
|
||||
bail!("Did not find any words in {}", config.wordlist);
|
||||
}
|
||||
|
||||
// spawn all event handlers, expect back a JoinHandle and a *Handle to the specific event
|
||||
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
|
||||
handles.output.send(AddHandles(handles.clone()))?;
|
||||
|
||||
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() && config.parallel == 0 {
|
||||
// --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
|
||||
});
|
||||
//
|
||||
// if --parallel is used, this branch won't execute in the main process, but will in the
|
||||
// children. This is because --parallel is stripped from the children's command line
|
||||
// arguments, so, when spawned, they won't have --parallel, the parallel value will be set
|
||||
// to the default of 0, and will hit this branch. This makes it so that the time limit
|
||||
// is enforced on each individual child process, instead of the main process
|
||||
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 +351,261 @@ 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").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>>();
|
||||
|
||||
// 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(async move {
|
||||
let mut output = Command::new(bin)
|
||||
.args(&args)
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("failed to spawn a child process");
|
||||
|
||||
let stdout = output.stdout.take().unwrap();
|
||||
|
||||
let mut bufread = BufReader::new(stdout);
|
||||
// output for a single line is a minimum of 51 bytes, so we'll start with that
|
||||
// + a little wiggle room, and grow as needed
|
||||
let mut buf: String = String::with_capacity(128);
|
||||
|
||||
while let Ok(n) = bufread.read_line(&mut buf) {
|
||||
if n > 0 {
|
||||
let trimmed = buf.trim();
|
||||
if !trimmed.is_empty() {
|
||||
println!("{trimmed}");
|
||||
}
|
||||
buf.clear();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = output.wait();
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
// 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(());
|
||||
}
|
||||
|
||||
// in order for the Stats object to know about which targets are being scanned, we need to
|
||||
// wait until the parallel branch has been handled before sending the UpdateTargets command
|
||||
// this ensures that only the targets being scanned are sent to the Stats object
|
||||
//
|
||||
// if sent before the parallel branch is handled, the Stats object will have duplicate
|
||||
// targets
|
||||
handles.stats.send(UpdateTargets(targets.clone()))?;
|
||||
|
||||
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 +615,84 @@ 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);
|
||||
async fn update_app() -> Result<self_update::Status, Box<dyn ::std::error::Error>> {
|
||||
let target_os = format!("{ARCH}-{OS}");
|
||||
let status = tokio::task::spawn_blocking(move || {
|
||||
self_update::backends::github::Update::configure()
|
||||
.repo_owner("epi052")
|
||||
.repo_name("feroxbuster")
|
||||
.bin_name("feroxbuster")
|
||||
.target(target_os.as_str())
|
||||
.show_download_progress(true)
|
||||
.current_version(cargo_crate_version!())
|
||||
.build()?
|
||||
.update()
|
||||
})
|
||||
.await??;
|
||||
|
||||
if CONFIGURATION.save_state {
|
||||
// start the ctrl+c handler
|
||||
scan_manager::initialize();
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let config = Arc::new(Configuration::new().with_context(|| "Could not create Configuration")?);
|
||||
|
||||
// setup logging based on the number of -v's used
|
||||
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.cached_stdin.is_empty() {
|
||||
vec!["http://localhost".to_string()]
|
||||
} else {
|
||||
config.cached_stdin.clone()
|
||||
};
|
||||
|
||||
// print the banner to stderr
|
||||
let std_stderr = stderr(); // std::io::stderr
|
||||
let banner = Banner::new(&targets, &config);
|
||||
if (!config.quiet && !config.silent) || config.parallel != 0 {
|
||||
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_default();
|
||||
*metadata.count_mut() += 1;
|
||||
}
|
||||
|
||||
/// create a new `Document` from the given HTML string
|
||||
pub(crate) fn from_html(raw_html: &str) -> Option<Self> {
|
||||
let selector = Selector::parse("body").unwrap();
|
||||
|
||||
let html = Html::parse_document(raw_html);
|
||||
|
||||
let element = html.select(&selector).next()?;
|
||||
|
||||
let text = element
|
||||
.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
|
||||
Some(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>").unwrap();
|
||||
assert_eq!(empty.number_of_terms, 0);
|
||||
|
||||
let other_empty = Document::from_html("<html><body><p></p></body></html>").unwrap();
|
||||
assert_eq!(other_empty.number_of_terms, 0);
|
||||
|
||||
let third_empty =
|
||||
Document::from_html("<!DOCTYPE html><html><!DOCTYPE html><p></p></html>").unwrap();
|
||||
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>",
|
||||
).unwrap();
|
||||
|
||||
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).unwrap();
|
||||
let keys = doc.terms().keys().map(|key| key.raw()).collect::<Vec<_>>();
|
||||
|
||||
let expected = ["worse", "wednesday"];
|
||||
|
||||
assert_eq!(doc.number_of_terms(), 2);
|
||||
|
||||
for key in keys {
|
||||
assert!(expected.contains(&key));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/nlp/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! small stand-alone tf-idf library, specifically designed for use in feroxbuster
|
||||
|
||||
mod constants;
|
||||
mod document;
|
||||
mod model;
|
||||
mod term;
|
||||
mod utils;
|
||||
|
||||
pub(crate) use self::document::Document;
|
||||
pub(crate) use self::model::TfIdf;
|
||||
pub(crate) use self::utils::preprocess;
|
||||
185
src/nlp/model.rs
Normal file
@@ -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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
100
src/nlp/term.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
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 {
|
||||
/// 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::default();
|
||||
|
||||
*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(crate) fn preprocess(text: &str) -> Vec<String> {
|
||||
let text = remove_punctuation(text);
|
||||
let text = normalize_case(text);
|
||||
let text = remove_stop_words(&text);
|
||||
|
||||
text.split_whitespace()
|
||||
.map(|word| word.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// optimized version of `str::to_lowercase`
|
||||
fn normalize_case<'a, S: Into<Cow<'a, str>>>(input: S) -> Cow<'a, str> {
|
||||
let input = input.into();
|
||||
|
||||
let first = input.find(char::is_uppercase);
|
||||
|
||||
if let Some(first_idx) = first {
|
||||
let mut output = String::from(&input[..first_idx]);
|
||||
output.reserve(input.len() - first_idx);
|
||||
|
||||
for c in input[first_idx..].chars() {
|
||||
if c.is_uppercase() {
|
||||
output.push(c.to_lowercase().next().unwrap())
|
||||
} else {
|
||||
output.push(c)
|
||||
}
|
||||
}
|
||||
|
||||
Cow::Owned(output)
|
||||
} else {
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
/// replace ascii and some utf-8 punctuation characters with ' ' (space) in the given string
|
||||
fn remove_punctuation(text: &str) -> String {
|
||||
text.replace(
|
||||
[
|
||||
'!', '\\', '"', '#', '$', '%', '&', '(', ')', '*', '+', ':', ';', '<', '=', '>', '?',
|
||||
'@', '[', ']', '^', '{', '}', '|', '~', ',', '\'', '“', '”', '’', '‘', '’', '‘', '/',
|
||||
'–', '—', '.',
|
||||
],
|
||||
" ",
|
||||
)
|
||||
}
|
||||
|
||||
/// remove stop words from the given string
|
||||
fn remove_stop_words(text: &str) -> String {
|
||||
BOUNDED_WORD_REGEX
|
||||
.replace_all(text, |caps: &Captures| {
|
||||
let word = &caps[0];
|
||||
if !STOP_WORDS.contains(&word) {
|
||||
word.to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// calculate inverse document frequency
|
||||
pub(super) fn inverse_document_frequency(num_docs: f32, doc_frequency: f32) -> f32 {
|
||||
f32::log10(num_docs / doc_frequency)
|
||||
}
|
||||
|
||||
/// calculate term frequency-inverse document frequency (tf-idf)
|
||||
pub(super) fn tf_idf_score(term_frequency: f32, idf: f32) -> f32 {
|
||||
term_frequency * idf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// ensure all expected punctuation characters are removed
|
||||
fn test_remove_punctuation() {
|
||||
let tester = "!\\\"#$%&()*+/:;<=>?@[]^{}|~,.'“”’‘–—\n‘’";
|
||||
// the `" \n"` is because of the things like / getting replaced with a space
|
||||
assert_eq!(
|
||||
remove_punctuation(tester),
|
||||
" \n "
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure uppercase characters are swapped to lowercase
|
||||
fn test_normalize_case() {
|
||||
let tester = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
assert_eq!(normalize_case(tester), "abcdefghijklmnopqrstuvwxyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure all stop words are removed from the list of stopwords ... intestuous
|
||||
fn test_remove_stopwords() {
|
||||
let all_words = STOP_WORDS
|
||||
.iter()
|
||||
.map(|&word| word.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let removed = remove_stop_words(&all_words).replace(' ', "");
|
||||
|
||||
// the remaining chars are from the contraction-based stop words
|
||||
assert_eq!(removed, "'d'll'm''s'ven'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), &["y", "all", "yelling"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure our calculations conform to the example provided at the link below
|
||||
///
|
||||
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
|
||||
///
|
||||
/// Consider a document containing 100 words wherein the word cat appears 3 times.
|
||||
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
|
||||
/// million documents and the word cat appears in one thousand of these. Then, the inverse
|
||||
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
|
||||
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
|
||||
fn idf_returns_expected_value() {
|
||||
let num_docs = 10_000_000_f32;
|
||||
let num_occurrences = 1_000_f32;
|
||||
let abs_diff = (inverse_document_frequency(num_docs, num_occurrences) - 4.0).abs();
|
||||
|
||||
assert!(abs_diff <= f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// ensure our calculations conform to the example provided at the link below
|
||||
///
|
||||
/// https://www.kaggle.com/paulrohan2020/tf-idf-tutorial/notebook#TF-IDF-Model
|
||||
///
|
||||
/// Consider a document containing 100 words wherein the word cat appears 3 times.
|
||||
/// The term frequency (i.e., tf) for cat is then (3 / 100) = 0.03. Now, assume we have 10
|
||||
/// million documents and the word cat appears in one thousand of these. Then, the inverse
|
||||
/// document frequency (i.e., idf) is calculated as log(10,000,000 / 1,000) = 4. Thus, the
|
||||
/// Tf-idf weight is the product of these quantities: 0.03 * 4 = 0.12.
|
||||
fn tf_idf_returns_expected_value() {
|
||||
let term_freq = 0.03_f32;
|
||||
let num_docs = 10_000_000_f32;
|
||||
let num_occurrences = 1_000_f32;
|
||||
let idf = inverse_document_frequency(num_docs, num_occurrences);
|
||||
let abs_diff = (tf_idf_score(term_freq, idf) - 0.12).abs();
|
||||
|
||||
assert!(abs_diff <= f32::EPSILON);
|
||||
}
|
||||
}
|
||||
920
src/parser.rs
103
src/progress.rs
@@ -1,31 +1,81 @@
|
||||
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()
|
||||
pub fn add_bar(prefix: &str, length: u64, bar_type: BarType) -> ProgressBar {
|
||||
let pb = ProgressBar::new(length).with_prefix(prefix.to_string());
|
||||
|
||||
update_style(&pb, bar_type);
|
||||
|
||||
PROGRESS_BAR.add(pb)
|
||||
}
|
||||
|
||||
/// Update the style of a progress bar based on the `BarType`
|
||||
pub fn update_style(bar: &ProgressBar, bar_type: BarType) {
|
||||
let mut style = ProgressStyle::default_bar().progress_chars("#>-").with_key(
|
||||
"smoothed_per_sec",
|
||||
|state: &indicatif::ProgressState, w: &mut dyn std::fmt::Write| match (
|
||||
state.pos(),
|
||||
state.elapsed().as_millis(),
|
||||
) {
|
||||
// https://github.com/console-rs/indicatif/issues/394#issuecomment-1309971049
|
||||
//
|
||||
// indicatif released a change to how they reported eta/per_sec
|
||||
// and the results looked really weird based on how we use the progress
|
||||
// bars. this fixes that
|
||||
(pos, elapsed_ms) if elapsed_ms > 0 => {
|
||||
write!(w, "{:.0}/s", pos as f64 * 1000_f64 / elapsed_ms as f64).unwrap()
|
||||
}
|
||||
_ => write!(w, "-").unwrap(),
|
||||
},
|
||||
);
|
||||
|
||||
style = match bar_type {
|
||||
BarType::Hidden => style.template("").unwrap(),
|
||||
BarType::Default => style
|
||||
.template("[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {smoothed_per_sec:7} {prefix} {msg}")
|
||||
.unwrap(),
|
||||
BarType::Message => style
|
||||
.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("#>-")
|
||||
"[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}} {{msg}}",
|
||||
"-"
|
||||
))
|
||||
.unwrap(),
|
||||
BarType::Total => style
|
||||
.template("[{bar:.yellow/blue}] - {elapsed:<4} {pos:>7}/{len:7} {eta:7} {msg}")
|
||||
.unwrap(),
|
||||
BarType::Quiet => style.template("Scanning: {prefix}").unwrap(),
|
||||
};
|
||||
|
||||
let progress_bar = PROGRESS_BAR.add(ProgressBar::new(length));
|
||||
|
||||
progress_bar.set_style(style);
|
||||
|
||||
progress_bar.set_prefix(&prefix);
|
||||
|
||||
progress_bar
|
||||
bar.set_style(style);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -35,16 +85,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();
|
||||
}
|
||||
}
|
||||
940
src/response.rs
Normal file
@@ -0,0 +1,940 @@
|
||||
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, parse_url_with_raw_path, status_colorizer, timestamp},
|
||||
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>,
|
||||
|
||||
/// Whether the response body was truncated due to size limits
|
||||
truncated: bool,
|
||||
|
||||
/// Timestamp of when this response was received
|
||||
timestamp: f64,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
truncated: false,
|
||||
timestamp: timestamp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Get the timestamp of this response
|
||||
pub fn timestamp(&self) -> f64 {
|
||||
self.timestamp
|
||||
}
|
||||
|
||||
/// Get whether this response was truncated due to size limits
|
||||
pub fn truncated(&self) -> bool {
|
||||
self.truncated
|
||||
}
|
||||
|
||||
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
|
||||
pub fn set_url(&mut self, url: &str) {
|
||||
match parse_url_with_raw_path(url) {
|
||||
Ok(url) => {
|
||||
self.url = url;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not parse {url} into a 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.clear(); // length is set to 0
|
||||
self.text.shrink_to_fit(); // allocated capacity shrinks to reflect the new size
|
||||
}
|
||||
|
||||
/// 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 = if let Some(mut path) = self.url.path_segments() {
|
||||
if let Some(last) = path.next_back() {
|
||||
last.contains('.') // last segment has some sort of extension, probably
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
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(
|
||||
mut response: Response,
|
||||
original_url: &str,
|
||||
method: &str,
|
||||
output_level: OutputLevel,
|
||||
max_size_read: usize,
|
||||
) -> 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 timestamp = timestamp();
|
||||
|
||||
// Read the response bytes with size limit to prevent OOM issues
|
||||
// Use chunk() to limit bytes during reading, not after
|
||||
let mut bytes_read = Vec::new();
|
||||
let mut total_bytes_read = 0;
|
||||
let mut was_truncated = false;
|
||||
|
||||
while let Some(chunk_result) = response.chunk().await.transpose() {
|
||||
match chunk_result.with_context(|| "Could not read chunk from response") {
|
||||
Ok(chunk) => {
|
||||
let chunk_len = chunk.len();
|
||||
|
||||
if total_bytes_read + chunk_len > max_size_read {
|
||||
// Only read the remaining bytes up to the limit
|
||||
let remaining = max_size_read - total_bytes_read;
|
||||
total_bytes_read += remaining;
|
||||
bytes_read.extend_from_slice(&chunk[..remaining]);
|
||||
was_truncated = true;
|
||||
log::debug!("Response body truncated at {max_size_read} bytes for {url}");
|
||||
break;
|
||||
} else {
|
||||
bytes_read.extend_from_slice(&chunk);
|
||||
total_bytes_read += chunk_len;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Error reading chunk, break and use what we have
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to text, handling UTF-8 errors gracefully
|
||||
let text = String::from_utf8_lossy(&bytes_read).to_string();
|
||||
|
||||
// Log warning if content was truncated
|
||||
if was_truncated {
|
||||
log::warn!(
|
||||
"Response body truncated to {} bytes for {url} (original size may be larger)",
|
||||
bytes_read.len()
|
||||
);
|
||||
}
|
||||
|
||||
// in the event that the content_length was 0, we can try to get the length
|
||||
// of the body we just parsed. At worst, it's still 0; at best we've accounted
|
||||
// for sites that reply without a content-length header and yet still have
|
||||
// contents in the body.
|
||||
//
|
||||
// thanks to twitter use @f3rn0s for pointing out the possibility
|
||||
//
|
||||
// update v2.12.0: added max_size_read to limit how much of the body we read
|
||||
// this means we need to account for the possibility that the content_length
|
||||
// is larger than what we actually read. That means we should only use the
|
||||
// actual bytes we read if we truncated the response body.
|
||||
let converted = total_bytes_read as u64;
|
||||
let content_length = if was_truncated && content_length > converted {
|
||||
// content_length is larger than what we read, use what we read
|
||||
log::debug!(
|
||||
"Using actual bytes read ({total_bytes_read}) as content_length instead of reported content_length ({content_length}) for {url}");
|
||||
// set content_length to what we actually read
|
||||
total_bytes_read as u64
|
||||
} else {
|
||||
// content_length is accurate or smaller than what we read, use old logic that
|
||||
// deals with content_length of 0
|
||||
content_length.max(text.len() as u64)
|
||||
};
|
||||
|
||||
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,
|
||||
truncated: was_truncated,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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().next_back().unwrap();
|
||||
|
||||
if !filename.is_empty() {
|
||||
// non-empty string, try to get extension
|
||||
let parts: Vec<_> = filename
|
||||
.split('.')
|
||||
// keep things like /.bash_history from becoming an extension
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.len() > 1 {
|
||||
// filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
|
||||
self.extension = Some(parts.last().unwrap().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extension) = &self.extension {
|
||||
if handles
|
||||
.config
|
||||
.status_codes
|
||||
.contains(&self.status().as_u16()) // in -s list
|
||||
// or -C was used, and -s should be all responses that aren't filtered
|
||||
|| !handles.config.filter_status.is_empty()
|
||||
{
|
||||
// only add extensions to those responses that pass our checks; filtered out
|
||||
// status codes are handled by should_filter, but we need to still check against
|
||||
// the allow list for what we want to keep
|
||||
#[cfg(test)]
|
||||
handles
|
||||
.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))
|
||||
.unwrap_or_default();
|
||||
#[cfg(not(test))]
|
||||
handles.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))?;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("exit: parse_extension");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function that determines if the configured maximum recursion depth has been reached
|
||||
///
|
||||
/// Essentially looks at the Url path and determines how many directories are present in the
|
||||
/// 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:?}");
|
||||
|
||||
// there's no reason to send the response body across the mpsc
|
||||
//
|
||||
// the only possible reason is for filtering on the body, but both `send_report`
|
||||
// calls are gated behind checks for `should_filter_response`
|
||||
let mut me = self;
|
||||
me.drop_text();
|
||||
|
||||
report_sender.send(Command::Report(Box::new(me)))?;
|
||||
|
||||
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(),
|
||||
matches!(
|
||||
self.output_level,
|
||||
OutputLevel::Silent | OutputLevel::SilentJSON
|
||||
),
|
||||
) {
|
||||
(true, true, false) => {
|
||||
// redirect with Location header, show where it goes if possible
|
||||
let loc = self
|
||||
.headers()
|
||||
.get("Location")
|
||||
.unwrap() // known Some() already
|
||||
.to_str()
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let loc = if loc.starts_with('/') {
|
||||
if let Ok(joined) = self.url().join(&loc) {
|
||||
joined.to_string()
|
||||
} else {
|
||||
loc
|
||||
}
|
||||
} else {
|
||||
loc
|
||||
};
|
||||
|
||||
// prettify the redirect target
|
||||
let loc = style(loc).yellow();
|
||||
|
||||
format!("{} => {loc}", self.url())
|
||||
}
|
||||
(_, _, true) => {
|
||||
// --silent was used, just show the url
|
||||
self.url().to_string()
|
||||
}
|
||||
_ => {
|
||||
// no redirect, no silent; check for truncation and report if needed
|
||||
let mut url_display = self.url().to_string();
|
||||
|
||||
if self.truncated {
|
||||
// only add truncation indicator if content was truncated and --silent not used
|
||||
url_display.push_str(&format!(
|
||||
" ({} to size limit)",
|
||||
style("truncated").yellow().bright()
|
||||
));
|
||||
}
|
||||
|
||||
url_display
|
||||
}
|
||||
};
|
||||
|
||||
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 {}\n",
|
||||
wild_status,
|
||||
method,
|
||||
lines,
|
||||
words,
|
||||
chars,
|
||||
status_colorizer(status),
|
||||
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
|
||||
if matches!(self.output_level, OutputLevel::SilentJSON) {
|
||||
self.as_json().unwrap_or_default()
|
||||
} else {
|
||||
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", 9)?;
|
||||
|
||||
// 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.serialize_field("truncated", &self.truncated)?;
|
||||
state.serialize_field("timestamp", &self.timestamp)?;
|
||||
|
||||
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,
|
||||
truncated: false,
|
||||
timestamp: timestamp(),
|
||||
};
|
||||
|
||||
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) = parse_url_with_raw_path(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());
|
||||
}
|
||||
}
|
||||
"truncated" => {
|
||||
if let Some(result) = value.as_bool() {
|
||||
response.truncated = result;
|
||||
}
|
||||
}
|
||||
"timestamp" => {
|
||||
if let Some(result) = value.as_f64() {
|
||||
response.timestamp = result;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that the truncated getter returns the correct value
|
||||
fn truncated_getter_returns_correct_value() {
|
||||
let mut response = FeroxResponse::default();
|
||||
|
||||
// Default should be false
|
||||
assert!(!response.truncated());
|
||||
|
||||
// Manually set truncated to true to test getter
|
||||
response.truncated = true;
|
||||
assert!(response.truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// test that truncated responses show [TRUNCATED] in URL display
|
||||
fn truncated_response_shows_in_url_display() {
|
||||
let response = FeroxResponse {
|
||||
url: Url::parse("http://localhost/test").unwrap(),
|
||||
truncated: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let display = response.as_str();
|
||||
assert!(display.contains("truncated"));
|
||||
}
|
||||
}
|
||||