From 7a6e29ec8eabb5ff75289b50066ef8d656514425 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Mon, 6 Jul 2026 03:37:24 +0200 Subject: [PATCH 1/2] grpc library --- go.mod | 15 +- go.sum | 26 ++ .../tests/integration/javascript_grpc_test.go | 61 ++++ internal/tests/integration/javascript_test.go | 2 + .../protocols/javascript/grpc-denied.yaml | 37 +++ .../protocols/javascript/grpc-health.yaml | 31 ++ pkg/js/compiler/grpc_integration_test.go | 61 ++++ pkg/js/compiler/pool.go | 1 + pkg/js/generated/go/libgrpc/grpc.go | 31 ++ pkg/js/generated/ts/grpc.ts | 154 ++++++++++ pkg/js/generated/ts/index.ts | 1 + pkg/js/libs/grpc/grpc.go | 247 +++++++++++++++ pkg/js/libs/grpc/grpc_methods_test.go | 285 ++++++++++++++++++ pkg/js/libs/grpc/grpc_test.go | 266 ++++++++++++++++ pkg/js/libs/grpc/invoke.go | 163 ++++++++++ 15 files changed, 1380 insertions(+), 1 deletion(-) create mode 100644 internal/tests/integration/javascript_grpc_test.go create mode 100644 internal/tests/integration/testdata/protocols/javascript/grpc-denied.yaml create mode 100644 internal/tests/integration/testdata/protocols/javascript/grpc-health.yaml create mode 100644 pkg/js/compiler/grpc_integration_test.go create mode 100644 pkg/js/generated/go/libgrpc/grpc.go create mode 100644 pkg/js/generated/ts/grpc.ts create mode 100644 pkg/js/libs/grpc/grpc.go create mode 100644 pkg/js/libs/grpc/grpc_methods_test.go create mode 100644 pkg/js/libs/grpc/grpc_test.go create mode 100644 pkg/js/libs/grpc/invoke.go diff --git a/go.mod b/go.mod index 759323a05b..e0253567db 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c github.com/docker/go-units v0.5.0 github.com/fatih/structs v1.1.0 + github.com/fullstorydev/grpcurl v1.9.3 github.com/getkin/kin-openapi v0.132.0 github.com/go-git/go-git/v5 v5.19.1 github.com/go-ldap/ldap/v3 v3.4.12 @@ -78,6 +79,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jcmturner/gokrb5/v8 v8.4.4 + github.com/jhump/protoreflect v1.17.0 github.com/kitabisa/go-ci v1.0.3 github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa github.com/lib/pq v1.11.2 @@ -124,6 +126,7 @@ require ( go.mongodb.org/mongo-driver v1.17.9 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 + google.golang.org/grpc v1.72.2 gopkg.in/yaml.v3 v3.0.1 moul.io/http2curl v1.0.0 ) @@ -177,6 +180,7 @@ require ( github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect + github.com/bufbuild/protocompile v0.14.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -195,6 +199,7 @@ require ( github.com/cloudflare/cfssl v1.6.4 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -213,6 +218,8 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/fgprof v0.9.5 // indirect @@ -227,6 +234,7 @@ require ( github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -241,6 +249,7 @@ require ( github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect @@ -316,6 +325,7 @@ require ( github.com/pierrec/lz4/v4 v4.1.23 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/projectdiscovery/asnmap v1.1.1 // indirect @@ -334,6 +344,7 @@ require ( github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/tidwall/btree v1.8.1 // indirect github.com/tidwall/buntdb v1.3.2 // indirect @@ -372,6 +383,7 @@ require ( github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/zcalusic/sysinfo v1.1.3 // indirect github.com/zeebo/blake3 v0.2.4 // indirect + github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect @@ -381,6 +393,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/arch v0.3.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect mellium.im/sasl v0.3.2 // indirect software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect ) @@ -410,7 +423,7 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.45.0 - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.11 gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index e61f0b6b3b..2fcef041e3 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= @@ -282,6 +284,8 @@ github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a h1:Ohw57yVY2dBTt+gsC6aZdteyxwlxfbtgkFEMTEkwgSw= github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= @@ -337,7 +341,11 @@ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FM github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 h1:R/ZjJpjQKsZ6L/+Gf9WHbt31GG8NMVcpRqUE+1mMIyo= github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -357,6 +365,8 @@ github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b/go.mod h1:l2Jrml4vo github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fullstorydev/grpcurl v1.9.3 h1:PC1Xi3w+JAvEE2Tg2Gf2RfVgPbf9+tbuQr1ZkyVU3jk= +github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko= @@ -388,6 +398,8 @@ github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhE github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -492,6 +504,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -613,6 +627,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jlaffaye/ftp v0.0.0-20190624084859-c1312a7102bf/go.mod h1:lli8NYPQOFy3O++YmYbqVgOcQ1JPCwdOy+5zSjKJ9qY= @@ -810,6 +826,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -978,6 +996,8 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -1125,6 +1145,8 @@ github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= @@ -1538,6 +1560,8 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1550,6 +1574,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= +google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/internal/tests/integration/javascript_grpc_test.go b/internal/tests/integration/javascript_grpc_test.go new file mode 100644 index 0000000000..77874240f6 --- /dev/null +++ b/internal/tests/integration/javascript_grpc_test.go @@ -0,0 +1,61 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "fmt" + "net" + + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +// startGRPCHealthServer starts an in-process gRPC server exposing the standard +// health service (status SERVING) and server reflection on a random loopback +// port. It returns the "host:port" address and a stop func. +func startGRPCHealthServer() (string, func(), error) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", nil, fmt.Errorf("grpc health listen: %w", err) + } + srv := grpc.NewServer() + hs := health.NewServer() + hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(srv, hs) + reflection.Register(srv) + go func() { _ = srv.Serve(lis) }() + return lis.Addr().String(), srv.Stop, nil +} + +// javascriptGRPCHealth exercises the nuclei/grpc library end to end against a +// real gRPC server via server reflection. +type javascriptGRPCHealth struct{} + +func (j *javascriptGRPCHealth) Execute(filePath string) error { + address, stop, err := startGRPCHealthServer() + if err != nil { + return err + } + defer stop() + + results, err := runSignedNucleiTemplateAndGetResults(filePath, address, debug) + if err != nil { + return err + } + return expectResultsCount(results, 1) +} + +// javascriptGRPCDenied asserts the nuclei/grpc library refuses to dial a host +// on the exclude list (network-policy enforcement) before connecting. +type javascriptGRPCDenied struct{} + +func (j *javascriptGRPCDenied) Execute(filePath string) error { + results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10") + if err != nil { + return err + } + return expectResultsCount(results, 1) +} diff --git a/internal/tests/integration/javascript_test.go b/internal/tests/integration/javascript_test.go index 4a101dbcba..9432f6cc72 100644 --- a/internal/tests/integration/javascript_test.go +++ b/internal/tests/integration/javascript_test.go @@ -34,6 +34,8 @@ var jsTestcases = []integrationCase{ {Path: "protocols/javascript/ssh-server-fingerprint.yaml", TestCase: &javascriptSSHServerFingerprint{}, DisableOn: javascriptDockerDisabled, Serial: true}, {Path: "protocols/javascript/net-multi-step.yaml", TestCase: &networkMultiStep{}}, {Path: "protocols/javascript/net-https.yaml", TestCase: &javascriptNetHttps{}}, + {Path: "protocols/javascript/grpc-health.yaml", TestCase: &javascriptGRPCHealth{}}, + {Path: "protocols/javascript/grpc-denied.yaml", TestCase: &javascriptGRPCDenied{}}, {Path: "protocols/javascript/rsync-test.yaml", TestCase: &javascriptRsyncTest{}, DisableOn: javascriptDockerDisabled, Serial: true}, {Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true}, {Path: "protocols/javascript/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true}, diff --git a/internal/tests/integration/testdata/protocols/javascript/grpc-denied.yaml b/internal/tests/integration/testdata/protocols/javascript/grpc-denied.yaml new file mode 100644 index 0000000000..489faa9555 --- /dev/null +++ b/internal/tests/integration/testdata/protocols/javascript/grpc-denied.yaml @@ -0,0 +1,37 @@ +id: grpc-network-policy-denied + +info: + name: gRPC Network Policy Denial + author: pdteam + severity: info + description: | + Hardening check for the nuclei/grpc javascript library: dialing a host on + the exclude list (RFC 5737 TEST-NET-3) must be rejected by the network + policy before any connection is made. The error is captured and asserted. + +javascript: + - code: | + const grpc = require('nuclei/grpc'); + let out = ""; + try { + const opts = new grpc.Options(); + opts.Plaintext = true; + const client = new grpc.Client("203.0.113.10:50051", opts); + client.Invoke('grpc.health.v1.Health/Check', '{}'); + } catch (e) { + out = "" + e; + } + out; + args: + Host: "{{Host}}" + matchers-condition: and + matchers: + - type: dsl + dsl: + - "success == true" + - type: word + part: response + words: + - "network policy" + - "203.0.113.10" + condition: and diff --git a/internal/tests/integration/testdata/protocols/javascript/grpc-health.yaml b/internal/tests/integration/testdata/protocols/javascript/grpc-health.yaml new file mode 100644 index 0000000000..b59554b6ee --- /dev/null +++ b/internal/tests/integration/testdata/protocols/javascript/grpc-health.yaml @@ -0,0 +1,31 @@ +id: grpc-health-check + +info: + name: gRPC Health Check + author: pdteam + severity: info + description: | + Exercises the nuclei/grpc javascript library end to end: dial through the + network policy, resolve the schema via server reflection and invoke the + standard health check, expecting a SERVING response. + +javascript: + - code: | + const grpc = require('nuclei/grpc'); + const opts = new grpc.Options(); + opts.Plaintext = true; + const client = new grpc.Client(address, opts); + const resp = client.Invoke('grpc.health.v1.Health/Check', '{"service":""}'); + client.Close(); + resp; + args: + address: "{{Host}}:{{Port}}" + matchers-condition: and + matchers: + - type: dsl + dsl: + - "success == true" + - type: word + part: response + words: + - "SERVING" diff --git a/pkg/js/compiler/grpc_integration_test.go b/pkg/js/compiler/grpc_integration_test.go new file mode 100644 index 0000000000..24bcac7d4c --- /dev/null +++ b/pkg/js/compiler/grpc_integration_test.go @@ -0,0 +1,61 @@ +package compiler + +import ( + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" + + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" +) + +// TestGRPCModuleRequire proves the nuclei/grpc native module is registered in +// the runtime pool and reachable end to end from a JS template via require(). +func TestGRPCModuleRequire(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + srv := grpc.NewServer() + hs := health.NewServer() + hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(srv, hs) + reflection.Register(srv) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + executionID := "grpc-require-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) + require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: executionID})) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + script := fmt.Sprintf(` + const grpc = require('nuclei/grpc'); + const opts = new grpc.Options(); + opts.Plaintext = true; + const client = new grpc.Client(%q, opts); + const resp = client.Invoke('grpc.health.v1.Health/Check', '{"service":""}'); + client.Close(); + ExportAs('resp', resp); + true; + `, lis.Addr().String()) + + compiled, err := SourceAutoMode(script, false) + require.NoError(t, err) + + result, err := New().ExecuteWithOptions(t.Context(), compiled, NewExecuteArgs(), &ExecuteOptions{ + ExecutionId: executionID, + Source: &script, + TimeoutVariants: &types.Timeouts{ + JsCompilerExecutionTimeout: 10 * time.Second, + }, + }) + require.NoError(t, err) + require.Contains(t, fmt.Sprint(result["resp"]), "SERVING") +} diff --git a/pkg/js/compiler/pool.go b/pkg/js/compiler/pool.go index d9f38e2be9..e05ad2eebf 100644 --- a/pkg/js/compiler/pool.go +++ b/pkg/js/compiler/pool.go @@ -19,6 +19,7 @@ import ( _ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libdcerpc" _ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libdcom" _ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libfs" + _ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libgrpc" _ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libikev2" _ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkerberos" _ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbforge" diff --git a/pkg/js/generated/go/libgrpc/grpc.go b/pkg/js/generated/go/libgrpc/grpc.go new file mode 100644 index 0000000000..a0376a53fb --- /dev/null +++ b/pkg/js/generated/go/libgrpc/grpc.go @@ -0,0 +1,31 @@ +package grpc + +import ( + lib_grpc "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/grpc" + + "github.com/projectdiscovery/goja" + "github.com/projectdiscovery/nuclei/v3/pkg/js/gojs" +) + +var ( + module = gojs.NewGojaModule("nuclei/grpc") +) + +func init() { + module.Set( + gojs.Objects{ + // Functions + "NewClient": lib_grpc.NewClient, + + // Var and consts + + // Objects / Classes + "Client": lib_grpc.NewClient, + "Options": gojs.GetClassConstructor[lib_grpc.Options](&lib_grpc.Options{}), + }, + ).Register() +} + +func Enable(runtime *goja.Runtime) { + module.Enable(runtime) +} diff --git a/pkg/js/generated/ts/grpc.ts b/pkg/js/generated/ts/grpc.ts new file mode 100644 index 0000000000..1a3f6cb470 --- /dev/null +++ b/pkg/js/generated/ts/grpc.ts @@ -0,0 +1,154 @@ + + +/** + * Client is a gRPC client for nuclei JS templates backed by grpcurl. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * const resp = client.Invoke('grpc.health.v1.Health/Check', '{}'); + * ``` + */ +export class Client { + + + public Target?: string; + + + // Constructor of Client + constructor(target: string, options?: Options) {} + /** + * Connect eagerly establishes the connection and descriptor source. It is + * optional; other methods connect on demand. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * client.Connect(); + * ``` + */ + public Connect(): boolean | null { + return null; + } + + + /** + * ListServices returns the fully-qualified names of all services exposed by the + * target (via reflection) or defined in the configured protoset. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * const services = client.ListServices(); + * ``` + */ + public ListServices(): string[] | null { + return null; + } + + + /** + * ListMethods returns the fully-qualified method names of the given service. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * const methods = client.ListMethods('grpc.health.v1.Health'); + * ``` + */ + public ListMethods(service: string): string[] | null { + return null; + } + + + /** + * DescribeSymbol returns the textual descriptor of a fully-qualified symbol + * (service, method or message type). + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * const text = client.DescribeSymbol('grpc.health.v1.Health'); + * ``` + */ + public DescribeSymbol(symbol: string): string | null { + return null; + } + + + /** + * Invoke calls a unary gRPC method with a JSON request and returns the JSON + * response. The method must be in 'package.Service/Method' or + * 'package.Service.Method' form. An empty message is treated as '{}'. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * const resp = client.Invoke('grpc.health.v1.Health/Check', '{"service":""}'); + * ``` + */ + public Invoke(method: string, message: string): string | null { + return null; + } + + + /** + * InvokeWithHeaders behaves like Invoke but also sends the given request + * metadata. Each header must be in 'key: value' form. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * const resp = client.InvokeWithHeaders('acme.v1.Svc/Get', '{}', ['authorization: Bearer x']); + * ``` + */ + public InvokeWithHeaders(method: string, message: string, headers: string[]): string | null { + return null; + } + + + /** + * Close releases the connection and any descriptor source resources. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const client = new grpc.Client('grpc.acme.com:443'); + * client.Close(); + * ``` + */ + public Close(): boolean | null { + return null; + } + + +} + + + +/** + * Options configures a gRPC Client. All fields are optional; the zero value + * connects over TLS using the target host as the server name and resolves + * the schema via server reflection. + * @example + * ```javascript + * const grpc = require('nuclei/grpc'); + * const opts = new grpc.Options(); + * opts.Plaintext = true; + * opts.TimeoutSeconds = 10; + * const client = new grpc.Client('grpc.acme.com:443', opts); + * ``` + */ +export interface Options { + + Plaintext?: boolean, + + InsecureSkipVerify?: boolean, + + ServerName?: string, + + TimeoutSeconds?: number, + + ProtosetFile?: string, + + MaxRecvMsgSize?: number, +} diff --git a/pkg/js/generated/ts/index.ts b/pkg/js/generated/ts/index.ts index 8822da9a42..c5c0179f56 100644 --- a/pkg/js/generated/ts/index.ts +++ b/pkg/js/generated/ts/index.ts @@ -3,6 +3,7 @@ export * as dcom from './dcom'; export * as dcerpc from './dcerpc'; export * as fs from './fs'; export * as goconsole from './goconsole'; +export * as grpc from './grpc'; export * as ikev2 from './ikev2'; export * as kerberos from './kerberos'; export * as krbforge from './krbforge'; diff --git a/pkg/js/libs/grpc/grpc.go b/pkg/js/libs/grpc/grpc.go new file mode 100644 index 0000000000..9c7f752da7 --- /dev/null +++ b/pkg/js/libs/grpc/grpc.go @@ -0,0 +1,247 @@ +// Package grpc implements a nuclei JavaScript library that speaks gRPC by +// wrapping the grpcurl SDK. It supports server reflection and precompiled +// protoset descriptors, and routes every connection and file access through +// nuclei's network and local-file-access policies. +package grpc + +import ( + "context" + "net" + "time" + + "github.com/fullstorydev/grpcurl" + "github.com/projectdiscovery/goja" + "google.golang.org/grpc" + + "github.com/projectdiscovery/nuclei/v3/pkg/js/utils" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" +) + +type ( + // Options configures a gRPC Client. All fields are optional; the zero value + // connects over TLS using the target host as the server name and resolves + // the schema via server reflection. + // @example + // ```javascript + // const grpc = require('nuclei/grpc'); + // const opts = new grpc.Options(); + // opts.Plaintext = true; + // opts.TimeoutSeconds = 10; + // const client = new grpc.Client('grpc.acme.com:443', opts); + // ``` + Options struct { + // Plaintext disables TLS and uses cleartext HTTP/2 (h2c). + Plaintext bool + // InsecureSkipVerify disables TLS certificate verification. + InsecureSkipVerify bool + // ServerName overrides the TLS SNI / certificate server name. + ServerName string + // TimeoutSeconds is the per-call timeout. 0 means no explicit timeout. + TimeoutSeconds int + // ProtosetFile is the path to a compiled FileDescriptorSet (protoset). + // When set, server reflection is not used. The file is read through the + // local-file-access allowlist. + ProtosetFile string + // MaxRecvMsgSize overrides the maximum received message size in bytes. + MaxRecvMsgSize int + } +) + +type ( + // Client is a gRPC client for nuclei JS templates backed by grpcurl. + // @example + // ```javascript + // const grpc = require('nuclei/grpc'); + // const client = new grpc.Client('grpc.acme.com:443'); + // const resp = client.Invoke('grpc.health.v1.Health/Check', '{}'); + // ``` + Client struct { + // Target is the gRPC endpoint in host:port form. + Target string + + nj *utils.NucleiJS + executionID string + opts Options + conn *grpcConn + } +) + +// grpcConn bundles the live connection and its descriptor source so they can be +// established once and reused across calls. +type grpcConn struct { + cc *grpc.ClientConn + src grpcurl.DescriptorSource + cleanup func() +} + +// NewClient creates a new gRPC client for the given target. +// +// Constructor: constructor(public target: string, public options?: Options) +func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object { + c := &Client{nj: utils.NewNucleiJS(runtime)} + c.nj.ObjectSig = "Client(target, {Options})" + + target, _ := c.nj.GetArg(call.Arguments, 0).(string) + c.nj.Require(target != "", "grpc target cannot be empty") + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + if err := runtime.ExportTo(call.Arguments[1], &c.opts); err != nil { + c.nj.HandleError(err, "invalid grpc options") + } + } + c.Target = target + c.executionID = c.nj.ExecutionId() + + // defense in depth: validate the host against the network policy at + // construction time. The dialer re-checks and enforces it on every dial. + host, _, err := net.SplitHostPort(target) + c.nj.HandleError(err, "invalid grpc target (expected host:port)") + c.nj.Require(host != "", "grpc target host cannot be empty") + c.nj.Require(protocolstate.IsHostAllowed(c.executionID, host), protocolstate.ErrHostDenied.Msgf(host).Error()) + + return utils.LinkConstructor(call, runtime, c) +} + +// ensure lazily establishes the connection and descriptor source. +func (c *Client) ensure() { + c.nj.Require(c.executionID != "", "grpc: missing executionId in runtime") + if c.conn != nil { + return + } + cc, err := dialTarget(c.nj.Context(), c.executionID, c.Target, connConfig{ + plaintext: c.opts.Plaintext, + insecureSkipVerify: c.opts.InsecureSkipVerify, + serverName: c.opts.ServerName, + maxRecvMsgSize: c.opts.MaxRecvMsgSize, + }) + c.nj.HandleError(err, "failed to create grpc client") + + src, cleanup, err := descriptorSource(c.nj.Context(), c.executionID, cc, c.opts.ProtosetFile) + if err != nil { + _ = cc.Close() + c.nj.HandleError(err, "failed to load grpc descriptor source") + } + c.conn = &grpcConn{cc: cc, src: src, cleanup: cleanup} +} + +func (c *Client) callContext() (context.Context, context.CancelFunc) { + ctx := c.nj.Context() + if c.opts.TimeoutSeconds > 0 { + return context.WithTimeout(ctx, time.Duration(c.opts.TimeoutSeconds)*time.Second) + } + return ctx, func() {} +} + +// Connect eagerly establishes the connection and descriptor source. It is +// optional; other methods connect on demand. +// @example +// ```javascript +// const grpc = require('nuclei/grpc'); +// const client = new grpc.Client('grpc.acme.com:443'); +// client.Connect(); +// ``` +func (c *Client) Connect() bool { + c.ensure() + return true +} + +// ListServices returns the fully-qualified names of all services exposed by the +// target (via reflection) or defined in the configured protoset. +// @example +// ```javascript +// const grpc = require('nuclei/grpc'); +// const client = new grpc.Client('grpc.acme.com:443'); +// const services = client.ListServices(); +// ``` +func (c *Client) ListServices() []string { + c.ensure() + services, err := grpcurl.ListServices(c.conn.src) + c.nj.HandleError(err, "failed to list grpc services") + return services +} + +// ListMethods returns the fully-qualified method names of the given service. +// @example +// ```javascript +// const grpc = require('nuclei/grpc'); +// const client = new grpc.Client('grpc.acme.com:443'); +// const methods = client.ListMethods('grpc.health.v1.Health'); +// ``` +func (c *Client) ListMethods(service string) []string { + c.nj.Require(service != "", "grpc service cannot be empty") + c.ensure() + methods, err := grpcurl.ListMethods(c.conn.src, service) + c.nj.HandleError(err, "failed to list grpc methods") + return methods +} + +// DescribeSymbol returns the textual descriptor of a fully-qualified symbol +// (service, method or message type). +// @example +// ```javascript +// const grpc = require('nuclei/grpc'); +// const client = new grpc.Client('grpc.acme.com:443'); +// const text = client.DescribeSymbol('grpc.health.v1.Health'); +// ``` +func (c *Client) DescribeSymbol(symbol string) string { + c.nj.Require(symbol != "", "grpc symbol cannot be empty") + c.ensure() + text, err := describeSymbol(c.conn.src, symbol) + c.nj.HandleError(err, "failed to describe grpc symbol") + return text +} + +// Invoke calls a unary gRPC method with a JSON request and returns the JSON +// response. The method must be in 'package.Service/Method' or +// 'package.Service.Method' form. An empty message is treated as '{}'. +// @example +// ```javascript +// const grpc = require('nuclei/grpc'); +// const client = new grpc.Client('grpc.acme.com:443'); +// const resp = client.Invoke('grpc.health.v1.Health/Check', '{"service":""}'); +// ``` +func (c *Client) Invoke(method string, message string) string { + return c.invoke(method, message, nil) +} + +// InvokeWithHeaders behaves like Invoke but also sends the given request +// metadata. Each header must be in 'key: value' form. +// @example +// ```javascript +// const grpc = require('nuclei/grpc'); +// const client = new grpc.Client('grpc.acme.com:443'); +// const resp = client.InvokeWithHeaders('acme.v1.Svc/Get', '{}', ['authorization: Bearer x']); +// ``` +func (c *Client) InvokeWithHeaders(method string, message string, headers []string) string { + return c.invoke(method, message, headers) +} + +func (c *Client) invoke(method, message string, headers []string) string { + c.nj.Require(method != "", "grpc method cannot be empty") + c.ensure() + ctx, cancel := c.callContext() + defer cancel() + resp, err := invokeUnary(ctx, c.conn.src, c.conn.cc, method, message, headers) + c.nj.HandleError(err, "grpc invoke failed") + return resp +} + +// Close releases the connection and any descriptor source resources. +// @example +// ```javascript +// const grpc = require('nuclei/grpc'); +// const client = new grpc.Client('grpc.acme.com:443'); +// client.Close(); +// ``` +func (c *Client) Close() bool { + if c.conn == nil { + return true + } + if c.conn.cleanup != nil { + c.conn.cleanup() + } + if c.conn.cc != nil { + _ = c.conn.cc.Close() + } + c.conn = nil + return true +} diff --git a/pkg/js/libs/grpc/grpc_methods_test.go b/pkg/js/libs/grpc/grpc_methods_test.go new file mode 100644 index 0000000000..aed749cac8 --- /dev/null +++ b/pkg/js/libs/grpc/grpc_methods_test.go @@ -0,0 +1,285 @@ +package grpc + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + grpc_testing "google.golang.org/grpc/interop/grpc_testing" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + + "github.com/projectdiscovery/nuclei/v3/pkg/types" +) + +const ( + unaryMethod = "grpc.testing.TestService/UnaryCall" + emptyMethod = "grpc.testing.TestService/EmptyCall" + streamingMethod = "grpc.testing.TestService/StreamingOutputCall" +) + +// interopTestService implements a handful of the standard grpc.testing +// TestService methods, exercising rich field types (enums, nested messages, +// bytes), request metadata, error status propagation and server streaming. +type interopTestService struct { + grpc_testing.UnimplementedTestServiceServer +} + +func (s *interopTestService) EmptyCall(_ context.Context, _ *grpc_testing.Empty) (*grpc_testing.Empty, error) { + return &grpc_testing.Empty{}, nil +} + +func (s *interopTestService) UnaryCall(ctx context.Context, req *grpc_testing.SimpleRequest) (*grpc_testing.SimpleResponse, error) { + if st := req.GetResponseStatus(); st != nil && st.GetCode() != 0 { + return nil, status.Error(codes.Code(st.GetCode()), st.GetMessage()) + } + resp := &grpc_testing.SimpleResponse{ + Payload: &grpc_testing.Payload{ + Type: req.GetResponseType(), + Body: make([]byte, req.GetResponseSize()), + }, + } + if req.GetFillUsername() { + if md, ok := metadata.FromIncomingContext(ctx); ok { + if v := md.Get("x-username"); len(v) > 0 { + resp.Username = v[0] + } + } + } + if req.GetFillOauthScope() { + resp.OauthScope = "test-scope" + } + return resp, nil +} + +func (s *interopTestService) StreamingOutputCall(req *grpc_testing.StreamingOutputCallRequest, stream grpc.ServerStreamingServer[grpc_testing.StreamingOutputCallResponse]) error { + for _, p := range req.GetResponseParameters() { + if err := stream.Send(&grpc_testing.StreamingOutputCallResponse{ + Payload: &grpc_testing.Payload{Type: req.GetResponseType(), Body: make([]byte, p.GetSize())}, + }); err != nil { + return err + } + } + return nil +} + +// startTestService starts an in-process TestService with reflection enabled and +// returns its "host:port" address. If cert is non-nil the server uses TLS. +func startTestService(t *testing.T, cert *tls.Certificate) string { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + var opts []grpc.ServerOption + if cert != nil { + opts = append(opts, grpc.Creds(credentials.NewServerTLSFromCert(cert))) + } + srv := grpc.NewServer(opts...) + grpc_testing.RegisterTestServiceServer(srv, &interopTestService{}) + // also register health so TLS test can reuse it if needed + hs := health.NewServer() + hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(srv, hs) + reflection.Register(srv) + + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + return lis.Addr().String() +} + +// marshalDescriptorSet serializes a FileDescriptorSet covering fd and all of +// its transitive imports. +func marshalDescriptorSet(t *testing.T, fd protoreflect.FileDescriptor) []byte { + t.Helper() + fds := &descriptorpb.FileDescriptorSet{} + seen := map[string]bool{} + var add func(f protoreflect.FileDescriptor) + add = func(f protoreflect.FileDescriptor) { + if seen[f.Path()] { + return + } + seen[f.Path()] = true + imports := f.Imports() + for i := 0; i < imports.Len(); i++ { + add(imports.Get(i).FileDescriptor) + } + fds.File = append(fds.File, protodesc.ToFileDescriptorProto(f)) + } + add(fd) + data, err := proto.Marshal(fds) + require.NoError(t, err) + return data +} + +func selfSignedCert(t *testing.T) *tls.Certificate { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + DNSNames: []string{"localhost"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv} +} + +func TestEmptyCall(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + resp := client.Invoke(emptyMethod, "{}") + require.Equal(t, "{}", resp) +} + +func TestUnaryCallEchoesPayloadAndScope(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + resp := client.Invoke(unaryMethod, `{"responseSize": 4, "fillOauthScope": true}`) + require.Contains(t, resp, "test-scope") + require.Contains(t, resp, "payload") + // 4 zero bytes base64-encoded + require.Contains(t, resp, "AAAAAA==") +} + +func TestUnaryCallWithMetadataUsername(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + resp := client.InvokeWithHeaders(unaryMethod, `{"fillUsername": true}`, []string{"x-username: nuclei-user"}) + require.Contains(t, resp, "nuclei-user") +} + +func TestUnaryCallPropagatesErrorStatus(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + cc, err := dialTarget(context.Background(), executionID, addr, connConfig{plaintext: true}) + require.NoError(t, err) + defer func() { _ = cc.Close() }() + + src, cleanup, err := descriptorSource(context.Background(), executionID, cc, "") + require.NoError(t, err) + defer cleanup() + + _, err = invokeUnary(context.Background(), src, cc, unaryMethod, `{"responseStatus": {"code": 5, "message": "boom"}}`, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "NotFound") + require.Contains(t, err.Error(), "boom") +} + +func TestServerStreamingReturnsMultipleResponses(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + resp := client.Invoke(streamingMethod, `{"responseParameters": [{"size": 1}, {"size": 2}, {"size": 3}]}`) + require.Equal(t, 3, strings.Count(resp, "payload")) +} + +func TestListMethodsForTestService(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + methods := client.ListMethods("grpc.testing.TestService") + require.Contains(t, methods, "grpc.testing.TestService.UnaryCall") + require.Contains(t, methods, "grpc.testing.TestService.EmptyCall") + require.Contains(t, methods, "grpc.testing.TestService.StreamingOutputCall") +} + +func TestDescribeMessageType(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + text := client.DescribeSymbol("grpc.testing.SimpleRequest") + require.Contains(t, text, "SimpleRequest") + require.Contains(t, text, "response_size") +} + +func TestUnaryCallViaProtoset(t *testing.T) { + addr := startTestService(t, nil) + templatesDir := setTemplateDir(t) + executionID := initExec(t, &types.Options{AllowLocalFileAccess: false}) + + protosetPath := filepath.Join(templatesDir, "test.protoset") + require.NoError(t, os.WriteFile(protosetPath, marshalDescriptorSet(t, grpc_testing.File_grpc_testing_test_proto), 0o600)) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true, ProtosetFile: protosetPath}) + require.NoError(t, err) + + resp := client.Invoke(unaryMethod, `{"responseSize": 2, "fillOauthScope": true}`) + require.Contains(t, resp, "test-scope") +} + +func TestInvokeRejectsMalformedRequestJSON(t *testing.T) { + addr := startTestService(t, nil) + executionID := initExec(t, &types.Options{}) + + cc, err := dialTarget(context.Background(), executionID, addr, connConfig{plaintext: true}) + require.NoError(t, err) + defer func() { _ = cc.Close() }() + + src, cleanup, err := descriptorSource(context.Background(), executionID, cc, "") + require.NoError(t, err) + defer cleanup() + + _, err = invokeUnary(context.Background(), src, cc, unaryMethod, `{"responseSize": `, nil) + require.Error(t, err) +} + +func TestInvokeOverTLS(t *testing.T) { + cert := selfSignedCert(t) + addr := startTestService(t, cert) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{InsecureSkipVerify: true}) + require.NoError(t, err) + + resp := client.Invoke(unaryMethod, `{"fillOauthScope": true}`) + require.Contains(t, resp, "test-scope") +} diff --git a/pkg/js/libs/grpc/grpc_test.go b/pkg/js/libs/grpc/grpc_test.go new file mode 100644 index 0000000000..261af8a7df --- /dev/null +++ b/pkg/js/libs/grpc/grpc_test.go @@ -0,0 +1,266 @@ +package grpc + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/projectdiscovery/goja" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + + "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" +) + +const healthMethod = "grpc.health.v1.Health/Check" + +// startHealthServer starts an in-process gRPC server exposing the standard +// health service (status SERVING) and server reflection, listening on a random +// loopback port. It returns the "host:port" address. +func startHealthServer(t *testing.T) string { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + srv := grpc.NewServer() + hs := health.NewServer() + hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(srv, hs) + reflection.Register(srv) + + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + return lis.Addr().String() +} + +// initExec initializes protocolstate for a unique executionId derived from the +// test name and registers cleanup. It returns the executionId. +func initExec(t *testing.T, options *types.Options) string { + t.Helper() + + executionID := "grpc-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) + options.ExecutionId = executionID + require.NoError(t, protocolstate.Init(options)) + t.Cleanup(func() { protocolstate.Close(executionID) }) + return executionID +} + +// newRuntimeClient constructs a *Client through the goja runtime exactly as a +// nuclei template would, wiring the executionId and ctx context values. +func newRuntimeClient(t *testing.T, executionID, target string, opts Options) (*Client, error) { + t.Helper() + + runtime := goja.New() + runtime.SetContextValue("executionId", executionID) + runtime.SetContextValue("ctx", context.Background()) + + obj, err := runtime.New(runtime.ToValue(NewClient), runtime.ToValue(target), runtime.ToValue(opts)) + if err != nil { + return nil, err + } + client, ok := obj.Export().(*Client) + require.True(t, ok, "expected *Client export, got %T", obj.Export()) + t.Cleanup(func() { client.Close() }) + return client, nil +} + +// healthProtoset builds a serialized FileDescriptorSet covering the health +// service (and its transitive imports). +func healthProtoset(t *testing.T) []byte { + t.Helper() + + fds := &descriptorpb.FileDescriptorSet{} + seen := map[string]bool{} + var add func(fd protoreflect.FileDescriptor) + add = func(fd protoreflect.FileDescriptor) { + if seen[fd.Path()] { + return + } + seen[fd.Path()] = true + imports := fd.Imports() + for i := 0; i < imports.Len(); i++ { + add(imports.Get(i).FileDescriptor) + } + fds.File = append(fds.File, protodesc.ToFileDescriptorProto(fd)) + } + add(healthpb.File_grpc_health_v1_health_proto) + + data, err := proto.Marshal(fds) + require.NoError(t, err) + return data +} + +func setTemplateDir(t *testing.T) string { + t.Helper() + templatesDir := t.TempDir() + original := config.DefaultConfig.TemplatesDirectory + config.DefaultConfig.SetTemplatesDir(templatesDir) + t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(original) }) + return templatesDir +} + +func TestInvokeHealthCheckViaReflection(t *testing.T) { + addr := startHealthServer(t) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + resp := client.Invoke(healthMethod, `{"service":""}`) + require.Contains(t, resp, "SERVING") +} + +func TestInvokeWithEmptyMessageDefaultsToObject(t *testing.T) { + addr := startHealthServer(t) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + resp := client.Invoke(healthMethod, "") + require.Contains(t, resp, "SERVING") +} + +func TestInvokeWithHeaders(t *testing.T) { + addr := startHealthServer(t) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + resp := client.InvokeWithHeaders(healthMethod, `{}`, []string{"x-custom: nuclei"}) + require.Contains(t, resp, "SERVING") +} + +func TestListServicesAndMethodsViaReflection(t *testing.T) { + addr := startHealthServer(t) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + services := client.ListServices() + require.Contains(t, services, "grpc.health.v1.Health") + + methods := client.ListMethods("grpc.health.v1.Health") + require.Contains(t, methods, "grpc.health.v1.Health.Check") +} + +func TestDescribeSymbol(t *testing.T) { + addr := startHealthServer(t) + executionID := initExec(t, &types.Options{}) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true}) + require.NoError(t, err) + + text := client.DescribeSymbol("grpc.health.v1.Health") + require.Contains(t, text, "Health") + require.Contains(t, text, "Check") +} + +func TestInvokeViaProtosetInsideTemplatesDir(t *testing.T) { + addr := startHealthServer(t) + templatesDir := setTemplateDir(t) + // local file access disabled: the protoset must be resolved from inside the + // templates directory by the allowlist. + executionID := initExec(t, &types.Options{AllowLocalFileAccess: false}) + + protosetPath := filepath.Join(templatesDir, "health.protoset") + require.NoError(t, os.WriteFile(protosetPath, healthProtoset(t), 0o600)) + + client, err := newRuntimeClient(t, executionID, addr, Options{Plaintext: true, ProtosetFile: protosetPath}) + require.NoError(t, err) + + resp := client.Invoke(healthMethod, `{"service":""}`) + require.Contains(t, resp, "SERVING") +} + +func TestProtosetOutsideTemplatesDirDenied(t *testing.T) { + setTemplateDir(t) + executionID := initExec(t, &types.Options{AllowLocalFileAccess: false}) + + // write the protoset outside the templates directory + outside := filepath.Join(t.TempDir(), "health.protoset") + require.NoError(t, os.WriteFile(outside, healthProtoset(t), 0o600)) + + _, _, err := descriptorSource(context.Background(), executionID, nil, outside) + require.Error(t, err) + require.Contains(t, err.Error(), "protoset path denied") +} + +func TestConstructorDeniesHostByNetworkPolicy(t *testing.T) { + executionID := initExec(t, &types.Options{ExcludeTargets: []string{"127.0.0.1"}}) + + _, err := newRuntimeClient(t, executionID, "127.0.0.1:9", Options{Plaintext: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "network policy") +} + +func TestDialTargetRejectsDeniedHost(t *testing.T) { + executionID := initExec(t, &types.Options{ExcludeTargets: []string{"127.0.0.1"}}) + + _, err := dialTarget(context.Background(), executionID, "127.0.0.1:9", connConfig{plaintext: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "network policy") +} + +func TestDialTargetRejectsInvalidTarget(t *testing.T) { + executionID := initExec(t, &types.Options{}) + + _, err := dialTarget(context.Background(), executionID, "missing-port", connConfig{plaintext: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "host:port") +} + +func TestDialTargetRejectsEmptyExecutionID(t *testing.T) { + _, err := dialTarget(context.Background(), "", "127.0.0.1:9", connConfig{plaintext: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "executionId") +} + +func TestInvokeUnaryEndToEndWithHelpers(t *testing.T) { + addr := startHealthServer(t) + executionID := initExec(t, &types.Options{}) + + cc, err := dialTarget(context.Background(), executionID, addr, connConfig{plaintext: true}) + require.NoError(t, err) + defer func() { _ = cc.Close() }() + + src, cleanup, err := descriptorSource(context.Background(), executionID, cc, "") + require.NoError(t, err) + defer cleanup() + + resp, err := invokeUnary(context.Background(), src, cc, healthMethod, `{"service":""}`, nil) + require.NoError(t, err) + require.Contains(t, resp, "SERVING") +} + +func TestInvokeUnaryUnknownMethod(t *testing.T) { + addr := startHealthServer(t) + executionID := initExec(t, &types.Options{}) + + cc, err := dialTarget(context.Background(), executionID, addr, connConfig{plaintext: true}) + require.NoError(t, err) + defer func() { _ = cc.Close() }() + + src, cleanup, err := descriptorSource(context.Background(), executionID, cc, "") + require.NoError(t, err) + defer cleanup() + + _, err = invokeUnary(context.Background(), src, cc, "grpc.health.v1.Health/DoesNotExist", `{}`, nil) + require.Error(t, err) +} diff --git a/pkg/js/libs/grpc/invoke.go b/pkg/js/libs/grpc/invoke.go new file mode 100644 index 0000000000..266bf41700 --- /dev/null +++ b/pkg/js/libs/grpc/invoke.go @@ -0,0 +1,163 @@ +package grpc + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "os" + "strings" + + "github.com/fullstorydev/grpcurl" + "github.com/jhump/protoreflect/grpcreflect" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" +) + +// connConfig holds the low level knobs used to build a gRPC client connection. +// It is intentionally decoupled from the JS-facing Options struct so the dial +// logic stays independently testable. +type connConfig struct { + plaintext bool + insecureSkipVerify bool + serverName string + maxRecvMsgSize int +} + +// dialTarget builds a *grpc.ClientConn whose every connection is routed through +// nuclei's network policy. The host is validated up front and the actual dial +// is delegated to the execution's fastdialer via a custom context dialer, so +// IP/host denylists and RestrictLocalNetworkAccess are always enforced. The +// passthrough scheme guarantees the target is handed verbatim to our dialer +// (instead of gRPC's built in DNS resolver), keeping resolution and policy +// enforcement inside fastdialer. +func dialTarget(ctx context.Context, executionID, target string, cfg connConfig) (*grpc.ClientConn, error) { + host, _, err := net.SplitHostPort(target) + if err != nil { + return nil, fmt.Errorf("invalid grpc target %q (expected host:port): %w", target, err) + } + if host == "" { + return nil, fmt.Errorf("grpc target host cannot be empty") + } + if executionID == "" { + return nil, fmt.Errorf("grpc: refusing to dial without executionId") + } + if !protocolstate.IsHostAllowed(executionID, host) { + return nil, protocolstate.ErrHostDenied.Msgf(host) + } + dialers := protocolstate.GetDialersWithId(executionID) + if dialers == nil || dialers.Fastdialer == nil { + return nil, fmt.Errorf("grpc: dialers not initialized for executionId %q", executionID) + } + + contextDialer := func(dialCtx context.Context, addr string) (net.Conn, error) { + return dialers.Fastdialer.Dial(dialCtx, "tcp", addr) + } + + var creds credentials.TransportCredentials + if cfg.plaintext { + creds = insecure.NewCredentials() + } else { + serverName := cfg.serverName + if serverName == "" { + serverName = host + } + creds = credentials.NewTLS(&tls.Config{ + InsecureSkipVerify: cfg.insecureSkipVerify, + ServerName: serverName, + MinVersion: tls.VersionTLS12, + }) + } + + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(creds), + grpc.WithContextDialer(contextDialer), + } + if cfg.maxRecvMsgSize > 0 { + opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(cfg.maxRecvMsgSize))) + } + _ = ctx // reserved for future dial-time hooks; connection is established lazily + return grpc.NewClient("passthrough:///"+target, opts...) +} + +// descriptorSource resolves the gRPC method/message schema either from a local +// compiled protoset file (read through the local-file-access allowlist) or, when +// no protoset is provided, from server reflection over the existing connection. +// The returned cleanup func must be called once the source is no longer needed. +func descriptorSource(ctx context.Context, executionID string, cc *grpc.ClientConn, protosetFile string) (grpcurl.DescriptorSource, func(), error) { + noop := func() {} + if strings.TrimSpace(protosetFile) != "" { + // resolve through the local-file-access allowlist: unless -lfa is set, + // only files inside the nuclei-templates directory are permitted. + normalized, err := protocolstate.NormalizePathWithExecutionId(executionID, protosetFile) + if err != nil { + return nil, noop, fmt.Errorf("protoset path denied: %w", err) + } + data, err := os.ReadFile(normalized) + if err != nil { + return nil, noop, fmt.Errorf("failed to read protoset file: %w", err) + } + fds := &descriptorpb.FileDescriptorSet{} + if err := proto.Unmarshal(data, fds); err != nil { + return nil, noop, fmt.Errorf("failed to parse protoset file: %w", err) + } + src, err := grpcurl.DescriptorSourceFromFileDescriptorSet(fds) + if err != nil { + return nil, noop, fmt.Errorf("failed to build descriptor source from protoset: %w", err) + } + return src, noop, nil + } + + refClient := grpcreflect.NewClientAuto(ctx, cc) + cleanup := func() { refClient.Reset() } + return grpcurl.DescriptorSourceFromServer(ctx, refClient), cleanup, nil +} + +// invokeUnary invokes a unary (or single-response) gRPC method described by src +// over cc, marshaling the JSON request and formatting the JSON response. +func invokeUnary(ctx context.Context, src grpcurl.DescriptorSource, cc *grpc.ClientConn, method, requestJSON string, headers []string) (string, error) { + body := strings.TrimSpace(requestJSON) + if body == "" { + body = "{}" + } + var in io.Reader = strings.NewReader(body) + + parser, formatter, err := grpcurl.RequestParserAndFormatter(grpcurl.FormatJSON, src, in, grpcurl.FormatOptions{ + EmitJSONDefaultFields: true, + AllowUnknownFields: false, + }) + if err != nil { + return "", fmt.Errorf("failed to build request parser: %w", err) + } + + var out bytes.Buffer + handler := &grpcurl.DefaultEventHandler{ + Out: &out, + Formatter: formatter, + } + + if err := grpcurl.InvokeRPC(ctx, src, cc, method, headers, handler, parser.Next); err != nil { + return "", err + } + if handler.Status != nil && handler.Status.Code() != codes.OK { + return "", fmt.Errorf("grpc status %s: %s", handler.Status.Code().String(), handler.Status.Message()) + } + return strings.TrimRight(out.String(), "\n"), nil +} + +// describeSymbol returns the textual descriptor for a fully-qualified symbol. +func describeSymbol(src grpcurl.DescriptorSource, symbol string) (string, error) { + dsc, err := src.FindSymbol(symbol) + if err != nil { + return "", err + } + return grpcurl.GetDescriptorText(dsc, src) +} From c8b8739fa0fdad8630d50f59d6858681f2da0776 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Mon, 6 Jul 2026 17:39:44 +0200 Subject: [PATCH 2/2] stabilize test --- pkg/protocols/http/request_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/protocols/http/request_test.go b/pkg/protocols/http/request_test.go index 2d16816833..d676022fe3 100644 --- a/pkg/protocols/http/request_test.go +++ b/pkg/protocols/http/request_test.go @@ -583,6 +583,12 @@ func TestExecuteParallelHTTP_GoroutineLeaks(t *testing.T) { // expirable LRU cache creates a background goroutine for TTL expiration that persists // see: https://github.com/hashicorp/golang-lru/blob/770151e9c8cdfae1797826b7b74c33d6f103fbd8/expirable/expirable_lru.go#L79 goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"), + // net/http keep-alive connections spawn persistConn read/write loops that are + // owned by the transport's idle-connection pool, not by the parallel executor. + // They are torn down asynchronously on idle timeout, and on slower runners + // (e.g. windows) that teardown can outlast goleak's retry window, so ignore them. + goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"), + goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"), ) options := testutils.DefaultOptions