diff --git a/cmd/cli/cmd/root.go b/cmd/cli/cmd/root.go index f174eba..df3b837 100644 --- a/cmd/cli/cmd/root.go +++ b/cmd/cli/cmd/root.go @@ -94,6 +94,7 @@ func init() { // Register generated API commands under 'api' parent for cleaner top-level help commands.RegisterAdminCommands(apiCmd, getAPIClient) commands.RegisterContextCommands(apiCmd, getAPIClient) + commands.RegisterContextPushCommand(apiCmd, getAPIClient) commands.RegisterCspmCommands(apiCmd, getAPIClient) // Register pentest and bughunt subcommands from generated DAST commands commands.RegisterPentestSubcommands(pentestCmd, getAPIClient) diff --git a/go.mod b/go.mod index f9054b8..8834006 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/nullify-platform/cli go 1.26.0 require ( + github.com/aws/aws-sdk-go-v2 v1.41.4 + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 github.com/docker/docker v28.5.2+incompatible github.com/google/go-github/v84 v84.0.0 github.com/mark3labs/mcp-go v0.44.1 @@ -13,8 +16,29 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-lambda-go v1.52.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sns v1.39.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sqs v1.42.22 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.68.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -46,19 +70,30 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gotest.tools/v3 v3.5.1 // indirect ) require ( + github.com/nullify-platform/logger v1.32.2 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.27.1 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 2c99398..701ccd6 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,52 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-lambda-go v1.52.0 h1:5NfiRaVl9FafUIt2Ld/Bv22kT371mfAI+l1Hd+tV7ZE= +github.com/aws/aws-lambda-go v1.52.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= +github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= +github.com/aws/aws-sdk-go-v2/service/sns v1.39.12 h1:yVf0R6Mp8iXmy3/yCY97YyHB1VSkxlxK0ywh14tGuuk= +github.com/aws/aws-sdk-go-v2/service/sns v1.39.12/go.mod h1:9pHipxPwPZJcYm1TEU4gBzwcceAREvks2GDGJewm8Lo= +github.com/aws/aws-sdk-go-v2/service/sqs v1.42.22 h1:CVksqT2e8RFAixRTlDqu1nj174Vjb3VqG7wyZEAlYuA= +github.com/aws/aws-sdk-go-v2/service/sqs v1.42.22/go.mod h1:n3/KSi68g5s54U9J1FV4fRz8oK+7ML2RJK+mDu6gGS0= +github.com/aws/aws-sdk-go-v2/service/ssm v1.68.1 h1:kDgdZuYBWSsh3U/jZOXwcqfX6UsSzFcmtgKx7C0c5/E= +github.com/aws/aws-sdk-go-v2/service/ssm v1.68.1/go.mod h1:xyao5chroDlX/9q/rKBxRKZPv9NdG5Pm9W5zS+wQJ84= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= @@ -36,6 +82,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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= @@ -70,6 +118,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/nullify-platform/logger v1.32.2 h1:04f3vcPHdudcmcOFt6dLFRay/SCepmfAXjq4phldcBQ= +github.com/nullify-platform/logger v1.32.2/go.mod h1:jwIlZkuGEaCHYKGSovCIVlcIpAaoXddQP4EvCL8oo+Q= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -102,10 +152,16 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= @@ -133,6 +189,8 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= diff --git a/internal/api/context_push.go b/internal/api/context_push.go new file mode 100644 index 0000000..657c3da --- /dev/null +++ b/internal/api/context_push.go @@ -0,0 +1,61 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" +) + +// ContextCredentialsRequest is the request body for POST /admin/context/credentials. +type ContextCredentialsRequest struct { + ContextType string `json:"contextType"` + Repository string `json:"repository"` + Branch string `json:"branch,omitempty"` + Environment string `json:"environment,omitempty"` + Name string `json:"name"` + PRNumber int `json:"prNumber,omitempty"` + FromPR int `json:"fromPR,omitempty"` + CommitSHA string `json:"commitSha,omitempty"` +} + +// ContextCredentialsResponse is the response from POST /admin/context/credentials. +type ContextCredentialsResponse struct { + Credentials struct { + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Expiration string `json:"expiration"` + } `json:"credentials"` + Bucket string `json:"bucket"` + KeyPrefix string `json:"keyPrefix"` + KMSKeyARN string `json:"kmsKeyArn,omitempty"` + Region string `json:"region"` +} + +// PostContextCredentials requests scoped STS credentials for uploading context data. +func (c *Client) PostContextCredentials(ctx context.Context, req ContextCredentialsRequest) (*ContextCredentialsResponse, error) { + path := "/admin/context/credentials" + + query := url.Values{} + for k, v := range c.DefaultParams { + query.Set(k, v) + } + + reqBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + respBody, err := c.do(ctx, "POST", c.BaseURL+path+"?"+query.Encode(), bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + + var resp ContextCredentialsResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &resp, nil +} diff --git a/internal/commands/context_push.go b/internal/commands/context_push.go new file mode 100644 index 0000000..089d5a4 --- /dev/null +++ b/internal/commands/context_push.go @@ -0,0 +1,191 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/nullify-platform/cli/internal/api" + "github.com/nullify-platform/cli/internal/lib" + "github.com/nullify-platform/cli/internal/upload" + "github.com/nullify-platform/logger/pkg/logger" + "github.com/spf13/cobra" +) + +const maxUploadSize = 50 << 20 // 50 MB + +// RegisterContextPushCommand adds the 'push' subcommand to the existing 'context' command. +// Must be called after RegisterContextCommands. +func RegisterContextPushCommand(parent *cobra.Command, getClient func() *api.Client) { + // Find the existing 'context' command registered by the generated code + var contextCmd *cobra.Command + for _, cmd := range parent.Commands() { + if cmd.Name() == "context" { + contextCmd = cmd + break + } + } + if contextCmd == nil { + contextCmd = &cobra.Command{ + Use: "context", + Short: "Context ingestion commands", + } + parent.AddCommand(contextCmd) + } + + var ( + contextType string + repository string + name string + environment string + branch string + prNumber int + fromPR int + dryRun bool + ) + + pushCmd := &cobra.Command{ + Use: "push [file]", + Short: "Upload context data to Nullify", + Long: "Upload a single context file (Terraform plan, CI log, etc.) to Nullify for infrastructure-aware security analysis.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + filePath := args[0] + + // Validate file exists and is within size limit + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("file not found: %s", filePath) + } + if info.Size() > maxUploadSize { + return fmt.Errorf("file %s is %d MB, exceeds maximum of %d MB", filePath, info.Size()>>20, maxUploadSize>>20) + } + + // Resolve repository: flag > env var > git auto-detect + repo := repository + if repo == "" { + repo = os.Getenv("GITHUB_REPOSITORY") + } + if repo == "" { + repo = lib.DetectGitContext().Repository + } + if repo == "" { + return fmt.Errorf("could not detect repository — use --repository, set GITHUB_REPOSITORY, or run inside a git repo") + } + + if branch == "" { + branch = os.Getenv("GITHUB_REF_NAME") + } + if branch == "" { + branch = lib.DetectGitContext().Branch + } + + commitSHA := os.Getenv("GITHUB_SHA") + if commitSHA == "" { + commitSHA = lib.DetectGitContext().CommitSHA + } + + // Auto-detect name from file path if not provided + if name == "" { + name = deriveNameFromPath(filePath) + } + + // Request scoped credentials + credsReq := api.ContextCredentialsRequest{ + ContextType: contextType, + Repository: repo, + Branch: branch, + Environment: environment, + Name: name, + PRNumber: prNumber, + FromPR: fromPR, + CommitSHA: commitSHA, + } + + if dryRun { + fmt.Printf("Dry run — would upload:\n") + fmt.Printf(" Type: %s\n", contextType) + fmt.Printf(" Repository: %s\n", repo) + fmt.Printf(" Branch: %s\n", branch) + fmt.Printf(" Environment: %s\n", environment) + fmt.Printf(" Name: %s\n", name) + if prNumber > 0 { + fmt.Printf(" PR: #%d\n", prNumber) + } + if fromPR > 0 { + fmt.Printf(" From PR: #%d\n", fromPR) + } + fmt.Printf(" File: %s (%d bytes)\n", filePath, info.Size()) + return nil + } + + client := getClient() + + logger.L(ctx).Info("requesting upload credentials", + logger.String("repository", repo), + logger.String("contextType", contextType), + logger.String("name", name), + ) + + creds, err := client.PostContextCredentials(ctx, credsReq) + if err != nil { + return fmt.Errorf("failed to get upload credentials: %w", err) + } + + uploader := upload.NewS3Uploader( + creds.Credentials.AccessKeyID, + creds.Credentials.SecretAccessKey, + creds.Credentials.SessionToken, + creds.Region, + creds.Bucket, + creds.KeyPrefix, + creds.KMSKeyARN, + ) + + metadata := upload.ContextMetadata{ + ContextType: contextType, + Repository: repo, + Branch: branch, + Environment: environment, + Name: name, + PRNumber: prNumber, + FromPR: fromPR, + CommitSHA: commitSHA, + CLIVersion: logger.Version, + } + + logger.L(ctx).Info("uploading", logger.String("file", filePath)) + if err := uploader.Upload(ctx, filePath, metadata); err != nil { + return fmt.Errorf("failed to upload %s: %w", filePath, err) + } + fmt.Printf("Uploaded %s → s3://%s/%slatest.json\n", filePath, creds.Bucket, creds.KeyPrefix) + + return nil + }, + } + + pushCmd.Flags().StringVar(&contextType, "type", "", "Context type (terraform, ci_logs, config, deploy, api_spec)") + pushCmd.Flags().StringVar(&repository, "repository", "", "Repository in org/repo format (auto-detected if omitted)") + pushCmd.Flags().StringVar(&name, "name", "", "Logical name for this context (e.g. networking, ecs-api). Auto-detected from file path if omitted.") + pushCmd.Flags().StringVar(&environment, "environment", "", "Deployment environment (development, staging, production, unknown)") + pushCmd.Flags().StringVar(&branch, "branch", "", "Git branch (auto-detected if omitted)") + pushCmd.Flags().IntVar(&prNumber, "pr-number", 0, "Pull request number") + pushCmd.Flags().IntVar(&fromPR, "from-pr", 0, "PR number that originated this deployment (for merge-to-main uploads)") + pushCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Log what would be uploaded without uploading") + _ = pushCmd.MarkFlagRequired("type") + + contextCmd.AddCommand(pushCmd) +} + +// deriveNameFromPath extracts a logical name from the file path. +// e.g. "infrastructure/networking/plan.json" → "infrastructure/networking" +// e.g. "plan.json" → "root" +func deriveNameFromPath(filePath string) string { + dir := filepath.Dir(filePath) + if dir == "." || dir == "/" || dir == "" { + return "root" + } + // Clean and normalize + return filepath.ToSlash(filepath.Clean(dir)) +} diff --git a/internal/lib/git_context.go b/internal/lib/git_context.go new file mode 100644 index 0000000..8284172 --- /dev/null +++ b/internal/lib/git_context.go @@ -0,0 +1,38 @@ +package lib + +import ( + "os/exec" + "strings" +) + +// GitContext contains auto-detected git information. +type GitContext struct { + Repository string // org/repo format + Branch string + CommitSHA string +} + +// DetectGitContext auto-detects repository, branch, and commit SHA from the current git repo. +func DetectGitContext() GitContext { + ctx := GitContext{} + ctx.Repository = DetectRepoFromGit() + ctx.Branch = detectBranch() + ctx.CommitSHA = detectCommitSHA() + return ctx +} + +func detectBranch() string { + out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func detectCommitSHA() string { + out, err := exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/upload/s3.go b/internal/upload/s3.go new file mode 100644 index 0000000..94c12d8 --- /dev/null +++ b/internal/upload/s3.go @@ -0,0 +1,114 @@ +package upload + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// ContextEnvelope wraps the uploaded payload with metadata. +type ContextEnvelope struct { + Metadata ContextMetadata `json:"metadata"` + Payload json.RawMessage `json:"payload"` +} + +// ContextMetadata contains metadata about the context upload. +type ContextMetadata struct { + ContextType string `json:"contextType"` + Repository string `json:"repository"` + Branch string `json:"branch,omitempty"` + Environment string `json:"environment,omitempty"` + Name string `json:"name"` + PRNumber int `json:"prNumber,omitempty"` + FromPR int `json:"fromPR,omitempty"` + CommitSHA string `json:"commitSha,omitempty"` + UploadedAt string `json:"uploadedAt"` + CLIVersion string `json:"cliVersion"` +} + +// S3Uploader uploads context data to S3 using temporary credentials. +type S3Uploader struct { + client *s3.Client + bucket string + keyPrefix string + kmsKeyARN string +} + +// NewS3Uploader creates a new S3 uploader with the given temporary credentials. +func NewS3Uploader(accessKeyID, secretAccessKey, sessionToken, region, bucket, keyPrefix, kmsKeyARN string) *S3Uploader { + creds := credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, sessionToken) + client := s3.New(s3.Options{ + Region: region, + Credentials: creds, + }) + return &S3Uploader{ + client: client, + bucket: bucket, + keyPrefix: keyPrefix, + kmsKeyARN: kmsKeyARN, + } +} + +// Upload reads the file, wraps it in an envelope, and uploads to S3 as latest.json +// and history/{timestamp}.json. +func (u *S3Uploader) Upload(ctx context.Context, filePath string, metadata ContextMetadata) error { + payload, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", filePath, err) + } + + // Validate that the payload is valid JSON + if !json.Valid(payload) { + return fmt.Errorf("file %s is not valid JSON", filePath) + } + + metadata.UploadedAt = time.Now().UTC().Format(time.RFC3339) + + envelope := ContextEnvelope{ + Metadata: metadata, + Payload: json.RawMessage(payload), + } + + envelopeJSON, err := json.Marshal(envelope) + if err != nil { + return fmt.Errorf("failed to marshal envelope: %w", err) + } + + // Upload latest.json (current state — overwritten each time) + latestKey := u.keyPrefix + "latest.json" + if err := u.putObject(ctx, latestKey, envelopeJSON); err != nil { + return fmt.Errorf("failed to upload %s: %w", latestKey, err) + } + + // Upload history/{timestamp}.json (immutable historical copy) + historyKey := u.keyPrefix + "history/" + time.Now().UTC().Format("2006-01-02T15-04-05Z") + ".json" + if err := u.putObject(ctx, historyKey, envelopeJSON); err != nil { + return fmt.Errorf("failed to upload %s: %w", historyKey, err) + } + + return nil +} + +func (u *S3Uploader) putObject(ctx context.Context, key string, data []byte) error { + input := &s3.PutObjectInput{ + Bucket: aws.String(u.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + } + + if u.kmsKeyARN != "" { + input.ServerSideEncryption = types.ServerSideEncryptionAwsKms + input.SSEKMSKeyId = aws.String(u.kmsKeyARN) + } + + _, err := u.client.PutObject(ctx, input) + return err +}