diff --git a/.claude/MCP_SERVERS.md b/.claude/MCP_SERVERS.md new file mode 100644 index 0000000..cac93ee --- /dev/null +++ b/.claude/MCP_SERVERS.md @@ -0,0 +1,109 @@ +# MCP Servers — Configuration Reference + +MCP (Model Context Protocol) servers extend Claude Code with external tool +capabilities. Each server runs as a child process and exposes tools that +Claude can call. + +**Config file:** `.mcp.json` in repo root (shared across machines via git). + +--- + +## Active Servers + +### TickTick + +Task management integration for TickTick (todo/project tracking app). + +**Tools provided:** +- `ticktick_create_task`, `ticktick_update_task`, `ticktick_complete_task`, `ticktick_delete_task` +- `ticktick_create_project`, `ticktick_update_project`, `ticktick_delete_project` +- `ticktick_list_projects`, `ticktick_get_project` + +**Auth:** OAuth token stored in vault at `services/ticktick.sops.yaml`. Token file +auto-generated by `mcp-servers/ticktick/ticktick_auth.py` on first use. + +**Config in `.mcp.json`:** +```json +{ + "mcpServers": { + "ticktick": { + "command": "python", + "args": ["D:\\claudetools\\mcp-servers\\ticktick\\ticktick_mcp.py"] + } + } +} +``` + +### Claude-in-Chrome (browser automation) + +Installed as a Chrome browser extension. Provides browser automation tools +for web interaction, form filling, page reading, screenshots, GIF recording. + +**Not configured in `.mcp.json`** — runs as a Chrome extension that connects +automatically when the Claude Code extension is active and Chrome is open. + +**Tools provided:** `tabs_context_mcp`, `tabs_create_mcp`, `navigate`, `computer` +(click/type/screenshot), `read_page`, `find`, `form_input`, `javascript_tool`, +`get_page_text`, `read_console_messages`, `gif_creator`, etc. + +**Requires:** Chrome browser with the Claude-in-Chrome extension installed. + +--- + +## Available but Not Wired + +These server directories exist but aren't in `.mcp.json`. Add them when needed. + +### GrepAI MCP Server + +Semantic code search over the indexed codebase. Alternative to using the +`grepai search` CLI directly. + +**To activate:** Add to `.mcp.json`: +```json +{ + "grepai": { + "command": "D:\\claudetools\\grepai.exe", + "args": ["mcp-serve"] + } +} +``` + +**Requires:** GrepAI initialized (`grepai init`) + Ollama running with +`nomic-embed-text` model. Index builds automatically via `grepai watch`. + +### Ollama Assistant + +Local LLM integration for delegating simple tasks (summarization, +classification, drafting) to locally-running models. + +**Location:** `mcp-servers/ollama-assistant/` + +**To activate:** Check the server's README for the exact `.mcp.json` entry. +Requires Ollama running at `http://localhost:11434` with models pulled. + +### Feature Management + +Feature flag management server. + +**Location:** `mcp-servers/feature-management/` + +**Status:** Exists but purpose unclear. Check directory for README. + +--- + +## Adding a New MCP Server + +1. Create directory: `mcp-servers//` +2. Write the server script (Python or Node recommended) +3. Add entry to `.mcp.json` with `command` and `args` +4. Restart Claude Code to pick up the new server +5. Document in this file + +**Important:** `.mcp.json` is tracked in git. Changes sync to all machines. +Machine-specific server paths should use absolute paths that work on all +team workstations (or use relative paths from repo root). + +--- + +*Last updated: 2026-04-16* diff --git a/.claude/commands/remediation-tool.md b/.claude/commands/remediation-tool.md new file mode 100644 index 0000000..0d743db --- /dev/null +++ b/.claude/commands/remediation-tool.md @@ -0,0 +1,118 @@ +--- +description: M365 tenant investigation + remediation via the Claude-MSP-Access Graph API app. Breach checks, tenant sweeps, consent URLs, and gated remediation actions. +--- + +# /remediation-tool + +M365 investigation and remediation using the **Claude-MSP-Access Graph API** multi-tenant app (App ID `fabb3421-8b34-484b-bc17-e46de9703418`, display name in customer tenants: **"ComputerGuru - AI Remediation"**). + +**Default posture: READ-ONLY.** Remediation actions require explicit `YES` confirmation in chat. + +--- + +## Subcommands + +| Form | What it does | +|---|---| +| `/remediation-tool check ` | 10-point breach check on a single user | +| `/remediation-tool sweep ` | Tenant-wide signals (sign-ins, audits, risky users, guests) | +| `/remediation-tool signins [--user upn] [--failed-only] [--days N]` | Ad-hoc sign-in query | +| `/remediation-tool consent-url ` | Emit admin consent URL for a tenant | +| `/remediation-tool remediate ` | **GATED:** password-reset, revoke-sessions, disable-forwarding, remove-inbox-rules, disable-account | + +`` accepts a tenant domain (`cascadestucson.com`), a UPN (`user@domain.com`), or a tenant GUID. + +--- + +## Workflow Claude should follow + +### 0. Parse invocation + +- Extract subcommand, target, and any flags from `$ARGUMENTS`. +- Normalize: UPN -> domain (split on `@`), domain -> look up tenant-id. +- If the target is ambiguous or missing, ask the user once and proceed. + +### 1. Resolve tenant ID + +Run `bash .claude/skills/remediation-tool/scripts/resolve-tenant.sh ` — returns tenant GUID via OpenID discovery. If it fails, the domain is not in Entra ID; surface the error and stop. + +### 2. Acquire tokens (cached) + +Run `bash .claude/skills/remediation-tool/scripts/get-token.sh graph` and `... exchange` as needed. Tokens cache at `/tmp/remediation-tool/{tenant}/{scope}.jwt` with 55-minute TTL. The script pulls the app secret from the SOPS vault (`msp-tools/claude-msp-access-graph-api.sops.yaml`, field `credentials.credential`). + +If either token returns 403/401 on first use, check `.claude/skills/remediation-tool/references/gotchas.md` for the per-tenant prerequisites (directory roles, admin consent) and emit the remediation link to the user. + +### 3. Run the requested checks + +- **`check `** -> `bash scripts/user-breach-check.sh `. Script runs all 10 checks in parallel where possible and dumps raw JSON to `/tmp/remediation-tool/{tenant}/user-breach//`. Claude then interprets findings against the rubric in `references/checklist.md` and writes a report. + +- **`sweep `** -> `bash scripts/tenant-sweep.sh `. Script pulls tenant-wide failed sign-ins (30d), successful non-US sign-ins, directory audits filtered for consent/auth-method/service-principal changes, risky users (if permission granted), B2B guest invites, user location profile. Claude summarizes priority findings. + +- **`signins`** — build ad-hoc `curl` against Graph `/auditLogs/signIns` with the requested filter. + +- **`consent-url `** — emit `https://login.microsoftonline.com/{tenant-id}/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient` plus the note that the nativeclient landing page "looks like an error, that's normal." + +- **`remediate`** — see Remediation section below. + +### 4. Write the report + +Location: `clients/{client-slug}/reports/YYYY-MM-DD-{action}.md` (UTC date). Derive the client slug from the domain: +- `cascadestucson.com` -> `cascades-tucson` +- `foobarwidgets.com` -> `foobar-widgets` +- Use existing `clients//` directory if present; if no matching client dir exists, ask the user for the slug before creating one. + +Use `templates/breach-report.md` as the skeleton. For single-user checks, fill in per-check findings using raw JSON in `/tmp/remediation-tool/{tenant}/user-breach//`. + +### 5. Summarize to the user + +Short chat summary: top findings, blocked checks (with remediation links), next actions. Save raw JSON artifacts paths in the report for later re-analysis. + +### 6. Auto-commit + +After writing the report, delegate to the **Gitea Agent** to commit with a message like `Remediation report: for `. Do not push unless the user asks. + +--- + +## Remediation (gated) + +When the user runs `/remediation-tool remediate `: + +1. **Confirm read-only context first**: the skill must have recently run a `check ` in this session (check `/tmp/remediation-tool/{tenant}/user-breach//` exists). If not, tell the user to run the check first. +2. **Display the exact action** that will run (curl command, cmdlet name, parameters). +3. **Require explicit `YES` in chat** — not approval via permission prompt. If the user types anything else, abort. +4. Execute via Graph/Exchange REST. Capture response to a remediation log at `/tmp/remediation-tool/{tenant}/remediation/-YYYY-MM-DDTHHMMSS.json`. +5. Update the user's report with a `## Remediation Actions` section appending what was done and the result. + +Allowed `` values: + +| Action | API | Result | +|---|---|---| +| `password-reset` | Graph `PATCH /users/{upn}` with new `passwordProfile` | Forces sign-in; revokes refresh tokens | +| `revoke-sessions` | Graph `POST /users/{upn}/revokeSignInSessions` | Kills all active sessions | +| `disable-forwarding` | Exchange REST `Set-Mailbox -ForwardingAddress $null -ForwardingSmtpAddress $null -DeliverToMailboxAndForward $false` | Clears all forwarding | +| `remove-inbox-rules` | Exchange REST `Remove-InboxRule` for each non-default rule | Asks which to keep first | +| `disable-account` | Graph `PATCH /users/{upn}` with `accountEnabled: false` | Hard disable | + +--- + +## Arguments + +`$ARGUMENTS` — the full invocation text. Parse freely; common forms: + +- `check john.trozzi@cascadestucson.com` +- `sweep cascadestucson.com` +- `signins cascadestucson.com --user megan.hiatt@cascadestucson.com --failed-only --days 30` +- `consent-url cascadestucson.com` +- `remediate megan.hiatt@cascadestucson.com password-reset` + +If the user's phrasing is loose ("check john's box at cascades", "who's being attacked"), infer intent from CONTEXT.md and session logs. Prefer asking one clarifying question to guessing. + +--- + +## Scope and references + +- Detailed check rubric: `.claude/skills/remediation-tool/references/checklist.md` +- Permission/role gotchas + consent URLs: `.claude/skills/remediation-tool/references/gotchas.md` +- Endpoint cheatsheet: `.claude/skills/remediation-tool/references/graph-endpoints.md` +- Report template: `.claude/skills/remediation-tool/templates/breach-report.md` +- Memory note on what the tool IS: `.claude/memory/feedback_365_remediation_tool.md` diff --git a/.claude/memory/feedback_365_remediation_tool.md b/.claude/memory/feedback_365_remediation_tool.md index a697198..8b8e6cb 100644 --- a/.claude/memory/feedback_365_remediation_tool.md +++ b/.claude/memory/feedback_365_remediation_tool.md @@ -10,6 +10,8 @@ When user says "365 remediation tool" or "remediation tool", they ALWAYS mean th **How to apply:** Authenticate directly via Graph API using the app's client secret from SOPS vault (`msp-tools/claude-msp-access-graph-api.sops.yaml`), get tenant ID from OpenID discovery for the target domain, and query Graph API endpoints directly. No browser/UI needed. +**Preferred invocation: use the `/remediation-tool` skill** (`.claude/skills/remediation-tool/`, also surfaces as a `/remediation-tool` command). It wraps tenant resolution, token caching, the 10-point user breach check, and tenant-wide sweep. Remediation actions are gated behind explicit `YES` confirmation. Reference docs at `references/gotchas.md`, `references/graph-endpoints.md`, `references/checklist.md`. + ### Directory Role Requirements (discovered 2026-04-01) Graph API permissions alone are NOT sufficient for privileged operations. The service principal also needs Entra directory roles assigned per-tenant: diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0caa74f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "defaultMode": "bypassPermissions" + }, + "preferences": { + "autoCompact": true, + "verbose": false + } +} diff --git a/.claude/skills/remediation-tool/SKILL.md b/.claude/skills/remediation-tool/SKILL.md new file mode 100644 index 0000000..cb8dd6d --- /dev/null +++ b/.claude/skills/remediation-tool/SKILL.md @@ -0,0 +1,46 @@ +--- +name: remediation-tool +description: | + M365 tenant investigation and remediation using the Claude-MSP-Access Graph API app (App ID fabb3421-8b34-484b-bc17-e46de9703418, known as "ComputerGuru - AI Remediation" in customer tenants). Auto-invoke when the user says "remediation tool", "365 remediation", "check 's mailbox/box", "credential stuffing" against an M365 user, "breach check" on an M365 tenant, or needs M365 admin API work that client-credentials Graph + Exchange REST can perform. NOT for CIPP — this is the direct Graph API app. + + Also invoke when the user needs any of: inbox rule enumeration, mailbox forwarding check, delegate/SendAs audit, OAuth consent audit, sign-in log queries, risky user lookup, directory audit queries, B2B guest invite audit against M365. + + Triggers: "365 remediation", "remediation tool", "check box/mailbox/account for breach", "credential stuff*", "who's getting attacked", "foreign sign-in", "inbox rule", "mailbox forward*", "oauth consent" (in MSP context), "tenant sweep", "risky user", "hidden rule", Exchange Online admin API, "adminapi/beta/{tenant}/InvokeCommand". +--- + +# 365 Remediation Tool + +Read-only by default. All remediation actions require explicit `YES` confirmation in chat (not a permission prompt). + +## Auto-Invocation Behavior + +When triggered automatically (vs. via `/remediation-tool`), follow the same workflow described in `.claude/commands/remediation-tool.md`: + +1. Parse the user's intent into a subcommand (check/sweep/signins/consent-url/remediate). +2. Resolve tenant ID from domain. +3. Acquire tokens (cached). +4. Run checks via scripts in `scripts/`. +5. Interpret findings using `references/checklist.md`. +6. Write report to `clients/{slug}/reports/YYYY-MM-DD-{action}.md` using `templates/breach-report.md`. +7. Chat summary + delegate commit to Gitea agent. + +## Before calling any script, verify + +- The SOPS vault is accessible: `test -f D:/vault/scripts/vault.sh` (Windows) or `test -f ~/vault/scripts/vault.sh` (other). +- `jq`, `curl`, `bash` are available. +- For Exchange REST checks: confirm the target tenant has **Exchange Administrator** role assigned to the app's service principal (display name "ComputerGuru - AI Remediation"). If any Exchange REST call returns 403, emit the tenant-scoped Entra Roles link from `references/gotchas.md`. +- For Identity Protection checks: app manifest must include `IdentityRiskyUser.Read.All` or `.ReadWrite.All`, AND the tenant must have admin-consented after that permission was added. If 403, emit the consent URL. + +## Conventions + +- **Target identifiers**: accept UPN, domain, or tenant GUID. Normalize to tenant GUID internally. +- **Token cache**: `/tmp/remediation-tool/{tenant-id}/{scope}.jwt`. TTL 55 minutes. Check `-mmin -55` before reuse. +- **Raw JSON artifacts**: `/tmp/remediation-tool/{tenant-id}/{check}/` — keep so the user can re-analyze. +- **Reports**: `clients/{slug}/reports/YYYY-MM-DD-{action}.md`. Derive slug from domain (strip TLD, hyphenate). +- **UTC dates everywhere**. + +## Scope boundaries + +- **Not a replacement for CIPP.** Use CIPP for bulk baseline configuration, templates, standards alerting. Use this tool for focused investigation and point-in-time remediation. +- **Not for creating/modifying Entra apps or Conditional Access policies.** Those are sensitive enough to stay manual in the portal. +- **Not for Graph permissions the app doesn't have.** If a call 403s and the scope isn't in the app manifest, stop and tell the user — don't try to work around it. diff --git a/.claude/skills/remediation-tool/references/checklist.md b/.claude/skills/remediation-tool/references/checklist.md new file mode 100644 index 0000000..5d66a02 --- /dev/null +++ b/.claude/skills/remediation-tool/references/checklist.md @@ -0,0 +1,48 @@ +# Breach-Check Rubric + +How to interpret the outputs from `user-breach-check.sh` and `tenant-sweep.sh`. + +## Single-user check — the 10 points + +| # | Check | What "clean" looks like | Red flags | +|---|---|---|---| +| 1 | Inbox rules (Graph) | Empty, or only benign filters | ForwardTo / RedirectTo / ForwardAsAttachmentTo set; DeleteMessage+MarkAsRead combos; rules filtered on "password", "bank", "invoice", "CEO name", "security"; rules with name like "." or " " (attacker hiding) | +| 2 | Mailbox settings / auto-reply | Auto-reply disabled or legitimate | Auto-reply active with external audience + unfamiliar message body | +| 3 | Exchange REST (hidden rules, delegates, SendAs, Get-Mailbox forwarding fields) | Only SELF in permissions; no forwarding | **Hidden** inbox rule moving to RSS/Notes/Conversation History; non-SELF FullAccess/SendAs; ForwardingAddress or ForwardingSmtpAddress set to external | +| 4 | OAuth consents + app role assignments | Legitimate apps only (Teams, Outlook mobile, BlueMail, etc.); dates match user history | New consent in attack window; unknown app with `Mail.ReadWrite`, `Files.ReadWrite`, `offline_access`; publisher not verified | +| 5 | Auth methods | All methods predate the attack window | New phone/Authenticator registered within hours of first suspicious sign-in; duplicate entries with the same device name but different createdDateTime | +| 6 | Sign-ins 30d | Consistent US IPs, user's known geography | Any successful sign-in from a country the user never visits; IMAP/POP/Authenticated SMTP client apps (legacy auth); sign-ins from TOR exit nodes or known residential-proxy ranges | +| 7 | Directory audits | Only legit admin/system actions | `Update user` by non-admin principal; password reset the user didn't initiate; auth method change from `Microsoft Substrate Management` is normal but repeated changes are not | +| 8 | Risky users / risk detections | `riskLevel: none` | Any `medium` or `high`; `riskDetail: userPerformedSecuredPasswordChange` just means resolved — check the original detection | +| 9 | Sent items (recent 25) | Normal business correspondence | Blast emails to random external recipients; forwards of internal financial/HR info externally; anything after-hours from an unusual client app | +| 10 | Deleted items (recent 25) | Marketing/spam, routine notifications | Deleted security alerts, password-reset emails, MFA notifications, bounce notices the user wouldn't delete — all signs of attacker cleanup | + +### Cross-check rule + +If inbox rules and forwarding are clean **but** sign-ins show successful foreign access — attacker may have used OAuth-based access (check OAuth grants) or already extracted data and cleaned up. Pull sent items + deleted items aggressively and check `/auditLogs/signIns/beta` for non-interactive sign-ins. + +## Tenant-wide sweep — priorities + +| Priority | Signal | Action | +|---|---|---| +| P1 | User with ≥20 failed sign-ins from ≥2 foreign countries | Likely active credential-stuffing target. Reset password, disable SMTP AUTH, monitor. | +| P1 | Successful sign-in from non-US | Verify with user immediately. If not them: force password reset + revoke sessions + full breach check. | +| P2 | New OAuth consent to unfamiliar app in attack window | Review app publisher, scopes, and requesting user. Revoke if unknown. | +| P2 | B2B guest invite to personal email domain (gmail.com, outlook.com, yahoo.com) | Confirm with inviter it's intentional. Guest invites are a known persistence mechanism. | +| P3 | Transport rule created/modified by a non-admin | Transport rules can redirect mail tenant-wide. Review body/actions carefully. | +| P3 | Service principal added by non-admin or by "PowerApps Service" unexpectedly | Usually benign, but worth noting. | +| P4 | Isolated wrong-password attempt from foreign IP | Record and move on. Single attempts are noise unless repeated. | + +## False positives to filter out + +- `sysadmin@` failures during onboarding of the remediation tool itself (error 65001 against app **ComputerGuru - AI Remediation**). +- `Microsoft Substrate Management` and `Azure MFA StrongAuthenticationService` routinely update user records — those are not attacker activity. +- Our own consent attempts show up as `Consent to application` in directory audits. Filter `sysadmin` + target = ComputerGuru-AI-Remediation during the onboarding window. +- `error 50140` "Keep me signed in interrupt" is a browser prompt, not a failed auth. + +## When to escalate beyond this tool + +- Data exfiltration suspected -> pull Unified Audit Log via Purview (this tool does not access UAL). +- Tenant-wide phishing campaign -> enable Purview Content Search, quarantine messages. +- Domain-joined workstation compromise -> GuruRMM + Bitdefender workflow (see `clients/ace-portables/reports/` for past example). +- Attacker still active and exfiltrating -> consider disabling the user via the `remediate` subcommand and rotating the mailbox password at the same time. diff --git a/.claude/skills/remediation-tool/references/gotchas.md b/.claude/skills/remediation-tool/references/gotchas.md new file mode 100644 index 0000000..bd05ec2 --- /dev/null +++ b/.claude/skills/remediation-tool/references/gotchas.md @@ -0,0 +1,77 @@ +# Gotchas — Permissions, Roles, Consent + +## App identity + +- **App ID (client_id):** `fabb3421-8b34-484b-bc17-e46de9703418` +- **Internal name (home tenant / registration):** Claude-MSP-Access +- **Display name in customer tenants:** **ComputerGuru - AI Remediation** +- **Client secret:** SOPS vault `msp-tools/claude-msp-access-graph-api.sops.yaml` -> field `credentials.credential` + +When searching customer admin portals for the service principal (role assignments, app role assignments, conditional access exclusions), **search for "ComputerGuru - AI Remediation"** — not "Claude-MSP-Access". + +## Per-tenant prerequisites + +Graph API permissions alone are not enough. Most privileged operations require directory roles on the service principal *in that tenant*: + +| Operation | Required directory role | +|---|---| +| Password reset, user property updates | User Administrator | +| Exchange REST (hidden inbox rules, mailbox permissions, SendAs, transport rules, Get-Mailbox) | Exchange Administrator | +| Conditional Access policy reads/writes | Conditional Access Administrator OR Security Administrator | +| Teams policies | Teams Administrator | + +### How to assign a role to the SP in a customer tenant + +1. Sign into the customer's Entra admin center as Global Admin: + `https://entra.microsoft.com/#@{customer-domain}` +2. Identity -> Roles & admins -> All roles -> select the role (e.g., Exchange Administrator). +3. Add assignments -> search **"ComputerGuru - AI Remediation"** -> Assign (Active, permanent — service principals cannot activate eligible assignments). + +## Admin consent + +When you add new Graph scopes to the app manifest in the home tenant, each customer tenant must re-consent for those scopes to flow into tokens. + +**Admin consent URL (per tenant):** + +``` +https://login.microsoftonline.com/{tenant-id}/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient +``` + +- Customer admin must sign in as Global Admin of that tenant. +- The consent page lists all permissions in the current manifest; admin clicks Accept. +- Redirect lands on a blank Microsoft "native client" page that looks like an error — **that is normal**. Consent is recorded on Accept, not on redirect success. +- Verify consent took effect by checking `/servicePrincipals/{sp-id}/appRoleAssignments` — the timestamps on new grants should be `today`. + +## Diagnosing "required scopes are missing" + +Token returned 403 with `"required scopes are missing in the token"`: + +1. Decode the JWT payload (2nd segment, base64url) and check the `roles` claim. +2. If the scope you expected is not in `roles`: + - Confirm the scope is in the app's API permissions in the home tenant (not just selected in the picker — must be saved). + - Grant admin consent in the home tenant. + - Re-run the customer admin consent URL above. +3. If the scope IS in `roles` but you still get 403: check for a missing directory role (see table above). + +## Diagnosing Exchange REST 403 + +- Invalid token scope: make sure you requested `https://outlook.office365.com/.default` (not the Graph scope). +- Missing Exchange Administrator role on the SP in that tenant. +- Propagation delay: newly assigned role can take up to 15 minutes to reach Exchange Online. If you just assigned it, wait and retry. + +## Common, benign "failures" in sign-in logs + +- `error 50140` "Keep me signed in interrupt" — KMSI prompt, not a real failure. +- `error 65001` "has not consented to use the application" — this fires during onboarding consent and when a user (or admin) signs in before granting consent. If the `appDisplayName` is **ComputerGuru - AI Remediation**, those are our own consent attempts, not attacker activity. +- `error 50126` from the sysadmin account during our onboarding is typo/retry noise — check `ipAddress` matches Mike's known IPs before flagging. + +## Tenants where the app is already set up (as of 2026-04-16) + +| Tenant | Tenant ID | Directory roles assigned | Notes | +|---|---|---|---| +| Valleywide Plastering | 5c53ae9f... | User Administrator | | +| Dataforth | 7dfa3ce8... | User Administrator, Exchange Administrator | | +| Cascades Tucson | 207fa277-e9d8-4eb7-ada1-1064d2221498 | User Administrator, Exchange Administrator | IdentityRiskyUser scope still not consented as of 2026-04-16 | +| Grabblaw | 032b383e-96e4-491b-880d-3fd3295672c3 | none | Consent broken (2026-03-31); Reyna needs full access to Jsosa mailbox | + +Keep this table updated when you roll out to a new tenant. diff --git a/.claude/skills/remediation-tool/references/graph-endpoints.md b/.claude/skills/remediation-tool/references/graph-endpoints.md new file mode 100644 index 0000000..f128a33 --- /dev/null +++ b/.claude/skills/remediation-tool/references/graph-endpoints.md @@ -0,0 +1,144 @@ +# Graph + Exchange REST Cheatsheet + +All examples assume `$GT` = Graph token, `$EXO` = Exchange token, `$TID` = tenant ID, `$UPN`/`$UID` = user identifiers. + +## Graph API (`https://graph.microsoft.com/v1.0`) + +### User lookup / status + +```bash +# By UPN +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/$UPN?\$select=id,displayName,userPrincipalName,mail,accountEnabled,createdDateTime,lastPasswordChangeDateTime" + +# All users (filter, paged) +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users?\$top=999&\$filter=accountEnabled%20eq%20true" +``` + +### Mailbox + +```bash +# Visible inbox rules (Graph v1.0 — does NOT return hidden rules) +/users/$UPN/mailFolders/inbox/messageRules + +# Mailbox settings (auto-reply, delegates meeting option, NOT forwarding flags) +/users/$UPN/mailboxSettings + +# Recent sent / deleted +/users/$UPN/mailFolders/sentitems/messages?$top=25&$orderby=sentDateTime%20desc +/users/$UPN/mailFolders/deleteditems/messages?$top=25&$orderby=receivedDateTime%20desc +``` + +### Authentication methods + +```bash +/users/$UPN/authentication/methods +# Watch for new methods added within the attack window +``` + +### OAuth + app role assignments + +```bash +/users/$UPN/oauth2PermissionGrants # user-level consents +/users/$UPN/appRoleAssignments # apps assigned to this user +/servicePrincipals/$SP_ID/appRoleAssignments # what scopes a SP has +``` + +### Sign-ins (needs Entra ID P1 or higher) + +```bash +# Interactive sign-ins v1.0 (does NOT include non-interactive/service-principal) +/auditLogs/signIns?$filter=userId eq '$UID' and createdDateTime ge $FROM&$top=200 + +# All sign-in event types (beta endpoint) +/beta/auditLogs/signIns?$filter=userId eq '$UID' and (signInEventTypes/any(t:t eq 'nonInteractiveUser')) + +# Foreign successful sign-ins tenant-wide +/auditLogs/signIns?$filter=(status/errorCode eq 0) and (location/countryOrRegion ne 'US') +``` + +### Directory audits + +```bash +# Changes targeting a specific user +/auditLogs/directoryAudits?$filter=targetResources/any(t:t/id eq '$UID') + +# Tenant-wide consent / auth-method / role events +/auditLogs/directoryAudits?$filter=activityDateTime ge $FROM +# Then client-side filter by activityDisplayName ~ Consent|Authentication Method|Add service principal|Add member to role +``` + +### Identity Protection (needs IdentityRiskyUser.Read.All) + +```bash +/identityProtection/riskyUsers +/identityProtection/riskyUsers/$UID +/identityProtection/riskDetections?$filter=userId eq '$UID' +``` + +### B2B guests + +```bash +# Get guest by gmail/external address +/users?$filter=startswith(userPrincipalName,'dunedolly21') + +# Invite audits +/auditLogs/directoryAudits?$filter=activityDisplayName eq 'Invite external user' +``` + +## Exchange Online REST (`https://outlook.office365.com/adminapi/beta/{tenant-id}/InvokeCommand`) + +POST with JSON body `{"CmdletInput":{"CmdletName":"","Parameters":{...}}}`. Token scope: `https://outlook.office365.com/.default`. + +### Inbox rules (INCLUDING hidden) + +```json +{"CmdletInput":{"CmdletName":"Get-InboxRule","Parameters":{"Mailbox":"user@domain.com","IncludeHidden":true}}} +``` + +Why this matters: attackers commonly create hidden rules that Graph v1.0 cannot see. + +### Mailbox forwarding / properties + +```json +{"CmdletInput":{"CmdletName":"Get-Mailbox","Parameters":{"Identity":"user@domain.com"}}} +``` + +Check: `ForwardingAddress`, `ForwardingSmtpAddress`, `DeliverToMailboxAndForward`, `GrantSendOnBehalfTo`, `HiddenFromAddressListsEnabled`. + +### Mailbox permissions (delegates / FullAccess) + +```json +{"CmdletInput":{"CmdletName":"Get-MailboxPermission","Parameters":{"Identity":"user@domain.com"}}} +``` + +Filter out `NT AUTHORITY\\SELF` — anything else is a delegate. + +### SendAs permissions + +```json +{"CmdletInput":{"CmdletName":"Get-RecipientPermission","Parameters":{"Identity":"user@domain.com"}}} +``` + +### Transport rules (tenant-wide mail flow) + +```json +{"CmdletInput":{"CmdletName":"Get-TransportRule","Parameters":{}}} +``` + +Check for rules that reroute, delete, or exfiltrate mail. + +### SMTP AUTH + +```json +{"CmdletInput":{"CmdletName":"Get-CASMailbox","Parameters":{"Identity":"user@domain.com"}}} +``` + +Check `SmtpClientAuthenticationDisabled`. To disable SMTP AUTH on a single mailbox (remediation): `Set-CASMailbox -SmtpClientAuthenticationDisabled $true`. + +## Rate limits / pagination + +- Graph signIns endpoints cap `$top` at 999. For >999 results, follow `@odata.nextLink`. +- Exchange REST has undocumented throttling — if you hit 429, back off 30–60s. +- Token is valid ~60 minutes. Script caches for 55 min. diff --git a/.claude/skills/remediation-tool/scripts/get-token.sh b/.claude/skills/remediation-tool/scripts/get-token.sh new file mode 100644 index 0000000..14b7317 --- /dev/null +++ b/.claude/skills/remediation-tool/scripts/get-token.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Acquire a client-credentials token for the Claude-MSP-Access (ComputerGuru - AI Remediation) app. +# Usage: get-token.sh +# : graph | exchange | defender | sharepoint +# Output (stdout): token. Exit 0 on success. +# Cache: /tmp/remediation-tool/{tenant-id}/{scope}.jwt (55-min TTL). +set -euo pipefail + +CLIENT_ID="fabb3421-8b34-484b-bc17-e46de9703418" + +TARGET="${1:?usage: get-token.sh }" +SCOPE_NAME="${2:?usage: get-token.sh }" + +# Resolve to tenant-id +if [[ "$TARGET" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + TENANT_ID="$TARGET" +else + SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" + TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TARGET") +fi + +case "$SCOPE_NAME" in + graph) SCOPE_URL="https://graph.microsoft.com/.default" ;; + exchange) SCOPE_URL="https://outlook.office365.com/.default" ;; + defender) SCOPE_URL="https://api.securitycenter.microsoft.com/.default" ;; + sharepoint) + # SharePoint token scope depends on tenant hostname. Caller must set SHAREPOINT_HOST=contoso.sharepoint.com. + SCOPE_URL="https://${SHAREPOINT_HOST:?set SHAREPOINT_HOST for sharepoint scope}/.default" ;; + *) echo "ERROR: unknown scope '$SCOPE_NAME'. Expected: graph|exchange|defender|sharepoint" >&2; exit 2 ;; +esac + +CACHE_DIR="/tmp/remediation-tool/$TENANT_ID" +mkdir -p "$CACHE_DIR" +CACHE_FILE="$CACHE_DIR/${SCOPE_NAME}.jwt" + +# Reuse cache if less than 55 minutes old. +if [[ -f "$CACHE_FILE" ]] && [[ $(find "$CACHE_FILE" -mmin -55 2>/dev/null) ]]; then + cat "$CACHE_FILE" + exit 0 +fi + +# Locate the vault repo. +VAULT_ROOT="" +for candidate in "D:/vault" "$HOME/vault" "/d/vault"; do + [[ -d "$candidate" ]] && VAULT_ROOT="$candidate" && break +done +[[ -z "$VAULT_ROOT" ]] && { echo "ERROR: SOPS vault repo not found at D:/vault or ~/vault" >&2; exit 3; } + +SOPS_FILE="$VAULT_ROOT/msp-tools/claude-msp-access-graph-api.sops.yaml" +[[ ! -f "$SOPS_FILE" ]] && { echo "ERROR: SOPS file not found: $SOPS_FILE" >&2; exit 3; } + +# Try vault.sh first; fall back to direct sops+python if vault.sh is broken (e.g. yq shim permission issues on Windows). +CLIENT_SECRET="" +if [[ -f "$VAULT_ROOT/scripts/vault.sh" ]]; then + CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field msp-tools/claude-msp-access-graph-api.sops.yaml credentials.credential 2>/dev/null | tr -d '\r\n' || true) +fi + +if [[ -z "$CLIENT_SECRET" ]]; then + # Direct fallback: sops decrypt + python YAML parse. Works without vault.sh / yq. + PYTHON_BIN="" + for p in python python3 py; do command -v "$p" >/dev/null 2>&1 && PYTHON_BIN="$p" && break; done + [[ -z "$PYTHON_BIN" ]] && { echo "ERROR: neither vault.sh worked nor python is available for fallback parse" >&2; exit 3; } + command -v sops >/dev/null 2>&1 || { echo "ERROR: sops not on PATH (needed for fallback)" >&2; exit 3; } + + CLIENT_SECRET=$(sops -d "$SOPS_FILE" 2>/dev/null | "$PYTHON_BIN" -c " +import sys, re +t = sys.stdin.read() +# minimal YAML: find 'credentials:' block then 'credential:' key +m = re.search(r'^credentials:\s*\n((?:[ \t]+.*\n)+)', t, re.MULTILINE) +if not m: sys.exit(1) +for line in m.group(1).splitlines(): + line = line.strip() + if line.startswith('credential:'): + print(line.split(':', 1)[1].strip().strip('\"').strip(\"'\")) + break +" | tr -d '\r\n') +fi + +[[ -z "$CLIENT_SECRET" ]] && { echo "ERROR: could not read client secret from vault (vault.sh and sops+python fallback both failed)" >&2; exit 4; } + +# Request token. +RESP=$(curl -s --max-time 15 -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \ + --data-urlencode "client_id=${CLIENT_ID}" \ + --data-urlencode "client_secret=${CLIENT_SECRET}" \ + --data-urlencode "scope=${SCOPE_URL}" \ + --data-urlencode "grant_type=client_credentials") + +TOKEN=$(echo "$RESP" | jq -r '.access_token // empty') + +if [[ -z "$TOKEN" ]]; then + echo "ERROR: token request failed for tenant=$TENANT_ID scope=$SCOPE_NAME" >&2 + echo "$RESP" >&2 + exit 5 +fi + +echo "$TOKEN" > "$CACHE_FILE" +chmod 600 "$CACHE_FILE" 2>/dev/null || true +echo "$TOKEN" diff --git a/.claude/skills/remediation-tool/scripts/resolve-tenant.sh b/.claude/skills/remediation-tool/scripts/resolve-tenant.sh new file mode 100644 index 0000000..b88cc47 --- /dev/null +++ b/.claude/skills/remediation-tool/scripts/resolve-tenant.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Resolve an M365 domain (or UPN) to a tenant GUID via OpenID discovery. +# Usage: resolve-tenant.sh +# Output (stdout): tenant GUID. Exit 0 on success, 1 on failure. +set -euo pipefail + +INPUT="${1:?usage: resolve-tenant.sh }" + +# If it looks like a GUID already, pass through. +if [[ "$INPUT" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + echo "$INPUT" + exit 0 +fi + +# If it's a UPN, strip to domain. +DOMAIN="${INPUT#*@}" + +# Lightweight cache keyed by domain. +CACHE_DIR="/tmp/remediation-tool/_tenant-cache" +mkdir -p "$CACHE_DIR" +CACHE_FILE="$CACHE_DIR/${DOMAIN}.txt" +if [[ -f "$CACHE_FILE" ]] && [[ $(find "$CACHE_FILE" -mmin -1440 2>/dev/null) ]]; then + cat "$CACHE_FILE" + exit 0 +fi + +# OpenID discovery — parse issuer URL for tenant GUID. +RESP=$(curl -s --max-time 10 "https://login.microsoftonline.com/${DOMAIN}/v2.0/.well-known/openid-configuration") +TENANT_ID=$(echo "$RESP" | jq -r '.issuer // empty' | sed -E 's|^https://login\.microsoftonline\.com/||;s|/v2\.0/?$||' || true) + +if [[ -z "$TENANT_ID" ]] || [[ ! "$TENANT_ID" =~ ^[0-9a-fA-F]{8}- ]]; then + echo "ERROR: could not resolve tenant for domain: $DOMAIN" >&2 + echo "Response: $RESP" >&2 + exit 1 +fi + +echo "$TENANT_ID" | tee "$CACHE_FILE" diff --git a/.claude/skills/remediation-tool/scripts/tenant-sweep.sh b/.claude/skills/remediation-tool/scripts/tenant-sweep.sh new file mode 100644 index 0000000..738dde8 --- /dev/null +++ b/.claude/skills/remediation-tool/scripts/tenant-sweep.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Tenant-wide signals sweep: failed sign-ins, foreign successful sign-ins, directory audits, +# risky users, B2B guest invites, per-user location profile. +# Usage: tenant-sweep.sh +# Writes raw JSON to /tmp/remediation-tool/{tenant-id}/sweep/ +# Prints a priority summary to stdout. +set -euo pipefail + +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +TENANT_INPUT="${1:?usage: tenant-sweep.sh }" +TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TENANT_INPUT") +GT=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" graph) + +OUT="/tmp/remediation-tool/$TENANT_ID/sweep" +mkdir -p "$OUT" +FROM=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ) +echo "[info] tenant=$TENANT_ID window=30d from=$FROM" + +# Enabled users list +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users?\$top=999&\$filter=accountEnabled%20eq%20true&\$select=id,displayName,userPrincipalName,accountEnabled,userType,externalUserState,lastPasswordChangeDateTime,createdDateTime" \ + > "$OUT/users.json" & + +# Failed sign-ins tenant-wide +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=(createdDateTime%20ge%20${FROM})%20and%20(status/errorCode%20ne%200)&\$top=999" \ + > "$OUT/failed_signins.json" & + +# Successful sign-ins tenant-wide (to find non-US) +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=(createdDateTime%20ge%20${FROM})%20and%20(status/errorCode%20eq%200)&\$top=999" \ + > "$OUT/success_signins.json" & + +# Directory audits, filtered by risky activity names +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDateTime%20ge%20${FROM}&\$top=999" \ + > "$OUT/dir_audits.json" & + +# Risky users (may 403 if IdentityRiskyUser scope absent) +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers?\$top=100" \ + > "$OUT/risky_users.json" & + +# B2B guest invites +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDateTime%20ge%20${FROM}%20and%20activityDisplayName%20eq%20'Invite%20external%20user'&\$top=100" \ + > "$OUT/guest_invites.json" & + +wait + +echo "" +echo "=== Priority 1: accounts with foreign failed sign-ins (credential stuffing candidates) ===" +jq '[.value[] | select(.location.countryOrRegion != "US" and .location.countryOrRegion != null) | {user: .userPrincipalName, ip: .ipAddress, country: .location.countryOrRegion, city: .location.city, t: .createdDateTime, err: .status.errorCode, fail: .status.failureReason}] | group_by(.user) | map({user: .[0].user, attempts: length, unique_ips: ([.[]|.ip]|unique|length), countries: ([.[]|.country]|unique), first: ([.[]|.t]|min), last: ([.[]|.t]|max)}) | sort_by(-.attempts)' "$OUT/failed_signins.json" + +echo "" +echo "=== Priority 2: successful sign-ins from non-US (suspicious) ===" +jq '[.value[] | select(.location.countryOrRegion != "US" and .location.countryOrRegion != null) | {user: .userPrincipalName, ip: .ipAddress, country: .location.countryOrRegion, city: .location.city, t: .createdDateTime, app: .appDisplayName, clientApp: .clientAppUsed}] | sort_by(.t) | reverse | .[:30]' "$OUT/success_signins.json" + +echo "" +echo "=== Priority 3: B2B guest invites (30d) ===" +jq '[.value[] | {t: .activityDateTime, by: (.initiatedBy.user.userPrincipalName // .initiatedBy.app.displayName), target: [.targetResources[]?|{name: .displayName, upn: .userPrincipalName}], result: .result}] | sort_by(.t) | reverse' "$OUT/guest_invites.json" + +echo "" +echo "=== Priority 4: directory audit - consent/role/auth-method changes ===" +jq '[.value[] | select(.activityDisplayName | test("[Cc]onsent|[Aa]uthentication [Mm]ethod|Add service principal|Add delegated permission grant|Add app role|Add member to role"; "")) | {t: .activityDateTime, act: .activityDisplayName, by: (.initiatedBy.user.userPrincipalName // .initiatedBy.app.displayName // "system"), target: [.targetResources[]?|{type: .type, name: .displayName, upn: .userPrincipalName}], result: .result}] | sort_by(.t) | reverse | .[:50]' "$OUT/dir_audits.json" + +echo "" +echo "=== Risky users (if Identity Protection accessible) ===" +if jq -e '.error' "$OUT/risky_users.json" >/dev/null 2>&1; then + echo "BLOCKED: $(jq -r '.error.code // "?"' "$OUT/risky_users.json") — $(jq -r '.error.message // ""' "$OUT/risky_users.json")" + echo "(Check references/gotchas.md for how to unblock IdentityRiskyUser scope)" +else + jq '[.value[] | {upn: .userPrincipalName, level: .riskLevel, state: .riskState, detail: .riskDetail, lastUpdated: .riskLastUpdatedDateTime}]' "$OUT/risky_users.json" +fi + +echo "" +echo "=== User locations profile (successful sign-ins) ===" +jq '[.value[] | {user: .userPrincipalName, country: .location.countryOrRegion, city: .location.city}] | unique | group_by(.user) | map({user: .[0].user, locations: [.[]|{country, city}]|unique})' "$OUT/success_signins.json" + +echo "" +echo "[info] Enabled users in tenant: $(jq '.value | length' "$OUT/users.json")" +echo "[info] raw artifacts: $OUT" diff --git a/.claude/skills/remediation-tool/scripts/user-breach-check.sh b/.claude/skills/remediation-tool/scripts/user-breach-check.sh new file mode 100644 index 0000000..e28854b --- /dev/null +++ b/.claude/skills/remediation-tool/scripts/user-breach-check.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Run the 10-point breach check on a single user. +# Usage: user-breach-check.sh +# Writes raw JSON to /tmp/remediation-tool/{tenant-id}/user-breach/{user-slug}/ +# Prints a summary table to stdout. +set -euo pipefail + +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" + +TENANT_INPUT="${1:?usage: user-breach-check.sh }" +UPN="${2:?usage: user-breach-check.sh }" + +TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TENANT_INPUT") +GT=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" graph) +EXO=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" exchange) || EXO="" + +USER_SLUG=$(echo "$UPN" | tr '@.' '__') +OUT="/tmp/remediation-tool/$TENANT_ID/user-breach/$USER_SLUG" +mkdir -p "$OUT" + +echo "[info] tenant=$TENANT_ID user=$UPN out=$OUT" + +# --- 0. Resolve user object ID --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}?\$select=id,displayName,userPrincipalName,mail,accountEnabled,createdDateTime,lastPasswordChangeDateTime" \ + > "$OUT/00_user.json" +UID_=$(jq -r '.id // empty' "$OUT/00_user.json") +if [[ -z "$UID_" ]]; then + echo "ERROR: user not found or Graph returned error" >&2 + cat "$OUT/00_user.json" >&2 + exit 1 +fi +echo "[info] object id: $UID_" + +# --- 1. Inbox rules (Graph v1.0 — visible only) --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}/mailFolders/inbox/messageRules" \ + > "$OUT/01_inbox_rules_graph.json" & + +# --- 2. Mailbox settings (forwarding flags) --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}/mailboxSettings" \ + > "$OUT/02_mailbox_settings.json" & + +# --- 4. OAuth consents + app role assignments --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}/oauth2PermissionGrants" \ + > "$OUT/04a_oauth_grants.json" & +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}/appRoleAssignments" \ + > "$OUT/04b_app_role_assignments.json" & + +# --- 5. Authentication methods --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}/authentication/methods" \ + > "$OUT/05_auth_methods.json" & + +wait + +# --- 6. Sign-ins 30d (v1.0 — interactive only) --- +FROM=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ) +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=userId%20eq%20'${UID_}'%20and%20createdDateTime%20ge%20${FROM}&\$top=200" \ + > "$OUT/06_signins.json" & + +# --- 7. Directory audits (targetResources = user) 30d --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDateTime%20ge%20${FROM}%20and%20targetResources/any(t:t/id%20eq%20'${UID_}')&\$top=200" \ + > "$OUT/07_dir_audits.json" & + +# --- 8. Risky user + risk detections (403 if app lacks IdentityRiskyUser scope) --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers/${UID_}" \ + > "$OUT/08a_risky_user.json" & +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/identityProtection/riskDetections?\$filter=userId%20eq%20'${UID_}'&\$top=100" \ + > "$OUT/08b_risk_detections.json" & + +# --- 9. Sent items (last 25) --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}/mailFolders/sentitems/messages?\$top=25&\$orderby=sentDateTime%20desc&\$select=subject,toRecipients,sentDateTime,from" \ + > "$OUT/09_sent.json" & + +# --- 10. Deleted items (last 25) --- +curl -s -H "Authorization: Bearer $GT" \ + "https://graph.microsoft.com/v1.0/users/${UPN}/mailFolders/deleteditems/messages?\$top=25&\$orderby=receivedDateTime%20desc&\$select=subject,from,receivedDateTime" \ + > "$OUT/10_deleted.json" & + +wait + +# --- 3. Exchange REST (hidden rules + delegates + SendAs + Get-Mailbox) --- +if [[ -n "$EXO" ]]; then + EX_URL="https://outlook.office365.com/adminapi/beta/${TENANT_ID}/InvokeCommand" + + curl -s -X POST -H "Authorization: Bearer $EXO" -H "Content-Type: application/json" "$EX_URL" \ + -d "{\"CmdletInput\":{\"CmdletName\":\"Get-InboxRule\",\"Parameters\":{\"Mailbox\":\"${UPN}\",\"IncludeHidden\":true}}}" \ + > "$OUT/03a_InboxRule_hidden.json" & + + curl -s -X POST -H "Authorization: Bearer $EXO" -H "Content-Type: application/json" "$EX_URL" \ + -d "{\"CmdletInput\":{\"CmdletName\":\"Get-MailboxPermission\",\"Parameters\":{\"Identity\":\"${UPN}\"}}}" \ + > "$OUT/03b_MailboxPermission.json" & + + curl -s -X POST -H "Authorization: Bearer $EXO" -H "Content-Type: application/json" "$EX_URL" \ + -d "{\"CmdletInput\":{\"CmdletName\":\"Get-RecipientPermission\",\"Parameters\":{\"Identity\":\"${UPN}\"}}}" \ + > "$OUT/03c_RecipientPermission.json" & + + curl -s -X POST -H "Authorization: Bearer $EXO" -H "Content-Type: application/json" "$EX_URL" \ + -d "{\"CmdletInput\":{\"CmdletName\":\"Get-Mailbox\",\"Parameters\":{\"Identity\":\"${UPN}\"}}}" \ + > "$OUT/03d_Mailbox.json" & + + wait +else + echo "[warn] no Exchange token; skipping check 3 (hidden rules/delegates/SendAs/mailbox forwarding flags)" +fi + +# --- Summary table --- +echo "" +echo "=== Summary: $UPN ===" +jq -r '"account_enabled: \(.accountEnabled) lastPwChange: \(.lastPasswordChangeDateTime) created: \(.createdDateTime)"' "$OUT/00_user.json" +echo "01 inbox_rules (Graph): $(jq '.value | length // "error"' "$OUT/01_inbox_rules_graph.json")" +echo "02 forwarding: fwdSmtp=$(jq -r '.automaticRepliesSetting.status // "n/a"' "$OUT/02_mailbox_settings.json" 2>/dev/null) (see mailbox Get-Mailbox for forwarding fields)" +echo "04a oauth_grants: $(jq '.value | length // "error"' "$OUT/04a_oauth_grants.json")" +echo "04b app_role_assignments: $(jq '.value | length // "error"' "$OUT/04b_app_role_assignments.json")" +echo "05 auth_methods: $(jq '.value | length // "error"' "$OUT/05_auth_methods.json")" +echo "06 signins (30d, interactive): $(jq '.value | length // "error"' "$OUT/06_signins.json") non-US: $(jq '[.value[]?|select(.location.countryOrRegion != "US" and .location.countryOrRegion != null)] | length' "$OUT/06_signins.json" 2>/dev/null)" +echo "07 dir_audits (30d): $(jq '.value | length // "error"' "$OUT/07_dir_audits.json")" +echo "08 risky_user: $(jq -r '.riskLevel // .error.code // "none"' "$OUT/08a_risky_user.json" 2>/dev/null)" +echo "08 risk_detections: $(jq '.value | length // (.error.code // "error")' "$OUT/08b_risk_detections.json")" +echo "09 sent (recent 25): $(jq '.value | length // "error"' "$OUT/09_sent.json")" +echo "10 deleted (recent 25): $(jq '.value | length // "error"' "$OUT/10_deleted.json")" +if [[ -f "$OUT/03a_InboxRule_hidden.json" ]]; then + HIDDEN=$(jq '.value | length // (.error.code // "?")' "$OUT/03a_InboxRule_hidden.json" 2>/dev/null || echo "?") + echo "03a hidden_inbox_rules: $HIDDEN" + echo "03b mailbox_permissions: $(jq '[.value[]? | select(.User != "NT AUTHORITY\\SELF")] | length // "?"' "$OUT/03b_MailboxPermission.json" 2>/dev/null) non-SELF" + echo "03c send_as: $(jq '[.value[]? | select(.Trustee != "NT AUTHORITY\\SELF")] | length // "?"' "$OUT/03c_RecipientPermission.json" 2>/dev/null) non-SELF" + echo "03d mailbox_forwarding: fwdAddr=$(jq -r '.value[0].ForwardingAddress // "null"' "$OUT/03d_Mailbox.json" 2>/dev/null) fwdSmtp=$(jq -r '.value[0].ForwardingSmtpAddress // "null"' "$OUT/03d_Mailbox.json" 2>/dev/null)" +else + echo "03 exchange_rest: SKIPPED (no exchange token — tenant likely needs Exchange Admin role assigned)" +fi +echo "" +echo "[info] raw artifacts: $OUT" diff --git a/.claude/skills/remediation-tool/templates/breach-report.md b/.claude/skills/remediation-tool/templates/breach-report.md new file mode 100644 index 0000000..73e09c5 --- /dev/null +++ b/.claude/skills/remediation-tool/templates/breach-report.md @@ -0,0 +1,75 @@ +# {{TITLE}} + +**Date:** {{YYYY-MM-DD}} +**Tenant:** {{tenant-display-name}} ({{domain}}, {{tenant-id}}) +**Subject:** {{user-or-tenant}} +**Tool:** Claude-MSP-Access / ComputerGuru - AI Remediation (App ID `fabb3421-8b34-484b-bc17-e46de9703418`) +**Scope:** {{read-only | included remediation}} + +## Summary + +- {{3-5 bullets: breach indicators found? which categories? priority actions?}} + +## Target details + +| Field | Value | +|---|---| +| UPN | | +| Object ID | | +| Account Enabled | | +| Created | | +| Last Password Change | | + +## Per-check findings + +### 1. Inbox rules (Graph) +{{count, flagged items verbatim}} + +### 2. Mailbox forwarding / settings +{{forwarding flags, auto-reply status}} + +### 3. Exchange REST (hidden rules, delegates, SendAs, Get-Mailbox) +{{hidden rule count, non-SELF permissions, ForwardingAddress/ForwardingSmtpAddress}} + +### 4. OAuth consents + app role assignments +{{apps consented, when, scopes}} + +### 5. Authentication methods +{{methods, creation dates — flag any inside attack window}} + +### 6. Sign-ins (30d) +{{count, unique IPs, countries, failures — flag non-US and legacy client apps}} + +### 7. Directory audits +{{30d changes targeting user, by-whom analysis}} + +### 8. Risky users / risk detections +{{risk level, recent detections — or note if blocked by missing permission}} + +### 9. Sent items (recent 25) +{{sample of recipients/subjects — flag blast patterns or unusual externals}} + +### 10. Deleted items (recent 25) +{{sample — flag deleted security alerts or MFA notifications}} + +## Suspicious items (pulled out of per-check findings) + +{{bullets for anything abnormal — external forwards, hidden rules, unfamiliar consents, foreign-geo sign-ins, new auth methods within attack window}} + +## Gaps — checks not completed + +{{list any 403s or missing permissions with exact remediation link (see gotchas.md)}} + +## Next actions + +1. {{specific action + owner + deadline}} +2. {{...}} + +## Remediation actions (if any) + +{{populated only when `/remediation-tool remediate` was executed — include cmdlet, parameters, response, timestamp}} + +## Data artifacts + +Raw JSON saved at `/tmp/remediation-tool/{{tenant-id}}/{{check-dir}}/` — files: +- {{list filenames the scripts produced}} diff --git a/clients/cascades-tucson/reports/2026-04-16-john-breach-check.md b/clients/cascades-tucson/reports/2026-04-16-john-breach-check.md new file mode 100644 index 0000000..6cc2c20 --- /dev/null +++ b/clients/cascades-tucson/reports/2026-04-16-john-breach-check.md @@ -0,0 +1,180 @@ +# John Trozzi - Credential Stuffing Breach Check + +**Date:** 2026-04-16 +**Tenant:** Cascades Tucson (cascadestucson.com, 207fa277-e9d8-4eb7-ada1-1064d2221498) +**User:** John Trozzi (john.trozzi@cascadestucson.com, a638f4b9-6936-4401-a9b7-015b9900e49e) +**Tool:** Claude-MSP-Access Graph API (remediation tool) + +## Summary + +- **No breach artifacts detected** — across 9 of 10 planned checks (Exchange REST now working). Mailbox is clean: no hidden rules, no delegates, no forwarding, no SendAs grants, no new OAuth consents, no new auth methods, no foreign-geo sign-ins, no anomalous sent/deleted items. +- Sign-in history (last 30d visible via v1.0): **11 sign-ins, 100% from 184.191.143.62 (Phoenix, AZ — Cox local IP)**. Only 1 failure (benign "Keep me signed in" interrupt). +- Directory audit shows clean IR sequence by sysadmin@cascadestucson.com: disable -> reset password -> enable. John then self-changed password at 16:04:46 UTC. +- **1 check still blocked**: Identity Protection (Risky Users / Risk Detections) — IdentityRiskyUser permission exists in app manifest but was not granted in the re-consent attempt. See Gap #2. +- **Confirm target:** We investigated John **Trozzi** (only John in tenant). If the victim is a different John, re-run. + +## John's Account + +| Field | Value | +|-|-| +| UPN | john.trozzi@cascadestucson.com | +| Object ID | a638f4b9-6936-4401-a9b7-015b9900e49e | +| Account Enabled | true | +| Created | 2022-02-18T18:31:39Z | +| Last Password Change | **2026-04-16T15:45:55Z** (sysadmin reset), then 2026-04-16T16:04:46Z (John self-change) | + +## Per-Check Findings + +### 1. Inbox Rules (Graph v1.0) - CLEAN +`/users/{upn}/mailFolders/inbox/messageRules` -> `value: []` (no rules). +Note: this endpoint does NOT show hidden rules. See Gap #1 below. + +### 2. Mailbox Forwarding / Settings - CLEAN +- User object: no `forwardingSmtpAddress`, no `forwardingAddress`. +- mailboxSettings: no forwarding configured. +- Auto-reply: **status=disabled** (off); scheduled 2026-04-16 16:00 UTC to 2026-04-17 16:00 UTC but reply bodies empty. Likely residual stub or John preparing OOO. Not active. + +### 3. Delegates / Mailbox Permissions / Hidden Rules (Exchange REST) - CLEAN (after role assigned) +After Exchange Administrator role was granted to ComputerGuru - AI Remediation SP at ~16:35 UTC: + +- **Get-InboxRule -IncludeHidden**: 1 rule found — "Junk E-mail Rule" (system default). No Forward/Redirect/Delete/Move actions. **CLEAN.** +- **Get-MailboxPermission**: only `NT AUTHORITY\SELF` (FullAccess+ReadPermission) — the owner. **No external full-access delegates.** +- **Get-RecipientPermission** (SendAs): only `NT AUTHORITY\SELF`. **No external SendAs.** +- **Get-Mailbox** forwarding fields: + - ForwardingAddress: null + - ForwardingSmtpAddress: null + - DeliverToMailboxAndForward: false + - GrantSendOnBehalfTo: [] + - HiddenFromAddressListsEnabled: false + - WhenChanged: 2026-04-16T16:05:31Z (recent — likely from the password reset / session revoke) +- **CLEAN** at mailbox level. + +### 4. OAuth Consents + App Role Assignments - CLEAN +Single user-consented app: +- **BlueMail** (3508ac12-63ff-4cc5-8edb-f3bb9ca63e4e) + - Scope 1: `User.Read` (on Microsoft Graph) + - Scope 2: `EAS.AccessAsUser.All Exchange.Manage` (on Office 365 Exchange Online, 072df88c-...) + - App role assignment created **2022-02-18T21:59:50Z** (same day as account creation — longstanding, legitimate) +- No new consents in attack window. + +### 5. Authentication Methods - CLEAN +| Method | Created | Notes | +|-|-|-| +| Password | 2026-04-16T15:45:55Z | Reset today (expected) | +| Phone +1 5203658200 (mobile) | unknown (old) | | +| MS Authenticator - Samsung SM-F731U | 2026-02-12T01:25:40Z | Pre-attack | +| MS Authenticator - SM-F731U (SoftwareToken) | null | Old | +| FIDO2 "Authenticator: Default Profile" (Android) | 2026-02-12T01:23:45Z | Pre-attack | + +All non-password methods predate the attack window. **Attacker did not register a new auth method.** + +### 6. Sign-Ins (v1.0 - interactive only) - 30-day window - CLEAN +- Count: 11 +- Unique IPs/geos: **only 184.191.143.62 / Phoenix, AZ, US** (Cox Communications, local) +- Failures: 1 (errorCode 50140, KMSI interrupt - benign) +- Apps: Microsoft Authentication Broker, Microsoft Office, My Profile, My Signins, Microsoft Account Controls V2 +- No anomalous client apps (no IMAP/POP/legacy) + +Note: v1.0 `/auditLogs/signIns` shows interactive sign-ins only. Non-interactive / service-principal sign-ins would require `/beta/auditLogs/signIns?$filter=signInEventTypes/any(t:t eq 'nonInteractiveUser')`. Not fetched in this pass. + +### 7. Directory Audits (targetResources = John) - CLEAN +31 events, all 2026-04-16. Timeline: +- 15:41:46 -> 15:41:52: sysadmin disables account (initial IR) +- 15:44:06 -> 15:44:19: enable / disable cycle (sysadmin) +- 15:45:55: **Reset user password** by sysadmin + token refresh invalidation +- 15:47:36 -> 15:47:42: enable account, updates +- 15:53:52: Azure MFA StrongAuthenticationService update (MFA state) +- 16:04:46: **User changed password** (John self-change post-reset) + +All initiators are `sysadmin@cascadestucson.com`, `Azure MFA StrongAuthenticationService`, `Microsoft Substrate Management`, or John himself. **No unauthorized changes.** + +### 8. Risky Users / Risk Detections - BLOCKED (403) +`/identityProtection/riskyUsers` and `/identityProtection/riskDetections` returned Forbidden. App is missing `IdentityRiskyUser.Read.All` Graph permission. See Gap #2. + +### 9. Sent Items (last 25) - CLEAN +All legitimate business correspondence: vendor replies (door veneer, master plan, pool table lighting, Model 1 Commercial Vehicles), internal fwds to coworkers (accounting, frontdesk, lauren.hasselman). Most recent 2026-04-16T15:28 to wpeterson@unwiredengineering.com (legitimate vendor). **No unusual recipients, no blast/phishing patterns.** + +### 10. Deleted Items (last 25) - CLEAN +Normal marketing/promo (Wayfair, BestBuy, Spotify, FloorAndDecor, Ring) + voicemails (8x8) + Zoom invites + legitimate business from arcstudiosinc.com. **Nothing security-relevant (no password-reset notices, no security alerts being hidden).** + +## Gaps - Checks We Could Not Complete + +### Gap #1: Exchange REST (403) +Cascades Tucson tenant does not have Exchange Administrator role assigned to Claude-MSP-Access SP. Missing checks: +- **Hidden inbox rules** (`Get-InboxRule -IncludeHidden` — attackers commonly create hidden rules that Graph v1.0 can't see) +- **Mailbox permissions / delegates** (attacker could have granted FullAccess or SendAs to an external/compromised account) +- **Mailbox-level forwarding flags** (`ForwardingAddress`, `ForwardingSmtpAddress`, `DeliverToMailboxAndForward`) + +**Remediation:** In Entra portal > Roles and administrators > Exchange Administrator > Add "Claude-MSP-Access" service principal (App ID: fabb3421-8b34-484b-bc17-e46de9703418). Then re-run Check 3. + +### Gap #2: Identity Protection (403) +App is missing `IdentityRiskyUser.Read.All` Graph permission. Missing checks: +- Risky user classification (low/medium/high/none) +- Risk detections (unfamiliar location, atypical travel, malware-linked IP, leaked credentials, password spray events) + +**Remediation:** In Entra portal > App registrations > Claude-MSP-Access > API permissions > add `IdentityRiskyUser.Read.All` (application) + grant admin consent. Then re-run Check 8. + +### Gap #3: Non-interactive sign-ins +v1.0 endpoint hides non-interactive and service-principal sign-ins. Attacker using refresh tokens or IMAP/SMTP would show up under `/beta/auditLogs/signIns` with `signInEventTypes` filter. Worth running once Gap #2 is resolved. + +## Next Actions + +1. **Confirm victim identity** — we investigated John **Trozzi**. Only one "John" in the tenant. If this is correct, proceed. +2. **Assign Exchange Administrator role** to Claude-MSP-Access in this tenant (5 min in Entra portal) so we can check hidden inbox rules + delegates — attackers' #1 persistence mechanism post-credential-stuffing. +3. **Add IdentityRiskyUser.Read.All** Graph permission to confirm Azure Identity Protection didn't flag John. +4. **Check the scheduled auto-reply** (16:00 UTC 04-16 to 16:00 UTC 04-17) with John — confirm he set it. +5. **Review Cascades Tucson Conditional Access policies** — session log 2026-04-01 noted 8 policies all enabled (MFA all users, legacy auth blocked). That's good defense; if credential stuffing targeted this tenant it likely bounced off MFA. +6. **Ask John** where he was when he noticed the breach signals (e.g., alert email, unfamiliar sign-in prompt). Origin of his suspicion may tell us whether the attack actually succeeded or was only attempted. +7. **Optional:** Pull `/beta/auditLogs/signIns?$filter=userId eq '' and signInEventTypes/any(t:t eq 'nonInteractiveUser')` to cover non-interactive tokens. + +## Tenant-Wide Findings (all 46 enabled accounts) + +Expanded sweep 2026-04-16 ~16:45 UTC. + +### PRIORITY: Megan Hiatt is under active credential-stuffing attack +- **126 failed sign-in attempts in last 30 days** — 8 unique IPs across CH, DE, GB, LT, NL, US +- Active TODAY: 23 failures at 15:58–16:01 UTC from **80.94.92.102 (Belfast, GB)** via **Authenticated SMTP** to Office 365 Exchange Online +- Earlier bursts: 2026-04-15 (Hamburg DE), 2026-04-13 (Belfast GB), and older activity noted in 2026-03-31 session log +- Block reason: "Sign-in was blocked because it came from an IP address with malicious activity" / account lockouts +- **Megan is NOT compromised** — every attempt blocked by IP reputation + MFA + account lockout. But: + - **Password last changed 2026-02-18** (~2 months old — attackers clearly have a stale but valid credential or are brute-forcing) + - Only 1 MFA method (MS Authenticator on iPhone 13) + - Mailbox checks CLEAN: no inbox rules beyond default/system + 1 legitimate "Cascade of Tucson" folder-move rule, no forwarding, no delegates + - Successful sign-ins all from AZ (Phoenix/Scottsdale/Glendale) — legitimate +- **Recommendations:** + 1. Force Megan password reset NOW (credential is known-targeted) + 2. Disable SMTP AUTH for Megan specifically (`Set-CASMailbox -SmtpClientAuthenticationDisabled $true`) — or tenant-wide + 3. Consider adding a tenant-wide CA policy blocking legacy auth (likely already in place per 2026-04-01 log) + 4. Monitor — attacker appears to retry in bursts every 1–3 days + +### External guest invite: dunedolly21@gmail.com — likely legitimate, worth confirming +- Invited 2026-04-14 23:57 UTC by **lauren.hasselman@cascadestucson.com** (from her mobile, Phoenix IP) +- Accepted 2026-04-15 01:10 UTC +- No group memberships, no app role assignments — guest has no meaningful access +- Lauren's own sign-ins are clean (100% AZ, MFA via iPhone 16 Pro Max, password reset 2026-03-31) +- **Action:** confirm with Lauren why she invited "dunedolly21" gmail — likely her personal/vendor/family account, but validate + +### Other accounts — clean +- **John Trozzi** — no foreign sign-ins, no breach artifacts (primary report above) +- **sysadmin@cascadestucson.com** — 17 "failures" are all benign: our own ComputerGuru-AI-Remediation consent flow errors (65001 not-consented, 50126 typos during onboarding) and one CA-policy MFA prompt. No attacker activity. +- **accounting@cascadestucson.com** — 1 isolated failure 2026-03-25 from Chennai, India (143.110.255.52), wrong password, never retried. Noise-level threat. +- **lois.lane, nurse, alyssa.brooks, christina.dupras, sharon.edwards, susan.hicks, karen.rossini, etc.** — only US-based failures, low counts, typical typos/MFA prompts. None show attacker patterns. + +### Locations profile (successful sign-ins, 30d) +Every successful sign-in across all 46 accounts was from a US city in Arizona (Phoenix, Scottsdale, Glendale, Tucson) or one Bethlehem PA user (veronica.feller — remote worker). **Zero foreign successful sign-ins tenant-wide.** + +### Directory audit signals - clean +- No unauthorized OAuth consents (the 4 "Consent to application" events today are our own ComputerGuru - AI Remediation re-consents) +- No unauthorized role assignments +- No new auth methods added outside expected MFA setup flows +- Microsoft system actors (Substrate Management, MFA StrongAuthenticationService, B2B Admin Worker) made routine updates + +## Data Artifacts + +Raw JSON responses saved under `/tmp/bc/`: +- 01_inbox_rules.json, 02a_user_obj.json, 02b_mailbox_settings.json +- 04a_oauth_grants.json, 04b_app_role_assignments.json, 05_auth_methods.json +- 06_signins.json (today only), 06b_signins30d.json +- 07_dirAudits.json, 08a_riskyUser.json (error), 08b_riskDetections.json (error) +- 09_sent.json, 10_deleted.json +- 03a_InboxRule.json, 03b_MbxPerm.json, 03c_Mailbox.json (all 403) diff --git a/clients/internal-infrastructure/reports/2026-04-16-howard-breach-check.md b/clients/internal-infrastructure/reports/2026-04-16-howard-breach-check.md new file mode 100644 index 0000000..65aa56b --- /dev/null +++ b/clients/internal-infrastructure/reports/2026-04-16-howard-breach-check.md @@ -0,0 +1,142 @@ +# Howard — Breach Check (azcomputerguru.com) + +**Date:** 2026-04-16 +**Tenant:** AZ Computer Guru (azcomputerguru.com, `ce61461e-81a0-4c84-bb4a-7b354a9a356d`) +**Subject:** howard@azcomputerguru.com (object id `c99de3bd-ddc1-43f1-907f-e84b91273660`) +**Tool:** Claude-MSP-Access / ComputerGuru - AI Remediation — via `/remediation-tool check` +**Scope:** Read-only + +## Summary + +- **No breach indicators.** Every one of the 174 foreign sign-in attempts in the last 30 days FAILED. Zero successful non-US sign-ins. +- Mailbox clean at the Graph level: 3 inbox rules, all user-authored filters (Telnyx status, Atlas_LNP whitelabel, Facebook notifications). No forward/redirect/delete actions. +- 4 OAuth grants + 8 app role assignments — all MSP-relevant apps (Syncro, Kaseya SSO, Tailscale, Graph Explorer, Perfect Wiki, ASUS, Uizard). No unfamiliar consents. +- 6 auth methods — all legitimate MFA (password, SMS, OATH token, 3 Microsoft Authenticator registrations across phone upgrades). +- **Password age: 18 months** (last changed 2024-09-24). Rotate as hygiene. +- **Ongoing credential-stuffing campaign:** attempts from CN (32), IN (32), KR (28), LU (15, via Azure CLI), BR (14), DE, JP, HK, MA, RU, SE, AE, GM, LA, NO, PT, TN, TW, UA, BG, BN, ID, PE, SO, TH, UG. All blocked. + +## Target details + +| Field | Value | +|---|---| +| UPN | howard@azcomputerguru.com | +| Object ID | c99de3bd-ddc1-43f1-907f-e84b91273660 | +| Account Enabled | true | +| Created | 2024-08-14 | +| Last Password Change | 2024-09-24 (18 months ago) | + +## Per-check findings + +### 1. Inbox rules (Graph) — CLEAN +3 rules, all user-authored folder moves: +- `Telnex` — Telnyx status notifications (noreply@statuspage.io) -> folder +- `Move all messages from Atlas_LNP@whitelabelcomm.com to whitelabeel` — WhiteLabel Comm LNP tickets -> folder +- `facebook` — Facebook notification senders -> folder + +No Forward/Redirect/Delete actions. + +### 2. Mailbox forwarding / settings — CLEAN +No forwarding via Graph user/mailboxSettings. Exchange REST check blocked (see Gaps). + +### 3. Hidden inbox rules / delegates / SendAs / mailbox-level forwarding — BLOCKED (403) +Exchange REST returned empty bodies — app's service principal lacks Exchange Administrator role in the azcomputerguru tenant. See Gaps. + +### 4. OAuth consents + app role assignments — CLEAN + +OAuth grants (user-consented scopes on Microsoft Graph): +| Client ID | Scopes | +|---|---| +| `bda7b1c9-f852-4916-ba9a-5942623882d8` | openid profile User.Read offline_access | +| `0f06016e-1ad1-4996-ad6c-25233e3bd997` | offline_access Calendars.ReadWrite | +| `c1ba11bc-9be2-4720-b6ac-7a19d3f31029` | openid email profile | +| `fe7fb591-b8ea-4715-87ee-b46375eb32c9` | User.Read email profile Team.ReadBasic.All Channel.ReadBasic.All offline_access openid | + +App role assignments (apps Howard has access to): +| Resource | Created | +|---|---| +| Syncro (original) | 2021-12-06 | +| Syncro v2 | 2024-08-27 | +| ASUS Account | 2024-11-07 | +| Perfect Wiki | 2025-02-11 | +| KaseyaSSO | 2025-05-11 | +| Tailscale | 2025-06-28 | +| Graph Explorer | 2025-11-07 | +| Uizard | 2025-11-21 | + +All fit MSP-tech profile. Nothing recent + unknown. + +### 5. Authentication methods — CLEAN +- Password (2024-09-24) +- Phone `+1 520-585-1310` +- Software OATH token +- Microsoft Authenticator "Pixel 6 Pro" +- Microsoft Authenticator "DE2118" +- Microsoft Authenticator "GooglePixel 6 Pro" (2025-06-25) + +Multiple Authenticator entries reflect phone upgrades/re-registrations over time. No method added inside a suspicious window. + +### 6. Sign-ins (30d) — CLEAN (attack active, fully blocked) + +**200 total sign-ins in 30 days. 174 non-US. Every non-US attempt FAILED. Zero successful foreign sign-ins.** + +Foreign failure distribution: + +| Country | Attempts | App targeted | +|---|---|---| +| CN | 32 | Office 365 Exchange Online | +| IN | 32 | Office 365 Exchange Online | +| KR | 28 | Office 365 Exchange Online | +| LU | 15 | Microsoft Azure CLI | +| BR | 14 | Office 365 Exchange Online | +| DE | 8 | Azure AD PowerShell | +| JP | 8 | Azure AD PowerShell | +| HK | 4 | Office 365 Exchange Online | +| MA | 4 | Office 365 Exchange Online | +| RU | 3 | Office 365 Exchange Online | +| SE | 3 | Office 365 Exchange Online | +| AE, GM, LA, NO, PT, TN, TW, UA | 2 each | Office 365 Exchange Online | +| BG, BN, ID, PE, SO, TH, UG | 1 each | Office 365 Exchange Online | + +Pattern: broad, distributed credential stuffing. Most attempts target legacy auth against Exchange Online. Luxembourg block specifically targets Azure CLI (corporate cloud-admin path). Germany + Japan targets Azure AD PowerShell. **Attacker knows Howard is an MSP admin and is probing admin-grade endpoints.** + +### 7. Directory audits (targetResources = Howard) — CLEAN +0 events in 30 days targeting Howard's account. No unauthorized changes. + +### 8. Risky users / risk detections — BLOCKED (403) +`IdentityRiskyUser.Read.All` not consented in azcomputerguru tenant. See Gaps. + +### 9. Sent items (recent 25) — CLEAN +Normal business correspondence. No blast patterns. + +### 10. Deleted items (recent 25) — CLEAN +Normal marketing/notifications. No deleted security alerts. + +## Gaps — blocked by missing permissions + +### Gap #1: Exchange REST (403) +The ComputerGuru - AI Remediation service principal doesn't have Exchange Administrator role in **our own** azcomputerguru tenant. Blocks: +- Hidden inbox rules (`Get-InboxRule -IncludeHidden`) +- Mailbox permissions / delegates +- SendAs permissions +- Mailbox-level forwarding flags + +**Fix:** Entra -> Roles & admins -> Exchange Administrator -> Add assignment -> search "ComputerGuru - AI Remediation" -> Active (permanent). + +### Gap #2: Identity Protection (403) +`IdentityRiskyUser.Read.All` not consented in azcomputerguru tenant. Blocks risky user classification and risk detection history. + +**Fix:** Admin consent URL - +``` +https://login.microsoftonline.com/ce61461e-81a0-4c84-bb4a-7b354a9a356d/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient +``` + +## Priority actions + +1. **Rotate Howard's password** — hygiene, 18 months old and he's actively targeted. Good time for a change. +2. **Close the gaps above on our own tenant** — we've been running the remediation tool against customer tenants without ever consenting on our own home tenant. That's an oversight. +3. **Review legacy auth exposure tenant-wide.** The credential-stuffing targets Exchange Online basic auth and AAD PowerShell — both should be blocked by Conditional Access. Confirm CA policies block legacy auth tenant-wide (not just for Howard). +4. **Consider moving Howard to passwordless / FIDO2 as primary** — given the volume of attempts, elevating beyond password+MFA would effectively neutralize the campaign. + +## Data artifacts + +Raw JSON at `/tmp/remediation-tool/ce61461e-81a0-4c84-bb4a-7b354a9a356d/user-breach/howard_azcomputerguru_com/` diff --git a/clients/valleywide/README.md b/clients/valleywide/README.md index f49398f..a4fb5b3 100644 --- a/clients/valleywide/README.md +++ b/clients/valleywide/README.md @@ -45,8 +45,41 @@ RDWeb (`https://VWP-QBS/RDWeb/Pages/login.aspx`) was exposed to the public inter - Consider 2FA / Conditional Access on any externally-reachable Windows service - Rotate `scanner` AD account password (last set 2024-10-17) as hygiene +## 2026-04-16: RemoteApp over VPN (post-gateway) + RDS licensing fix + +After the 2026-04-13 public RDWeb port-forward removal, users launching the QuickBooks RemoteApp via VPN hit `0x3000008` (RD Gateway unreachable) because the RDP manifest still routed through the gateway at the (now-firewalled) public IP. + +### Changes made + +1. **RDS Deployment** (on VWP-QBS, via Server Manager -> RDS -> Edit Deployment Properties -> RD Gateway) set to **"Do not use an RD Gateway server"**. New RDP manifests now write `gatewayusagemethod:i:0` and `full address:s:VWP-QBS.VWP.US` — direct connect, no gateway. + +2. **UDM static DNS record** fixed typo `qwp-qbs.vwp.us` -> `vwp-qbs.vwp.us` (UniFi UI: Settings -> Routing -> DNS -> Static DNS Records), still pointing to `172.16.9.169`. Required because `vwp.us` is a real registered domain (resolves publicly to the website) but `vwp-qbs.vwp.us` is only valid internally. VPN clients receive DNS=192.168.4.1 (the UDM) via OpenVPN push, so this override is what lets them find the session host. + +3. **RDS licensing configuration** (on VWP-QBS, via `Win32_TerminalServiceSetting` WMI): + - Mode: Per User (LicensingType=4) + - Specified license server: `vwp-qbs.vwp.us` (the same box — RDS-Licensing role was installed and activated but the RDSH was never pointed at it, so users hit "no license servers available") + +### Rationale + +- **No RD Gateway needed**: VWP users are either on-LAN or VPN-connected. OpenVPN pushes routes for 172.16.9.0/24, 192.168.0.0/24, 192.168.3.0/24. VPN -> LAN firewall policy is ACCEPT-all (`UBIOS_VPN_LAN_USER`). Gateway was only serving the public-access use case which is now intentionally closed. +- **DNS override avoids split-horizon complexity**: rather than pushing internal AD DNS (172.16.9.2) to VPN clients, we use the UDM's dnsmasq for both public and internal names, with overrides for the handful of internal FQDNs clients actually need. + +### Current VPN / DNS topology + +- OpenVPN server on UDM: pushes `192.168.4.0/24` to clients, routes for the three LAN subnets, DNS=192.168.4.1 (UDM) +- Site-to-site WireGuard peers visible on UDM (`wgsts1001`, `wgsts1003`, `wgsts1005`) — likely UniFi SiteMagic to ACG / other sites +- Static DNS records on UDM (as of 2026-04-16): `vwp-qbs.vwp.us` -> `172.16.9.169` + +## RDS CAL purchase (outstanding) + +VWP-QBS's RDS License Server is activated and running, but **has no real CALs installed** — only the Windows 2000-era `Built-in TS Per Device CAL` placeholder pack. Once grace period expires (or after the 2026-04-16 pointer fix re-triggers licensing logic), users will either get a fresh grace window or start seeing "license server has no licenses" errors. + +**Action item:** purchase a pack of **Windows Server 2022 RDS Per User CALs** sized to the active user count (check VWP-QBS for distinct interactive logon count last 30d to size accurately). Install via `licmgr.msc` on VWP-QBS. Current licensing mode is Per User, matching this purchase path. + ## Open items -- Confirm UPnP state on UDM -- Document intended RDWeb access pattern (who connects from where) -- Add Valleywide entry to SOPS vault +- Confirm UPnP state on UDM (2026-04-13 recommendation — still not verified) +- Document intended RDWeb access pattern (who connects from where) — superseded partially by 2026-04-16 VPN-only decision, but formalize +- Add Valleywide entry to SOPS vault (SOPS vault now has `clients/vwp/*` entries: adsrvr, dc1, udm, xenserver, quickbooks-server-idrac — superseded) +- RDS CALs purchase (see above) +- Rotate `scanner` AD account password (carried from 2026-04-13) diff --git a/projects/msp-tools/guru-rmm/session-logs/2026-04-16-session.md b/projects/msp-tools/guru-rmm/session-logs/2026-04-16-session.md index 1f2d71b..66f9b8c 100644 --- a/projects/msp-tools/guru-rmm/session-logs/2026-04-16-session.md +++ b/projects/msp-tools/guru-rmm/session-logs/2026-04-16-session.md @@ -305,3 +305,55 @@ az ad sp credential reset --id 516d0bdc-5416-4d02-8521-b70e2bb26d29 - **First signed MSI:** 2026-04-16 15:15 UTC (gururmm-agent-0.6.1.msi, 1.16 MB) - **Full Microsoft cert chain validates** through signtool from Windows workstation for both .exe and .msi - **Billing impact:** Trusted Signing Basic ~$9.99/mo + per-signature fees (fractional cents each). SP creation, cert profile creation, jsign — all free. + +--- + +## Update: afternoon (continued same day) + +### MSI Installer — tested + verified + +- WiX 5.0.2 installed on Windows workstation via `dotnet tool install --global wix --version 5.0.2` +- WiX does NOT work on Linux (despite .NET tooling — errors on Directory path validation). Windows-only for MSI builds. +- WiX 7 was blocked by OSMF EULA requirement — stepped back to v5. +- Built minimal `installer/gururmm.wxs` (installs exe to Program Files, creates ProgramData dir, Apps & Features entry) +- Signed MSI via `sign.ps1` — full chain verifies (Arizona Computer Guru LLC) +- Test install: `msiexec /qn` silent install ✓, signature preserved on installed binary ✓, Apps & Features shows publisher ✓, uninstall clean ✓ +- `installer/build-msi.ps1` wrapper script created (downloads signed agent, builds MSI, signs MSI, emits sha256) +- Decision: Jupiter Windows VM (Server 2022) planned for production MSI builds; WiX on user's workstation for now + +### Len's Auto Brokerage — test client onboarded + +- **Client:** Len's Auto Brokerage (code: LAB) +- **Client ID:** bc76984f-8dc9-42e7-b978-c8def1143144 +- **Site:** Main +- **Site ID:** d8f69cd8-5c42-43bc-ae45-9cc6078d37fb +- **Site code:** UPPER-STAR-2820 +- **API key:** grmm_mnR0gxGRxZ9wMqyn9Q4QxCrn6jbsJkZW (shown once, saved to vault) +- ~10 Windows endpoints planned +- Vault entry: `clients/lens-auto-brokerage.sops.yaml` + +### Server migration issue discovered + +- Attempted to rebuild gururmm-server to get `/install/:site_code` routes (exist in source but not in running binary) +- New build fails: `migration 5 was previously applied but has been modified` — sqlx checksum drift +- Migration 5 (005_temperature_metrics.sql) file content unchanged per git but sha384 doesn't match DB's recorded hash +- Likely cause: sqlx crate version upgrade changed hash algorithm, or file bytes changed via line-ending normalization +- Rolled back to stable binary (production restored, /health OK) +- **Open item:** fix migration checksum drift to deploy server with install landing page routes + +### Smart App Control docs + +- Documented how to check/disable SAC on Windows 11 (for agent install at client sites) +- SAC is separate from SmartScreen — our Public Trust signing helps SmartScreen but SAC is stricter +- Main path: check state via `Get-MpComputerStatus`, disable via Settings if blocking, add Defender exclusions + +### Uranus server (ex-Pavon) — documented + +- Pavon server renamed to Uranus, re-IP'd from 172.16.1.33 → 172.16.3.21 +- OwnCloud external storage mount (ID 6, SMB share `Storage`) updated from old IP to new via `occ files_external:config 6 host 172.16.3.21` +- Verified: `files_external:verify 6` → status ok +- Swept all infrastructure (vault, CF DNS, NPM, pfSense) — no other references to old IP +- Dell PowerEdge R730xd, 32 threads (Xeon E5-2630 v3), only 7.7 GiB RAM (2× 4GB RDIMM in 24 slots) +- RAM upgrade needed before Windows build VM — recommended 8× 8GB DDR4 RDIMM (~$50 eBay) +- Jupiter VM for build in the meantime (125 GiB RAM, ~60 GiB free) +- Vault entry: `infrastructure/uranus-unraid.sops.yaml`, credentials.md updated diff --git a/projects/msp-tools/howard-bootstrap/README.txt b/projects/msp-tools/howard-bootstrap/README.txt new file mode 100644 index 0000000..47c42ec --- /dev/null +++ b/projects/msp-tools/howard-bootstrap/README.txt @@ -0,0 +1,42 @@ +AZ Computer Guru - ClaudeTools Setup +===================================== + +This package sets up the Claude Code workspace on your machine. + +WHAT'S INCLUDED: + setup.bat - Run this first. It installs everything. + keys.txt - Vault decryption key (if Mike included it) + README.txt - This file + +WHAT IT DOES: + 1. Checks for prerequisites (git, claude, python, sops) + - Auto-installs missing ones via winget + 2. Clones the shared ClaudeTools repo from Gitea + 3. Clones the encrypted credential vault + 4. Sets up the decryption key for vault access + 5. Creates a "ClaudeTools" shortcut on your desktop + +HOW TO RUN: + 1. Extract this zip to any drive (e.g., D:\) + 2. Double-click setup.bat + 3. Follow the prompts (you'll be asked for your Gitea + password on first clone - ask Mike) + 4. After setup, double-click "ClaudeTools" on your desktop + 5. Claude will introduce itself and walk you through everything + +YOUR GITEA ACCOUNT: + URL: https://git.azcomputerguru.com + Username: howard + Password: Ask Mike (you'll change it on first login) + +IF SOMETHING GOES WRONG: + - Close and re-run setup.bat (it's safe to run multiple times) + - If git clone fails: check network/VPN/Tailscale connection + - If vault fails: make sure keys.txt is at + %APPDATA%\sops\age\keys.txt + - Ask Mike or ask Claude (once it's running) + +AFTER SETUP: + Your workspace lives at :\claudetools + Credentials vault at :\vault + Everything syncs to Gitea automatically via /sync command diff --git a/projects/msp-tools/howard-bootstrap/keys.txt b/projects/msp-tools/howard-bootstrap/keys.txt new file mode 100644 index 0000000..5893fdf --- /dev/null +++ b/projects/msp-tools/howard-bootstrap/keys.txt @@ -0,0 +1,3 @@ +# created: 2026-03-30T13:53:19-07:00 +# public key: age1qz7ct84m50u06h97artqddkj3c8se2yu4nxu59clq8rhj945jc0s5excpr +AGE-SECRET-KEY-1DE3V6V0ZLLZ45A7GA77M79CTN4LZQMTRCURP8VRGNLV6T2FSZEEQXUW2EU diff --git a/projects/msp-tools/howard-bootstrap/setup.bat b/projects/msp-tools/howard-bootstrap/setup.bat new file mode 100644 index 0000000..715c3fc --- /dev/null +++ b/projects/msp-tools/howard-bootstrap/setup.bat @@ -0,0 +1,176 @@ +@echo off +setlocal EnableDelayedExpansion +title AZ Computer Guru - ClaudeTools Setup +color 0A + +echo ============================================ +echo AZ Computer Guru - ClaudeTools Bootstrap +echo ============================================ +echo. +echo This sets up the Claude Code workspace on +echo this machine. Takes about 5 minutes. +echo. +echo Press any key to start, or Ctrl+C to cancel. +pause >nul + +:: Determine target drive (same drive as this script) +set "DRIVE=%~d0" +set "BASE=%DRIVE%\claudetools" +set "VAULT=%DRIVE%\vault" +set "AGE_DIR=%APPDATA%\sops\age" +set "SCRIPT_DIR=%~dp0" + +echo. +echo [1/7] Checking prerequisites... + +:: Check git +where git >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo [!] git not found. Installing via winget... + winget install --id Git.Git -e --accept-package-agreements --accept-source-agreements + echo [!] Please close and reopen this script after git installs. + pause + exit /b 1 +) +echo [OK] git found + +:: Check claude +where claude >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo [!] Claude Code not found. Installing... + winget install --id Anthropic.ClaudeCode -e --accept-package-agreements --accept-source-agreements 2>nul + if %ERRORLEVEL% neq 0 ( + echo [!] winget install failed. Try: npm install -g @anthropic-ai/claude-code + echo OR download from https://claude.ai/download + pause + exit /b 1 + ) +) +echo [OK] Claude Code found + +:: Check sops +where sops >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo [!] SOPS not found. Installing via winget... + winget install --id Mozilla.sops -e --accept-package-agreements --accept-source-agreements +) +echo [OK] SOPS found + +:: Check python +where python >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo [!] Python not found. Installing via winget... + winget install --id Python.Python.3.12 -e --accept-package-agreements --accept-source-agreements + echo [!] Please close and reopen this script after Python installs. + pause + exit /b 1 +) +echo [OK] Python found + +echo. +echo [2/7] Setting up age decryption key... + +if not exist "%AGE_DIR%" mkdir "%AGE_DIR%" +if exist "%SCRIPT_DIR%keys.txt" ( + copy /Y "%SCRIPT_DIR%keys.txt" "%AGE_DIR%\keys.txt" >nul + echo [OK] Age key installed from bootstrap package +) else if exist "%AGE_DIR%\keys.txt" ( + echo [OK] Age key already present +) else ( + echo [!!] Age decryption key not found! + echo. + echo Ask Mike for the keys.txt file and place it at: + echo %AGE_DIR%\keys.txt + echo. + echo Without this file, credential vault access won't work. + echo Setup will continue but vault commands will fail until + echo the key is in place. + echo. + pause +) + +echo. +echo [3/7] Cloning ClaudeTools repo... + +if not exist "%BASE%\.git" ( + git clone https://howard@git.azcomputerguru.com/azcomputerguru/claudetools.git "%BASE%" + if %ERRORLEVEL% neq 0 ( + echo [!] Clone failed. Check your Gitea credentials. + echo Username: howard + echo Password: ask Mike for initial password + pause + exit /b 1 + ) + echo [OK] Cloned to %BASE% +) else ( + echo [OK] Already exists, pulling latest... + cd /d "%BASE%" && git pull +) + +echo. +echo [4/7] Cloning Vault repo... + +if not exist "%VAULT%\.git" ( + git clone https://howard@git.azcomputerguru.com/azcomputerguru/vault.git "%VAULT%" + if %ERRORLEVEL% neq 0 ( + echo [!] Vault clone failed. Check credentials. + pause + exit /b 1 + ) + echo [OK] Cloned to %VAULT% +) else ( + echo [OK] Already exists, pulling latest... + cd /d "%VAULT%" && git pull +) + +echo. +echo [5/7] Configuring git identity... + +cd /d "%BASE%" +git config user.name "Howard Enos" +git config user.email "howard@azcomputerguru.com" +cd /d "%VAULT%" +git config user.name "Howard Enos" +git config user.email "howard@azcomputerguru.com" +echo [OK] Git identity set to Howard Enos + +echo. +echo [6/7] Creating desktop shortcut... + +set "SHORTCUT=%USERPROFILE%\Desktop\ClaudeTools.bat" +( +echo @echo off +echo title ClaudeTools - AZ Computer Guru +echo cd /d "%BASE%" +echo claude +) > "%SHORTCUT%" +echo [OK] Created: %SHORTCUT% + +echo. +echo [7/7] Verifying setup... + +echo Repo: %BASE% +echo Vault: %VAULT% +echo Age key: %AGE_DIR%\keys.txt +if exist "%AGE_DIR%\keys.txt" ( + echo Vault: [OK] key present + cd /d "%BASE%" + bash "%VAULT%/scripts/vault.sh" list 2>nul | find /c ".sops.yaml" >nul 2>&1 && echo Decrypt: [OK] vault accessible || echo Decrypt: [!] vault test failed +) else ( + echo Vault: [!] key missing - ask Mike +) + +echo. +echo ============================================ +echo Setup Complete! +echo ============================================ +echo. +echo Next steps: +echo 1. Double-click "ClaudeTools" on your desktop +echo 2. Claude will ask who you are - say "Howard" +echo 3. Claude will walk you through the system +echo. +echo If you need the vault key, ask Mike. +echo Your Gitea login: howard / (ask Mike for password) +echo. +pause