Compare commits
703 Commits
v0.0.5-dev
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8594b205ee | ||
|
|
6acf08aec0 | ||
|
|
33c4ca5059 | ||
|
|
a430641960 | ||
|
|
76788aa1cb | ||
|
|
dbc2eadac3 | ||
|
|
ecf8c93f3f | ||
|
|
070c5f5a24 | ||
|
|
4e39cebe61 | ||
|
|
767e7cb16e | ||
|
|
4da939276e | ||
|
|
dc5846fb93 | ||
|
|
18a996dd53 | ||
|
|
333729a032 | ||
|
|
0a67953b3f | ||
|
|
3316c81f35 | ||
|
|
51e185a09f | ||
|
|
4599bacf17 | ||
|
|
e971466097 | ||
|
|
65c44c97c1 | ||
|
|
1c824c5515 | ||
|
|
e1908e783b | ||
|
|
6f5d0cea33 | ||
|
|
dc3bc11a51 | ||
|
|
9aebb9d68e | ||
|
|
801e81ac0d | ||
|
|
aa06dc6d93 | ||
|
|
8e02db1d09 | ||
|
|
6ad8539938 | ||
|
|
6ade12a5f6 | ||
|
|
521a5a5756 | ||
|
|
54bade56e9 | ||
|
|
1aecf640aa | ||
|
|
b1738ac35a | ||
|
|
5663b59636 | ||
|
|
dc14669605 | ||
|
|
a8cd3a5b08 | ||
|
|
2d74224fd3 | ||
|
|
b4aaf026c9 | ||
|
|
e48452dd05 | ||
|
|
9f55120d2d | ||
|
|
f88f76a1c5 | ||
|
|
08e8915242 | ||
|
|
d5a64a4f61 | ||
|
|
8671c25fb5 | ||
|
|
3eb501c72b | ||
|
|
498f809e94 | ||
|
|
c2cb4375f8 | ||
|
|
7f8b335393 | ||
|
|
969404956c | ||
|
|
e9d3da03db | ||
|
|
ec792952f8 | ||
|
|
d94a05844e | ||
|
|
c449d94dad | ||
|
|
c0144e60e3 | ||
|
|
d3c87b6e06 | ||
|
|
0ae3560ba1 | ||
|
|
e4e9dc2590 | ||
|
|
f2a4722d04 | ||
|
|
850f2c1dc9 | ||
|
|
6dcbd15923 | ||
|
|
82505f2460 | ||
|
|
1afeb397f7 | ||
|
|
5844d4ffd1 | ||
|
|
651b09b085 | ||
|
|
118210c7c7 | ||
|
|
269b843bb7 | ||
|
|
68609f7397 | ||
|
|
d0d21e6710 | ||
|
|
319cfdac5e | ||
|
|
458391e6f0 | ||
|
|
c8ad28bf1a | ||
|
|
3791b9555c | ||
|
|
30a7b484d1 | ||
|
|
41684afa80 | ||
|
|
2d06114eb7 | ||
|
|
52aa033fe4 | ||
|
|
d8b83e2bc2 | ||
|
|
b9ebcc71da | ||
|
|
0f4c01b83a | ||
|
|
7cb923b2fd | ||
|
|
e32784a09e | ||
|
|
db6832e782 | ||
|
|
4b80c4a624 | ||
|
|
12785f9c05 | ||
|
|
7d4569fb3d | ||
|
|
47939fafaf | ||
|
|
e68ead129b | ||
|
|
742365bc18 | ||
|
|
dee54e5184 | ||
|
|
1e84769891 | ||
|
|
16a67216b4 | ||
|
|
0b4e76d858 | ||
|
|
ccd2c48e0c | ||
|
|
150cad94a6 | ||
|
|
c488d980cc | ||
|
|
3d8b7c5b63 | ||
|
|
b4976630da | ||
|
|
3d6d8ed036 | ||
|
|
075627f53f | ||
|
|
1e2b75fa9c | ||
|
|
06988f9b33 | ||
|
|
c3d96dbef5 | ||
|
|
5f4cccbc85 | ||
|
|
cd7caffb4a | ||
|
|
548ede77b8 | ||
|
|
3fa7307d93 | ||
|
|
5cc80f9f88 | ||
|
|
387f6aeb72 | ||
|
|
200257bab3 | ||
|
|
a87e6ab466 | ||
|
|
a0f6467e85 | ||
|
|
78e2bad49b | ||
|
|
4f989c59f6 | ||
|
|
932269d6bb | ||
|
|
7a2a365136 | ||
|
|
459a246488 | ||
|
|
966971b941 | ||
|
|
f9d17487ad | ||
|
|
8b71c540e6 | ||
|
|
cccf646856 | ||
|
|
124199b331 | ||
|
|
5a5a9518c6 | ||
|
|
3d94f0f710 | ||
|
|
4f43177976 | ||
|
|
0a2d642ea8 | ||
|
|
49cc6ca395 | ||
|
|
a2c003f634 | ||
|
|
72e08bdd8a | ||
|
|
1f8a5750d2 | ||
|
|
826b8eafdf | ||
|
|
5c7ab43567 | ||
|
|
a04cf78657 | ||
|
|
9e9abdd162 | ||
|
|
5840ebd227 | ||
|
|
f2a2eb0548 | ||
|
|
ac1c83393f | ||
|
|
a77be8f4c4 | ||
|
|
eebae09e82 | ||
|
|
e90756d5a3 | ||
|
|
c795255e35 | ||
|
|
36d5c021f3 | ||
|
|
3490812ed1 | ||
|
|
5b766bb27b | ||
|
|
0bdbf2da8a | ||
|
|
00eb5c8ec7 | ||
|
|
0a3fc8d428 | ||
|
|
3d7ea62f61 | ||
|
|
88f5178a05 | ||
|
|
3d9639d0ef | ||
|
|
03e1a10c1d | ||
|
|
7f88f83a8c | ||
|
|
0d1ba75d4c | ||
|
|
2e0c4a5d3d | ||
|
|
923fc4744c | ||
|
|
859ce29ab0 | ||
|
|
0ad855f2a8 | ||
|
|
f6797d0d6f | ||
|
|
6cf88c2e86 | ||
|
|
aaa1794e89 | ||
|
|
0bfd37027f | ||
|
|
d7f7a6c72e | ||
|
|
976c5317ac | ||
|
|
86382bbc70 | ||
|
|
d0111cec20 | ||
|
|
95c2970e6d | ||
|
|
bf2316843b | ||
|
|
02fa8de1b4 | ||
|
|
828467309c | ||
|
|
c156f54274 | ||
|
|
f6258fdc86 | ||
|
|
635baeb86e | ||
|
|
6eec7e1740 | ||
|
|
18b9ffee84 | ||
|
|
ed80658f46 | ||
|
|
21a41aa3e8 | ||
|
|
05e6fbebfd | ||
|
|
c8d4cd38c9 | ||
|
|
35b0021569 | ||
|
|
4be215d914 | ||
|
|
c1d9050d26 | ||
|
|
14ca44453f | ||
|
|
2c682bdf66 | ||
|
|
855f0afb21 | ||
|
|
49b2e0f3df | ||
|
|
5387a395a3 | ||
|
|
ec141c3693 | ||
|
|
e0bb4a7836 | ||
|
|
27548de0c8 | ||
|
|
666011a975 | ||
|
|
5b4ae39c6a | ||
|
|
9bd1fb019b | ||
|
|
1e631efb06 | ||
|
|
5f960dbf6c | ||
|
|
c9bb677999 | ||
|
|
fdb3bdd0f2 | ||
|
|
ec02875f75 | ||
|
|
2f4e02e883 | ||
|
|
71fce1b1b2 | ||
|
|
1864e687f8 | ||
|
|
d599ac8407 | ||
|
|
1db3d0d2cf | ||
|
|
359712bee0 | ||
|
|
c313485e2f | ||
|
|
99612906d8 | ||
|
|
a47764af5f | ||
|
|
1e9fae0330 | ||
|
|
89a6233e76 | ||
|
|
bc7773d90a | ||
|
|
08b5f612ac | ||
|
|
9ffed8f039 | ||
|
|
e34ef28d03 | ||
|
|
45915b806a | ||
|
|
d6d3ea8896 | ||
|
|
ccc7712e61 | ||
|
|
6d9af94dc0 | ||
|
|
e98690ca42 | ||
|
|
0701766574 | ||
|
|
2d9cacfe59 | ||
|
|
2354b4af31 | ||
|
|
2c92368985 | ||
|
|
01396c1243 | ||
|
|
6b050feee4 | ||
|
|
3fab4697a7 | ||
|
|
141e984067 | ||
|
|
7daa0728ab | ||
|
|
d98075e84d | ||
|
|
657502940f | ||
|
|
5ef16388de | ||
|
|
31da7ee2b7 | ||
|
|
7535652640 | ||
|
|
19aa34e5bb | ||
|
|
1101fdab0f | ||
|
|
f02786f4d1 | ||
|
|
0186904385 | ||
|
|
6e4786a5ad | ||
|
|
041dd151ae | ||
|
|
2333893d4e | ||
|
|
4d5bbfff03 | ||
|
|
30f0de2c87 | ||
|
|
8a56f5b195 | ||
|
|
7f23bad2d3 | ||
|
|
af18e3ac8b | ||
|
|
7c3500db41 | ||
|
|
1308f2610d | ||
|
|
094e557720 | ||
|
|
9fb9b3fca6 | ||
|
|
1899b99502 | ||
|
|
080964f1d1 | ||
|
|
780375b489 | ||
|
|
3c70b4a0c9 | ||
|
|
41a9b533de | ||
|
|
4f18e7986a | ||
|
|
358861d283 | ||
|
|
1217a3717a | ||
|
|
6c0f4269f3 | ||
|
|
130ee7dff6 | ||
|
|
007adb4a5a | ||
|
|
8f29d4aba0 | ||
|
|
652b465893 | ||
|
|
0d698b7b21 | ||
|
|
f163d4c5b7 | ||
|
|
42fce64f6e | ||
|
|
4e4302f530 | ||
|
|
350ab7831c | ||
|
|
26cfebd6fa | ||
|
|
fcc4c4b2da | ||
|
|
d86abc1e86 | ||
|
|
887f3d9a93 | ||
|
|
b197943ada | ||
|
|
78590158c7 | ||
|
|
d63fed2543 | ||
|
|
2031fc6d17 | ||
|
|
378b1ed89e | ||
|
|
ebf6516817 | ||
|
|
a9eb625796 | ||
|
|
d7356d8cf7 | ||
|
|
f5e7aa9457 | ||
|
|
d854a93631 | ||
|
|
edb5c25b62 | ||
|
|
9feafdbb88 | ||
|
|
3c4f4275ba | ||
|
|
25281e511f | ||
|
|
8a32af83dc | ||
|
|
760079337e | ||
|
|
6c7b160e84 | ||
|
|
298ddbe199 | ||
|
|
14309727fc | ||
|
|
63de99fab8 | ||
|
|
82c631c907 | ||
|
|
e1f707141f | ||
|
|
e196604c1f | ||
|
|
5429f8f3ff | ||
|
|
f1c7c6123a | ||
|
|
0979eda194 | ||
|
|
d9a6fd9015 | ||
|
|
73f6678f6b | ||
|
|
160c094ad3 | ||
|
|
a8cb51c0c5 | ||
|
|
cc45ff513a | ||
|
|
ce7fff57cf | ||
|
|
d2290cdfe7 | ||
|
|
78a728b87a | ||
|
|
24cb277b82 | ||
|
|
d39b4c16ee | ||
|
|
d213b87171 | ||
|
|
b707398a10 | ||
|
|
14bf541042 | ||
|
|
b87652bb27 | ||
|
|
105217834d | ||
|
|
eeda65a8b7 | ||
|
|
763772c98e | ||
|
|
44743a962a | ||
|
|
ae8c2892d6 | ||
|
|
ec4bd2c341 | ||
|
|
96360bad60 | ||
|
|
beb6e2d96a | ||
|
|
9c32f4d8ca | ||
|
|
be85c7ec93 | ||
|
|
67cdf8e536 | ||
|
|
b6b26b7811 | ||
|
|
985c68731e | ||
|
|
f237b88b46 | ||
|
|
dde52a9d01 | ||
|
|
6123c75f96 | ||
|
|
feb90a9289 | ||
|
|
708712abfc | ||
|
|
f1d6f51237 | ||
|
|
c01a24c203 | ||
|
|
53e5f6d244 | ||
|
|
15063e5993 | ||
|
|
7eae3203f3 | ||
|
|
155343dc43 | ||
|
|
aa501a76c0 | ||
|
|
82217aeb07 | ||
|
|
4f1cb39e56 | ||
|
|
59967ca8cf | ||
|
|
0df9261598 | ||
|
|
bda53d0857 | ||
|
|
6ec6cf2b03 | ||
|
|
18af8d54e1 | ||
|
|
69e32d99cc | ||
|
|
ad6340e60d | ||
|
|
06b080a81c | ||
|
|
d8d94b7c1a | ||
|
|
7cb7e7fc95 | ||
|
|
7686e85e52 | ||
|
|
eeaf4e1e29 | ||
|
|
a613190e64 | ||
|
|
30ac9c043e | ||
|
|
d016c63174 | ||
|
|
c0e915cfaa | ||
|
|
debe15e572 | ||
|
|
476cd4d29d | ||
|
|
fce0410ae3 | ||
|
|
a9b424dc51 | ||
|
|
fca2b8e3ab | ||
|
|
7a5d637a50 | ||
|
|
5c1052c6c6 | ||
|
|
95c87b4f7b | ||
|
|
70511831ce | ||
|
|
d7a1508b8e | ||
|
|
9b33168aac | ||
|
|
2b478588b7 | ||
|
|
e62291fc81 | ||
|
|
403c71db9f | ||
|
|
d6cb45b405 | ||
|
|
40acc58451 | ||
|
|
d0884a2ee5 | ||
|
|
377646078f | ||
|
|
a69cd670b4 | ||
|
|
78af01e42c | ||
|
|
8dc557204a | ||
|
|
8495e3daa9 | ||
|
|
32c2b07e5b | ||
|
|
95cc1e3ad7 | ||
|
|
26d68d49de | ||
|
|
e49ab7e29a | ||
|
|
5aa2027b61 | ||
|
|
e6f6288701 | ||
|
|
1645f4003d | ||
|
|
e1a66bb864 | ||
|
|
f81a15d78e | ||
|
|
f1e78cfc12 | ||
|
|
47c605ee06 | ||
|
|
c2175fe5a2 | ||
|
|
c4d4cb31b5 | ||
|
|
48ece84a36 | ||
|
|
14cde55629 | ||
|
|
926837efc3 | ||
|
|
1d48cf116a | ||
|
|
6ac40c592d | ||
|
|
a058f7160c | ||
|
|
4eab4092cf | ||
|
|
c044946c75 | ||
|
|
818ab810ab | ||
|
|
d7798ab194 | ||
|
|
d3c0d7c279 | ||
|
|
ebe5411ba1 | ||
|
|
b5c2b14c7c | ||
|
|
b523eb57aa | ||
|
|
6e6225c574 | ||
|
|
eb6a914a1a | ||
|
|
948b411a6e | ||
|
|
ab3ee44b6d | ||
|
|
eb7f3cab94 | ||
|
|
c4fcab47d1 | ||
|
|
b7260331cc | ||
|
|
edb40c01cb | ||
|
|
b6b0381ef2 | ||
|
|
bfa63005e6 | ||
|
|
cbdec10ca6 | ||
|
|
38940ea364 | ||
|
|
fef3d6d947 | ||
|
|
36f229774f | ||
|
|
fbe42b497c | ||
|
|
7f2f729418 | ||
|
|
79d7cf9599 | ||
|
|
c0f9848b77 | ||
|
|
b662145492 | ||
|
|
774323c28f | ||
|
|
d0f6a828c1 | ||
|
|
6f3c5ad28e | ||
|
|
debca4218b | ||
|
|
1dba6fdd8e | ||
|
|
f93f786f1d | ||
|
|
be81848cc0 | ||
|
|
f6e055d5c5 | ||
|
|
b08c8a4581 | ||
|
|
bd743347d0 | ||
|
|
875d96b4f0 | ||
|
|
d63e9277c7 | ||
|
|
273d999561 | ||
|
|
79377e36fc | ||
|
|
ffd73b1e7d | ||
|
|
c3b3e2525e | ||
|
|
4efa6111ed | ||
|
|
f4df5f845f | ||
|
|
31fa59b9e3 | ||
|
|
2a273760e5 | ||
|
|
578c2b4318 | ||
|
|
596d58333b | ||
|
|
86175b6b9d | ||
|
|
4200c2b453 | ||
|
|
0d6fa599a8 | ||
|
|
e9abffaed5 | ||
|
|
795dc271b2 | ||
|
|
12bd28dfad | ||
|
|
f09faf879f | ||
|
|
d7e6de4987 | ||
|
|
44ad5d6630 | ||
|
|
0831a6414c | ||
|
|
34b5356d0c | ||
|
|
9580bf05eb | ||
|
|
9578e00630 | ||
|
|
aeec13cea2 | ||
|
|
7b48d023cd | ||
|
|
84d332396f | ||
|
|
4a0dc9834a | ||
|
|
092d9a78d9 | ||
|
|
d56b950030 | ||
|
|
9a7d2f2bbf | ||
|
|
e014e3833c | ||
|
|
2a8581d515 | ||
|
|
230a1d3eeb | ||
|
|
bb42821d94 | ||
|
|
8adcc8ea5f | ||
|
|
b4c1c2e938 | ||
|
|
358e2148d2 | ||
|
|
894354e8f9 | ||
|
|
f5ebf92c6a | ||
|
|
73c001891d | ||
|
|
6dbe0464a6 | ||
|
|
b35485f49b | ||
|
|
7394334ba8 | ||
|
|
65e4690f83 | ||
|
|
1161fe60bc | ||
|
|
b945fe36ba | ||
|
|
535ee17642 | ||
|
|
05211b7858 | ||
|
|
f6a08ffcd6 | ||
|
|
96e612981d | ||
|
|
ee1f6e9a4b | ||
|
|
9819e9deb8 | ||
|
|
810d673088 | ||
|
|
13847f2db4 | ||
|
|
a6c34fe62f | ||
|
|
42546ce740 | ||
|
|
b52e9bbc34 | ||
|
|
f4dccaa846 | ||
|
|
a8c1f101c7 | ||
|
|
b545c5b26a | ||
|
|
360f6dda22 | ||
|
|
4fad0ff0c7 | ||
|
|
e20604a260 | ||
|
|
1f5122ab11 | ||
|
|
99a07a4295 | ||
|
|
91244843ed | ||
|
|
9ea542c9ac | ||
|
|
d47ec57f9c | ||
|
|
2aa546861a | ||
|
|
f43b5674ae | ||
|
|
0c2f8ab593 | ||
|
|
023686e1f5 | ||
|
|
15747ff0d1 | ||
|
|
a90aae0131 | ||
|
|
b234cd39ca | ||
|
|
c7a1a0ae67 | ||
|
|
676f85af45 | ||
|
|
d921a148eb | ||
|
|
7719545aab | ||
|
|
b9d0cac4fd | ||
|
|
e0b89d0e7c | ||
|
|
3b2c2cf72c | ||
|
|
80a70dab6f | ||
|
|
8833085d86 | ||
|
|
3d1cd033b4 | ||
|
|
590a4fde15 | ||
|
|
85da069617 | ||
|
|
f65f7e8bca | ||
|
|
96ce79647d | ||
|
|
9a4d07ebd2 | ||
|
|
7d191523a6 | ||
|
|
f5789c6253 | ||
|
|
d103b73056 | ||
|
|
a63f11b2fb | ||
|
|
7a9801e8e6 | ||
|
|
8fd40507d9 | ||
|
|
5f9c3282a7 | ||
|
|
6783db789a | ||
|
|
ef7c234bb9 | ||
|
|
14d12e24ab | ||
|
|
bed227ad19 | ||
|
|
31b4c738e0 | ||
|
|
0e888a67d0 | ||
|
|
99059f46f0 | ||
|
|
ea77a440f6 | ||
|
|
f9c511806c | ||
|
|
cce7943a78 | ||
|
|
de871dda82 | ||
|
|
8e90c7ba05 | ||
|
|
681eb922b3 | ||
|
|
5807671f5a | ||
|
|
cc918c6ab7 | ||
|
|
d197326010 | ||
|
|
9c2e20175f | ||
|
|
f894d5aa3c | ||
|
|
79c5940fa5 | ||
|
|
70ba72169c | ||
|
|
30aa9d8c05 | ||
|
|
16f1768f22 | ||
|
|
1bb8a4d666 | ||
|
|
3e3febd523 | ||
|
|
5ada5206b6 | ||
|
|
4e0dc6e645 | ||
|
|
09ec5d0373 | ||
|
|
d605529870 | ||
|
|
3fb8e9b5fd | ||
|
|
d1c6a84fc0 | ||
|
|
a7e503322f | ||
|
|
de922e2ea2 | ||
|
|
c774854ce1 | ||
|
|
8fdb1d61b4 | ||
|
|
41e484ba0f | ||
|
|
c55e0a767d | ||
|
|
7279b34483 | ||
|
|
7fea4f3e54 | ||
|
|
6f920445c2 | ||
|
|
3f7254eee0 | ||
|
|
2923c4025c | ||
|
|
0aa75665b8 | ||
|
|
0cf1afacf2 | ||
|
|
311b83199f | ||
|
|
ed2b547d1f | ||
|
|
1cfd346698 | ||
|
|
33d61f1098 | ||
|
|
6bf729d1ac | ||
|
|
58b9f75463 | ||
|
|
07de20a857 | ||
|
|
e583c05d91 | ||
|
|
d65157b194 | ||
|
|
503f9b2884 | ||
|
|
1009189f90 | ||
|
|
e1c3689537 | ||
|
|
454e87dfa8 | ||
|
|
f5370493b8 | ||
|
|
aea651aa89 | ||
|
|
8498ee5edb | ||
|
|
68b3d00ebd | ||
|
|
a565c8bc9a | ||
|
|
0f38e0603e | ||
|
|
277aba8813 | ||
|
|
2a9360e5e4 | ||
|
|
7a6b13885b | ||
|
|
e46f739526 | ||
|
|
035065f5ab | ||
|
|
e3078acb82 | ||
|
|
92efd33855 | ||
|
|
ef67b0c34b | ||
|
|
cf4428d608 | ||
|
|
3ba2a456d5 | ||
|
|
26e5055581 | ||
|
|
dfd7927196 | ||
|
|
650055a1dd | ||
|
|
00d2fc2027 | ||
|
|
eef27dde5b | ||
|
|
e1f8a69505 | ||
|
|
4ffa8a10df | ||
|
|
9f155583b6 | ||
|
|
291acb274c | ||
|
|
765253c015 | ||
|
|
517eec9a30 | ||
|
|
95d6228f77 | ||
|
|
b479f78735 | ||
|
|
8e76ca6844 | ||
|
|
94c76ba3f8 | ||
|
|
a900a3d201 | ||
|
|
7f3c36410b | ||
|
|
4bff6d1e65 | ||
|
|
89f70b58f8 | ||
|
|
b9fb908ced | ||
|
|
f385c0bb72 | ||
|
|
ff103ce891 | ||
|
|
946d5cbc8a | ||
|
|
2c9fc1b397 | ||
|
|
6a20a91502 | ||
|
|
32bdf6183c | ||
|
|
95364928c1 | ||
|
|
9ad78d5a4b | ||
|
|
571ac59e51 | ||
|
|
1cb84f13c0 | ||
|
|
f8274a2dd9 | ||
|
|
989c4d5e5b | ||
|
|
7474fdc3ec | ||
|
|
cc7de3fdb6 | ||
|
|
4dd3b86a6e | ||
|
|
4bf6892b7b | ||
|
|
61bf471934 | ||
|
|
708eb81a0c | ||
|
|
981887de11 | ||
|
|
6593fd40f5 | ||
|
|
f43920e6e6 | ||
|
|
e16f907027 | ||
|
|
39971ed58b | ||
|
|
580362738d | ||
|
|
2300ea0844 | ||
|
|
93bbc74981 | ||
|
|
33f1173787 | ||
|
|
bb43ed44d5 | ||
|
|
b47355bc03 | ||
|
|
6928b858cd | ||
|
|
d6ee7ae229 | ||
|
|
b338689814 | ||
|
|
2f9dbd0c4b | ||
|
|
abf6e71499 | ||
|
|
bf2dbe0f1b | ||
|
|
7f11b3303d | ||
|
|
dccaa5b1ba | ||
|
|
d63b920974 | ||
|
|
3db93e9726 | ||
|
|
1e492ba31a | ||
|
|
1c91ecfb5f | ||
|
|
77dbf4900d | ||
|
|
9e1cc18faa | ||
|
|
ab3d7e9f34 | ||
|
|
430d002c3c | ||
|
|
115b7e4f0b | ||
|
|
8097829f54 | ||
|
|
b9530c0fbe | ||
|
|
252e44814b | ||
|
|
56fdeb64ce | ||
|
|
ca1fbbcee2 | ||
|
|
2ecc52ef00 | ||
|
|
4b16c05ff9 | ||
|
|
46bd7e2d13 | ||
|
|
4882e46815 | ||
|
|
0c99a6988a | ||
|
|
0c41bdd5b0 | ||
|
|
eadcd1a83d | ||
|
|
d92561ca8f | ||
|
|
8873a351ab | ||
|
|
b37834af74 | ||
|
|
e8cebfa539 | ||
|
|
0475f5faf2 | ||
|
|
6412840db3 | ||
|
|
24f265359b | ||
|
|
2ca64478cf | ||
|
|
2e9b5fb8e0 | ||
|
|
5b50255c57 | ||
|
|
d4884c0457 | ||
|
|
032d0770c7 | ||
|
|
5939b9df10 | ||
|
|
b5457c23b1 | ||
|
|
e895f8a0ec | ||
|
|
504598de65 | ||
|
|
b4c407dddc | ||
|
|
9c1e224a43 | ||
|
|
23c71ee662 | ||
|
|
0c74a07c0c | ||
|
|
be9f5f37ca | ||
|
|
1e6c674d67 | ||
|
|
654bd5cd6f | ||
|
|
52c72edfee |
24
.cursor/mcp.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"pinner-mcp-stdio-server": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"ghcr.io/safedep/pinner-mcp:latest"
|
||||
]
|
||||
},
|
||||
"vet-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"ghcr.io/safedep/vet:latest",
|
||||
"server",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
.cursor/rules/vet-packages.mdc
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Always use vet-mcp to vet any open source library package suggested by AI generated code before installation.
|
||||
Follow the rules below to use vet-mcp:
|
||||
|
||||
- Try to find the latest version. If not found, it is likely a slopsquatting vulnerability
|
||||
- Always check for critical vulnerabilities and malware
|
||||
15
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
42
.github/vet/policy.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
name: SafeDep vet OSS suite
|
||||
description: |
|
||||
Customized filter suite for vet vetting vet.
|
||||
tags:
|
||||
- general
|
||||
- safedep-vet
|
||||
filters:
|
||||
- name: critical-or-high-vulns
|
||||
check_type: CheckTypeVulnerability
|
||||
summary: Critical or high risk vulnerabilities were found
|
||||
value: |
|
||||
vulns.critical.exists(p, true) || vulns.high.exists(p, true)
|
||||
- name: low-popularity
|
||||
check_type: CheckTypePopularity
|
||||
summary: Component popularity is low by Github stars count
|
||||
value: |
|
||||
projects.exists(p, (p.type == "GITHUB") && (p.stars < 10))
|
||||
- name: risky-oss-licenses
|
||||
check_type: CheckTypeLicense
|
||||
summary: Risky OSS license was detected
|
||||
value: |
|
||||
licenses.exists(p, p == "GPL-2.0") ||
|
||||
licenses.exists(p, p == "GPL-2.0-only") ||
|
||||
licenses.exists(p, p == "GPL-3.0") ||
|
||||
licenses.exists(p, p == "GPL-3.0-only") ||
|
||||
licenses.exists(p, p == "BSD-3-Clause OR GPL-2.0")
|
||||
- name: ossf-unmaintained
|
||||
check_type: CheckTypeMaintenance
|
||||
summary: Component appears to be unmaintained
|
||||
value: |
|
||||
scorecard.scores["Maintained"] == 0
|
||||
- name: osv-malware
|
||||
check_type: CheckTypeMalware
|
||||
summary: Malicious (malware) component detected
|
||||
value: |
|
||||
vulns.all.exists(v, v.id.startsWith("MAL-"))
|
||||
- name: ossf-dangerous-workflow
|
||||
check_type: CheckTypeSecurityScorecard
|
||||
summary: Component release pipeline appear to use dangerous workflows
|
||||
value: |
|
||||
scorecard.scores["Dangerous-Workflow"] == 0
|
||||
|
||||
138
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-generated-code:
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run code generation
|
||||
run: make generate
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "ERROR: Generated code is out of sync!"
|
||||
echo "Please run 'make generate' and commit the changes."
|
||||
echo ""
|
||||
echo "Files with changes:"
|
||||
git status --porcelain
|
||||
echo ""
|
||||
echo "Diff:"
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run-test:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build and Test
|
||||
run: |
|
||||
go mod tidy
|
||||
go build
|
||||
go test -coverprofile=coverage.txt -v ./...
|
||||
env:
|
||||
VET_E2E: true
|
||||
|
||||
# Used to avoid rate limiting issue while running
|
||||
# test suites that use GitHub API
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Coverage
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push'
|
||||
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
run-e2e:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Build vet
|
||||
run: |
|
||||
go mod tidy
|
||||
go build
|
||||
|
||||
- name: Run E2E Scenarios
|
||||
run: |
|
||||
./test/scenarios/all.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run E2E Scenarios with Insights V2
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
./test/scenarios/all.sh
|
||||
env:
|
||||
E2E_INSIGHTS_V2: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# This will not be available when there is a PR from a forked repository
|
||||
VET_API_KEY: ${{ secrets.SAFEDEP_CLOUD_API_KEY }}
|
||||
VET_CONTROL_TOWER_TENANT_ID: ${{ secrets.SAFEDEP_CLOUD_TENANT_DOMAIN }}
|
||||
|
||||
build-container-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||
|
||||
- name: Build Multi-Platform Container Image (verification only)
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t build-container-test:latest .
|
||||
|
||||
- name: Build and Load Native Platform Image for Testing
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64 --load \
|
||||
-t build-container-test:latest .
|
||||
|
||||
- name: Test Container Image
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e VET_API_KEY=${{ secrets.SAFEDEP_CLOUD_API_KEY }} \
|
||||
-e VET_CONTROL_TOWER_TENANT_ID=${{ secrets.SAFEDEP_CLOUD_TENANT_DOMAIN }} \
|
||||
build-container-test:latest \
|
||||
auth verify
|
||||
18
.github/workflows/codeql.yml
vendored
@ -18,9 +18,13 @@ on:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
if: "!contains(github.event.commits[0].message, '[noci]')"
|
||||
timeout-minutes: 30
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@ -31,14 +35,20 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
language: ["go"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@ -53,6 +63,6 @@ jobs:
|
||||
go build
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
156
.github/workflows/container.yml
vendored
Normal file
@ -0,0 +1,156 @@
|
||||
name: Container Image Releaser
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
concurrency: ci-container-release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: "!contains(github.event.commits[0].message, '[noci]')"
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Registry Login
|
||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55
|
||||
|
||||
- name: Build and Push Container Image
|
||||
run: |
|
||||
# Get the tag if this was a tag push event
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
TAG=${{ github.ref_name }}
|
||||
# Validate tag format (must be vX.Y.Z)
|
||||
if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
# Build and push with both version tag and latest
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64 \
|
||||
-t $REGISTRY/$IMAGE_NAME:$TAG \
|
||||
-t $REGISTRY/$IMAGE_NAME:latest \
|
||||
.
|
||||
else
|
||||
echo "Invalid tag format. Must be in format vX.Y.Z (e.g. v1.2.3)"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# For non-tag pushes, just use latest tag
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64 \
|
||||
-t $REGISTRY/$IMAGE_NAME:latest \
|
||||
.
|
||||
fi
|
||||
|
||||
publish-mcp-registry:
|
||||
if: startsWith(github.ref, 'refs/tags/') # only run this when new tag is publish
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./.mcp-publisher
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Ensure jq is installed
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
|
||||
- name: Get version from tag
|
||||
# Strip 'v' prefix from tag (e.g., v1.0.0 -> 1.0.0) as
|
||||
# - we want clean version (x.y.z) without v prefix, since its already added by registry UI
|
||||
# - in case of docker image, we hardcode in server.json docker image identifier
|
||||
run: echo "VET_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: fill version in server.json
|
||||
run: sed -i "s/VERSION_FROM_ENV/$VET_VERSION/g" server.json
|
||||
|
||||
# publish mcp server
|
||||
- name: Install mcp-publisher
|
||||
run: |
|
||||
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
||||
|
||||
- name: Authenticate to MCP Registry
|
||||
run: ./mcp-publisher login github-oidc
|
||||
|
||||
- name: Publish server to MCP Registry
|
||||
run: ./mcp-publisher publish
|
||||
|
||||
verify-publish-mcp-registry:
|
||||
needs: publish-mcp-registry
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Ensure jq is installed
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
|
||||
- name: Get version from tag
|
||||
# Strip 'v' prefix from tag (e.g., v1.0.0 -> 1.0.0) as
|
||||
# - we want clean version (x.y.z) without v prefix, since its already added by registry UI
|
||||
# - in case of docker image, we hardcode in server.json docker image identifier
|
||||
run: echo "VET_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Query MCP Registry and verify server is published
|
||||
env:
|
||||
SERVER_NAME: "io.github.safedep/vet-mcp"
|
||||
REGISTRY_URL: "https://registry.modelcontextprotocol.io/v0.1/servers"
|
||||
run: |
|
||||
export EXPECTED_VERSION=$VET_VERSION
|
||||
|
||||
echo "Checking MCP Registry for $SERVER_NAME"
|
||||
|
||||
# Query registry
|
||||
url="${REGISTRY_URL}?search=${SERVER_NAME}"
|
||||
echo "Requesting: $url"
|
||||
http_status=$(curl -s -o response.json -w "%{http_code}" "$url")
|
||||
if [ "$http_status" -ne 200 ]; then
|
||||
echo "Registry query failed with HTTP status $http_status"
|
||||
cat response.json || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pretty print the response for debugging
|
||||
echo "Registry response (truncated):"
|
||||
jq 'if .servers then {servers: (.servers | length)} else . end' response.json
|
||||
|
||||
# Check for name and version match
|
||||
jq -e --arg name "$SERVER_NAME" --arg ver "$EXPECTED_VERSION" 'any(.servers[]; .server.name == $name and .server.version == $ver)' response.json >/dev/null || {
|
||||
echo "ERROR: Server $SERVER_NAME with version $EXPECTED_VERSION not found"
|
||||
echo "Full response:"
|
||||
cat response.json
|
||||
exit 1
|
||||
}
|
||||
echo "Found server $SERVER_NAME with version $EXPECTED_VERSION"
|
||||
21
.github/workflows/dependency-review.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
# Dependency Review Action
|
||||
#
|
||||
# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
|
||||
#
|
||||
# Source repository: https://github.com/actions/dependency-review-action
|
||||
# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
|
||||
name: "Dependency Review"
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@cc4f6536e38d1126c5e3b0683d469a14f23bfea4 # v3
|
||||
65
.github/workflows/gh-pages-deploy.yml
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
name: CLI Reference Manual GitHub Pages Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_GEN_DIR: ./docs/manual
|
||||
|
||||
jobs:
|
||||
# Build Jekkll (md -> html)
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build vet
|
||||
run: go build
|
||||
|
||||
- name: Generate MD Docs in ${{ env.SOURCE_GEN_DIR }}
|
||||
run:
|
||||
./vet doc generate --markdown ${{ env.SOURCE_GEN_DIR }}
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: ${{ env.SOURCE_GEN_DIR }}
|
||||
destination: ./_site
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
needs: build
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
27
.github/workflows/golangci-lint.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: Go Linter
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
|
||||
with:
|
||||
version: latest
|
||||
args: --issues-exit-code=1 --timeout=10m
|
||||
only-new-issues: true
|
||||
80
.github/workflows/goreleaser.yml
vendored
@ -1,15 +1,22 @@
|
||||
name: goreleaser
|
||||
name: Release Automation
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*" # triggers only if push new tag version, like `0.8.4` or else
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
concurrency: ci-release-automation
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
OSX_CROSS_TOOLCHAIN_REPOSITORY: https://github.com/abhisek/osxcross
|
||||
OSX_CROSS_MACOS_SDK_VERSION: "12.3"
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
hashes: ${{ steps.hash.outputs.hashes }}
|
||||
permissions:
|
||||
@ -27,9 +34,9 @@ jobs:
|
||||
- uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18 # v2
|
||||
- uses: docker/setup-buildx-action@8c0edbc76e98fa90f69d9a2c020dcb50019dc325 # v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 # v3.5.0
|
||||
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34
|
||||
with:
|
||||
go-version: 1.19
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- name: ghcr-login
|
||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # v1
|
||||
@ -37,31 +44,62 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install OSX Cross Compiler Build Tools
|
||||
run: sudo apt-get install -y -qq build-essential clang gcc g++ gcc-mingw-w64 zlib1g-dev libmpc-dev libmpfr-dev libgmp-dev cmake libxml2-dev libssl-dev xz-utils gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
|
||||
|
||||
- name: Setup OSX Cross Compiler Tool Chain Environment
|
||||
run: |
|
||||
echo "OSXCROSS_DIR=$(dirname $GITHUB_WORKSPACE)/osxcross" >> $GITHUB_ENV
|
||||
- name: Clone OSX Cross Compiler Tool Chain
|
||||
run: git clone $OSX_CROSS_TOOLCHAIN_REPOSITORY $OSXCROSS_DIR
|
||||
|
||||
- name: Setup Cache for OSX Cross Compiler Tool Chain
|
||||
id: osxcross-cache
|
||||
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # v3
|
||||
with:
|
||||
key: ${{ runner.os }}-osxcross-${{ env.OSX_CROSS_MACOS_SDK_VERSION }}
|
||||
path: |
|
||||
${{ env.OSXCROSS_DIR }}/target/bin
|
||||
- name: Build OSX Cross Compiler Tool Chain
|
||||
if: steps.osxcross-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd $OSXCROSS_DIR
|
||||
SDK_VERSION=$OSX_CROSS_MACOS_SDK_VERSION UNATTENDED=yes ./build.sh
|
||||
- name: Add OSX Cross Compiler Tool Chain to Path
|
||||
run: |
|
||||
echo "$OSXCROSS_DIR/target/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Run GoReleaser
|
||||
id: run-goreleaser
|
||||
uses: goreleaser/goreleaser-action@8f67e590f2d095516493f017008adc464e63adb1 # v4.1.0
|
||||
uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0
|
||||
with:
|
||||
version: latest
|
||||
args: release --rm-dist
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate subject
|
||||
id: hash
|
||||
env:
|
||||
ARTIFACTS: "${{ steps.run-goreleaser.outputs.artifacts }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path')
|
||||
echo "hashes=$(cat $checksum_file | base64 -w0)" >> "$GITHUB_OUTPUT"
|
||||
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
|
||||
- name: Upload dist folder
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dist-artifacts
|
||||
path: dist/
|
||||
|
||||
provenance:
|
||||
needs: [goreleaser]
|
||||
permissions:
|
||||
actions: read # To read the workflow path.
|
||||
id-token: write # To sign the provenance.
|
||||
contents: write # To add assets to a release.
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.4.0
|
||||
attestations: write # To write attestations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download dist folder
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
base64-subjects: "${{ needs.goreleaser.outputs.hashes }}"
|
||||
upload-assets: true
|
||||
private-repository: true
|
||||
name: dist-artifacts
|
||||
path: dist/
|
||||
|
||||
- name: Attest build provenance (checksums)
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
||||
with:
|
||||
subject-checksums: dist/checksums.txt
|
||||
|
||||
63
.github/workflows/publish-npm.yml
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
name: Publish NPM Package
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release Automation"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
package_version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Extract Tag Version
|
||||
id: version
|
||||
run: |
|
||||
TAG_VERSION="${{ github.event.workflow_run.head_branch }}"
|
||||
if [[ "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
VERSION="${TAG_VERSION#v}" # Remove leading 'v'
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No valid tag found in head_branch: $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
- name: Prepare package
|
||||
run: |
|
||||
cd publish/npm
|
||||
npm version ${{ steps.version.outputs.version }} --no-git-tag-version
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
cd publish/npm
|
||||
npm publish --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
test-installation:
|
||||
needs: publish-npm
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: ["14", "18", "20"]
|
||||
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Test installation
|
||||
run: |
|
||||
npm install -g @safedep/vet@${{ needs.publish-npm.outputs.package_version }}
|
||||
vet version
|
||||
vet --help || true
|
||||
60
.github/workflows/scorecard.yml
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: "35 22 * * 0"
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
publish_results: true
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@807578363a7869ca324a79039e6db9c843e0e100 # v2.1.27
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
13
.github/workflows/secret_scan.yml
vendored
@ -3,18 +3,23 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trufflehog:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
- name: TruffleHog OSS
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
uses: trufflesecurity/trufflehog@8b6f55b592e46ac44a42dc3e3dee0ebcc0f56df5
|
||||
with:
|
||||
path: ./
|
||||
base: main
|
||||
head: HEAD
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
|
||||
46
.github/workflows/vet-ci.yml
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
name: vet OSS Components
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
vet:
|
||||
name: vet
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- name: Enable Cloud Mode
|
||||
run: echo "SAFEDEP_CLOUD_MODE=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Override Cloud Mode if Actor is Dependabot
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
run: echo "SAFEDEP_CLOUD_MODE=false" >> $GITHUB_ENV
|
||||
|
||||
- name: Override Cloud Mode if PR is from External Repository
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: echo "SAFEDEP_CLOUD_MODE=false" >> $GITHUB_ENV
|
||||
|
||||
- name: Run vet
|
||||
uses: safedep/vet-action@01f547ee95dfd4f8f11fa64b399e5e00f22b0801
|
||||
with:
|
||||
policy: .github/vet/policy.yml
|
||||
cloud: ${{ env.SAFEDEP_CLOUD_MODE }}
|
||||
cloud-key: ${{ secrets.SAFEDEP_CLOUD_API_KEY }}
|
||||
cloud-tenant: ${{ secrets.SAFEDEP_CLOUD_TENANT_DOMAIN }}
|
||||
enable-comments-proxy: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SAFEDEP_CLOUD_MODE: ${{ env.SAFEDEP_CLOUD_MODE }}
|
||||
36
.github/workflows/vet-container-scanning-e2e.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: Container Scan E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
e2e-scan:
|
||||
name: E2E Scan on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build and Test
|
||||
run: |
|
||||
make
|
||||
|
||||
- name: Run container scan tests
|
||||
shell: bash
|
||||
run: |
|
||||
./vet scan --image alpine:latest
|
||||
./vet scan --image ghcr.io/safedep/vet:latest
|
||||
./vet scan --image node:20
|
||||
14
.gitignore
vendored
@ -5,6 +5,11 @@
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
@ -15,5 +20,12 @@
|
||||
# vendor/
|
||||
|
||||
/vet
|
||||
|
||||
dist/
|
||||
/.env.dev
|
||||
.vscode/
|
||||
|
||||
# MacOS specific files
|
||||
**/.DS_Store
|
||||
|
||||
# Auto-generated context files
|
||||
CLAUDE.md
|
||||
|
||||
18
.golangci.yml
Normal file
@ -0,0 +1,18 @@
|
||||
# golangci-lint configuration file
|
||||
# See https://golangci-lint.run/usage/configuration/
|
||||
|
||||
version: "2"
|
||||
linters:
|
||||
exclusions:
|
||||
paths: []
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofumpt
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- localmodule
|
||||
@ -1,18 +1,70 @@
|
||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
version: 2
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- id: linux
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
overrides:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
env:
|
||||
- CC=x86_64-linux-gnu-gcc
|
||||
- CXX=x86_64-linux-gnu-g++
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
env:
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
- CXX=aarch64-linux-gnu-g++
|
||||
|
||||
- id: darwin
|
||||
goos: [darwin]
|
||||
goarch: [amd64, arm64]
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
|
||||
- id: windows
|
||||
goos: [windows]
|
||||
goarch: [amd64]
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
- CXX=x86_64-w64-mingw32-g++
|
||||
|
||||
release:
|
||||
# for prerelease it doesn't build and distribute
|
||||
prerelease: auto
|
||||
|
||||
universal_binaries:
|
||||
- replace: true
|
||||
|
||||
brews:
|
||||
- name: vet
|
||||
homepage: https://safedep.io
|
||||
description: "SafeDep vet is a tool for identifying open source software supply chain risks"
|
||||
license: "Apache-2.0"
|
||||
repository:
|
||||
owner: safedep
|
||||
name: homebrew-tap
|
||||
branch: main
|
||||
|
||||
# TODO: Move to PR workflow once v1.17 is released
|
||||
# branch: develop/vet
|
||||
# pull_request:
|
||||
# enabled: true
|
||||
# base: main
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
@ -27,16 +79,16 @@ archives:
|
||||
- goos: windows
|
||||
format: zip
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
name_template: "checksums.txt"
|
||||
algorithm: sha256
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
version_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
# The lines beneath this are called `modelines`. See `:help modeline`
|
||||
# Feel free to remove those if you don't want/use them.
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
|
||||
74
.mcp-publisher/server.json
Normal file
@ -0,0 +1,74 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
|
||||
"name": "io.github.safedep/vet-mcp",
|
||||
"title": "SafeDep Vet MCP",
|
||||
"description": "Protect your AI agents and IDEs from malicious open-source packages.",
|
||||
"version": "VERSION_FROM_ENV",
|
||||
"websiteUrl": "https://safedep.io",
|
||||
"repository": {
|
||||
"url": "https://github.com/safedep/vet",
|
||||
"source": "github"
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://raw.githubusercontent.com/safedep/.github/9275c7d1b59f718d73e47cecd93df92e7bfbea25/assets/logo/safedep-logo-darkshade.svg",
|
||||
"mimeType": "image/svg+xml",
|
||||
"sizes": [
|
||||
"48x48",
|
||||
"96x96"
|
||||
],
|
||||
"theme": "light"
|
||||
},
|
||||
{
|
||||
"src": "https://raw.githubusercontent.com/safedep/.github/9275c7d1b59f718d73e47cecd93df92e7bfbea25/assets/logo/safedep-logo.svg",
|
||||
"mimeType": "image/svg+xml",
|
||||
"sizes": [
|
||||
"48x48",
|
||||
"96x96"
|
||||
],
|
||||
"theme": "dark"
|
||||
}
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "oci",
|
||||
"identifier": "ghcr.io/safedep/vet:vVERSION_FROM_ENV",
|
||||
"runtimeHint": "docker",
|
||||
"runtimeArguments": [
|
||||
{
|
||||
"type": "named",
|
||||
"name": "--rm",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "named",
|
||||
"name": "-i",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"packageArguments": [
|
||||
{
|
||||
"type": "positional",
|
||||
"value": "-s"
|
||||
},
|
||||
{
|
||||
"type": "named",
|
||||
"name": "-l",
|
||||
"value": "/tmp/vet-mcp.log"
|
||||
},
|
||||
{
|
||||
"type": "positional",
|
||||
"value": "server"
|
||||
},
|
||||
{
|
||||
"type": "positional",
|
||||
"value": "mcp"
|
||||
}
|
||||
],
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
2
.tool-versions
Normal file
@ -0,0 +1,2 @@
|
||||
golang 1.25.1
|
||||
gitleaks 8.16.4
|
||||
23
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"[go]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "golang.go",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"gopls": {
|
||||
"usePlaceholders": true,
|
||||
"completeUnimported": true,
|
||||
"staticcheck": true,
|
||||
"ui.semanticTokens": true,
|
||||
"formatting.gofumpt": true,
|
||||
},
|
||||
"cSpell.words": [
|
||||
"ineffassign",
|
||||
"Infof",
|
||||
"lockfiles",
|
||||
"nolint",
|
||||
"safedep"
|
||||
]
|
||||
}
|
||||
102
CONTRIBUTING.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Contributing Guide
|
||||
|
||||
You can contribute to `vet` and help make it better. Apart from bug fixes,
|
||||
features, we particularly value contributions in the form of:
|
||||
|
||||
- Documentation improvements
|
||||
- Bug reports
|
||||
- Using `vet` in your projects and providing feedback
|
||||
|
||||
## How to contribute
|
||||
|
||||
1. Fork the repository
|
||||
2. Add your changes
|
||||
3. Submit a pull request
|
||||
|
||||
## How to report a bug
|
||||
|
||||
Create a new issue and add the label "bug".
|
||||
|
||||
## How to suggest a new feature
|
||||
|
||||
Create a new issue and add the label "enhancement".
|
||||
|
||||
## Development workflow
|
||||
|
||||
When contributing changes to repository, follow these steps:
|
||||
|
||||
1. If you modified code that requires generation (e.g., enum registrations, ent schemas), run `make generate` and commit the generated files
|
||||
2. Ensure tests are passing
|
||||
3. Ensure you write test cases for new code
|
||||
4. `Signed-off-by` line is required in commit message (use `-s` flag while committing)
|
||||
|
||||
## Developer Setup
|
||||
|
||||
### Requirements
|
||||
|
||||
- Go 1.25.0+
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
- Install [ASDF](https://asdf-vm.com/)
|
||||
- Install the development tools
|
||||
|
||||
```bash
|
||||
asdf plugin add golang
|
||||
asdf plugin add gitleaks
|
||||
asdf install
|
||||
```
|
||||
|
||||
- Install git hooks (using Go toolchain)
|
||||
|
||||
```bash
|
||||
go tool github.com/evilmartians/lefthook install
|
||||
```
|
||||
|
||||
Install `golangci-lint`
|
||||
|
||||
```shell
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
Install build tools
|
||||
|
||||
```bash
|
||||
make dev-setup
|
||||
```
|
||||
|
||||
Generate code from API specs and build `vet`
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
Quick build without regenerating code from API specs
|
||||
|
||||
```bash
|
||||
make quick-vet
|
||||
```
|
||||
|
||||
### Generate Code
|
||||
|
||||
If you modify code that requires generation (enum registrations in `pkg/analyzer/filterv2/enums.go`, ent schemas in `ent/schema/*.go`), run:
|
||||
|
||||
```bash
|
||||
make generate
|
||||
```
|
||||
|
||||
**Important**: Generated files must be committed to the repository. CI will fail if generated code is out of sync.
|
||||
|
||||
### Format Code
|
||||
|
||||
```bash
|
||||
golangci-lint fmt
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
45
Dockerfile
Normal file
@ -0,0 +1,45 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm@sha256:c4bc0741e3c79c0e2d47ca2505a06f5f2a44682ada94e1dba251a3854e60c2bd AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install cross-compilation tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc-aarch64-linux-gnu \
|
||||
libc6-dev-arm64-cross \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
# Set up cross-compilation environment based on target platform
|
||||
RUN case "${TARGETPLATFORM}" in \
|
||||
"linux/amd64") \
|
||||
CC=gcc CXX=g++ GOOS=linux GOARCH=amd64 make quick-vet ;; \
|
||||
"linux/arm64") \
|
||||
CC=aarch64-linux-gnu-gcc CXX=aarch64-linux-gnu-g++ GOOS=linux GOARCH=arm64 make quick-vet ;; \
|
||||
*) echo "Unsupported platform: ${TARGETPLATFORM}" && exit 1 ;; \
|
||||
esac
|
||||
|
||||
FROM debian:12-slim@sha256:b1a741487078b369e78119849663d7f1a5341ef2768798f7b7406c4240f86aef
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/safedep/vet
|
||||
LABEL org.opencontainers.image.description="Open source software supply chain security tool"
|
||||
LABEL org.opencontainers.image.licenses=Apache-2.0
|
||||
LABEL io.modelcontextprotocol.server.name="io.github.safedep/vet-mcp"
|
||||
|
||||
COPY ./samples/ /vet/samples
|
||||
COPY --from=build /build/vet /usr/local/bin/vet
|
||||
|
||||
ENTRYPOINT ["vet"]
|
||||
12
MAINTAINERS.txt
Normal file
@ -0,0 +1,12 @@
|
||||
vet is built and maintained by SafeDep with the help of the community.
|
||||
https://safedep.io
|
||||
|
||||
Abhisek Datta
|
||||
Email: abhisek@safedep.io
|
||||
GitHub username: @abhisek
|
||||
Affiliation: SafeDep
|
||||
|
||||
Nikhil Mittal
|
||||
Email: nikhil.mittal641@gmail.com
|
||||
GitHub username: @c0d3G33k
|
||||
Affiliation: Chargebee
|
||||
63
Makefile
@ -2,32 +2,77 @@ SHELL := /bin/bash
|
||||
GITCOMMIT := $(shell git rev-parse HEAD)
|
||||
VERSION := "$(shell git describe --tags --abbrev=0)-$(shell git rev-parse --short HEAD)"
|
||||
|
||||
all: clean setup vet
|
||||
all: quick-vet
|
||||
|
||||
oapi-codegen-install:
|
||||
go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.10.1
|
||||
.PHONY: ent
|
||||
ent:
|
||||
go generate ./ent
|
||||
|
||||
.PHONY: filterv2-gen
|
||||
filterv2-gen:
|
||||
go generate ./pkg/analyzer/filterv2/...
|
||||
|
||||
generate: ent filterv2-gen
|
||||
|
||||
protoc-install:
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
|
||||
oapi-codegen:
|
||||
oapi-codegen -package insightapi -generate types ./api/insights-v1.yml > ./gen/insightapi/insights.types.go
|
||||
oapi-codegen -package insightapi -generate client ./api/insights-v1.yml > ./gen/insightapi/insights.client.go
|
||||
oapi-codegen -package controlplane -generate types ./api/cp-v1-trials.yml > ./gen/controlplane/trials.types.go
|
||||
oapi-codegen -package controlplane -generate client ./api/cp-v1-trials.yml > ./gen/controlplane/trials.client.go
|
||||
dev-setup: protoc-install
|
||||
|
||||
protoc-codegen:
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/filterinput \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/filter_input_spec.proto
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/filtersuite \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/filter_suite_spec.proto
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/exceptionsapi \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/exceptions_spec.proto
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/models \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/models.proto
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/models \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/insights_models.proto
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/jsonreport \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/json_report_spec.proto
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/violations \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/violations.proto
|
||||
protoc -I ./api \
|
||||
--go_out=./gen/checks \
|
||||
--go_opt=paths=source_relative \
|
||||
./api/checks.proto
|
||||
|
||||
setup:
|
||||
mkdir -p out gen/insightapi gen/controlplane gen/filterinput
|
||||
mkdir -p out \
|
||||
gen/insightapi \
|
||||
gen/cpv1trials \
|
||||
gen/cpv1 \
|
||||
gen/syncv1 \
|
||||
gen/filterinput \
|
||||
gen/filtersuite \
|
||||
gen/exceptionsapi \
|
||||
gen/models \
|
||||
gen/jsonreport \
|
||||
gen/violations \
|
||||
gen/checks
|
||||
|
||||
GO_CFLAGS=-X main.commit=$(GITCOMMIT) -X main.version=$(VERSION)
|
||||
GO_LDFLAGS=-ldflags "-w $(GO_CFLAGS)"
|
||||
|
||||
quick-vet:
|
||||
go build ${GO_LDFLAGS}
|
||||
|
||||
vet: oapi-codegen protoc-codegen
|
||||
go build ${GO_LDFLAGS}
|
||||
|
||||
|
||||
590
README.md
@ -1,106 +1,582 @@
|
||||
# vet
|
||||
<div align="center">
|
||||
<img width="3024" height="1964" alt="image" src="./docs/assets/vet-terminal.png" />
|
||||
|
||||
`vet` is a tool for identifying risks in open source software supply chain. It
|
||||
helps engineering and security teams to identify potential issues in their open
|
||||
source dependencies and evaluate them against organizational policies.
|
||||
<h1>SafeDep VET</h1>
|
||||
|
||||
<p><strong>🚀 Enterprise grade open source software supply chain security</strong></p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/safedep/vet/releases"><strong>Download</strong></a> •
|
||||
<a href="#-quick-start"><strong>Quick Start</strong></a> •
|
||||
<a href="https://docs.safedep.io/"><strong>Documentation</strong></a> •
|
||||
<a href="#-community"><strong>Community</strong></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://goreportcard.com/report/github.com/safedep/vet)
|
||||
[](https://github.com/safedep/vet/blob/main/LICENSE)
|
||||
[](https://github.com/safedep/vet/releases)
|
||||
[](https://api.securityscorecards.dev/projects/github.com/safedep/vet)
|
||||
[](https://slsa.dev)
|
||||
[](https://github.com/safedep/vet/actions/workflows/codeql.yml)
|
||||
[](https://pkg.go.dev/github.com/safedep/vet)
|
||||
|
||||
## TL;DR
|
||||
[](https://deepwiki.com/safedep/vet)
|
||||
|
||||
> Ensure `$(go env GOPATH)/bin` is in your `$PATH`
|
||||
</div>
|
||||
|
||||
Install using `go get`
|
||||
---
|
||||
|
||||
## 🎯 Why vet?
|
||||
|
||||
> **70-90% of modern software constitute code from open sources** — How do we know if it's safe?
|
||||
|
||||
**vet** is an open source software supply chain security tool built for **developers and security engineers** who need:
|
||||
|
||||
✅ **Next-gen Software Composition Analysis** — Vulnerability and malicious package detection
|
||||
✅ **Policy as Code** — Express opinionated security policies using [CEL](https://cel.dev/)
|
||||
✅ **Real-time malicious package detection** — Powered by [SafeDep Cloud](https://docs.safedep.io/cloud/malware-analysis) active scanning
|
||||
✅ **Multi-ecosystem support** — npm, PyPI, Maven, Go, Docker, GitHub Actions, and more
|
||||
✅ **CI/CD native** — Built for DevSecOps workflows with support for GitHub Actions, GitLab CI, and more
|
||||
✅ **MCP Server** — Run `vet` as a MCP server to vet open source packages from AI suggested code
|
||||
✅ **Agents** — Run AI agents to query and analyze scan results
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**Install in seconds:**
|
||||
|
||||
```bash
|
||||
# macOS & Linux
|
||||
brew install safedep/tap/vet
|
||||
```
|
||||
|
||||
or download a [pre-built binary](https://github.com/safedep/vet/releases)
|
||||
|
||||
**Scan your project:**
|
||||
|
||||
```bash
|
||||
# Scan current directory
|
||||
vet scan -D .
|
||||
|
||||
# Scan a single file
|
||||
vet scan -M package-lock.json
|
||||
|
||||
# Fail CI on critical vulnerabilities
|
||||
vet scan -D . --filter 'vulns.critical.exists(p, true)' --filter-fail
|
||||
|
||||
# Fail CI on OpenSSF Scorecard requirements
|
||||
vet scan -D . --filter 'scorecard.scores.Maintained < 5' --filter-fail
|
||||
|
||||
# Fail CI if a package is published from a GitHub repository with less than 5 stars
|
||||
vet scan -D . --filter 'projects.exists(p, p.type == "GITHUB" && p.stars < 5)' --filter-fail
|
||||
```
|
||||
|
||||
## 🔒 Key Features
|
||||
|
||||
### 🕵️ **Code Analysis**
|
||||
|
||||
Unlike dependency scanners that flood you with noise, `vet` analyzes your **actual code usage** to prioritize real risks. See [dependency usage evidence](https://docs.safedep.io/guides/dependency-usage-identification) for more details.
|
||||
|
||||
### 🛡️ **Malicious Package Detection**
|
||||
|
||||
Integrated with [SafeDep Cloud](https://docs.safedep.io/cloud/malware-analysis) for real-time protection against malicious packages in the wild. Free for open source projects. Fallback to _Query Mode_ when API key is not provided. Read more [about malicious package scanning](#️-malicious-package-detection-1).
|
||||
|
||||
### 📋 **Policy as Code**
|
||||
|
||||
Define security policies using CEL expressions to enforce context specific security requirements.
|
||||
|
||||
```bash
|
||||
# Block packages with critical CVEs
|
||||
vet scan \
|
||||
--filter 'vulns.critical.exists(p, true)'
|
||||
|
||||
# Enforce license compliance
|
||||
vet scan \
|
||||
--filter 'licenses.contains_license("GPL-3.0")'
|
||||
|
||||
# Enforce OpenSSF Scorecard requirements
|
||||
# Require minimum OpenSSF Scorecard scores
|
||||
vet scan \
|
||||
--filter 'scorecard.scores.Maintained < 5'
|
||||
```
|
||||
|
||||
### 🎯 **Multi-Format Support**
|
||||
|
||||
- **Package Managers**: npm, PyPI, Maven, Go, Ruby, Rust, PHP
|
||||
- **Container Images**: Docker, OCI
|
||||
- **SBOMs**: CycloneDX, SPDX
|
||||
- **Binary Artifacts**: JAR files, Python wheels
|
||||
- **Source Code**: Direct repository scanning
|
||||
|
||||
## 🔥 See vet in Action
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/assets/vet-demo.gif" alt="vet Demo" width="100%" />
|
||||
</div>
|
||||
|
||||
## 🚀 Production Ready Integrations
|
||||
|
||||
### 📦 **GitHub Actions**
|
||||
|
||||
Zero config security guardrails against vulnerabilities and malicious packages in your CI/CD pipeline
|
||||
**with your own opinionated policies**:
|
||||
|
||||
```yaml
|
||||
- uses: safedep/vet-action@v1
|
||||
with:
|
||||
policy: ".github/vet/policy.yml"
|
||||
```
|
||||
|
||||
See more in [vet-action](https://github.com/safedep/vet-action) documentation.
|
||||
|
||||
### 🔧 **GitLab CI**
|
||||
|
||||
Enterprise grade scanning with [vet CI Component](https://gitlab.com/explore/catalog/safedep/ci-components/vet):
|
||||
|
||||
```yaml
|
||||
include:
|
||||
- component: gitlab.com/safedep/ci-components/vet/scan@main
|
||||
```
|
||||
|
||||
### 🐳 **Container Integration**
|
||||
|
||||
Run `vet` anywhere, even your internal developer platform or custom CI/CD environment using our container image.
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/app ghcr.io/safedep/vet:latest scan -D /app
|
||||
```
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [🎯 Why vet?](#-why-vet)
|
||||
- [⚡ Quick Start](#-quick-start)
|
||||
- [🔒 Key Features](#-key-features)
|
||||
- [🕵️ **Code Analysis**](#️-code-analysis)
|
||||
- [🛡️ **Malicious Package Detection**](#️-malicious-package-detection)
|
||||
- [📋 **Policy as Code**](#-policy-as-code)
|
||||
- [🎯 **Multi-Format Support**](#-multi-format-support)
|
||||
- [🔥 See vet in Action](#-see-vet-in-action)
|
||||
- [🚀 Production Ready Integrations](#-production-ready-integrations)
|
||||
- [📦 **GitHub Actions**](#-github-actions)
|
||||
- [🔧 **GitLab CI**](#-gitlab-ci)
|
||||
- [🐳 **Container Integration**](#-container-integration)
|
||||
- [📚 Table of Contents](#-table-of-contents)
|
||||
- [📦 Installation Options](#-installation-options)
|
||||
- [🍺 **Homebrew (Recommended)**](#-homebrew-recommended)
|
||||
- [📥 **Direct Download**](#-direct-download)
|
||||
- [🐹 **Go Install**](#-go-install)
|
||||
- [🐳 **Container Image**](#-container-image)
|
||||
- [⚙️ **Verify Installation**](#️-verify-installation)
|
||||
- [🎮 Advanced Usage](#-advanced-usage)
|
||||
- [🔍 **Scanning Options**](#-scanning-options)
|
||||
- [🎯 **Policy Enforcement Examples**](#-policy-enforcement-examples)
|
||||
- [🔧 **SBOM Support**](#-sbom-support)
|
||||
- [📊 **Query Mode \& Data Persistence**](#-query-mode--data-persistence)
|
||||
- [📊 Reporting](#-reporting)
|
||||
- [📋 **Report Formats**](#-report-formats)
|
||||
- [🎯 **Report Examples**](#-report-examples)
|
||||
- [🤖 **MCP Server**](#-mcp-server)
|
||||
- [🤖 **Agents**](#-agents)
|
||||
- [🛡️ Malicious Package Detection](#️-malicious-package-detection-1)
|
||||
- [🚀 **Quick Setup**](#-quick-setup)
|
||||
- [🎯 **Advanced Malicious Package Analysis**](#-advanced-malicious-package-analysis)
|
||||
- [🔒 **Security Features**](#-security-features)
|
||||
- [📊 Privacy and Telemetry](#-privacy-and-telemetry)
|
||||
- [🎊 Community \& Support](#-community--support)
|
||||
- [🌟 **Join the Community**](#-join-the-community)
|
||||
- [💡 **Get Help \& Share Ideas**](#-get-help--share-ideas)
|
||||
- [⭐ **Star History**](#-star-history)
|
||||
- [🙏 **Built With Open Source**](#-built-with-open-source)
|
||||
|
||||
## 📦 Installation Options
|
||||
|
||||
### 🍺 **Homebrew (Recommended)**
|
||||
|
||||
```bash
|
||||
brew tap safedep/tap
|
||||
brew install safedep/tap/vet
|
||||
```
|
||||
|
||||
### 📥 **Direct Download**
|
||||
|
||||
See [releases](https://github.com/safedep/vet/releases) for the latest version.
|
||||
|
||||
### 🐹 **Go Install**
|
||||
|
||||
```bash
|
||||
go install github.com/safedep/vet@latest
|
||||
```
|
||||
|
||||
Alternatively, look at [Releases](https://github.com/safedep/vet/releases) for
|
||||
a pre-built binary for your platform. [SLSA Provenance](https://slsa.dev/provenance/v0.1) is published
|
||||
along with each binary release.
|
||||
|
||||
Get a trial API key for [Insights API](https://safedep.io/docs/concepts/raya-data-platform-overview) access
|
||||
### 🐳 **Container Image**
|
||||
|
||||
```bash
|
||||
vet auth trial --email john.doe@example.com
|
||||
# Quick test
|
||||
docker run --rm ghcr.io/safedep/vet:latest version
|
||||
|
||||
# Scan local directory
|
||||
docker run --rm -v $(pwd):/workspace ghcr.io/safedep/vet:latest scan -D /workspace
|
||||
```
|
||||
|
||||
> A time limited trial API key will be sent over email.
|
||||
|
||||
Configure `vet` to use API Key to access [Insights API](https://safedep.io/docs/concepts/raya-data-platform-overview)
|
||||
### ⚙️ **Verify Installation**
|
||||
|
||||
```bash
|
||||
vet auth configure
|
||||
vet version
|
||||
# Should display version and build information
|
||||
```
|
||||
|
||||
> Insights API is used to enrich OSS packages with meta-data for rich query and policy
|
||||
> decisions
|
||||
## 🎮 Advanced Usage
|
||||
|
||||
Run `vet` to identify risks
|
||||
### 🔍 **Scanning Options**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
**📁 Directory Scanning**
|
||||
|
||||
```bash
|
||||
vet scan -D /path/to/repository
|
||||
# Scan current directory
|
||||
vet scan
|
||||
|
||||
# Scan a given directory
|
||||
vet scan -D /path/to/project
|
||||
|
||||
# Resolve and scan transitive dependencies
|
||||
vet scan -D . --transitive
|
||||
```
|
||||
|
||||
or scan a specific (supported) package manifest
|
||||
**📄 Manifest Files**
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/pom.xml
|
||||
vet scan --lockfiles /path/to/requirements.txt
|
||||
vet scan --lockfiles /path/to/package-lock.json
|
||||
# Package managers
|
||||
vet scan -M package-lock.json
|
||||
vet scan -M requirements.txt
|
||||
vet scan -M pom.xml
|
||||
vet scan -M go.mod
|
||||
vet scan -M Gemfile.lock
|
||||
```
|
||||
|
||||
> Use `vet scan parsers` to list supported package manifest parsers
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
The default scan uses an opinionated [Console Reporter](#) which presents
|
||||
a summary of findings per package manifest. Thats NOT about it. Read more for
|
||||
expression based filtering and policy evaluation.
|
||||
|
||||
## Filtering
|
||||
|
||||
Find dependencies that seems not very popular
|
||||
**🐙 GitHub Integration**
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/pom.xml --report-console=false \
|
||||
--filter='projects.exists(x, x.stars < 10)'
|
||||
# Setup GitHub access
|
||||
vet connect github
|
||||
|
||||
# Scan repositories
|
||||
vet scan --github https://github.com/user/repo
|
||||
|
||||
# Organization scanning
|
||||
vet scan --github-org https://github.com/org
|
||||
```
|
||||
|
||||
Find dependencies with a critical vulnerability
|
||||
**📦 Artifact Scanning**
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/pom.xml --report-console=false \
|
||||
--filter='vulns.critical.exists_one(x, true)'
|
||||
# Container images
|
||||
vet scan --image nginx:latest
|
||||
vet scan --image /path/to/image-saved-file.tar
|
||||
|
||||
# Binary artifacts
|
||||
vet scan -M app.jar
|
||||
vet scan -M package.whl
|
||||
```
|
||||
|
||||
> Use filtering along with `query` command for offline slicing and dicing of
|
||||
> enriched package manifests. Read [filtering guide](docs/filtering.md)
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[Common Expressions Language](https://github.com/google/cel-spec) is used to
|
||||
evaluate filters on packages. Learn more about [filtering with vet](docs/filtering.md).
|
||||
Look at [filter input spec](api/filter_input_spec.proto) on attributes
|
||||
available to the filter expression.
|
||||
### 🎯 **Policy Enforcement Examples**
|
||||
|
||||
## Policy Evaluation
|
||||
```bash
|
||||
# Security-first scanning
|
||||
vet scan -D . \
|
||||
--filter 'vulns.critical.exists(p, true) || vulns.high.exists(p, true)' \
|
||||
--filter-fail
|
||||
|
||||
TODO
|
||||
# License compliance
|
||||
vet scan -D . \
|
||||
--filter 'licenses.contains_license("GPL-3.0")' \
|
||||
--filter-fail
|
||||
|
||||
## FAQ
|
||||
# OpenSSF Scorecard requirements
|
||||
vet scan -D . \
|
||||
--filter 'scorecard.scores.Maintained < 5' \
|
||||
--filter-fail
|
||||
|
||||
### How do I disable the stupid banner?
|
||||
# Popularity-based filtering
|
||||
vet scan -D . \
|
||||
--filter 'projects.exists(p, p.type == "GITHUB" && p.stars < 50)' \
|
||||
--filter-fail
|
||||
```
|
||||
|
||||
Set environment variable `VET_DISABLE_BANNER=1`
|
||||
### 🔧 **SBOM Support**
|
||||
|
||||
### Can I use this tool without an API Key for Insight Service?
|
||||
```bash
|
||||
# Scan a CycloneDX SBOM
|
||||
vet scan -M sbom.json --type bom-cyclonedx
|
||||
|
||||
Probably no. All useful data (enrichments) for a detected package comes from
|
||||
a backend service. The service is rate limited with quotas to prevent abuse.
|
||||
# Scan a SPDX SBOM
|
||||
vet scan -M sbom.spdx.json --type bom-spdx
|
||||
|
||||
Look at `api/insights-v1.yml`. It contains the contract expected for Insights
|
||||
API. You can perhaps consider rolling out your own to avoid dependency with our
|
||||
backend.
|
||||
# Generate SBOM output
|
||||
vet scan -D . --report-cdx=output.sbom.json
|
||||
|
||||
## References
|
||||
# Package URL scanning
|
||||
vet scan --purl pkg:npm/lodash@4.17.21
|
||||
```
|
||||
|
||||
* https://github.com/google/osv-scanner
|
||||
### 📊 **Query Mode & Data Persistence**
|
||||
|
||||
For large codebases and repeated analysis:
|
||||
|
||||
```bash
|
||||
# Scan once, query multiple times
|
||||
vet scan -D . --json-dump-dir ./scan-data
|
||||
|
||||
# Query with different filters
|
||||
vet query --from ./scan-data \
|
||||
--filter 'vulns.critical.exists(p, true)'
|
||||
|
||||
# Generate focused reports
|
||||
vet query --from ./scan-data \
|
||||
--filter 'licenses.contains_license("GPL")' \
|
||||
--report-json license-violations.json
|
||||
```
|
||||
|
||||
## 📊 Reporting
|
||||
|
||||
**vet** generate reports that are tailored for different stakeholders:
|
||||
|
||||
### 📋 **Report Formats**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="30%"><strong>🔍 For Security Teams</strong></td>
|
||||
<td width="70%">
|
||||
|
||||
```bash
|
||||
# SARIF for GitHub Security tab
|
||||
vet scan -D . --report-sarif=report.sarif
|
||||
|
||||
# JSON for custom tooling
|
||||
vet scan -D . --report-json=report.json
|
||||
|
||||
# CSV for spreadsheet analysis
|
||||
vet scan -D . --report-csv=report.csv
|
||||
|
||||
# HTML for web-based analysis
|
||||
vet scan -D . --report-html=report.html
|
||||
```
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>📖 For Developers</strong></td>
|
||||
<td>
|
||||
|
||||
```bash
|
||||
# Markdown reports for PRs
|
||||
vet scan -D . --report-markdown=report.md
|
||||
|
||||
# Console summary (default)
|
||||
vet scan -D . --report-summary
|
||||
```
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>🏢 For Compliance</strong></td>
|
||||
<td>
|
||||
|
||||
```bash
|
||||
# SBOM generation
|
||||
vet scan -D . --report-cdx=sbom.json
|
||||
|
||||
# Dependency graphs
|
||||
vet scan -D . --report-graph=dependencies.dot
|
||||
```
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🎯 **Report Examples**
|
||||
|
||||
```bash
|
||||
# Multi-format output
|
||||
vet scan -D . \
|
||||
--report-json=report.json \
|
||||
--report-sarif=report.sarif \
|
||||
--report-markdown=report.md \
|
||||
--report-html=report.html
|
||||
|
||||
# Focus on specific issues
|
||||
vet scan -D . \
|
||||
--filter 'vulns.high.exists(p, true)' \
|
||||
--report-json=report.json
|
||||
```
|
||||
|
||||
### 🤖 **MCP Server**
|
||||
|
||||
**vet** can be used as an MCP server to vet open source packages from AI suggested code.
|
||||
|
||||
```bash
|
||||
# Start the MCP server with SSE transport
|
||||
vet server mcp --server-type sse
|
||||
```
|
||||
|
||||
For more details, see [vet MCP Server](./docs/mcp.md) documentation.
|
||||
|
||||
### 🤖 **Agents**
|
||||
|
||||
See [vet Agents](./docs/agent.md) documentation for more details.
|
||||
|
||||
## 🛡️ Malicious Package Detection
|
||||
|
||||
**Malicious package detection through active scanning and code analysis** powered by
|
||||
[SafeDep Cloud](https://docs.safedep.io/cloud/malware-analysis). `vet` requires an API
|
||||
key for active scanning of unknown packages. When API key is not provided, `vet` will
|
||||
fallback to _Query Mode_ which detects known malicious packages from [SafeDep](https://safedep.io)
|
||||
and [OSV](https://osv.dev) databases.
|
||||
|
||||
- Grab a free API key by running `vet cloud quickstart`
|
||||
- API access is free forever for open source projects
|
||||
- No proprietary code is collected for malicious package detection
|
||||
- Only open source package scanning from public repositories is supported
|
||||
|
||||
### 🚀 **Quick Setup**
|
||||
|
||||
> Malicious package detection requires an API key for [SafeDep Cloud](https://docs.safedep.io/cloud/malware-analysis).
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
vet cloud quickstart
|
||||
|
||||
# Enable malware scanning
|
||||
vet scan -D . --malware
|
||||
|
||||
# Query for known malicious packages without API key
|
||||
vet scan -D . --malware-query
|
||||
```
|
||||
|
||||
Example malicious packages detected and reported by [SafeDep Cloud](https://docs.safedep.io/cloud/malware-analysis)
|
||||
malicious package detection:
|
||||
|
||||
- [MAL-2025-3541: express-cookie-parser](https://safedep.io/malicious-npm-package-express-cookie-parser/)
|
||||
- [MAL-2025-4339: eslint-config-airbnb-compat](https://safedep.io/digging-into-dynamic-malware-analysis-signals/)
|
||||
- [MAL-2025-4029: ts-runtime-compat-check](https://safedep.io/digging-into-dynamic-malware-analysis-signals/)
|
||||
- [MAL-2025-2227: nyc-config](https://safedep.io/nyc-config-malicious-package/)
|
||||
|
||||
### 🎯 **Advanced Malicious Package Analysis**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
**🔍 Scan packages with malicious package detection enabled**
|
||||
|
||||
```bash
|
||||
# Real-time scanning
|
||||
vet scan -D . --malware
|
||||
|
||||
# Timeout adjustment
|
||||
vet scan -D . --malware \
|
||||
--malware-analysis-timeout=300s
|
||||
|
||||
# Batch analysis
|
||||
vet scan -D . --malware \
|
||||
--json-dump-dir=./analysis
|
||||
```
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
**🎭 Specialized Scans**
|
||||
|
||||
```bash
|
||||
# VS Code extensions
|
||||
vet scan --vsx --malware
|
||||
|
||||
# GitHub Actions
|
||||
vet scan -D .github/workflows --malware
|
||||
|
||||
# Container Images
|
||||
vet scan --image nats:2.10 --malware
|
||||
|
||||
# Scan a single package and fail if its malicious
|
||||
vet scan --purl pkg:/npm/nyc-config@10.0.0 --fail-fast
|
||||
|
||||
# Active scanning of a single package (requires API key)
|
||||
vet inspect malware \
|
||||
--purl pkg:npm/nyc-config@10.0.0
|
||||
```
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🔒 **Security Features**
|
||||
|
||||
- ✅ **Real-time analysis** of packages against known malware databases
|
||||
- ✅ **Behavioral analysis** using static and dynamic analysis
|
||||
- ✅ **Zero day protection** through active code scanning
|
||||
- ✅ **Human in the loop** for triaging and investigation of high impact findings
|
||||
- ✅ **Real time analysis** with public [analysis log](https://vetpkg.dev/mal)
|
||||
|
||||
## 📊 Privacy and Telemetry
|
||||
|
||||
`vet` collects anonymous usage telemetry to improve the product. **Your code and package information is never transmitted.**
|
||||
|
||||
```bash
|
||||
# Disable telemetry (optional)
|
||||
export VET_DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
## 🎊 Community & Support
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 🌟 **Join the Community**
|
||||
|
||||
[](https://rebrand.ly/safedep-community)
|
||||
[](https://github.com/safedep/vet/discussions)
|
||||
[](https://twitter.com/safedepio)
|
||||
|
||||
</div>
|
||||
|
||||
### 💡 **Get Help & Share Ideas**
|
||||
|
||||
- 🚀 **[Interactive Tutorial](https://killercoda.com/safedep/scenario/101-intro)** - Learn vet hands-on
|
||||
- 📚 **[Complete Documentation](https://docs.safedep.io/)** - Comprehensive guides
|
||||
- 💬 **[Discord Community](https://rebrand.ly/safedep-community)** - Real-time support
|
||||
- 🐛 **[Issue Tracker](https://github.com/safedep/vet/issues)** - Bug reports & feature requests
|
||||
- 🤝 **[Contributing Guide](CONTRIBUTING.md)** - Join the development
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### ⭐ **Star History**
|
||||
|
||||
[](https://star-history.com/#safedep/vet&Date)
|
||||
|
||||
### 🙏 **Built With Open Source**
|
||||
|
||||
vet stands on the shoulders of giants:
|
||||
|
||||
[OSV](https://osv.dev) • [OpenSSF Scorecard](https://securityscorecards.dev/) • [SLSA](https://slsa.dev/) • [OSV-SCALIBR](https://github.com/google/osv-scalibr) • [Syft](https://github.com/anchore/syft)
|
||||
|
||||
---
|
||||
|
||||
<p><strong>⚡ Secure your supply chain today. Star the repo ⭐ and get started!</strong></p>
|
||||
|
||||
Created with ❤️ by [SafeDep](https://safedep.io) and the open source community
|
||||
|
||||
</div>
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=304d1856-fcb3-4166-bfbf-b3e40d0f1e3b" />
|
||||
|
||||
69
agent/agent.go
Normal file
@ -0,0 +1,69 @@
|
||||
// Package agent declares the building blocks for implement vet agent.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
type AnswerFormat string
|
||||
|
||||
const (
|
||||
AnswerFormatMarkdown AnswerFormat = "markdown"
|
||||
AnswerFormatJSON AnswerFormat = "json"
|
||||
)
|
||||
|
||||
type Output struct {
|
||||
Answer string
|
||||
Format AnswerFormat
|
||||
}
|
||||
|
||||
type Memory interface {
|
||||
AddInteraction(ctx context.Context, interaction *schema.Message) error
|
||||
GetInteractions(ctx context.Context) ([]*schema.Message, error)
|
||||
Clear(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Session interface {
|
||||
ID() string
|
||||
Memory() Memory
|
||||
}
|
||||
|
||||
// AgentExecutionContext is to pass additional context to the agent
|
||||
// on a per execution basis. This is required so that an agent can be configured
|
||||
// and shared with different components while allowing the component to pass
|
||||
// additional context to the agent.
|
||||
type AgentExecutionContext struct {
|
||||
// OnToolCall is called when the agent is about to call a tool.
|
||||
// This is used for introspection only and not to mutate the agent's behavior.
|
||||
OnToolCall func(context.Context, Session, Input, string, string) error
|
||||
}
|
||||
|
||||
type AgentExecutionContextOpt func(*AgentExecutionContext)
|
||||
|
||||
func WithToolCallHook(fn func(context.Context, Session, Input, string, string) error) AgentExecutionContextOpt {
|
||||
return func(a *AgentExecutionContext) {
|
||||
a.OnToolCall = fn
|
||||
}
|
||||
}
|
||||
|
||||
type Agent interface {
|
||||
// Execute executes the agent with the given input and returns the output.
|
||||
// Internally the agent may perform a multi-step operation based on config,
|
||||
// instructions and available tools.
|
||||
Execute(context.Context, Session, Input, ...AgentExecutionContextOpt) (Output, error)
|
||||
}
|
||||
|
||||
// AgentToolCallIntrospectionFn is a function that introspects a tool call.
|
||||
// This is aligned with eino contract.
|
||||
type AgentToolCallIntrospectionFn func(context.Context /* name */, string /* args */, string) ( /* args */ string, error)
|
||||
|
||||
type ToolBuilder interface {
|
||||
Build(context.Context) ([]tool.BaseTool, error)
|
||||
}
|
||||
165
agent/llm.go
Normal file
@ -0,0 +1,165 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/claude"
|
||||
"github.com/cloudwego/eino-ext/components/model/gemini"
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
// Map of fast vs. default models.
|
||||
var defaultModelMap = map[string]map[string]string{
|
||||
"openai": {
|
||||
"default": "gpt-4o",
|
||||
"fast": "gpt-4o-mini",
|
||||
},
|
||||
"claude": {
|
||||
"default": "claude-sonnet-4-20250514",
|
||||
"fast": "claude-sonnet-4-20250514",
|
||||
},
|
||||
"gemini": {
|
||||
"default": "gemini-2.5-pro",
|
||||
"fast": "gemini-2.5-flash",
|
||||
},
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Vendor string
|
||||
Name string
|
||||
Fast bool
|
||||
Client model.ToolCallingChatModel
|
||||
}
|
||||
|
||||
// BuildModelFromEnvironment builds a model from the environment variables.
|
||||
// The order of preference is:
|
||||
// 1. OpenAI
|
||||
// 2. Claude
|
||||
// 3. Gemini
|
||||
// 4. Others..
|
||||
func BuildModelFromEnvironment(fastMode bool) (*Model, error) {
|
||||
if model, err := buildOpenAIModelFromEnvironment(fastMode); err == nil {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
if model, err := buildClaudeModelFromEnvironment(fastMode); err == nil {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
if model, err := buildGeminiModelFromEnvironment(fastMode); err == nil {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no usable LLM found for use with agent")
|
||||
}
|
||||
|
||||
func buildOpenAIModelFromEnvironment(fastMode bool) (*Model, error) {
|
||||
defaultModel := defaultModelMap["openai"]["default"]
|
||||
if fastMode {
|
||||
defaultModel = defaultModelMap["openai"]["fast"]
|
||||
}
|
||||
|
||||
modelName := os.Getenv("OPENAI_MODEL_OVERRIDE")
|
||||
if modelName == "" {
|
||||
modelName = defaultModel
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("OPENAI_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("OPENAI_API_KEY is not set")
|
||||
}
|
||||
|
||||
model, err := openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
|
||||
Model: modelName,
|
||||
APIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create openai model: %w", err)
|
||||
}
|
||||
|
||||
return &Model{
|
||||
Vendor: "openai",
|
||||
Name: modelName,
|
||||
Fast: fastMode,
|
||||
Client: model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildClaudeModelFromEnvironment(fastMode bool) (*Model, error) {
|
||||
defaultModel := defaultModelMap["claude"]["default"]
|
||||
if fastMode {
|
||||
defaultModel = defaultModelMap["claude"]["fast"]
|
||||
}
|
||||
|
||||
modelName := os.Getenv("ANTHROPIC_MODEL_OVERRIDE")
|
||||
if modelName == "" {
|
||||
modelName = defaultModel
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("ANTHROPIC_API_KEY is not set")
|
||||
}
|
||||
|
||||
model, err := claude.NewChatModel(context.Background(), &claude.Config{
|
||||
Model: modelName,
|
||||
APIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create claude model: %w", err)
|
||||
}
|
||||
|
||||
return &Model{
|
||||
Vendor: "claude",
|
||||
Name: modelName,
|
||||
Fast: fastMode,
|
||||
Client: model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildGeminiModelFromEnvironment(fastMode bool) (*Model, error) {
|
||||
defaultModel := defaultModelMap["gemini"]["default"]
|
||||
if fastMode {
|
||||
defaultModel = defaultModelMap["gemini"]["fast"]
|
||||
}
|
||||
|
||||
modelName := os.Getenv("GEMINI_MODEL_OVERRIDE")
|
||||
if modelName == "" {
|
||||
modelName = defaultModel
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("GEMINI_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("GEMINI_API_KEY is not set")
|
||||
}
|
||||
|
||||
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
|
||||
APIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gemini client: %w", err)
|
||||
}
|
||||
|
||||
model, err := gemini.NewChatModel(context.Background(), &gemini.Config{
|
||||
Model: modelName,
|
||||
Client: client,
|
||||
ThinkingConfig: &genai.ThinkingConfig{
|
||||
IncludeThoughts: false,
|
||||
ThinkingBudget: nil,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gemini model: %w", err)
|
||||
}
|
||||
|
||||
return &Model{
|
||||
Vendor: "gemini",
|
||||
Name: modelName,
|
||||
Fast: fastMode,
|
||||
Client: model,
|
||||
}, nil
|
||||
}
|
||||
17
agent/llm_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDefaultModelsMap(t *testing.T) {
|
||||
t.Run("default model map must have vendor, model, and fast model", func(t *testing.T) {
|
||||
for vendor, models := range defaultModelMap {
|
||||
assert.NotEmpty(t, vendor)
|
||||
assert.NotEmpty(t, models["default"])
|
||||
assert.NotEmpty(t, models["fast"])
|
||||
}
|
||||
})
|
||||
}
|
||||
145
agent/mcp.go
Normal file
@ -0,0 +1,145 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
einomcp "github.com/cloudwego/eino-ext/components/tool/mcp"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
type McpClientToolBuilderConfig struct {
|
||||
// Common config
|
||||
ClientName string
|
||||
ClientVersion string
|
||||
|
||||
// SSE client config
|
||||
SseURL string
|
||||
Headers map[string]string
|
||||
|
||||
// Stdout client config
|
||||
SkipDefaultTools bool
|
||||
SQLQueryToolEnabled bool
|
||||
SQLQueryToolDBPath string
|
||||
PackageRegistryToolEnabled bool
|
||||
|
||||
// Enable debug mode for the MCP client.
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type mcpClientToolBuilder struct {
|
||||
config McpClientToolBuilderConfig
|
||||
}
|
||||
|
||||
var _ ToolBuilder = (*mcpClientToolBuilder)(nil)
|
||||
|
||||
// NewMcpClientToolBuilder creates a new MCP client tool builder for `vet` MCP server.
|
||||
// This basically connects to vet MCP server over SSE or executes the `vet server mcp` command
|
||||
// to start a MCP server in stdio mode. We maintain loose coupling between the MCP client and the MCP server
|
||||
// by allowing the client to be configured with a set of flags to enable/disable specific tools. We do this
|
||||
// to ensure vet MCP contract is not violated and evolves independently. vet Agents will in turn depend on
|
||||
// vet MCP server for data access.
|
||||
func NewMcpClientToolBuilder(config McpClientToolBuilderConfig) (*mcpClientToolBuilder, error) {
|
||||
return &mcpClientToolBuilder{
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *mcpClientToolBuilder) Build(ctx context.Context) ([]tool.BaseTool, error) {
|
||||
var cli *client.Client
|
||||
var err error
|
||||
|
||||
if b.config.SseURL != "" {
|
||||
cli, err = b.buildSseClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sse client: %w", err)
|
||||
}
|
||||
} else {
|
||||
cli, err = b.buildStdioClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdio client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = cli.Start(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start mcp client: %w", err)
|
||||
}
|
||||
|
||||
initRequest := mcp.InitializeRequest{}
|
||||
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initRequest.Params.ClientInfo = mcp.Implementation{
|
||||
Name: b.config.ClientName,
|
||||
Version: b.config.ClientVersion,
|
||||
}
|
||||
|
||||
_, err = cli.Initialize(ctx, initRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize mcp client: %w", err)
|
||||
}
|
||||
|
||||
tools, err := einomcp.GetTools(ctx, &einomcp.Config{
|
||||
Cli: cli,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get tools: %w", err)
|
||||
}
|
||||
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
func (b *mcpClientToolBuilder) buildSseClient() (*client.Client, error) {
|
||||
cli, err := client.NewSSEMCPClient(b.config.SseURL, client.WithHeaders(b.config.Headers))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sse client: %w", err)
|
||||
}
|
||||
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
// buildStdioClient is used to start vet mcp server with arguments
|
||||
// based on the configuration.
|
||||
func (b *mcpClientToolBuilder) buildStdioClient() (*client.Client, error) {
|
||||
binaryPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get running binary path: %w", err)
|
||||
}
|
||||
|
||||
// vet-mcp server defaults to stdio transport. See cmd/server/mcp.go
|
||||
vetMcpServerCommandArgs := []string{"server", "mcp"}
|
||||
|
||||
if b.config.Debug {
|
||||
vetMcpServerLogFile := filepath.Join(os.TempDir(), "vet-mcp-server.log")
|
||||
vetMcpServerCommandArgs = append(vetMcpServerCommandArgs, "-l", vetMcpServerLogFile)
|
||||
}
|
||||
|
||||
if b.config.SQLQueryToolEnabled {
|
||||
vetMcpServerCommandArgs = append(vetMcpServerCommandArgs, "--sql-query-tool")
|
||||
vetMcpServerCommandArgs = append(vetMcpServerCommandArgs, "--sql-query-tool-db-path",
|
||||
b.config.SQLQueryToolDBPath)
|
||||
}
|
||||
|
||||
if b.config.PackageRegistryToolEnabled {
|
||||
vetMcpServerCommandArgs = append(vetMcpServerCommandArgs, "--package-registry-tool")
|
||||
}
|
||||
|
||||
if b.config.SkipDefaultTools {
|
||||
vetMcpServerCommandArgs = append(vetMcpServerCommandArgs, "--skip-default-tools")
|
||||
}
|
||||
|
||||
environmentVariables := []string{}
|
||||
if b.config.Debug {
|
||||
environmentVariables = append(environmentVariables, "APP_LOG_LEVEL=debug")
|
||||
}
|
||||
|
||||
cli, err := client.NewStdioMCPClient(binaryPath, environmentVariables, vetMcpServerCommandArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdio client: %w", err)
|
||||
}
|
||||
|
||||
return cli, nil
|
||||
}
|
||||
45
agent/memory.go
Normal file
@ -0,0 +1,45 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type simpleMemory struct {
|
||||
mutex sync.RWMutex
|
||||
interactions []*schema.Message
|
||||
}
|
||||
|
||||
var _ Memory = (*simpleMemory)(nil)
|
||||
|
||||
func NewSimpleMemory() (*simpleMemory, error) {
|
||||
return &simpleMemory{
|
||||
interactions: make([]*schema.Message, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *simpleMemory) AddInteraction(ctx context.Context, interaction *schema.Message) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.interactions = append(m.interactions, interaction)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *simpleMemory) GetInteractions(ctx context.Context) ([]*schema.Message, error) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
return m.interactions, nil
|
||||
}
|
||||
|
||||
func (m *simpleMemory) Clear(ctx context.Context) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.interactions = make([]*schema.Message, 0)
|
||||
return nil
|
||||
}
|
||||
279
agent/memory_test.go
Normal file
@ -0,0 +1,279 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSimpleMemory(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, memory)
|
||||
|
||||
// Test that initial interactions are empty
|
||||
ctx := context.Background()
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(interactions))
|
||||
}
|
||||
|
||||
func TestSimpleMemory_AddInteraction(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
message := &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: "test message",
|
||||
}
|
||||
|
||||
err = memory.AddInteraction(ctx, message)
|
||||
assert.NoError(t, err)
|
||||
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(interactions))
|
||||
assert.Equal(t, message, interactions[0])
|
||||
}
|
||||
|
||||
func TestSimpleMemory_AddMultipleInteractions(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []*schema.Message{
|
||||
{Role: schema.User, Content: "first message"},
|
||||
{Role: schema.Assistant, Content: "second message"},
|
||||
{Role: schema.User, Content: "third message"},
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
err = memory.AddInteraction(ctx, msg)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, len(interactions))
|
||||
|
||||
for i, msg := range messages {
|
||||
assert.Equal(t, msg, interactions[i])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleMemory_GetInteractions(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test empty interactions
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, interactions)
|
||||
assert.Equal(t, 0, len(interactions))
|
||||
|
||||
// Add some interactions
|
||||
message1 := &schema.Message{Role: schema.User, Content: "message 1"}
|
||||
message2 := &schema.Message{Role: schema.Assistant, Content: "message 2"}
|
||||
|
||||
err = memory.AddInteraction(ctx, message1)
|
||||
require.NoError(t, err)
|
||||
err = memory.AddInteraction(ctx, message2)
|
||||
require.NoError(t, err)
|
||||
|
||||
interactions, err = memory.GetInteractions(ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(interactions))
|
||||
assert.Equal(t, message1, interactions[0])
|
||||
assert.Equal(t, message2, interactions[1])
|
||||
}
|
||||
|
||||
func TestSimpleMemory_Clear(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Add some interactions
|
||||
message1 := &schema.Message{Role: schema.User, Content: "message 1"}
|
||||
message2 := &schema.Message{Role: schema.Assistant, Content: "message 2"}
|
||||
|
||||
err = memory.AddInteraction(ctx, message1)
|
||||
require.NoError(t, err)
|
||||
err = memory.AddInteraction(ctx, message2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify interactions exist
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, len(interactions))
|
||||
|
||||
// Clear interactions
|
||||
err = memory.Clear(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify interactions are cleared
|
||||
interactions, err = memory.GetInteractions(ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(interactions))
|
||||
}
|
||||
|
||||
func TestSimpleMemory_NilInteraction(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test adding nil interaction
|
||||
err = memory.AddInteraction(ctx, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(interactions))
|
||||
assert.Nil(t, interactions[0])
|
||||
}
|
||||
|
||||
func TestSimpleMemory_ConcurrentAccess(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
numGoroutines := 100
|
||||
messagesPerGoroutine := 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
// Concurrent writes
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < messagesPerGoroutine; j++ {
|
||||
message := &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: fmt.Sprintf("goroutine-%d-message-%d", goroutineID, j),
|
||||
}
|
||||
err := memory.AddInteraction(ctx, message)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify all interactions were added
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, numGoroutines*messagesPerGoroutine, len(interactions))
|
||||
}
|
||||
|
||||
func TestSimpleMemory_ConcurrentReadWrite(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
numReaders := 10
|
||||
numWriters := 10
|
||||
messagesPerWriter := 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numReaders + numWriters)
|
||||
|
||||
// Concurrent writers
|
||||
for i := 0; i < numWriters; i++ {
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < messagesPerWriter; j++ {
|
||||
message := &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: fmt.Sprintf("writer-%d-message-%d", writerID, j),
|
||||
}
|
||||
err := memory.AddInteraction(ctx, message)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent readers
|
||||
for i := 0; i < numReaders; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < messagesPerWriter; j++ {
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, interactions)
|
||||
// Length can vary due to concurrent writes
|
||||
assert.GreaterOrEqual(t, len(interactions), 0)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Final verification
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, numWriters*messagesPerWriter, len(interactions))
|
||||
}
|
||||
|
||||
func TestSimpleMemory_ClearDuringConcurrentAccess(t *testing.T) {
|
||||
memory, err := NewSimpleMemory()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
numWriters := 5
|
||||
messagesPerWriter := 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numWriters + 1) // +1 for the clearer
|
||||
|
||||
// Add some initial interactions
|
||||
for i := 0; i < 5; i++ {
|
||||
message := &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: fmt.Sprintf("initial-message-%d", i),
|
||||
}
|
||||
err := memory.AddInteraction(ctx, message)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Concurrent writers
|
||||
for i := 0; i < numWriters; i++ {
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < messagesPerWriter; j++ {
|
||||
message := &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: fmt.Sprintf("writer-%d-message-%d", writerID, j),
|
||||
}
|
||||
err := memory.AddInteraction(ctx, message)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Clear operation
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Clear after some writes have happened
|
||||
err := memory.Clear(ctx)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Final state check - should be consistent
|
||||
interactions, err := memory.GetInteractions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, interactions)
|
||||
// The exact number depends on timing of clear operation
|
||||
assert.GreaterOrEqual(t, len(interactions), 0)
|
||||
}
|
||||
156
agent/mock.go
Normal file
@ -0,0 +1,156 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// MockAgent provides a simple implementation of the Agent interface for testing
|
||||
type mockAgent struct{}
|
||||
|
||||
// MockSession is a simple session implementation
|
||||
type mockSession struct {
|
||||
sessionID string
|
||||
memory Memory
|
||||
}
|
||||
|
||||
type mockMemory struct {
|
||||
interactions []*schema.Message
|
||||
}
|
||||
|
||||
func (m *mockMemory) AddInteraction(ctx context.Context, interaction *schema.Message) error {
|
||||
m.interactions = append(m.interactions, interaction)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockMemory) GetInteractions(ctx context.Context) ([]*schema.Message, error) {
|
||||
return m.interactions, nil
|
||||
}
|
||||
|
||||
func (m *mockMemory) Clear(ctx context.Context) error {
|
||||
m.interactions = make([]*schema.Message, 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMockAgent creates a new mock agent
|
||||
func NewMockAgent() *mockAgent {
|
||||
return &mockAgent{}
|
||||
}
|
||||
|
||||
// NewMockSession creates a new mock session
|
||||
func NewMockSession() *mockSession {
|
||||
return &mockSession{
|
||||
sessionID: "mock-session-1",
|
||||
memory: &mockMemory{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockSession) ID() string {
|
||||
return s.sessionID
|
||||
}
|
||||
|
||||
func (s *mockSession) Memory() Memory {
|
||||
return s.memory
|
||||
}
|
||||
|
||||
// Execute implements the Agent interface with mock responses
|
||||
func (m *mockAgent) Execute(ctx context.Context, session Session, input Input, opts ...AgentExecutionContextOpt) (Output, error) {
|
||||
// Simple mock responses based on input
|
||||
query := strings.ToLower(input.Query)
|
||||
|
||||
var response string
|
||||
|
||||
switch {
|
||||
case strings.Contains(query, "vulnerability") || strings.Contains(query, "vuln"):
|
||||
response = `🔍 **Vulnerability Analysis**
|
||||
|
||||
I found 3 critical vulnerabilities in your dependencies:
|
||||
|
||||
**Critical Issues:**
|
||||
• lodash@4.17.19: CVE-2021-23337 (Command Injection)
|
||||
• jackson-databind@2.9.8: CVE-2020-36518 (Deserialization)
|
||||
• urllib3@1.24.1: CVE-2021-33503 (SSRF)
|
||||
|
||||
**Recommendation:** Update these packages immediately. All have fixes available in newer versions.
|
||||
|
||||
Would you like me to analyze the impact of updating these packages?`
|
||||
|
||||
case strings.Contains(query, "malware") || strings.Contains(query, "malicious"):
|
||||
response = `🚨 **Malware Detection Results**
|
||||
|
||||
I detected 2 potentially malicious packages:
|
||||
|
||||
**High Risk:**
|
||||
• suspicious-package@1.0.0: Contains obfuscated code and cryptocurrency mining
|
||||
• typosquatted-lib@2.1.0: Mimics popular library with malicious payload
|
||||
|
||||
**Action Required:** Remove these packages immediately and scan your systems.
|
||||
|
||||
Would you like me to suggest secure alternatives?`
|
||||
|
||||
case strings.Contains(query, "secure") || strings.Contains(query, "security"):
|
||||
response = `🛡️ **Security Posture Assessment**
|
||||
|
||||
**Overall Security Score: 6.2/10 (Moderate Risk)**
|
||||
|
||||
**Summary:**
|
||||
• 23 total security issues found
|
||||
• 3 critical vulnerabilities requiring immediate action
|
||||
• 2 malicious packages detected
|
||||
• 15 packages with maintenance concerns
|
||||
|
||||
**Priority Actions:**
|
||||
1. Remove malicious packages (Critical)
|
||||
2. Update vulnerable dependencies (High)
|
||||
3. Implement dependency scanning in CI/CD (Medium)
|
||||
|
||||
Would you like me to create a detailed remediation plan?`
|
||||
|
||||
case strings.Contains(query, "update"):
|
||||
response = `⬆️ **Update Analysis**
|
||||
|
||||
Analyzing update recommendations for your dependencies...
|
||||
|
||||
**Safe Updates Available:**
|
||||
• 12 packages can be safely updated (patch versions)
|
||||
• 5 packages have minor version updates with new features
|
||||
• 3 packages require major version updates (breaking changes)
|
||||
|
||||
**Priority Updates:**
|
||||
1. lodash: 4.17.19 → 4.17.21 (Security fix, no breaking changes)
|
||||
2. urllib3: 1.24.1 → 1.26.18 (Security fix, minimal risk)
|
||||
|
||||
Would you like detailed impact analysis for any specific package?`
|
||||
|
||||
default:
|
||||
response = fmt.Sprintf(`🤖 **Security Analysis**
|
||||
|
||||
I'm analyzing your question about: "%s"
|
||||
|
||||
I have access to comprehensive security data including:
|
||||
• Vulnerability databases
|
||||
• Malware detection results
|
||||
• Dependency analysis
|
||||
• License compliance
|
||||
• Maintainer health metrics
|
||||
|
||||
**Available Analysis Types:**
|
||||
• Security posture assessment
|
||||
• Vulnerability impact analysis
|
||||
• Malware detection
|
||||
• Update recommendations
|
||||
• Compliance checking
|
||||
|
||||
What specific aspect would you like me to analyze in detail?`, input.Query)
|
||||
}
|
||||
|
||||
return Output{
|
||||
Answer: response,
|
||||
Format: AnswerFormatMarkdown,
|
||||
}, nil
|
||||
}
|
||||
164
agent/react.go
Normal file
@ -0,0 +1,164 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
einoutils "github.com/cloudwego/eino/components/tool/utils"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/flow/agent/react"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type ReactQueryAgentConfig struct {
|
||||
MaxSteps int
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
type reactQueryAgent struct {
|
||||
config ReactQueryAgentConfig
|
||||
model model.ToolCallingChatModel
|
||||
tools []tool.BaseTool
|
||||
}
|
||||
|
||||
var _ Agent = (*reactQueryAgent)(nil)
|
||||
|
||||
type reactQueryAgentOpt func(*reactQueryAgent)
|
||||
|
||||
func WithTools(tools []tool.BaseTool) reactQueryAgentOpt {
|
||||
return func(a *reactQueryAgent) {
|
||||
a.tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
func NewReactQueryAgent(model model.ToolCallingChatModel,
|
||||
config ReactQueryAgentConfig, opts ...reactQueryAgentOpt,
|
||||
) (*reactQueryAgent, error) {
|
||||
a := &reactQueryAgent{
|
||||
config: config,
|
||||
model: model,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(a)
|
||||
}
|
||||
|
||||
if a.config.MaxSteps == 0 {
|
||||
a.config.MaxSteps = 30
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *reactQueryAgent) Execute(ctx context.Context, session Session, input Input, opts ...AgentExecutionContextOpt) (Output, error) {
|
||||
executionContext := &AgentExecutionContext{}
|
||||
for _, opt := range opts {
|
||||
opt(executionContext)
|
||||
}
|
||||
|
||||
agent, err := react.NewAgent(ctx, &react.AgentConfig{
|
||||
ToolCallingModel: a.model,
|
||||
ToolsConfig: compose.ToolsNodeConfig{
|
||||
Tools: a.wrapToolsForError(a.tools),
|
||||
ToolArgumentsHandler: func(ctx context.Context, name string, arguments string) (string, error) {
|
||||
// Only allow introspection if the function is provided. Do not allow mutation.
|
||||
if executionContext.OnToolCall != nil {
|
||||
_ = executionContext.OnToolCall(ctx, session, input, name, arguments)
|
||||
}
|
||||
|
||||
return arguments, nil
|
||||
},
|
||||
},
|
||||
MaxStep: a.config.MaxSteps,
|
||||
})
|
||||
if err != nil {
|
||||
return Output{}, fmt.Errorf("failed to create react agent: %w", err)
|
||||
}
|
||||
|
||||
var messages []*schema.Message
|
||||
|
||||
// Start with the system prompt if available
|
||||
if a.config.SystemPrompt != "" {
|
||||
messages = append(messages, &schema.Message{
|
||||
Role: schema.System,
|
||||
Content: a.config.SystemPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
// Add the previous interactions to the messages
|
||||
interactions, err := session.Memory().GetInteractions(ctx)
|
||||
if err != nil {
|
||||
return Output{}, fmt.Errorf("failed to get session memory: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Add a limit to the number of interactions to avoid context bloat
|
||||
messages = append(messages, interactions...)
|
||||
|
||||
// Add the current user query message to the messages
|
||||
userQueryMsg := &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: input.Query,
|
||||
}
|
||||
|
||||
messages = append(messages, userQueryMsg)
|
||||
|
||||
// Execute the agent to produce a response
|
||||
msg, err := agent.Generate(ctx, messages)
|
||||
if err != nil {
|
||||
return Output{}, fmt.Errorf("failed to generate response: %w", err)
|
||||
}
|
||||
|
||||
// Add the user query message to the session memory
|
||||
err = session.Memory().AddInteraction(ctx, userQueryMsg)
|
||||
if err != nil {
|
||||
return Output{}, fmt.Errorf("failed to add user query message to session memory: %w", err)
|
||||
}
|
||||
|
||||
// Add the agent response message to the session memory
|
||||
err = session.Memory().AddInteraction(ctx, msg)
|
||||
if err != nil {
|
||||
return Output{}, fmt.Errorf("failed to add response message to session memory: %w", err)
|
||||
}
|
||||
|
||||
return Output{
|
||||
Answer: a.schemaContent(msg),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *reactQueryAgent) wrapToolsForError(tools []tool.BaseTool) []tool.BaseTool {
|
||||
wrappedTools := make([]tool.BaseTool, len(tools))
|
||||
|
||||
for i, tool := range tools {
|
||||
wrappedTools[i] = einoutils.WrapToolWithErrorHandler(tool, func(_ context.Context, err error) string {
|
||||
errorMessage := map[string]string{
|
||||
"error": err.Error(),
|
||||
"suggestion": "Tool call failed, Please try a different approach or check your input.",
|
||||
}
|
||||
|
||||
encodedError, err := json.Marshal(errorMessage)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(encodedError)
|
||||
})
|
||||
}
|
||||
|
||||
return wrappedTools
|
||||
}
|
||||
|
||||
func (a *reactQueryAgent) schemaContent(msg *schema.Message) string {
|
||||
content := msg.Content
|
||||
|
||||
if len(msg.MultiContent) > 0 {
|
||||
content = ""
|
||||
for _, part := range msg.MultiContent {
|
||||
content += part.Text + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
29
agent/session.go
Normal file
@ -0,0 +1,29 @@
|
||||
package agent
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type session struct {
|
||||
sessionID string
|
||||
memory Memory
|
||||
}
|
||||
|
||||
var _ Session = (*session)(nil)
|
||||
|
||||
func NewSession(memory Memory) (*session, error) {
|
||||
return newSessionWithID(uuid.New().String(), memory), nil
|
||||
}
|
||||
|
||||
func newSessionWithID(id string, memory Memory) *session {
|
||||
return &session{
|
||||
sessionID: id,
|
||||
memory: memory,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) ID() string {
|
||||
return s.sessionID
|
||||
}
|
||||
|
||||
func (s *session) Memory() Memory {
|
||||
return s.memory
|
||||
}
|
||||
651
agent/ui.go
Normal file
@ -0,0 +1,651 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Message types for Bubbletea updates
|
||||
type statusUpdateMsg struct {
|
||||
message string
|
||||
}
|
||||
|
||||
type agentResponseMsg struct {
|
||||
content string
|
||||
}
|
||||
|
||||
type agentThinkingMsg struct {
|
||||
thinking bool
|
||||
}
|
||||
|
||||
type agentToolCallMsg struct {
|
||||
toolName string
|
||||
toolArgs string
|
||||
}
|
||||
|
||||
type thinkingTickMsg struct{}
|
||||
|
||||
var (
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Padding(0, 1)
|
||||
|
||||
inputPromptStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
inputCursorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255"))
|
||||
|
||||
inputBorderStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
Padding(0, 1)
|
||||
|
||||
thinkingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("33")).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
// AgentUI represents the main TUI model
|
||||
type agentUI struct {
|
||||
viewport viewport.Model
|
||||
textInput textarea.Model
|
||||
width int
|
||||
height int
|
||||
statusMessage string
|
||||
isThinking bool
|
||||
messages []uiMessage
|
||||
ready bool
|
||||
agent Agent
|
||||
session Session
|
||||
config AgentUIConfig
|
||||
thinkingFrame int
|
||||
inputHistory []string
|
||||
historyIndex int
|
||||
currentInput string
|
||||
}
|
||||
|
||||
// Message represents a chat message
|
||||
type uiMessage struct {
|
||||
Role string // "user", "agent", "system"
|
||||
Content string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// AgentUIConfig defines the configuration for the UI
|
||||
type AgentUIConfig struct {
|
||||
Width int
|
||||
Height int
|
||||
InitialSystemMessage string
|
||||
TextInputPlaceholder string
|
||||
TitleText string
|
||||
MaxHistory int
|
||||
|
||||
// Only for informational purposes.
|
||||
ModelName string
|
||||
ModelVendor string
|
||||
ModelFast bool
|
||||
}
|
||||
|
||||
// DefaultAgentUIConfig returns the opinionated default configuration for the UI
|
||||
func DefaultAgentUIConfig() AgentUIConfig {
|
||||
return AgentUIConfig{
|
||||
Width: 80,
|
||||
Height: 20,
|
||||
MaxHistory: 50,
|
||||
InitialSystemMessage: "Security Agent initialized",
|
||||
TextInputPlaceholder: "Ask me anything...",
|
||||
TitleText: "Security Agent",
|
||||
}
|
||||
}
|
||||
|
||||
// NewAgentUI creates a new agent UI instance
|
||||
func NewAgentUI(agent Agent, session Session, config AgentUIConfig) *agentUI {
|
||||
vp := viewport.New(config.Width, config.Height)
|
||||
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = ""
|
||||
ta.Focus()
|
||||
ta.SetHeight(1)
|
||||
ta.SetWidth(80)
|
||||
ta.CharLimit = 1000
|
||||
ta.ShowLineNumbers = false
|
||||
|
||||
ui := &agentUI{
|
||||
viewport: vp,
|
||||
textInput: ta,
|
||||
statusMessage: "",
|
||||
messages: []uiMessage{},
|
||||
agent: agent,
|
||||
session: session,
|
||||
config: config,
|
||||
thinkingFrame: 0,
|
||||
inputHistory: []string{},
|
||||
historyIndex: -1,
|
||||
currentInput: "",
|
||||
}
|
||||
|
||||
ui.addSystemMessage(config.InitialSystemMessage)
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
// Init implements the tea.Model interface
|
||||
func (m *agentUI) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textarea.Blink,
|
||||
m.tickThinking(),
|
||||
)
|
||||
}
|
||||
|
||||
// Update implements the tea.Model interface
|
||||
func (m *agentUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyEnter:
|
||||
if m.textInput.Focused() && !m.isThinking {
|
||||
// Handle user input only if agent is not in thinking mode
|
||||
userInput := strings.TrimSpace(m.textInput.Value())
|
||||
if userInput != "" {
|
||||
// Add to history and reset navigation
|
||||
m.addToHistory(userInput)
|
||||
|
||||
// Add the input to the message list and reset user input field
|
||||
m.addUserMessage(userInput)
|
||||
m.resetInputField()
|
||||
|
||||
// Check if it's a slash command
|
||||
if strings.HasPrefix(userInput, "/") {
|
||||
// Handle slash command
|
||||
cmd := m.handleSlashCommand(userInput)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
} else {
|
||||
// Execute agent query
|
||||
cmds = append(cmds,
|
||||
m.setThinking(true),
|
||||
m.executeAgentQuery(userInput),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case tea.KeyTab:
|
||||
// Switch focus between input and viewport, but not while agent is thinking
|
||||
if !m.isThinking {
|
||||
if m.textInput.Focused() {
|
||||
m.textInput.Blur()
|
||||
} else {
|
||||
m.textInput.Focus()
|
||||
cmds = append(cmds, textarea.Blink)
|
||||
}
|
||||
}
|
||||
|
||||
case tea.KeyUp, tea.KeyDown:
|
||||
if m.textInput.Focused() && !m.isThinking {
|
||||
// Navigate input history when text input is focused
|
||||
var direction int
|
||||
if msg.Type == tea.KeyUp {
|
||||
direction = 1 // Go back in history
|
||||
} else {
|
||||
direction = -1 // Go forward in history
|
||||
}
|
||||
|
||||
historyEntry := m.navigateHistory(direction)
|
||||
m.textInput.SetValue(historyEntry)
|
||||
m.textInput.CursorEnd()
|
||||
} else if !m.textInput.Focused() {
|
||||
// Allow scrolling in viewport when not focused on text input
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case tea.KeyPgUp, tea.KeyPgDown:
|
||||
// Allow scrolling in viewport when not focused on text input
|
||||
if !m.textInput.Focused() {
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case tea.KeyHome:
|
||||
if !m.textInput.Focused() {
|
||||
m.viewport.GotoTop()
|
||||
}
|
||||
|
||||
case tea.KeyEnd:
|
||||
if !m.textInput.Focused() {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
// Handle window resize
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
// Calculate dimensions for minimal UI
|
||||
headerHeight := 2 // Header + blank line
|
||||
inputHeight := 2 // Input area + status
|
||||
spacing := 1 // Bottom spacing
|
||||
|
||||
// Calculate viewport dimensions to maximize output area
|
||||
viewportHeight := m.height - headerHeight - inputHeight - spacing
|
||||
|
||||
// Ensure minimum height
|
||||
if viewportHeight < 10 {
|
||||
viewportHeight = 10
|
||||
}
|
||||
|
||||
// Full width utilization
|
||||
viewportWidth := m.width
|
||||
|
||||
// Ensure minimum width
|
||||
if viewportWidth < 50 {
|
||||
viewportWidth = 50
|
||||
}
|
||||
|
||||
m.viewport.Width = viewportWidth
|
||||
m.viewport.Height = viewportHeight
|
||||
m.textInput.SetWidth(m.width - 3)
|
||||
|
||||
// Update content when dimensions change
|
||||
m.viewport.SetContent(m.renderMessages())
|
||||
|
||||
if !m.ready {
|
||||
m.ready = true
|
||||
}
|
||||
|
||||
case statusUpdateMsg:
|
||||
m.statusMessage = msg.message
|
||||
|
||||
case agentThinkingMsg:
|
||||
m.isThinking = msg.thinking
|
||||
|
||||
// When agent starts thinking, blur the input
|
||||
if m.isThinking {
|
||||
m.resetInputField()
|
||||
m.textInput.Blur()
|
||||
m.thinkingFrame = 0
|
||||
cmds = append(cmds, m.tickThinking())
|
||||
} else {
|
||||
// Re-focus input when thinking stops
|
||||
m.textInput.Focus()
|
||||
cmds = append(cmds, textarea.Blink)
|
||||
}
|
||||
|
||||
case agentResponseMsg:
|
||||
m.addAgentMessage(msg.content)
|
||||
cmds = append(cmds, m.setThinking(false))
|
||||
|
||||
case agentToolCallMsg:
|
||||
m.addToolCallMessage(fmt.Sprintf("🔧 %s", msg.toolName), msg.toolArgs)
|
||||
|
||||
case thinkingTickMsg:
|
||||
if m.isThinking {
|
||||
m.thinkingFrame = (m.thinkingFrame + 1) % 4
|
||||
cmds = append(cmds, m.tickThinking())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Update child components
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Only update text input if not thinking
|
||||
if !m.isThinking {
|
||||
m.textInput, cmd = m.textInput.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View implements the tea.Model interface
|
||||
func (m *agentUI) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "Initializing..."
|
||||
}
|
||||
|
||||
modelAbility := "fast"
|
||||
if !m.config.ModelFast {
|
||||
modelAbility = "slow"
|
||||
}
|
||||
|
||||
modelStatusLine := fmt.Sprintf("%s/%s (%s)", m.config.ModelVendor, m.config.ModelName, modelAbility)
|
||||
|
||||
header := headerStyle.Render(fmt.Sprintf("%s %s", m.config.TitleText, modelStatusLine))
|
||||
|
||||
content := m.viewport.View()
|
||||
|
||||
var thinkingIndicator string
|
||||
if m.isThinking {
|
||||
thinkingFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
spinner := thinkingFrames[m.thinkingFrame%len(thinkingFrames)]
|
||||
thinkingIndicator = thinkingStyle.Render(fmt.Sprintf("%s thinking...", spinner))
|
||||
}
|
||||
|
||||
var inputArea string
|
||||
userInput := m.textInput.Value()
|
||||
cursor := ""
|
||||
if m.textInput.Focused() && !m.isThinking {
|
||||
cursor = inputCursorStyle.Render("▊")
|
||||
}
|
||||
|
||||
inputContent := fmt.Sprintf("%s%s%s", inputPromptStyle.Render("> "), userInput, cursor)
|
||||
inputArea = inputBorderStyle.Width(m.width - 2).Render(inputContent)
|
||||
|
||||
statusLine := inputPromptStyle.Render(fmt.Sprintf("** %s | ctrl+c to exit", modelStatusLine))
|
||||
|
||||
var components []string
|
||||
components = append(components, header, "", content, "")
|
||||
|
||||
if thinkingIndicator != "" {
|
||||
components = append(components, thinkingIndicator)
|
||||
}
|
||||
|
||||
components = append(components, inputArea, statusLine)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, components...)
|
||||
}
|
||||
|
||||
func (m *agentUI) resetInputField() {
|
||||
m.textInput.Reset()
|
||||
m.textInput.SetValue("")
|
||||
m.textInput.CursorStart()
|
||||
}
|
||||
|
||||
func (m *agentUI) addUserMessage(content string) {
|
||||
m.messages = append(m.messages, uiMessage{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
m.viewport.SetContent(m.renderMessages())
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func (m *agentUI) addAgentMessage(content string) {
|
||||
m.messages = append(m.messages, uiMessage{
|
||||
Role: "agent",
|
||||
Content: content,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
m.viewport.SetContent(m.renderMessages())
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func (m *agentUI) addSystemMessage(content string) {
|
||||
m.messages = append(m.messages, uiMessage{
|
||||
Role: "system",
|
||||
Content: content,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
m.viewport.SetContent(m.renderMessages())
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func (m *agentUI) addToolCallMessage(toolName string, toolArgs string) {
|
||||
content := fmt.Sprintf(" %s", toolName)
|
||||
if toolArgs != "" && toolArgs != "{}" {
|
||||
content += fmt.Sprintf("\n └─ %s", toolArgs)
|
||||
}
|
||||
|
||||
m.messages = append(m.messages, uiMessage{
|
||||
Role: "tool",
|
||||
Content: content,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
m.viewport.SetContent(m.renderMessages())
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
// renderMessages formats all messages for display
|
||||
func (m *agentUI) renderMessages() string {
|
||||
var rendered []string
|
||||
|
||||
rendered = append(rendered, "", "")
|
||||
|
||||
contentWidth := m.viewport.Width - 2 // Account for internal padding
|
||||
if contentWidth < 40 {
|
||||
contentWidth = 40
|
||||
}
|
||||
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle("notty"),
|
||||
glamour.WithWordWrap(contentWidth),
|
||||
)
|
||||
if err != nil {
|
||||
r = nil
|
||||
}
|
||||
|
||||
for _, msg := range m.messages {
|
||||
timestamp := msg.Timestamp.Format("15:04:05")
|
||||
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
userHeaderStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Bold(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(lipgloss.Color("86")).
|
||||
Padding(0, 1)
|
||||
|
||||
userContentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Padding(0, 2)
|
||||
|
||||
rendered = append(rendered,
|
||||
userHeaderStyle.Render(fmt.Sprintf("[%s] → You:", timestamp)),
|
||||
userContentStyle.Render(msg.Content),
|
||||
"",
|
||||
)
|
||||
case "agent":
|
||||
var content string
|
||||
if r != nil {
|
||||
renderedMarkdown, err := r.Render(msg.Content)
|
||||
if err == nil {
|
||||
content = strings.TrimSpace(renderedMarkdown)
|
||||
} else {
|
||||
content = msg.Content // Fallback to plain text
|
||||
}
|
||||
} else {
|
||||
content = msg.Content
|
||||
}
|
||||
|
||||
agentHeaderStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("39")).
|
||||
Bold(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(lipgloss.Color("39")).
|
||||
Padding(0, 1)
|
||||
|
||||
agentContentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Padding(0, 2)
|
||||
|
||||
rendered = append(rendered,
|
||||
agentHeaderStyle.Render(fmt.Sprintf("[%s] ← Agent:", timestamp)),
|
||||
agentContentStyle.Render(content),
|
||||
"",
|
||||
)
|
||||
case "system":
|
||||
systemStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Italic(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(lipgloss.Color("241")).
|
||||
Padding(0, 1)
|
||||
|
||||
rendered = append(rendered,
|
||||
systemStyle.Render(fmt.Sprintf("[%s] ℹ %s", timestamp, msg.Content)),
|
||||
"",
|
||||
)
|
||||
case "tool":
|
||||
toolStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245")).
|
||||
Italic(true).
|
||||
Faint(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(lipgloss.Color("245")).
|
||||
Padding(0, 1)
|
||||
|
||||
rendered = append(rendered,
|
||||
toolStyle.Render(fmt.Sprintf("[%s] %s", timestamp, msg.Content)),
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
rendered = append(rendered, "", "")
|
||||
|
||||
return strings.Join(rendered, "\n")
|
||||
}
|
||||
|
||||
func (m *agentUI) updateStatus(message string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return statusUpdateMsg{message: message}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agentUI) setThinking(thinking bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return agentThinkingMsg{thinking: thinking}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agentUI) executeAgentQuery(userInput string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
input := Input{
|
||||
Query: userInput,
|
||||
}
|
||||
|
||||
toolCallHook := func(_ context.Context, _ Session, _ Input, toolName string, toolArgs string) error {
|
||||
m.Update(agentToolCallMsg{toolName: toolName, toolArgs: toolArgs})
|
||||
return nil
|
||||
}
|
||||
|
||||
output, err := m.agent.Execute(ctx, m.session, input, WithToolCallHook(toolCallHook))
|
||||
if err != nil {
|
||||
return agentResponseMsg{
|
||||
content: fmt.Sprintf("❌ **Error**\n\nSorry, I encountered an error while processing your query:\n\n%s", err.Error()),
|
||||
}
|
||||
}
|
||||
|
||||
return agentResponseMsg{content: output.Answer}
|
||||
}
|
||||
}
|
||||
|
||||
// StartUI starts the TUI application with the default configuration
|
||||
func StartUI(agent Agent, session Session) error {
|
||||
config := DefaultAgentUIConfig()
|
||||
config.InitialSystemMessage = ""
|
||||
return StartUIWithConfig(agent, session, config)
|
||||
}
|
||||
|
||||
// StartUIWithConfig starts the TUI application with the provided configuration
|
||||
func StartUIWithConfig(agent Agent, session Session, config AgentUIConfig) error {
|
||||
ui := NewAgentUI(agent, session, config)
|
||||
|
||||
p := tea.NewProgram(
|
||||
ui,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *agentUI) tickThinking() tea.Cmd {
|
||||
return tea.Tick(150*time.Millisecond, func(time.Time) tea.Msg {
|
||||
return thinkingTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// handleSlashCommand processes commands that start with '/'
|
||||
func (m *agentUI) handleSlashCommand(command string) tea.Cmd {
|
||||
switch command {
|
||||
case "/exit":
|
||||
m.addSystemMessage("Goodbye! Exiting gracefully...")
|
||||
return tea.Quit
|
||||
default:
|
||||
m.addSystemMessage(fmt.Sprintf("Unknown command: %s", command))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// addToHistory adds input to history buffer with a maximum of 50 entries
|
||||
func (m *agentUI) addToHistory(input string) {
|
||||
// Don't add empty strings or duplicates of the last entry
|
||||
if input == "" || (len(m.inputHistory) > 0 && m.inputHistory[len(m.inputHistory)-1] == input) {
|
||||
return
|
||||
}
|
||||
|
||||
m.inputHistory = append(m.inputHistory, input)
|
||||
|
||||
// Keep only the last maxHistory entries
|
||||
if len(m.inputHistory) > m.config.MaxHistory {
|
||||
m.inputHistory = m.inputHistory[len(m.inputHistory)-m.config.MaxHistory:]
|
||||
}
|
||||
|
||||
// Reset history navigation
|
||||
m.historyIndex = -1
|
||||
m.currentInput = ""
|
||||
}
|
||||
|
||||
// navigateHistory moves through input history and returns the selected entry
|
||||
func (m *agentUI) navigateHistory(direction int) string {
|
||||
if len(m.inputHistory) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Save current input when starting navigation
|
||||
if m.historyIndex == -1 {
|
||||
m.currentInput = m.textInput.Value()
|
||||
}
|
||||
|
||||
// Calculate new index
|
||||
newIndex := m.historyIndex + direction
|
||||
|
||||
// Handle boundaries
|
||||
if newIndex < -1 {
|
||||
newIndex = -1
|
||||
} else if newIndex >= len(m.inputHistory) {
|
||||
newIndex = len(m.inputHistory) - 1
|
||||
}
|
||||
|
||||
m.historyIndex = newIndex
|
||||
|
||||
// Return the appropriate entry
|
||||
if m.historyIndex == -1 {
|
||||
return m.currentInput
|
||||
}
|
||||
|
||||
return m.inputHistory[len(m.inputHistory)-1-m.historyIndex]
|
||||
}
|
||||
280
agent/ui_test.go
Normal file
@ -0,0 +1,280 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAgentUICreation(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
assert.NotNil(t, ui, "Failed to create AgentUI")
|
||||
assert.Empty(t, ui.statusMessage, "Expected empty status message")
|
||||
assert.False(t, ui.isThinking, "UI should not be thinking initially")
|
||||
assert.Equal(t, 0, ui.thinkingFrame, "Thinking frame should be 0 initially")
|
||||
|
||||
// Check that system message was added if config has one
|
||||
if config.InitialSystemMessage != "" {
|
||||
assert.NotEmpty(t, ui.messages, "Expected system message to be added if InitialSystemMessage is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAgentUIConfig(t *testing.T) {
|
||||
config := DefaultAgentUIConfig()
|
||||
|
||||
assert.Equal(t, 80, config.Width, "Expected default width 80")
|
||||
assert.Equal(t, 20, config.Height, "Expected default height 20")
|
||||
assert.Equal(t, "Security Agent", config.TitleText, "Expected title 'Security Agent'")
|
||||
assert.Equal(t, "Ask me anything...", config.TextInputPlaceholder, "Expected placeholder 'Ask me anything...'")
|
||||
}
|
||||
|
||||
func TestMessageManagement(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
initialCount := len(ui.messages)
|
||||
|
||||
// Test adding user message
|
||||
ui.addUserMessage("Test user message")
|
||||
assert.Equal(t, initialCount+1, len(ui.messages), "Expected message count to increase")
|
||||
|
||||
lastMessage := ui.messages[len(ui.messages)-1]
|
||||
assert.Equal(t, "user", lastMessage.Role, "Expected last message role to be 'user'")
|
||||
assert.Equal(t, "Test user message", lastMessage.Content, "Expected last message content to be 'Test user message'")
|
||||
|
||||
// Test adding agent message
|
||||
ui.addAgentMessage("Test agent response")
|
||||
assert.Equal(t, initialCount+2, len(ui.messages), "Expected message count to increase")
|
||||
|
||||
lastMessage = ui.messages[len(ui.messages)-1]
|
||||
assert.Equal(t, "agent", lastMessage.Role, "Expected last message role to be 'agent'")
|
||||
|
||||
// Test adding system message
|
||||
ui.addSystemMessage("System notification")
|
||||
assert.Equal(t, initialCount+3, len(ui.messages), "Expected message count to increase")
|
||||
|
||||
lastMessage = ui.messages[len(ui.messages)-1]
|
||||
assert.Equal(t, "system", lastMessage.Role, "Expected last message role to be 'system'")
|
||||
|
||||
// Test adding tool call message
|
||||
ui.addToolCallMessage("ScanVulnerabilities", `{"path": "/app"}`)
|
||||
assert.Equal(t, initialCount+4, len(ui.messages), "Expected message count to increase")
|
||||
|
||||
lastMessage = ui.messages[len(ui.messages)-1]
|
||||
assert.Equal(t, "tool", lastMessage.Role, "Expected last message role to be 'tool'")
|
||||
}
|
||||
|
||||
func TestMessageRendering(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
// Set up viewport dimensions for rendering
|
||||
ui.viewport.Width = 80
|
||||
ui.viewport.Height = 20
|
||||
|
||||
ui.addUserMessage("How many vulnerabilities?")
|
||||
ui.addAgentMessage("Found 5 critical vulnerabilities")
|
||||
|
||||
rendered := ui.renderMessages()
|
||||
|
||||
assert.NotEmpty(t, rendered, "Expected non-empty rendered output")
|
||||
assert.Contains(t, rendered, "How many vulnerabilities?", "Rendered output should contain user message")
|
||||
assert.Contains(t, rendered, "Found 5 critical vulnerabilities", "Rendered output should contain agent message")
|
||||
assert.Contains(t, rendered, "You:", "Rendered output should contain user label")
|
||||
assert.Contains(t, rendered, "Agent:", "Rendered output should contain agent label")
|
||||
}
|
||||
|
||||
func TestViewportDimensions(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
// Test window resize handling
|
||||
resizeMsg := tea.WindowSizeMsg{Width: 100, Height: 30}
|
||||
ui.Update(resizeMsg)
|
||||
|
||||
assert.Equal(t, 100, ui.width, "Expected width 100")
|
||||
assert.Equal(t, 30, ui.height, "Expected height 30")
|
||||
|
||||
// Test minimum dimensions enforcement
|
||||
resizeMsg = tea.WindowSizeMsg{Width: 10, Height: 5}
|
||||
ui.Update(resizeMsg)
|
||||
|
||||
assert.GreaterOrEqual(t, ui.viewport.Width, 50, "Viewport width should be enforced to minimum 50")
|
||||
assert.GreaterOrEqual(t, ui.viewport.Height, 10, "Viewport height should be enforced to minimum 10")
|
||||
}
|
||||
|
||||
func TestViewRendering(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
config.ModelName = "gpt-4"
|
||||
config.ModelVendor = "openai"
|
||||
config.ModelFast = false
|
||||
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
ui.width = 80
|
||||
ui.height = 24
|
||||
ui.ready = true
|
||||
|
||||
view := ui.View()
|
||||
|
||||
assert.Contains(t, view, "Security Agent", "View should contain title")
|
||||
assert.Contains(t, view, "openai/gpt-4", "View should contain model information")
|
||||
assert.Contains(t, view, ">", "View should contain input prompt")
|
||||
assert.Contains(t, view, "ctrl+c to exit", "View should contain exit instruction")
|
||||
}
|
||||
|
||||
func TestThinkingState(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
ui.width = 80
|
||||
ui.height = 24
|
||||
ui.ready = true
|
||||
|
||||
// Initially not thinking
|
||||
assert.False(t, ui.isThinking, "UI should not be thinking initially")
|
||||
|
||||
// Set thinking state
|
||||
thinkingMsg := agentThinkingMsg{thinking: true}
|
||||
ui.Update(thinkingMsg)
|
||||
|
||||
assert.True(t, ui.isThinking, "UI should be thinking after agentThinkingMsg")
|
||||
|
||||
// Check view contains thinking indicator
|
||||
view := ui.View()
|
||||
assert.Contains(t, view, "thinking...", "View should contain thinking indicator when thinking")
|
||||
|
||||
// Stop thinking
|
||||
thinkingMsg = agentThinkingMsg{thinking: false}
|
||||
ui.Update(thinkingMsg)
|
||||
|
||||
assert.False(t, ui.isThinking, "UI should not be thinking after agentThinkingMsg with false")
|
||||
}
|
||||
|
||||
func TestKeyboardHandling(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
ui.width = 80
|
||||
ui.height = 24
|
||||
ui.ready = true
|
||||
|
||||
var keyMsg tea.KeyMsg
|
||||
var model tea.Model
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Test Ctrl+C exits immediately
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyCtrlC}
|
||||
_, cmd = ui.Update(keyMsg)
|
||||
|
||||
assert.NotNil(t, cmd, "Ctrl+C should return quit command")
|
||||
|
||||
// Test Tab key for focus switching when not thinking
|
||||
ui.textInput.Focus()
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
|
||||
model, _ = ui.Update(keyMsg)
|
||||
ui = model.(*agentUI)
|
||||
|
||||
assert.False(t, ui.textInput.Focused(), "Tab should blur text input when it's focused")
|
||||
|
||||
// Test Enter key handling when not thinking
|
||||
ui.textInput.Focus()
|
||||
ui.textInput.SetValue("test message")
|
||||
initialMessageCount := len(ui.messages)
|
||||
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyEnter}
|
||||
model, _ = ui.Update(keyMsg)
|
||||
ui = model.(*agentUI)
|
||||
|
||||
assert.Equal(t, initialMessageCount+1, len(ui.messages), "Enter should add user message when input is not empty")
|
||||
|
||||
// Note: Input field reset happens when thinking starts, not immediately
|
||||
// The resetInputField() is called, but the UI state may not reflect it immediately in tests
|
||||
}
|
||||
|
||||
func TestInputFieldReset(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
// Set some input text
|
||||
ui.textInput.SetValue("test input")
|
||||
assert.Equal(t, "test input", ui.textInput.Value(), "Input should contain test text")
|
||||
|
||||
// Reset input field
|
||||
ui.resetInputField()
|
||||
assert.Empty(t, ui.textInput.Value(), "Input should be empty after reset")
|
||||
}
|
||||
|
||||
func TestCommandCreation(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
// Test status update command
|
||||
cmd := ui.updateStatus("Testing status")
|
||||
assert.NotNil(t, cmd, "updateStatus should return a non-nil command")
|
||||
|
||||
// Test thinking command
|
||||
cmd = ui.setThinking(true)
|
||||
assert.NotNil(t, cmd, "setThinking should return a non-nil command")
|
||||
|
||||
// Test execute agent query command
|
||||
cmd = ui.executeAgentQuery("test query")
|
||||
assert.NotNil(t, cmd, "executeAgentQuery should return a non-nil command")
|
||||
}
|
||||
|
||||
func TestMessageTimestamps(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
before := time.Now()
|
||||
ui.addUserMessage("Test message")
|
||||
after := time.Now()
|
||||
|
||||
message := ui.messages[len(ui.messages)-1]
|
||||
|
||||
assert.True(t, message.Timestamp.After(before) || message.Timestamp.Equal(before), "Message timestamp should be after or equal to before time")
|
||||
assert.True(t, message.Timestamp.Before(after) || message.Timestamp.Equal(after), "Message timestamp should be before or equal to after time")
|
||||
}
|
||||
|
||||
func TestUIInitialization(t *testing.T) {
|
||||
mockAgent := NewMockAgent()
|
||||
mockSession := NewMockSession()
|
||||
config := DefaultAgentUIConfig()
|
||||
ui := NewAgentUI(mockAgent, mockSession, config)
|
||||
|
||||
// Test Init command
|
||||
cmd := ui.Init()
|
||||
assert.NotNil(t, cmd, "Init should return a non-nil command")
|
||||
|
||||
// Test initial state before ready
|
||||
view := ui.View()
|
||||
assert.Equal(t, "Loading...", view, "View should show loading before ready")
|
||||
|
||||
// Test with zero dimensions
|
||||
ui.ready = true
|
||||
ui.width = 0
|
||||
ui.height = 0
|
||||
view = ui.View()
|
||||
assert.Equal(t, "Initializing...", view, "View should show initializing with zero dimensions")
|
||||
}
|
||||
17
api/checks.proto
Normal file
@ -0,0 +1,17 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/safedep/vet/gen/checks";
|
||||
|
||||
enum CheckType {
|
||||
CheckTypeUnknown = 0;
|
||||
CheckTypeVulnerability = 1;
|
||||
CheckTypeMalware = 2;
|
||||
CheckTypePopularity = 3;
|
||||
CheckTypeMaintenance = 4;
|
||||
CheckTypeSecurityScorecard = 5;
|
||||
CheckTypeLicense = 6;
|
||||
|
||||
reserved 7 to 99;
|
||||
|
||||
CheckTypeOther = 100;
|
||||
}
|
||||
@ -1,123 +0,0 @@
|
||||
openapi: 3.0.2
|
||||
info:
|
||||
title: SafeDep Control Plane API for Trials Registration
|
||||
contact:
|
||||
name: SafeDep API
|
||||
url: 'https://safedep.io'
|
||||
description: |
|
||||
Trials API provide a way for obtaining an API Key for data plane service access
|
||||
using an Email Address. Trials is different from Registrations as the later
|
||||
allows full access to the control plane while Trials is meant to allow access
|
||||
only to a time bounded (expirable) API key for quick evaluation of tools.
|
||||
version: 0.0.1
|
||||
servers:
|
||||
- url: 'https://{apiHost}/{apiBase}'
|
||||
variables:
|
||||
apiHost:
|
||||
default: api.safedep.io
|
||||
apiBase:
|
||||
default: control-plane/v1
|
||||
tags:
|
||||
- name: Control Plane
|
||||
description: Control Plane API
|
||||
paths:
|
||||
/trials:
|
||||
post:
|
||||
description: |
|
||||
Register a trial user to obtain an expirable API Key. The API key will
|
||||
be generated and sent to the user over Email to ensure validity and access
|
||||
to the email by the requester. System defined limits will be applied to
|
||||
maximum number of trial API keys that can be generated for an email.
|
||||
operationId: registerTrialUser
|
||||
tags:
|
||||
- Control Plane
|
||||
requestBody:
|
||||
description: Trial registration request
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrialRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Successfully created an API key request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrialResponse'
|
||||
'403':
|
||||
description: Access to the API is denied
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
'429':
|
||||
description: Rate limit block
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
'500':
|
||||
description: Failed due to internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
components:
|
||||
schemas:
|
||||
ApiError:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: A descriptive message about the error meant for developer consumption
|
||||
type:
|
||||
type: string
|
||||
description: An optional service or domain specific error group
|
||||
enum:
|
||||
- invalid_request
|
||||
- operation_failed
|
||||
- internal_error
|
||||
code:
|
||||
type: string
|
||||
description: An error code identifying the error
|
||||
enum:
|
||||
- api_guard_invalid_credentials
|
||||
- api_guard_rate_limit_exceeded
|
||||
- api_guard_unauthorized
|
||||
- api_guard_error
|
||||
- app_generic_error
|
||||
- app_security_error
|
||||
- app_insufficient_parameters
|
||||
- app_feature_not_enabled
|
||||
- app_package_version_not_found
|
||||
params:
|
||||
type: object
|
||||
description: Optional error specific attributes
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
TrialRequest:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
required:
|
||||
- email
|
||||
TrialResponse:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
minLength: 6
|
||||
maxLength: 512
|
||||
description: The ID of the trial registration request created in the system
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The expiry time of the API key
|
||||
18
api/exceptions_spec.proto
Normal file
@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/safedep/vet/gen/exceptionsapi";
|
||||
|
||||
message Exception {
|
||||
string id = 1;
|
||||
string ecosystem = 2;
|
||||
string name = 3;
|
||||
string version = 4;
|
||||
string expires = 5;
|
||||
string pattern = 6; // To be used for special cases
|
||||
}
|
||||
|
||||
message ExceptionSuite {
|
||||
string name = 1;
|
||||
string description = 2;
|
||||
repeated Exception exceptions = 3;
|
||||
}
|
||||
@ -3,48 +3,27 @@ syntax = "proto3";
|
||||
/* Specifcations for filter input that can be used for query by CEL */
|
||||
option go_package = "github.com/safedep/vet/gen/filterinput";
|
||||
|
||||
message Vulnerability {
|
||||
string id = 1; // OSV ID
|
||||
string cve = 2; // CVE ID
|
||||
}
|
||||
import "insights_models.proto";
|
||||
|
||||
// Only hold vulnerability IDs
|
||||
message Vulnerabilities {
|
||||
repeated Vulnerability all = 1;
|
||||
repeated Vulnerability critical = 2;
|
||||
repeated Vulnerability high = 3;
|
||||
repeated Vulnerability medium = 4;
|
||||
repeated Vulnerability low = 5;
|
||||
message FilterInputVulnerabilities {
|
||||
repeated InsightVulnerability all = 1;
|
||||
repeated InsightVulnerability critical = 2;
|
||||
repeated InsightVulnerability high = 3;
|
||||
repeated InsightVulnerability medium = 4;
|
||||
repeated InsightVulnerability low = 5;
|
||||
}
|
||||
|
||||
// OpenSSF Scorecard
|
||||
message Scorecard {
|
||||
map<string, float> scores = 1;
|
||||
}
|
||||
|
||||
enum ProjectType {
|
||||
UNKNOWN = 0;
|
||||
GITHUB = 1;
|
||||
}
|
||||
|
||||
message ProjectInfo {
|
||||
string name = 1;
|
||||
ProjectType type = 2;
|
||||
int32 stars = 3;
|
||||
int32 forks = 4;
|
||||
int32 issues = 5;
|
||||
}
|
||||
|
||||
message PackageVersion {
|
||||
message FilterInputPackageVersion {
|
||||
string ecosystem = 1;
|
||||
string name = 2;
|
||||
string version = 3;
|
||||
}
|
||||
|
||||
message FilterInput {
|
||||
PackageVersion pkg = 1;
|
||||
Vulnerabilities vulns = 2;
|
||||
Scorecard scorecard = 3;
|
||||
repeated ProjectInfo projects = 4;
|
||||
FilterInputPackageVersion pkg = 1;
|
||||
FilterInputVulnerabilities vulns = 2;
|
||||
InsightScorecard scorecard = 3;
|
||||
repeated InsightProjectInfo projects = 4;
|
||||
repeated string licenses = 5;
|
||||
}
|
||||
|
||||
22
api/filter_suite_spec.proto
Normal file
@ -0,0 +1,22 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/safedep/vet/gen/filtersuite";
|
||||
|
||||
import "checks.proto";
|
||||
|
||||
message Filter {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
CheckType check_type = 3;
|
||||
string summary = 4;
|
||||
string description = 5;
|
||||
repeated string references = 6;
|
||||
repeated string tags = 7;
|
||||
}
|
||||
|
||||
message FilterSuite {
|
||||
string name = 1;
|
||||
string description = 2;
|
||||
repeated Filter filters = 3;
|
||||
repeated string tags = 4;
|
||||
}
|
||||
56
api/insights_models.proto
Normal file
@ -0,0 +1,56 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/safedep/vet/gen/models";
|
||||
|
||||
message InsightVulnerabilitySeverity {
|
||||
enum Type {
|
||||
UNKNOWN_TYPE = 0;
|
||||
CVSSV2 = 1;
|
||||
CVSSV3 = 2;
|
||||
}
|
||||
|
||||
enum Risk {
|
||||
UNKNOWN_RISK = 0;
|
||||
LOW = 1;
|
||||
MEDIUM = 2;
|
||||
HIGH = 3;
|
||||
CRITICAL = 4;
|
||||
}
|
||||
|
||||
Type type = 1;
|
||||
string score = 2; // Score based on type (usually the CVSS metric)
|
||||
Risk risk = 3;
|
||||
}
|
||||
|
||||
message InsightVulnerability {
|
||||
string id = 1; // OSV ID
|
||||
string cve = 2; // CVE ID. DO NOT USE THIS outside vet. Its used for internal legacy reason
|
||||
string title = 3;
|
||||
repeated string aliases = 4; // Other IDs for same vuln in different databases
|
||||
repeated InsightVulnerabilitySeverity severities = 5;
|
||||
}
|
||||
|
||||
message InsightLicenseInfo {
|
||||
string id = 1; // SPDX license ID
|
||||
}
|
||||
|
||||
message InsightScorecard {
|
||||
map<string, float> scores = 1;
|
||||
float score = 2;
|
||||
}
|
||||
|
||||
message InsightProjectInfo {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
GITHUB = 1;
|
||||
}
|
||||
|
||||
string name = 1;
|
||||
Type type = 2;
|
||||
int32 stars = 3;
|
||||
int32 forks = 4;
|
||||
int32 issues = 5;
|
||||
string url = 6;
|
||||
}
|
||||
|
||||
|
||||
111
api/json_report_spec.proto
Normal file
@ -0,0 +1,111 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/safedep/vet/gen/jsonreportspec";
|
||||
|
||||
import "models.proto";
|
||||
import "insights_models.proto";
|
||||
import "violations.proto";
|
||||
|
||||
enum RemediationAdviceType {
|
||||
UnknownAdviceType = 0;
|
||||
UpgradePackage = 1;
|
||||
AlternatePopularPackage = 2;
|
||||
AlternateSecurePackage = 3;
|
||||
}
|
||||
|
||||
message RemediationAdvice {
|
||||
RemediationAdviceType type = 1;
|
||||
Package package = 2;
|
||||
string target_package_name = 3;
|
||||
string target_package_version = 4;
|
||||
string target_alternate_package_name = 5;
|
||||
string target_alternate_package_version = 6;
|
||||
}
|
||||
|
||||
// We are introducing the concept of Threat as a reporting entity so
|
||||
// that we can report threats like lockfile poisoning using a standard schema.
|
||||
// But why do we need threats? Why not just use vet's paradigm of policy over
|
||||
// enriched packages? The reason is, there are threats that are applicable in
|
||||
// an environment, against a manifest or other entities or even group of entities.
|
||||
// Hence it is required to introduce a threat as a reporting entity so that external
|
||||
// tools can consume vet's reports and take actions based on the threats.
|
||||
message ReportThreat {
|
||||
enum Confidence {
|
||||
UnknownConfidence = 0;
|
||||
|
||||
High = 1;
|
||||
Medium = 2;
|
||||
Low = 3;
|
||||
}
|
||||
|
||||
enum Source {
|
||||
UnknownSource = 0;
|
||||
|
||||
CWE = 1;
|
||||
}
|
||||
|
||||
enum SubjectType {
|
||||
UnknownSubject = 0;
|
||||
|
||||
Package = 1;
|
||||
Manifest = 2;
|
||||
}
|
||||
|
||||
enum ReportThreatId {
|
||||
UnknownReportThreatId = 0;
|
||||
|
||||
LockfilePoisoning = 1;
|
||||
}
|
||||
|
||||
ReportThreatId id = 1;
|
||||
string instanceId = 2; // Unique threat instance ID per (ID, SubjectType, Subject) tuple
|
||||
string message = 3;
|
||||
SubjectType subject_type = 4;
|
||||
string subject = 5;
|
||||
Confidence confidence = 6;
|
||||
Source source = 7;
|
||||
string source_id = 8;
|
||||
}
|
||||
|
||||
message PackageManifestReport {
|
||||
string id = 1;
|
||||
Ecosystem ecosystem = 2;
|
||||
string path = 3;
|
||||
repeated ReportThreat threats = 4;
|
||||
string display_path = 5;
|
||||
string source_type = 6;
|
||||
string namespace = 7;
|
||||
}
|
||||
|
||||
// PackageReport represents the first class entity for which we have different type
|
||||
// of reporting information
|
||||
message PackageReport {
|
||||
Package package = 1;
|
||||
|
||||
// The manifests identified by IDs where this package belongs to
|
||||
repeated string manifests = 2;
|
||||
|
||||
repeated Violation violations = 3;
|
||||
repeated RemediationAdvice advices = 4;
|
||||
|
||||
// Insights data
|
||||
repeated InsightVulnerability vulnerabilities = 5;
|
||||
repeated InsightLicenseInfo licenses = 6;
|
||||
repeated InsightProjectInfo projects = 8;
|
||||
|
||||
// Threats
|
||||
repeated ReportThreat threats = 7;
|
||||
}
|
||||
|
||||
message ReportMeta {
|
||||
string tool_name = 1;
|
||||
string tool_version = 2;
|
||||
string created_at = 3;
|
||||
}
|
||||
|
||||
message Report {
|
||||
ReportMeta meta = 1;
|
||||
|
||||
repeated PackageManifestReport manifests = 2;
|
||||
repeated PackageReport packages = 3;
|
||||
}
|
||||
35
api/models.proto
Normal file
@ -0,0 +1,35 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/safedep/vet/gen/models";
|
||||
|
||||
// Core data models on which `vet` operations. This should eventually
|
||||
// become source of truth and we should remove the model definitions in
|
||||
// Go code `models.go` and instead generate code from here
|
||||
|
||||
enum Ecosystem {
|
||||
UNKNOWN_ECOSYSTEM = 0;
|
||||
Maven = 1;
|
||||
RubyGems = 2;
|
||||
Go = 3;
|
||||
Npm = 4;
|
||||
PyPI = 5;
|
||||
Cargo = 6;
|
||||
NuGet = 7;
|
||||
Packagist = 8;
|
||||
Hex = 9;
|
||||
Pub = 10;
|
||||
CycloneDxSBOM = 11;
|
||||
SpdxSBOM = 12;
|
||||
}
|
||||
|
||||
message Package {
|
||||
Ecosystem ecosystem = 1;
|
||||
string name = 2;
|
||||
string version = 3;
|
||||
}
|
||||
|
||||
message PackageManifest {
|
||||
Ecosystem ecosystem = 1;
|
||||
string path = 2;
|
||||
repeated Package packages = 3;
|
||||
}
|
||||
15
api/violations.proto
Normal file
@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/safedep/vet/gen/violations";
|
||||
|
||||
import "models.proto";
|
||||
import "checks.proto";
|
||||
import "filter_suite_spec.proto";
|
||||
|
||||
message Violation {
|
||||
CheckType check_type = 1;
|
||||
Package package = 2;
|
||||
Filter filter = 3;
|
||||
|
||||
map<string, string> extra = 4;
|
||||
}
|
||||
111
auth.go
@ -1,36 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/command"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
authInsightApiBaseUrl string
|
||||
authControlPlaneApiBaseUrl string
|
||||
authTrialEmail string
|
||||
)
|
||||
var authTenantDomain string
|
||||
|
||||
func newAuthCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Configure and verify Insights API authentication",
|
||||
Short: "Configure vet authentication",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("You must choose an appropriate command: configure, verify\n")
|
||||
os.Exit(1)
|
||||
return nil
|
||||
return errors.New("a valid sub-command is required")
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(configureAuthCommand())
|
||||
cmd.AddCommand(verifyAuthCommand())
|
||||
cmd.AddCommand(trialsRegisterCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@ -39,73 +34,73 @@ func configureAuthCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "configure",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Print("Enter API Key: ")
|
||||
key, err := term.ReadPassword(syscall.Stdin)
|
||||
var key string
|
||||
var err error
|
||||
|
||||
err = survey.AskOne(&survey.Password{
|
||||
Message: "Enter the API key",
|
||||
}, &key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
logger.Fatalf("Failed to setup auth: %v", err)
|
||||
}
|
||||
|
||||
err = auth.Configure(auth.Config{
|
||||
ApiUrl: authInsightApiBaseUrl,
|
||||
ApiKey: string(key),
|
||||
})
|
||||
if auth.TenantDomain() != "" && auth.TenantDomain() != authTenantDomain {
|
||||
ui.PrintWarning("Tenant domain mismatch. Existing: %s, New: %s, continue? ",
|
||||
auth.TenantDomain(), authTenantDomain)
|
||||
|
||||
var confirm bool
|
||||
err = survey.AskOne(&survey.Confirm{
|
||||
Message: "Do you want to continue?",
|
||||
}, &confirm)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
logger.Fatalf("Failed to setup auth: %v", err)
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
if !confirm {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
auth.SetRuntimeCloudTenant(authTenantDomain)
|
||||
auth.SetRuntimeApiKey(key)
|
||||
|
||||
err = auth.Verify()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to verify auth: %v", err)
|
||||
}
|
||||
|
||||
err = auth.PersistApiKey(key, authTenantDomain)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to configure auth: %v", err)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&authInsightApiBaseUrl, "api", "", auth.DefaultApiUrl(),
|
||||
"Base URL of Insights API")
|
||||
cmd.Flags().StringVarP(&authTenantDomain, "tenant", "", "",
|
||||
"Tenant domain for SafeDep Cloud")
|
||||
|
||||
_ = cmd.MarkFlagRequired("tenant")
|
||||
|
||||
return cmd
|
||||
|
||||
}
|
||||
|
||||
func verifyAuthCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("Verify auth command is currently work in progress\n")
|
||||
os.Exit(1)
|
||||
if auth.CommunityMode() {
|
||||
ui.PrintSuccess("Running in Community Mode")
|
||||
}
|
||||
|
||||
command.FailOnError("auth/verify", auth.Verify())
|
||||
|
||||
ui.PrintSuccess("Authentication key is valid!")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func trialsRegisterCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "trial",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client := auth.NewTrialRegistrationClient(auth.TrialConfig{
|
||||
Email: authTrialEmail,
|
||||
ControlPlaneApiUrl: authControlPlaneApiBaseUrl,
|
||||
})
|
||||
|
||||
res, err := client.Execute()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Trial registration successful with Id:%s\n", res.Id)
|
||||
fmt.Printf("Check your email (%s) for API key and usage instructions\n", authTrialEmail)
|
||||
fmt.Printf("The trial API key will expire on %s\n", res.ExpiresAt.String())
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&authTrialEmail, "email", "", "",
|
||||
"Email address to use for sending trial API key")
|
||||
cmd.Flags().StringVarP(&authControlPlaneApiBaseUrl, "control-plane", "",
|
||||
auth.DefaultControlPlaneApiUrl(), "Base URL of Control Plane API for registrations")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
53
cmd/agent/common.go
Normal file
@ -0,0 +1,53 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
|
||||
"github.com/safedep/vet/agent"
|
||||
)
|
||||
|
||||
func buildModelFromEnvironment() (*agent.Model, error) {
|
||||
model, err := agent.BuildModelFromEnvironment(fastMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build LLM model adapter using environment configuration: %w", err)
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func executeAgentPrompt(agentExecutor agent.Agent, session agent.Session, prompt string) error {
|
||||
output, err := agentExecutor.Execute(context.Background(), session, agent.Input{
|
||||
Query: prompt,
|
||||
}, agent.WithToolCallHook(func(ctx context.Context, session agent.Session, input agent.Input, toolName string, toolArgs string) error {
|
||||
os.Stderr.WriteString(fmt.Sprintf("Tool called: %s with args: %s\n", toolName, toolArgs))
|
||||
return nil
|
||||
}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute agent: %w", err)
|
||||
}
|
||||
|
||||
terminalRenderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(80),
|
||||
glamour.WithEmoji(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create glamour renderer: %w", err)
|
||||
}
|
||||
|
||||
rendered, err := terminalRenderer.Render(output.Answer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render answer: %w", err)
|
||||
}
|
||||
|
||||
_, err = os.Stdout.WriteString(rendered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write answer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
34
cmd/agent/main.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Package agent provides a CLI for running agents.
|
||||
package agent
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
var (
|
||||
maxAgentSteps int
|
||||
|
||||
// Use a fast model when available. Opinionated. Can be overridden by the
|
||||
// setting environment variables.
|
||||
fastMode bool
|
||||
|
||||
// User wants the agent to answer a single question and not start the
|
||||
// interactive agent. Not all agents may support this.
|
||||
singlePrompt string
|
||||
)
|
||||
|
||||
func NewAgentCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Run an available AI agent",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().IntVar(&maxAgentSteps, "max-steps", 30, "The maximum number of steps for the agent executor")
|
||||
cmd.PersistentFlags().StringVarP(&singlePrompt, "prompt", "p", "", "A single prompt to run the agent with")
|
||||
cmd.PersistentFlags().BoolVar(&fastMode, "fast", false, "Prefer a fast model when available (compromises on advanced reasoning)")
|
||||
|
||||
cmd.AddCommand(newQueryAgentCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
107
cmd/agent/query.go
Normal file
@ -0,0 +1,107 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/agent"
|
||||
"github.com/safedep/vet/internal/analytics"
|
||||
"github.com/safedep/vet/internal/command"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
//go:embed query_prompt.md
|
||||
var querySystemPrompt string
|
||||
|
||||
var queryAgentDBPath string
|
||||
|
||||
func newQueryAgentCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "Query agent allows analysis and querying the vet sqlite3 report database",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeQueryAgent()
|
||||
if err != nil {
|
||||
logger.Errorf("failed to execute query agent: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&queryAgentDBPath, "db", "", "The path to the vet sqlite3 report database")
|
||||
|
||||
_ = cmd.MarkFlagRequired("db")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func executeQueryAgent() error {
|
||||
analytics.TrackAgentQuery()
|
||||
|
||||
toolBuilder, err := agent.NewMcpClientToolBuilder(agent.McpClientToolBuilderConfig{
|
||||
ClientName: "vet-query-agent",
|
||||
ClientVersion: command.GetVersion(),
|
||||
SkipDefaultTools: true,
|
||||
SQLQueryToolEnabled: true,
|
||||
SQLQueryToolDBPath: queryAgentDBPath,
|
||||
PackageRegistryToolEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create MCP client tool builder: %w", err)
|
||||
}
|
||||
|
||||
tools, err := toolBuilder.Build(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build tools: %w", err)
|
||||
}
|
||||
|
||||
model, err := buildModelFromEnvironment()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build LLM model adapter using environment configuration: %w", err)
|
||||
}
|
||||
|
||||
agentExecutor, err := agent.NewReactQueryAgent(model.Client, agent.ReactQueryAgentConfig{
|
||||
MaxSteps: maxAgentSteps,
|
||||
SystemPrompt: querySystemPrompt,
|
||||
}, agent.WithTools(tools))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create agent: %w", err)
|
||||
}
|
||||
|
||||
memory, err := agent.NewSimpleMemory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create memory: %w", err)
|
||||
}
|
||||
|
||||
session, err := agent.NewSession(memory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
if singlePrompt != "" {
|
||||
err = executeAgentPrompt(agentExecutor, session, singlePrompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute agent prompt: %w", err)
|
||||
}
|
||||
} else {
|
||||
|
||||
uiConfig := agent.DefaultAgentUIConfig()
|
||||
uiConfig.TitleText = "🔍 Query Agent - Interactive Query Mode"
|
||||
uiConfig.TextInputPlaceholder = "Ask me anything about your scan data..."
|
||||
uiConfig.InitialSystemMessage = "🤖 Query Agent initialized. Ask me anything about your dependencies, vulnerabilities and other supply chain risks."
|
||||
uiConfig.ModelName = model.Name
|
||||
uiConfig.ModelVendor = model.Vendor
|
||||
uiConfig.ModelFast = model.Fast
|
||||
|
||||
err = agent.StartUIWithConfig(agentExecutor, session, uiConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start agent interaction UI: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
57
cmd/agent/query_prompt.md
Normal file
@ -0,0 +1,57 @@
|
||||
Your task is to assist the user in finding useful information from vet scan results
|
||||
available in an sqlite3 database.
|
||||
|
||||
To answer user's query, you MUST do the following:
|
||||
|
||||
1. **Schema Discovery**: Use the database schema introspection tool to understand the available tables, columns, and relationships
|
||||
2. **Query Planning**: Analyze the user's question and plan your approach:
|
||||
- Identify which tables contain the relevant data
|
||||
- Determine the relationships between tables needed
|
||||
- Plan the query structure before writing SQL
|
||||
3. **Query Execution**: Execute your planned query using the database query tool
|
||||
4. **Result Validation**: Verify the results make sense and answer the user's question
|
||||
5. **Response Formatting**: Present findings in clear markdown format
|
||||
|
||||
GUIDELINES:
|
||||
|
||||
* **Query Best Practices**:
|
||||
- Always use `COUNT(*)` instead of `SELECT *` when determining table sizes
|
||||
- Use `LIMIT` and `OFFSET` for pagination with large result sets
|
||||
- Prefer JOINs over subqueries for better performance
|
||||
- Use aggregate functions (COUNT, SUM, AVG) for statistical queries
|
||||
|
||||
* **Data Integrity**:
|
||||
- NEVER make assumptions about data that you haven't verified through queries
|
||||
- If a query returns unexpected results, re-examine your approach
|
||||
- Always check for NULL values and handle them appropriately
|
||||
- Validate that your query logic matches the user's intent
|
||||
|
||||
* **Error Handling**:
|
||||
- If a query fails, explain the error and try an alternative approach
|
||||
- If no data is found, clearly state this rather than making assumptions
|
||||
- When data seems incomplete, acknowledge limitations in your response
|
||||
|
||||
IMPORTANT CONSTRAINTS:
|
||||
|
||||
* **Prevent Hallucinations**:
|
||||
- Only report data that you have actually queried from the database
|
||||
- NEVER invent or assume data points that weren't returned by your queries
|
||||
- If you're unsure about a result, query the data again to confirm
|
||||
- Always distinguish between actual data and your interpretation of it
|
||||
|
||||
* **User Interaction**:
|
||||
- Ask for clarification if the user's query is ambiguous
|
||||
- Provide context about what the data represents (e.g., "This shows vulnerabilities found in your dependencies")
|
||||
- If you cannot answer with available data, explain what information is missing
|
||||
|
||||
* **Response Format**:
|
||||
- Present tabular data as markdown tables with appropriate headers
|
||||
- Include summary statistics when relevant (e.g., "Found 15 vulnerabilities across 8 packages")
|
||||
- Use clear headings to organize complex responses
|
||||
- Always explain what the data means in the context of security scanning
|
||||
|
||||
* **Domain Context**:
|
||||
- Remember that vet scans analyze software dependencies for security issues
|
||||
- Common entities include: packages, vulnerabilities, licenses, malware, scorecards
|
||||
- Explain technical terms that may be unfamiliar to users
|
||||
|
||||
191
cmd/cloud/key.go
Normal file
@ -0,0 +1,191 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/cloud"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
keyName string
|
||||
keyDescription string
|
||||
keyExpiresIn int
|
||||
|
||||
listKeysName string
|
||||
listKeysIncludeExpired bool
|
||||
listKeysOnlyMine bool
|
||||
|
||||
deleteKeyId string
|
||||
)
|
||||
|
||||
func newKeyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "key",
|
||||
Short: "Manage API keys",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newKeyCreateCommand())
|
||||
cmd.AddCommand(newListKeyCommand())
|
||||
cmd.AddCommand(newDeleteKeyCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDeleteKeyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete an API key",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeDeleteKey()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to delete API key: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&deleteKeyId, "id", "", "ID of the API key to delete")
|
||||
_ = cmd.MarkFlagRequired("id")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func executeDeleteKey() error {
|
||||
client, err := auth.ControlPlaneClientConnection("vet-cloud-key-delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyService, err := cloud.NewApiKeyService(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = keyService.DeleteKey(deleteKeyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintSuccess("API key deleted successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newListKeyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List API keys",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeListKeys()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to list API keys: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&listKeysName, "name", "",
|
||||
"List keys with partial match on the name")
|
||||
cmd.Flags().BoolVar(&listKeysIncludeExpired, "include-expired", false,
|
||||
"Include expired keys in the list")
|
||||
cmd.Flags().BoolVar(&listKeysOnlyMine, "only-mine", false,
|
||||
"List only keys created by the current user")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func executeListKeys() error {
|
||||
client, err := auth.ControlPlaneClientConnection("vet-cloud-key-list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyService, err := cloud.NewApiKeyService(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keys, err := keyService.ListKeys(&cloud.ListApiKeyRequest{
|
||||
Name: listKeysName,
|
||||
IncludeExpired: listKeysIncludeExpired,
|
||||
OnlyMine: listKeysOnlyMine,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(keys.Keys) == 0 {
|
||||
ui.PrintSuccess("No API keys found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
tbl := ui.NewTabler(ui.TablerConfig{})
|
||||
tbl.AddHeader("ID", "Name", "Expires At", "Description")
|
||||
|
||||
for _, key := range keys.Keys {
|
||||
expiresAt := key.ExpiresAt.In(time.Local).Format(time.RFC822)
|
||||
tbl.AddRow(key.ID, key.Name, expiresAt, key.Desc)
|
||||
}
|
||||
|
||||
return tbl.Finish()
|
||||
}
|
||||
|
||||
func newKeyCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new API key",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeCreateKey()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create API key: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&keyName, "name", "", "Name of the API key")
|
||||
cmd.Flags().StringVar(&keyDescription, "description", "", "Description of the API key")
|
||||
cmd.Flags().IntVar(&keyExpiresIn, "expires-in", 30,
|
||||
"Number of days after which the API key will expire")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func executeCreateKey() error {
|
||||
client, err := auth.ControlPlaneClientConnection("vet-cloud-key-create")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyService, err := cloud.NewApiKeyService(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := keyService.CreateApiKey(&cloud.CreateApiKeyRequest{
|
||||
Name: keyName,
|
||||
Desc: keyDescription,
|
||||
ExpiryInDays: keyExpiresIn,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintSuccess("API key created successfully.")
|
||||
ui.PrintSuccess("Key: %s", key.Key)
|
||||
ui.PrintSuccess("Expires at: %s", key.ExpiresAt.Format(time.RFC3339))
|
||||
|
||||
return nil
|
||||
}
|
||||
68
cmd/cloud/login.go
Normal file
@ -0,0 +1,68 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cli/oauth/api"
|
||||
"github.com/cli/oauth/device"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
func newCloudLoginCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Login to SafeDep cloud for management tasks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeCloudLogin()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to login to the SafeDep cloud: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func executeCloudLogin() error {
|
||||
token, err := executeDeviceAuthFlow()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute device auth flow: %w", err)
|
||||
}
|
||||
|
||||
return auth.PersistCloudTokens(token.Token,
|
||||
token.RefreshToken, tenantDomain)
|
||||
}
|
||||
|
||||
func executeDeviceAuthFlow() (*api.AccessToken, error) {
|
||||
code, err := device.RequestCode(http.DefaultClient,
|
||||
auth.CloudIdentityServiceDeviceCodeUrl(),
|
||||
auth.CloudIdentityServiceClientId(),
|
||||
[]string{"offline_access", "openid", "profile", "email"},
|
||||
device.WithAudience(auth.CloudIdentityServiceAudience()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request device code: %w", err)
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Please visit %s and enter the code %s to authenticate",
|
||||
code.VerificationURIComplete, code.UserCode)
|
||||
|
||||
token, err := device.Wait(context.TODO(),
|
||||
http.DefaultClient, auth.CloudIdentityServiceTokenUrl(),
|
||||
device.WaitOptions{
|
||||
ClientID: auth.CloudIdentityServiceClientId(),
|
||||
DeviceCode: code,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
87
cmd/cloud/main.go
Normal file
@ -0,0 +1,87 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
)
|
||||
|
||||
var (
|
||||
tenantDomain string
|
||||
outputCSV string
|
||||
outputMarkdown string
|
||||
)
|
||||
|
||||
func NewCloudCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cloud",
|
||||
Short: "Manage and query cloud resources (control plane)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVar(&tenantDomain, "tenant", "",
|
||||
"Tenant domain to use for the command")
|
||||
|
||||
cmd.PersistentFlags().StringVar(&outputCSV, "csv", "",
|
||||
"Output table views to a CSV file")
|
||||
|
||||
cmd.PersistentFlags().StringVar(&outputMarkdown, "markdown", "",
|
||||
"Output table views to a Markdown file")
|
||||
|
||||
cmd.AddCommand(newCloudLoginCommand())
|
||||
cmd.AddCommand(newRegisterCommand())
|
||||
cmd.AddCommand(newCloudQuickstartCommand())
|
||||
|
||||
queryCmd := newQueryCommand()
|
||||
queryCmd.PreRunE = requireAccessTokenCheck
|
||||
|
||||
pingCmd := newPingCommand()
|
||||
pingCmd.PreRunE = requireAccessTokenCheck
|
||||
|
||||
whoamiCmd := newWhoamiCommand()
|
||||
whoamiCmd.PreRunE = requireAccessTokenCheck
|
||||
|
||||
keyCmd := newKeyCommand()
|
||||
keyCmd.PreRunE = requireAccessTokenCheck
|
||||
|
||||
cmd.AddCommand(queryCmd)
|
||||
cmd.AddCommand(pingCmd)
|
||||
cmd.AddCommand(whoamiCmd)
|
||||
cmd.AddCommand(keyCmd)
|
||||
|
||||
cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
if tenantDomain != "" {
|
||||
auth.SetRuntimeCloudTenant(tenantDomain)
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func requireAccessTokenCheck(cmd *cobra.Command, args []string) error {
|
||||
// Check if token was obtained/refreshed 5 mins ago
|
||||
// If > 5 mins, check the access token expiry
|
||||
// else return
|
||||
if auth.ShouldCheckAccessTokenExpiry() {
|
||||
// Check if access token is expired
|
||||
// If expired (ok), refresh the session
|
||||
if ok, err := auth.IsAccessTokenExpired(); err != nil {
|
||||
tenantDomainPlaceholder := auth.TenantDomain()
|
||||
if tenantDomainPlaceholder == "" {
|
||||
tenantDomainPlaceholder = "<your-tenant-domain>"
|
||||
}
|
||||
|
||||
ui.PrintError("Automatic token refresh failed, please re-login using `vet cloud login --tenant %s`", tenantDomainPlaceholder)
|
||||
return fmt.Errorf("failed to check access token expiry: %w", err)
|
||||
} else if ok {
|
||||
ui.PrintMsg("Refreshing Access Token")
|
||||
return auth.RefreshCloudSession()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
50
cmd/cloud/ping.go
Normal file
@ -0,0 +1,50 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/cloud"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
func newPingCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ping",
|
||||
Short: "Ping the control plane to check authentication and connectivity",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := pingControlPlane()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to ping control plane: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pingControlPlane() error {
|
||||
conn, err := auth.ControlPlaneClientConnection("vet-cloud-ping")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pingService, err := cloud.NewPingService(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pr, err := pingService.Ping()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Ping successful. Started at %s, finished at %s",
|
||||
pr.StartedAt.Format(time.RFC3339), pr.FinishedAt.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
178
cmd/cloud/query.go
Normal file
@ -0,0 +1,178 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/cloud/query"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
querySql string
|
||||
queryPageSize int
|
||||
)
|
||||
|
||||
func newQueryCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "Query risks by executing SQL queries",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newQuerySchemaCommand())
|
||||
cmd.AddCommand(newQueryExecuteCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newQuerySchemaCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema",
|
||||
Short: "Get the schema for the query service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := getQuerySchema()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get query schema: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newQueryExecuteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "execute",
|
||||
Short: "Execute a query",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeQuery()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to execute query: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&querySql, "sql", "s", "", "SQL query to execute")
|
||||
cmd.Flags().IntVarP(&queryPageSize, "limit", "", 100, "Limit the number of results returned")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func getQuerySchema() error {
|
||||
client, err := auth.ControlPlaneClientConnection("vet-cloud-query")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
queryService, err := query.NewQueryService(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := queryService.GetSchema()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tbl := ui.NewTabler(ui.TablerConfig{
|
||||
CsvPath: outputCSV,
|
||||
MarkdownPath: outputMarkdown,
|
||||
})
|
||||
|
||||
tbl.AddHeader("Name", "Column Name", "Selectable", "Filterable", "Reference")
|
||||
|
||||
schemas := response.GetSchemas()
|
||||
for _, schema := range schemas {
|
||||
schemaName := schema.GetName()
|
||||
columns := schema.GetColumns()
|
||||
|
||||
sort.Slice(columns, func(i, j int) bool {
|
||||
return columns[i].GetName() < columns[j].GetName()
|
||||
})
|
||||
|
||||
for _, column := range columns {
|
||||
tbl.AddRow(schemaName,
|
||||
column.GetName(),
|
||||
column.GetSelectable(),
|
||||
column.GetFilterable(),
|
||||
column.GetReferenceUrl())
|
||||
}
|
||||
}
|
||||
|
||||
return tbl.Finish()
|
||||
}
|
||||
|
||||
func executeQuery() error {
|
||||
if querySql == "" {
|
||||
return errors.New("SQL string is required")
|
||||
}
|
||||
|
||||
client, err := auth.ControlPlaneClientConnection("vet-cloud-query")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
queryService, err := query.NewQueryService(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := queryService.ExecuteSql(querySql, queryPageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return renderQueryResponseAsTable(response)
|
||||
}
|
||||
|
||||
func renderQueryResponseAsTable(response *query.QueryResponse) error {
|
||||
tbl := ui.NewTabler(ui.TablerConfig{
|
||||
CsvPath: outputCSV,
|
||||
MarkdownPath: outputMarkdown,
|
||||
})
|
||||
|
||||
if response.Count() == 0 {
|
||||
logger.Infof("No results found")
|
||||
return nil
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Query returned %d results", response.Count())
|
||||
|
||||
// Header
|
||||
headers := []string{}
|
||||
response.GetRow(0).ForEachField(func(key string, _ interface{}) {
|
||||
headers = append(headers, key)
|
||||
})
|
||||
|
||||
sort.Strings(headers)
|
||||
|
||||
headerRow := []interface{}{}
|
||||
for _, header := range headers {
|
||||
headerRow = append(headerRow, header)
|
||||
}
|
||||
|
||||
tbl.AddHeader(headerRow...)
|
||||
|
||||
// Ensure we have a consistent order of columns
|
||||
response.ForEachRow(func(row *query.QueryRow) {
|
||||
rowValues := []interface{}{}
|
||||
for _, header := range headers {
|
||||
rowValues = append(rowValues, row.GetField(header))
|
||||
}
|
||||
|
||||
tbl.AddRow(rowValues...)
|
||||
})
|
||||
|
||||
return tbl.Finish()
|
||||
}
|
||||
314
cmd/cloud/quickstart.go
Normal file
@ -0,0 +1,314 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
controltowerv1pb "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1"
|
||||
controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/cloud"
|
||||
)
|
||||
|
||||
func newCloudQuickstartCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "quickstart",
|
||||
Short: "Quick onboarding to SafeDep Cloud and cli setup",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeCloudQuickstart()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// executeCloudQuickstart executes an opinionated quick start flow for the user
|
||||
// with the goal of least friction on-boarding to SafeDep Cloud and configuring
|
||||
// the cli with everything required to start using SafeDep Cloud services.
|
||||
func executeCloudQuickstart() error {
|
||||
ui.PrintMsg("🚀 Starting SafeDep Cloud Quickstart...")
|
||||
ui.PrintMsg("👋 Hello! Let's get you onboarded..")
|
||||
|
||||
// This will execute cloud authentication flow and persist the cloud tokens
|
||||
// in the local config file.
|
||||
if err := quickStartAuthentication(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Here we create a connection to the control plane with cloud token. This
|
||||
// connection may not be multi-tenant because user may not have any tenants
|
||||
// yet.
|
||||
conn, err := quickStartCreateConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Here we check if the user has any tenants. If not, we create a new one.
|
||||
userInfo, err := quickStartTenantSetup(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Here we get the tenant from the user info. The tenant domain is stored
|
||||
// in the local config file.
|
||||
tenant, err := quickStartSetupTenantFromAccess(userInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintMsg("✅ Your tenant is set to: %s", tenant.GetDomain())
|
||||
|
||||
// Close the previous connection
|
||||
if err := conn.Close(); err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while closing cloud connection: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Here we re-create the connection because we need a multi-tenant connection
|
||||
conn, err = quickStartCreateConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := quickStartAPIKeyCreation(conn, tenant); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintMsg("✅ All done!")
|
||||
ui.PrintMsg("")
|
||||
ui.PrintMsg("🎉 You are all set! You can now start using SafeDep Cloud")
|
||||
|
||||
// TODO: We need the ability to auto-detect the project name and version
|
||||
// and then use that to sync the results to SafeDep Cloud
|
||||
ui.PrintMsg("✨ Run `vet scan -D /path/to/code --report-sync` to scan your code and sync the results to SafeDep Cloud")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func quickStartSetupTenantFromAccess(userInfo *controltowerv1.GetUserInfoResponse) (*controltowerv1pb.Tenant, error) {
|
||||
if len(userInfo.GetAccess()) == 0 {
|
||||
ui.PrintError("❌ Oops! This is weird, you should have access to at least one tenant. Please contact support.")
|
||||
return nil, fmt.Errorf("no tenant access")
|
||||
}
|
||||
|
||||
// If user has access to multiple tenants
|
||||
// Ask user about which tenant they want to use if they have more than one
|
||||
var tenant *controltowerv1pb.Tenant
|
||||
if len(userInfo.GetAccess()) > 1 {
|
||||
// Print all tenants with index
|
||||
var tenantOptions []string
|
||||
ui.PrintMsg("🔍 You have access to the following tenants:")
|
||||
for idx, tenant := range userInfo.GetAccess() {
|
||||
ui.PrintMsg("%s", fmt.Sprintf(" - [%d] %s", idx, tenant.GetTenant().GetDomain()))
|
||||
tenantOptions = append(tenantOptions, tenant.GetTenant().GetDomain())
|
||||
}
|
||||
|
||||
// Ask user which tenant they want to use
|
||||
var tenantIndex int
|
||||
err := survey.AskOne(&survey.Select{
|
||||
Message: "🔍 Which tenant do you want to use?",
|
||||
Options: tenantOptions,
|
||||
}, &tenantIndex)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while asking which tenant to use: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tenant = userInfo.GetAccess()[tenantIndex].GetTenant()
|
||||
} else {
|
||||
tenant = userInfo.GetAccess()[0].GetTenant()
|
||||
}
|
||||
|
||||
if err := auth.PersistTenantDomain(tenant.GetDomain()); err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while persisting your tenant domain: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func quickStartAuthentication() error {
|
||||
ui.PrintMsg("🔑 Start by creating an account or sign-in to your existing account")
|
||||
|
||||
token, err := executeDeviceAuthFlow()
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while authenticating you: %s", err.Error())
|
||||
ui.PrintMsg("ℹ️ If you are using email and password, ensure your email is verified.")
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintSuccess("✅ Successfully authenticated you!")
|
||||
|
||||
ui.PrintMsg("🔑 Saving your cloud credentials in your local config...")
|
||||
if err := auth.PersistCloudTokens(token.Token, token.RefreshToken, ""); err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while saving your cloud credentials: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintSuccess("✅ Successfully saved your cloud credentials!")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func quickStartCreateConnection() (*grpc.ClientConn, error) {
|
||||
conn, err := auth.ControlPlaneClientConnection("vet-cloud-quickstart")
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while creating cloud connection: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func quickStartTenantSetup(conn *grpc.ClientConn) (*controltowerv1.GetUserInfoResponse, error) {
|
||||
ui.PrintMsg("🔍 Checking if you have an existing tenant...")
|
||||
|
||||
userService, err := cloud.NewUserService(conn)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while creating user service: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userInfo, err := userService.CurrentUserInfo()
|
||||
if err != nil {
|
||||
return quickStartCreateNewTenant(conn)
|
||||
}
|
||||
|
||||
ui.PrintMsg("✅ You are already registered with SafeDep Cloud")
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func quickStartCreateNewTenant(conn *grpc.ClientConn) (*controltowerv1.GetUserInfoResponse, error) {
|
||||
ui.PrintMsg("📝 Looks like you don't have an existing tenant. Let's create one for you...")
|
||||
|
||||
userName, domain, err := quickStartGetTenantInputs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
onboardingService, err := cloud.NewOnboardingService(conn)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while creating onboarding service: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = onboardingService.Register(&cloud.RegisterRequest{
|
||||
Name: userName,
|
||||
Email: registerEmail,
|
||||
OrgName: "Quickstart Organization",
|
||||
OrgDomain: domain,
|
||||
})
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while registering your tenant: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ui.PrintSuccess("✅ Successfully created a new tenant!")
|
||||
ui.PrintMsg("🔑 Please wait while we get you onboarded...")
|
||||
|
||||
userService, err := cloud.NewUserService(conn)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while creating user service: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userService.CurrentUserInfo()
|
||||
}
|
||||
|
||||
func quickStartGetTenantInputs() (string, string, error) {
|
||||
var userName string
|
||||
err := survey.AskOne(&survey.Input{
|
||||
Message: "👤 What should we call you?",
|
||||
Default: "John Doe",
|
||||
}, &userName)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while asking for your name: %s", err.Error())
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
autoDomain := fmt.Sprintf("quickstart-%s", time.Now().Format("20060102150405"))
|
||||
var domain string
|
||||
err = survey.AskOne(&survey.Input{
|
||||
Message: "📝 We have automatically generated a domain for you. Here is your chance to update",
|
||||
Default: autoDomain,
|
||||
}, &domain)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while asking for your domain: %s", err.Error())
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if domain == "" {
|
||||
domain = autoDomain
|
||||
}
|
||||
|
||||
return userName, domain, nil
|
||||
}
|
||||
|
||||
func quickStartAPIKeyCreation(conn *grpc.ClientConn, tenant *controltowerv1pb.Tenant) error {
|
||||
var createAPIKey bool
|
||||
err := survey.AskOne(&survey.Confirm{
|
||||
Message: "🔑 Do you want to create a new API key for this tenant?",
|
||||
Default: true,
|
||||
}, &createAPIKey)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while asking if you want to create an API key: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if !createAPIKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
var showAPIKey bool
|
||||
err = survey.AskOne(&survey.Confirm{
|
||||
Message: "Would you like to see the API key in addition to configuring it?",
|
||||
Default: true,
|
||||
}, &showAPIKey)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while asking about showing the API key: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
createApiKeyService, err := cloud.NewApiKeyService(conn)
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while creating the API key service: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
apiKey, err := createApiKeyService.CreateApiKey(&cloud.CreateApiKeyRequest{
|
||||
Name: fmt.Sprintf("Quick Start API Key: %s", time.Now().Format("20060102150405")),
|
||||
Desc: "This is a quick start API key created for you by vet",
|
||||
ExpiryInDays: 30,
|
||||
})
|
||||
if err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while creating the API key: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth.PersistApiKey(apiKey.Key, tenant.GetDomain()); err != nil {
|
||||
ui.PrintError("❌ Oops! Something went wrong while persisting the API key: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if showAPIKey {
|
||||
ui.PrintMsg("✅ Here is your API key: %s", text.BgGreen.Sprint(apiKey.Key))
|
||||
ui.PrintMsg("🔒 Your key will expire on: %s", apiKey.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
ui.PrintMsg("ℹ️ Your tenant domain is: %s", text.BgGreen.Sprint(tenant.GetDomain()))
|
||||
ui.PrintMsg("🔑 Please save this API key in a secure location, it will not be shown again.")
|
||||
|
||||
return nil
|
||||
}
|
||||
77
cmd/cloud/register.go
Normal file
@ -0,0 +1,77 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/cloud"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
registerEmail string
|
||||
registerName string
|
||||
registerOrgName string
|
||||
registerOrgDomain string
|
||||
)
|
||||
|
||||
func newRegisterCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Register a new user and tenant",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := registerUserTenant()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to register user: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(®isterEmail, "email", "cloud@safedep.io", "Email of the user (not required for SafeDep cloud)")
|
||||
cmd.Flags().StringVar(®isterName, "name", "", "Name of the user")
|
||||
cmd.Flags().StringVar(®isterOrgName, "org-name", "", "Name of the organization")
|
||||
cmd.Flags().StringVar(®isterOrgDomain, "org-domain", "", "Domain of the organization")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("org-name")
|
||||
_ = cmd.MarkFlagRequired("org-domain")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func registerUserTenant() error {
|
||||
conn, err := auth.ControlPlaneClientConnection("vet-cloud-register")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
onboardingService, err := cloud.NewOnboardingService(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := onboardingService.Register(&cloud.RegisterRequest{
|
||||
Name: registerName,
|
||||
Email: registerEmail,
|
||||
OrgName: registerOrgName,
|
||||
OrgDomain: registerOrgDomain,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Registered user and tenant.")
|
||||
ui.PrintSuccess("Tenant domain: %s", res.TenantDomain)
|
||||
|
||||
err = auth.PersistTenantDomain(res.TenantDomain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to persist tenant domain: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
63
cmd/cloud/whoami.go
Normal file
@ -0,0 +1,63 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/cloud"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
func newWhoamiCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Print information about the current user",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeWhoami()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to execute whoami: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func executeWhoami() error {
|
||||
conn, err := auth.ControlPlaneClientConnection("vet-cloud-whoami")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userService, err := cloud.NewUserService(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := userService.CurrentUserInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tbl := ui.NewTabler(ui.TablerConfig{})
|
||||
|
||||
tbl.AddHeader("Email", "Tenant", "Access Level")
|
||||
for _, access := range res.GetAccess() {
|
||||
accessName := "UNSPECIFIED"
|
||||
if name, ok := controltowerv1.AccessLevel_name[int32(access.GetLevel())]; ok {
|
||||
accessName = name
|
||||
}
|
||||
|
||||
tbl.AddRow(res.GetUser().GetEmail(),
|
||||
access.GetTenant().GetDomain(),
|
||||
fmt.Sprintf("%s (%d)", accessName, access.GetRole()))
|
||||
}
|
||||
|
||||
return tbl.Finish()
|
||||
}
|
||||
33
cmd/code/lang.go
Normal file
@ -0,0 +1,33 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"github.com/safedep/code/core"
|
||||
"github.com/safedep/code/lang"
|
||||
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
func getAllLanguageCodeStrings() ([]string, error) {
|
||||
langs, err := lang.AllLanguages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var languageCodes []string
|
||||
for _, lang := range langs {
|
||||
languageCodes = append(languageCodes, string(lang.Meta().Code))
|
||||
}
|
||||
return languageCodes, nil
|
||||
}
|
||||
|
||||
func getLanguagesFromCodes(languageCodes []string) ([]core.Language, error) {
|
||||
var languages []core.Language
|
||||
for _, languageCode := range languageCodes {
|
||||
language, err := lang.GetLanguage(languageCode)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to get language for code %s: %v", languageCode, err)
|
||||
return nil, err
|
||||
}
|
||||
languages = append(languages, language)
|
||||
}
|
||||
return languages, nil
|
||||
}
|
||||
28
cmd/code/main.go
Normal file
@ -0,0 +1,28 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/command"
|
||||
)
|
||||
|
||||
var languageCodes []string
|
||||
|
||||
func NewCodeCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "code",
|
||||
Short: "Analyze source code",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
defaultAllLanguageCodes, err := getAllLanguageCodeStrings()
|
||||
command.FailOnError("setup-default-languages", err)
|
||||
|
||||
cmd.PersistentFlags().StringArrayVar(&languageCodes, "lang", defaultAllLanguageCodes, "Source code languages to analyze")
|
||||
|
||||
cmd.AddCommand(newScanCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
96
cmd/code/scan.go
Normal file
@ -0,0 +1,96 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/command"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/code"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
"github.com/safedep/vet/pkg/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
dbPath string
|
||||
appDirs []string
|
||||
importDirs []string
|
||||
excludePatterns []string
|
||||
skipDependencyUsagePlugin bool
|
||||
)
|
||||
|
||||
func newScanCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "scan",
|
||||
Short: "Scan source code",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
startScan()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&dbPath, "db", "", "Path to create the sqlite database")
|
||||
cmd.Flags().StringArrayVar(&appDirs, "app", []string{"."}, "Directories to scan for application code files")
|
||||
cmd.Flags().StringArrayVar(&importDirs, "import-dir", []string{}, "Directories to scan for import files")
|
||||
cmd.Flags().StringArrayVarP(&excludePatterns, "exclude", "", []string{},
|
||||
"Name patterns to ignore while scanning a codebase")
|
||||
cmd.Flags().BoolVar(&skipDependencyUsagePlugin, "skip-dependency-usage-plugin", false, "Skip dependency usage plugin analysis")
|
||||
|
||||
_ = cmd.MarkFlagRequired("db")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func startScan() {
|
||||
command.FailOnError("scan", internalStartScan())
|
||||
}
|
||||
|
||||
func internalStartScan() error {
|
||||
allowedLanguages, err := getLanguagesFromCodes(languageCodes)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to get languages from codes: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
entSqliteStorage, err := storage.NewEntSqliteStorage(storage.EntSqliteClientConfig{
|
||||
Path: dbPath,
|
||||
ReadOnly: false,
|
||||
SkipSchemaCreation: false,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to create ent sqlite storage: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
excludePatternsRegexps := []*regexp.Regexp{}
|
||||
for _, pattern := range excludePatterns {
|
||||
excludePatternsRegexps = append(excludePatternsRegexps, regexp.MustCompile(pattern))
|
||||
}
|
||||
|
||||
codeScanner, err := code.NewScanner(code.ScannerConfig{
|
||||
AppDirectories: appDirs,
|
||||
ImportDirectories: importDirs,
|
||||
ExcludePatterns: excludePatternsRegexps,
|
||||
Languages: allowedLanguages,
|
||||
SkipDependencyUsagePlugin: skipDependencyUsagePlugin,
|
||||
Callbacks: &code.ScannerCallbackRegistry{
|
||||
OnScanStart: func() error {
|
||||
ui.StartSpinner("Scanning code")
|
||||
return nil
|
||||
},
|
||||
OnScanEnd: func() error {
|
||||
ui.StopSpinner()
|
||||
ui.PrintSuccess("🚀 Code scanning completed. Run vet scan with code context using --code flag")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}, entSqliteStorage)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to create code scanner: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return codeScanner.Scan(context.Background())
|
||||
}
|
||||
72
cmd/doc/generate.go
Normal file
@ -0,0 +1,72 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
)
|
||||
|
||||
var (
|
||||
// markdownOutDir is the output directory for markdown doc files
|
||||
markdownOutDir string
|
||||
|
||||
// manOutDir is the output directory for troff (man markup) doc files
|
||||
manOutDir string
|
||||
)
|
||||
|
||||
func newGenerateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate docs / manual artifacts",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// we specify the root (see, not parent) command since its the starting point for docs
|
||||
return runGenerateCommand(cmd.Root())
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVar(&markdownOutDir, "markdown", "", "The output directory for markdown doc files")
|
||||
cmd.PersistentFlags().StringVar(&manOutDir, "man", "", "The output directory for troff (man markup) doc files")
|
||||
|
||||
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
|
||||
// At least one of the output directory is required
|
||||
if markdownOutDir == "" && manOutDir == "" {
|
||||
return errors.New("no output directory specified, at least one of the output directory is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runGenerateCommand(rootCmd *cobra.Command) error {
|
||||
// If markdown directory is specified
|
||||
if markdownOutDir != "" {
|
||||
// Create Markdown Manual
|
||||
if err := doc.GenMarkdownTree(rootCmd, markdownOutDir); err != nil {
|
||||
return errors.Wrap(err, "failed to generate markdown manual")
|
||||
}
|
||||
|
||||
fmt.Println("Markdown manual doc created in: ", markdownOutDir)
|
||||
}
|
||||
|
||||
// If troff (man markup) directory is specified
|
||||
if manOutDir != "" {
|
||||
// Create Troff (man markup) Manual
|
||||
manHeader := &doc.GenManHeader{
|
||||
Title: "VET",
|
||||
Source: "SafeDep",
|
||||
Manual: "VET Manual",
|
||||
}
|
||||
|
||||
if err := doc.GenManTree(rootCmd, manHeader, manOutDir); err != nil {
|
||||
return errors.Wrap(err, "failed to generate man (troff) manual")
|
||||
}
|
||||
|
||||
fmt.Println("Troff (man markup) manual doc created in: ", manOutDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
17
cmd/doc/main.go
Normal file
@ -0,0 +1,17 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewDocCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doc",
|
||||
Short: "Documentation generation internal utilities",
|
||||
Hidden: true, // Hide from vet public commands and docs itself, since its only build utility
|
||||
}
|
||||
|
||||
cmd.AddCommand(newGenerateCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
20
cmd/inspect/main.go
Normal file
@ -0,0 +1,20 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewPackageInspectCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "inspect",
|
||||
Short: "Inspect an OSS package",
|
||||
Long: `Inspect an OSS package using deep inspection and analysis.
|
||||
This command will integrate with local and remote analysis services.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newPackageMalwareInspectCommand())
|
||||
return cmd
|
||||
}
|
||||
310
cmd/inspect/malware.go
Normal file
@ -0,0 +1,310 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"buf.build/gen/go/safedep/api/grpc/go/safedep/services/malysis/v1/malysisv1grpc"
|
||||
malysisv1pb "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/malysis/v1"
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/malysis/v1"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/safedep/dry/adapters"
|
||||
"github.com/safedep/dry/api/pb"
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/analytics"
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/common/registry"
|
||||
vetutils "github.com/safedep/vet/pkg/common/utils"
|
||||
"github.com/safedep/vet/pkg/malysis"
|
||||
"github.com/safedep/vet/pkg/reporter"
|
||||
)
|
||||
|
||||
var (
|
||||
malwareAnalysisPackageUrl string
|
||||
malwareAnalysisTimeout time.Duration
|
||||
malwareAnalysisReportJSON string
|
||||
malwareAnalysisReportOSV string
|
||||
malwareAnalysisNoWait bool
|
||||
|
||||
malwareReportOSVFinderName string
|
||||
malwareReportOSVContacts []string
|
||||
malwareReportOSVReferenceURL string
|
||||
malwareReportOSVUseRange bool
|
||||
)
|
||||
|
||||
func newPackageMalwareInspectCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "malware",
|
||||
Short: "Inspect an OSS package for malware",
|
||||
Long: `Inspect an OSS package for malware using SafeDep Malware Analysis API`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := executeMalwareAnalysis()
|
||||
if err != nil {
|
||||
ui.PrintError("Failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&malwareAnalysisPackageUrl, "purl", "",
|
||||
"Package URL to inspect for malware")
|
||||
cmd.Flags().DurationVar(&malwareAnalysisTimeout, "timeout", 5*time.Minute,
|
||||
"Timeout for malware analysis")
|
||||
cmd.Flags().StringVar(&malwareAnalysisReportJSON, "report-json", "",
|
||||
"Path to save malware analysis report in JSON format")
|
||||
cmd.Flags().StringVar(&malwareAnalysisReportOSV, "report-osv", "",
|
||||
"Dir path to save malware analysis report in OSV format and ossf/malicious-packages format")
|
||||
cmd.Flags().BoolVar(&malwareAnalysisNoWait, "no-wait", false,
|
||||
"Do not wait for malware analysis to complete")
|
||||
cmd.Flags().StringVar(&malwareReportOSVFinderName, "report-osv-finder-name", "",
|
||||
"Finder name for malware analysis report in OSV format")
|
||||
cmd.Flags().StringSliceVar(&malwareReportOSVContacts, "report-osv-contacts", []string{},
|
||||
"Contacts for malware analysis report in OSV format (URL, email, etc.)")
|
||||
cmd.Flags().StringVar(&malwareReportOSVReferenceURL, "report-osv-reference-url", "",
|
||||
"Custom reference URL for malware analysis report (defaults to app.safedep.io)")
|
||||
cmd.Flags().BoolVar(&malwareReportOSVUseRange, "report-osv-with-ranges", false,
|
||||
"Use range-based versioning in OSV report (default: use explicit versions)")
|
||||
|
||||
_ = cmd.MarkFlagRequired("purl")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func executeMalwareAnalysis() error {
|
||||
analytics.TrackCommandInspectMalwareAnalysis()
|
||||
|
||||
err := auth.Verify()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access to Malicious Package Analysis requires an API key. " +
|
||||
"For more details: https://docs.safedep.io/cloud/quickstart/")
|
||||
}
|
||||
|
||||
cc, err := auth.MalwareAnalysisClientConnection("malware-analysis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service := malysisv1grpc.NewMalwareAnalysisServiceClient(cc)
|
||||
|
||||
purl, err := pb.NewPurlPackageVersion(malwareAnalysisPackageUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
githubClient, err := adapters.NewGithubClient(adapters.DefaultGitHubClientConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GitHub client: %v", err)
|
||||
}
|
||||
|
||||
versionResolver, err := registry.NewPackageVersionResolver(githubClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create package version resolver: %v", err)
|
||||
}
|
||||
|
||||
packageVersion := purl.PackageVersion()
|
||||
|
||||
// If package version is empty or latest replace it with actual literal latest version
|
||||
// Reference: https://github.com/safedep/vet/issues/446
|
||||
if packageVersion.GetVersion() == "" || packageVersion.GetVersion() == "latest" {
|
||||
ui.PrintMsg("Resolving package version")
|
||||
version, err := versionResolver.ResolvePackageLatestVersion(purl.Ecosystem(), purl.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve package latest version: %v", err)
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Resolved package version: %s", version)
|
||||
packageVersion.Version = version
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancelFun := context.WithTimeout(ctx, malwareAnalysisTimeout)
|
||||
|
||||
defer cancelFun()
|
||||
|
||||
// For GitHub Actions packages, we need to resolve the commit hash
|
||||
if packageVersion.GetPackage().GetEcosystem() == packagev1.Ecosystem_ECOSYSTEM_GITHUB_ACTIONS {
|
||||
ui.PrintMsg("Resolving commit hash for GitHub Actions package")
|
||||
|
||||
commitHash, err := resolveGitHubActionsCommitHash(ctx, packageVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve commit hash for GitHub Actions package: %v", err)
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Resolved commit hash for GitHub Actions package: %s", commitHash)
|
||||
packageVersion.Version = commitHash
|
||||
}
|
||||
|
||||
analyzePackageResponse, err := service.AnalyzePackage(ctx, &malysisv1.AnalyzePackageRequest{
|
||||
Target: &malysisv1pb.PackageAnalysisTarget{
|
||||
PackageVersion: packageVersion,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit package for malware analysis: %v", err)
|
||||
}
|
||||
|
||||
ui.PrintMsg("Submitted package for malware analysis with ID: %s",
|
||||
analyzePackageResponse.GetAnalysisId())
|
||||
|
||||
if malwareAnalysisNoWait {
|
||||
return nil
|
||||
}
|
||||
|
||||
ui.StartSpinner("Waiting for malware analysis to complete")
|
||||
var report *malysisv1pb.Report
|
||||
var verificationRecord *malysisv1pb.VerificationRecord
|
||||
|
||||
for {
|
||||
reportResponse, err := service.GetAnalysisReport(ctx, &malysisv1.GetAnalysisReportRequest{
|
||||
AnalysisId: analyzePackageResponse.GetAnalysisId(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get malware analysis report: %v", err)
|
||||
}
|
||||
|
||||
if reportResponse.GetStatus() == malysisv1.AnalysisStatus_ANALYSIS_STATUS_FAILED {
|
||||
return fmt.Errorf("malware analysis failed: %s", reportResponse.GetErrorMessage())
|
||||
}
|
||||
|
||||
if reportResponse.GetStatus() == malysisv1.AnalysisStatus_ANALYSIS_STATUS_COMPLETED {
|
||||
report = reportResponse.GetReport()
|
||||
verificationRecord = reportResponse.GetVerificationRecord()
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
ui.StopSpinner()
|
||||
|
||||
if report == nil {
|
||||
return fmt.Errorf("malware analysis report is empty")
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Malware analysis completed successfully")
|
||||
|
||||
if malwareAnalysisReportJSON != "" {
|
||||
ui.PrintMsg("Generating JSON report")
|
||||
|
||||
err = writeJSONReport(report)
|
||||
if err != nil {
|
||||
ui.PrintError("Failed to render malware analysis report in JSON format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if malwareAnalysisReportOSV != "" {
|
||||
if !report.GetInference().GetIsMalware() {
|
||||
ui.PrintWarning("Report is not malware, skipping OSV report generation")
|
||||
return nil
|
||||
} else {
|
||||
ui.PrintMsg("Generating OSV report in: %s", malwareAnalysisReportOSV)
|
||||
|
||||
err = writeOSVReport(report)
|
||||
if err != nil {
|
||||
ui.PrintError("Failed to render malware analysis report in OSV format: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renderMalwareAnalysisReport(malwareAnalysisPackageUrl,
|
||||
analyzePackageResponse.GetAnalysisId(), report, verificationRecord)
|
||||
}
|
||||
|
||||
func writeOSVReport(report *malysisv1pb.Report) error {
|
||||
err := os.MkdirAll(malwareAnalysisReportOSV, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
generator, err := malysis.NewOpenSSFMaliciousPackageReportGenerator(malysis.OpenSSFMaliciousPackageReportGeneratorConfig{
|
||||
Dir: malwareAnalysisReportOSV,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OpenSSF malicious package report generator: %v", err)
|
||||
}
|
||||
|
||||
err = generator.GenerateReport(context.Background(), report, malysis.OpenSSFMaliciousPackageReportParams{
|
||||
FinderName: malwareReportOSVFinderName,
|
||||
Contacts: malwareReportOSVContacts,
|
||||
ReferenceURL: malwareReportOSVReferenceURL,
|
||||
UseRange: malwareReportOSVUseRange,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OpenSSF malicious package report: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONReport(report *malysisv1pb.Report) error {
|
||||
data, err := utils.ToPbJson(report, " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(malwareAnalysisReportJSON, []byte(data), 0o644)
|
||||
}
|
||||
|
||||
func renderMalwareAnalysisReport(purl string, analysisId string,
|
||||
report *malysisv1pb.Report, vr *malysisv1pb.VerificationRecord,
|
||||
) error {
|
||||
ui.PrintMsg("Malware analysis report for package: %s", purl)
|
||||
|
||||
tbl := table.NewWriter()
|
||||
tbl.SetOutputMirror(os.Stdout)
|
||||
tbl.SetStyle(table.StyleLight)
|
||||
|
||||
tbl.AppendHeader(table.Row{"Package URL", "Status", "Confidence"})
|
||||
|
||||
status := reporter.InfoBgText(" SAFE ")
|
||||
if report.GetInference().GetIsMalware() {
|
||||
if vr != nil && vr.IsMalware {
|
||||
status = reporter.CriticalBgText(" MALICIOUS ")
|
||||
} else {
|
||||
status = reporter.WarningBgText(" SUSPICIOUS ")
|
||||
}
|
||||
}
|
||||
|
||||
confidence := report.GetInference().GetConfidence().String()
|
||||
confidence = strings.TrimPrefix(confidence, "CONFIDENCE_")
|
||||
|
||||
tbl.AppendRow(table.Row{purl, status, confidence})
|
||||
tbl.Render()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(reporter.WarningText(fmt.Sprintf("** The full report is available at: %s",
|
||||
reportVisualizationUrl(analysisId))))
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func reportVisualizationUrl(analysisId string) string {
|
||||
return malysis.ReportURL(analysisId)
|
||||
}
|
||||
|
||||
func resolveGitHubActionsCommitHash(ctx context.Context, packageVersion *packagev1.PackageVersion) (string, error) {
|
||||
gha, err := adapters.NewGithubClient(adapters.DefaultGitHubClientConfig())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GitHub client: %v", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(packageVersion.GetPackage().GetName(), "/")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid repository name: %s - should be in the format <owner>/<repo>", packageVersion.GetPackage().GetName())
|
||||
}
|
||||
|
||||
owner := parts[0]
|
||||
repo := parts[1]
|
||||
|
||||
return vetutils.ResolveGitHubRepositoryCommitSHA(ctx,
|
||||
gha, owner, repo, packageVersion.GetVersion())
|
||||
}
|
||||
17
cmd/server/main.go
Normal file
@ -0,0 +1,17 @@
|
||||
package server
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func NewServerCommand() *cobra.Command {
|
||||
cmd := cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start available servers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newMcpServerCommand())
|
||||
|
||||
return &cmd
|
||||
}
|
||||
195
cmd/server/mcp.go
Normal file
@ -0,0 +1,195 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"buf.build/gen/go/safedep/api/grpc/go/safedep/services/insights/v2/insightsv2grpc"
|
||||
"buf.build/gen/go/safedep/api/grpc/go/safedep/services/malysis/v1/malysisv1grpc"
|
||||
"github.com/safedep/dry/adapters"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/auth"
|
||||
"github.com/safedep/vet/mcp"
|
||||
"github.com/safedep/vet/mcp/server"
|
||||
"github.com/safedep/vet/mcp/tools"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
mcpServerSseServerAddr string
|
||||
mcpServerServerType string
|
||||
skipDefaultTools bool
|
||||
registerVetSQLQueryTool bool
|
||||
vetSQLQueryToolDBPath string
|
||||
registerPackageRegistryTool bool
|
||||
sseServerAllowedOrigins []string
|
||||
sseServerAllowedHosts []string
|
||||
)
|
||||
|
||||
func newMcpServerCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Start the MCP server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := startMcpServer()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to start server: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&mcpServerSseServerAddr, "sse-server-addr", "localhost:9988", "The address to listen for SSE connections")
|
||||
cmd.Flags().StringVar(&mcpServerServerType, "server-type", "stdio", "The type of server to start (stdio, sse)")
|
||||
|
||||
cmd.Flags().StringSliceVar(
|
||||
&sseServerAllowedOrigins,
|
||||
"sse-allowed-origins",
|
||||
nil,
|
||||
"List of allowed origin prefixes for SSE connections. By default, we allow http://localhost:, http://127.0.0.1: and https://localhost:.",
|
||||
)
|
||||
cmd.Flags().StringSliceVar(
|
||||
&sseServerAllowedHosts,
|
||||
"sse-allowed-hosts",
|
||||
nil,
|
||||
"List of allowed hosts for SSE connections. By default, we allow localhost:9988, 127.0.0.1:9988 and [::1]:9988.",
|
||||
)
|
||||
|
||||
// We allow skipping default tools to allow for custom tools to be registered when the server starts.
|
||||
// This is useful for agents to avoid unnecessary tool registration.
|
||||
cmd.Flags().BoolVar(&skipDefaultTools, "skip-default-tools", false, "Skip registering default tools")
|
||||
|
||||
// Options to register sqlite3 query tool
|
||||
cmd.Flags().BoolVar(®isterVetSQLQueryTool, "sql-query-tool", false, "Register the vet report query by SQL tool (requires database path)")
|
||||
cmd.Flags().StringVar(&vetSQLQueryToolDBPath, "sql-query-tool-db-path", "", "The path to the vet SQLite3 database file")
|
||||
|
||||
// Options to register package registry tool
|
||||
cmd.Flags().BoolVar(®isterPackageRegistryTool, "package-registry-tool", false, "Register the package registry tool")
|
||||
|
||||
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
|
||||
if registerVetSQLQueryTool && vetSQLQueryToolDBPath == "" {
|
||||
return fmt.Errorf("database path is required for SQL query tool")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func startMcpServer() error {
|
||||
driver, err := buildMcpDriver()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build MCP driver: %w", err)
|
||||
}
|
||||
|
||||
var mcpSrv server.McpServer
|
||||
switch mcpServerServerType {
|
||||
case "stdio":
|
||||
mcpSrv, err = server.NewMcpServerWithStdioTransport(server.DefaultMcpServerConfig())
|
||||
case "sse":
|
||||
config := server.DefaultMcpServerConfig()
|
||||
|
||||
// Override with user supplied config
|
||||
config.SseServerAddr = mcpServerSseServerAddr
|
||||
|
||||
// override origins and hosts defaults only if user explicitly set them.
|
||||
// When explicitly passed as cmd line args, cobra parses
|
||||
// --sse-allowed-hosts='' as empty slice. Otherwise if not provided,
|
||||
// sse-allowed-hosts will be nil.
|
||||
if sseServerAllowedOrigins != nil {
|
||||
config.SseServerAllowedOriginsPrefix = sseServerAllowedOrigins
|
||||
}
|
||||
if sseServerAllowedHosts != nil {
|
||||
config.SseServerAllowedHosts = sseServerAllowedHosts
|
||||
}
|
||||
|
||||
mcpSrv, err = server.NewMcpServerWithSseTransport(config)
|
||||
default:
|
||||
return fmt.Errorf("invalid server type: %s", mcpServerServerType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create MCP server: %w", err)
|
||||
}
|
||||
|
||||
if !skipDefaultTools {
|
||||
err = doRegisterDefaultTools(mcpSrv, driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register default tools: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if registerVetSQLQueryTool {
|
||||
err = doRegisterVetSQLQueryTool(mcpSrv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register vet SQL query tool: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if registerPackageRegistryTool {
|
||||
err = doRegisterPackageRegistryTool(mcpSrv, driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register package registry tool: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = mcpSrv.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start MCP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func doRegisterDefaultTools(mcpSrv server.McpServer, driver mcp.Driver) error {
|
||||
return tools.RegisterAll(mcpSrv, driver)
|
||||
}
|
||||
|
||||
func doRegisterVetSQLQueryTool(mcpSrv server.McpServer) error {
|
||||
tool, err := tools.NewVetSQLQueryTool(vetSQLQueryToolDBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create vet SQL query tool: %w", err)
|
||||
}
|
||||
|
||||
return mcpSrv.RegisterTool(tool)
|
||||
}
|
||||
|
||||
func doRegisterPackageRegistryTool(mcpSrv server.McpServer, driver mcp.Driver) error {
|
||||
err := mcpSrv.RegisterTool(tools.NewPackageRegistryTool(driver))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register package registry tool: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMcpDriver() (mcp.Driver, error) {
|
||||
insightsConn, err := auth.InsightsV2CommunityClientConnection("vet-mcp-insights")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create insights client: %w", err)
|
||||
}
|
||||
|
||||
communityConn, err := auth.MalwareAnalysisCommunityClientConnection("vet-mcp-malware")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create community client: %w", err)
|
||||
}
|
||||
|
||||
insightsClient := insightsv2grpc.NewInsightServiceClient(insightsConn)
|
||||
malysisClient := malysisv1grpc.NewMalwareAnalysisServiceClient(communityConn)
|
||||
|
||||
githubAdapter, err := adapters.NewGithubClient(adapters.DefaultGitHubClientConfig())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create github client: %w", err)
|
||||
}
|
||||
|
||||
driver, err := mcp.NewDefaultDriver(insightsClient, malysisClient, githubAdapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MCP driver: %w", err)
|
||||
}
|
||||
|
||||
return driver, nil
|
||||
}
|
||||
60
cmd/server/mcp_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/safedep/vet/test"
|
||||
)
|
||||
|
||||
func TestMcpDriver(t *testing.T) {
|
||||
test.EnsureEndToEndTestIsEnabled(t)
|
||||
|
||||
driver, err := buildMcpDriver()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build MCP driver: %v", err)
|
||||
}
|
||||
|
||||
t.Run("malysis community service is accessible", func(t *testing.T) {
|
||||
report, err := driver.GetPackageVersionMalwareReport(context.Background(), &packagev1.PackageVersion{
|
||||
Package: &packagev1.Package{
|
||||
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
|
||||
Name: "express",
|
||||
},
|
||||
Version: "4.17.1",
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, report)
|
||||
})
|
||||
|
||||
t.Run("insights community service is accessible", func(t *testing.T) {
|
||||
vulns, err := driver.GetPackageVersionVulnerabilities(context.Background(), &packagev1.PackageVersion{
|
||||
Package: &packagev1.Package{
|
||||
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
|
||||
Name: "express",
|
||||
},
|
||||
Version: "4.17.1",
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, vulns)
|
||||
assert.NotEmpty(t, vulns)
|
||||
})
|
||||
|
||||
t.Run("package registry adapter is accessible", func(t *testing.T) {
|
||||
res, err := driver.GetPackageLatestVersion(context.Background(), &packagev1.Package{
|
||||
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
|
||||
Name: "express",
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
assert.Equal(t, "express", res.GetPackage().GetName())
|
||||
assert.Equal(t, packagev1.Ecosystem_ECOSYSTEM_NPM, res.GetPackage().GetEcosystem())
|
||||
assert.NotEmpty(t, res.GetPackage().GetName())
|
||||
})
|
||||
}
|
||||
152
connect.go
Normal file
@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/cli/oauth/device"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/safedep/vet/internal/connect"
|
||||
"github.com/safedep/vet/internal/ui"
|
||||
"github.com/safedep/vet/pkg/common/logger"
|
||||
)
|
||||
|
||||
func newConnectCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "connect",
|
||||
Short: "Connect with 3rd party apps",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return errors.New("a valid sub-command is required")
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(connectGithubCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func connectGithubCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "github",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
githubAccessToken, err := getAccessTokenFromUser()
|
||||
if err != nil {
|
||||
githubAccessToken, err = getAccessTokenViaDeviceFlow()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to connect with Github API: %s", err.Error())
|
||||
}
|
||||
|
||||
err = connect.PersistGithubAccessToken(githubAccessToken)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to persist Github connection token: %s", err.Error())
|
||||
}
|
||||
|
||||
ui.PrintSuccess("Github Access Token configured and saved at '%s' for your convenience.", connect.GetConfigFileHint())
|
||||
ui.PrintSuccess("You can use vet to scan your github repositories")
|
||||
ui.PrintSuccess("Run the command to scan your github repository")
|
||||
ui.PrintSuccess("\tvet scan --github https://github.com/<Org|User>/<Repo>")
|
||||
|
||||
os.Exit(1)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func getAccessTokenFromUser() (string, error) {
|
||||
var by_github_acces_token string
|
||||
|
||||
prompt := &survey.Select{
|
||||
Message: "Do you have access token ready?",
|
||||
Options: []string{"Y", "N"},
|
||||
Default: "Y",
|
||||
}
|
||||
|
||||
err := survey.AskOne(prompt, &by_github_acces_token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if by_github_acces_token != "Y" {
|
||||
return "", fmt.Errorf("user refused to provide access token")
|
||||
}
|
||||
|
||||
password := &survey.Password{
|
||||
Message: "Provide your access token: ",
|
||||
}
|
||||
|
||||
var accessToken string
|
||||
err = survey.AskOne(password, &accessToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func getAccessTokenViaDeviceFlow() (string, error) {
|
||||
var by_web_flow string
|
||||
prompt := &survey.Select{
|
||||
Message: "Do you want to connect with your Github account to continue?",
|
||||
Options: []string{"Y", "N"},
|
||||
Default: "Y",
|
||||
}
|
||||
|
||||
err := survey.AskOne(prompt, &by_web_flow)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if by_web_flow != "Y" {
|
||||
return "", fmt.Errorf("user cancelled device flow")
|
||||
}
|
||||
|
||||
ui.PrintMsg("Starting Github authentication using oauth2 device flow")
|
||||
|
||||
token, err := connectGithubWithDeviceFlow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func connectGithubWithDeviceFlow() (string, error) {
|
||||
clientID := connect.GetGithubOAuth2ClientId()
|
||||
scopes := []string{"repo", "read:org"}
|
||||
httpClient := http.DefaultClient
|
||||
|
||||
logger.Debugf("Initiating Github device flow auth using clientId: %s", clientID)
|
||||
|
||||
// TODO: We are coupling with Github cloud API here. Self-hosted Github enterprise won't work
|
||||
code, err := device.RequestCode(httpClient, "https://github.com/login/device/code", clientID, scopes)
|
||||
if err != nil {
|
||||
ui.PrintError("Error while requesting code from github: %s", err.Error())
|
||||
return "", err
|
||||
}
|
||||
|
||||
ui.PrintMsg("Copy the code: %s", code.UserCode)
|
||||
ui.PrintMsg("Navigate to the URL and paste the code: %s", code.VerificationURI)
|
||||
|
||||
// TODO: We are coupling with Github cloud API here. Self-hosted Github enterprise won't work
|
||||
accessToken, err := device.Wait(context.TODO(), httpClient,
|
||||
"https://github.com/login/oauth/access_token",
|
||||
device.WaitOptions{
|
||||
ClientID: clientID,
|
||||
DeviceCode: code,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
logger.Debugf("Completed device flow with Github successfully")
|
||||
return accessToken.Token, nil
|
||||
}
|
||||
20
docs/.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
1
docs/.node-version
Normal file
@ -0,0 +1 @@
|
||||
16.15.0
|
||||
9
docs/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# vet Documentation
|
||||
|
||||
## Usage
|
||||
|
||||
`vet` user documentation is available at [https://docs.safedep.io/](https://docs.safedep.io/)
|
||||
|
||||
## Development
|
||||
|
||||
- [Storage](./storage.md)
|
||||
40
docs/agent.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Agents
|
||||
|
||||
`vet` natively supports AI agents with MCP based integration for tools.
|
||||
|
||||
To get started, set an API key for the LLM you want to use. Example:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=sk-...
|
||||
export GEMINI_API_KEY=AIza...
|
||||
```
|
||||
|
||||
> **Note:** You can also set the model to use with `OPENAI_MODEL_OVERRIDE`, `ANTHROPIC_MODEL_OVERRIDE` and `GEMINI_MODEL_OVERRIDE` environment variables to override the default model used by the agent.
|
||||
|
||||
## Fast Mode
|
||||
|
||||
All agents support a `--fast` flag to use a faster LLM model instead of a slower but more powerful reasoning model. This is only for influencing the default choice of model. It can be overridden by setting model provider specific environment variables such as `OPENAI_MODEL_OVERRIDE`, `ANTHROPIC_MODEL_OVERRIDE` and `GEMINI_MODEL_OVERRIDE`.
|
||||
|
||||
## Available Agents
|
||||
|
||||
### Query Agent
|
||||
|
||||
The query agent helps run query and analysis over vet's sqlite3 reporting database. To use it:
|
||||
|
||||
* Run a `vet` scan and generate report in sqlite3 format
|
||||
|
||||
```bash
|
||||
vet scan --insights-v2 -M package-lock.json --report-sqlite3 report.db
|
||||
```
|
||||
|
||||
**Note:** Agents only work with `--insights-v2`
|
||||
|
||||
* Start the query agent
|
||||
|
||||
```bash
|
||||
vet agent query --db report.db
|
||||
```
|
||||
|
||||
* Thats it! Start asking questions about the scan results.
|
||||
|
||||
BIN
docs/assets/safedep-discord.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/assets/vet-demo.gif
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
docs/assets/vet-docs.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
BIN
docs/assets/vet-logo-dark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/assets/vet-logo-light.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/assets/vet-logo.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/assets/vet-mcp-cursor.png
Normal file
|
After Width: | Height: | Size: 719 KiB |
BIN
docs/assets/vet-mcp-vscode.png
Normal file
|
After Width: | Height: | Size: 624 KiB |
BIN
docs/assets/vet-scan-directory.png
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
docs/assets/vet-terminal.png
Normal file
|
After Width: | Height: | Size: 516 KiB |
35
docs/doc-generate.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Doc Generate
|
||||
|
||||
Docs for `cmd/doc/` command
|
||||
|
||||
> [!NOTE]
|
||||
> This command is `HIDDEN` and not listed in the help output of `vet`. It is used to generate the documentation / manual for the `vet` command line application.
|
||||
|
||||
## doc
|
||||
|
||||
Documentation generation internal utilities
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
-h, --help help for doc
|
||||
```
|
||||
|
||||
## doc generate
|
||||
|
||||
Generate docs / manual artifacts
|
||||
|
||||
```
|
||||
doc generate [flags]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
-h, --help help for generate
|
||||
--man string The output directory for troff (man markup) doc files
|
||||
--markdown string The output directory for markdown doc files
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> At least one of the output directory is required
|
||||
@ -1,115 +0,0 @@
|
||||
# Filtering
|
||||
|
||||
Filter command helps solve the problem of visibility for OSS dependencies in an
|
||||
application. To support various requirements, we adopt a generic [expressions
|
||||
language](https://github.com/google/cel-spec) for flexible filtering.
|
||||
|
||||
## Input
|
||||
|
||||
Filter expressions work on packages (aka. dependencies) and evaluates to
|
||||
a boolean result. The package is included in the results table if the
|
||||
expression evaluates to `true`.
|
||||
|
||||
Filter expressions get the following input data to work with
|
||||
|
||||
| Variable | Content |
|
||||
|-------------|-------------------------------------------------------------|
|
||||
| `_` | The root variable, holding other variables |
|
||||
| `vulns` | Holds a map of vulnerabiliteis by severity |
|
||||
| `scorecard` | Holds OpenSSF scorecard |
|
||||
| `projects` | Holds a list of source projects associated with the package |
|
||||
| `licenses` | Holds a list of liceses in SPDX license code format |
|
||||
|
||||
|
||||
## Expressions
|
||||
|
||||
Expressions are [CEL](https://github.com/google/cel-spec) statements. While
|
||||
CEL internals are not required, an [introductory](https://github.com/google/cel-spec/blob/master/doc/intro.md)
|
||||
knowledge of CEL will help formulating queries.
|
||||
|
||||
### Example Queries
|
||||
|
||||
| Description | Query |
|
||||
|----------------------------------------------|---------------------------------------|
|
||||
| Find packages with a critical vulnerability | `vulns.critical.exists(x, true)` |
|
||||
| Find unmaintained packages as per OpenSSF SC | `scorecard.score["Maintenance"] == 0` |
|
||||
| Find packages with low stars | `projects.exists(x, x.stars < 10)` |
|
||||
| Find packages with GPL-2.0 license | `licenses.exists(x, x == "GPL-2.0")`
|
||||
|
||||
Refer to [scorecard checks](https://github.com/ossf/scorecard#checks-1) for
|
||||
a list of checks available from OpenSSF Scorecards project.
|
||||
|
||||
## Query Workflow
|
||||
|
||||
Scanning a package manifest is a resource intensive process as it involves
|
||||
enriching package metadata by queryin [Insights API](https://safedep.io/docs/concepts/raya-data-platform-overview).
|
||||
However, for filtering and reporting may be done multiple times on the same
|
||||
manifest. To speed up the process, we can dump the enriched data as JSON and
|
||||
load the same for filtering and reporting.
|
||||
|
||||
Dump enriched JSON manifests to a directory (example)
|
||||
|
||||
```bash
|
||||
vet scan --lockfile /path/to/package-lock.json --json-dump-dir /tmp/dump
|
||||
vet scan -D /path/to/repository --json-dump-dir /tmp/dump-many
|
||||
```
|
||||
|
||||
Load the enriched metadata for filtering and reporting
|
||||
|
||||
```bash
|
||||
vet query --from /tmp/dump --report-console
|
||||
vet query --from /tmp/dump --filter 'scorecard.score["Maintenance"] == 0'
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### How does the filter input JSON look like?
|
||||
|
||||
```json
|
||||
{
|
||||
"pkg": {
|
||||
"ecosystem": "npm",
|
||||
"name": "lodash.camelcase",
|
||||
"version": "4.3.0"
|
||||
},
|
||||
"vulns": {
|
||||
"all": [],
|
||||
"critical": [],
|
||||
"high": [],
|
||||
"medium": [],
|
||||
"low": []
|
||||
},
|
||||
"scorecard": {
|
||||
"scores": {
|
||||
"Binary-Artifacts": 10,
|
||||
"Branch-Protection": 0,
|
||||
"CII-Best-Practices": 0,
|
||||
"Code-Review": 8,
|
||||
"Dangerous-Workflow": 10,
|
||||
"Dependency-Update-Tool": 0,
|
||||
"Fuzzing": 0,
|
||||
"License": 10,
|
||||
"Maintained": 0,
|
||||
"Packaging": -1,
|
||||
"Pinned-Dependencies": 9,
|
||||
"SAST": 0,
|
||||
"Security-Policy": 10,
|
||||
"Signed-Releases": -1,
|
||||
"Token-Permissions": 0,
|
||||
"Vulnerabilities": 10
|
||||
}
|
||||
},
|
||||
"projects": [
|
||||
{
|
||||
"name": "lodash/lodash",
|
||||
"type": "GITHUB",
|
||||
"stars": 55518,
|
||||
"forks": 6787,
|
||||
"issues": 464
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
"MIT"
|
||||
]
|
||||
}
|
||||
```
|
||||
37
docs/manual/_config.yml
Normal file
@ -0,0 +1,37 @@
|
||||
title: SafeDep/vet Manual
|
||||
description: CLI reference manual for SafeDep/vet. Next-gen software composition analysis and malicious package protection tool.
|
||||
remote_theme: just-the-docs/just-the-docs@v0.8.0
|
||||
markdown: kramdown
|
||||
|
||||
# Custom Color Schema, defined in _sass/color_schemes/safedep.scss
|
||||
color_scheme: safedep
|
||||
|
||||
# GitHub repo link (appears in top right)
|
||||
repo_url: https://github.com/safedep/vet
|
||||
# Aux links (top navigation bar)
|
||||
aux_links:
|
||||
"GitHub": https://github.com/safedep/vet
|
||||
aux_links_new_tab: true
|
||||
# Heading anchor links (h1, h2, h3...)
|
||||
heading_anchors: true
|
||||
|
||||
# Search functionality
|
||||
search_enabled: true
|
||||
search.heading_level: 2
|
||||
|
||||
# Logo (if you have one)
|
||||
logo: "/assets/logo.png"
|
||||
favicon_ico: "/assets/favicon.png"
|
||||
# Footer
|
||||
footer_content: "Copyright © 2025 SafeDep Inc."
|
||||
|
||||
# Back to top button
|
||||
back_to_top: true
|
||||
back_to_top_text: "Back to top"
|
||||
|
||||
plugins:
|
||||
- jekyll-seo-tag
|
||||
- jekyll-github-metadata
|
||||
- jekyll-include-cache
|
||||
- jekyll-sitemap
|
||||
|
||||
19
docs/manual/_sass/color_schemes/safedep.scss
Normal file
@ -0,0 +1,19 @@
|
||||
// Change Default Theme (purple's variable) to branding color
|
||||
|
||||
// SafeDep Brand Color is #0d9488
|
||||
$safedep-brand: #0d9488;
|
||||
|
||||
// This will override the the default color schema
|
||||
$purple-000: #0c8d75; // A little bit dark in that color for links (much better for links)
|
||||
$purple-100: #0a7562; // A little more dark on that link color for hover effect
|
||||
$link-color: $purple-000; // Set Link Color
|
||||
|
||||
// Override variables for the .btn-primary button
|
||||
$btn-primary-color: $safedep-brand;
|
||||
$btn-primary-bg: $safedep-brand; // Background color of the button
|
||||
$btn-primary-border: $safedep-brand; // Border color of the button
|
||||
|
||||
$btn-hover-color: #10bcad;
|
||||
$btn-primary-hover-bg: $btn-hover-color; // Background color on hover
|
||||
$btn-primary-hover-border: $btn-hover-color; // Border color on hover
|
||||
|
||||
BIN
docs/manual/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
docs/manual/assets/logo.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
28
docs/manual/index.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
title: SafeDep/vet Manual
|
||||
layout: home
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# SafeDep `vet` CLI manual
|
||||
|
||||
[`vet`](https://github.com/safedep/vet) is a free and open source software supply chain security tool. It helps developers and security engineers protect against malicious open source packages and establish policy driven guardrails.
|
||||
|
||||
<br />
|
||||
> _This CLI reference provides detailed documentation for all vet commands, flags, and options._
|
||||
|
||||
[Go to CLI Manual](vet.html){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 }
|
||||
|
||||
## `vet` Installation
|
||||
|
||||
```bash
|
||||
brew install safedep/tap/vet
|
||||
```
|
||||
|
||||
See [other installation options](https://github.com/safedep/vet?tab=readme-ov-file#-installation-options)
|
||||
|
||||
## Other Resources
|
||||
|
||||
Website: <https://safedep.io>
|
||||
|
||||
Docs: [https://docs.safedep.io](https://docs.safedep.io/introduction)
|
||||
266
docs/mcp.md
Normal file
@ -0,0 +1,266 @@
|
||||
# vet MCP Server
|
||||
|
||||
[](cursor://anysphere.cursor-deeplink/mcp/install?name=vet-mcp&config=eyJjb21tYW5kIjoiZG9ja2VyIHJ1biAtLXJtIC1pIGdoY3IuaW8vc2FmZWRlcC92ZXQ6bGF0ZXN0IC1zIC1sIC90bXAvdmV0LW1jcC5sb2cgc2VydmVyIG1jcCJ9)
|
||||
|
||||
The `vet` MCP server is designed to run locally using `stdio` or `sse` transports.
|
||||
It provides tools for MCP clients such as Claude Code, Cursor and others to vet
|
||||
open source packages before they are used in a project through AI generated code.
|
||||
|
||||
`vet` MCP server can protect against [Slopsquatting](https://en.wikipedia.org/wiki/Slopsquatting) attacks, malicious packages,
|
||||
vulnerabilities and other security risks.
|
||||
|
||||
## Supported Ecosystems
|
||||
|
||||
`vet` MCP server currently supports the following ecosystems:
|
||||
|
||||
- npm
|
||||
- PyPI
|
||||
|
||||
## Usage
|
||||
|
||||
Start the MCP server using SSE transport:
|
||||
|
||||
```bash
|
||||
vet server mcp --server-type sse
|
||||
```
|
||||
|
||||
Start the MCP server using stdio transport:
|
||||
|
||||
```bash
|
||||
vet -s -l /tmp/vet-mcp.log server mcp --server-type stdio
|
||||
```
|
||||
|
||||
> Avoid using `stdout` logging as it will interfere with the MCP server output.
|
||||
|
||||
### SSE Transport Features
|
||||
|
||||
The SSE (Server-Sent Events) transport supports:
|
||||
|
||||
- **GET requests**: For establishing SSE connections to receive real-time events
|
||||
- **HEAD requests**: For endpoint health checks and capability probing (useful for tools like Langchain)
|
||||
- **POST requests**: For sending messages to the MCP server via the message endpoint
|
||||
|
||||
The SSE endpoint returns appropriate headers for HEAD requests without a body, allowing tools to verify endpoint availability and capabilities.
|
||||
|
||||
### Security: Host and Origin Guards
|
||||
|
||||
For SSE, the server enforces simple, user-configurable guards to reduce the risk
|
||||
of unauthorized cross-origin access and DNS rebinding attacks.
|
||||
|
||||
- **Host guard**: Only allows connections whose `Host` header matches an allowed
|
||||
host list.
|
||||
- **Origin guard**: For browser requests, only allows requests whose `Origin`
|
||||
starts with an allowed prefix.
|
||||
|
||||
These checks are on by default with sensible localhost defaults, and you can
|
||||
customize them with flags when starting the server.
|
||||
|
||||
#### Defaults
|
||||
|
||||
- **Allowed hosts**: `localhost:9988`, `127.0.0.1:9988`, `[::1]:9988`
|
||||
- **Allowed origin prefixes**: `http://localhost:`, `http://127.0.0.1:`, `https://localhost:`
|
||||
|
||||
Requests that fail the host check are rejected with status `403`, and requests
|
||||
that fail the origin check are rejected with status `403`.
|
||||
|
||||
#### Customize allowed hosts and origins
|
||||
|
||||
You can override the defaults using the following flags:
|
||||
|
||||
```bash
|
||||
vet server mcp \
|
||||
--server-type sse \
|
||||
--sse-allowed-hosts "localhost:8080,127.0.0.1:8080" \
|
||||
--sse-allowed-origins "http://localhost:,https://localhost:"
|
||||
```
|
||||
|
||||
If you are running behind a proxy or using a different port, set both lists to
|
||||
match your environment. For example, when exposing SSE on port 3001:
|
||||
|
||||
```bash
|
||||
vet server mcp \
|
||||
--server-type sse \
|
||||
--sse-allowed-hosts "localhost:3001,127.0.0.1:3001" \
|
||||
--sse-allowed-origins "http://localhost:,http://127.0.0.1:,https://localhost:"
|
||||
```
|
||||
|
||||
With Docker, append the same flags to the container command:
|
||||
|
||||
```bash
|
||||
docker run --rm -i ghcr.io/safedep/vet:latest \
|
||||
server mcp \
|
||||
--server-type sse \
|
||||
--sse-allowed-hosts "localhost:9988,127.0.0.1:9988" \
|
||||
--sse-allowed-origins "http://localhost:,http://127.0.0.1:,https://localhost:"
|
||||
```
|
||||
|
||||
Tip: Non-browser clients may omit the `Origin` header. Those requests are
|
||||
allowed as long as the host guard passes.
|
||||
|
||||
## Configure MCP Client
|
||||
|
||||
> **Note:** The example below uses pre-build docker image. You can build your own by running
|
||||
> `docker build -t ghcr.io/safedep/vet:latest .`
|
||||
|
||||
Add following configuration to your MCP client configuration file:
|
||||
|
||||
```json
|
||||
"vet-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"ghcr.io/safedep/vet:latest",
|
||||
"-l",
|
||||
"/tmp/vet-mcp.log",
|
||||
"server",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`vet` uses `stdio` by default, if you wish to use SSE transport, add following args: `"--server-type", "sse"`
|
||||
|
||||
**Note:** While `docker` containers can help keep the MCP server process isolated, it needs to be periodically updated to the latest version.
|
||||
|
||||
Alternatively, you can use `vet` binary directly as an MCP server using `stdio` transport. Use the following command to start the MCP server:
|
||||
|
||||
```bash
|
||||
vet -l /tmp/vet-mcp.log server mcp --server-type stdio
|
||||
```
|
||||
|
||||
### Configure IDE
|
||||
|
||||
#### Cursor
|
||||
|
||||
Add `vet-mcp` server to either `.cursor/mcp.json` (project specific configuration) or `~/.cursor/mcp.json` (applies to all projects)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vet-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"ghcr.io/safedep/vet:latest",
|
||||
"-l",
|
||||
"/tmp/vet-mcp.log",
|
||||
"server",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add the following to `.cursor/rules/vet-mcp.mdc` file:
|
||||
|
||||
```
|
||||
---
|
||||
description: vet Open Source Packages using vet MCP server
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Always use vet-mcp to vet any open source library package suggested by AI generated code before installation.
|
||||
Follow the rules below to use vet-mcp:
|
||||
|
||||
- Try to find the latest version. If not found, it is likely a slopsquatting vulnerability
|
||||
- Always check for critical vulnerabilities and malware
|
||||
```
|
||||
|
||||
Example usage with Cursor:
|
||||
|
||||

|
||||
|
||||
[📄 Cursor MCP server reference](https://docs.cursor.com/context/model-context-protocol#manual-configuration)
|
||||
|
||||
#### Visual Studio Code
|
||||
|
||||
Add `vet-mcp` server to `.vscode/mcp.json` (project specific configuration)
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"vet-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"ghcr.io/safedep/vet:latest",
|
||||
"-l",
|
||||
"/tmp/vet-mcp.log",
|
||||
"server",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In order to use `vet-mcp` for all projects in Visual Studio Code, add following `mcp` setting in [Visual Studio Code User Settings](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server-to-your-user-settings) (`settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"vet-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"ghcr.io/safedep/vet:latest",
|
||||
"-l",
|
||||
"/tmp/vet-mcp.log",
|
||||
"server",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add the following to `.github/copilot-instructions.md` file:
|
||||
|
||||
```
|
||||
Always use vet-mcp to vet any open source library package suggested by AI generated code before installation.
|
||||
Follow the rules below to use vet-mcp:
|
||||
|
||||
- Try to find the latest version. If not found, it is likely a slopsquatting vulnerability
|
||||
- Always check for critical vulnerabilities and malware
|
||||
```
|
||||
|
||||
Example usage with Visual Studio Code:
|
||||
|
||||

|
||||
|
||||
[📄 Visual Studio Code MCP server reference](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
|
||||
|
||||
#### Claude Code
|
||||
|
||||
Add the following to `.mcp.json` in your Claude Code project:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vet-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"ghcr.io/safedep/vet:latest",
|
||||
"server",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** You can also use `vet` binary directly as an MCP server using `stdio` transport.
|
||||
21
docs/osv-report.md
Normal file
@ -0,0 +1,21 @@
|
||||
## OSV (OSSF) Report
|
||||
|
||||
Using `--report-osv` we can generate report for `OSSF` malicious package database.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
vet inspect malware --purl ... --report-osv .
|
||||
```
|
||||
|
||||
The value of `--report-osv` is the root of [ossf/malicious-packages](https://github.com/ossf/malicious-packages/) repository,
|
||||
it automatically places the JSON report in correct location, like `osv/malicious/npm/...`.
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Usage | Default Value |
|
||||
| -------------------------- | ----------------------------------- | --------------------------------------------- |
|
||||
| `report-osv-finder-name` | Name of finder | `SafeDep` |
|
||||
| `report-osv-contacts` | Contact Info, email, website etc | `https://safedep.io` |
|
||||
| `report-osv-reference-url` | Report Reference URL, like blog etc | `https://app.safedep.io/community/malysis/ID` |
|
||||
| `report-osv-with-ranges` | Use `ranges` affected property | discrete `versions` |
|
||||
47
docs/policy-dev.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Policy Engine Development
|
||||
|
||||
The policy engine is implemented using [Common Expressions Languages](https://cel.dev).
|
||||
This development document is ONLY for Policy v2, internally represented
|
||||
as Filter V2 for naming consistency.
|
||||
|
||||
## Enum Constants
|
||||
|
||||
Protobuf enums are exposed as integer values in CEL. To improve policy readability, we generate enum constant maps that allow using symbolic names instead of integers.
|
||||
|
||||
**Example usage in policies:**
|
||||
|
||||
```cel
|
||||
// Instead of: p.project.type == 1
|
||||
p.project.type == ProjectSourceType.GITHUB
|
||||
|
||||
// Instead of: pkg.ecosystem == 2
|
||||
pkg.ecosystem == Ecosystem.NPM
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- `pkg/analyzer/filterv2/enums.go` registers enums via `RegisteredEnums` by referencing protobuf-generated `Type_value` maps
|
||||
- `pkg/analyzer/filterv2/enumgen/` generates `enums_generated.go` with constant maps
|
||||
- Run `go generate ./pkg/analyzer/filterv2/` to regenerate after adding new enums
|
||||
|
||||
**Adding new enums:**
|
||||
|
||||
1. Add entry to `RegisteredEnums` in `pkg/analyzer/filterv2/enums.go`:
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "SeverityRisk",
|
||||
Prefix: "RISK_",
|
||||
ValueMap: vulnerabilityv1.Severity_Risk_value,
|
||||
}
|
||||
```
|
||||
|
||||
2. Declare the enum variable in `pkg/analyzer/filterv2/eval.go` `NewEvaluator()`:
|
||||
|
||||
```go
|
||||
cel.Variable("SeverityRisk", cel.MapType(cel.StringType, cel.IntType))
|
||||
```
|
||||
|
||||
3. Run `go generate ./pkg/analyzer/filterv2/`
|
||||
|
||||
The generator automatically strips prefixes (e.g., `RISK_CRITICAL` → `CRITICAL`) and keeps enums synchronized with protobuf definitions.
|
||||
1132
docs/sqlite3-reporter-queries.md
Normal file
29
docs/storage.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Storage
|
||||
|
||||
`vet` contains a storage engine defined in `pkg/storage`. We use `sqlite3` as
|
||||
the database and [ent](https://entgo.io/) as the ORM.
|
||||
|
||||
## Usage
|
||||
|
||||
- Create new schema using the following command
|
||||
|
||||
```shell
|
||||
go run -mod=mod entgo.io/ent/cmd/ent new CodeSourceFile
|
||||
```
|
||||
|
||||
- Schemas are generated in `./ent/schema` directory
|
||||
- Edit the generated schema file and add the necessary fields and edges
|
||||
- Generate the models from the schema using the following command
|
||||
|
||||
```shell
|
||||
make ent
|
||||
```
|
||||
|
||||
- Make sure to commit any changes to `ent` directory including the generated
|
||||
files
|
||||
|
||||
## Guidance
|
||||
|
||||
All schemas are stored in `./ent/schema` directory. To avoid naming conflicts,
|
||||
prefer prefixing the schema name with the logical module name. Example: `CodeSourceFile` is
|
||||
used as the schema for storing `SourceFile` within `Code` analysis module.
|
||||
2373
ent/client.go
Normal file
129
ent/codesourcefile.go
Normal file
@ -0,0 +1,129 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/safedep/vet/ent/codesourcefile"
|
||||
)
|
||||
|
||||
// CodeSourceFile is the model entity for the CodeSourceFile schema.
|
||||
type CodeSourceFile struct {
|
||||
config `json:"-"`
|
||||
// ID of the ent.
|
||||
ID int `json:"id,omitempty"`
|
||||
// Path holds the value of the "path" field.
|
||||
Path string `json:"path,omitempty"`
|
||||
// Edges holds the relations/edges for other nodes in the graph.
|
||||
// The values are being populated by the CodeSourceFileQuery when eager-loading is set.
|
||||
Edges CodeSourceFileEdges `json:"edges"`
|
||||
selectValues sql.SelectValues
|
||||
}
|
||||
|
||||
// CodeSourceFileEdges holds the relations/edges for other nodes in the graph.
|
||||
type CodeSourceFileEdges struct {
|
||||
// DepsUsageEvidences holds the value of the deps_usage_evidences edge.
|
||||
DepsUsageEvidences []*DepsUsageEvidence `json:"deps_usage_evidences,omitempty"`
|
||||
// loadedTypes holds the information for reporting if a
|
||||
// type was loaded (or requested) in eager-loading or not.
|
||||
loadedTypes [1]bool
|
||||
}
|
||||
|
||||
// DepsUsageEvidencesOrErr returns the DepsUsageEvidences value or an error if the edge
|
||||
// was not loaded in eager-loading.
|
||||
func (e CodeSourceFileEdges) DepsUsageEvidencesOrErr() ([]*DepsUsageEvidence, error) {
|
||||
if e.loadedTypes[0] {
|
||||
return e.DepsUsageEvidences, nil
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "deps_usage_evidences"}
|
||||
}
|
||||
|
||||
// scanValues returns the types for scanning values from sql.Rows.
|
||||
func (*CodeSourceFile) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case codesourcefile.FieldID:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case codesourcefile.FieldPath:
|
||||
values[i] = new(sql.NullString)
|
||||
default:
|
||||
values[i] = new(sql.UnknownType)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// assignValues assigns the values that were returned from sql.Rows (after scanning)
|
||||
// to the CodeSourceFile fields.
|
||||
func (csf *CodeSourceFile) assignValues(columns []string, values []any) error {
|
||||
if m, n := len(values), len(columns); m < n {
|
||||
return fmt.Errorf("mismatch number of scan values: %d != %d", m, n)
|
||||
}
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case codesourcefile.FieldID:
|
||||
value, ok := values[i].(*sql.NullInt64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field id", value)
|
||||
}
|
||||
csf.ID = int(value.Int64)
|
||||
case codesourcefile.FieldPath:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field path", values[i])
|
||||
} else if value.Valid {
|
||||
csf.Path = value.String
|
||||
}
|
||||
default:
|
||||
csf.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value returns the ent.Value that was dynamically selected and assigned to the CodeSourceFile.
|
||||
// This includes values selected through modifiers, order, etc.
|
||||
func (csf *CodeSourceFile) Value(name string) (ent.Value, error) {
|
||||
return csf.selectValues.Get(name)
|
||||
}
|
||||
|
||||
// QueryDepsUsageEvidences queries the "deps_usage_evidences" edge of the CodeSourceFile entity.
|
||||
func (csf *CodeSourceFile) QueryDepsUsageEvidences() *DepsUsageEvidenceQuery {
|
||||
return NewCodeSourceFileClient(csf.config).QueryDepsUsageEvidences(csf)
|
||||
}
|
||||
|
||||
// Update returns a builder for updating this CodeSourceFile.
|
||||
// Note that you need to call CodeSourceFile.Unwrap() before calling this method if this CodeSourceFile
|
||||
// was returned from a transaction, and the transaction was committed or rolled back.
|
||||
func (csf *CodeSourceFile) Update() *CodeSourceFileUpdateOne {
|
||||
return NewCodeSourceFileClient(csf.config).UpdateOne(csf)
|
||||
}
|
||||
|
||||
// Unwrap unwraps the CodeSourceFile entity that was returned from a transaction after it was closed,
|
||||
// so that all future queries will be executed through the driver which created the transaction.
|
||||
func (csf *CodeSourceFile) Unwrap() *CodeSourceFile {
|
||||
_tx, ok := csf.config.driver.(*txDriver)
|
||||
if !ok {
|
||||
panic("ent: CodeSourceFile is not a transactional entity")
|
||||
}
|
||||
csf.config.driver = _tx.drv
|
||||
return csf
|
||||
}
|
||||
|
||||
// String implements the fmt.Stringer.
|
||||
func (csf *CodeSourceFile) String() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("CodeSourceFile(")
|
||||
builder.WriteString(fmt.Sprintf("id=%v, ", csf.ID))
|
||||
builder.WriteString("path=")
|
||||
builder.WriteString(csf.Path)
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// CodeSourceFiles is a parsable slice of CodeSourceFile.
|
||||
type CodeSourceFiles []*CodeSourceFile
|
||||
83
ent/codesourcefile/codesourcefile.go
Normal file
@ -0,0 +1,83 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package codesourcefile
|
||||
|
||||
import (
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
)
|
||||
|
||||
const (
|
||||
// Label holds the string label denoting the codesourcefile type in the database.
|
||||
Label = "code_source_file"
|
||||
// FieldID holds the string denoting the id field in the database.
|
||||
FieldID = "id"
|
||||
// FieldPath holds the string denoting the path field in the database.
|
||||
FieldPath = "path"
|
||||
// EdgeDepsUsageEvidences holds the string denoting the deps_usage_evidences edge name in mutations.
|
||||
EdgeDepsUsageEvidences = "deps_usage_evidences"
|
||||
// Table holds the table name of the codesourcefile in the database.
|
||||
Table = "code_source_files"
|
||||
// DepsUsageEvidencesTable is the table that holds the deps_usage_evidences relation/edge.
|
||||
DepsUsageEvidencesTable = "deps_usage_evidences"
|
||||
// DepsUsageEvidencesInverseTable is the table name for the DepsUsageEvidence entity.
|
||||
// It exists in this package in order to avoid circular dependency with the "depsusageevidence" package.
|
||||
DepsUsageEvidencesInverseTable = "deps_usage_evidences"
|
||||
// DepsUsageEvidencesColumn is the table column denoting the deps_usage_evidences relation/edge.
|
||||
DepsUsageEvidencesColumn = "deps_usage_evidence_used_in"
|
||||
)
|
||||
|
||||
// Columns holds all SQL columns for codesourcefile fields.
|
||||
var Columns = []string{
|
||||
FieldID,
|
||||
FieldPath,
|
||||
}
|
||||
|
||||
// ValidColumn reports if the column name is valid (part of the table columns).
|
||||
func ValidColumn(column string) bool {
|
||||
for i := range Columns {
|
||||
if column == Columns[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
// PathValidator is a validator for the "path" field. It is called by the builders before save.
|
||||
PathValidator func(string) error
|
||||
)
|
||||
|
||||
// OrderOption defines the ordering options for the CodeSourceFile queries.
|
||||
type OrderOption func(*sql.Selector)
|
||||
|
||||
// ByID orders the results by the id field.
|
||||
func ByID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldID, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByPath orders the results by the path field.
|
||||
func ByPath(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldPath, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByDepsUsageEvidencesCount orders the results by deps_usage_evidences count.
|
||||
func ByDepsUsageEvidencesCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborsCount(s, newDepsUsageEvidencesStep(), opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// ByDepsUsageEvidences orders the results by deps_usage_evidences terms.
|
||||
func ByDepsUsageEvidences(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborTerms(s, newDepsUsageEvidencesStep(), append([]sql.OrderTerm{term}, terms...)...)
|
||||
}
|
||||
}
|
||||
func newDepsUsageEvidencesStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.To(DepsUsageEvidencesInverseTable, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, DepsUsageEvidencesTable, DepsUsageEvidencesColumn),
|
||||
)
|
||||
}
|
||||