diff --git a/README.md b/README.md index c17def9..a8c50fa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![npm](https://img.shields.io/npm/v/@optiqor/cli.svg?label=%40optiqor%2Fcli&color=blue)](https://www.npmjs.com/package/@optiqor/cli) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Go Reference](https://pkg.go.dev/badge/github.com/optiqor/optiqor-cli.svg)](https://pkg.go.dev/github.com/optiqor/optiqor-cli) -[![CI](https://img.shields.io/github/actions/workflow/status/optiqor/optiqor/ci.yml?branch=main&label=ci)](https://github.com/optiqor/optiqor-cli/actions/workflows/ci.yml) +[![CI](https://img.shields.io/github/actions/workflow/status/optiqor/optiqor-cli/ci.yml?branch=main&label=ci)](https://github.com/optiqor/optiqor-cli/actions/workflows/ci.yml) [![Downloads](https://img.shields.io/npm/dm/@optiqor/cli.svg)](https://www.npmjs.com/package/@optiqor/cli) ```sh @@ -87,7 +87,7 @@ sudo mv optiqor /usr/local/bin/ ``` > [!TIP] -> All release artifacts are signed with [Cosign](https://docs.sigstore.dev/cosign/overview/). Verification instructions on the [release page](https://github.com/optiqor/optiqor-cli/releases). +> All release artifacts are signed with [Cosign](https://github.com/sigstore/cosign). Verification instructions on the [release page](https://github.com/optiqor/optiqor-cli/releases). ### Option 5: Build from source @@ -440,7 +440,7 @@ flowchart TD | Flag | Default | Description | | --- | --- | --- | | `--json` | false | Emit machine-readable JSON | -| `--offline` | true | Do not perform any network calls | +| `--offline` | false | Block opt-in network calls such as `--share` | | `--share` | false | Upload sanitized analysis to optiqor.dev (opt-in) | | `--no-color` | false | Disable ANSI color in output | | `--quiet` | false | Suppress all output except findings | @@ -453,8 +453,8 @@ flowchart TD | Variable | Purpose | | --- | --- | | `OPTIQOR_NO_COLOR` | Disable color output (CI-friendly, equivalent to `--no-color`) | -| `OPTIQOR_OFFLINE` | Force offline mode | -| `OPTIQOR_SHARE_BASE_URL` | Override the share endpoint (for self-hosted Optiqor) | +| `OPTIQOR_OFFLINE` | Set to `1`, `true`, `yes`, or `on` to force offline mode | +| `OPTIQOR_SHARE_URL` | Override the share endpoint (for self-hosted Optiqor) | | `OPTIQOR_SKIP_POSTINSTALL` | Skip the npm postinstall binary download (for offline npm caches) | --- diff --git a/cmd/optiqor/main.go b/cmd/optiqor/main.go index 53a3cef..0df2695 100644 --- a/cmd/optiqor/main.go +++ b/cmd/optiqor/main.go @@ -13,6 +13,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/spf13/cobra" @@ -201,16 +202,19 @@ side-effect of parsing — they are not the headline feature. return err } if shareFlag { - emitShareURL(cmd, rep) + if offlineMode(offline) { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "warning: --share is ignored in --offline mode") + } else { + emitShareURL(cmd, rep) + } } - _ = offline return checkFailOn(rep, effFailOn) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "emit machine-readable JSON") cmd.Flags().StringVar(&htmlPath, "html", "", "also write a self-contained HTML report to this path") - cmd.Flags().BoolVar(&offline, "offline", true, "do not perform any network calls (always true in Phase 1)") - cmd.Flags().BoolVar(&shareFlag, "share", false, "print optiqor.dev/r/ for the sanitised analysis (no upload in Phase 1)") + cmd.Flags().BoolVar(&offline, "offline", false, "block opt-in network calls such as --share (also: OPTIQOR_OFFLINE=1)") + cmd.Flags().BoolVar(&shareFlag, "share", false, "upload sanitised analysis and print optiqor.dev/r/") cmd.Flags().BoolVar(&roast, "roast", false, "humorous output (findings stay accurate)") cmd.Flags().StringVar(&minSev, "severity", "", "drop findings below this severity (low|med|high)") cmd.Flags().StringArrayVar(&detectors, "detector", nil, "only run findings from these detector IDs (repeatable)") @@ -228,7 +232,7 @@ func writeHTMLReport(path string, rep render.Report) error { if err != nil { return fmt.Errorf("open --html: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() return htmlrender.Render(f, htmlrender.Data{ Source: rep.Source, Workloads: rep.Workloads, @@ -334,6 +338,21 @@ func validSeverity(s rules.Severity) bool { return s == rules.SeverityHigh || s == rules.SeverityMed || s == rules.SeverityLow } +// offlineMode is the canonical egress gate. Any new network call in +// this binary must short-circuit when this returns true. +func offlineMode(flag bool) bool { + return flag || envTruthy("OPTIQOR_OFFLINE") +} + +func envTruthy(name string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func toUpper(s string) string { out := make([]byte, len(s)) for i := 0; i < len(s); i++ { diff --git a/cmd/optiqor/main_test.go b/cmd/optiqor/main_test.go index 772db2f..14479b8 100644 --- a/cmd/optiqor/main_test.go +++ b/cmd/optiqor/main_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -138,6 +140,91 @@ func TestAnalyze_JSONShape(t *testing.T) { } } +func TestAnalyze_OfflineShareWarnsAndSkipsUpload(t *testing.T) { + cmd := newRootCmd() + var out bytes.Buffer + var errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs([]string{"analyze", "../../testdata/fixtures/basic-chart/values.yaml", "--offline", "--share"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute --offline --share: %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String()) + } + if !strings.Contains(errBuf.String(), "warning: --share is ignored in --offline mode") { + t.Fatalf("missing offline share warning:\n%s", errBuf.String()) + } + if strings.Contains(errBuf.String(), "share URL:") { + t.Fatalf("offline share should not print share URL:\n%s", errBuf.String()) + } +} + +func TestAnalyze_ShareUploadsWhenOfflineDisabled(t *testing.T) { + shareServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("share upload method = %s, want POST", r.Method) + } + if r.Header.Get("X-Optiqor-Hash") == "" { + t.Error("share upload missing X-Optiqor-Hash header") + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(shareServer.Close) + t.Setenv("OPTIQOR_SHARE_URL", shareServer.URL) + + cmd := newRootCmd() + var out bytes.Buffer + var errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs([]string{"analyze", "../../testdata/fixtures/basic-chart/values.yaml", "--share"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute --share: %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String()) + } + if strings.Contains(errBuf.String(), "warning: --share is ignored in --offline mode") { + t.Fatalf("online share should not warn about offline mode:\n%s", errBuf.String()) + } + if !strings.Contains(errBuf.String(), "share URL:") { + t.Fatalf("online share should print share URL:\n%s", errBuf.String()) + } + if !strings.Contains(errBuf.String(), "(uploaded)") { + t.Fatalf("online share should report upload success:\n%s", errBuf.String()) + } +} + +func TestAnalyze_OfflineEnvShareWarnsAndSkipsUpload(t *testing.T) { + t.Setenv("OPTIQOR_OFFLINE", "1") + cmd := newRootCmd() + var out bytes.Buffer + var errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs([]string{"analyze", "../../testdata/fixtures/basic-chart/values.yaml", "--offline=false", "--share"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute OPTIQOR_OFFLINE=1 --offline=false --share: %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String()) + } + if !strings.Contains(errBuf.String(), "warning: --share is ignored in --offline mode") { + t.Fatalf("missing env offline share warning:\n%s", errBuf.String()) + } + if strings.Contains(errBuf.String(), "share URL:") { + t.Fatalf("offline env share should not print share URL:\n%s", errBuf.String()) + } +} + +func TestAnalyze_HelpDocumentsOfflineEnv(t *testing.T) { + cmd := newRootCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"analyze", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute analyze --help: %v", err) + } + for _, want := range []string{"--offline", "OPTIQOR_OFFLINE=1", "--share"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("analyze help missing %q:\n%s", want, buf.String()) + } + } +} + func TestResolveColor_NoColorFlag(t *testing.T) { cmd := newRootCmd() if got := resolveColor(cmd, true); got { diff --git a/pkg/htmlrender/htmlrender.go b/pkg/htmlrender/htmlrender.go index c2bfa67..7335132 100644 --- a/pkg/htmlrender/htmlrender.go +++ b/pkg/htmlrender/htmlrender.go @@ -49,7 +49,11 @@ const AccuracyDisclosure = "Sandbox accuracy: ±40%. Install the Optiqor agent f type Mode int const ( + // ModeSandbox renders the public, ±40%-accuracy sandbox banner used by + // the CLI and the optiqor.com share preview. ModeSandbox Mode = iota + // ModeAgent renders the exact-accuracy banner used by the paid + // in-cluster agent path. ModeAgent ) diff --git a/verify.sh b/verify.sh index 792992c..cdd7135 100755 --- a/verify.sh +++ b/verify.sh @@ -201,8 +201,8 @@ for flag in --json --no-color --severity --fail-on --detector --config --offline bash -c "'$BIN' analyze --help 2>&1 | grep -q -- '$flag'" done -check "--offline defaults to true (zero-config never phones home)" \ - bash -c "'$BIN' analyze --help 2>&1 | grep -qE 'offline.*default.*true'" +check "--offline documents the env override" \ + bash -c "'$BIN' analyze --help 2>&1 | grep -q -- '--offline' && '$BIN' analyze --help 2>&1 | grep -q 'OPTIQOR_OFFLINE=1'" check "--share is OFF by default" \ bash -c "'$BIN' analyze --help 2>&1 | grep -q -- '--share' && ! '$BIN' analyze --help 2>&1 | grep -qE 'share.*default *true'"