diff --git a/README.md b/README.md index 4059e186..4d1dd756 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,65 @@ docker run -v $(pwd):/root debricked/cli:2-resolution-debian debricked scan -t < ### CI/CD integration If you would rather use `debricked` in your CI/CD pipelines, check out the [templates](examples/templates/README.md). +## MCP +`debricked mcp start` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, letting +AI coding assistants (Claude, Copilot, Cursor, etc.) check whether a dependency is allowed by your Fortify SCA +policies before it's installed. + +1. Authenticate once with `debricked auth login`, or have a [Debricked access token](https://docs.debricked.com/product/administration/generate-access-token) ready to pass via `--access-token`/`DEBRICKED_TOKEN`. +2. Point your MCP client at the `debricked` binary with the `mcp start` arguments. Examples below assume `debricked` + is on your `PATH`; otherwise use the full path to the binary. + +### VS Code (`.vscode/mcp.json`) +```jsonc +{ + "servers": { + "debricked": { + "type": "stdio", + "command": "debricked", + "args": ["mcp", "start"] + } + } +} +``` + +### Claude Desktop (`claude_desktop_config.json`) +```jsonc +{ + "mcpServers": { + "debricked": { + "type": "stdio", + "command": "debricked", + "args": ["mcp", "start"] + } + } +} +``` + +### Cursor (`.cursor/mcp.json`) +```jsonc +{ + "mcpServers": { + "debricked": { + "type": "stdio", + "command": "debricked", + "args": ["mcp", "start"] + } + } +} +``` + +If you haven't run `debricked auth login`, pass the token explicitly instead: +```jsonc +{ + "command": "debricked", + "args": ["mcp", "start"], + "env": { + "DEBRICKED_TOKEN": "" + } +} +``` + ## Policy Validate your `debricked_policy.json` against Debricked's policy schema before you scan, so that a broken policy file is caught locally instead of in your pipeline. diff --git a/build/docker/debian.Dockerfile b/build/docker/debian.Dockerfile index d21b2bb7..3bedc813 100644 --- a/build/docker/debian.Dockerfile +++ b/build/docker/debian.Dockerfile @@ -126,6 +126,7 @@ RUN apt -y update && apt -y install --no-install-recommends \ libsqlite3-dev \ libsqlite3-0 \ libstdc++6 \ + zip \ libxml2-dev \ libxml2 \ libz3-4 \ diff --git a/go.mod b/go.mod index ab7779e0..7cf94551 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/becheran/wildmatch-go v1.0.0 github.com/bmatcuk/doublestar/v4 v4.6.0 github.com/chelnak/ysmrr v0.2.1 + github.com/debricked/fortify-sca-mcp/v26 v26.3.2 github.com/fatih/color v1.16.0 github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.11.0 @@ -19,8 +20,8 @@ require ( github.com/stretchr/testify v1.9.0 github.com/vifraa/gopom v0.2.1 github.com/zalando/go-keyring v0.2.5 - golang.org/x/oauth2 v0.22.0 - golang.org/x/tools v0.36.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/tools v0.42.0 gopkg.in/yaml.v3 v3.0.1 lukechampine.com/blake3 v1.2.1 ) @@ -39,6 +40,7 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -51,12 +53,15 @@ require ( github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/skeema/knownhosts v1.2.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -66,16 +71,18 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.41.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.15.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 09afc495..2175b038 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/debricked/fortify-sca-mcp/v26 v26.3.2 h1:33vsbzmTT9Pfs+QQohwOCyCPOcA49X87ryUeK06DpbQ= +github.com/debricked/fortify-sca-mcp/v26 v26.3.2/go.mod h1:CK+FgE9i0E2YS0WOljHDuFVZdE3GQrggoqDuF9C+CYw= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -54,10 +56,14 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -99,6 +105,8 @@ github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2Em github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -125,6 +133,10 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -163,6 +175,8 @@ github.com/vifraa/gopom v0.2.1 h1:MYVMAMyiGzXPPy10EwojzKIL670kl5Zbae+o3fFvQEM= github.com/vifraa/gopom v0.2.1/go.mod h1:oPa1dcrGrtlO37WPDBm5SqHAT+wTgF8An1Q71Z6Vv4o= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= @@ -175,14 +189,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -190,15 +204,15 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= -golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -214,15 +228,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -230,14 +244,16 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/mcp/fake_authenticator_test.go b/internal/cmd/mcp/fake_authenticator_test.go new file mode 100644 index 00000000..945d14fa --- /dev/null +++ b/internal/cmd/mcp/fake_authenticator_test.go @@ -0,0 +1,17 @@ +package mcp + +import ( + "golang.org/x/oauth2" +) + +// fakeAuthenticator is a test double for auth.IAuthenticator. +type fakeAuthenticator struct { + token *oauth2.Token + err error +} + +func (f fakeAuthenticator) Authenticate() error { return nil } +func (f fakeAuthenticator) Logout() error { return nil } +func (f fakeAuthenticator) Token() (*oauth2.Token, error) { + return f.token, f.err +} diff --git a/internal/cmd/mcp/mcp.go b/internal/cmd/mcp/mcp.go new file mode 100644 index 00000000..b336dc3b --- /dev/null +++ b/internal/cmd/mcp/mcp.go @@ -0,0 +1,21 @@ +package mcp + +import ( + "github.com/debricked/cli/internal/auth" + "github.com/spf13/cobra" +) + +// NewMCPCmd creates the parent `mcp` command, grouping MCP related subcommands. +// accessToken is the resolved root-level access token (from --access-token / DEBRICKED_TOKEN). +// authenticator provides a fallback to the CLI's cached login when accessToken is empty. +// baseURL is the Debricked host (respects DEBRICKED_URI) the MCP server should talk to. +func NewMCPCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL string) *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Debricked MCP server.", + Long: `Run a Model Context Protocol (MCP) server exposing Debricked functionality.`, + } + cmd.AddCommand(NewStartCmd(accessToken, authenticator, baseURL)) + + return cmd +} diff --git a/internal/cmd/mcp/mcp_test.go b/internal/cmd/mcp/mcp_test.go new file mode 100644 index 00000000..ac235444 --- /dev/null +++ b/internal/cmd/mcp/mcp_test.go @@ -0,0 +1,25 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewMCPCmd(t *testing.T) { + token := "" + cmd := NewMCPCmd(&token, fakeAuthenticator{}, "https://debricked.com") + commands := cmd.Commands() + nbrOfCommands := 1 + assert.Lenf(t, commands, nbrOfCommands, "failed to assert that there were %d sub commands connected", nbrOfCommands) + + match := false + for _, subCmd := range commands { + if subCmd.Name() == "start" { + match = true + + break + } + } + assert.True(t, match, "failed to assert that start command was registered") +} diff --git a/internal/cmd/mcp/start.go b/internal/cmd/mcp/start.go new file mode 100644 index 00000000..4eb69cb2 --- /dev/null +++ b/internal/cmd/mcp/start.go @@ -0,0 +1,87 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/cmd/cmderror" + "github.com/debricked/fortify-sca-mcp/v26/pkg/server" + "github.com/spf13/cobra" +) + +// serveFn is a seam over server.Serve to allow tests to substitute a fake implementation. +var serveFn = server.Serve + +// verifyFn is a seam over server.VerifyAccessToken to allow tests to substitute a fake implementation. +var verifyFn = server.VerifyAccessToken + +// apiVersion matches the Debricked API version the rest of the CLI targets (e.g. /api/1.0/...). +const apiVersion = "1.0" + +// NewStartCmd creates the `mcp start` command, which runs an MCP server over stdio. +func NewStartCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL string) *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Start the Debricked MCP server over stdio.", + Long: `Start a Model Context Protocol (MCP) server, communicating over stdin/stdout, until the client disconnects or the process is interrupted.`, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + token, resolveErr := resolveToken(*accessToken, authenticator) + if token == "" { + err := errors.New("no access token found. Pass --access-token/DEBRICKED_TOKEN, or run `debricked auth login` first") + if resolveErr != nil { + err = fmt.Errorf("%w (%v)", err, resolveErr) + } + + return cmderror.CommandError{Code: 1, Err: err} + } + + options := server.Options{AccessToken: token, BaseURL: baseURL, APIVersion: apiVersion} + + // Fail fast on bad credentials instead of only discovering it on the first tool call. + if err := verifyFn(cmd.Context(), options); err != nil { + return cmderror.CommandError{Code: 1, Err: fmt.Errorf("access token rejected: %w", err)} + } + + // stdout is reserved for MCP JSON-RPC traffic, so status goes to stderr. + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "debricked MCP server authenticated, running on stdio. Hit Ctrl-C to exit."); err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + err := serveFn(cmd.Context(), options, os.Stdin, os.Stdout) + if err == nil || errors.Is(err, context.Canceled) { + return nil + } + + return cmderror.CommandError{Code: 1, Err: err} + }, + } + + return cmd +} + +// resolveToken prefers an explicit access token, falling back to the CLI's cached login. +// The Fortify SCA MCP server treats this value as a refresh token, so the cached +// session's RefreshToken is used rather than its short-lived JWT AccessToken. +// The returned error (only set when the token is empty) carries the underlying +// cause, e.g. a keyring failure vs. simply never having logged in. +func resolveToken(accessToken string, authenticator auth.IAuthenticator) (string, error) { + if token := strings.TrimSpace(accessToken); token != "" { + return token, nil + } + + cachedToken, err := authenticator.Token() + if err != nil { + return "", err + } + if cachedToken == nil || strings.TrimSpace(cachedToken.RefreshToken) == "" { + return "", errors.New("cached login has no refresh token") + } + + return strings.TrimSpace(cachedToken.RefreshToken), nil +} diff --git a/internal/cmd/mcp/start_test.go b/internal/cmd/mcp/start_test.go new file mode 100644 index 00000000..28a5b37e --- /dev/null +++ b/internal/cmd/mcp/start_test.go @@ -0,0 +1,281 @@ +package mcp + +import ( + "context" + "errors" + "io" + "os" + "testing" + + "github.com/debricked/cli/internal/cmd/cmderror" + "github.com/debricked/fortify-sca-mcp/v26/pkg/server" + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func TestNewStartCmdMissingToken(t *testing.T) { + token := "" + cmd := NewStartCmd(&token, fakeAuthenticator{err: errors.New("not logged in")}, "https://debricked.com") + cmd.SetArgs([]string{}) + + err := cmd.Execute() + + assert.Error(t, err) + var cmdErr cmderror.CommandError + assert.ErrorAs(t, err, &cmdErr) + assert.Contains(t, err.Error(), "not logged in") +} + +func TestNewStartCmdExplicitTokenTakesPrecedence(t *testing.T) { + original := serveFn + defer func() { serveFn = original }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + verifyFn = func(context.Context, server.Options) error { return nil } + + var gotToken string + serveFn = func(_ context.Context, options server.Options, _ io.Reader, _ io.Writer) error { + gotToken = options.AccessToken + + return nil + } + + token := "explicit-token" + authenticator := fakeAuthenticator{token: &oauth2.Token{RefreshToken: "cached-refresh-token"}} //nolint:gosec // test fixture, not a real credential + cmd := NewStartCmd(&token, authenticator, "https://debricked.com") + + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, "explicit-token", gotToken) +} + +func TestNewStartCmdFallsBackToCachedLogin(t *testing.T) { + original := serveFn + defer func() { serveFn = original }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + verifyFn = func(context.Context, server.Options) error { return nil } + + var gotToken string + serveFn = func(_ context.Context, options server.Options, _ io.Reader, _ io.Writer) error { + gotToken = options.AccessToken + + return nil + } + + token := "" + authenticator := fakeAuthenticator{token: &oauth2.Token{ //nolint:gosec // test fixture, not a real credential + AccessToken: "cached-jwt", + RefreshToken: "cached-refresh-token", + }} + cmd := NewStartCmd(&token, authenticator, "https://debricked.com") + + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, "cached-refresh-token", gotToken) +} + +func TestNewStartCmdPropagatesBaseURL(t *testing.T) { + original := serveFn + defer func() { serveFn = original }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + + var gotVerifyBaseURL, gotServeBaseURL, gotServeAPIVersion string + verifyFn = func(_ context.Context, options server.Options) error { + gotVerifyBaseURL = options.BaseURL + + return nil + } + serveFn = func(_ context.Context, options server.Options, _ io.Reader, _ io.Writer) error { + gotServeBaseURL = options.BaseURL + gotServeAPIVersion = options.APIVersion + + return nil + } + + token := "token123" + cmd := NewStartCmd(&token, fakeAuthenticator{}, "https://on-prem.example.com") + + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, "https://on-prem.example.com", gotVerifyBaseURL) + assert.Equal(t, "https://on-prem.example.com", gotServeBaseURL) + assert.Equal(t, "1.0", gotServeAPIVersion) +} + +func TestNewStartCmdVerifyFailurePreventsServe(t *testing.T) { + originalServe := serveFn + defer func() { serveFn = originalServe }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + + serveCalled := false + serveFn = func(context.Context, server.Options, io.Reader, io.Writer) error { + serveCalled = true + + return nil + } + verifyFn = func(context.Context, server.Options) error { + return server.ErrUnauthorized + } + + token := "bad-token" + cmd := NewStartCmd(&token, fakeAuthenticator{}, "https://debricked.com") + + err := cmd.Execute() + + assert.Error(t, err) + var cmdErr cmderror.CommandError + assert.ErrorAs(t, err, &cmdErr) + assert.False(t, serveCalled, "serveFn should not run when verification fails") +} + +func TestNewStartCmdContextPropagation(t *testing.T) { + original := serveFn + defer func() { serveFn = original }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + verifyFn = func(context.Context, server.Options) error { return nil } + + var gotCtx context.Context + serveFn = func(ctx context.Context, options server.Options, _ io.Reader, _ io.Writer) error { + gotCtx = ctx + assert.Equal(t, "token123", options.AccessToken) + + return nil + } + + token := "token123" + cmd := NewStartCmd(&token, fakeAuthenticator{}, "https://debricked.com") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd.SetContext(ctx) + + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, ctx, gotCtx) +} + +func TestNewStartCmdContextCanceledIsCleanExit(t *testing.T) { + original := serveFn + defer func() { serveFn = original }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + verifyFn = func(context.Context, server.Options) error { return nil } + + serveFn = func(_ context.Context, _ server.Options, _ io.Reader, _ io.Writer) error { + return context.Canceled + } + + token := "token123" + cmd := NewStartCmd(&token, fakeAuthenticator{}, "https://debricked.com") + + err := cmd.Execute() + + assert.NoError(t, err) +} + +func TestNewStartCmdServeError(t *testing.T) { + original := serveFn + defer func() { serveFn = original }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + verifyFn = func(context.Context, server.Options) error { return nil } + + serveFn = func(_ context.Context, _ server.Options, _ io.Reader, _ io.Writer) error { + return errors.New("boom") + } + + token := "token123" + cmd := NewStartCmd(&token, fakeAuthenticator{}, "https://debricked.com") + + err := cmd.Execute() + + assert.Error(t, err) + var cmdErr cmderror.CommandError + assert.ErrorAs(t, err, &cmdErr) +} + +func TestNewStartCmdNoStdout(t *testing.T) { + original := serveFn + defer func() { serveFn = original }() + originalVerify := verifyFn + defer func() { verifyFn = originalVerify }() + verifyFn = func(context.Context, server.Options) error { return nil } + serveFn = func(_ context.Context, _ server.Options, _ io.Reader, _ io.Writer) error { + return nil + } + + oldStdout := os.Stdout + r, w, err := os.Pipe() + assert.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = oldStdout }() + + token := "token123" + cmd := NewStartCmd(&token, fakeAuthenticator{}, "https://debricked.com") + execErr := cmd.Execute() + + _ = w.Close() + os.Stdout = oldStdout + out, readErr := io.ReadAll(r) + assert.NoError(t, readErr) + + assert.NoError(t, execErr) + assert.Empty(t, out) +} + +func TestResolveToken(t *testing.T) { + tests := []struct { + name string + explicit string + authenticator fakeAuthenticator + wantToken string + wantErr bool + }{ + { + name: "explicit token wins over cached login", + explicit: "explicit-token", + authenticator: fakeAuthenticator{token: &oauth2.Token{RefreshToken: "cached-refresh-token"}}, //nolint:gosec // test fixture, not a real credential + wantToken: "explicit-token", + }, + { //nolint:gosec // test fixture, not a real credential + name: "falls back to cached refresh token", + explicit: "", + authenticator: fakeAuthenticator{token: &oauth2.Token{AccessToken: "jwt", RefreshToken: "cached-refresh-token"}}, //nolint:gosec // test fixture, not a real credential + wantToken: "cached-refresh-token", + }, + { + name: "authenticator error surfaces as error", + explicit: "", + authenticator: fakeAuthenticator{err: errors.New("keyring unavailable")}, + wantToken: "", + wantErr: true, + }, + { + name: "cached login with empty refresh token errors", + explicit: "", + authenticator: fakeAuthenticator{token: &oauth2.Token{}}, + wantToken: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotToken, err := resolveToken(tt.explicit, tt.authenticator) + + assert.Equal(t, tt.wantToken, gotToken) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 06c91b3d..616d7ead 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -5,6 +5,7 @@ import ( "github.com/debricked/cli/internal/cmd/callgraph" "github.com/debricked/cli/internal/cmd/files" "github.com/debricked/cli/internal/cmd/fingerprint" + "github.com/debricked/cli/internal/cmd/mcp" "github.com/debricked/cli/internal/cmd/policy" "github.com/debricked/cli/internal/cmd/report" "github.com/debricked/cli/internal/cmd/resolve" @@ -55,6 +56,7 @@ Read more: https://docs.debricked.com/product/administration/generate-access-tok rootCmd.AddCommand(callgraph.NewCallgraphCmd(container.CallgraphGenerator())) rootCmd.AddCommand(auth.NewAuthCmd(container.Authenticator())) rootCmd.AddCommand(policy.NewPolicyCmd(container.PolicyValidator())) + rootCmd.AddCommand(mcp.NewMCPCmd(&accessToken, container.Authenticator(), container.DebClient().Host())) rootCmd.CompletionOptions.DisableDefaultCmd = true diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index 084385a4..17c9fe74 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -11,7 +11,7 @@ import ( func TestNewRootCmd(t *testing.T) { cmd := NewRootCmd("v0.0.0", wire.GetCliContainer()) commands := cmd.Commands() - nbrOfCommands := 8 + nbrOfCommands := 9 if len(commands) != nbrOfCommands { t.Errorf( "failed to assert that there were %d sub commands connected (was %d)", @@ -36,6 +36,16 @@ func TestNewRootCmd(t *testing.T) { } assert.Truef(t, match, "failed to assert that flag was present: "+OldAccessTokenFlag) assert.Len(t, viperKeys, 23) + + mcpMatch := false + for _, subCmd := range commands { + if subCmd.Name() == "mcp" { + mcpMatch = true + + break + } + } + assert.True(t, mcpMatch, "failed to assert that mcp command was registered") } func TestPreRun(t *testing.T) {