diff --git a/docs/schemas/gcp/gkeautopilotresource.json b/docs/schemas/gcp/gkeautopilotresource.json index dcdfaad1..72594afc 100644 --- a/docs/schemas/gcp/gkeautopilotresource.json +++ b/docs/schemas/gcp/gkeautopilotresource.json @@ -49,6 +49,9 @@ "enable": { "type": "boolean" }, + "hstsValue": { + "type": "string" + }, "image": { "type": "string" }, diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 00000000..98172750 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,21 @@ +# OSV-Scanner ignore list. Mirrors the `not_affected` statements in +# `vex/openvex.json` for the benefit of OpenSSF Scorecard's Vulnerabilities +# check, which queries osv.dev directly and doesn't read VEX. The VEX file +# remains the source-of-truth justification; this file is a re-statement +# in a format the scanner understands. +# +# Add an entry here ONLY after the corresponding `not_affected` statement +# lands in vex/openvex.json (review by code owners under the default +# `*` CODEOWNERS rule). Suppressing a real vulnerability here without VEX +# is a regression — track upstream-fix availability in the VEX +# impact_statement, and remove from BOTH files when fixable. +# +# Reference: https://google.github.io/osv-scanner/configuration/ + +[[IgnoredVulns]] +id = "GO-2022-0635" +reason = "VEX not_affected: github.com/aws/aws-sdk-go is required transitively by github.com/pulumi/pulumi/pkg/v3/operations; the s3crypto subpackage is never imported by this codebase nor any reachable code path. govulncheck -mode=source confirms 0 reachable vulnerabilities. Full justification in vex/openvex.json." + +[[IgnoredVulns]] +id = "GO-2022-0646" +reason = "VEX not_affected: same s3crypto unreachability as GO-2022-0635 — different advisory ID against the same subpackage. govulncheck confirms zero reachable calls to the affected symbols. Full justification in vex/openvex.json." diff --git a/pkg/clouds/k8s/types.go b/pkg/clouds/k8s/types.go index 5c03b189..055feab0 100644 --- a/pkg/clouds/k8s/types.go +++ b/pkg/clouds/k8s/types.go @@ -61,6 +61,11 @@ type CaddyConfig struct { // "Local" preserves the client source IP (required for correct X-Forwarded-For from Cloudflare). // "Cluster" (default) SNATs source IP to a node IP, losing the direct client IP. ExternalTrafficPolicy *string `json:"externalTrafficPolicy,omitempty" yaml:"externalTrafficPolicy,omitempty"` + // HSTSValue overrides the Strict-Transport-Security value emitted by + // the embedded `(hsts)` snippet. Applies to every site this Caddy + // aggregator serves; there is no per-stack override. Unset or empty + // uses the snippet's default (`max-age=31536000; includeSubDomains; preload`). + HSTSValue *string `json:"hstsValue,omitempty" yaml:"hstsValue,omitempty"` } type DisruptionBudget struct { diff --git a/pkg/clouds/pulumi/docker/build_and_push.go b/pkg/clouds/pulumi/docker/build_and_push.go index f71042c9..7c64b7a6 100644 --- a/pkg/clouds/pulumi/docker/build_and_push.go +++ b/pkg/clouds/pulumi/docker/build_and_push.go @@ -253,33 +253,11 @@ func executeSecurityOperations(ctx *sdk.Context, stack api.Stack, dockerImage *d return fmt.Sprintf("sh %s", shellQuote(path)) }).(sdk.StringOutput), - // Disable the default behaviour of passing the previous run's - // stdout/stderr back as PULUMI_COMMAND_STDOUT / PULUMI_COMMAND_STDERR - // env vars on Update. pulumi-command's - // `run()` builds envp with: - // - // if in.AddPreviousOutputInEnv == nil || *in.AddPreviousOutputInEnv { - // if out.Stdout != "" { - // cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", - // util.PulumiCommandStdout, out.Stdout)) - // } - // ... - // } - // - // The security-report script writes the full rendered vulnerability - // table to stdout for inline build-log visibility. On a chrome-base- - // derived image that's a 150-200 KB markdown table (thousands of CVE - // rows). pulumi-command captures that into the resource's `Stdout` - // output, which is then echoed back as a SINGLE envp entry on the - // next Update. Kernel ARG_MAX (~128 KB on Linux) covers argv+envp - // combined, so the next deploy fails at `fork/exec /bin/sh: argument - // list too long` — even though our argv is now ~80 bytes thanks to - // tempfile staging. - // - // Our security scripts don't read PULUMI_COMMAND_STDOUT; turning - // this off is purely a no-op for the script's behaviour. The - // stdout is still captured into state for `pulumi stack output` and - // downstream resource references, just not re-injected into envp. + // Suppress pulumi-command's default Stdout-into-envp re-injection on + // Update: the security-report script writes a ~150-200 KB CVE table + // to stdout, which combined with argv exhausts kernel ARG_MAX. Our + // scripts don't read PULUMI_COMMAND_STDOUT, so this is a no-op for + // behaviour; stdout still lands in state for downstream refs. AddPreviousOutputInEnv: sdk.Bool(false), }, sdk.DependsOn(reportDeps)) if err != nil { diff --git a/pkg/clouds/pulumi/kubernetes/caddy.go b/pkg/clouds/pulumi/kubernetes/caddy.go index 32a5bc5c..ab7cebb6 100644 --- a/pkg/clouds/pulumi/kubernetes/caddy.go +++ b/pkg/clouds/pulumi/kubernetes/caddy.go @@ -36,6 +36,18 @@ func isPulumiOutputSet(output sdk.StringOutput) bool { return output.ElementType() != nil } +// caddyHSTSEnv returns env-var additions overriding the `(hsts)` snippet's +// header. Empty string is treated as unset — Caddy's `os.LookupEnv` would +// otherwise treat a present-but-empty var as "found" and ship a broken +// `Strict-Transport-Security ""`. +func caddyHSTSEnv(cfg *k8s.CaddyConfig) map[string]string { + v := lo.FromPtr(cfg).HSTSValue + if v == nil || *v == "" { + return nil + } + return map[string]string{"HSTS_VALUE": *v} +} + func CaddyResource(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { if input.Descriptor.Type != k8s.ResourceTypeCaddy { return nil, errors.Errorf("unsupported caddy type %q", input.Descriptor.Type) @@ -311,6 +323,10 @@ func DeployCaddyService(ctx *sdk.Context, caddy CaddyDeployment, input api.Resou TextVolumes: caddyVolumes, } + for k, v := range caddyHSTSEnv(caddy.CaddyConfig) { + deploymentConfig.StackConfig.Env[k] = v + } + // Prepare secret environment variables (e.g., GOOGLE_APPLICATION_CREDENTIALS for GCP) secretEnvs := make(map[string]string) if len(caddy.SecretEnvs) > 0 { diff --git a/pkg/clouds/pulumi/kubernetes/caddy_hsts_value_test.go b/pkg/clouds/pulumi/kubernetes/caddy_hsts_value_test.go new file mode 100644 index 00000000..09b46aa9 --- /dev/null +++ b/pkg/clouds/pulumi/kubernetes/caddy_hsts_value_test.go @@ -0,0 +1,62 @@ +package kubernetes + +import ( + "reflect" + "testing" + + . "github.com/onsi/gomega" + "github.com/samber/lo" + + "github.com/simple-container-com/api/pkg/clouds/k8s" +) + +func TestCaddyfileEmbedHSTSPlaceholder(t *testing.T) { + RegisterTestingT(t) + content, err := Caddyconfig.ReadFile("embed/caddy/Caddyfile") + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring(`{$HSTS_VALUE:max-age=31536000; includeSubDomains; preload}`)) +} + +func TestCaddyConfig_HSTSValue_FieldShape(t *testing.T) { + RegisterTestingT(t) + ft, ok := reflect.TypeOf(k8s.CaddyConfig{}).FieldByName("HSTSValue") + Expect(ok).To(BeTrue()) + Expect(ft.Type.String()).To(Equal("*string")) + Expect(ft.Tag.Get("json")).To(Equal("hstsValue,omitempty")) + Expect(ft.Tag.Get("yaml")).To(Equal("hstsValue,omitempty")) +} + +func TestCaddyHSTSEnv_WiringContract(t *testing.T) { + RegisterTestingT(t) + + tests := []struct { + name string + cfg *k8s.CaddyConfig + want map[string]string + }{ + {name: "nil_config", cfg: nil, want: nil}, + {name: "unset_field", cfg: &k8s.CaddyConfig{}, want: nil}, + {name: "empty_string_treated_as_unset", cfg: &k8s.CaddyConfig{HSTSValue: lo.ToPtr("")}, want: nil}, + { + name: "explicit_value_overrides", + cfg: &k8s.CaddyConfig{HSTSValue: lo.ToPtr("max-age=31536000; includeSubDomains")}, + want: map[string]string{"HSTS_VALUE": "max-age=31536000; includeSubDomains"}, + }, + { + name: "max_age_zero_to_disable", + cfg: &k8s.CaddyConfig{HSTSValue: lo.ToPtr("max-age=0")}, + want: map[string]string{"HSTS_VALUE": "max-age=0"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := caddyHSTSEnv(tt.cfg) + if tt.want == nil { + Expect(got).To(BeEmpty()) + } else { + Expect(got).To(Equal(tt.want)) + } + }) + } +} diff --git a/pkg/clouds/pulumi/kubernetes/embed/caddy/Caddyfile b/pkg/clouds/pulumi/kubernetes/embed/caddy/Caddyfile index b32734ff..430b0eb7 100644 --- a/pkg/clouds/pulumi/kubernetes/embed/caddy/Caddyfile +++ b/pkg/clouds/pulumi/kubernetes/embed/caddy/Caddyfile @@ -3,8 +3,9 @@ } (hsts) { + # Default preserved byte-for-byte; override via CaddyConfig.HSTSValue. header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + Strict-Transport-Security "{$HSTS_VALUE:max-age=31536000; includeSubDomains; preload}" } @httpReq { header X-Forwarded-Proto http