diff --git a/extract/secret.go b/extract/secret.go new file mode 100644 index 0000000..7d5e626 --- /dev/null +++ b/extract/secret.go @@ -0,0 +1,92 @@ +package extract + +import ( + "fmt" + + "github.com/aquasecurity/trivy/pkg/iac/terraform" + "github.com/hashicorp/hcl/v2" + + "github.com/coder/preview/types" +) + +// SecretFromBlock decodes a `data "coder_secret" {}` Terraform block into a +// SecretRequirement. Exactly one of `env` or `file` must be set, and +// `help_message` is required. Returns (nil, diags) on validation failure. +func SecretFromBlock(block *terraform.Block) (req *types.SecretRequirement, diags hcl.Diagnostics) { + defer func() { + // Extra safety mechanism to ensure that if a panic occurs, we do not break + // everything else. + if r := recover(); r != nil { + req = nil + diags = hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Panic occurred in extracting secret requirement. This should not happen, please report this to Coder.", + Detail: fmt.Sprintf("panic in secret extract: %+v", r), + }, + } + } + }() + + // help_message is required AND must be a string; requiredString + // handles both checks and emits a proper type diagnostic. + help, helpDiag := requiredString(block, "help_message") + if helpDiag != nil { + diags = diags.Append(helpDiag) + } + + // Check presence separately from value so we can distinguish "attribute + // absent" from "attribute present but wrong type"; the latter must produce + // a type diagnostic instead of being silently treated as unset. + envAttr := block.GetAttribute("env") + fileAttr := block.GetAttribute("file") + envSet := envAttr != nil && !envAttr.IsNil() + fileSet := fileAttr != nil && !fileAttr.IsNil() + + var env, file string + if envSet { + v, d := requiredString(block, "env") + if d != nil { + diags = diags.Append(d) + } + env = v + } + if fileSet { + v, d := requiredString(block, "file") + if d != nil { + diags = diags.Append(d) + } + file = v + } + + // Mutual exclusivity is based on presence, not parsed value, so a + // wrong-type attribute still counts as "set" here. + switch { + case !envSet && !fileSet: + r := block.HCLBlock().DefRange + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid "coder_secret" block`, + Detail: `Exactly one of "env" or "file" must be set, neither were set`, + Subject: &r, + }) + case envSet && fileSet: + r := block.HCLBlock().DefRange + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid "coder_secret" block`, + Detail: `Exactly one of "env" or "file" must be set, both were set`, + Subject: &r, + }) + } + + if diags.HasErrors() { + return nil, diags + } + + return &types.SecretRequirement{ + Env: env, + File: file, + HelpMessage: help, + }, diags +} diff --git a/extract/secret_test.go b/extract/secret_test.go new file mode 100644 index 0000000..213e097 --- /dev/null +++ b/extract/secret_test.go @@ -0,0 +1,32 @@ +package extract_test + +import ( + "strings" + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/stretchr/testify/require" + + "github.com/coder/preview/extract" +) + +// Test_SecretFromBlock_PanicRecover verifies that a panic inside +// SecretFromBlock is converted into an error diagnostic rather than crashing +// the whole extraction pass. A nil block triggers a nil pointer dereference +// inside requiredString, which the deferred recover should catch. +func Test_SecretFromBlock_PanicRecover(t *testing.T) { + t.Parallel() + + req, diags := extract.SecretFromBlock(nil) + require.Nil(t, req) + require.True(t, diags.HasErrors(), "expected diagnostics; got %v", diags) + + var found bool + for _, d := range diags { + if d.Severity == hcl.DiagError && strings.Contains(d.Summary, "Panic occurred in extracting secret requirement") { + found = true + break + } + } + require.True(t, found, "expected panic diagnostic; got: %v", diags) +} diff --git a/go.mod b/go.mod index 930a2f1..c3c174d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/coder/preview -go 1.25.6 +go 1.25.8 require ( cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 @@ -10,8 +10,8 @@ require ( github.com/coder/websocket v1.8.13 github.com/go-chi/chi v4.1.2+incompatible github.com/hashicorp/go-cty v1.5.0 - github.com/hashicorp/go-version v1.8.0 - github.com/hashicorp/hc-install v0.9.2 + github.com/hashicorp/go-version v1.9.0 + github.com/hashicorp/hc-install v0.9.4 github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform-exec v0.24.0 github.com/hashicorp/terraform-json v0.27.1 @@ -35,7 +35,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect - github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/alecthomas/chroma v0.10.0 // indirect github.com/apparentlymart/go-cidr v1.1.0 // indirect @@ -135,17 +135,17 @@ require ( 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 - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/api v0.260.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/go.sum b/go.sum index dff4a7d..7a81599 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= @@ -169,10 +169,10 @@ github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyN github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= -github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= -github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -232,10 +232,10 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= -github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.4 h1:KKWOpUG0EqIV63Qk2GGFrZ0s275NVs5lKf9N5vjBNoc= +github.com/hashicorp/hc-install v0.9.4/go.mod h1:4LRYeEN2bMIFfIv57ldMWt9awfuZhvpbRt0vWmv51WU= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= @@ -434,27 +434,27 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -468,27 +468,27 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= diff --git a/preview.go b/preview.go index 32ac43f..3490dc4 100644 --- a/preview.go +++ b/preview.go @@ -40,10 +40,11 @@ type Output struct { // JSON marshalling is handled in the custom methods. ModuleOutput cty.Value `json:"-"` - Parameters []types.Parameter `json:"parameters"` - WorkspaceTags types.TagBlocks `json:"workspace_tags"` - Presets []types.Preset `json:"presets"` - Variables []types.Variable `json:"variables"` + Parameters []types.Parameter `json:"parameters"` + WorkspaceTags types.TagBlocks `json:"workspace_tags"` + Presets []types.Preset `json:"presets"` + Variables []types.Variable `json:"variables"` + SecretRequirements []types.SecretRequirement `json:"secret_requirements"` // Files is included for printing diagnostics. // They can be marshalled, but not unmarshalled. This is a limitation // of the HCL library. @@ -279,18 +280,20 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn preValidPresets := presets(modules, rp) tags, tagDiags := workspaceTags(modules, p.Files()) vars := variables(modules) + secretReqs, secretDiags := secrets(modules) // Add warnings diags = diags.Extend(warnings(modules)) return &Output{ - ModuleOutput: outputs, - Parameters: rp, - WorkspaceTags: tags, - Presets: preValidPresets, - Files: p.Files(), - Variables: vars, - }, diags.Extend(overrideDiags).Extend(rpDiags).Extend(tagDiags) + ModuleOutput: outputs, + Parameters: rp, + WorkspaceTags: tags, + Presets: preValidPresets, + Files: p.Files(), + Variables: vars, + SecretRequirements: secretReqs, + }, diags.Extend(overrideDiags).Extend(rpDiags).Extend(tagDiags).Extend(secretDiags) } func (i Input) RichParameterValue(key string) (string, bool) { diff --git a/preview_test.go b/preview_test.go index 49a7fb6..64dc2c1 100644 --- a/preview_test.go +++ b/preview_test.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "testing" + "testing/fstest" "github.com/hashicorp/hcl/v2" "github.com/stretchr/testify/assert" @@ -41,13 +42,14 @@ func Test_Extract(t *testing.T) { failPreview bool input preview.Input - expTags map[string]string - unknownTags []string - params map[string]assertParam - variables map[string]assertVariable - presetsFuncs func(t *testing.T, presets []types.Preset) - presets map[string]assertPreset - warnings []*regexp.Regexp + expTags map[string]string + unknownTags []string + params map[string]assertParam + variables map[string]assertVariable + presetsFuncs func(t *testing.T, presets []types.Preset) + presets map[string]assertPreset + warnings []*regexp.Regexp + secretRequirements []types.SecretRequirement }{ { name: "bad param values", @@ -657,6 +659,38 @@ func Test_Extract(t *testing.T) { prebuildCount(1), }, }, + { + name: "secrets basic", + dir: "secretsbasic", + secretRequirements: []types.SecretRequirement{ + {Env: "GITHUB_TOKEN", HelpMessage: "Add a GitHub PAT"}, + {File: "~/.aws/credentials", HelpMessage: "Add AWS creds"}, + }, + }, + { + name: "secrets conditional off", + dir: "secretsconditional", + input: preview.Input{ + ParameterValues: map[string]string{"use_github": "false"}, + }, + params: map[string]assertParam{ + "use_github": ap().value("false"), + }, + secretRequirements: nil, + }, + { + name: "secrets conditional on", + dir: "secretsconditional", + input: preview.Input{ + ParameterValues: map[string]string{"use_github": "true"}, + }, + params: map[string]assertParam{ + "use_github": ap().value("true"), + }, + secretRequirements: []types.SecretRequirement{ + {Env: "GITHUB_TOKEN", HelpMessage: "Add a GitHub PAT"}, + }, + }, { name: "override", dir: "override", @@ -756,6 +790,10 @@ func Test_Extract(t *testing.T) { require.True(t, ok, "unknown variable %s", variable.Name) check(t, variable) } + + // Assert secret requirements + require.ElementsMatch(t, tc.secretRequirements, output.SecretRequirements, + "secret requirements do not match expected") }) } } @@ -1105,3 +1143,172 @@ DiagLoop: assert.Equal(t, []string{}, checks, "missing expected diagnostic errors") } + +func Test_SecretRequirementErrors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + tf string + wantDiag string // substring match on summary+" "+detail + }{ + { + name: "missing help_message", + tf: ` +data "coder_secret" "x" { + env = "X" +} +`, + wantDiag: `help_message`, + }, + { + name: "help_message null", + tf: ` +data "coder_secret" "x" { + env = "X" + help_message = null +} +`, + wantDiag: `help_message`, + }, + { + name: "help_message wrong type (number)", + tf: ` +data "coder_secret" "x" { + env = "X" + help_message = 42 +} +`, + wantDiag: `Expected a string`, + }, + { + name: "neither env nor file", + tf: ` +data "coder_secret" "x" { + help_message = "need one" +} +`, + wantDiag: `Exactly one of "env" or "file" must be set`, + }, + { + name: "both env and file", + tf: ` +data "coder_secret" "x" { + env = "X" + file = "~/y" + help_message = "ok" +} +`, + wantDiag: `Exactly one of "env" or "file" must be set`, + }, + { + name: "env wrong type (number)", + tf: ` +data "coder_secret" "x" { + env = 42 + help_message = "ok" +} +`, + wantDiag: `Expected a string`, + }, + { + name: "file wrong type (bool)", + tf: ` +data "coder_secret" "x" { + file = true + help_message = "ok" +} +`, + wantDiag: `Expected a string`, + }, + { + name: "env null", + tf: ` +data "coder_secret" "x" { + env = null + help_message = "ok" +} +`, + wantDiag: `Expected a string`, + }, + { + name: "file null", + tf: ` +data "coder_secret" "x" { + file = null + help_message = "ok" +} +`, + wantDiag: `Expected a string`, + }, + { + name: "duplicate block labels", + tf: ` +data "coder_secret" "x" { + env = "A" + help_message = "first" +} +data "coder_secret" "x" { + env = "B" + help_message = "second" +} +`, + wantDiag: `duplicate coder_secret blocks with name "x"`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + fsys := fstest.MapFS{"main.tf": &fstest.MapFile{Data: []byte(tc.tf)}} + _, diags := preview.Preview(context.Background(), preview.Input{}, fsys) + require.True(t, diags.HasErrors(), "expected errors; got %v", diags) + var found bool + for _, d := range diags { + if strings.Contains(d.Summary+" "+d.Detail, tc.wantDiag) { + found = true + break + } + } + require.True(t, found, + "no diag matching %q; got: %v", tc.wantDiag, diags) + }) + } +} + +// Test_SecretRequirement_DuplicateDetectionRequiresExtractionSuccess pins the +// current behavior: duplicate-label detection only fires for blocks that +// successfully extract. When one of two same-labeled blocks has an extraction +// error (e.g. wrong attribute type), the dedup bookkeeping skips it, so the +// "duplicate coder_secret blocks" diagnostic does not appear — only the +// per-block extraction error does. This matches parameter.go's precedent. +// If this behavior is ever changed to "track duplicates regardless of +// extraction success," update this test. +func Test_SecretRequirement_DuplicateDetectionRequiresExtractionSuccess(t *testing.T) { + t.Parallel() + tf := ` +data "coder_secret" "x" { + env = 42 + help_message = "first" +} +data "coder_secret" "x" { + env = "B" + help_message = "second" +} +` + fsys := fstest.MapFS{"main.tf": &fstest.MapFile{Data: []byte(tf)}} + _, diags := preview.Preview(context.Background(), preview.Input{}, fsys) + require.True(t, diags.HasErrors(), "expected errors; got %v", diags) + + var hasTypeErr, hasDupeErr bool + for _, d := range diags { + msg := d.Summary + " " + d.Detail + if strings.Contains(msg, "Expected a string") { + hasTypeErr = true + } + if strings.Contains(msg, "duplicate coder_secret blocks") { + hasDupeErr = true + } + } + require.True(t, hasTypeErr, "expected type error for env=42; got: %v", diags) + require.False(t, hasDupeErr, + "duplicate diagnostic unexpectedly fired; dedup should only track successful extractions. diags: %v", diags) +} diff --git a/secret.go b/secret.go new file mode 100644 index 0000000..a918102 --- /dev/null +++ b/secret.go @@ -0,0 +1,56 @@ +package preview + +import ( + "fmt" + "strings" + + "github.com/aquasecurity/trivy/pkg/iac/terraform" + "github.com/hashicorp/hcl/v2" + + "github.com/coder/preview/extract" + "github.com/coder/preview/types" +) + +func secrets(modules terraform.Modules) ([]types.SecretRequirement, hcl.Diagnostics) { + diags := make(hcl.Diagnostics, 0) + reqs := make([]types.SecretRequirement, 0) + // Track blocks by label (e.g. "x" in `data "coder_secret" "x"`) so we can + // emit a single duplicate diagnostic per colliding label. Mirrors the + // parameter dedup pattern in parameter.go. + exists := make(map[string][]*terraform.Block) + + for _, mod := range modules { + blocks := mod.GetDatasByType(types.BlockTypeSecret) + for _, block := range blocks { + req, rDiags := extract.SecretFromBlock(block) + if len(rDiags) > 0 { + diags = diags.Extend(rDiags) + } + if req != nil { + reqs = append(reqs, *req) + name := block.NameLabel() + exists[name] = append(exists[name], block) + } + } + } + + for name, blocks := range exists { + if len(blocks) <= 1 { + continue + } + var detail strings.Builder + for _, b := range blocks { + _, _ = detail.WriteString(fmt.Sprintf("block %q at %s\n", + b.Type()+"."+strings.Join(b.Labels(), "."), + b.HCLBlock().TypeRange)) + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Found %d duplicate coder_secret blocks with name %q, this is not allowed", len(blocks), name), + Detail: detail.String(), + }) + } + + types.SortSecretRequirements(reqs) + return reqs, diags +} diff --git a/testdata/secretsbasic/main.tf b/testdata/secretsbasic/main.tf new file mode 100644 index 0000000..646732f --- /dev/null +++ b/testdata/secretsbasic/main.tf @@ -0,0 +1,9 @@ +data "coder_secret" "gh" { + env = "GITHUB_TOKEN" + help_message = "Add a GitHub PAT" +} + +data "coder_secret" "aws" { + file = "~/.aws/credentials" + help_message = "Add AWS creds" +} diff --git a/testdata/secretsbasic/skipe2e b/testdata/secretsbasic/skipe2e new file mode 100644 index 0000000..6da9986 --- /dev/null +++ b/testdata/secretsbasic/skipe2e @@ -0,0 +1 @@ +coder_secret is not yet in the released coder provider \ No newline at end of file diff --git a/testdata/secretsconditional/main.tf b/testdata/secretsconditional/main.tf new file mode 100644 index 0000000..29dc34c --- /dev/null +++ b/testdata/secretsconditional/main.tf @@ -0,0 +1,12 @@ +data "coder_parameter" "use_github" { + name = "use_github" + type = "bool" + default = "false" + mutable = true +} + +data "coder_secret" "gh" { + count = data.coder_parameter.use_github.value == "true" ? 1 : 0 + env = "GITHUB_TOKEN" + help_message = "Add a GitHub PAT" +} diff --git a/testdata/secretsconditional/skipe2e b/testdata/secretsconditional/skipe2e new file mode 100644 index 0000000..6da9986 --- /dev/null +++ b/testdata/secretsconditional/skipe2e @@ -0,0 +1 @@ +coder_secret is not yet in the released coder provider \ No newline at end of file diff --git a/types/secret.go b/types/secret.go new file mode 100644 index 0000000..feb5c7b --- /dev/null +++ b/types/secret.go @@ -0,0 +1,29 @@ +package types + +import ( + "slices" + "strings" +) + +// @typescript-ignore BlockTypeSecret +const BlockTypeSecret = "coder_secret" + +// SecretRequirement describes a `data "coder_secret"` block declared in a +// template. Exactly one of Env or File will be non-empty; validation of that +// invariant happens during extraction. +type SecretRequirement struct { + Env string `json:"env,omitempty"` + File string `json:"file,omitempty"` + HelpMessage string `json:"help_message,omitempty"` +} + +// SortSecretRequirements orders requirements first by Env then by File so +// diagnostic output is stable across runs. +func SortSecretRequirements(reqs []SecretRequirement) { + slices.SortFunc(reqs, func(a, b SecretRequirement) int { + if c := strings.Compare(a.Env, b.Env); c != 0 { + return c + } + return strings.Compare(a.File, b.File) + }) +} diff --git a/types/secret_test.go b/types/secret_test.go new file mode 100644 index 0000000..a3e0846 --- /dev/null +++ b/types/secret_test.go @@ -0,0 +1,54 @@ +package types_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/preview/types" +) + +// Test_SecretRequirement_JSON_Omitempty verifies that empty Env, File, and +// HelpMessage fields are omitted from the marshaled JSON. Since extraction +// enforces exactly one of Env/File is non-empty, omitempty makes the on-the- +// wire shape explicit about which field applies. +func Test_SecretRequirement_JSON_Omitempty(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req types.SecretRequirement + want string + }{ + { + name: "env only", + req: types.SecretRequirement{Env: "FOO", HelpMessage: "set FOO"}, + want: `{"env":"FOO","help_message":"set FOO"}`, + }, + { + name: "file only", + req: types.SecretRequirement{File: "~/bar", HelpMessage: "set bar"}, + want: `{"file":"~/bar","help_message":"set bar"}`, + }, + { + name: "empty help_message is omitted", + req: types.SecretRequirement{Env: "FOO"}, + want: `{"env":"FOO"}`, + }, + { + name: "all empty produces empty object", + req: types.SecretRequirement{}, + want: `{}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := json.Marshal(tc.req) + require.NoError(t, err) + require.JSONEq(t, tc.want, string(got)) + }) + } +}