diff --git a/.gitignore b/.gitignore index d5fb58f..a5dc8df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .env -csax \ No newline at end of file +csax diff --git a/README.md b/README.md index c722bcb..389cf6b 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,12 @@ Admin CLI for CrydenSync — manage users, sessions, and audit logs from the terminal. Not end-user facing; this is for developers/operators running a CrydenSync-backed app, same as `psql` is for a database, not for the app's own users. -## Installation +## Install ```bash go install github.com/crydensync/csax@latest ``` -If you get `command not found` (or, on Windows, `'csax' is not recognized`) after this, `csax` installed correctly — it's just not on your `PATH` yet. Fix: - -**Linux / macOS / Termux:** -```bash -echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc # use ~/.zshrc if that's your shell -source ~/.bashrc -``` - -**Windows (PowerShell):** -```powershell -setx PATH "$env:Path;$(go env GOPATH)\bin" -``` -Then open a new terminal window for it to take effect. - -Confirm it worked: -```bash -csax version -``` - ## Setup ```bash @@ -48,11 +29,19 @@ csax sessions revoke --user csax sessions revoke-all --user csax audit tail --user [--limit N] csax audit search --event [--limit N] # system-wide, across all users -csax oauth providers list [--json] # which providers have client ID/secret set +csax audit ask "" # alias for `ai ask` — natural-language question over your own data +csax oauth providers list [--json] | add # add: interactive, configures one provider's credentials csax oauth test # round-trips the provider's real endpoints before a live user hits it +csax oauth users get [--json] # which providers an account has linked +csax oauth unlink --provider # force-unlink a provider from an account +csax oauth config # interactive: BASE_URL/FRONTEND_URL + both providers at once csax ai query "" [--json] # read-only, allowlisted lookups over users/sessions/audit_events csax ai logs "" # natural-language audit event search, summarized — never acts +csax ai anomalies scan [--since 24h] # fixed-prompt `ai logs`, looks for credential-stuffing/abuse patterns +csax ai ask "" # direct question over any allowlisted entity, answered in plain language csax ai audit # flags likely misconfigurations from a fixed checklist — never auto-applies anything +csax ai config # interactive: provider/model/API key + auto-creates the read-only DB role +csax doctor # one-shot health check: DB, migrations, AI config, OAuth config csax stats # total users, active sessions, etc. csax health csax version @@ -60,23 +49,37 @@ csax version ## Optional: OAuth admin commands -`oauth providers list` and `oauth test` read the SAME env vars `api` +`oauth providers list`/`add`/`test` all read the SAME env vars `api` uses for its own OAuth config — they check the real values production uses, not a separate csax-only copy: ``` -BASE_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET +BASE_URL, FRONTEND_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET ``` -`oauth users get`/`oauth unlink` (managing which providers a specific -user has linked) are designed but not built yet — they need two new -`cryden` engine facades that don't exist as of this CLI's current -`cryden` dependency version. +Run `csax oauth config` for an interactive setup of all of the above, +or `csax oauth providers add ` to add just one provider once +`BASE_URL`/`FRONTEND_URL` are already set. + +**`BASE_URL` vs `FRONTEND_URL` — a mixup that actually happened during +testing:** `BASE_URL` is your BACKEND's own public URL, since that's +where the `/api/oauth/.../callback` route lives. If your frontend and +backend are on different domains (e.g. Vercel + Railway), `BASE_URL` +is the Railway one, not the Vercel one. Both wizards spell this out +explicitly rather than assuming it's obvious. + +`oauth users get`/`oauth unlink` manage which providers a specific +user has linked. These don't call any `cryden` engine method — they +query `oauth_identities` directly via SQL, same pattern as `users +list`/`stats` below. No engine change was needed or made to support +them. ## Optional: AI-assisted admin commands -`ai query`/`ai logs`/`ai audit` need their own config, on top of the -usual `.env`: +Run `csax ai config` for an interactive setup — it prompts for the +provider/model/API-key-env-name, and can create the read-only +database role for you directly (see below), rather than handing you a +script to run by hand. Or set these in `.env` yourself: ``` AI_PROVIDER=groq # or "openrouter" — both speak the same OpenAI-compatible chat completions shape @@ -85,10 +88,17 @@ AI_MODEL=... # any model id your chosen provider serves READONLY_DATABASE_URL=... # a SEPARATE connection string, pointing at a Postgres role that is physically read-only ``` -`READONLY_DATABASE_URL` is the real safety boundary for `ai query`/`ai -logs` — even a bug in the underlying allowlist validation can't cause -a write if the credential itself is incapable of one. Don't point it -at the same role `DATABASE_URL` uses. +`READONLY_DATABASE_URL` is the real safety boundary for `ai +query`/`ai logs`/`ai anomalies scan`/`ai ask` — even a bug in the +underlying allowlist validation can't cause a write if the credential +itself is incapable of one. Don't point it at the same role +`DATABASE_URL` uses. + +`csax ai config`'s role setup works against ANY Postgres provider, not +just Supabase — it only appends Supabase's `.` +username suffix when it actually detects a Supabase pooler host; a +self-hosted Postgres, Neon, RDS, etc. all just get the plain role +name. `ai audit` works even without any AI config — the checklist itself is plain Go, not model-generated; the model is only used to add a short @@ -96,17 +106,33 @@ prioritization narrative on top, which is skipped silently if AI isn't configured. None of the `ai` commands ever execute an action a finding surfaces — -`ai audit` never applies a fix, and `ai logs` never revokes a session -or locks an account it flags. Anything like that is a suggestion in -the output text, run yourself as a separate, explicit command. +`ai audit` never applies a fix, `ai logs`/`ai anomalies scan` never +revoke a session or lock an account they flag. Anything like that is a +suggestion in the output text, run yourself as a separate, explicit +command. + +**Privacy note on `ai ask`/`ai logs`/`ai anomalies scan`:** emails and +IPs are redacted to stable per-query placeholders (`email_1`, `ip_2`) +before anything is sent to your configured AI provider for +summarization — patterns like repetition and clustering are still +visible to the model, but real values never leave your infrastructure +through that path. The full, unredacted data is still what gets +printed to your own terminal afterward. This is a default `csax` +ships with, not something separately audited — worth knowing if +you're evaluating this for a deployment with stricter data-handling +requirements. ## Design notes -- Every command uses either the engine's public API/store methods, or — for a small number of read-only, system-wide commands the engine's store interfaces don't support (`users list`, `stats`, `audit search`) — direct SQL against the known Postgres schema, the same way `csax migrate` already does. No CrydenSync engine Go code was modified or added specifically to support the CLI. +- Every command uses either the engine's public API/store methods, or — for a small number of read-only, system-wide commands the engine's store interfaces don't support (`users list`, `stats`, `audit search`, `oauth users get`, `oauth unlink`) — direct SQL against the known Postgres schema, the same way `csax migrate` already does. No CrydenSync engine Go code was modified or added specifically to support the CLI. - `MIGRATIONS_DIR` (default `./migrations`) should point at a folder containing both CrydenSync's own migration files and your app's own — `csax migrate` treats them the same, just files matching `*.up.sql`/`*.down.sql`, run in filename order. - No CLI framework dependency (no Cobra) — deliberately dependency-light, same philosophy as the engine itself. This is also why `oauth`/`ai` help text lives in `usage()` by hand rather than being generated — there's no framework here to generate it from. - `ai` commands are the one part of csax that calls out to something other than this deployment's own Postgres — the `ai.LLMProvider` interface ships zero implementations upstream in `cryden`; csax brings its own (`llmProvider` in `aiprovider.go`, an OpenAI-chat-completions-shaped client — works with Groq, OpenRouter, or any other provider speaking that same shape via `AI_PROVIDER`), same pattern as `notify.EmailSender`. -- Colored output by default (auto-disabled when not writing to a real terminal). Commands returning structured data (`users get`, `users list`, `sessions list`) support `--json` for scripting. +- Colored, box-drawing table output by default (`table.go`) — auto-disabled when not writing to a real terminal, or when `NO_COLOR` is set (see https://no-color.org). Long values (UUIDs, emails) are truncated with `…` rather than blowing up column widths. Audit event types get semantic color: red for anything `*_failed`/`*_reuse_detected`/`*_locked`, green for `*_success`/`*_linked`/`*_unlocked`. +- A terminal spinner (`spinner.go`) shows during any real network/DB round trip — AI provider calls, the OAuth endpoint reachability check, and the read-only role's connection test. Skipped automatically in the same non-TTY/`NO_COLOR` cases as the table output, so scripted/piped usage never sees spinner frames mixed into captured output. +- `csax doctor` aggregates the existing `csax health` and `csax ai audit` checks plus an OAuth config summary into one command — it calls the same underlying functions those commands use rather than re-implementing the checks a second time. +- **Known gap, not addressed this release:** `--json` is only implemented on a handful of commands (`users get`, `users list`, `oauth providers list`, `oauth users get`, `ai query`). Retrofitting it consistently across every command (`ai logs`, `ai ask`, `ai anomalies scan`, `ai audit`, `doctor`, `stats`, `audit tail`/`search`) is real, not-yet-done work — each needs its own JSON-serializable shape, not just a flag. +- **Known gap, not addressed this release:** this is still one flat `package main` across ~20 files. Splitting into proper subpackages (`internal/oauth`, `internal/ai`, `internal/config`, ...) is a real structural change touching nearly every file's imports at once — deliberately NOT attempted alongside this release's feature work, since it's a much higher-risk change to make at the same time as everything else here, and much harder to review as one large diff. Worth doing as its own dedicated pass, with nothing else changing in that same commit. ## License diff --git a/cmd_ai_anomalies.go b/cmd_ai_anomalies.go new file mode 100644 index 0000000..634873f --- /dev/null +++ b/cmd_ai_anomalies.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/crydensync/cryden/v2/ai" + "github.com/crydensync/cryden/v2/store/postgres" +) + +// cmdAIAnomaliesScan is `ai logs` pre-run with a fixed, no-input +// prompt — deliberately implemented as a thin wrapper around the same +// auditOnlyProvider/ExecuteQuery path rather than a separate code +// path, so the two commands can never quietly drift apart in +// behavior. +func cmdAIAnomaliesScan(cfg csaxConfig, since string) { + readonlyDB, provider := mustAISetup(cfg) + defer readonlyDB.Close() + + store := postgres.NewSafeQueryStore(readonlyDB) + auditProvider := &auditOnlyProvider{inner: provider} + + naturalLanguage := fmt.Sprintf( + "unusual or suspicious activity in the last %s — repeated failed logins, token reuse detections, logins from new or unusual locations, or clusters of signups from the same source", + since, + ) + var result ai.QueryResult + err := withSpinner("Scanning for anomalies...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, auditProvider, naturalLanguage) + return qerr + }) + if err != nil { + fmt.Println(red("Anomaly scan failed: " + err.Error())) + os.Exit(1) + } + + if len(result.Rows) == 0 { + fmt.Println(dim("No events found in that window.")) + return + } + + var summary string + summarizeErr := withSpinner("Summarizing...", func() error { + var serr error + summary, serr = provider.Summarize(context.Background(), + "You are scanning audit log events for a system administrator, looking specifically for signs of "+ + "credential stuffing, account takeover attempts, or abuse. Some identifiers below are placeholders "+ + "(email_1, ip_2, etc.) standing in for real values — refer to them exactly as given, never invent a "+ + "real-looking email or IP. Summarize what you actually see in 2-4 plain-language sentences. Only "+ + "describe patterns present in the data given — never invent detail. If nothing looks concerning, "+ + "say so plainly rather than manufacturing a finding.", + redactForSummary(result)) + return serr + }) + if summarizeErr != nil { + fmt.Println(yellow("(could not generate a summary: " + summarizeErr.Error() + ")")) + } else { + fmt.Println(summary) + fmt.Println() + } + + fmt.Println(dim("Run `csax ai logs \"...\"` for detail on any of these, or `csax ai query` for user-level data.")) + fmt.Println() + printQueryResult(result, false) +} diff --git a/cmd_ai_ask.go b/cmd_ai_ask.go new file mode 100644 index 0000000..c5cc22c --- /dev/null +++ b/cmd_ai_ask.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/crydensync/cryden/v2/ai" + "github.com/crydensync/cryden/v2/store/postgres" +) + +// cmdAIAsk implements `csax ai ask ""` (aliased as `csax +// audit ask`). Unlike `ai logs`, this does NOT force Entity to +// audit_events — a real operator question ("anything weird with +// devray@example.com this week?") may need to look at users or +// sessions too, not just the audit trail. Same ExecuteQuery/ +// SafeQueryStore path as `ai query`, phrased as a direct question +// with a narrative answer instead of a raw table. +func cmdAIAsk(cfg csaxConfig, question string) { + readonlyDB, provider := mustAISetup(cfg) + defer readonlyDB.Close() + + store := postgres.NewSafeQueryStore(readonlyDB) + var result ai.QueryResult + err := withSpinner("Thinking...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, provider, question) + return qerr + }) + if err != nil { + if errors.Is(err, ai.ErrUnsafeQueryIntent) { + fmt.Println(red("That question can't be translated into an allowed query.")) + fmt.Println(dim("Try rephrasing, or ask about one of: users, sessions, audit_events.")) + os.Exit(1) + } + fmt.Println(red("Couldn't answer that: " + err.Error())) + os.Exit(1) + } + + if len(result.Rows) == 0 { + fmt.Println(dim("No matching data found.")) + return + } + + var answer string + answerErr := withSpinner("Summarizing...", func() error { + var aerr error + answer, aerr = provider.Summarize(context.Background(), + "You are answering a system administrator's direct question about their own application's data. "+ + "Some identifiers below are placeholders (email_1, ip_2, etc.) standing in for real values — refer "+ + "to them exactly as given, never invent a real-looking email or IP. Answer the question directly in "+ + "2-4 plain-language sentences, using only what's in the data given. If the data doesn't actually "+ + "answer the question, say so rather than guessing.", + question+"\n\nData:\n"+redactForSummary(result)) + return aerr + }) + if answerErr != nil { + fmt.Println(yellow("(could not generate an answer: " + answerErr.Error() + ")")) + } else { + fmt.Println(answer) + fmt.Println() + } + + printQueryResult(result, false) +} diff --git a/cmd_ai_audit.go b/cmd_ai_audit.go index 7b6a17f..379248a 100644 --- a/cmd_ai_audit.go +++ b/cmd_ai_audit.go @@ -83,12 +83,17 @@ func cmdAIAudit(cfg csaxConfig) { if err != nil || flagged == 0 { return } - narrative, err := provider.Summarize(context.Background(), - "You are prioritizing a fixed list of security configuration findings for a system administrator. "+ - "You do not add new findings or invent facts not present in the list. Explain, in 2-3 sentences, "+ - "which flagged item to fix first and why, in plain language.", - formatFindingsForSummary(findings)) - if err == nil && narrative != "" { + var narrative string + summarizeErr := withSpinner("Summarizing...", func() error { + var serr error + narrative, serr = provider.Summarize(context.Background(), + "You are prioritizing a fixed list of security configuration findings for a system administrator. "+ + "You do not add new findings or invent facts not present in the list. Explain, in 2-3 sentences, "+ + "which flagged item to fix first and why, in plain language.", + formatFindingsForSummary(findings)) + return serr + }) + if summarizeErr == nil && narrative != "" { fmt.Println("\n" + narrative) } } diff --git a/cmd_ai_config.go b/cmd_ai_config.go new file mode 100644 index 0000000..002c306 --- /dev/null +++ b/cmd_ai_config.go @@ -0,0 +1,277 @@ +package main + +import ( + "bufio" + "database/sql" + "fmt" + "net/url" + "os" + "sort" + "strings" + + "github.com/crydensync/cryden/v2/ai" +) + +// cmdAIConfig interactively sets up everything `csax ai query`/`ai +// logs`/`ai audit` need: the AI provider, model, API key env var +// name, and — the part that caused the most friction in manual +// testing — a genuinely read-only Postgres role, created for the +// user rather than handed to them as a script to run by hand. +func cmdAIConfig(cfg csaxConfig) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("Setting up AI-assisted commands (ai query, ai logs, ai audit).") + fmt.Println() + + provider := promptDefault(reader, "AI provider (groq/openrouter)", "groq") + model := promptDefault(reader, "Model id", defaultModelFor(provider)) + apiKeyEnv := promptDefault(reader, "Env var name holding your API key (the key itself is never stored here)", strings.ToUpper(provider)+"_API_KEY") + + fmt.Println() + values := map[string]string{ + "AI_PROVIDER": provider, + "AI_MODEL": model, + "AI_API_KEY_ENV": apiKeyEnv, + } + + if promptYesNo(reader, "Set up a read-only database role now? (recommended — required for ai query/ai logs)", true) { + readonlyURL := setupReadonlyRole(reader, cfg) + if readonlyURL != "" { + values["READONLY_DATABASE_URL"] = readonlyURL + } + } else { + fmt.Println("Skipped. Run `csax ai config` again later, or set READONLY_DATABASE_URL by hand — see the README for the manual SQL.") + } + + appendEnvValues(reader, values) + fmt.Println() + fmt.Printf("Don't forget: export %s= before running ai commands.\n", apiKeyEnv) +} + +func defaultModelFor(provider string) string { + switch provider { + case "groq": + return "openai/gpt-oss-20b" + case "openrouter": + return "nvidia/nemotron-nano-9b-v2:free" + default: + return "" + } +} + +// setupReadonlyRole creates a Postgres role scoped to exactly the +// tables ai.AllowedEntities covers — the same allowlist ExecuteQuery +// enforces, so this list can never silently drift from what's +// actually queryable. Runs the SQL directly via the admin +// DATABASE_URL already in cfg, rather than handing the user a script +// to run by hand and hoping they get the connection right. +func setupReadonlyRole(reader *bufio.Reader, cfg csaxConfig) string { + roleName := promptDefault(reader, "Read-only role name", "csax_readonly") + password := promptDefault(reader, "Password for this role (leave blank to auto-generate)", "") + generated := password == "" + if generated { + password = generateSecret()[:24] + } + + db, err := sql.Open("postgres", cfg.DatabaseURL) + if err != nil { + fmt.Printf("could not open admin DB connection: %v\n", err) + return "" + } + defer db.Close() + + dbName, err := currentDatabaseName(db) + if err != nil { + fmt.Printf("could not determine current database name: %v\n", err) + return "" + } + + // Tables are taken from ai.AllowedEntities, not hardcoded here — + // if that allowlist ever changes, this wizard's grants + // automatically follow it instead of silently going stale. + tables := make([]string, 0, len(ai.AllowedEntities)) + for name := range ai.AllowedEntities { + tables = append(tables, name) + } + sort.Strings(tables) + + statements := []string{ + fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD %s", pqIdent(roleName), pqLiteral(password)), + fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s", pqIdent(dbName), pqIdent(roleName)), + fmt.Sprintf("GRANT USAGE ON SCHEMA public TO %s", pqIdent(roleName)), + } + for _, t := range tables { + statements = append(statements, fmt.Sprintf("GRANT SELECT ON %s TO %s", pqIdent(t), pqIdent(roleName))) + } + + fmt.Printf("Creating role %q with SELECT on: %s\n", roleName, strings.Join(tables, ", ")) + for _, stmt := range statements { + if _, err := db.Exec(stmt); err != nil { + // A role that already exists is a common, harmless case + // (re-running this wizard) — don't abort the whole setup + // over it, just note it and keep going with the grants. + if strings.Contains(err.Error(), "already exists") { + fmt.Printf(" (skipped, already exists: %s)\n", firstWords(stmt, 4)) + continue + } + fmt.Printf("failed running: %s\n error: %v\n", stmt, err) + return "" + } + } + fmt.Println("✔ Role created and granted.") + + readonlyURL, err := buildReadonlyURL(cfg.DatabaseURL, roleName, password) + if err != nil { + fmt.Printf("role was created, but couldn't build the connection string automatically: %v\n", err) + fmt.Println("Build it by hand: same host/port/database as DATABASE_URL, with this role's credentials.") + return "" + } + + testDB, err := sql.Open("postgres", readonlyURL) + if err == nil { + defer testDB.Close() + var pingErr error + withSpinner("Verifying connection...", func() error { + pingErr = testDB.Ping() + return nil // spinner just reports timing here, not success/failure + }) + if pingErr == nil { + fmt.Println("✔ Verified: the read-only connection works.") + } else { + fmt.Printf("⚠ Role created, but the test connection failed: %v\n", pingErr) + fmt.Println(" This can happen with some poolers (e.g. Supabase) needing a moment to recognize a new role — try `csax ai query` again shortly.") + } + } + + if generated { + fmt.Printf("Generated password: %s — this is only shown once, save it if you need it separately.\n", password) + } + + return readonlyURL +} + +// buildReadonlyURL constructs the read-only connection string from +// the existing admin DATABASE_URL, swapping in the new role's +// credentials. Handles ONE known provider-specific quirk explicitly +// (Supabase's pooler requires . as the username) +// rather than assuming every deployment works that way — a plain +// self-hosted Postgres, Neon, RDS, etc. all just get the plain role +// name. +func buildReadonlyURL(adminURL, roleName, password string) (string, error) { + u, err := url.Parse(adminURL) + if err != nil { + return "", err + } + + newUsername := roleName + if isSupabasePoolerHost(u.Hostname()) { + if existing := u.User.Username(); existing != "" { + if dot := strings.LastIndex(existing, "."); dot != -1 { + projectRef := existing[dot+1:] + newUsername = roleName + "." + projectRef + } + } + } + + u.User = url.UserPassword(newUsername, password) + return u.String(), nil +} + +func isSupabasePoolerHost(host string) bool { + return strings.Contains(host, "supabase.com") || strings.Contains(host, "supabase.co") +} + +func currentDatabaseName(db *sql.DB) (string, error) { + var name string + err := db.QueryRow("SELECT current_database()").Scan(&name) + return name, err +} + +// pqIdent and pqLiteral do minimal, defensive escaping for values +// interpolated into DDL statements that Postgres' protocol can't +// parameterize (role/table names, CREATE ROLE's password clause). +// This is wizard input the person is typing about their own +// database, not attacker-controlled — but escaping costs nothing and +// avoids a broken role name silently becoming a SQL syntax error or +// worse. +func pqIdent(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} + +func pqLiteral(s string) string { + return `'` + strings.ReplaceAll(s, `'`, `''`) + `'` +} + +func firstWords(s string, n int) string { + words := strings.Fields(s) + if len(words) > n { + words = words[:n] + } + return strings.Join(words, " ") + "..." +} + +func promptDefault(reader *bufio.Reader, label, def string) string { + if def != "" { + fmt.Printf("%s [%s]: ", label, def) + } else { + fmt.Printf("%s: ", label) + } + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return def + } + return line +} + +func promptYesNo(reader *bufio.Reader, label string, def bool) bool { + suffix := "[Y/n]" + if !def { + suffix = "[y/N]" + } + fmt.Printf("%s %s: ", label, suffix) + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(strings.ToLower(line)) + if line == "" { + return def + } + return line == "y" || line == "yes" +} + +// appendEnvValues writes/updates .env with the given key-value pairs, +// preserving whatever's already there — cmd_config.go's writer +// replaces the whole file, which would destroy DATABASE_URL/JWT_SECRET +// if reused here, so this reads first and only touches matching keys. +func appendEnvValues(reader *bufio.Reader, values map[string]string) { + existing := map[string]string{} + var order []string + if data, err := os.ReadFile(".env"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if eq := strings.Index(line, "="); eq != -1 { + key := line[:eq] + existing[key] = line[eq+1:] + order = append(order, key) + } + } + } + for k, v := range values { + if _, ok := existing[k]; !ok { + order = append(order, k) + } + existing[k] = v + } + + var b strings.Builder + for _, k := range order { + fmt.Fprintf(&b, "%s=%s\n", k, existing[k]) + } + if err := os.WriteFile(".env", []byte(b.String()), 0600); err != nil { + fmt.Printf("failed to write .env: %v\n", err) + os.Exit(1) + } + fmt.Println("✔ Updated .env") +} diff --git a/cmd_ai_logs.go b/cmd_ai_logs.go index d244719..40274b0 100644 --- a/cmd_ai_logs.go +++ b/cmd_ai_logs.go @@ -18,14 +18,28 @@ import ( // validated path, not two. type auditOnlyProvider struct { inner *llmProvider + // originalEntity records what the model actually chose, before + // being overridden below — lets cmdAILogs warn the person when + // they've asked a question this command was never going to + // answer (e.g. "show me all users" silently returning audit + // events instead, with no indication that's not what they meant). + originalEntity string } -func (p auditOnlyProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) { +func (p *auditOnlyProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) { intent, err := p.inner.ParseQueryIntent(ctx, naturalLanguage) if err != nil { return ai.QueryIntent{}, err } + p.originalEntity = intent.Entity intent.Entity = "audit_events" // this command is audit-only by definition — never trust the model's entity choice here + // Force real rows, never an aggregate — `ai logs` exists to show + // actual events for review. Left to the model, a vague prompt + // like "any failed logins recently" can get interpreted as + // Aggregate: "count", which produces a single useless number + // instead of the log entries the command is actually for. + intent.Aggregate = "" + intent.GroupBy = "" return intent, nil } @@ -40,24 +54,42 @@ func cmdAILogs(cfg csaxConfig, naturalLanguage string) { defer readonlyDB.Close() store := postgres.NewSafeQueryStore(readonlyDB) - result, err := ai.ExecuteQuery(context.Background(), store, auditOnlyProvider{inner: provider}, naturalLanguage) + auditProvider := &auditOnlyProvider{inner: provider} + var result ai.QueryResult + err := withSpinner("Searching audit log...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, auditProvider, naturalLanguage) + return qerr + }) if err != nil { fmt.Println(red("Log search failed: " + err.Error())) os.Exit(1) } + if auditProvider.originalEntity != "" && auditProvider.originalEntity != "audit_events" { + fmt.Println(yellow(fmt.Sprintf("(Note: ai logs only searches audit events — your question looked like it was about %s. Try `csax ai query` for that.)", auditProvider.originalEntity))) + fmt.Println() + } + if len(result.Rows) == 0 { fmt.Println(dim("No matching audit events.")) return } - summary, err := provider.Summarize(context.Background(), - "You summarize a list of audit log events for a system administrator in 2-3 plain-language sentences. "+ - "Only describe patterns you can see in the data given. Never suggest the administrator take an action you're not certain about; "+ - "if a corrective action seems relevant, phrase it as a suggestion, never as something already done.", - formatRowsForSummary(result)) - if err != nil { - fmt.Println(yellow("(could not generate a summary: " + err.Error() + ")")) + var summary string + summarizeErr := withSpinner("Summarizing...", func() error { + var serr error + summary, serr = provider.Summarize(context.Background(), + "You summarize a list of audit log events for a system administrator in 2-3 plain-language sentences. "+ + "Some identifiers below are placeholders (email_1, ip_2, etc.) standing in for real values — refer to "+ + "them exactly as given, never invent a real-looking email or IP. Only describe patterns you can see "+ + "in the data given. Never suggest the administrator take an action you're not certain about; "+ + "if a corrective action seems relevant, phrase it as a suggestion, never as something already done.", + redactForSummary(result)) + return serr + }) + if summarizeErr != nil { + fmt.Println(yellow("(could not generate a summary: " + summarizeErr.Error() + ")")) } else { fmt.Println(summary) fmt.Println() @@ -65,14 +97,3 @@ func cmdAILogs(cfg csaxConfig, naturalLanguage string) { printQueryResult(result, false) } - -func formatRowsForSummary(result ai.QueryResult) string { - out := "" - for _, row := range result.Rows { - for i, v := range row { - out += result.Columns[i] + "=" + v + " " - } - out += "\n" - } - return out -} diff --git a/cmd_ai_query.go b/cmd_ai_query.go index a615334..0cbc1cc 100644 --- a/cmd_ai_query.go +++ b/cmd_ai_query.go @@ -22,7 +22,12 @@ func cmdAIQuery(cfg csaxConfig, naturalLanguage string, jsonOutput bool) { defer readonlyDB.Close() store := postgres.NewSafeQueryStore(readonlyDB) - result, err := ai.ExecuteQuery(context.Background(), store, provider, naturalLanguage) + var result ai.QueryResult + err := withSpinner("Thinking...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, provider, naturalLanguage) + return qerr + }) if err != nil { if errors.Is(err, ai.ErrUnsafeQueryIntent) { // Deliberately does NOT echo the model's raw output or @@ -52,16 +57,7 @@ func printQueryResult(result ai.QueryResult, jsonOutput bool) { fmt.Println(dim("No rows.")) return } - for _, col := range result.Columns { - fmt.Printf("%-24s", col) - } - fmt.Println() - for _, row := range result.Rows { - for _, v := range row { - fmt.Printf("%-24s", v) - } - fmt.Println() - } + printTable(result.Columns, result.Rows) fmt.Printf("\n%d row(s).\n", len(result.Rows)) } diff --git a/cmd_doctor.go b/cmd_doctor.go new file mode 100644 index 0000000..82441bd --- /dev/null +++ b/cmd_doctor.go @@ -0,0 +1,73 @@ +package main + +import ( + "database/sql" + "fmt" +) + +// cmdDoctor runs every health/config check csax knows how to run, in +// one command. Reuses the same underlying checks as `csax health` and +// `csax ai audit` rather than re-implementing them — one source of +// truth for "is JWT_SECRET long enough", not two that could drift. +func cmdDoctor(cfg csaxConfig, db *sql.DB) { + fmt.Println("csax doctor") + fmt.Println() + + fmt.Println("Database") + if err := db.Ping(); err != nil { + fmt.Printf(" ✗ unreachable: %v\n", err) + } else { + fmt.Println(" ✔ reachable") + checkMigration(db, "csax_migrations", "run `csax migrate up`") + checkMigration(db, "oauth_identities", "run the 0002_oauth_identities migration (see README)") + } + + fmt.Println() + fmt.Println("AI-assisted commands") + for _, f := range runAuditChecklist(cfg) { + icon, color := "✓", green + if f.Severity == "HIGH" { + icon, color = "✗", red + } else if f.Severity == "MEDIUM" { + icon, color = "⚠", yellow + } + fmt.Printf(" %s %s\n", color(icon), f.Message) + } + if _, err := newLLMProvider(cfg); err != nil { + fmt.Printf(" ⚠ %v (run `csax ai config`)\n", err) + } else { + fmt.Println(" ✔ AI provider configured") + } + + fmt.Println() + fmt.Println("OAuth") + anyConfigured := false + for _, p := range oauthProviders(cfg) { + configured := p.ClientIDSet && p.ClientSecretSet + if configured { + anyConfigured = true + fmt.Printf(" ✔ %s configured\n", p.Name) + } + } + if !anyConfigured { + fmt.Println(" ⚠ no providers configured (optional — run `csax oauth config` to add one)") + } + if cfg.BaseURL == "" && anyConfigured { + fmt.Println(" ✗ BASE_URL is not set, but a provider is configured — OAuth callback URLs cannot be built") + } + + fmt.Println() +} + +// checkMigration reports whether a table exists — a cheap, reliable +// proxy for "has this migration been run" without needing a real +// migration-tracking scheme for every possible table. +func checkMigration(db *sql.DB, table, fixHint string) { + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = $1`, table).Scan(&count) + if err != nil || count == 0 { + fmt.Printf(" ✗ table %q missing — %s\n", table, fixHint) + return + } + fmt.Printf(" ✔ table %q present\n", table) +} diff --git a/cmd_health.go b/cmd_health.go index 178950c..fc0b6cf 100644 --- a/cmd_health.go +++ b/cmd_health.go @@ -5,8 +5,8 @@ import ( "fmt" ) -const csaxVersion = "v0.1.0" -const targetCrydenVersion = "cryden/v2 v2.0.0" +const csaxVersion = "dev" // set to the real tag (e.g. "v1.1.0") when this release is actually cut +const targetCrydenVersion = "cryden/v2 v2.1.0" func cmdHealth(db *sql.DB) { if err := db.Ping(); err != nil { diff --git a/cmd_oauth.go b/cmd_oauth.go index 9e4e708..f1ae8e4 100644 --- a/cmd_oauth.go +++ b/cmd_oauth.go @@ -1,10 +1,12 @@ package main import ( + "bufio" "encoding/json" "fmt" "net/http" "os" + "strings" "time" ) @@ -58,17 +60,13 @@ func cmdOAuthProvidersList(cfg csaxConfig, jsonOutput bool) { return } - fmt.Printf("%-10s %-12s %-16s %-18s\n", "PROVIDER", "CONFIGURED", "CLIENT ID SET", "CLIENT SECRET SET") + columns := []string{"PROVIDER", "CONFIGURED", "CLIENT ID SET", "CLIENT SECRET SET"} + var rows [][]string for _, p := range providers { configured := p.ClientIDSet && p.ClientSecretSet - configuredStr := yesNo(configured) - if configured { - configuredStr = green(configuredStr) - } else { - configuredStr = dim(configuredStr) - } - fmt.Printf("%-10s %-12s %-16s %-18s\n", p.Name, configuredStr, yesNo(p.ClientIDSet), yesNo(p.ClientSecretSet)) + rows = append(rows, []string{p.Name, yesNo(configured), yesNo(p.ClientIDSet), yesNo(p.ClientSecretSet)}) } + printTable(columns, rows) if cfg.BaseURL == "" { fmt.Println(yellow("\nwarning: BASE_URL is not set — provider redirect URIs cannot be computed correctly")) } @@ -113,9 +111,14 @@ func cmdOAuthTest(cfg csaxConfig, providerName string) { } client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(p.discoveryCheckAddr) - if err != nil { - fmt.Println(red("✗") + " could not reach " + p.Name + "'s endpoint: " + err.Error()) + var resp *http.Response + reachErr := withSpinner("Checking "+p.Name+"'s endpoint...", func() error { + var rerr error + resp, rerr = client.Get(p.discoveryCheckAddr) + return rerr + }) + if reachErr != nil { + fmt.Println(red("✗") + " could not reach " + p.Name + "'s endpoint: " + reachErr.Error()) ok = false } else { resp.Body.Close() @@ -135,6 +138,34 @@ func cmdOAuthTest(cfg csaxConfig, providerName string) { } } +// cmdOAuthProvidersAdd configures ONE named provider — a narrower, +// faster alternative to the full `csax oauth config` wizard for +// someone who already has BASE_URL/FRONTEND_URL set and just wants to +// add a provider's credentials. +func cmdOAuthProvidersAdd(cfg csaxConfig, providerName string) { + if providerName != "google" && providerName != "github" { + fmt.Println(red("unknown provider: " + providerName + " (expected: google, github)")) + os.Exit(1) + } + if cfg.BaseURL == "" { + fmt.Println(yellow("BASE_URL is not set yet — run `csax oauth config` first, or set it by hand before adding a provider.")) + os.Exit(1) + } + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("Adding %s.\n", providerName) + clientID := promptDefault(reader, providerName+" client ID", "") + clientSecret := promptDefault(reader, providerName+" client secret", "") + + prefix := strings.ToUpper(providerName) + appendEnvValues(reader, map[string]string{ + prefix + "_CLIENT_ID": clientID, + prefix + "_CLIENT_SECRET": clientSecret, + }) + printCallbackURLs(cfg.BaseURL, providerName) + fmt.Println("Run `csax oauth test " + providerName + "` to confirm it's reachable.") +} + func yesNo(b bool) string { if b { return "yes" diff --git a/cmd_oauth_config.go b/cmd_oauth_config.go new file mode 100644 index 0000000..7773f21 --- /dev/null +++ b/cmd_oauth_config.go @@ -0,0 +1,65 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// cmdOAuthConfig interactively sets up OAuth env vars. Deliberately +// explicit about the BASE_URL vs FRONTEND_URL distinction — this is +// exactly the mixup that broke a real deployment during testing +// (BASE_URL was set to the frontend's Vercel URL instead of the +// backend's own URL, since that's where the callback ROUTE actually +// lives), so the prompts spell out which is which rather than +// assuming it's obvious. +func cmdOAuthConfig(cfg csaxConfig) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("Setting up OAuth (Google/GitHub login).") + fmt.Println() + fmt.Println("BASE_URL is your BACKEND's own public URL — the callback route") + fmt.Println("(/api/oauth/.../callback) is a route on YOUR SERVER, not on your") + fmt.Println("frontend. If your frontend and backend are on different domains") + fmt.Println("(e.g. Vercel + Railway), BASE_URL is the Railway one.") + fmt.Println() + + baseURL := promptDefault(reader, "BASE_URL (your backend's own URL)", cfg.BaseURL) + frontendURL := promptDefault(reader, "FRONTEND_URL (where the browser lands after login)", cfg.FrontendURL) + + values := map[string]string{ + "BASE_URL": baseURL, + "FRONTEND_URL": frontendURL, + } + + if promptYesNo(reader, "Configure Google?", true) { + values["GOOGLE_CLIENT_ID"] = promptDefault(reader, "Google client ID", "") + values["GOOGLE_CLIENT_SECRET"] = promptDefault(reader, "Google client secret", "") + printCallbackURLs(baseURL, "google") + } + if promptYesNo(reader, "Configure GitHub?", true) { + values["GITHUB_CLIENT_ID"] = promptDefault(reader, "GitHub client ID", "") + values["GITHUB_CLIENT_SECRET"] = promptDefault(reader, "GitHub client secret", "") + printCallbackURLs(baseURL, "github") + } + + appendEnvValues(reader, values) + fmt.Println() + fmt.Println("Register the callback URLs printed above in each provider's console before testing.") + fmt.Println("Run `csax oauth test ` afterward to confirm it's reachable.") +} + +func printCallbackURLs(baseURL, provider string) { + base := strings.TrimRight(baseURL, "/") + fmt.Printf("\n Register these in %s's console:\n", capitalizeFirst(provider)) + fmt.Printf(" %s/api/oauth/%s/callback\n", base, provider) + fmt.Printf(" %s/api/oauth/%s/link/callback\n\n", base, provider) +} + +func capitalizeFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/cmd_users.go b/cmd_users.go index 26ee3f8..7db8b72 100644 --- a/cmd_users.go +++ b/cmd_users.go @@ -54,6 +54,99 @@ func cmdUsersGet(db *sql.DB, email string, jsonOutput bool) { fmt.Printf("Sessions: %d active\n", len(sessions)) } +// oauthIdentityRow mirrors one row of oauth_identities — kept local +// to this file since it's a read-only query result, not a store type. +type oauthIdentityRow struct { + ID string + Provider string + ExternalID string + CreatedAt string +} + +// cmdOAuthUsersGet lists which providers an account has linked. +// oauth_identities has no engine store method exposed for this yet +// (see the eight-gaps-style facade decision) — same pattern as +// cmdStats: direct SQL against the known, documented schema, not an +// engine change. +func cmdOAuthUsersGet(db *sql.DB, email string, jsonOutput bool) { + if email == "" { + fmt.Println("usage: csax oauth users get [--json]") + os.Exit(1) + } + + var userID string + err := db.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&userID) + if err != nil { + fmt.Println(red("failed to find user: " + err.Error())) + os.Exit(1) + } + + rows, err := db.Query(` + SELECT id, provider, external_id, created_at + FROM oauth_identities WHERE user_id = $1 + ORDER BY created_at ASC + `, userID) + if err != nil { + fmt.Println(red("failed to query oauth_identities: " + err.Error())) + os.Exit(1) + } + defer rows.Close() + + var identities []oauthIdentityRow + for rows.Next() { + var r oauthIdentityRow + if err := rows.Scan(&r.ID, &r.Provider, &r.ExternalID, &r.CreatedAt); err != nil { + fmt.Println(red("failed reading row: " + err.Error())) + os.Exit(1) + } + identities = append(identities, r) + } + + if jsonOutput { + out := map[string]any{"email": email, "user_id": userID, "linked_providers": identities} + b, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(b)) + return + } + + fmt.Printf("%s (user_id: %s)\n", email, userID) + if len(identities) == 0 { + fmt.Println(dim(" no linked providers")) + return + } + for _, id := range identities { + fmt.Printf(" %-8s — linked %s\n", id.Provider, id.CreatedAt) + } +} + +// cmdOAuthUnlink force-unlinks one provider from an account — the +// admin escape hatch, same spirit as `csax users unlock`. +func cmdOAuthUnlink(db *sql.DB, email, provider string) { + if email == "" || provider == "" { + fmt.Println("usage: csax oauth unlink --provider ") + os.Exit(1) + } + + var userID string + err := db.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&userID) + if err != nil { + fmt.Println(red("failed to find user: " + err.Error())) + os.Exit(1) + } + + result, err := db.Exec(`DELETE FROM oauth_identities WHERE user_id = $1 AND provider = $2`, userID, provider) + if err != nil { + fmt.Println(red("failed to unlink: " + err.Error())) + os.Exit(1) + } + affected, _ := result.RowsAffected() + if affected == 0 { + fmt.Println(yellow(fmt.Sprintf("%s has no linked %s account — nothing to do.", email, provider))) + return + } + fmt.Println(green(fmt.Sprintf("✔ Unlinked %s from %s", provider, email))) +} + func cmdUsersUnlock(db *sql.DB, email string) { if email == "" { fmt.Println("usage: csax users unlock ") diff --git a/colors.go b/colors.go index e515a9d..4b575be 100644 --- a/colors.go +++ b/colors.go @@ -6,9 +6,12 @@ import "os" // stdlib-only philosophy. Colors are skipped automatically when // output isn't a real terminal (e.g. piped to a file or another // program), so scripting against csax output never sees raw escape -// codes mixed into the data. +// codes mixed into the data. Also respects NO_COLOR (see +// https://no-color.org) — any non-empty value disables color, +// regardless of terminal detection, since that's the person's +// explicit preference overriding csax's own guess. -var colorsEnabled = isTerminal() +var colorsEnabled = isTerminal() && os.Getenv("NO_COLOR") == "" func isTerminal() bool { fi, err := os.Stdout.Stat() diff --git a/config.go b/config.go index acfcb12..ac0f4d2 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,7 @@ type csaxConfig struct { // so `csax oauth test` checks the actual values production uses, // not a separate csax-only copy that could drift out of sync. BaseURL string + FrontendURL string GoogleClientID string GoogleClientSecret string GitHubClientID string @@ -43,6 +44,7 @@ func loadConfig() (csaxConfig, error) { MigrationsDir: os.Getenv("MIGRATIONS_DIR"), BaseURL: strings.TrimRight(os.Getenv("BASE_URL"), "/"), + FrontendURL: strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"), GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), diff --git a/main.go b/main.go index d498629..0e7e0a8 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,12 @@ import ( "github.com/crydensync/cryden/v2" ) +const logo = ` + ▄████████▄ ▄▄▄ + █ ██████▀▘ ▄▄▄▄▄ csax — CrydenSync admin CLI + █ ▀▀▀▀▘ ▀▀▀▀▀▘ self-hosted auth, owned by you +` + func usage() { fmt.Println(`csax — CrydenSync admin CLI @@ -24,21 +30,34 @@ Usage: csax sessions revoke-all --user csax audit tail --user [--limit N] csax audit search --event [--limit N] - csax oauth providers list [--json] + csax audit ask "" + csax oauth providers list [--json] | add csax oauth test + csax oauth users get [--json] + csax oauth unlink --provider + csax oauth config csax ai query "" [--json] csax ai logs "" + csax ai anomalies scan [--since 24h] + csax ai ask "" csax ai audit + csax ai config + csax doctor csax stats csax health csax version oauth and ai commands are optional — see README for the env vars each -one needs. Run any command with no further args for its specific usage.`) +one needs, or run ` + "`csax ai config`" + ` / ` + "`csax oauth config`" + ` for an +interactive setup. Run any command with no further args for its +specific usage.`) } func main() { if len(os.Args) < 2 { + if colorsEnabled { + fmt.Println(dim(logo)) + } usage() os.Exit(1) } @@ -159,7 +178,7 @@ func main() { db := mustConnect(cfg) defer db.Close() if len(os.Args) < 3 { - fmt.Println("usage: csax audit tail|search [args]") + fmt.Println(`usage: csax audit tail|search [args] | ask ""`) os.Exit(1) } switch os.Args[2] { @@ -175,42 +194,89 @@ func main() { limit := fs.Int("limit", 20, "max events to show") fs.Parse(os.Args[3:]) cmdAuditSearch(db, *event, *limit) + case "ask": + // alias for `ai ask` — same underlying command, just + // phrased the way someone thinking "ask about my audit + // trail" is more likely to type it. db was already + // opened above for tail/search but isn't needed here — + // cmdAIAsk opens its own read-only connection. + if len(os.Args) < 4 { + fmt.Println(`usage: csax audit ask ""`) + os.Exit(1) + } + cmdAIAsk(cfg, os.Args[3]) default: - fmt.Println("usage: csax audit tail|search [args]") + fmt.Println(`usage: csax audit tail|search [args] | ask ""`) os.Exit(1) } case "oauth": cfg := mustLoadConfig() if len(os.Args) < 3 { - fmt.Println("usage: csax oauth providers list | test ") + fmt.Println("usage: csax oauth providers list | test | users get | unlink --provider ") os.Exit(1) } switch os.Args[2] { case "providers": - if len(os.Args) < 4 || os.Args[3] != "list" { - fmt.Println("usage: csax oauth providers list [--json]") + if len(os.Args) < 4 { + fmt.Println("usage: csax oauth providers list [--json] | add ") + os.Exit(1) + } + switch os.Args[3] { + case "list": + fs := flag.NewFlagSet("oauth providers list", flag.ExitOnError) + jsonOut := fs.Bool("json", false, "output as JSON") + fs.Parse(os.Args[4:]) + cmdOAuthProvidersList(cfg, *jsonOut) + case "add": + if len(os.Args) < 5 { + fmt.Println("usage: csax oauth providers add ") + os.Exit(1) + } + cmdOAuthProvidersAdd(cfg, os.Args[4]) + default: + fmt.Println("usage: csax oauth providers list [--json] | add ") os.Exit(1) } - fs := flag.NewFlagSet("oauth providers list", flag.ExitOnError) - jsonOut := fs.Bool("json", false, "output as JSON") - fs.Parse(os.Args[4:]) - cmdOAuthProvidersList(cfg, *jsonOut) case "test": if len(os.Args) < 4 { fmt.Println("usage: csax oauth test ") os.Exit(1) } cmdOAuthTest(cfg, os.Args[3]) + case "users": + if len(os.Args) < 5 || os.Args[3] != "get" { + fmt.Println("usage: csax oauth users get [--json]") + os.Exit(1) + } + db := mustConnect(cfg) + defer db.Close() + fs := flag.NewFlagSet("oauth users get", flag.ExitOnError) + jsonOut := fs.Bool("json", false, "output as JSON") + fs.Parse(os.Args[5:]) + cmdOAuthUsersGet(db, os.Args[4], *jsonOut) + case "unlink": + if len(os.Args) < 4 { + fmt.Println("usage: csax oauth unlink --provider ") + os.Exit(1) + } + fs := flag.NewFlagSet("oauth unlink", flag.ExitOnError) + providerFlag := fs.String("provider", "", "provider to unlink (required)") + fs.Parse(os.Args[4:]) + db := mustConnect(cfg) + defer db.Close() + cmdOAuthUnlink(db, os.Args[3], *providerFlag) + case "config": + cmdOAuthConfig(cfg) default: - fmt.Println("usage: csax oauth providers list | test ") + fmt.Println("usage: csax oauth providers list | test | users get | unlink --provider | config") os.Exit(1) } case "ai": cfg := mustLoadConfig() if len(os.Args) < 3 { - fmt.Println(`usage: csax ai query "" | logs "" | audit`) + fmt.Println(`usage: csax ai query "<...>" | logs "<...>" | audit | config | anomalies scan [--since 24h] | ask "<...>"`) os.Exit(1) } switch os.Args[2] { @@ -231,11 +297,38 @@ func main() { cmdAILogs(cfg, os.Args[3]) case "audit": cmdAIAudit(cfg) + case "config": + cmdAIConfig(cfg) + case "anomalies": + if len(os.Args) < 4 || os.Args[3] != "scan" { + fmt.Println("usage: csax ai anomalies scan [--since 24h]") + os.Exit(1) + } + fs := flag.NewFlagSet("ai anomalies scan", flag.ExitOnError) + since := fs.String("since", "24h", "how far back to look") + fs.Parse(os.Args[4:]) + cmdAIAnomaliesScan(cfg, *since) + case "ask": + if len(os.Args) < 4 { + fmt.Println(`usage: csax ai ask ""`) + os.Exit(1) + } + cmdAIAsk(cfg, os.Args[3]) default: - fmt.Println(`usage: csax ai query "" | logs "" | audit`) + fmt.Println(`usage: csax ai query "<...>" | logs "<...>" | audit | config | anomalies scan [--since 24h] | ask "<...>"`) os.Exit(1) } + // `audit ask` is an alias for `ai ask`, handled inside the real + // top-level `case "audit":` block above (not duplicated here — + // Go doesn't allow two cases with the same value in one switch). + + case "doctor": + cfg := mustLoadConfig() + db := mustConnect(cfg) + defer db.Close() + cmdDoctor(cfg, db) + case "stats": cfg := mustLoadConfig() db := mustConnect(cfg) diff --git a/migrations/0002_oauth_identities.up.sql b/migrations/0002_oauth_identities.up.sql index 408be77..a433f58 100644 --- a/migrations/0002_oauth_identities.up.sql +++ b/migrations/0002_oauth_identities.up.sql @@ -13,4 +13,3 @@ CREATE TABLE oauth_identities ( ); CREATE INDEX idx_oauth_identities_user_id ON oauth_identities(user_id); - diff --git a/redact.go b/redact.go new file mode 100644 index 0000000..bf9baec --- /dev/null +++ b/redact.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/crydensync/cryden/v2/ai" +) + +// redactForSummary builds the text sent to an AI provider for +// summarization, replacing email and IP values with stable +// per-result placeholders (email_1, ip_2, ...) rather than sending +// real PII to a third-party provider. +// +// This is a deliberate default, not a policy someone else already +// signed off on — flagging that plainly. Unlike the docs site's "Ask +// AI" (which only ever sends public documentation text), audit data +// contains real emails and IPs, and CrydenSync's own operator may not +// want that leaving their infrastructure even to a provider they +// trust for other things. Placeholders are stable WITHIN one result +// set, so repetition and clustering (e.g. "email_3 appears 5 times") +// are still visible to the model — the actual values just never are. +// +// The real, unredacted data is still what gets PRINTED to the +// terminal afterward (see printQueryResult in each caller) — only the +// text handed to Summarize goes through this. +func redactForSummary(result ai.QueryResult) string { + emailIdx, ipIdx := -1, -1 + for i, col := range result.Columns { + switch col { + case "email": + emailIdx = i + case "ip": + ipIdx = i + } + } + + emails := map[string]string{} + ips := map[string]string{} + + var b strings.Builder + for _, row := range result.Rows { + for i, v := range row { + col := result.Columns[i] + switch i { + case emailIdx: + v = pseudonym(emails, v, "email") + case ipIdx: + v = pseudonym(ips, v, "ip") + } + fmt.Fprintf(&b, "%s=%s ", col, v) + } + b.WriteString("\n") + } + return b.String() +} + +func pseudonym(seen map[string]string, value, label string) string { + if value == "" { + return value + } + if existing, ok := seen[value]; ok { + return existing + } + placeholder := fmt.Sprintf("%s_%d", label, len(seen)+1) + seen[value] = placeholder + return placeholder +} diff --git a/spinner.go b/spinner.go new file mode 100644 index 0000000..2677d6a --- /dev/null +++ b/spinner.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "time" +) + +// withSpinner runs fn while showing a spinner, clearing it afterward +// regardless of outcome. Only spins on a real terminal with colors +// enabled — a spinner in piped/scripted output is just noise (and +// worse, corrupts anything parsing that output line by line), same +// reasoning as colorsEnabled gating everywhere else in csax. +// +// Used for anything that makes a real network/DB round trip — the +// AI provider calls in particular took long enough during testing +// that a silent terminal looked hung, not just slow. +func withSpinner(label string, fn func() error) error { + if !colorsEnabled { + return fn() + } + + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + done := make(chan struct{}) + go func() { + i := 0 + for { + select { + case <-done: + return + default: + fmt.Printf("\r%s %s", dim(frames[i%len(frames)]), label) + i++ + time.Sleep(80 * time.Millisecond) + } + } + }() + + err := fn() + close(done) + // \r + enough spaces to overwrite the longest spinner line, then + // \r again to put the cursor back at column 0 for whatever prints + // next — leaves no leftover spinner text behind. + fmt.Printf("\r%s\r", spacesLen(len(label)+4)) + return err +} + +func spacesLen(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = ' ' + } + return string(b) +} diff --git a/table.go b/table.go new file mode 100644 index 0000000..1085e25 --- /dev/null +++ b/table.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "strings" +) + +// maxCellWidth caps how wide any single column gets — a full UUID or +// long email would otherwise blow up every row's width. Truncated +// values end in "…" so it's visually obvious they're cut, not just +// short data. +const maxCellWidth = 36 + +// printTable renders columns/rows as a real box-drawing table. Falls +// back to plain columns with no box characters when colors are +// disabled (piped output, NO_COLOR, non-TTY) — box-drawing characters +// in a script's captured output are just noise for whatever's +// parsing it, same reasoning as the existing color-disable behavior. +func printTable(columns []string, rows [][]string) { + if !colorsEnabled { + printPlainTable(columns, rows) + return + } + + cells := make([][]string, len(rows)+1) + cells[0] = truncateRow(columns) + for i, row := range rows { + cells[i+1] = truncateRow(row) + } + widths := columnWidths(cells) + + printBorder(widths, "┌", "┬", "┐") + printRow(cells[0], widths, true) + printBorder(widths, "├", "┼", "┤") + for i := 1; i < len(cells); i++ { + printRow(cells[i], widths, false) + } + printBorder(widths, "└", "┴", "┘") +} + +func printPlainTable(columns []string, rows [][]string) { + println_ := func(cells []string) { + printfLine(strings.Join(cells, " | ")) + } + println_(columns) + for _, row := range rows { + println_(row) + } +} + +func printfLine(s string) { + // Small indirection so this file has exactly one place that + // writes plain lines — kept separate from fmt.Println calls + // elsewhere so a future output-destination change (e.g. writing + // to a log file too) only touches one spot. + fmt.Println(s) +} + +func truncateRow(row []string) []string { + out := make([]string, len(row)) + for i, v := range row { + out[i] = truncateCell(v) + } + return out +} + +func truncateCell(v string) string { + if len(v) <= maxCellWidth { + return v + } + return v[:maxCellWidth-1] + "…" +} + +func columnWidths(cells [][]string) []int { + widths := make([]int, len(cells[0])) + for _, row := range cells { + for i, v := range row { + if len([]rune(v)) > widths[i] { + widths[i] = len([]rune(v)) + } + } + } + return widths +} + +func printBorder(widths []int, left, mid, right string) { + var b strings.Builder + b.WriteString(dim(left)) + for i, w := range widths { + b.WriteString(dim(strings.Repeat("─", w+2))) + if i < len(widths)-1 { + b.WriteString(dim(mid)) + } + } + b.WriteString(dim(right)) + fmt.Println(b.String()) +} + +func printRow(cells []string, widths []int, header bool) { + var b strings.Builder + b.WriteString(dim("│")) + for i, v := range cells { + padded := v + strings.Repeat(" ", widths[i]-len([]rune(v))) + if header { + padded = dim("\033[1m" + padded + "\033[22m") + } else { + padded = colorizeCell(v, padded) + } + b.WriteString(" " + padded + " ") + b.WriteString(dim("│")) + } + fmt.Println(b.String()) +} + +// colorizeCell gives audit event types a semantic color — the +// specific thing the earlier "any failed logins recently" test made +// obvious was missing: a wall of same-colored text makes it hard to +// spot the one row that matters. Anything else prints unchanged. +func colorizeCell(rawValue, padded string) string { + switch { + case strings.Contains(rawValue, "failed"), strings.Contains(rawValue, "reuse_detected"), strings.Contains(rawValue, "locked"): + return red(padded) + case strings.Contains(rawValue, "success"), strings.Contains(rawValue, "linked"), strings.Contains(rawValue, "unlocked"): + return green(padded) + default: + return padded + } +}