From 754756dfe33aa2b2000709e2cd5ccc2cfc933ff6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 12:04:58 -0500 Subject: [PATCH 1/6] fix(security): OPA and Cedar policy stubs now deny by default Stub Evaluate() methods previously returned Allowed: true, silently permitting all requests when no real backend was connected. They now return Allowed: false with an explicit safety reason. A log.Warn is emitted at Init() time for both backends. Set allow_stub_backends: true in the module config to restore allow-all behaviour for testing. Co-Authored-By: Claude Opus 4.6 --- module/policy_engine.go | 64 +++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/module/policy_engine.go b/module/policy_engine.go index 1b53266a..b1943f43 100644 --- a/module/policy_engine.go +++ b/module/policy_engine.go @@ -3,6 +3,7 @@ package module import ( "context" "fmt" + "log/slog" "sync" "github.com/CrisisTextLine/modular" @@ -53,14 +54,20 @@ func (m *PolicyEngineModule) Init(app modular.Application) error { m.backend = "mock" } + allowStub := isTruthy(m.config["allow_stub_backends"]) + switch m.backend { case "mock": m.engine = newMockPolicyEngine() case "opa": endpoint, _ := m.config["endpoint"].(string) - m.engine = newOPAPolicyEngine(endpoint) + m.engine = newOPAPolicyEngine(endpoint, allowStub) + slog.Warn("WARNING: using stub policy engine — all requests will be DENIED. Set allow_stub_backends: true in config to use stub backends for testing.", + "module", m.name, "backend", "opa", "allow_stub_backends", allowStub) case "cedar": - m.engine = newCedarPolicyEngine() + m.engine = newCedarPolicyEngine(allowStub) + slog.Warn("WARNING: using stub policy engine — all requests will be DENIED. Set allow_stub_backends: true in config to use stub backends for testing.", + "module", m.name, "backend", "cedar", "allow_stub_backends", allowStub) default: return fmt.Errorf("policy.engine %q: unsupported backend %q", m.name, m.backend) } @@ -165,6 +172,17 @@ func (e *mockPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*P }, nil } +// isTruthy returns true if v is a bool true, or a string "true"/"1"/"yes". +func isTruthy(v any) bool { + switch val := v.(type) { + case bool: + return val + case string: + return val == "true" || val == "1" || val == "yes" + } + return false +} + func containsString(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || func() bool { @@ -182,16 +200,17 @@ func containsString(s, substr string) bool { // opaPolicyEngine is a stub for OPA (Open Policy Agent) integration. // Production: POST to the OPA REST API at /v1/data/. type opaPolicyEngine struct { - endpoint string - mu sync.RWMutex - policies map[string]string + endpoint string + allowStub bool + mu sync.RWMutex + policies map[string]string } -func newOPAPolicyEngine(endpoint string) *opaPolicyEngine { +func newOPAPolicyEngine(endpoint string, allowStub bool) *opaPolicyEngine { if endpoint == "" { endpoint = "http://localhost:8181" } - return &opaPolicyEngine{endpoint: endpoint, policies: make(map[string]string)} + return &opaPolicyEngine{endpoint: endpoint, allowStub: allowStub, policies: make(map[string]string)} } func (e *opaPolicyEngine) LoadPolicy(name, content string) error { @@ -215,9 +234,16 @@ func (e *opaPolicyEngine) ListPolicies() []PolicyInfo { func (e *opaPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*PolicyDecision, error) { // Production: POST {"input": input} to /v1/data/ // and parse the result body for {"result": {"allow": true}}. + if e.allowStub { + return &PolicyDecision{ + Allowed: true, + Reasons: []string{"opa stub: allow_stub_backends enabled"}, + Metadata: map[string]any{"backend": "opa", "endpoint": e.endpoint, "input": input}, + }, nil + } return &PolicyDecision{ - Allowed: true, - Reasons: []string{"opa stub: default allow"}, + Allowed: false, + Reasons: []string{"STUB IMPLEMENTATION - not connected to real backend - denied for safety"}, Metadata: map[string]any{"backend": "opa", "endpoint": e.endpoint, "input": input}, }, nil } @@ -227,12 +253,13 @@ func (e *opaPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*Po // cedarPolicyEngine is a stub for Cedar policy language integration. // Production: use the cedar-go library (github.com/cedar-policy/cedar-go). type cedarPolicyEngine struct { - mu sync.RWMutex - policies map[string]string + allowStub bool + mu sync.RWMutex + policies map[string]string } -func newCedarPolicyEngine() *cedarPolicyEngine { - return &cedarPolicyEngine{policies: make(map[string]string)} +func newCedarPolicyEngine(allowStub bool) *cedarPolicyEngine { + return &cedarPolicyEngine{allowStub: allowStub, policies: make(map[string]string)} } func (e *cedarPolicyEngine) LoadPolicy(name, content string) error { @@ -256,9 +283,16 @@ func (e *cedarPolicyEngine) ListPolicies() []PolicyInfo { func (e *cedarPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*PolicyDecision, error) { // Production: build a cedar.Request from input (principal, action, resource, context) // and call policySet.IsAuthorized(request). + if e.allowStub { + return &PolicyDecision{ + Allowed: true, + Reasons: []string{"cedar stub: allow_stub_backends enabled"}, + Metadata: map[string]any{"backend": "cedar", "input": input}, + }, nil + } return &PolicyDecision{ - Allowed: true, - Reasons: []string{"cedar stub: default allow"}, + Allowed: false, + Reasons: []string{"STUB IMPLEMENTATION - not connected to real backend - denied for safety"}, Metadata: map[string]any{"backend": "cedar", "input": input}, }, nil } From 39ef8bff29440e1175fd60dba48984607c359dd6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 12:05:01 -0500 Subject: [PATCH 2/6] fix(wfctl): add -env flag to runRun to fix deploy cloud passthrough runDeployCloud passes -env to the run subcommand but runRun did not accept it, causing an unknown flag error. Add -env flag and propagate it as WORKFLOW_ENV environment variable. Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/run.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/wfctl/run.go b/cmd/wfctl/run.go index 61f485c8..2f5a3a48 100644 --- a/cmd/wfctl/run.go +++ b/cmd/wfctl/run.go @@ -20,6 +20,7 @@ import ( func runRun(args []string) error { fs := flag.NewFlagSet("run", flag.ExitOnError) logLevel := fs.String("log-level", "info", "Log level (debug, info, warn, error)") + env := fs.String("env", "", "Environment name (sets WORKFLOW_ENV)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl run [options] \n\nRun a workflow engine from a config file.\n\nOptions:\n") fs.PrintDefaults() @@ -27,6 +28,11 @@ func runRun(args []string) error { if err := fs.Parse(args); err != nil { return err } + if *env != "" { + if err := os.Setenv("WORKFLOW_ENV", *env); err != nil { + return fmt.Errorf("failed to set WORKFLOW_ENV: %w", err) + } + } if fs.NArg() < 1 { fs.Usage() return fmt.Errorf("config file path is required") From 343fb05117a0e0da7648e20e36702ede681e0015 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 12:05:27 -0500 Subject: [PATCH 3/6] fix(security): use Stripe billing provider when STRIPE_API_KEY is set The server always used MockBillingProvider regardless of environment, meaning real billing was never invoked in production. Now checks STRIPE_API_KEY at startup: if present, constructs a StripeProvider (also reads STRIPE_WEBHOOK_SECRET); otherwise falls back to mock with a Warn-level log so the omission is visible in production logs. Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index fd3df258..58601df9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -712,7 +712,15 @@ func (app *serverApp) initStores(logger *slog.Logger) error { // ----------------------------------------------------------------------- billingMeter := billing.NewInMemoryMeter() - billingProvider := billing.NewMockBillingProvider() + var billingProvider billing.BillingProvider + if stripeKey := os.Getenv("STRIPE_API_KEY"); stripeKey != "" { + webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") + billingProvider = billing.NewStripeProvider(stripeKey, webhookSecret, billing.StripePlanIDs{}) + logger.Info("Billing: using Stripe provider") + } else { + logger.Warn("STRIPE_API_KEY not set — billing is using MockBillingProvider; set STRIPE_API_KEY to enable real billing") + billingProvider = billing.NewMockBillingProvider() + } billingHandler := billing.NewHandler(billingMeter, billingProvider) billingMux := http.NewServeMux() billingHandler.RegisterRoutes(billingMux) From 8d0b6048014f8cb5851519c41d1c0f1b115e5b16 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 12:05:40 -0500 Subject: [PATCH 4/6] fix(wfctl): replace manual YAML parser in loadWfctlConfig with yaml.Unmarshal The hand-rolled line splitter mishandled quoted values, YAML nesting, and special characters. Replace it with a proper wfctlConfigFile struct and yaml.Unmarshal to correctly parse the nested project/git/deploy sections written by writeWfctlConfig. Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/git_connect.go | 75 +++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/cmd/wfctl/git_connect.go b/cmd/wfctl/git_connect.go index 170059e7..8f29bb19 100644 --- a/cmd/wfctl/git_connect.go +++ b/cmd/wfctl/git_connect.go @@ -7,6 +7,8 @@ import ( "os/exec" "path/filepath" "strings" + + "gopkg.in/yaml.v3" ) // wfctlConfig represents the .wfctl.yaml project config file. @@ -236,6 +238,25 @@ ui/node_modules/ return os.WriteFile(".gitignore", []byte(content), 0600) } +// wfctlConfigFile mirrors the nested YAML structure of .wfctl.yaml for unmarshaling. +type wfctlConfigFile struct { + Project struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + ConfigFile string `yaml:"configFile"` + } `yaml:"project"` + Git struct { + Repository string `yaml:"repository"` + Branch string `yaml:"branch"` + AutoPush bool `yaml:"autoPush"` + GenerateActions bool `yaml:"generateActions"` + } `yaml:"git"` + Deploy struct { + Target string `yaml:"target"` + Namespace string `yaml:"namespace"` + } `yaml:"deploy"` +} + // loadWfctlConfig reads .wfctl.yaml from the current directory. func loadWfctlConfig() (*wfctlConfig, error) { data, err := os.ReadFile(".wfctl.yaml") @@ -243,44 +264,28 @@ func loadWfctlConfig() (*wfctlConfig, error) { return nil, fmt.Errorf("failed to read .wfctl.yaml: %w (run 'wfctl git connect' first)", err) } + var raw wfctlConfigFile + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse .wfctl.yaml: %w", err) + } + cfg := &wfctlConfig{ - GitBranch: "main", - DeployTarget: "kubernetes", + ProjectName: raw.Project.Name, + ProjectVersion: raw.Project.Version, + ConfigFile: raw.Project.ConfigFile, + GitRepository: raw.Git.Repository, + GitBranch: raw.Git.Branch, + GitAutoPush: raw.Git.AutoPush, + GenerateActions: raw.Git.GenerateActions, + DeployTarget: raw.Deploy.Target, + DeployNamespace: raw.Deploy.Namespace, } - // Simple line-by-line YAML parser for .wfctl.yaml - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - kv := strings.SplitN(line, ":", 2) - if len(kv) != 2 { - continue - } - key := strings.TrimSpace(kv[0]) - val := strings.TrimSpace(kv[1]) - - switch key { - case "name": - cfg.ProjectName = val - case "version": - cfg.ProjectVersion = strings.Trim(val, "\"") - case "configFile": - cfg.ConfigFile = val - case "repository": - cfg.GitRepository = val - case "branch": - cfg.GitBranch = val - case "autoPush": - cfg.GitAutoPush = val == "true" - case "generateActions": - cfg.GenerateActions = val == "true" - case "target": - cfg.DeployTarget = val - case "namespace": - cfg.DeployNamespace = val - } + if cfg.GitBranch == "" { + cfg.GitBranch = "main" + } + if cfg.DeployTarget == "" { + cfg.DeployTarget = "kubernetes" } return cfg, nil From 678b6ed5b828b33e3d41c97ebded8a711420ed72 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 12:06:14 -0500 Subject: [PATCH 5/6] fix(wfctl): remove bytesReaderImpl in favor of bytes.NewReader The hand-rolled bytesReaderImpl struct duplicates the stdlib bytes.NewReader. Delete it and replace the single call site with bytes.NewReader(data) directly. Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/plugin_install.go | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 47ec1f23..7aa22afd 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -2,6 +2,7 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" "crypto/sha256" "encoding/hex" @@ -249,7 +250,7 @@ func verifyChecksum(data []byte, expected string) error { // extractTarGz decompresses and extracts a .tar.gz archive into destDir. // It guards against path traversal (zip-slip) attacks. func extractTarGz(data []byte, destDir string) error { - gzr, err := gzip.NewReader(bytesReader(data)) + gzr, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { return fmt.Errorf("open gzip: %w", err) } @@ -301,25 +302,6 @@ func extractTarGz(data []byte, destDir string) error { return nil } -// bytesReader wraps a byte slice as an io.Reader. -type bytesReaderImpl struct { - data []byte - pos int -} - -func (b *bytesReaderImpl) Read(p []byte) (int, error) { - if b.pos >= len(b.data) { - return 0, io.EOF - } - n := copy(p, b.data[b.pos:]) - b.pos += n - return n, nil -} - -func bytesReader(data []byte) io.Reader { - return &bytesReaderImpl{data: data} -} - // stripTopDir removes the first path component from a tar entry name. // e.g. "workflow-plugin-admin-darwin-amd64/admin.plugin" -> "admin.plugin" func stripTopDir(name string) string { From abf1a9a9fbda699043d9a2f1bbda93157a7a2ea6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 12:06:31 -0500 Subject: [PATCH 6/6] fix(wfctl): add compare as alias for contract test subcommand Users expect wfctl contract compare to work as a natural alias for wfctl contract test. Add compare to the dispatch switch and update the usage text to document the alias. Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/contract.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/wfctl/contract.go b/cmd/wfctl/contract.go index 18366a88..48df0096 100644 --- a/cmd/wfctl/contract.go +++ b/cmd/wfctl/contract.go @@ -93,7 +93,7 @@ func runContract(args []string) error { return contractUsage() } switch args[0] { - case "test": + case "test", "compare": return runContractTest(args[1:]) default: return contractUsage() @@ -104,7 +104,8 @@ func contractUsage() error { fmt.Fprintf(os.Stderr, `Usage: wfctl contract [options] Subcommands: - test Generate a contract from a config and optionally compare to a baseline + test Generate a contract from a config and optionally compare to a baseline + compare Alias for test Run 'wfctl contract test -h' for details. `)