Compare commits
406 Commits
| 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 |
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
|
||||
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
|
||||
|
||||
105
.github/workflows/ci.yml
vendored
@ -3,43 +3,84 @@ 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@v3
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568
|
||||
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
|
||||
with:
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build and Test
|
||||
run: |
|
||||
go mod tidy
|
||||
go build
|
||||
go test -v ./...
|
||||
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@v3
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568
|
||||
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
|
||||
with:
|
||||
go-version: 1.21
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Build vet
|
||||
@ -50,22 +91,48 @@ jobs:
|
||||
- name: Run E2E Scenarios
|
||||
run: |
|
||||
./test/scenarios/all.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-container:
|
||||
- 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@v3
|
||||
- name: Checkout Source
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||
|
||||
- name: Build Container Image
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64 --platform linux/arm64 \
|
||||
-t build-container-test .
|
||||
- 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
|
||||
|
||||
52
.github/workflows/codeql.yml
vendored
@ -13,10 +13,10 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "main" ]
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@ -35,34 +35,34 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
language: ["go"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568
|
||||
with:
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
- 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
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
- name: Initialize CodeQL
|
||||
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.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- run: |
|
||||
go mod tidy
|
||||
go build
|
||||
- run: |
|
||||
go mod tidy
|
||||
go build
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
140
.github/workflows/container.yml
vendored
@ -27,28 +27,130 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Registry Login
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- 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@v2
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55
|
||||
|
||||
- name: Build and Push Container Image
|
||||
run: |
|
||||
docker buildx build --push --platform linux/amd64 --platform linux/arm64 \
|
||||
-t $REGISTRY/$IMAGE_NAME:latest \
|
||||
.
|
||||
- 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"
|
||||
|
||||
10
.github/workflows/dependency-review.yml
vendored
@ -4,7 +4,7 @@
|
||||
#
|
||||
# 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'
|
||||
name: "Dependency Review"
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v3
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v3
|
||||
- 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
|
||||
15
.github/workflows/golangci-lint.yml
vendored
@ -6,23 +6,22 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version: '1.21'
|
||||
cache: false
|
||||
- uses: actions/checkout@v3
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
|
||||
with:
|
||||
version: latest
|
||||
args: --issues-exit-code=1 --timeout=10m
|
||||
only-new-issues: true
|
||||
skip-pkg-cache: true
|
||||
skip-build-cache: true
|
||||
|
||||
|
||||
51
.github/workflows/goreleaser.yml
vendored
@ -3,7 +3,7 @@ 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
|
||||
|
||||
@ -20,7 +20,7 @@ jobs:
|
||||
outputs:
|
||||
hashes: ${{ steps.hash.outputs.hashes }}
|
||||
permissions:
|
||||
contents: write # for goreleaser/goreleaser-action to create a GitHub release
|
||||
contents: write # for goreleaser/goreleaser-action to create a GitHub release
|
||||
packages: write # for goreleaser/goreleaser-action to publish docker images
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@ -34,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.21
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- name: ghcr-login
|
||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # v1
|
||||
@ -46,59 +46,60 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install OSX Cross Compiler Build Tools
|
||||
run: sudo apt-get install -y -qq clang gcc g++ zlib1g-dev libmpc-dev libmpfr-dev libgmp-dev cmake libxml2-dev libssl-dev xz-utils
|
||||
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@v3
|
||||
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
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
|
||||
- name: Upload dist folder
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dist-artifacts
|
||||
path: dist/
|
||||
|
||||
- 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"
|
||||
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.10.0
|
||||
with:
|
||||
base64-subjects: "${{ needs.goreleaser.outputs.hashes }}"
|
||||
upload-assets: true
|
||||
private-repository: true
|
||||
attestations: write # To write attestations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download dist folder
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
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
|
||||
8
.github/workflows/scorecard.yml
vendored
@ -10,9 +10,9 @@ on:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '35 22 * * 0'
|
||||
- cron: "35 22 * * 0"
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ["main"]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@80e868c13c90f172d68d1f4501dee99e2479f7af # v2.1.3
|
||||
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
@ -47,7 +47,7 @@ jobs:
|
||||
# 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@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
|
||||
8
.github/workflows/secret_scan.yml
vendored
@ -13,13 +13,13 @@ jobs:
|
||||
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,5 +1,6 @@
|
||||
# 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
|
||||
@ -10,34 +11,37 @@ env:
|
||||
|
||||
builds:
|
||||
- id: linux
|
||||
binary: vet
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
env:
|
||||
- CC=x86_64-linux-gnu-gcc
|
||||
- CXX=x86_64-linux-gnu-g++
|
||||
- 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-amd64
|
||||
binary: vet
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- id: darwin
|
||||
goos: [darwin]
|
||||
goarch: [amd64, arm64]
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
|
||||
- id: darwin-arm64
|
||||
binary: vet
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
- id: windows
|
||||
goos: [windows]
|
||||
goarch: [amd64]
|
||||
env:
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
- CXX=x86_64-w64-mingw32-g++
|
||||
|
||||
release:
|
||||
# for prerelease it doesn't build and distribute
|
||||
@ -51,7 +55,7 @@ brews:
|
||||
homepage: https://safedep.io
|
||||
description: "SafeDep vet is a tool for identifying open source software supply chain risks"
|
||||
license: "Apache-2.0"
|
||||
tap:
|
||||
repository:
|
||||
owner: safedep
|
||||
name: homebrew-tap
|
||||
branch: main
|
||||
@ -72,19 +76,19 @@ archives:
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
- 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
golang 1.21.5
|
||||
golang 1.25.1
|
||||
gitleaks 8.16.4
|
||||
|
||||
2
.vscode/settings.json
vendored
@ -3,7 +3,7 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "golang.go",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"gopls": {
|
||||
|
||||
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
|
||||
```
|
||||
27
Dockerfile
@ -1,28 +1,45 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.21-bullseye AS build
|
||||
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
|
||||
|
||||
RUN make quick-vet
|
||||
# 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 gcr.io/distroless/cc
|
||||
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
|
||||
|
||||
USER nonroot:nonroot
|
||||
|
||||
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
|
||||
30
Makefile
@ -2,28 +2,22 @@ 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
|
||||
|
||||
linter-install:
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.0
|
||||
.PHONY: ent
|
||||
ent:
|
||||
go generate ./ent
|
||||
|
||||
oapi-codegen-install:
|
||||
go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.10.1
|
||||
.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
|
||||
|
||||
dev-setup: linter-install oapi-codegen-install protoc-install
|
||||
|
||||
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 cpv1trials -generate types ./api/cp-v1-trials.yml > ./gen/cpv1trials/trials.types.go
|
||||
oapi-codegen -package cpv1trials -generate client ./api/cp-v1-trials.yml > ./gen/cpv1trials/trials.client.go
|
||||
oapi-codegen -package cpv1 -generate types ./api/cp-v1.yml > ./gen/cpv1/cp.types.go
|
||||
oapi-codegen -package cpv1 -generate client ./api/cp-v1.yml > ./gen/cpv1/cp.client.go
|
||||
oapi-codegen -package syncv1 -generate types ./api/sync-v1.yml > ./gen/syncv1/sync.types.go
|
||||
oapi-codegen -package syncv1 -generate client ./api/sync-v1.yml > ./gen/syncv1/sync.client.go
|
||||
dev-setup: protoc-install
|
||||
|
||||
protoc-codegen:
|
||||
protoc -I ./api \
|
||||
@ -76,10 +70,10 @@ setup:
|
||||
GO_CFLAGS=-X main.commit=$(GITCOMMIT) -X main.version=$(VERSION)
|
||||
GO_LDFLAGS=-ldflags "-w $(GO_CFLAGS)"
|
||||
|
||||
vet: oapi-codegen protoc-codegen
|
||||
quick-vet:
|
||||
go build ${GO_LDFLAGS}
|
||||
|
||||
quick-vet:
|
||||
vet: oapi-codegen protoc-codegen
|
||||
go build ${GO_LDFLAGS}
|
||||
|
||||
.PHONY: test
|
||||
|
||||
599
README.md
@ -1,215 +1,582 @@
|
||||
<h1 align="center">
|
||||
<img alt="SafeDep Vet" src="docs/static/img/vet-logo.png" width="150" />
|
||||
</h1>
|
||||
<p align="center">
|
||||
🙌 Refer to <b><a href="https://safedep.io/docs/">https://safedep.io/docs</a></b> for the documentation 📖
|
||||
</p>
|
||||
<div align="center">
|
||||
<img width="3024" height="1964" alt="image" src="./docs/assets/vet-terminal.png" />
|
||||
|
||||
<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://github.com/safedep/vet/actions/workflows/codeql.yml)
|
||||
[](https://slsa.dev)
|
||||
[](https://github.com/safedep/vet/actions/workflows/scorecard.yml)
|
||||
[](https://twitter.com/intent/follow?screen_name=safedepio)
|
||||
[](https://github.com/safedep/vet/actions/workflows/codeql.yml)
|
||||
[](https://pkg.go.dev/github.com/safedep/vet)
|
||||
|
||||
[](https://safedep.io/docs)
|
||||
## Automate Open Source Package Vetting in CI/CD
|
||||
[](https://deepwiki.com/safedep/vet)
|
||||
|
||||
`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.
|
||||
</div>
|
||||
|
||||
## 🔥 vet in action
|
||||
---
|
||||
|
||||

|
||||
## 🎯 Why vet?
|
||||
|
||||
## Getting Started
|
||||
> **70-90% of modern software constitute code from open sources** — How do we know if it's safe?
|
||||
|
||||
- Download the binary file for your operating system / architecture from the [Official GitHub Releases](https://github.com/safedep/vet/releases)
|
||||
**vet** is an open source software supply chain security tool built for **developers and security engineers** who need:
|
||||
|
||||
- You can also install `vet` using homebrew in MacOS and Linux
|
||||
✅ **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
|
||||
```
|
||||
|
||||
- Alternatively, build from source
|
||||
### 📥 **Direct Download**
|
||||
|
||||
> Ensure $(go env GOPATH)/bin is in your $PATH
|
||||
See [releases](https://github.com/safedep/vet/releases) for the latest version.
|
||||
|
||||
### 🐹 **Go Install**
|
||||
|
||||
```bash
|
||||
go install github.com/safedep/vet@main
|
||||
go install github.com/safedep/vet@latest
|
||||
```
|
||||
|
||||
- Configure `vet` to use community mode for Insights API
|
||||
### 🐳 **Container Image**
|
||||
|
||||
```bash
|
||||
vet auth configure --community
|
||||
# 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
|
||||
```
|
||||
|
||||
> Insights API is used to enrich OSS packages with metadata for rich query and policy decisions.
|
||||
|
||||
- You can verify the configured key is successful by running the following command
|
||||
### ⚙️ **Verify Installation**
|
||||
|
||||
```bash
|
||||
vet auth verify
|
||||
vet version
|
||||
# Should display version and build information
|
||||
```
|
||||
|
||||
### Running Scan
|
||||
## 🎮 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
|
||||
```
|
||||
|
||||

|
||||
|
||||
- You can also 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
|
||||
```
|
||||
|
||||
> [Example Security Gate](https://github.com/safedep/demo-client-java/pull/2) using `vet` to prevent introducing new OSS dependency risk in an application.
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
#### Scanning SBOM
|
||||
|
||||
- To scan an SBOM in [CycloneDX](https://cyclonedx.org/) format
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/cyclonedx-sbom.json --lockfile-as bom-cyclonedx
|
||||
```
|
||||
|
||||
- To scan an SBOM in [SPDX](https://spdx.dev/) format
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/spdx-sbom.json --lockfile-as bom-spdx
|
||||
```
|
||||
|
||||
> **Note:** SBOM scanning feature is currently in experimental stage
|
||||
|
||||
#### Scanning Github Repositories
|
||||
|
||||
- Setup github access token to scan private repo
|
||||
**🐙 GitHub Integration**
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
Alternatively, set `GITHUB_TOKEN` environment variable with [Github PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
|
||||
|
||||
- To scan remote Github repositories, including private ones
|
||||
**📦 Artifact Scanning**
|
||||
|
||||
```bash
|
||||
vet scan --github https://github.com/safedep/vet
|
||||
# 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
|
||||
```
|
||||
|
||||
**Note:** You may need to enable [Dependency Graph](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-the-dependency-graph) at repository or organization level for Github repository scanning to work.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
#### Scanning Github Organization
|
||||
|
||||
> You must setup the required access for scanning private repositories
|
||||
> before scanning organizations
|
||||
### 🎯 **Policy Enforcement Examples**
|
||||
|
||||
```bash
|
||||
vet scan --github-org https://github.com/safedep
|
||||
# Security-first scanning
|
||||
vet scan -D . \
|
||||
--filter 'vulns.critical.exists(p, true) || vulns.high.exists(p, true)' \
|
||||
--filter-fail
|
||||
|
||||
# License compliance
|
||||
vet scan -D . \
|
||||
--filter 'licenses.contains_license("GPL-3.0")' \
|
||||
--filter-fail
|
||||
|
||||
# OpenSSF Scorecard requirements
|
||||
vet scan -D . \
|
||||
--filter 'scorecard.scores.Maintained < 5' \
|
||||
--filter-fail
|
||||
|
||||
# Popularity-based filtering
|
||||
vet scan -D . \
|
||||
--filter 'projects.exists(p, p.type == "GITHUB" && p.stars < 50)' \
|
||||
--filter-fail
|
||||
```
|
||||
|
||||
> **Note:** `vet` will block and wait if it encounters Github secondary rate limit.
|
||||
|
||||
#### Scanning Package URL
|
||||
|
||||
- To scan a [purl](https://github.com/package-url/purl-spec)
|
||||
### 🔧 **SBOM Support**
|
||||
|
||||
```bash
|
||||
vet scan --purl pkg:/gem/nokogiri@1.10.4
|
||||
# Scan a CycloneDX SBOM
|
||||
vet scan -M sbom.json --type bom-cyclonedx
|
||||
|
||||
# Scan a SPDX SBOM
|
||||
vet scan -M sbom.spdx.json --type bom-spdx
|
||||
|
||||
# Generate SBOM output
|
||||
vet scan -D . --report-cdx=output.sbom.json
|
||||
|
||||
# Package URL scanning
|
||||
vet scan --purl pkg:npm/lodash@4.17.21
|
||||
```
|
||||
|
||||
#### Available Parsers
|
||||
### 📊 **Query Mode & Data Persistence**
|
||||
|
||||
- To list supported package manifest parsers including experimental modules
|
||||
For large codebases and repeated analysis:
|
||||
|
||||
```bash
|
||||
vet scan parsers --experimental
|
||||
# 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
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
## 📊 Reporting
|
||||
|
||||
- Refer to [https://safedep.io/docs](https://safedep.io/docs) for the detailed documentation
|
||||
**vet** generate reports that are tailored for different stakeholders:
|
||||
|
||||
[](https://safedep.io/docs)
|
||||
### 📋 **Report Formats**
|
||||
|
||||
## 🎊 Community
|
||||
|
||||
First of all, thank you so much for showing interest in `vet`, we appreciate it ❤️
|
||||
|
||||
- Join the server using the link - [https://rebrand.ly/safedep-community](https://rebrand.ly/safedep-community)
|
||||
|
||||
[](https://rebrand.ly/safedep-community)
|
||||
|
||||
## 💻 Development
|
||||
|
||||
## Requirements
|
||||
|
||||
* Go 1.21+
|
||||
|
||||
### Setup
|
||||
|
||||
* Install [ASDF](https://asdf-vm.com/)
|
||||
* Install the development tools
|
||||
<table>
|
||||
<tr>
|
||||
<td width="30%"><strong>🔍 For Security Teams</strong></td>
|
||||
<td width="70%">
|
||||
|
||||
```bash
|
||||
asdf install
|
||||
# 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
|
||||
```
|
||||
|
||||
* Install `lefthook`
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>📖 For Developers</strong></td>
|
||||
<td>
|
||||
|
||||
```bash
|
||||
go install github.com/evilmartians/lefthook@latest
|
||||
# Markdown reports for PRs
|
||||
vet scan -D . --report-markdown=report.md
|
||||
|
||||
# Console summary (default)
|
||||
vet scan -D . --report-summary
|
||||
```
|
||||
|
||||
* Install git hooks
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>🏢 For Compliance</strong></td>
|
||||
<td>
|
||||
|
||||
```bash
|
||||
$(go env GOPATH)/bin/lefthook install
|
||||
# SBOM generation
|
||||
vet scan -D . --report-cdx=sbom.json
|
||||
|
||||
# Dependency graphs
|
||||
vet scan -D . --report-graph=dependencies.dot
|
||||
```
|
||||
|
||||
### Build
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Install build tools
|
||||
### 🎯 **Report Examples**
|
||||
|
||||
```bash
|
||||
make dev-setup
|
||||
# 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
|
||||
```
|
||||
|
||||
Generate code from API specs and build `vet`
|
||||
### 🤖 **MCP Server**
|
||||
|
||||
**vet** can be used as an MCP server to vet open source packages from AI suggested code.
|
||||
|
||||
```bash
|
||||
make
|
||||
# Start the MCP server with SSE transport
|
||||
vet server mcp --server-type sse
|
||||
```
|
||||
|
||||
Quick build without regenerating code from API specs
|
||||
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
|
||||
make quick-vet
|
||||
# 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
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
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
|
||||
go test -v ./...
|
||||
# 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
|
||||
```
|
||||
|
||||
## Star History
|
||||
</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)
|
||||
|
||||
## 🔖 References
|
||||
### 🙏 **Built With Open Source**
|
||||
|
||||
- [https://github.com/google/osv-scanner](https://github.com/google/osv-scanner)
|
||||
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")
|
||||
}
|
||||
@ -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
|
||||
113
api/cp-v1.yml
@ -1,113 +0,0 @@
|
||||
openapi: 3.0.2
|
||||
info:
|
||||
title: SafeDep Control Plane API
|
||||
contact:
|
||||
name: SafeDep API
|
||||
url: 'https://safedep.io'
|
||||
description: |
|
||||
The SafeDep Control Plane API provides configuration and management plane
|
||||
access to clients for the SafeDep platform
|
||||
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:
|
||||
/auths/me:
|
||||
get:
|
||||
description: |
|
||||
Introspection API for getting configuration information associated
|
||||
with the supplied API credentials
|
||||
operationId: getApiCredentialIntrospection
|
||||
tags:
|
||||
- Control Plane
|
||||
responses:
|
||||
'200':
|
||||
description: API credentials introspection information
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CredentialIntrospectionResponse'
|
||||
'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:
|
||||
securitySchemes:
|
||||
api_key:
|
||||
type: apiKey
|
||||
name: Authorization
|
||||
in: header
|
||||
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
|
||||
CredentialIntrospectionResponse:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The entity type to which this credential belongs to
|
||||
enum:
|
||||
- TrialUser
|
||||
- Organization
|
||||
- Team
|
||||
- User
|
||||
expiry:
|
||||
type: string
|
||||
description: Expiry timestamp in RFC3399 format if expirable
|
||||
required:
|
||||
- type
|
||||
@ -50,6 +50,7 @@ message InsightProjectInfo {
|
||||
int32 stars = 3;
|
||||
int32 forks = 4;
|
||||
int32 issues = 5;
|
||||
string url = 6;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -72,6 +72,9 @@ message PackageManifestReport {
|
||||
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
|
||||
@ -88,6 +91,7 @@ message PackageReport {
|
||||
// Insights data
|
||||
repeated InsightVulnerability vulnerabilities = 5;
|
||||
repeated InsightLicenseInfo licenses = 6;
|
||||
repeated InsightProjectInfo projects = 8;
|
||||
|
||||
// Threats
|
||||
repeated ReportThreat threats = 7;
|
||||
|
||||
1015
api/sync-v1.yml
99
auth.go
@ -2,39 +2,30 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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
|
||||
authCommunity bool
|
||||
)
|
||||
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 {
|
||||
return errors.New("a valid sub-command is required")
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVarP(&authControlPlaneApiBaseUrl, "control-plane", "",
|
||||
auth.DefaultControlPlaneApiUrl(), "Base URL of Control Plane API")
|
||||
|
||||
cmd.AddCommand(configureAuthCommand())
|
||||
cmd.AddCommand(verifyAuthCommand())
|
||||
cmd.AddCommand(trialsRegisterCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@ -46,25 +37,39 @@ func configureAuthCommand() *cobra.Command {
|
||||
var key string
|
||||
var err error
|
||||
|
||||
if !authCommunity {
|
||||
err = survey.AskOne(&survey.Password{
|
||||
Message: "Enter the API key",
|
||||
}, &key)
|
||||
} else {
|
||||
authInsightApiBaseUrl = auth.DefaultCommunityApiUrl()
|
||||
}
|
||||
|
||||
err = survey.AskOne(&survey.Password{
|
||||
Message: "Enter the API key",
|
||||
}, &key)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to setup auth: %v", err)
|
||||
}
|
||||
|
||||
err = auth.Configure(auth.Config{
|
||||
ApiUrl: authInsightApiBaseUrl,
|
||||
ApiKey: string(key),
|
||||
ControlPlaneApiUrl: authControlPlaneApiBaseUrl,
|
||||
Community: authCommunity,
|
||||
})
|
||||
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 {
|
||||
logger.Fatalf("Failed to setup auth: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@ -74,13 +79,12 @@ func configureAuthCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&authInsightApiBaseUrl, "api", "", auth.DefaultApiUrl(),
|
||||
"Base URL of Insights API")
|
||||
cmd.Flags().BoolVarP(&authCommunity, "community", "", false,
|
||||
"Use community API endpoint for Insights")
|
||||
cmd.Flags().StringVarP(&authTenantDomain, "tenant", "", "",
|
||||
"Tenant domain for SafeDep Cloud")
|
||||
|
||||
_ = cmd.MarkFlagRequired("tenant")
|
||||
|
||||
return cmd
|
||||
|
||||
}
|
||||
|
||||
func verifyAuthCommand() *cobra.Command {
|
||||
@ -91,9 +95,7 @@ func verifyAuthCommand() *cobra.Command {
|
||||
ui.PrintSuccess("Running in Community Mode")
|
||||
}
|
||||
|
||||
failOnError("auth/verify", auth.Verify(&auth.VerifyConfig{
|
||||
ControlPlaneApiUrl: authControlPlaneApiBaseUrl,
|
||||
}))
|
||||
command.FailOnError("auth/verify", auth.Verify())
|
||||
|
||||
ui.PrintSuccess("Authentication key is valid!")
|
||||
return nil
|
||||
@ -102,32 +104,3 @@ func verifyAuthCommand() *cobra.Command {
|
||||
|
||||
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")
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
@ -1,19 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"context"
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newConnectCommand() *cobra.Command {
|
||||
@ -143,7 +143,6 @@ func connectGithubWithDeviceFlow() (string, error) {
|
||||
ClientID: clientID,
|
||||
DeviceCode: code,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@ -1,33 +1,9 @@
|
||||
# vet docs overview
|
||||
# vet Documentation
|
||||
|
||||
This section contains documentation for `vet`. This documentation site is based on the Docusaurus framework.
|
||||
## Usage
|
||||
|
||||
## Getting Started
|
||||
`vet` user documentation is available at [https://docs.safedep.io/](https://docs.safedep.io/)
|
||||
|
||||
To start a local environment of this project docs, please do the following
|
||||
## Development
|
||||
|
||||
- Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/safedep/vet.git
|
||||
```
|
||||
|
||||
- Navigate to the guide directory
|
||||
|
||||
```bash
|
||||
cd vet/docs
|
||||
```
|
||||
|
||||
- Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
- Start the development server
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
- Navigate to [http://localhost:3000](http://localhost:3000) for accessing the `vet` documentation locally
|
||||
- [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.
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
BIN
docs/assets/vet-demo.gif
Normal file
|
After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 489 KiB 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 |
|
Before Width: | Height: | Size: 43 KiB 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 |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 346 KiB |
BIN
docs/assets/vet-terminal.png
Normal file
|
After Width: | Height: | Size: 516 KiB |
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
};
|
||||
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,8 +0,0 @@
|
||||
{
|
||||
"label": "👀 Advanced Usage",
|
||||
"position": 6,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Advanced usage of vet for various workflows"
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
title: 🚫 Allow & Deny List
|
||||
---
|
||||
|
||||
# 🚫 Vet - Allow & Deny List
|
||||
|
||||
In this section we will leverage the [Exceptions](./exceptions) to configure and design the entire workflows with allow & deny list.
|
||||
@ -1,43 +0,0 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: ✍️ Build Your Own Queries
|
||||
---
|
||||
|
||||
# ✍️ Build Your Own Queries (BYOQ)
|
||||
|
||||
## Query Workflow
|
||||
|
||||
Scanning a package manifest is a resource intensive process as it involves enriching package metadata by querying Insights API. However, 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-summary
|
||||
vet query --from /tmp/dump --filter 'scorecard.score.Maintained == 0'
|
||||
```
|
||||
|
||||
## Security Guardrails with Filters
|
||||
|
||||
A simple security guardrail (in CI) can be achieved using the filters. The `--filter-fail` argument tells the `Filter Analyzer` module to fail the command if any package matches the given filter.
|
||||
|
||||
- Example: If OpenSSF Scorecard not maintained project score is `0` then fail the build
|
||||
|
||||
```bash
|
||||
vet query --from /path/to/json-dump \
|
||||
--filter 'scorecard.scores.Maintained == 0' \
|
||||
--filter-fail
|
||||
```
|
||||
|
||||
- Subsequently, the command fails with `-1` exit code in case of match
|
||||
|
||||
```bash
|
||||
➜ vet git:(develop) ✗ echo $?
|
||||
255
|
||||
```
|
||||
@ -1,88 +0,0 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: ❗ Exceptions
|
||||
---
|
||||
|
||||
# ❗ Vet - Exceptions Management
|
||||
|
||||
Any security scanning tool may produce
|
||||
|
||||
1. False positive
|
||||
2. Issues that are acceptable for a period of time
|
||||
3. Issues that are ignored permanently
|
||||
|
||||
:::info
|
||||
|
||||
To support exceptions, we introduce the exception model defined in [exception spec](https://github.com/safedep/vet/blob/main/api/exceptions_spec.proto)
|
||||
|
||||
:::
|
||||
|
||||
## Use-case
|
||||
|
||||
As a user of `vet` tool, I want to add all existing packages or package versions as `exceptions` to make the scanner and filter analyses to ignore them while reporting issues so that I can deploy `vet` as a security guardrail to prevent introducing new packages with security issues
|
||||
|
||||
This workflow will allow users to
|
||||
|
||||
1. Accept the current issues as backlog to be mitigated over time
|
||||
2. Deploy `vet` as a security gate in CI to prevent introducing new issues
|
||||
|
||||
### Security Risks
|
||||
|
||||
Exceptions management should handle the potential security risk of ignoring a package and its future issues. To mitigate this risk, we will ensure that issues can be ignored till an acceptable time window and not permanently.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Generate Exceptions File
|
||||
|
||||
- Run a scan and dump raw data to a directory
|
||||
|
||||
```bash
|
||||
vet scan -D /path/to/repo --json-dump-dir /path/to/dump
|
||||
```
|
||||
|
||||
- Use `vet query` to generate exceptions for all existing packages
|
||||
|
||||
```bash
|
||||
vet query --from /path/to/dump \
|
||||
--exceptions-generate /path/to/exceptions.yml \
|
||||
--exceptions-filter 'true' \ # Optional filter for packages to add
|
||||
--exceptions-till '2023-12-12'
|
||||
```
|
||||
|
||||
:::info
|
||||
|
||||
`--exceptions-till` is parsed as `YYYY-mm-dd` and will generate a timestamp of `00:00:00` in UTC timezone for the date in RFC3339 format
|
||||
|
||||
:::
|
||||
|
||||
### Customize Exceptions File
|
||||
|
||||
The generated exceptions file will add all packages, matching optional filter, into the `exceptions.yml` file. This file should be reviewed and customised as required before using it.
|
||||
|
||||
### Use Exceptions to Ignore Specific Packages
|
||||
|
||||
An exceptions file can be passed as a global flag to `vet`. It will be used for various commands such as `scan` or `query`.
|
||||
|
||||
```bash
|
||||
./vet --exceptions /path/to/exceptions.yml scan -D /path/to/repo
|
||||
```
|
||||
|
||||
:::caution
|
||||
|
||||
Do not pass this flag while generating exceptions list in query workflow to avoid incorrect exception list generation
|
||||
|
||||
:::
|
||||
|
||||
## Behavior
|
||||
|
||||
- All exceptions rules are applied only on a `Package`
|
||||
- All comparisons will be case-insensitive except version
|
||||
- Only `version` can have a value of `*` matching any version
|
||||
- Exceptions are globally managed and will be shared across packages
|
||||
- Exempted packages will be ignored by all analysers and reporters
|
||||
- First match policy for exceptions matching
|
||||
|
||||
Anti-patterns that will NOT be implemented
|
||||
|
||||
- Exceptions will not be implemented for manifests because they will cause false negatives
|
||||
- Exceptions will not be created without an expiry to avoid future false negatives on the package
|
||||
@ -1,125 +0,0 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 🔎 Filtering
|
||||
---
|
||||
|
||||
# 🔎 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.
|
||||
|
||||
## Example
|
||||
|
||||
- The scan will list only packages that use the `MIT` license.
|
||||
|
||||
```bash
|
||||
vet scan -D /path/to/repo \
|
||||
--report-summary=false \
|
||||
--filter 'licenses.exists(p, p == "MIT")'
|
||||
```
|
||||
|
||||
- Find dependencies that seems not very popular
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/pom.xml --report-summary=false \
|
||||
--filter='projects.exists(x, x.stars < 10)'
|
||||
```
|
||||
|
||||
- Find dependencies with a critical vulnerability
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/pom.xml --report-summary=false \
|
||||
--filter='vulns.critical.exists_one(x, true)'
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
:::tip
|
||||
|
||||
Refer to [filter input spec](https://github.com/safedep/vet/blob/main/api/filter_input_spec.proto) for detailed structure of input messages.
|
||||
|
||||
:::
|
||||
|
||||
## 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. Expressions are logical
|
||||
statements that evaluate to `true` or `false`.
|
||||
|
||||
### Example Queries
|
||||
|
||||
| Description | Query |
|
||||
|----------------------------------------------|--------------------------------------|
|
||||
| Find packages with a critical vulnerability | `vulns.critical.exists(x, true)` |
|
||||
| Find unmaintained packages as per OpenSSF SC | `scorecard.scores.Maintained == 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")`
|
||||
|
||||
:::tip
|
||||
|
||||
Refer to [scorecard checks](https://github.com/ossf/scorecard#checks-1) for a list of checks available from OpenSSF Scorecards project.
|
||||
|
||||
:::
|
||||
|
||||
## 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"
|
||||
]
|
||||
}
|
||||
```
|
||||
@ -1,27 +0,0 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
title: 🚧 Path Exclusion
|
||||
---
|
||||
|
||||
# 🚧 Path Exclusion
|
||||
|
||||
`vet` supports path exclusions for scenarios where a directory is the scan target but certain path patterns within the directory should be excluded from scan. This is available only for the `scan` command.
|
||||
|
||||
```bash
|
||||
vet scan -D /path/to/target --exclude 'docs/*'
|
||||
```
|
||||
|
||||
- Multiple path patterns can be provided for exclusion while scanning a directory
|
||||
|
||||
```bash
|
||||
vet scan -D /path/to/target \
|
||||
--exclude 'docs/*' \
|
||||
--exclude 'sub/dir/path/*'
|
||||
```
|
||||
|
||||
:::info
|
||||
|
||||
The exclusion pattern matches any path, directory or file. Internally it uses
|
||||
Go regexp [MatchString](https://pkg.go.dev/regexp#MatchString)
|
||||
|
||||
:::
|
||||
@ -1,66 +0,0 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: 📜 Policy as Code
|
||||
---
|
||||
|
||||
# 📜 Policy as Code (PaC)
|
||||
|
||||
## What is Policy as Code?
|
||||
|
||||
The idea of treating policy as code / config / rules that can be evaluated by tools to make runtime decisions. Generally, policy as code approach reduces ***cost of compliance*** by automating grunt work required to audit for policy violations.
|
||||
|
||||
## Why use Policy as Code?
|
||||
|
||||
Manual verification and approval of new software components is, to put it simply, tedious, contentious, error prone and incomplete. It is not possible to manually analyses a 3rd party dependency and its transitive dependencies. We believe organizational software supply chain policies should be codified so that tools can be build to automatically evaluate every 3rd party artifact for compliance against policies.
|
||||
|
||||
Examples of software supply chain policy may include:
|
||||
|
||||
- Dependencies must not have known critical vulnerabilities
|
||||
- Dependencies must be maintained
|
||||
- Dependencies must not be new / must have an extensive user base
|
||||
- Publishers must follow security standards e.g. [SLSA](https://slsa.dev/)
|
||||
|
||||
## Filter Suite
|
||||
|
||||
A single filter is useful for identification of packages that meet some specific criteria. While it helps solve various use-cases, it is not entirely suitable for `security guardrails` where multiple filters may be required to express an organization's acceptable OSS usage policy.
|
||||
|
||||
- For example, an organization may define a filter to deny certain type of packages
|
||||
|
||||
1. Any package that has a high or critical vulnerability
|
||||
2. Any package that does not match acceptable OSS licenses
|
||||
3. Any package that has a low [OpenSSF scorecard score](https://github.com/ossf/scorecard)
|
||||
|
||||
- To express this policy, multiple filters are needed such as
|
||||
|
||||
```bash
|
||||
vulns.critical.exists(p, true) ||
|
||||
licenses.exists(p, (p != "MIT") && (p != "Apache-2.0")) ||
|
||||
(scorecard.scores.Maintained == 0)
|
||||
```
|
||||
|
||||
- To solve this problem, we introduce the concept of `Filter Suite`. It can be represented as an YAML file containing multiple filters to match:
|
||||
|
||||
```yaml
|
||||
name: Generic Filter Suite
|
||||
description: Example filter suite with canned filters
|
||||
filters:
|
||||
- name: critical-vuln
|
||||
value: |
|
||||
vulns.critical.exists(p, true)
|
||||
- name: safe-licenses
|
||||
value: |
|
||||
licenses.exists(p, (p != "MIT") && (p != "Apache-2.0"))
|
||||
- name: ossf-maintained
|
||||
value: |
|
||||
scorecard.scores.Maintained == 0
|
||||
```
|
||||
|
||||
- A scan or query operation can be invoked using the filter suite:
|
||||
|
||||
```bash
|
||||
vet scan -D /path/to/repo --filter-suite /path/to/filters.yml --filter-fail
|
||||
```
|
||||
|
||||
- The filter suite will be evaluated as
|
||||
- Ordered list of filters as given in the suite file
|
||||
- Stop on first rule match for a given package
|
||||
@ -1,65 +0,0 @@
|
||||
---
|
||||
sidebar_position: 9
|
||||
title: ⏩ Shell Completion
|
||||
---
|
||||
|
||||
# ⏩ Shell completion for vet
|
||||
|
||||
Command-line completion or Shell completion is a feature provided by shells like `bash` or `zsh` that lets you type commands in a fast and easy way. This functionality automatically fills in partially typed commands when the user press the `tab` key.
|
||||
|
||||
- To enable shell completion for `vet` for various shells follow the below steps
|
||||
|
||||
## 1. Identify your current environment shell
|
||||
|
||||
```bash
|
||||
❯ echo $SHELL
|
||||
|
||||
/bin/zsh
|
||||
```
|
||||
|
||||
## 2. Generate the completion command for your shell
|
||||
|
||||
```zsh
|
||||
❯ vet completion zsh -h
|
||||
|
||||
Generate the autocompletion script for the zsh shell.
|
||||
|
||||
If shell completion is not already enabled in your environment you will need
|
||||
to enable it. You can execute the following once:
|
||||
|
||||
echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
To load completions in your current shell session:
|
||||
|
||||
source <(vet completion zsh); compdef _vet vet
|
||||
|
||||
To load completions for every new session, execute once:
|
||||
|
||||
#### Linux:
|
||||
|
||||
vet completion zsh > "${fpath[1]}/_vet"
|
||||
|
||||
#### macOS:
|
||||
|
||||
vet completion zsh > $(brew --prefix)/share/zsh/site-functions/_vet
|
||||
|
||||
You will need to start a new shell for this setup to take effect.
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 3. Run the commands to setup completion
|
||||
|
||||
```zsh
|
||||
echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
source <(vet completion zsh); compdef _vet vet
|
||||
vet completion zsh > $(brew --prefix)/share/zsh/site-functions/_vet
|
||||
```
|
||||
|
||||
## 4. Open new shell and you can see the completion activated
|
||||
|
||||
```zsh
|
||||
❯ vet [tab]
|
||||
```
|
||||
|
||||

|
||||
@ -1,10 +0,0 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: 🏛️ Architecture
|
||||
---
|
||||
|
||||
# 🏛️ Vet Architecture
|
||||
|
||||
Here is a very high-level overview of `vet` architecture from 10,000 foot view to solve your Open Source Risks. Vet has been create with powerful design & architecture to be scalable, efficient and extendable.
|
||||
|
||||

|
||||
@ -1,12 +0,0 @@
|
||||
---
|
||||
sidebar_position: 17
|
||||
title: 🎊 Community
|
||||
---
|
||||
|
||||
# 🎊 Community
|
||||
|
||||
First of all, thank you so much for showing interest in `vet`, we appreciate it ❤️
|
||||
|
||||
**Join the server using the link - https://rebrand.ly/safedep-community**
|
||||
|
||||
[](https://rebrand.ly/safedep-community)
|
||||
@ -1,177 +0,0 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: 🧩 Configuration
|
||||
---
|
||||
|
||||
# 🧩 Configuring Vet
|
||||
|
||||
`vet` comes with super powers 🚀, this section will help you to understand and explore some of them so that you can take your open source security to next level 😎
|
||||
|
||||

|
||||
|
||||
## API Key
|
||||
|
||||
`vet` uses control plane API for the insights required to enrich the information of dependencies, and its information.
|
||||
|
||||
### Generating an API key
|
||||
|
||||
- You can run the following command with your email address to receive an API key. After running the following command, you will receive an email with the API key.
|
||||
|
||||
```bash
|
||||
vet auth trial --email john.doe@example.com
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Configuring an API key
|
||||
|
||||
- You can configure the api key using the following command
|
||||
|
||||
```bash
|
||||
vet auth configure
|
||||
```
|
||||
|
||||

|
||||
|
||||
- You can also pass the API key through environment variable using the variable `VET_API_KEY`
|
||||
|
||||
### Renewing an API key
|
||||
|
||||
- To renew an API key, you can re-register using the email. Even reach out to us at [contact@safedep.io](mailto:contact@safedep.io) and we would be happy to work with you
|
||||
|
||||
## Using Community Mode
|
||||
|
||||
- You can use community endpoint for Insights API without API key
|
||||
|
||||
```bash
|
||||
vet auth configure --community
|
||||
```
|
||||
|
||||
- For CI job, set environment variable `VET_COMMUNITY_MODE=true` to enable community runtime mode
|
||||
|
||||
## Scanning
|
||||
|
||||
### Scanning Directories
|
||||
|
||||
- If you wanted to scan the whole directory & automatically parse the dependencies/lockfile, you can use the `-D` or `--directory` flag.
|
||||
|
||||
```bash
|
||||
vet scan -D your-code/directory/path/
|
||||
```
|
||||
|
||||
:::info
|
||||
|
||||
If you do not specify any directory, by default it takes present working directory as the input.
|
||||
|
||||
:::
|
||||
|
||||
### Scanning Files
|
||||
|
||||
- If you wanted to scan the specific file `lockfile` you can use the `-L` or `--lockfiles` flag.
|
||||
|
||||
```bash
|
||||
vet scan -D your-code/directory/path/
|
||||
```
|
||||
|
||||
:::info
|
||||
|
||||
If you do not specify any directory, by default it takes present working directory as the input.
|
||||
|
||||
:::
|
||||
|
||||
### Scanning Non-standard files
|
||||
|
||||
- Sometimes you might have non-standard filenames for the dependencies, lockfiles. You can scan them as a supported package manifest with a non-standard name using the following command
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/gradle-compileOnly.lock --lockfile-as gradle.lockfile
|
||||
```
|
||||
|
||||
### Scanning Multiple files
|
||||
|
||||
```bash
|
||||
vet scan --lockfiles /path/to/gradle.lockfile --lockfiles requirements.txt
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Scanning Parsers
|
||||
|
||||
`vet` currently has 10 scanning parsers for various dependencies formats including Go, Python, Java, etc.
|
||||
|
||||
```bash
|
||||
❯ vet scan parsers
|
||||
Available Lockfile Parsers
|
||||
==========================
|
||||
|
||||
[0] buildscript-gradle.lockfile
|
||||
[1] go.mod
|
||||
[2] gradle.lockfile
|
||||
[3] package-lock.json
|
||||
[4] Pipfile.lock
|
||||
[5] pnpm-lock.yaml
|
||||
[6] poetry.lock
|
||||
[7] pom.xml
|
||||
[8] requirements.txt
|
||||
[9] yarn.lock
|
||||
```
|
||||
|
||||
## Scan Options
|
||||
|
||||
### Silent scan
|
||||
|
||||
- `vet` supports silent scan to prevent rendering UI using the following command with `-s` or `--silent` flag
|
||||
|
||||
```bash
|
||||
vet scan -s --lockfiles demo-client-java/gradle.lockfile
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Scan concurrency
|
||||
|
||||
- By default it set to `5`, you can increase or decrease using the `--concurrency` or `-C` flag
|
||||
|
||||
```bash
|
||||
❯ vet scan -C 10 --lockfiles demo-client-java/gradle.lockfile
|
||||
Scanning packages ... done! [115 in 5.87s]
|
||||
Scanning manifests ... done! [1 in 5.87s]
|
||||
```
|
||||
|
||||
- You can see the difference between the above and below scan time with same file(s)
|
||||
|
||||
```bash
|
||||
❯ vet scan -C 1 --lockfiles demo-client-java/gradle.lockfile
|
||||
Scanning packages ... done! [115 in 10.567s]
|
||||
Scanning manifests ... done! [1 in 10.567s]
|
||||
```
|
||||
|
||||
### Scanning transitive dependencies
|
||||
|
||||
- You can perform the transitive dependencies scan by running the following command with `--transitive` flag
|
||||
|
||||
```bash
|
||||
vet scan --transitive --lockfiles demo-client-java/gradle.lockfile
|
||||
```
|
||||
|
||||

|
||||
|
||||
- As you can see the above scan has found issues across `201` libraries
|
||||
|
||||
### Configuring transitive dependencies depth level
|
||||
|
||||
- You can change the transitive dependencies scan depth by running the following command with `--transitive-depth` flag
|
||||
|
||||
```bash
|
||||
vet scan --transitive --transitive-depth 5 --lockfiles demo-client-java/gradle.lockfile
|
||||
```
|
||||
|
||||

|
||||
|
||||
- As you can see the above scan has found issues across `237` libraries
|
||||
|
||||
:::info
|
||||
|
||||
By default if you don't specify the flag it takes `2` as depth
|
||||
|
||||
:::
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"label": "🌐 Ecosystem",
|
||||
"position": 15,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "🚧 Work-in-Progress (WIP): SafeDep's vet ecosystem"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
# Artifactory Systems
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
|
||||
## JFrog
|
||||
|
||||
## Nexus
|
||||
@ -1,3 +0,0 @@
|
||||
# Developer Tooling
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
@ -1,9 +0,0 @@
|
||||
# Gateways
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
|
||||
## Kong API Gateway
|
||||
|
||||
## AWS API Gateway
|
||||
|
||||
## Traefik Gateway
|
||||
@ -1,11 +0,0 @@
|
||||
# IDE
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
|
||||
## VSCode
|
||||
|
||||
## JetBrains
|
||||
|
||||
## Vim
|
||||
|
||||
## NeoVim
|
||||
@ -1,9 +0,0 @@
|
||||
# Integrations
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
## Gitlab CI
|
||||
|
||||
## Bitbucket Pipelines
|
||||
@ -1,9 +0,0 @@
|
||||
# Logging & Monitoring Systems
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
|
||||
## Elastic Stack
|
||||
|
||||
## Splunk
|
||||
|
||||
## DataDog
|
||||
@ -1,11 +0,0 @@
|
||||
# Service Mesh
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
|
||||
## Istio - Service Mesh
|
||||
|
||||
## LinkerD - Service Mesh
|
||||
|
||||
## Cilium - Service Mesh
|
||||
|
||||
## Kong - Service Mesh
|
||||
@ -1,11 +0,0 @@
|
||||
# Systems & Solutions
|
||||
|
||||
🚧 Work-in-Progress (WIP)
|
||||
|
||||
## Kubernetes
|
||||
|
||||
## Nomad
|
||||
|
||||
## AWS Fargate
|
||||
|
||||
## Google Cloud Run
|
||||