Session log: multi-user setup, audit + gap fixes, Howard onboarding package
Two session logs: - session-logs/2026-04-16-session.md: cross-cutting (multi-user, audit, infrastructure) - guru-rmm session log appended: MSI installer, Len's Auto Brokerage, Uranus, migration drift Gap fixes: GrepAI initialized + MCP server added, Ollama models pulling, settings.json created (bypassPermissions), MCP_SERVERS.md written. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
109
.claude/MCP_SERVERS.md
Normal file
109
.claude/MCP_SERVERS.md
Normal file
@@ -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/<name>/`
|
||||
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*
|
||||
118
.claude/commands/remediation-tool.md
Normal file
118
.claude/commands/remediation-tool.md
Normal file
@@ -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 <upn>` | 10-point breach check on a single user |
|
||||
| `/remediation-tool sweep <domain>` | Tenant-wide signals (sign-ins, audits, risky users, guests) |
|
||||
| `/remediation-tool signins <domain> [--user upn] [--failed-only] [--days N]` | Ad-hoc sign-in query |
|
||||
| `/remediation-tool consent-url <domain>` | Emit admin consent URL for a tenant |
|
||||
| `/remediation-tool remediate <upn> <action>` | **GATED:** password-reset, revoke-sessions, disable-forwarding, remove-inbox-rules, disable-account |
|
||||
|
||||
`<domain>` 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 <domain>` — 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 <tenant-id> 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 <upn>`** -> `bash scripts/user-breach-check.sh <tenant> <upn>`. Script runs all 10 checks in parallel where possible and dumps raw JSON to `/tmp/remediation-tool/{tenant}/user-breach/<slug>/`. Claude then interprets findings against the rubric in `references/checklist.md` and writes a report.
|
||||
|
||||
- **`sweep <domain>`** -> `bash scripts/tenant-sweep.sh <tenant>`. 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 <domain>`** — 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/<slug>/` 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/<slug>/`.
|
||||
|
||||
### 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: <action> for <target>`. Do not push unless the user asks.
|
||||
|
||||
---
|
||||
|
||||
## Remediation (gated)
|
||||
|
||||
When the user runs `/remediation-tool remediate <upn> <action>`:
|
||||
|
||||
1. **Confirm read-only context first**: the skill must have recently run a `check <upn>` in this session (check `/tmp/remediation-tool/{tenant}/user-breach/<slug>/` 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/<slug>-YYYY-MM-DDTHHMMSS.json`.
|
||||
5. Update the user's report with a `## Remediation Actions` section appending what was done and the result.
|
||||
|
||||
Allowed `<action>` 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`
|
||||
@@ -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:
|
||||
|
||||
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions"
|
||||
},
|
||||
"preferences": {
|
||||
"autoCompact": true,
|
||||
"verbose": false
|
||||
}
|
||||
}
|
||||
46
.claude/skills/remediation-tool/SKILL.md
Normal file
46
.claude/skills/remediation-tool/SKILL.md
Normal file
@@ -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 <user>'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 <user> 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.
|
||||
48
.claude/skills/remediation-tool/references/checklist.md
Normal file
48
.claude/skills/remediation-tool/references/checklist.md
Normal file
@@ -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@<tenant>` 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.
|
||||
77
.claude/skills/remediation-tool/references/gotchas.md
Normal file
77
.claude/skills/remediation-tool/references/gotchas.md
Normal file
@@ -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.
|
||||
144
.claude/skills/remediation-tool/references/graph-endpoints.md
Normal file
144
.claude/skills/remediation-tool/references/graph-endpoints.md
Normal file
@@ -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":"<cmdlet>","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.
|
||||
98
.claude/skills/remediation-tool/scripts/get-token.sh
Normal file
98
.claude/skills/remediation-tool/scripts/get-token.sh
Normal file
@@ -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 <tenant-id-or-domain> <scope>
|
||||
# <scope>: 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 <tenant-id|domain> <scope>}"
|
||||
SCOPE_NAME="${2:?usage: get-token.sh <tenant-id|domain> <scope>}"
|
||||
|
||||
# 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"
|
||||
37
.claude/skills/remediation-tool/scripts/resolve-tenant.sh
Normal file
37
.claude/skills/remediation-tool/scripts/resolve-tenant.sh
Normal file
@@ -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 <domain-or-upn-or-tenantid>
|
||||
# Output (stdout): tenant GUID. Exit 0 on success, 1 on failure.
|
||||
set -euo pipefail
|
||||
|
||||
INPUT="${1:?usage: resolve-tenant.sh <domain|upn|tenant-id>}"
|
||||
|
||||
# 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"
|
||||
82
.claude/skills/remediation-tool/scripts/tenant-sweep.sh
Normal file
82
.claude/skills/remediation-tool/scripts/tenant-sweep.sh
Normal file
@@ -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 <tenant-id-or-domain>
|
||||
# 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|domain>}"
|
||||
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"
|
||||
141
.claude/skills/remediation-tool/scripts/user-breach-check.sh
Normal file
141
.claude/skills/remediation-tool/scripts/user-breach-check.sh
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the 10-point breach check on a single user.
|
||||
# Usage: user-breach-check.sh <tenant-id-or-domain> <upn>
|
||||
# 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 <tenant-id|domain> <upn>}"
|
||||
UPN="${2:?usage: user-breach-check.sh <tenant-id|domain> <upn>}"
|
||||
|
||||
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"
|
||||
75
.claude/skills/remediation-tool/templates/breach-report.md
Normal file
75
.claude/skills/remediation-tool/templates/breach-report.md
Normal file
@@ -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}}
|
||||
Reference in New Issue
Block a user