diff --git a/README.md b/README.md index c7cff16..6cb6d91 100644 --- a/README.md +++ b/README.md @@ -516,6 +516,18 @@ jc setup # Walk through first-time configuration The wizard guides you through profile selection, authentication (API key or service account), organization ID, output format, color, and list limit. On re-run, existing settings are shown — press Enter to keep current values. Each step saves immediately, so partial completion (Ctrl-C) preserves progress. +### Audit + +```bash +jc audit # All checks, grouped by category +jc audit --category security # Security checks only +jc audit --severity high # High/critical findings only +jc audit --output json # For skills + CI pipelines +jc audit --exit-code --threshold high # CI gate: non-zero on high+ findings +``` + +A composable cross-resource health check registry. Each finding carries a severity (`info` → `critical`), a `resource_ref` like `admin:alice@acme.com`, and a `remediation_hint` that names the exact `jc` command to fix it. Categories: `security`, `compliance`, `hygiene`, `identity`. Backs the `jc-security-audit` and `jc-compliance-check` skills. Full check reference: [docs/AUDIT.md](docs/AUDIT.md). + ### Interactive TUI ```bash diff --git a/cmd/sitegen/main.go b/cmd/sitegen/main.go index 97b1d66..611adce 100644 --- a/cmd/sitegen/main.go +++ b/cmd/sitegen/main.go @@ -80,6 +80,10 @@ var categories = []category{ Name: "Setup", Commands: []string{"jc auth", "jc config"}, }, + { + Name: "Diagnostics", + Commands: []string{"jc audit", "jc doctor"}, + }, } type enrichedCommand struct { diff --git a/docs/AUDIT.md b/docs/AUDIT.md new file mode 100644 index 0000000..3dc4f62 --- /dev/null +++ b/docs/AUDIT.md @@ -0,0 +1,162 @@ +# `jc audit` — check reference + +`jc audit` runs a battery of cross-resource health checks against the +configured JumpCloud org. Each check is severity-tagged (`info`, `low`, +`medium`, `high`, `critical`) and ships with a `remediation_hint` that +names the exact `jc` command to fix it. Output formats: + +- Default human (grouped by category, severity-glyph prefix) +- `--output json` — wrapper object with `results`, `findings`, `warnings` +- `--output ndjson` — one finding per line, for streaming pipelines +- `--output table` / `--output csv` — tabular, fields: + `severity, category, check_id, resource_ref, title` + +For CI gating, pair `--exit-code` with `--threshold high` (or any other +severity). Non-zero exit when any finding meets or exceeds the threshold. + +```bash +jc audit # everything, human-readable +jc audit --category security # security checks only +jc audit --severity high # high/critical findings only +jc audit --output json # machine-readable for skills +jc audit --exit-code --threshold high # CI gate: fail on high+ findings +``` + +The check registry lives in `internal/audit/checks.go`. Adding a new +check is one `Register` call from `init()` — the CLI surface, JSON shape, +sidebar showcase entry, and skill prompts all surface it automatically. + +## Security + +### `admins-without-mfa` — CRITICAL + +Admin accounts without `enableMultiFactor` AND `totpEnrolled` both true. +Admin accounts are the highest-value target in any directory; missing +MFA on even one admin is a single-step compromise path to org-wide +takeover. + +**Remediation:** Admin Portal → Administrators → edit → Enable +Multi-Factor Authentication. No automatable API for this — JumpCloud +requires the admin to enroll via the portal. + +### `users-without-mfa` — HIGH + +Active (not suspended, not locked) users without an enrolled MFA factor +(`totp_enabled=false` AND `mfa.configured=false`). Scopes to active +users only: a suspended/locked account isn't a live attack surface. + +**Remediation:** Enforce MFA via an auth policy on the user's groups +(`jc auth-policies create`), or have the user enroll via the JumpCloud +user portal. + +### `suspended-not-locked` — MEDIUM + +Users where `suspended=true` but `account_locked=false`. Suspension +prevents new login but doesn't necessarily invalidate active sessions +or refresh tokens; locking forces re-auth on every gate. + +**Remediation:** `jc users lock `. + +### `iplists-empty` — LOW + +IP lists with zero IP entries. An empty IP list referenced by an auth +policy fails open or closed depending on policy semantics — both are +footguns: in fail-open you've eliminated the gate; in fail-closed +you've locked the user base out of the gated resource. + +**Remediation:** Populate (`jc iplists update`) or delete +(`jc iplists delete`). + +## Compliance + +### `mfa-adoption-rate` — scales by adoption + +Org-wide MFA adoption among active users. Severity scales: + +| Adoption | Severity | +|----------|----------| +| <50% | CRITICAL | +| <80% | HIGH | +| <95% | MEDIUM | +| ≥95% | silent (no finding) | + +The 95% threshold matches the bar in SOC 2 and ISO 27001 audit +frameworks for "material control gap." + +**Remediation:** Enforce MFA via auth policies covering user groups. + +### `admin-mfa-coverage` — CRITICAL + +Admin MFA adoption with a hard 100% target. Reported as a single +finding when adoption is below 100%; pair with `admins-without-mfa` for +the per-admin list. + +### `password-age` — MEDIUM + +Active users with `password_date` older than 90 days. 90 days mirrors +the common compliance bar (HIPAA, PCI DSS). + +If your compliance framework has moved off mandatory rotation (NIST SP +800-63B recommends against forced rotation absent compromise), filter +this check out with `--severity high`. + +### `fde-coverage` — scales by coverage + +Full-disk encryption coverage across managed macOS and Windows devices. +Linux / iOS / etc. are excluded (no comparable FDE telemetry via the +JumpCloud API). + +| Coverage | Severity | +|----------|----------| +| <50% | CRITICAL | +| <90% | HIGH | +| <100% | MEDIUM | +| 100% | silent | + +**Remediation:** Push the JumpCloud FDE policy to unencrypted devices. +FileVault / BitLocker keys are escrowed to JumpCloud for recovery. + +## Hygiene + +### `stale-devices` — MEDIUM + +Devices with `lastContact` more than 30 days ago. Either the device is +decommissioned (delete to reclaim the license) or the agent has +crashed (investigate). Stale devices count toward your license while +contributing no telemetry. + +**Remediation:** `jc devices delete ` (if decommissioned) or +`jc devices get ` to investigate agent state. + +### `auth-policies-disabled` — LOW + +Authentication policies in the disabled state. Disabled policies are +dead code — silent until a future operator wonders why traffic isn't +being gated. + +**Remediation:** Re-enable (`jc auth-policies enable `) or delete +(`jc auth-policies delete `). + +## Identity + +### `recently-created-admins` — INFO + +Admin accounts created in the last 14 days. Newly-created admins are +both a legitimate onboarding signal AND a common post-compromise +persistence mechanism — surface them for cross-check against IAM +tickets, Slack approvals, or a CMDB. + +This is INFO severity, not LOW: a legitimate new admin shouldn't +contribute to a hygiene score, but every new admin should be **seen**. + +## Roadmap + +Checks deferred for v1 because they require per-resource follow-up +calls (N+1 patterns that hurt at scale): + +- `empty-user-groups` / `empty-system-groups` — needs `/usergroups/{id}/members` +- `suspended-users-with-ssh-keys` — needs `/systemusers/{id}/sshkeys` +- `policies-without-scope` — needs the graph traversal + +Filed under cleanup follow-ups once we have a batched member-count +endpoint or sustained-load benchmarks justifying the fan-out cost. diff --git a/docs/site/commands.json b/docs/site/commands.json index 8716be5..4ab1551 100644 --- a/docs/site/commands.json +++ b/docs/site/commands.json @@ -10,7 +10,8 @@ "Integrations", "Org \u0026 Lifecycle", "AI \u0026 Automation", - "Setup" + "Setup", + "Diagnostics" ], "commands": [ { @@ -25,6 +26,57 @@ "switch" ] }, + { + "path": "jc audit", + "description": "Run cross-resource health checks (security, compliance, hygiene, identity)", + "long": "A composable check registry that audits the entire org in one pass — admins without MFA, MFA adoption rate, FDE coverage, stale devices, disabled auth policies, suspicious admin lifecycle events, and more. Each finding is severity-tagged (info → critical), tagged with a `resource_ref` for downstream grouping, and ships with a `remediation_hint` that names the exact `jc` command to fix it. Use `--category security|compliance|hygiene|identity` to scope, `--severity high` to filter to actionable findings, and `--exit-code --threshold high` to gate CI pipelines. The same primitive powers the `jc-security-audit` and `jc-compliance-check` skills (which now interpret structured findings rather than scripting raw queries). Adding a new check is one Register call in `internal/audit/checks.go` — the registry, CLI surface, JSON shape, and skill prompts all update automatically.", + "category": "Diagnostics", + "subcommands": [ + "verify" + ], + "flags": [ + { + "name": "category", + "type": "string[]", + "description": "Restrict to one or more categories: security, compliance, hygiene, identity" + }, + { + "name": "severity", + "type": "string", + "description": "Show only findings at or above this severity (info, low, medium, high, critical)" + }, + { + "name": "threshold", + "type": "string", + "default": "high", + "description": "Severity threshold used by --exit-code" + }, + { + "name": "exit-code", + "type": "bool", + "description": "Exit with code 1 if any finding meets or exceeds --threshold (for CI gating)" + } + ] + }, + { + "path": "jc doctor", + "description": "No-auth diagnostic — env, config, auth resolution, API connectivity", + "long": "A pre-flight check for any environment where `jc` is about to run. Reports the active profile, credential source (flag / env / keychain / config), fingerprint of the resolved key, config file location, and runs a single read-only probe against the JumpCloud API to confirm the credentials actually authenticate. Works without auth (skips the API probe gracefully) so it's safe to run in a Dockerfile build step or fresh-clone sanity check. Useful as the first command in a runbook or on-call playbook — when something's wrong with `jc`, this is the fastest path to the cause.", + "category": "Diagnostics", + "flags": [ + { + "name": "probe-timeout", + "type": "duration", + "default": "5s", + "description": "Timeout for the live API probe" + }, + { + "name": "skip-probe", + "type": "bool", + "description": "Skip the live API probe (no network)" + } + ] + }, { "path": "jc config", "description": "Configuration management", diff --git a/docs/site/llms-full.txt b/docs/site/llms-full.txt index 8722039..9f6d974 100644 --- a/docs/site/llms-full.txt +++ b/docs/site/llms-full.txt @@ -522,6 +522,34 @@ View and update jc CLI configuration — default output format, color/pager beha **Subcommands:** `view`, `set` +### Diagnostics + +#### `jc audit` + +Run cross-resource health checks (security, compliance, hygiene, identity) + +A composable check registry that audits the entire org in one pass — admins without MFA, MFA adoption rate, FDE coverage, stale devices, disabled auth policies, suspicious admin lifecycle events, and more. Each finding is severity-tagged (info → critical), tagged with a `resource_ref` for downstream grouping, and ships with a `remediation_hint` that names the exact `jc` command to fix it. Use `--category security|compliance|hygiene|identity` to scope, `--severity high` to filter to actionable findings, and `--exit-code --threshold high` to gate CI pipelines. The same primitive powers the `jc-security-audit` and `jc-compliance-check` skills (which now interpret structured findings rather than scripting raw queries). Adding a new check is one Register call in `internal/audit/checks.go` — the registry, CLI surface, JSON shape, and skill prompts all update automatically. + +**Subcommands:** `verify` + +**Flags:** + +- `--category` `` — Restrict to one or more categories: security, compliance, hygiene, identity +- `--severity` `` — Show only findings at or above this severity (info, low, medium, high, critical) +- `--threshold` `` (default `high`) — Severity threshold used by --exit-code +- `--exit-code` `` — Exit with code 1 if any finding meets or exceeds --threshold (for CI gating) + +#### `jc doctor` + +No-auth diagnostic — env, config, auth resolution, API connectivity + +A pre-flight check for any environment where `jc` is about to run. Reports the active profile, credential source (flag / env / keychain / config), fingerprint of the resolved key, config file location, and runs a single read-only probe against the JumpCloud API to confirm the credentials actually authenticate. Works without auth (skips the API probe gracefully) so it's safe to run in a Dockerfile build step or fresh-clone sanity check. Useful as the first command in a runbook or on-call playbook — when something's wrong with `jc`, this is the fastest path to the cause. + +**Flags:** + +- `--probe-timeout` `` (default `5s`) — Timeout for the live API probe +- `--skip-probe` `` — Skip the live API probe (no network) + ## Resource types Every resource has a JSON schema available via `jc schema `: diff --git a/docs/site/llms.txt b/docs/site/llms.txt index 2a2ae2a..5f2a989 100644 --- a/docs/site/llms.txt +++ b/docs/site/llms.txt @@ -71,6 +71,11 @@ The jc CLI is a Go-based command-line tool for JumpCloud — managing users, dev - `jc auth` — Authentication commands - `jc config` — Configuration management +### Diagnostics + +- `jc audit` — Run cross-resource health checks (security, compliance, hygiene, identity) +- `jc doctor` — No-auth diagnostic — env, config, auth resolution, API connectivity + ## See also - [Full reference](llms-full.txt) diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..e67fdef --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,282 @@ +// Package audit implements cross-resource health checks for a JumpCloud +// org, exposed via `jc audit`. The package is the registry + runner; the +// CLI surface and skill prompts compose against it. +// +// Each check is one Go function tagged with a Category + intrinsic +// Severity. Checks consume a shared *Data bundle (fetched once, in +// parallel) and emit Findings. The runner filters by category + min +// severity, applies the --threshold exit-code policy, and returns a +// sortable result set. +// +// Adding a new check is a one-line Register call from an init() — see +// checks_security.go for the canonical shape. +package audit + +import ( + "context" + "fmt" + "sort" + "time" +) + +// Severity is the impact level a finding represents. Ordered low → high +// so callers can do range comparisons (Severity.AtLeast). +type Severity string + +const ( + SeverityInfo Severity = "info" + SeverityLow Severity = "low" + SeverityMedium Severity = "medium" + SeverityHigh Severity = "high" + SeverityCritical Severity = "critical" +) + +// severityRank maps each severity to a comparable int. Unknown values +// rank lowest so they don't accidentally trip --threshold gates. +var severityRank = map[Severity]int{ + SeverityInfo: 1, + SeverityLow: 2, + SeverityMedium: 3, + SeverityHigh: 4, + SeverityCritical: 5, +} + +// AtLeast returns true if s is >= other in the standard severity order. +// Used by --severity and --threshold filters. +func (s Severity) AtLeast(other Severity) bool { + return severityRank[s] >= severityRank[other] +} + +// Valid reports whether s is one of the declared constants. Returned +// from CLI flag parsing so an unknown --severity surfaces clearly. +func (s Severity) Valid() bool { + _, ok := severityRank[s] + return ok +} + +// Category groups checks for filtering and skill scope. Categories are +// curated, not free-form — a typo on a check Category would silently +// orphan it from --category filters. +type Category string + +const ( + CategorySecurity Category = "security" + CategoryCompliance Category = "compliance" + CategoryHygiene Category = "hygiene" + CategoryIdentity Category = "identity" +) + +// Categories returns the known categories in display order. +func Categories() []Category { + return []Category{CategorySecurity, CategoryCompliance, CategoryHygiene, CategoryIdentity} +} + +// Valid reports whether c is one of the declared constants. +func (c Category) Valid() bool { + for _, k := range Categories() { + if k == c { + return true + } + } + return false +} + +// Finding is the atomic unit emitted by a check. One check can emit +// many findings (one per offending resource), or none (the org is +// clean for that check). +// +// ResourceRef uses a ":" convention (admin:foo@bar.com, +// device:host-42, group:contractors) so skills and CI gates can group +// or deduplicate by resource without parsing free-form text. +type Finding struct { + CheckID string `json:"check_id"` + Title string `json:"title"` + Category Category `json:"category"` + Severity Severity `json:"severity"` + ResourceRef string `json:"resource_ref,omitempty"` + RemediationHint string `json:"remediation_hint,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// CheckResult captures one check's outcome, including a fatal Error if +// the check couldn't run (e.g. its required API fetch failed). An empty +// Findings slice + empty Error means "ran clean, no issues." +type CheckResult struct { + CheckID string `json:"check_id"` + Title string `json:"title"` + Category Category `json:"category"` + Findings []Finding `json:"findings"` + Error string `json:"error,omitempty"` + DurationMS int64 `json:"duration_ms"` +} + +// MaxSeverity returns the highest finding severity in the result, or +// "" if there are none. Useful for status-line rendering and threshold +// comparisons. +func (r CheckResult) MaxSeverity() Severity { + var max Severity + for _, f := range r.Findings { + if max == "" || severityRank[f.Severity] > severityRank[max] { + max = f.Severity + } + } + return max +} + +// AuditCheck is a registered check. ID must be globally unique and +// kebab-case. Run consumes the shared Data bundle and emits findings; +// it should not perform its own API calls. +type AuditCheck struct { + ID string + Title string + Category Category + Run func(ctx context.Context, d *Data) ([]Finding, error) +} + +var registry []AuditCheck + +// Register adds a check to the global registry. Called from init() in +// each checks_*.go file. Duplicate IDs panic at registration time — a +// silent overwrite would make findings invisible without an obvious +// failure mode. +func Register(c AuditCheck) { + for _, existing := range registry { + if existing.ID == c.ID { + panic(fmt.Sprintf("audit: duplicate check ID %q", c.ID)) + } + } + if !c.Category.Valid() { + panic(fmt.Sprintf("audit: check %q has invalid category %q", c.ID, c.Category)) + } + registry = append(registry, c) +} + +// All returns a copy of the registry so callers can mutate the slice +// (sort, filter) without corrupting the package-level state. +func All() []AuditCheck { + out := make([]AuditCheck, len(registry)) + copy(out, registry) + return out +} + +// RunOptions configures a Run invocation. Empty Categories means "all"; +// empty MinSeverity means "no filter." +type RunOptions struct { + Categories []Category + MinSeverity Severity +} + +// match returns true if the check survives the category filter. +func (o RunOptions) matchCategory(cat Category) bool { + if len(o.Categories) == 0 { + return true + } + for _, want := range o.Categories { + if want == cat { + return true + } + } + return false +} + +// Run executes the registered checks against the supplied Data bundle. +// Checks run sequentially (cheap, the data is already in memory) so the +// runner can preserve registration order in the output and so a single +// check's error doesn't race against another's progress reporting. +// +// Each check is bounded by ctx; cancellation is checked between checks +// and surfaces immediately as ctx.Err(). +func Run(ctx context.Context, d *Data, opts RunOptions) ([]CheckResult, error) { + var results []CheckResult + for _, c := range registry { + if err := ctx.Err(); err != nil { + return results, err + } + if !opts.matchCategory(c.Category) { + continue + } + start := time.Now() + findings, err := c.Run(ctx, d) + duration := time.Since(start).Milliseconds() + + res := CheckResult{ + CheckID: c.ID, + Title: c.Title, + Category: c.Category, + DurationMS: duration, + } + if err != nil { + res.Error = err.Error() + results = append(results, res) + continue + } + if opts.MinSeverity != "" && opts.MinSeverity.Valid() { + findings = filterBySeverity(findings, opts.MinSeverity) + } + res.Findings = findings + results = append(results, res) + } + return results, nil +} + +func filterBySeverity(in []Finding, min Severity) []Finding { + out := in[:0] + for _, f := range in { + if f.Severity.AtLeast(min) { + out = append(out, f) + } + } + return out +} + +// SortByCategoryAndSeverity orders results for human display: category +// in canonical order, then highest-severity-first within each category, +// then check ID for stable tiebreak. +func SortByCategoryAndSeverity(results []CheckResult) { + catOrder := make(map[Category]int) + for i, c := range Categories() { + catOrder[c] = i + } + sort.SliceStable(results, func(i, j int) bool { + ci, cj := catOrder[results[i].Category], catOrder[results[j].Category] + if ci != cj { + return ci < cj + } + si, sj := severityRank[results[i].MaxSeverity()], severityRank[results[j].MaxSeverity()] + if si != sj { + return si > sj + } + return results[i].CheckID < results[j].CheckID + }) +} + +// AnyFindingAtLeast returns true if any result contains a finding at or +// above the given severity. Used by --exit-code/--threshold to derive +// the CLI exit status. +func AnyFindingAtLeast(results []CheckResult, threshold Severity) bool { + if !threshold.Valid() { + return false + } + for _, r := range results { + for _, f := range r.Findings { + if f.Severity.AtLeast(threshold) { + return true + } + } + } + return false +} + +// AnyCheckError returns true if any check failed to run (its Run +// returned a non-nil error, typically because its required data wasn't +// fetched). Distinct from findings — a degraded audit shouldn't look +// like a clean one. Used alongside AnyFindingAtLeast for --exit-code +// so a partial fetch can't silently green-light a CI gate. +func AnyCheckError(results []CheckResult) bool { + for _, r := range results { + if r.Error != "" { + return true + } + } + return false +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..48e6c37 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,296 @@ +package audit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" +) + +func TestSeverity_AtLeast(t *testing.T) { + if !SeverityCritical.AtLeast(SeverityLow) { + t.Error("critical should be >= low") + } + if SeverityLow.AtLeast(SeverityCritical) { + t.Error("low should not be >= critical") + } + if !SeverityHigh.AtLeast(SeverityHigh) { + t.Error("equal severities should compare >=") + } + // Unknown severity ranks at 0 — should never satisfy any real threshold. + if Severity("bogus").AtLeast(SeverityInfo) { + t.Error("unknown severity should not satisfy info threshold") + } +} + +func TestCheckResult_MaxSeverity(t *testing.T) { + r := CheckResult{ + Findings: []Finding{ + {Severity: SeverityLow}, + {Severity: SeverityCritical}, + {Severity: SeverityMedium}, + }, + } + if r.MaxSeverity() != SeverityCritical { + t.Errorf("got %v, want critical", r.MaxSeverity()) + } + if (CheckResult{}.MaxSeverity()) != "" { + t.Error("empty findings should yield empty max") + } +} + +func TestRegister_DuplicateIDPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on duplicate ID") + } + }() + // admins-without-mfa is already registered by init() + Register(AuditCheck{ + ID: "admins-without-mfa", + Title: "dup", + Category: CategorySecurity, + Run: func(context.Context, *Data) ([]Finding, error) { return nil, nil }, + }) +} + +func TestRun_CategoryFilter(t *testing.T) { + d := &Data{Admins: []json.RawMessage{}} + res, err := Run(context.Background(), d, RunOptions{Categories: []Category{CategoryCompliance}}) + if err != nil { + t.Fatalf("Run error: %v", err) + } + for _, r := range res { + if r.Category != CategoryCompliance { + t.Errorf("category filter leaked: got %v", r.Category) + } + } + if len(res) == 0 { + t.Error("expected at least one compliance result") + } +} + +func TestRun_SeverityFilter(t *testing.T) { + // Synthesize a Data set where admins-without-mfa would emit a CRITICAL + // finding. Filtering at min=critical should keep it; min=info+ keeps it + // too; only an unsatisfiable threshold drops it. + d := &Data{Admins: []json.RawMessage{ + mustMarshal(map[string]any{"_id": "1", "email": "a@x", "enableMultiFactor": false}), + }} + res, err := Run(context.Background(), d, RunOptions{ + Categories: []Category{CategorySecurity}, + MinSeverity: SeverityCritical, + }) + if err != nil { + t.Fatalf("Run error: %v", err) + } + var got int + for _, r := range res { + got += len(r.Findings) + } + if got == 0 { + t.Error("expected at least one CRITICAL finding to survive min=critical filter") + } +} + +func TestRun_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Run(ctx, &Data{}, RunOptions{}) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled, got %v", err) + } +} + +func TestAnyFindingAtLeast(t *testing.T) { + results := []CheckResult{ + {Findings: []Finding{{Severity: SeverityLow}}}, + {Findings: []Finding{{Severity: SeverityHigh}}}, + } + if !AnyFindingAtLeast(results, SeverityHigh) { + t.Error("should detect HIGH finding") + } + if AnyFindingAtLeast(results, SeverityCritical) { + t.Error("should not falsely report CRITICAL") + } + if AnyFindingAtLeast(results, Severity("bogus")) { + t.Error("bogus threshold should never match") + } +} + +func TestAnyCheckError(t *testing.T) { + // Regression guard for the Bugbot PR #47 finding: a check that + // errored is a degraded result, not a clean run. --exit-code must + // treat this distinctly from "no findings." + if AnyCheckError([]CheckResult{{Findings: nil}}) { + t.Error("clean result should not register as error") + } + if !AnyCheckError([]CheckResult{{Error: "admins fetch unavailable"}}) { + t.Error("errored check should be detected") + } + if !AnyCheckError([]CheckResult{ + {Findings: []Finding{{Severity: SeverityLow}}}, + {Error: "users fetch unavailable"}, + }) { + t.Error("mixed result with one error should be detected") + } +} + +func TestSortByCategoryAndSeverity(t *testing.T) { + results := []CheckResult{ + {CheckID: "z", Category: CategoryHygiene, Findings: []Finding{{Severity: SeverityLow}}}, + {CheckID: "a", Category: CategorySecurity, Findings: []Finding{{Severity: SeverityCritical}}}, + {CheckID: "b", Category: CategorySecurity, Findings: []Finding{{Severity: SeverityLow}}}, + } + SortByCategoryAndSeverity(results) + // security/critical first, security/low next, hygiene last + if results[0].CheckID != "a" || results[1].CheckID != "b" || results[2].CheckID != "z" { + t.Errorf("sort order wrong: %v %v %v", results[0].CheckID, results[1].CheckID, results[2].CheckID) + } +} + +// ─── per-check tests ─────────────────────────────────────────────── + +func TestCheckAdminsWithoutMFA(t *testing.T) { + d := &Data{Admins: []json.RawMessage{ + mustMarshal(map[string]any{"_id": "1", "email": "good@x", "enableMultiFactor": true, "totpEnrolled": true}), + mustMarshal(map[string]any{"_id": "2", "email": "bad@x", "enableMultiFactor": false}), + }} + findings, err := checkAdminsWithoutMFA(context.Background(), d) + if err != nil { + t.Fatalf("error: %v", err) + } + if len(findings) != 1 { + t.Fatalf("expected 1 finding, got %d", len(findings)) + } + if findings[0].Severity != SeverityCritical { + t.Errorf("expected CRITICAL, got %v", findings[0].Severity) + } + if findings[0].ResourceRef != "admin:bad@x" { + t.Errorf("wrong resource_ref: %s", findings[0].ResourceRef) + } +} + +func TestCheckAdminsWithoutMFA_NilDataReturnsError(t *testing.T) { + _, err := checkAdminsWithoutMFA(context.Background(), &Data{}) + if err == nil { + t.Error("expected error when admins fetch unavailable") + } +} + +func TestMFAAdoptionSeverity(t *testing.T) { + tests := []struct { + pct float64 + want Severity + }{ + {30, SeverityCritical}, + {60, SeverityHigh}, + {85, SeverityMedium}, + {95, ""}, + {100, ""}, + } + for _, tc := range tests { + if got := mfaAdoptionSeverity(tc.pct); got != tc.want { + t.Errorf("pct=%v: got %v, want %v", tc.pct, got, tc.want) + } + } +} + +func TestCheckStaleDevices_UsesDataNow(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + d := &Data{ + Now: now, + Devices: []json.RawMessage{ + mustMarshal(map[string]any{"_id": "1", "hostname": "fresh", "os": "Mac OS X", "lastContact": "2026-05-25T00:00:00Z"}), + mustMarshal(map[string]any{"_id": "2", "hostname": "stale", "os": "Mac OS X", "lastContact": "2026-04-01T00:00:00Z"}), + mustMarshal(map[string]any{"_id": "3", "hostname": "no-contact", "os": "Mac OS X"}), + }, + } + findings, err := checkStaleDevices(context.Background(), d) + if err != nil { + t.Fatalf("error: %v", err) + } + if len(findings) != 1 { + t.Fatalf("expected 1 stale finding, got %d", len(findings)) + } + if findings[0].ResourceRef != "device:stale" { + t.Errorf("wrong device flagged: %s", findings[0].ResourceRef) + } +} + +// ─── data fetcher ────────────────────────────────────────────────── + +type stubFetcher struct { + usersErr error + calls atomic.Int32 +} + +func (s *stubFetcher) noop(_ context.Context) ([]json.RawMessage, error) { + s.calls.Add(1) + return []json.RawMessage{}, nil +} + +func (s *stubFetcher) Users(ctx context.Context) ([]json.RawMessage, error) { + s.calls.Add(1) + if s.usersErr != nil { + return nil, s.usersErr + } + return []json.RawMessage{[]byte(`{"_id":"1"}`)}, nil +} +func (s *stubFetcher) Admins(ctx context.Context) ([]json.RawMessage, error) { + return s.noop(ctx) +} +func (s *stubFetcher) Devices(ctx context.Context) ([]json.RawMessage, error) { + return s.noop(ctx) +} +func (s *stubFetcher) UserGroups(ctx context.Context) ([]json.RawMessage, error) { + return s.noop(ctx) +} +func (s *stubFetcher) SystemGroups(ctx context.Context) ([]json.RawMessage, error) { + return s.noop(ctx) +} +func (s *stubFetcher) AuthPolicies(ctx context.Context) ([]json.RawMessage, error) { + return s.noop(ctx) +} +func (s *stubFetcher) IPLists(ctx context.Context) ([]json.RawMessage, error) { + return s.noop(ctx) +} + +func TestFetch_ParallelSubFetches(t *testing.T) { + s := &stubFetcher{} + d, err := Fetch(context.Background(), s) + if err != nil { + t.Fatalf("Fetch error: %v", err) + } + if d.Users == nil || len(d.Users) != 1 { + t.Error("users not populated") + } + if s.calls.Load() != 7 { + t.Errorf("expected 7 fetcher calls, got %d", s.calls.Load()) + } +} + +func TestFetch_SoftFailuresBecomeWarnings(t *testing.T) { + s := &stubFetcher{usersErr: fmt.Errorf("upstream 503")} + d, err := Fetch(context.Background(), s) + if err != nil { + t.Fatalf("Fetch should not fail when at least one sub-fetch succeeds: %v", err) + } + if d.Users != nil { + t.Error("expected nil users on fetch failure") + } + if len(d.Warnings) != 1 { + t.Errorf("expected 1 warning, got %d", len(d.Warnings)) + } +} + +func mustMarshal(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} diff --git a/internal/audit/checks.go b/internal/audit/checks.go new file mode 100644 index 0000000..41ace31 --- /dev/null +++ b/internal/audit/checks.go @@ -0,0 +1,543 @@ +package audit + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// All checks register in init() — keeping them in one file makes the +// catalog auditable at a glance. Add a new check by: +// 1. Writing a check function: func checkXxx(ctx, *Data) ([]Finding, error) +// 2. Registering it in init() with a unique kebab-case ID and category. +// +// Severity is set per-finding (a single check may emit findings at +// different severities — e.g. admin-mfa-coverage is CRITICAL if any +// admin lacks MFA but is silent if every admin has it). +func init() { + Register(AuditCheck{ + ID: "admins-without-mfa", + Title: "Admins without multi-factor authentication", + Category: CategorySecurity, + Run: checkAdminsWithoutMFA, + }) + Register(AuditCheck{ + ID: "users-without-mfa", + Title: "Active users without MFA enrolled", + Category: CategorySecurity, + Run: checkUsersWithoutMFA, + }) + Register(AuditCheck{ + ID: "suspended-not-locked", + Title: "Users suspended but not account-locked", + Category: CategorySecurity, + Run: checkSuspendedNotLocked, + }) + Register(AuditCheck{ + ID: "iplists-empty", + Title: "IP lists with no entries", + Category: CategorySecurity, + Run: checkEmptyIPLists, + }) + + Register(AuditCheck{ + ID: "mfa-adoption-rate", + Title: "Organization-wide MFA adoption rate", + Category: CategoryCompliance, + Run: checkMFAAdoptionRate, + }) + Register(AuditCheck{ + ID: "admin-mfa-coverage", + Title: "Admin MFA coverage (target: 100%)", + Category: CategoryCompliance, + Run: checkAdminMFACoverage, + }) + Register(AuditCheck{ + ID: "password-age", + Title: "Users with passwords older than 90 days", + Category: CategoryCompliance, + Run: checkPasswordAge, + }) + Register(AuditCheck{ + ID: "fde-coverage", + Title: "Full-disk encryption coverage", + Category: CategoryCompliance, + Run: checkFDECoverage, + }) + + Register(AuditCheck{ + ID: "stale-devices", + Title: "Devices that haven't checked in for 30+ days", + Category: CategoryHygiene, + Run: checkStaleDevices, + }) + Register(AuditCheck{ + ID: "auth-policies-disabled", + Title: "Authentication policies that are disabled", + Category: CategoryHygiene, + Run: checkDisabledAuthPolicies, + }) + + Register(AuditCheck{ + ID: "recently-created-admins", + Title: "Admins created in the last 14 days", + Category: CategoryIdentity, + Run: checkRecentAdmins, + }) +} + +// staleDeviceThreshold is the boundary for the stale-devices check. +// 30 days mirrors the threshold used by the existing +// jc-compliance-check skill. +const staleDeviceThreshold = 30 * 24 * time.Hour + +// passwordAgeThreshold is the policy line for the password-age check. +// 90 days mirrors the common compliance bar (SOC 2, ISO 27001 baselines). +const passwordAgeThreshold = 90 * 24 * time.Hour + +// recentAdminWindow is the lookback for the recently-created-admins +// check. 14 days surfaces both legitimate onboardings and recently- +// rotated credentials worth a sanity check. +const recentAdminWindow = 14 * 24 * time.Hour + +// ─── security ────────────────────────────────────────────────────── + +func checkAdminsWithoutMFA(_ context.Context, d *Data) ([]Finding, error) { + if d.Admins == nil { + return nil, fmt.Errorf("admins fetch unavailable") + } + var findings []Finding + for _, raw := range d.Admins { + var a struct { + ID string `json:"_id"` + Email string `json:"email"` + EnableMultiFactor bool `json:"enableMultiFactor"` + TotpEnrolled bool `json:"totpEnrolled"` + } + if err := json.Unmarshal(raw, &a); err != nil { + continue + } + if a.EnableMultiFactor && a.TotpEnrolled { + continue + } + findings = append(findings, Finding{ + CheckID: "admins-without-mfa", + Title: "Admin without MFA", + Category: CategorySecurity, + Severity: SeverityCritical, + ResourceRef: "admin:" + a.Email, + RemediationHint: "Admin Portal → Administrators → " + a.Email + " → Enable Multi-Factor Authentication. Admin accounts without MFA are the highest-impact persistent risk in any directory.", + Detail: fmt.Sprintf("Admin %s has MFA disabled (enableMultiFactor=%v, totpEnrolled=%v).", a.Email, a.EnableMultiFactor, a.TotpEnrolled), + }) + } + return findings, nil +} + +func checkUsersWithoutMFA(_ context.Context, d *Data) ([]Finding, error) { + if d.Users == nil { + return nil, fmt.Errorf("users fetch unavailable") + } + var findings []Finding + for _, raw := range d.Users { + var u struct { + ID string `json:"_id"` + Username string `json:"username"` + Email string `json:"email"` + Activated bool `json:"activated"` + Suspended bool `json:"suspended"` + AccountLocked bool `json:"account_locked"` + TOTPEnabled bool `json:"totp_enabled"` + MFA struct { + Configured bool `json:"configured"` + } `json:"mfa"` + } + if err := json.Unmarshal(raw, &u); err != nil { + continue + } + // Only flag active users — a locked/suspended user without MFA + // isn't an attack surface. Mirrors the compliance_view scoping. + if !u.Activated || u.Suspended || u.AccountLocked { + continue + } + if u.TOTPEnabled || u.MFA.Configured { + continue + } + findings = append(findings, Finding{ + CheckID: "users-without-mfa", + Title: "Active user without MFA", + Category: CategorySecurity, + Severity: SeverityHigh, + ResourceRef: "user:" + u.Username, + RemediationHint: "Enforce MFA via an auth policy (`jc auth-policies`) covering this user's groups, or have the user enroll via the JumpCloud user portal.", + Detail: fmt.Sprintf("Active user %s (%s) has no MFA factor enrolled.", u.Username, u.Email), + }) + } + return findings, nil +} + +func checkSuspendedNotLocked(_ context.Context, d *Data) ([]Finding, error) { + if d.Users == nil { + return nil, fmt.Errorf("users fetch unavailable") + } + var findings []Finding + for _, raw := range d.Users { + var u struct { + ID string `json:"_id"` + Username string `json:"username"` + Suspended bool `json:"suspended"` + AccountLocked bool `json:"account_locked"` + } + if err := json.Unmarshal(raw, &u); err != nil { + continue + } + if !u.Suspended || u.AccountLocked { + continue + } + findings = append(findings, Finding{ + CheckID: "suspended-not-locked", + Title: "Suspended user without account lock", + Category: CategorySecurity, + Severity: SeverityMedium, + ResourceRef: "user:" + u.Username, + RemediationHint: "Run `jc users lock " + u.Username + "` — suspension alone leaves residual session/token attack surface; locking forces re-auth on every gate.", + Detail: fmt.Sprintf("User %s is suspended but account_locked=false.", u.Username), + }) + } + return findings, nil +} + +func checkEmptyIPLists(_ context.Context, d *Data) ([]Finding, error) { + if d.IPLists == nil { + return nil, fmt.Errorf("ip lists fetch unavailable") + } + var findings []Finding + for _, raw := range d.IPLists { + var l struct { + ID string `json:"id"` + Name string `json:"name"` + IPs []any `json:"ips"` + } + if err := json.Unmarshal(raw, &l); err != nil { + continue + } + if len(l.IPs) > 0 { + continue + } + findings = append(findings, Finding{ + CheckID: "iplists-empty", + Title: "IP list with no entries", + Category: CategorySecurity, + Severity: SeverityLow, + ResourceRef: "iplist:" + l.Name, + RemediationHint: "Either populate the list (`jc iplists update`) or delete it (`jc iplists delete`). An empty IP list referenced by an auth policy fails open or closed depending on policy semantics — both are footguns.", + Detail: "IP list " + l.Name + " has no IP entries.", + }) + } + return findings, nil +} + +// ─── compliance ──────────────────────────────────────────────────── + +func checkMFAAdoptionRate(_ context.Context, d *Data) ([]Finding, error) { + if d.Users == nil { + return nil, fmt.Errorf("users fetch unavailable") + } + total, enrolled := 0, 0 + for _, raw := range d.Users { + var u struct { + Activated bool `json:"activated"` + Suspended bool `json:"suspended"` + AccountLocked bool `json:"account_locked"` + TOTPEnabled bool `json:"totp_enabled"` + MFA struct { + Configured bool `json:"configured"` + } `json:"mfa"` + } + if err := json.Unmarshal(raw, &u); err != nil { + continue + } + if !u.Activated || u.Suspended || u.AccountLocked { + continue + } + total++ + if u.TOTPEnabled || u.MFA.Configured { + enrolled++ + } + } + if total == 0 { + return nil, nil + } + pct := float64(enrolled) / float64(total) * 100 + sev := mfaAdoptionSeverity(pct) + if sev == "" { + return nil, nil // ≥95% — no finding + } + return []Finding{{ + CheckID: "mfa-adoption-rate", + Title: "MFA adoption below target", + Category: CategoryCompliance, + Severity: sev, + ResourceRef: "org", + RemediationHint: "Enforce MFA via auth policies on user groups. Tracked threshold: 95% adoption; common compliance frameworks (SOC 2, ISO 27001) consider <95% a material control gap.", + Detail: fmt.Sprintf("%d of %d active users enrolled in MFA (%.1f%%).", enrolled, total, pct), + }}, nil +} + +// mfaAdoptionSeverity scales severity to adoption rate. The brackets +// are deliberate: <50% is critical (controls don't exist), 50-80% is +// high (controls leak), 80-95% is medium (long tail), ≥95% silent. +func mfaAdoptionSeverity(pct float64) Severity { + switch { + case pct < 50: + return SeverityCritical + case pct < 80: + return SeverityHigh + case pct < 95: + return SeverityMedium + default: + return "" + } +} + +func checkAdminMFACoverage(_ context.Context, d *Data) ([]Finding, error) { + if d.Admins == nil { + return nil, fmt.Errorf("admins fetch unavailable") + } + total, mfa := 0, 0 + for _, raw := range d.Admins { + var a struct { + EnableMultiFactor bool `json:"enableMultiFactor"` + TotpEnrolled bool `json:"totpEnrolled"` + } + if err := json.Unmarshal(raw, &a); err != nil { + continue + } + total++ + if a.EnableMultiFactor && a.TotpEnrolled { + mfa++ + } + } + if total == 0 || mfa == total { + return nil, nil + } + pct := float64(mfa) / float64(total) * 100 + return []Finding{{ + CheckID: "admin-mfa-coverage", + Title: "Admin MFA coverage below 100%", + Category: CategoryCompliance, + Severity: SeverityCritical, + ResourceRef: "org", + RemediationHint: "Every admin must have MFA — admin accounts are the highest-value targets in the directory. See findings from `admins-without-mfa` for the specific accounts.", + Detail: fmt.Sprintf("%d of %d admins have MFA enabled (%.1f%%). Target: 100%%.", mfa, total, pct), + }}, nil +} + +func checkPasswordAge(_ context.Context, d *Data) ([]Finding, error) { + if d.Users == nil { + return nil, fmt.Errorf("users fetch unavailable") + } + cutoff := d.Now.Add(-passwordAgeThreshold) + var stale int + for _, raw := range d.Users { + var u struct { + Activated bool `json:"activated"` + Suspended bool `json:"suspended"` + AccountLocked bool `json:"account_locked"` + PasswordDate string `json:"password_date"` + } + if err := json.Unmarshal(raw, &u); err != nil { + continue + } + if !u.Activated || u.Suspended || u.AccountLocked || u.PasswordDate == "" { + continue + } + t, err := time.Parse(time.RFC3339, u.PasswordDate) + if err != nil { + continue + } + if t.Before(cutoff) { + stale++ + } + } + if stale == 0 { + return nil, nil + } + return []Finding{{ + CheckID: "password-age", + Title: "Users with passwords older than 90 days", + Category: CategoryCompliance, + Severity: SeverityMedium, + ResourceRef: "org", + RemediationHint: "Rotate stale passwords via an org-wide password policy or targeted `jc users reset-password` runs. If your compliance framework has dropped mandatory rotation (NIST SP 800-63B), this check can be filtered out via --severity high.", + Detail: fmt.Sprintf("%d active users have a password_date older than 90 days.", stale), + }}, nil +} + +func checkFDECoverage(_ context.Context, d *Data) ([]Finding, error) { + if d.Devices == nil { + return nil, fmt.Errorf("devices fetch unavailable") + } + var total, encrypted int + for _, raw := range d.Devices { + var dev struct { + OS string `json:"os"` + FDE struct { + Active bool `json:"active"` + } `json:"fde"` + } + if err := json.Unmarshal(raw, &dev); err != nil { + continue + } + // FDE is only meaningful on macOS/Windows. Linux/iOS/etc. + // don't expose a comparable FDE state via this API. + if dev.OS != "Mac OS X" && dev.OS != "Windows" { + continue + } + total++ + if dev.FDE.Active { + encrypted++ + } + } + if total == 0 || encrypted == total { + return nil, nil + } + pct := float64(encrypted) / float64(total) * 100 + sev := fdeCoverageSeverity(pct) + return []Finding{{ + CheckID: "fde-coverage", + Title: "Full-disk encryption coverage below target", + Category: CategoryCompliance, + Severity: sev, + ResourceRef: "org", + RemediationHint: "Push the JumpCloud FDE policy to unencrypted macOS/Windows devices. FileVault/BitLocker keys are escrowed back to JumpCloud for recovery.", + Detail: fmt.Sprintf("%d of %d managed macOS/Windows devices have FDE active (%.1f%%).", encrypted, total, pct), + }}, nil +} + +func fdeCoverageSeverity(pct float64) Severity { + switch { + case pct < 50: + return SeverityCritical + case pct < 90: + return SeverityHigh + default: + return SeverityMedium + } +} + +// ─── hygiene ─────────────────────────────────────────────────────── + +func checkStaleDevices(_ context.Context, d *Data) ([]Finding, error) { + if d.Devices == nil { + return nil, fmt.Errorf("devices fetch unavailable") + } + cutoff := d.Now.Add(-staleDeviceThreshold) + var findings []Finding + for _, raw := range d.Devices { + var dev struct { + ID string `json:"_id"` + Hostname string `json:"hostname"` + DisplayName string `json:"displayName"` + OS string `json:"os"` + LastContact string `json:"lastContact"` + } + if err := json.Unmarshal(raw, &dev); err != nil { + continue + } + if dev.LastContact == "" { + continue + } + t, err := time.Parse(time.RFC3339, dev.LastContact) + if err != nil { + continue + } + if !t.Before(cutoff) { + continue + } + name := dev.Hostname + if name == "" { + name = dev.DisplayName + } + findings = append(findings, Finding{ + CheckID: "stale-devices", + Title: "Device has not checked in for 30+ days", + Category: CategoryHygiene, + Severity: SeverityMedium, + ResourceRef: "device:" + name, + RemediationHint: "Either the device is decommissioned (delete via `jc devices delete`) or the agent has crashed (investigate via `jc devices get`). Stale devices count toward your license while contributing no telemetry.", + Detail: fmt.Sprintf("Device %s (%s) last contact: %s.", name, dev.OS, dev.LastContact), + }) + } + return findings, nil +} + +func checkDisabledAuthPolicies(_ context.Context, d *Data) ([]Finding, error) { + if d.AuthPolicies == nil { + return nil, fmt.Errorf("auth policies fetch unavailable") + } + var findings []Finding + for _, raw := range d.AuthPolicies { + var p struct { + ID string `json:"id"` + Name string `json:"name"` + Disabled bool `json:"disabled"` + } + if err := json.Unmarshal(raw, &p); err != nil { + continue + } + if !p.Disabled { + continue + } + findings = append(findings, Finding{ + CheckID: "auth-policies-disabled", + Title: "Disabled auth policy", + Category: CategoryHygiene, + Severity: SeverityLow, + ResourceRef: "auth-policy:" + p.Name, + RemediationHint: "Either re-enable (`jc auth-policies enable " + p.Name + "`) or delete (`jc auth-policies delete " + p.Name + "`). A disabled policy is dead code — it's silent until a future operator wonders why traffic isn't being gated.", + Detail: "Auth policy " + p.Name + " is disabled.", + }) + } + return findings, nil +} + +// ─── identity ────────────────────────────────────────────────────── + +func checkRecentAdmins(_ context.Context, d *Data) ([]Finding, error) { + if d.Admins == nil { + return nil, fmt.Errorf("admins fetch unavailable") + } + cutoff := d.Now.Add(-recentAdminWindow) + var findings []Finding + for _, raw := range d.Admins { + var a struct { + ID string `json:"_id"` + Email string `json:"email"` + Created string `json:"created"` + } + if err := json.Unmarshal(raw, &a); err != nil { + continue + } + if a.Created == "" { + continue + } + t, err := time.Parse(time.RFC3339, a.Created) + if err != nil { + continue + } + if t.Before(cutoff) { + continue + } + findings = append(findings, Finding{ + CheckID: "recently-created-admins", + Title: "Admin created in the last 14 days", + Category: CategoryIdentity, + Severity: SeverityInfo, + ResourceRef: "admin:" + a.Email, + RemediationHint: "Confirm this admin account was created intentionally — newly-created admin accounts are a common post-compromise persistence mechanism. Cross-check against your IAM tickets / Slack approvals.", + Detail: fmt.Sprintf("Admin %s was created at %s.", a.Email, a.Created), + }) + } + return findings, nil +} diff --git a/internal/audit/data.go b/internal/audit/data.go new file mode 100644 index 0000000..1c8fcb6 --- /dev/null +++ b/internal/audit/data.go @@ -0,0 +1,113 @@ +package audit + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" +) + +// Data is the shared snapshot every check reads from. The fetcher +// populates it once, in parallel, before the run loop starts — so 12 +// checks against the same admin list don't re-fetch admins 12 times. +// +// A nil slice on any field means that sub-fetch failed; checks that +// require that data must guard against it and surface a clear error so +// the result table can distinguish "ran clean" from "couldn't run." +// Failures land in Warnings (non-fatal) — only an all-empty fetch +// causes Fetch to return an error. +type Data struct { + Users []json.RawMessage + Admins []json.RawMessage + Devices []json.RawMessage + UserGroups []json.RawMessage + SystemGroups []json.RawMessage + AuthPolicies []json.RawMessage + IPLists []json.RawMessage + Now time.Time + Warnings []string +} + +// Fetcher abstracts the JumpCloud API surface the audit needs. The CLI +// provides a real client-backed implementation; tests provide a stub. +// Each method is invoked from its own goroutine, so implementations +// must be goroutine-safe (the api.V1Client/V2Client both are). +type Fetcher interface { + Users(ctx context.Context) ([]json.RawMessage, error) + Admins(ctx context.Context) ([]json.RawMessage, error) + Devices(ctx context.Context) ([]json.RawMessage, error) + UserGroups(ctx context.Context) ([]json.RawMessage, error) + SystemGroups(ctx context.Context) ([]json.RawMessage, error) + AuthPolicies(ctx context.Context) ([]json.RawMessage, error) + IPLists(ctx context.Context) ([]json.RawMessage, error) +} + +// nowFn is overridable so tests can pin "now" for deterministic +// age-based checks (stale devices, password age, recently-created +// admins). +var nowFn = time.Now + +// Fetch runs every Fetcher method in parallel and aggregates the +// results into a Data bundle. Same best-effort contract as the MCP +// compliance_view fetcher: a transient sub-fetch failure surfaces as a +// Warning rather than blocking the snapshot. Only if *every* sub-fetch +// fails does Fetch itself return an error — at that point there's +// nothing the audit can usefully report on. +func Fetch(ctx context.Context, f Fetcher) (*Data, error) { + d := &Data{Now: nowFn().UTC()} + + var ( + mu sync.Mutex + warnings []string + ) + warn := func(format string, args ...any) { + mu.Lock() + warnings = append(warnings, fmt.Sprintf(format, args...)) + mu.Unlock() + } + + type fetchJob struct { + name string + run func() ([]json.RawMessage, error) + set func([]json.RawMessage) + } + jobs := []fetchJob{ + {"users", func() ([]json.RawMessage, error) { return f.Users(ctx) }, func(v []json.RawMessage) { d.Users = v }}, + {"admins", func() ([]json.RawMessage, error) { return f.Admins(ctx) }, func(v []json.RawMessage) { d.Admins = v }}, + {"devices", func() ([]json.RawMessage, error) { return f.Devices(ctx) }, func(v []json.RawMessage) { d.Devices = v }}, + {"user groups", func() ([]json.RawMessage, error) { return f.UserGroups(ctx) }, func(v []json.RawMessage) { d.UserGroups = v }}, + {"system groups", func() ([]json.RawMessage, error) { return f.SystemGroups(ctx) }, func(v []json.RawMessage) { d.SystemGroups = v }}, + {"auth policies", func() ([]json.RawMessage, error) { return f.AuthPolicies(ctx) }, func(v []json.RawMessage) { d.AuthPolicies = v }}, + {"ip lists", func() ([]json.RawMessage, error) { return f.IPLists(ctx) }, func(v []json.RawMessage) { d.IPLists = v }}, + } + + var wg sync.WaitGroup + for _, j := range jobs { + wg.Add(1) + go func(j fetchJob) { + defer wg.Done() + rows, err := j.run() + if err != nil { + warn("listing %s: %v", j.name, err) + return + } + mu.Lock() + j.set(rows) + mu.Unlock() + }(j) + } + wg.Wait() + + if len(warnings) > 0 { + d.Warnings = warnings + } + + if d.Users == nil && d.Admins == nil && d.Devices == nil && + d.UserGroups == nil && d.SystemGroups == nil && + d.AuthPolicies == nil && d.IPLists == nil { + return nil, fmt.Errorf("all audit sub-fetches failed: %v", warnings) + } + + return d, nil +} diff --git a/internal/cmd/audit.go b/internal/cmd/audit.go index 1ba2a91..17a862a 100644 --- a/internal/cmd/audit.go +++ b/internal/cmd/audit.go @@ -1,29 +1,398 @@ package cmd import ( + "context" "crypto/ed25519" "encoding/base64" + "encoding/json" "fmt" + "io" "os" "path/filepath" + "strings" "github.com/spf13/cobra" + "github.com/klaassen-consulting/jc/internal/api" + "github.com/klaassen-consulting/jc/internal/audit" "github.com/klaassen-consulting/jc/internal/config" "github.com/klaassen-consulting/jc/internal/mcp" + "github.com/klaassen-consulting/jc/internal/output" ) func newAuditCmd() *cobra.Command { + var ( + categoriesFlag []string + severityFlag string + thresholdFlag string + exitCodeFlag bool + ) + cmd := &cobra.Command{ Use: "audit", - Short: "Inspect and verify MCP audit logs", - Long: "Subcommands for working with MCP audit logs, including signed-manifest verification (KLA-411).", + Short: "Run cross-resource health checks (security, compliance, hygiene, identity)", + Long: `Run a battery of health checks across the configured JumpCloud org and +report any findings, grouped by category and ordered by severity. + +The check registry is composable — adding a new check is a one-line +registration in internal/audit/. The same primitive backs the +jc-security-audit and jc-compliance-check skills (which now invoke +` + "`jc audit --category {security,compliance} --output json`" + ` instead of +scripting raw queries). + +Severities, low → high: info, low, medium, high, critical. + +Default invocation runs every registered check; use --category and +--severity to scope. Pair --exit-code with --threshold for CI gating +(non-zero exit when any finding meets or exceeds the threshold). + +Output formats: json (skill-friendly), human (default, grouped by +category), table, csv, ndjson, yaml.`, + Example: ` jc audit # everything, human-readable + jc audit --category security # security checks only + jc audit --severity high # high/critical findings only + jc audit --output json # machine-readable for skills + jc audit --exit-code --threshold high # CI gate: fail on high+ findings`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAuditHealth(cmd, auditHealthOpts{ + categories: categoriesFlag, + minSeverity: severityFlag, + threshold: thresholdFlag, + exitOnThresh: exitCodeFlag, + }) + }, } + cmd.Flags().StringSliceVar(&categoriesFlag, "category", nil, + "Restrict to one or more categories: security, compliance, hygiene, identity") + cmd.Flags().StringVar(&severityFlag, "severity", "", + "Show only findings at or above this severity (info, low, medium, high, critical)") + cmd.Flags().StringVar(&thresholdFlag, "threshold", "high", + "Severity threshold used by --exit-code") + cmd.Flags().BoolVar(&exitCodeFlag, "exit-code", false, + "Exit with code 1 if any finding meets or exceeds --threshold (for CI gating)") + cmd.AddCommand(newAuditVerifyCmd()) return cmd } +type auditHealthOpts struct { + categories []string + minSeverity string + threshold string + exitOnThresh bool +} + +func runAuditHealth(cmd *cobra.Command, o auditHealthOpts) error { + // Validate severity / threshold up front so we fail before the + // API fetch on a typo — wasted round-trips erode trust in --exit-code + // gates that retry on transient failures. + var minSev audit.Severity + if o.minSeverity != "" { + minSev = audit.Severity(strings.ToLower(o.minSeverity)) + if !minSev.Valid() { + return fmt.Errorf("invalid --severity %q: want one of info, low, medium, high, critical", o.minSeverity) + } + } + threshold := audit.Severity(strings.ToLower(o.threshold)) + if !threshold.Valid() { + return fmt.Errorf("invalid --threshold %q: want one of info, low, medium, high, critical", o.threshold) + } + + var cats []audit.Category + for _, c := range o.categories { + cat := audit.Category(strings.ToLower(c)) + if !cat.Valid() { + return fmt.Errorf("invalid --category %q: want one of security, compliance, hygiene, identity", c) + } + cats = append(cats, cat) + } + + v1, err := newV1ClientForAudit() + if err != nil { + return fmt.Errorf("building v1 client: %w", err) + } + v2, err := newV2ClientForAudit() + if err != nil { + return fmt.Errorf("building v2 client: %w", err) + } + + ctx := cmd.Context() + data, err := audit.Fetch(ctx, &clientFetcher{v1: v1, v2: v2}) + if err != nil { + return fmt.Errorf("fetching audit data: %w", err) + } + + results, err := audit.Run(ctx, data, audit.RunOptions{ + Categories: cats, + MinSeverity: minSev, + }) + if err != nil { + return fmt.Errorf("running audit: %w", err) + } + audit.SortByCategoryAndSeverity(results) + + if err := renderAuditResults(cmd.OutOrStdout(), results, data.Warnings); err != nil { + return fmt.Errorf("rendering: %w", err) + } + + if o.exitOnThresh { + // A check that couldn't run is a degraded result, not a clean + // one — CI gating must treat it as failure or the gate is a lie + // the next time a sub-fetch flakes. Pre-fix, --exit-code only + // considered findings and would green-light a half-completed + // audit (Bugbot PR #47 review). + switch { + case audit.AnyFindingAtLeast(results, threshold): + return &ExitError{ + Code: 1, + Err: fmt.Errorf("audit found findings at or above threshold %q", threshold), + } + case audit.AnyCheckError(results): + return &ExitError{ + Code: 1, + Err: fmt.Errorf("audit completed with check errors — see [ERR] lines above; data may be incomplete"), + } + } + } + return nil +} + +// newV1ClientForAudit / newV2ClientForAudit are package vars so audit +// unit tests can stub clients without going through the full auth +// resolution path. +var newV1ClientForAudit = api.NewV1Client +var newV2ClientForAudit = api.NewV2Client + +// clientFetcher adapts the v1/v2 API clients to the audit.Fetcher +// interface. Lives in cmd/ (rather than audit/) so the audit package +// stays unaware of the api/ client surface — easier to mock and easier +// to retarget at a different transport (MCP, in-memory) later. +type clientFetcher struct { + v1 *api.V1Client + v2 *api.V2Client +} + +func (f *clientFetcher) Users(ctx context.Context) ([]json.RawMessage, error) { + r, err := f.v1.ListAll(ctx, "/systemusers", api.ListOptions{}) + if err != nil { + return nil, err + } + return r.Data, nil +} + +func (f *clientFetcher) Admins(ctx context.Context) ([]json.RawMessage, error) { + // V1 /users serves admins (not /administrators which doesn't exist). + // See internal/cmd/admins.go and the compliance MCP App for prior art. + r, err := f.v1.ListAll(ctx, "/users", api.ListOptions{}) + if err != nil { + return nil, err + } + return r.Data, nil +} + +func (f *clientFetcher) Devices(ctx context.Context) ([]json.RawMessage, error) { + r, err := f.v1.ListAll(ctx, "/systems", api.ListOptions{}) + if err != nil { + return nil, err + } + return r.Data, nil +} + +func (f *clientFetcher) UserGroups(ctx context.Context) ([]json.RawMessage, error) { + r, err := f.v2.ListAll(ctx, "/usergroups", api.V2ListOptions{}) + if err != nil { + return nil, err + } + return r.Data, nil +} + +func (f *clientFetcher) SystemGroups(ctx context.Context) ([]json.RawMessage, error) { + r, err := f.v2.ListAll(ctx, "/systemgroups", api.V2ListOptions{}) + if err != nil { + return nil, err + } + return r.Data, nil +} + +func (f *clientFetcher) AuthPolicies(ctx context.Context) ([]json.RawMessage, error) { + r, err := f.v2.ListAll(ctx, "/authn/policies", api.V2ListOptions{}) + if err != nil { + return nil, err + } + return r.Data, nil +} + +func (f *clientFetcher) IPLists(ctx context.Context) ([]json.RawMessage, error) { + r, err := f.v2.ListAll(ctx, "/iplists", api.V2ListOptions{}) + if err != nil { + return nil, err + } + return r.Data, nil +} + +// renderAuditResults dispatches on --output. JSON, table, csv, and +// ndjson re-use the shared output package by marshalling the findings +// slice to json.RawMessage rows. Human format is hand-rolled for the +// grouped-by-category layout — output.WriteList's default human format +// is a flat key:value dump that's hard to scan for an audit report. +func renderAuditResults(w io.Writer, results []audit.CheckResult, warnings []string) error { + opts := output.CurrentOptions() + switch opts.Format { + case output.FormatJSON: + return writeAuditJSON(w, results, warnings) + case output.FormatNDJSON: + return writeAuditNDJSON(w, results) + case output.FormatTable, output.FormatCSV, output.FormatYAML: + return writeAuditTabular(w, results, opts) + default: + return writeAuditHuman(w, results, warnings) + } +} + +// auditJSONPayload is the JSON shape callers should depend on. It's +// intentionally a wrapper object (not a bare array of findings) so +// future additions (run metadata, timings, warnings) extend the shape +// without breaking existing consumers. +type auditJSONPayload struct { + Results []audit.CheckResult `json:"results"` + Findings []audit.Finding `json:"findings"` + Warnings []string `json:"warnings,omitempty"` +} + +func writeAuditJSON(w io.Writer, results []audit.CheckResult, warnings []string) error { + payload := auditJSONPayload{ + Results: results, + Findings: flattenFindings(results), + Warnings: warnings, + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(payload) +} + +func writeAuditNDJSON(w io.Writer, results []audit.CheckResult) error { + enc := json.NewEncoder(w) + for _, f := range flattenFindings(results) { + if err := enc.Encode(f); err != nil { + return err + } + } + return nil +} + +func writeAuditTabular(w io.Writer, results []audit.CheckResult, opts output.Options) error { + rows := make([]json.RawMessage, 0) + for _, f := range flattenFindings(results) { + b, err := json.Marshal(f) + if err != nil { + return err + } + rows = append(rows, b) + } + opts.DefaultFields = []string{"severity", "category", "check_id", "resource_ref", "title"} + return output.WriteList(w, rows, opts) +} + +func flattenFindings(results []audit.CheckResult) []audit.Finding { + var all []audit.Finding + for _, r := range results { + all = append(all, r.Findings...) + } + return all +} + +// severityGlyph is the single-char tag used in human output. Chosen to +// be ASCII so the report copy-pastes cleanly into runbooks and Slack; +// color (when stdout is a tty) carries the visual weight. +func severityGlyph(s audit.Severity) string { + switch s { + case audit.SeverityCritical: + return "X" + case audit.SeverityHigh: + return "!" + case audit.SeverityMedium: + return "*" + case audit.SeverityLow: + return "." + case audit.SeverityInfo: + return "i" + default: + return "?" + } +} + +func writeAuditHuman(w io.Writer, results []audit.CheckResult, warnings []string) error { + totalFindings, totalErrors := 0, 0 + for _, r := range results { + totalFindings += len(r.Findings) + if r.Error != "" { + totalErrors++ + } + } + + // Clean run = zero findings AND zero check errors. Pre-fix this + // only checked totalFindings, so a check that failed to run with + // no other findings looked like "OK — checks ran clean" (Bugbot + // PR #47 review). + if totalFindings == 0 && totalErrors == 0 { + fmt.Fprintf(w, "OK — %d checks ran clean, no findings.\n", len(results)) + writeAuditWarnings(w, warnings) + return nil + } + + // Group results by category for display. Sort was already applied + // by SortByCategoryAndSeverity, so just walk in order. + var currentCat audit.Category + for _, r := range results { + if r.Category != currentCat { + currentCat = r.Category + fmt.Fprintf(w, "\n== %s ==\n", strings.ToUpper(string(r.Category))) + } + if r.Error != "" { + fmt.Fprintf(w, " [ERR] %s — %s\n", r.CheckID, r.Error) + continue + } + if len(r.Findings) == 0 { + continue + } + fmt.Fprintf(w, " %s — %s (%d)\n", r.CheckID, r.Title, len(r.Findings)) + for _, f := range r.Findings { + fmt.Fprintf(w, " %s [%s] %s\n", severityGlyph(f.Severity), strings.ToUpper(string(f.Severity)), f.Detail) + if f.RemediationHint != "" { + fmt.Fprintf(w, " → %s\n", f.RemediationHint) + } + } + } + + summary := fmt.Sprintf("\n%d findings across %d checks", totalFindings, len(results)) + if totalErrors > 0 { + summary += fmt.Sprintf(" (%d check error%s)", totalErrors, plural(totalErrors)) + } + fmt.Fprintln(w, summary+".") + writeAuditWarnings(w, warnings) + return nil +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func writeAuditWarnings(w io.Writer, warnings []string) { + if len(warnings) == 0 { + return + } + fmt.Fprintln(w, "\nWarnings (partial data — these sub-fetches failed):") + for _, msg := range warnings { + fmt.Fprintf(w, " - %s\n", msg) + } +} + +// ─── existing: audit verify ──────────────────────────────────────── + func newAuditVerifyCmd() *cobra.Command { var profileFlag, logPath, pubkeyOverride string diff --git a/internal/schema/schema.go b/internal/schema/schema.go index ba3f756..f5d85c6 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -721,6 +721,28 @@ func BuildCommandManifest() CommandManifest { Long: "Manage JumpCloud credentials and switch between organizations. The CLI supports two credential types: a static API key (read from `JC_API_KEY`, the config, or the system keychain), or an OAuth service account (client ID + secret), with automatic token refresh. Credentials can be stored as plaintext, in macOS Keychain / GNOME libsecret / Windows Credential Manager via a `keychain://` reference, or supplied per-invocation with `--api-key`. Multiple named profiles let MSPs and admins flip between orgs without re-authenticating (`jc auth switch `), and `jc auth status` reveals the active profile, credential type, fingerprint, and source.", Subcommands: []string{"login", "logout", "status", "switch"}, }, + { + Path: "jc audit", + Description: "Run cross-resource health checks (security, compliance, hygiene, identity)", + Long: "A composable check registry that audits the entire org in one pass — admins without MFA, MFA adoption rate, FDE coverage, stale devices, disabled auth policies, suspicious admin lifecycle events, and more. Each finding is severity-tagged (info → critical), tagged with a `resource_ref` for downstream grouping, and ships with a `remediation_hint` that names the exact `jc` command to fix it. Use `--category security|compliance|hygiene|identity` to scope, `--severity high` to filter to actionable findings, and `--exit-code --threshold high` to gate CI pipelines. The same primitive powers the `jc-security-audit` and `jc-compliance-check` skills (which now interpret structured findings rather than scripting raw queries). Adding a new check is one Register call in `internal/audit/checks.go` — the registry, CLI surface, JSON shape, and skill prompts all update automatically.", + Subcommands: []string{"verify"}, + Flags: []FlagEntry{ + {Name: "category", Type: "string[]", Description: "Restrict to one or more categories: security, compliance, hygiene, identity"}, + {Name: "severity", Type: "string", Description: "Show only findings at or above this severity (info, low, medium, high, critical)"}, + {Name: "threshold", Type: "string", Default: "high", Description: "Severity threshold used by --exit-code"}, + {Name: "exit-code", Type: "bool", Description: "Exit with code 1 if any finding meets or exceeds --threshold (for CI gating)"}, + }, + }, + { + Path: "jc doctor", + Description: "No-auth diagnostic — env, config, auth resolution, API connectivity", + Long: "A pre-flight check for any environment where `jc` is about to run. Reports the active profile, credential source (flag / env / keychain / config), fingerprint of the resolved key, config file location, and runs a single read-only probe against the JumpCloud API to confirm the credentials actually authenticate. Works without auth (skips the API probe gracefully) so it's safe to run in a Dockerfile build step or fresh-clone sanity check. Useful as the first command in a runbook or on-call playbook — when something's wrong with `jc`, this is the fastest path to the cause.", + Subcommands: []string{}, + Flags: []FlagEntry{ + {Name: "probe-timeout", Type: "duration", Default: "5s", Description: "Timeout for the live API probe"}, + {Name: "skip-probe", Type: "bool", Description: "Skip the live API probe (no network)"}, + }, + }, { Path: "jc config", Description: "Configuration management", diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index 2649428..36537e8 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -226,6 +226,10 @@ func TestBuildCommandManifest_FlagTypes(t *testing.T) { validTypes := map[string]bool{ "string": true, "bool": true, "int": true, "string[]": true, + // duration: time.Duration flags (e.g. --probe-timeout 5s). Distinct + // from string so LLM consumers know to format as a Go duration + // literal rather than guessing units. + "duration": true, } for _, f := range m.GlobalFlags { diff --git a/skills/jc-compliance-check/SKILL.md b/skills/jc-compliance-check/SKILL.md index a8c7666..90117d3 100644 --- a/skills/jc-compliance-check/SKILL.md +++ b/skills/jc-compliance-check/SKILL.md @@ -1,158 +1,115 @@ --- name: jc-compliance-check -description: Run a JumpCloud compliance check — MFA enforcement, device management, policy coverage, password policy, admin security using the jc CLI +description: Run a JumpCloud compliance check — MFA adoption rate, admin MFA coverage, FDE coverage, password age, plus password-policy + admin-account inspection, using the jc CLI --- # JumpCloud Compliance Check -Run a structured compliance check across a JumpCloud organization and produce a pass/warn/fail report. +Produce a structured compliance report against the configured +JumpCloud org. The numeric compliance ratios (MFA, FDE, password age, +admin MFA coverage) come from `jc audit --category compliance`; the +config-state checks (password policy, admin count) come from direct +`jc org settings` / `jc admins list` inspection. ## Prerequisites - `jc` installed and authenticated (`jc auth status`) +- Org-admin role for `jc org settings` -## Compliance Checks - -### Check 1: MFA Enforcement - -**Goal:** All active users should have MFA enabled. +## Step 1 — Run the built-in compliance audit ```bash -# Total active users -jc users list --filter "activated:eq:true" --query "length(@)" - -# Active users with MFA -jc users list --filter "activated:eq:true" --query "length([?totp_enabled==\`true\`])" - -# Users missing MFA (the actionable list) -jc users list --filter "activated:eq:true" --query "[?totp_enabled==\`false\`].{username:username,email:email}" -t +jc audit --category compliance --output json ``` -| Threshold | Result | -|-----------|--------| -| 100% MFA | PASS | -| 80-99% MFA | WARN | -| < 80% MFA | FAIL | +Returns structured findings for: -### Check 2: Device Management +- **mfa-adoption-rate** — % of active users with MFA. Severity scales: + <50% CRITICAL, <80% HIGH, <95% MEDIUM, ≥95% silent (no finding). +- **admin-mfa-coverage** — % of admins with MFA. CRITICAL if <100%. +- **fde-coverage** — % of managed macOS/Windows devices with full-disk + encryption active. <50% CRITICAL, <90% HIGH, otherwise MEDIUM. +- **password-age** — count of active users with `password_date` older + than 90 days. Single MEDIUM finding (drop with `--severity high` if + your framework has moved off mandatory rotation per NIST SP 800-63B). -**Goal:** All devices should be actively managed (contacted recently). +Each finding carries a `detail` line you can render directly (it +includes the percentages and counts). -```bash -# All devices -jc devices list --query "length(@)" - -# Devices by OS -jc devices list --query "[].os" | sort | uniq -c | sort -rn - -# Devices not contacted in 30+ days (likely unmanaged) -jc devices list --all --query "[?lastContact < '$(date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)'].{hostname:hostname,os:os,lastContact:lastContact}" -t -``` +## Step 2 — Inspect password policy -| Threshold | Result | -|-----------|--------| -| All contacted within 30 days | PASS | -| > 90% contacted within 30 days | WARN | -| < 90% contacted within 30 days | FAIL | - -### Check 3: Policy Coverage - -**Goal:** Key security policies should be applied to all devices. +`jc audit` doesn't yet introspect the org-wide password policy; do +this directly. ```bash -jc policies list -t -``` - -For each critical policy (FileVault, screen lock, firewall, etc.): +# Get the active org ID (or pass via JC_ORG_ID env var) +jc org list --ids -```bash -jc policies results POLICY_NAME --query "[?status=='failed' || status=='pending'].{system:systemID,status:status}" -t +# Inspect the policy +jc org settings $ORG_ID --query "passwordPolicy" ``` -| Threshold | Result | -|-----------|--------| -| All devices applied | PASS | -| Pending but no failures | WARN | -| Any failures | FAIL | +Check (PASS/WARN/FAIL per compliance framework you're targeting): -### Check 4: Password Policy +- Minimum length ≥ 12 (PASS), ≥ 8 (WARN), < 8 (FAIL) +- Complexity requirements enabled +- Account lockout threshold configured +- Password history depth ≥ 5 -**Goal:** Organization has a strong password policy configured. +## Step 3 — Admin account inventory -First, get the org ID: ```bash -jc org list --ids +jc admins list --output json ``` -Then check password policy: -```bash -jc org settings THE_ORG_ID --query "passwordPolicy" -``` +Beyond the MFA coverage `jc audit` already reports: -Check for: -- Minimum length >= 12 -- Complexity requirements enabled -- Account lockout configured +- **Count** — fewer admins = smaller blast radius. Heuristic: <5 for + small orgs, scale with org size, no hard rule. +- **Roles** — verify the spread of `roleName` matches your access + model (e.g. one "Administrator" + several "Manager" / "Read Only"). +- **Stale logins** — admins whose `lastLogin` is >90 days old; consider + rotating or deprovisioning. -### Check 5: Admin Account Security +## Step 4 — Report -**Goal:** Minimal admin accounts, all with MFA. +Produce a layered PASS/WARN/FAIL table. -```bash -jc admins list -t +``` +JumpCloud Compliance Report — ORG — DATE + +| # | Check | Status | Detail | +|---|----------------------|--------|-----------------------------------------| +| 1 | MFA adoption | WARN | 87 of 100 active users (87%) — target 95%+ | +| 2 | Admin MFA coverage | PASS | 4 of 4 admins have MFA | +| 3 | FDE coverage | FAIL | 3 of 10 macOS/Windows devices (30%) | +| 4 | Password age (>90d) | WARN | 12 active users overdue | +| 5 | Password policy | PASS | Min 14 chars, complexity, lockout | +| 6 | Admin inventory | PASS | 4 admins, 0 stale logins | + +Overall: 3 PASS, 2 WARN, 1 FAIL ``` -Check: -- Number of admins (fewer than 5 for small orgs, proportional for larger) -- All admins should have `enableMultiFactor: true` -- No inactive/unused admin accounts - -| Threshold | Result | -|-----------|--------| -| All admins have MFA, reasonable count | PASS | -| Some admins missing MFA | WARN | -| Many admins, no MFA enforcement | FAIL | - -### Check 6: Conditional Access Policies - -**Goal:** At least one conditional access policy is active. +For each WARN or FAIL: cite the remediation hint from the `jc audit` +finding (or a direct `jc` command for the config-state checks). -```bash -jc auth-policies list -t -``` +## CI gating -Check: -- At least one policy exists and is enabled -- Policies cover MFA requirements for sensitive access -- IP-based restrictions are in place if applicable +For a deterministic compliance gate (e.g. quarterly cron, GitHub Action): ```bash -jc iplists list -t +jc audit --category compliance --exit-code --threshold high ``` -| Threshold | Result | -|-----------|--------| -| Active policies with MFA/IP restrictions | PASS | -| Policies exist but some disabled | WARN | -| No conditional access policies | FAIL | - -## Report Format +Non-zero exit when any compliance finding is at or above HIGH — +suitable for blocking a release pipeline or paging on-call. -Produce a summary table: - -``` -JumpCloud Compliance Report — ORG_NAME — DATE - -| # | Check | Status | Detail | -|---|--------------------------|--------|----------------------------------| -| 1 | MFA Enforcement | PASS | 100% of 150 active users | -| 2 | Device Management | WARN | 3 of 200 devices stale (>30 days)| -| 3 | Policy Coverage | PASS | FileVault 100%, Firewall 100% | -| 4 | Password Policy | PASS | Min 12 chars, complexity enabled | -| 5 | Admin Security | WARN | 4 admins, 1 missing MFA | -| 6 | Conditional Access | PASS | 3 policies active, MFA required | - -Overall: 4 PASS, 2 WARN, 0 FAIL -``` +## Why this layout -For each WARN or FAIL, include a specific recommendation with the jc command to fix it. +Pre-2026.06 this skill scripted ~6 inline `jc users list` / +`jc devices list` queries with bash-side filtering. `jc audit` +consolidates the ratio math into structured findings with consistent +severity language, so the skill now interprets rather than re-derives. +The config-state checks (password policy, admin inventory) remain +inline because they're org-settings introspection, not cross-resource +aggregation. diff --git a/skills/jc-security-audit/SKILL.md b/skills/jc-security-audit/SKILL.md index 9e8f9b6..c699aa9 100644 --- a/skills/jc-security-audit/SKILL.md +++ b/skills/jc-security-audit/SKILL.md @@ -1,116 +1,97 @@ --- name: jc-security-audit -description: Run a JumpCloud security audit — check MFA adoption, find inactive users, review auth failures, audit admins using the jc CLI +description: Run a JumpCloud security audit — admins without MFA, users without MFA, suspended-not-locked accounts, IP list hygiene, plus auth-failure trends from Directory Insights, using the jc CLI --- # JumpCloud Security Audit -Run a comprehensive security audit across a JumpCloud organization. +Run a focused security audit across a JumpCloud organization. The bulk +of the checks ride on `jc audit` (the built-in cross-resource audit +registry); we layer Directory Insights queries on top for the runtime +attack signal (failed authentications) that `jc audit` doesn't model. ## Prerequisites - `jc` installed and authenticated (`jc auth status`) -- Insights access requires appropriate permissions +- Insights access requires Directory Insights enabled on the org -## Audit Steps - -### 1. MFA Adoption - -Find users without MFA enabled: +## Step 1 — Run the built-in security audit ```bash -jc users list --query "[?totp_enabled==\`false\`].{username:username,email:email,activated:activated}" -t +jc audit --category security --category identity --output json ``` -Count total vs MFA-enabled: -```bash -jc users list --query "length([?totp_enabled==\`true\`])" -jc users list --query "length(@)" -``` +This returns structured findings: each one has `check_id`, `severity` +(info/low/medium/high/critical), `resource_ref` (e.g. `admin:foo@bar`), +`remediation_hint`, and `detail`. Group by `severity` and surface the +critical ones first. -Report the MFA adoption percentage. Flag any activated users without MFA as a risk. +Categories included: -### 2. Suspended and Locked Accounts - -```bash -jc users list --filter "suspended:eq:true" -t -jc users list --filter "account_locked:eq:true" -t -``` +- **security** — admins without MFA, active users without MFA, + suspended-but-not-locked users, IP lists with no entries +- **identity** — admins created in the last 14 days (sanity check for + post-compromise persistence) -Review whether these accounts should be deleted or are appropriately locked. +## Step 2 — Layer in auth-failure signal (Directory Insights) -### 3. Failed Authentication Events (Last 24h) +`jc audit` is a control-state audit; it doesn't see live traffic. +Combine with Insights to spot active attacks: ```bash -jc insights query --service sso --last 24h --event-type sso_auth_failed -t -``` - -Look for: -- Repeated failures from the same user (possible brute force) -- Failures from unusual IPs -- Failures at unusual times +# Failed SSO authentications in the last 24 hours +jc insights query --service sso --last 24h --event-type sso_auth_failed -o json -### 4. All Auth Events (Last 7 Days) - -```bash -jc insights count --service all --last 7d -jc insights query --service all --last 7d --limit 20 -t +# Volume comparison: total auth events vs failures +jc insights count --service all --last 24h +jc insights count --service sso --last 24h --event-type sso_auth_failed ``` -### 5. Admin Accounts +Flag: +- Repeated failures from the same `initiated_by.email` → possible brute force +- Failures from unfamiliar `client_ip` ranges → possible credential theft +- Failure clusters at unusual times (off-hours, weekends) -```bash -jc admins list -t -``` +## Step 3 — Report -Check: -- Number of admin accounts (fewer is better) -- Whether all admins have MFA enabled -- Any inactive or unnecessary admin accounts +Produce a layered report: -### 6. Auth Policy Coverage +1. **Findings from `jc audit`** — table with `severity | check_id | + resource_ref | detail | remediation_hint`. Order CRITICAL → HIGH → + MEDIUM → LOW → INFO. +2. **Auth-failure signal** — counts, top offending IPs, top targeted + users from the Insights data. +3. **Recommended actions** — for each finding, the exact `jc` command + from `remediation_hint`. -```bash -jc auth-policies list -t -``` - -Review: -- Are there conditional access policies in place? -- Are any disabled that should be enabled? -- Do policies require MFA for sensitive operations? +### Example report shape -### 7. Device Compliance - -```bash -jc policies list -t ``` - -For key policies (e.g., FileVault, screen lock): -```bash -jc policies results POLICY_NAME -t +JumpCloud Security Audit — ORG — DATE + +Control-state findings (jc audit): +| Severity | Check | Resource | Action | +|----------|------------------------|--------------------------|--------| +| CRITICAL | admins-without-mfa | admin:alice@acme.com | Enable MFA in Admin Portal | +| HIGH | users-without-mfa | user:bob | jc auth-policies create … | +| MEDIUM | suspended-not-locked | user:carol | jc users lock carol | +| LOW | iplists-empty | iplist:OfficeNAT | jc iplists update OfficeNAT --ips … | + +Runtime signal (Directory Insights, last 24h): +- Total auth events: 1,247 +- Failed SSO auths: 18 (1.4%) +- Top offending IP: 185.220.101.42 (12 failures, all targeting alice@acme.com) +- Recommendation: jc auth-policies create — block this IP range ``` -Check for devices with `failed` or `pending` status. - -### 8. IP Lists - -```bash -jc iplists list -t -``` - -Verify that office/VPN IP ranges are configured for auth policies. - -## Report - -Summarize findings as: +## Why this layout -| Area | Status | Finding | -|------|--------|---------| -| MFA Adoption | PASS/WARN/FAIL | X% of users have MFA enabled | -| Inactive Accounts | PASS/WARN | X suspended, Y locked | -| Auth Failures (24h) | PASS/WARN/FAIL | X failed attempts | -| Admin Accounts | PASS/WARN | X admins, MFA status | -| Auth Policies | PASS/WARN | X policies active | -| Device Compliance | PASS/WARN/FAIL | X devices non-compliant | +The pre-2026.06 version of this skill scripted ~10 individual `jc` +queries inline. `jc audit` consolidates 8 of those into a single +structured fetch with severity tagging, so the skill now: -Include specific recommendations for any WARN or FAIL items. +- Spends most of its token budget on **interpretation** rather than + re-deriving findings from raw data +- Gets new checks automatically when the audit registry grows +- Produces consistent severity language with `jc-compliance-check` and + CI gates (`jc audit --exit-code --threshold high`)