Compare commits
6 Commits
6e2d99bd23
...
daeea5f26c
| Author | SHA1 | Date | |
|---|---|---|---|
| daeea5f26c | |||
| deecac745d | |||
| 327dc329ab | |||
| 0499f06ff8 | |||
| e7233d69a3 | |||
| e2b8fcee21 |
@@ -45,33 +45,25 @@ When invoked, use the Syncro REST API via `curl`. All requests include `?api_key
|
||||
|
||||
Every Syncro API call is attributed to the **owner of the API key**. Comments, line items, timer entries, and invoices created by the API are logged as the API user — regardless of who is running the command. So the skill MUST use a per-user API key that matches the actual tech running it, or comments will be misattributed.
|
||||
|
||||
| Vault entry | Syncro user | user_id |
|
||||
| identity.json user | Syncro user | user_id |
|
||||
|---|---|---|
|
||||
| `msp-tools/syncro-howard.sops.yaml` | Howard Enos | 1750 |
|
||||
| `msp-tools/syncro.sops.yaml` | Michael Swanson | 1735 (current shared fallback) |
|
||||
| `mike` | Michael Swanson | 1735 |
|
||||
| `howard` | Howard Enos | 1750 |
|
||||
|
||||
When Mike generates his own per-user key, add `msp-tools/syncro-mike.sops.yaml` and demote the shared entry or remove it entirely.
|
||||
Keys are baked into the skill below. To add a new user: generate a token in Syncro → Admin → API Tokens, add a case to the key-select block, and store a backup copy in the vault at `msp-tools/syncro-<user>.sops.yaml`.
|
||||
|
||||
### Get API key
|
||||
|
||||
```bash
|
||||
VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh"
|
||||
BASE="https://computerguru.syncromsp.com/api/v1"
|
||||
|
||||
# Select key by identity.json user; fall back to shared key if per-user missing
|
||||
# Per-user keys — actions in Syncro are attributed to the key owner
|
||||
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
|
||||
KEY_PATH="msp-tools/syncro-${USER_ID}.sops.yaml"
|
||||
if ! bash "$VAULT" list 2>/dev/null | grep -qx "${KEY_PATH}"; then
|
||||
echo "[WARN] No per-user Syncro key at ${KEY_PATH} — falling back to shared key. Actions will be attributed to the shared key owner." >&2
|
||||
KEY_PATH="msp-tools/syncro.sops.yaml"
|
||||
fi
|
||||
API_KEY=$(bash "$VAULT" get-field "$KEY_PATH" credentials.credential)
|
||||
```
|
||||
|
||||
Verify attribution before destructive operations:
|
||||
```bash
|
||||
ME=$(curl -s "${BASE}/me?api_key=${API_KEY}" | jq -r '.user_name + " (user_id=" + (.user_id|tostring) + ")"')
|
||||
echo "Authenticated as: $ME"
|
||||
case "$USER_ID" in
|
||||
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
|
||||
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
|
||||
*) echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
### Adding a per-user key
|
||||
@@ -108,26 +100,177 @@ echo "Authenticated as: $ME"
|
||||
|---|---|---|---|
|
||||
| List tickets | GET | `/tickets?status=<status>&per_page=25` | — |
|
||||
| Get ticket | GET | `/tickets/<id>` | — |
|
||||
| Create ticket | POST | `/tickets` | `{"customer_id": N, "subject": "...", "problem_type": "...", "status": "New"}` |
|
||||
| Create ticket | POST | `/tickets` | see full create workflow below |
|
||||
| Update ticket | PUT | `/tickets/<id>` | `{"status": "In Progress", "priority": "..."}` |
|
||||
| Delete ticket | DELETE | `/tickets/<id>` | — |
|
||||
|
||||
**Ticket statuses:** `New`, `In Progress`, `Waiting on Customer`, `Waiting on Vendor`, `Scheduled`, `Resolved`, `Invoiced`, `Closed`
|
||||
|
||||
**Ticket fields (create/update):**
|
||||
- `customer_id` (required for create)
|
||||
- `subject` (required for create)
|
||||
- `problem_type` (string, free-form)
|
||||
- `status` (string, one of the statuses above)
|
||||
- `priority` (string) — set this; leave blank only if user says not to
|
||||
- `due_date` (ISO date)
|
||||
- `user_id` (assign to tech) — set this; Mike = 1735, Winter = 1737, Rob = 1760
|
||||
- `contact_id` (customer contact)
|
||||
- `ticket_type_id` (ticket category)
|
||||
**Priority format** (number-prefixed string): `"1 High"`, `"2 Normal"`, `"3 Low"`, `"4 Urgent"`
|
||||
Default: `"2 Normal"`. Use `"4 Urgent"` for emergency/after-hours.
|
||||
|
||||
**Always set `user_id` and `priority` on create** unless the user says otherwise. Ask if unknown.
|
||||
- Assignee = whoever worked the ticket (Mike = 1735, Winter = 1737, Rob = 1760)
|
||||
- Priority = `Normal` by default; `Urgent` for emergency/after-hours tickets
|
||||
**Problem types (Issue Type dropdown — use closest match, else "Not determined"):**
|
||||
`API`, `Email`, `Emergency Service`, `File Services / Permissions`, `Hardware`, `Maintenance`,
|
||||
`New User / M365 Account Creation`, `New User / Workstation Deployment`, `Not determined`,
|
||||
`Onsite`, `Other`, `Phone/VOIP`, `Remote`, `Security`, `Server Migration`, `Service Request`,
|
||||
`Software`, `Website`
|
||||
|
||||
**Appointment types:**
|
||||
|
||||
| Name | ID | location_type |
|
||||
|---|---|---|
|
||||
| In Shop | 4321 | shop |
|
||||
| Onsite | 4322 | customer |
|
||||
| Phone Call | 4323 | pre_defined |
|
||||
| Reminder | 193053 | manual_entry |
|
||||
| Remote | 59289 | pre_defined |
|
||||
|
||||
**Tech user IDs:** Mike = 1735, Howard = 1750, Winter = 1737, Rob = 1760
|
||||
|
||||
---
|
||||
|
||||
### Ticket creation workflow (full — 3 API calls)
|
||||
|
||||
Ticket creation in Syncro maps to three separate API calls. Gather all inputs first, show a full preview, wait for confirmation, then execute in order.
|
||||
|
||||
#### Step 1 — Gather inputs
|
||||
|
||||
Collect in one pass (do not ask field by field):
|
||||
|
||||
| # | Field | Notes |
|
||||
|---|---|---|
|
||||
| 1 | **Subject** | Brief title: reason for the ticket |
|
||||
| 2 | **Issue Type** (`problem_type`) | From dropdown above; "Not determined" if unclear |
|
||||
| 3 | **Priority** | "2 Normal" default; "4 Urgent" for emergencies |
|
||||
| 4 | **Description** | Expanded detail — becomes the "Initial Issue" comment body |
|
||||
| 5 | **Do Not Email** | Suppress customer notification on ticket create? (yes for internal/reminder tickets) |
|
||||
| 6 | **Due Date** | ISO date |
|
||||
| 7 | **Assigned Tech** | Who owns the ticket |
|
||||
| 8 | **Contact** | Look up from `GET /customers/{id}` → `.contacts[]`; show list, ask user to pick |
|
||||
| 9 | **Address/Site** | `address_id` — also comes from customer contacts with address data |
|
||||
| 10 | **Appointment Type** | From table above; omit section if no appointment needed |
|
||||
| 11 | **Location** | Free text; usually blank unless onsite at non-primary address |
|
||||
| 12 | **Start Time** | ISO8601 datetime; omit if no scheduled appointment |
|
||||
| 13 | **End Time** | Default: start + 90 minutes |
|
||||
| 14 | **Appointment Owner** | Usually same as assigned tech; noted for calendar attribution (not a separate API field — inherits from ticket `user_id`) |
|
||||
| 15 | **Do Not Invite** | If not onsite, suppress calendar invite — note: not directly controllable via API; inform user if they need this set manually |
|
||||
| 16 | **Asset** | Search `GET /customer_assets?customer_id=N&query=<name>` if a specific device is involved |
|
||||
|
||||
#### Step 2 — Look up customer data
|
||||
|
||||
Before showing the preview, fetch what you need:
|
||||
|
||||
```bash
|
||||
# Get contacts and addresses
|
||||
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{contacts: [.customer.contacts[] | {id, name, address1, email}]}'
|
||||
|
||||
# Search assets
|
||||
curl -s "${BASE}/customer_assets?customer_id=${CUST_ID}&query=<name>&api_key=${API_KEY}" | jq '[.assets[] | {id, name, asset_type}]'
|
||||
```
|
||||
|
||||
#### Step 3 — Show preview and confirm
|
||||
|
||||
Display the full ticket before posting. Include all populated fields. Wait for explicit confirmation.
|
||||
|
||||
```
|
||||
TICKET PREVIEW
|
||||
--------------
|
||||
Customer: <name>
|
||||
Subject: <subject>
|
||||
Issue Type: <problem_type>
|
||||
Priority: <priority>
|
||||
Description: <description>
|
||||
Due Date: <due_date>
|
||||
Assigned To: <tech name>
|
||||
Contact: <contact name>
|
||||
Address: <address>
|
||||
Do Not Email: <yes/no>
|
||||
|
||||
APPOINTMENT
|
||||
-----------
|
||||
Type: <type name>
|
||||
Start: <start_at>
|
||||
End: <end_at> (90 min)
|
||||
Location: <location or blank>
|
||||
|
||||
ASSET: <asset name or none>
|
||||
|
||||
Confirm? (yes/no)
|
||||
```
|
||||
|
||||
#### Step 4 — Execute (after confirmation)
|
||||
|
||||
**Call 1 — Create ticket:**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/ticket_payload.json
|
||||
# Parse: TICKET_ID=$(... | jq -r '.ticket.id')
|
||||
# Parse: CUST_ID=$(... | jq -r '.ticket.customer_id')
|
||||
```
|
||||
|
||||
Payload fields (omit null/blank):
|
||||
```json
|
||||
{
|
||||
"customer_id": N,
|
||||
"subject": "...",
|
||||
"problem_type": "...",
|
||||
"status": "New",
|
||||
"priority": "2 Normal",
|
||||
"user_id": N,
|
||||
"due_date": "YYYY-MM-DD",
|
||||
"contact_id": N,
|
||||
"address_id": N,
|
||||
"start_at": "ISO8601",
|
||||
"end_at": "ISO8601",
|
||||
"asset_ids": [N]
|
||||
}
|
||||
```
|
||||
|
||||
**Call 2 — Post initial description as "Initial Issue" comment:**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/comment_payload.json
|
||||
# Parse: .comment.id (NOT .id — see Hard Rules)
|
||||
```
|
||||
|
||||
Payload:
|
||||
```json
|
||||
{
|
||||
"subject": "Initial Issue",
|
||||
"body": "<the full description>",
|
||||
"hidden": false,
|
||||
"do_not_email": true
|
||||
}
|
||||
```
|
||||
Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise.
|
||||
|
||||
**Call 3 — Create appointment (only if start_at provided):**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/appt_payload.json
|
||||
```
|
||||
|
||||
Payload:
|
||||
```json
|
||||
{
|
||||
"ticket_id": N,
|
||||
"customer_id": N,
|
||||
"appointment_type_id": N,
|
||||
"start_at": "ISO8601",
|
||||
"end_at": "ISO8601",
|
||||
"location": ""
|
||||
}
|
||||
```
|
||||
|
||||
Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed.
|
||||
|
||||
**Always use temp files for payloads** — never inline JSON in curl -d with ticket data (special characters, newlines in description will break the shell).
|
||||
|
||||
#### Comments
|
||||
|
||||
@@ -187,10 +330,10 @@ Two verified ways to add billable time. Both produce ticket line items that tran
|
||||
| Update line item | PUT | `/tickets/<id>/update_line_item` |
|
||||
|
||||
```bash
|
||||
# Add
|
||||
# Add (always include price_retail — API does not auto-apply product rates)
|
||||
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"product_id": 1190473, "quantity": 0.5, "name": "Labor - Remote Business", "description": "Work description", "taxable": false}'
|
||||
-d '{"product_id": 1190473, "quantity": 0.5, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "Work description", "taxable": false}'
|
||||
|
||||
# Remove
|
||||
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
|
||||
@@ -222,24 +365,26 @@ curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
|
||||
**add_line_item required fields:**
|
||||
- `name` — required (422 if missing)
|
||||
- `description` — required (422 if missing)
|
||||
- `product_id` — labor product ID (see list below)
|
||||
- `product_id` — labor product ID (see table below)
|
||||
- `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour)
|
||||
- `price_retail` — **only price field that saves**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00. **Always set `price_retail` explicitly.** Omitting it leaves the line at $0.00 and the invoice generates at $0 (verified 2026-04-23 on #32203). Fetch the current rate with `GET /products/<id>` → `.product.price_retail`, then pass that value on `add_line_item`.
|
||||
- `price_retail` — **must always be set explicitly**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00. Syncro does NOT auto-calculate rates via API even though it does in the web UI. Omitting it leaves the line at $0.00 and the invoice generates at $0 (verified 2026-04-23 on #32203). Always pass the rate from the table below.
|
||||
- `taxable: false` — **always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted
|
||||
|
||||
**Do NOT remove ticket line items after invoicing.** Leave them on the ticket — the "Add/View Charges" button and billing verification by techs depends on seeing line items there.
|
||||
|
||||
**Labor product IDs** (rates verified 2026-04-23; always fetch live with `GET /products/<id>` before billing):
|
||||
**Labor product IDs and rates** (rates pulled from Syncro API 2026-04-24):
|
||||
|
||||
| Product ID | Name | Rate | Notes |
|
||||
| product_id | Name | price_retail ($/hr) | Notes |
|
||||
|---|---|---|---|
|
||||
| `1190473` | Labor - Remote Business | — | standard remote work |
|
||||
| `26118` | Labor - Onsite Business | $175.00/hr | base onsite rate |
|
||||
| `26184` | Labor - Emergency or After Hours Business | $262.50/hr | **1.5× onsite; time-and-a-half baked into the rate.** Non-prepaid customers only. Do NOT stack with `26118` for the same hours. |
|
||||
| `9269129` | Labor - Prepaid Project Labor | — | debits from customer `prepay_hours` bank |
|
||||
| `9269124` | Labor - Internal Labor | — | |
|
||||
| `26117` | Fee - Travel Time | — | |
|
||||
| `68055` | Labor - Website Labor | — | |
|
||||
| `1190473` | Labor - Remote Business | `150.00` | Standard remote work |
|
||||
| `26118` | Labor - Onsite Business | `175.00` | Base onsite rate |
|
||||
| `26184` | Labor - Emergency or After Hours Business | `262.50` | **1.5× onsite; time-and-a-half baked into the rate.** Non-prepaid customers only. Do NOT stack with `26118` for the same hours. |
|
||||
| `9269129` | Labor - Prepaid Project Labor | `0.00` | Debits from customer `prepay_hours` bank |
|
||||
| `9269124` | Labor - Internal Labor | `0.00` | Non-billable internal time |
|
||||
| `26117` | Fee - Travel Time | `40.00` | Per travel event (not hourly) |
|
||||
| `68055` | Labor - Website Labor | `150.00` | Website-related work |
|
||||
|
||||
`price_retail` is the per-unit rate. Line item total = `price_retail × quantity`.
|
||||
|
||||
**Emergency / after-hours billing branches by whether customer has prepaid labor:**
|
||||
|
||||
@@ -253,10 +398,10 @@ Check: `GET /customers/<id>` → `.customer.prepay_hours` (string; `"0.0"` means
|
||||
**Rationale (Winter, 2026-04-23):** Prepaid blocks debit by QUANTITY, not dollars. To charge time-and-a-half against a prepaid block we bump the quantity to 1.5× on the Onsite product rather than switching to the Emergency product — switching would double-count because the Emergency product has the 1.5× already built into its dollar rate.
|
||||
|
||||
**Example — 2 hour emergency onsite job:**
|
||||
- Non-prepaid customer: one line of 2.0 hrs × `26184` → $525.00 billed
|
||||
- Prepaid customer: one line of 3.0 hrs × `26118` → debits 3 hrs from the prepaid block ($525.00 equivalent, drawn from prepay)
|
||||
- Non-prepaid customer: one line of 2.0 hrs × `26184` @ $262.50 → $525.00 billed
|
||||
- Prepaid customer: one line of 3.0 hrs × `26118` @ $175.00 → debits 3 hrs from prepaid block
|
||||
|
||||
Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after I stacked 1hr `26118` + 1hr `26184` for a single hour of emergency work — the $ doubled because the 1.5× was applied twice.
|
||||
Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after a stack of 1hr `26118` + 1hr `26184` for a single hour of emergency work — the $ doubled because the 1.5× was applied twice.
|
||||
|
||||
#### Timer Entries (time tracking reference)
|
||||
|
||||
@@ -331,9 +476,10 @@ curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
|
||||
|
||||
# Step 2: Add billable line item (convert minutes to decimal hours)
|
||||
# 60 min = 1.0, 30 min = 0.5, 45 min = 0.75, etc.
|
||||
# Always include price_retail — Syncro does NOT auto-apply rates via API
|
||||
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"product_id": 1190473, "quantity": 1.0, "name": "Labor - Remote Business", "description": "..."}'
|
||||
-d '{"product_id": 1190473, "quantity": 1.0, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "...", "taxable": false}'
|
||||
|
||||
# Step 3: Create invoice
|
||||
curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
|
||||
|
||||
@@ -28,7 +28,7 @@ After full onboarding, update the Onboarded column below.
|
||||
| Jema Enterprises, LLC | jemaenterprises.com | 41268042-9a8e-41c2-9a3c-0775398b86cb | NO | |
|
||||
| JR Kennedy Company | jrkco.com | a92594b9-c8ad-4dba-8b40-14fcd32c723c | NO | |
|
||||
| Khalsa Montessori School | khalsamontessorischools.onmicrosoft.com | b2950f9d-81f8-40e4-85d9-2854d1d4f31b | NO | |
|
||||
| Kittle Design & Construction | kittlearizona.com | 3d073ebe-806a-4a5e-9035-3c7c4a264fc0 | NO | |
|
||||
| Kittle Design & Construction | kittlearizona.com | 3d073ebe-806a-4a5e-9035-3c7c4a264fc0 | PARTIAL | Sec Inv consented 2026-04-23; Exchange Admin role NOT assigned; Tenant Admin not consented; breach check run — Alexis + Ken inbox rules flagged |
|
||||
| LeeAnn Parkinson | lamaddux.com | 2f0c4c92-c608-4ee0-bdc2-87d5fd8fe929 | NO | |
|
||||
| Marty Ryan | martylryan.com | 48581923-2153-48b9-82b3-6a3587813041 | YES | Sec Inv + Tenant Admin consented; all roles assigned 2026-04-20 |
|
||||
| MVAN Enterprises, Inc | mvan.onmicrosoft.com | 5affaf1e-de89-416b-a655-1b2cf615d5b1 | NO | |
|
||||
|
||||
@@ -9,7 +9,7 @@ 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)
|
||||
GT=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" investigator)
|
||||
|
||||
OUT="/tmp/remediation-tool/$TENANT_ID/sweep"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
@@ -53,6 +53,10 @@ class Settings(BaseSettings):
|
||||
GRAPH_SENDER_EMAIL: str = "noreply@azcomputerguru.com"
|
||||
ADMIN_NOTIFICATION_EMAIL: str = "mike@azcomputerguru.com"
|
||||
|
||||
# Bitdefender GravityZone
|
||||
GRAVITYZONE_API_KEY: str = ""
|
||||
GRAVITYZONE_API_BASE_URL: str = "https://cloud.gravityzone.bitdefender.com/api/v1.0/jsonrpc"
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from api.routers import (
|
||||
quotes,
|
||||
admin_quotes,
|
||||
ticktick,
|
||||
gravityzone,
|
||||
)
|
||||
|
||||
# Import middleware
|
||||
@@ -133,6 +134,7 @@ app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin
|
||||
|
||||
# External integrations
|
||||
app.include_router(ticktick.router, prefix="/api/ticktick", tags=["TickTick"])
|
||||
app.include_router(gravityzone.router, prefix="/api/gravityzone", tags=["GravityZone"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
253
api/routers/gravityzone.py
Normal file
253
api/routers/gravityzone.py
Normal file
@@ -0,0 +1,253 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.gravityzone import (
|
||||
GZCompanyItem,
|
||||
GZEndpointDetail,
|
||||
GZEndpointItem,
|
||||
GZStatusResponse,
|
||||
GZSweepResult,
|
||||
)
|
||||
from api.services.gravityzone_service import (
|
||||
ACG_COMPANIES_CONTAINER_ID,
|
||||
get_gravityzone_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _raise_on_failure(result, detail_prefix: str = "GravityZone error") -> dict:
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"{detail_prefix}: {result.error or 'unknown error'}",
|
||||
)
|
||||
return result.data or {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Status
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status",
|
||||
response_model=GZStatusResponse,
|
||||
summary="GravityZone API key status and license info",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_status(current_user: dict = Depends(get_current_user)):
|
||||
service = get_gravityzone_service()
|
||||
result = await service.get_api_status()
|
||||
data = _raise_on_failure(result, "GravityZone status")
|
||||
|
||||
return GZStatusResponse(
|
||||
enabled_apis=data.get("enabledApis", []),
|
||||
key_created_at=data.get("createdAt"),
|
||||
used_slots=data.get("usedSlots"),
|
||||
total_slots=data.get("totalSlots"),
|
||||
expiry_date=data.get("expiryDate"),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Companies
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/companies",
|
||||
summary="List GravityZone client companies",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def list_companies(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(100, ge=1, le=500),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
service = get_gravityzone_service()
|
||||
result = await service.list_client_companies(page=page, per_page=per_page)
|
||||
data = _raise_on_failure(result, "GravityZone companies")
|
||||
|
||||
companies = [
|
||||
GZCompanyItem(
|
||||
id=item.get("id", ""),
|
||||
name=item.get("name", ""),
|
||||
type=item.get("type", 1),
|
||||
)
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
return {"total": data.get("total", len(companies)), "companies": companies}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/companies/{company_id}/endpoints",
|
||||
summary="List endpoints for a GravityZone company",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def list_endpoints(
|
||||
company_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=200),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
service = get_gravityzone_service()
|
||||
result = await service.list_endpoints(company_id, page=page, per_page=per_page)
|
||||
data = _raise_on_failure(result, "GravityZone endpoints")
|
||||
|
||||
endpoints = [
|
||||
GZEndpointItem(
|
||||
id=item.get("id", ""),
|
||||
name=item.get("name", ""),
|
||||
fqdn=item.get("fqdn"),
|
||||
ip=item.get("ip"),
|
||||
os_version=item.get("operatingSystemVersion"),
|
||||
is_managed=bool(item.get("isManaged", False)),
|
||||
policy_name=(item.get("policy") or {}).get("name"),
|
||||
)
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
return {"total": data.get("total", len(endpoints)), "endpoints": endpoints}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/endpoints/{endpoint_id}",
|
||||
response_model=GZEndpointDetail,
|
||||
summary="Get detailed info for a single GravityZone endpoint",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_endpoint(
|
||||
endpoint_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
service = get_gravityzone_service()
|
||||
result = await service.get_endpoint_details(endpoint_id)
|
||||
data = _raise_on_failure(result, "GravityZone endpoint detail")
|
||||
|
||||
malware = data.get("malwareStatus", {})
|
||||
agent = data.get("agent", {})
|
||||
|
||||
return GZEndpointDetail(
|
||||
id=data.get("id", endpoint_id),
|
||||
name=data.get("name", ""),
|
||||
company_id=data.get("companyId"),
|
||||
infected=bool(malware.get("infected", False)),
|
||||
detection_active=bool(malware.get("detection", False)),
|
||||
signature_outdated=bool(agent.get("signatureOutdated", False)),
|
||||
product_outdated=bool(agent.get("productOutdated", False)),
|
||||
agent_version=agent.get("productVersion"),
|
||||
engine_version=agent.get("engineVersion"),
|
||||
last_seen=data.get("lastSeen"),
|
||||
last_update=agent.get("lastUpdate"),
|
||||
state=data.get("state", 0),
|
||||
modules=data.get("modules"),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Quarantine
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/companies/{company_id}/quarantine",
|
||||
summary="List quarantine items for a GravityZone company",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def list_quarantine(
|
||||
company_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=200),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
service = get_gravityzone_service()
|
||||
result = await service.list_quarantine_items(company_id, page=page, per_page=per_page)
|
||||
data = _raise_on_failure(result, "GravityZone quarantine")
|
||||
|
||||
return {"total": data.get("total", 0), "items": data.get("items", [])}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Security sweep
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_sweep_result(summaries) -> GZSweepResult:
|
||||
stale_cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
not_seen_recently = 0
|
||||
|
||||
for s in summaries:
|
||||
if s.last_seen:
|
||||
try:
|
||||
last_seen_dt = datetime.fromisoformat(
|
||||
s.last_seen.replace("Z", "+00:00")
|
||||
)
|
||||
if last_seen_dt.tzinfo is None:
|
||||
last_seen_dt = last_seen_dt.replace(tzinfo=timezone.utc)
|
||||
if last_seen_dt < stale_cutoff:
|
||||
not_seen_recently += 1
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
return GZSweepResult(
|
||||
total=len(summaries),
|
||||
infected=sum(1 for s in summaries if s.infected),
|
||||
signature_outdated=sum(1 for s in summaries if s.signature_outdated),
|
||||
product_outdated=sum(1 for s in summaries if s.product_outdated),
|
||||
not_seen_recently=not_seen_recently,
|
||||
endpoints=[
|
||||
{
|
||||
"endpoint_id": s.endpoint_id,
|
||||
"name": s.name,
|
||||
"company_id": s.company_id,
|
||||
"infected": s.infected,
|
||||
"detection_active": s.detection_active,
|
||||
"signature_outdated": s.signature_outdated,
|
||||
"product_outdated": s.product_outdated,
|
||||
"last_seen": s.last_seen,
|
||||
"agent_version": s.agent_version,
|
||||
"state": s.state,
|
||||
}
|
||||
for s in summaries
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sweep/{parent_id}",
|
||||
response_model=GZSweepResult,
|
||||
summary="Security sweep for all endpoints under a parent ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def sweep_parent(
|
||||
parent_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
service = get_gravityzone_service()
|
||||
summaries = await service.security_sweep(parent_id)
|
||||
return _build_sweep_result(summaries)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sweep",
|
||||
response_model=GZSweepResult,
|
||||
summary="Security sweep across all ACG client companies",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def sweep_all_clients(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
service = get_gravityzone_service()
|
||||
summaries = await service.security_sweep_all_clients()
|
||||
return _build_sweep_result(summaries)
|
||||
52
api/schemas/gravityzone.py
Normal file
52
api/schemas/gravityzone.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GZEndpointItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
fqdn: Optional[str] = None
|
||||
ip: Optional[str] = None
|
||||
os_version: Optional[str] = None
|
||||
is_managed: bool
|
||||
policy_name: Optional[str] = None
|
||||
|
||||
|
||||
class GZEndpointDetail(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
company_id: Optional[str] = None
|
||||
infected: bool
|
||||
detection_active: bool
|
||||
signature_outdated: bool
|
||||
product_outdated: bool
|
||||
agent_version: Optional[str] = None
|
||||
engine_version: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
last_update: Optional[str] = None
|
||||
state: int
|
||||
modules: Optional[dict] = None
|
||||
|
||||
|
||||
class GZCompanyItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: int
|
||||
|
||||
|
||||
class GZSweepResult(BaseModel):
|
||||
total: int
|
||||
infected: int
|
||||
signature_outdated: int
|
||||
product_outdated: int
|
||||
not_seen_recently: int
|
||||
endpoints: list[dict]
|
||||
|
||||
|
||||
class GZStatusResponse(BaseModel):
|
||||
enabled_apis: list[str]
|
||||
key_created_at: Optional[str] = None
|
||||
used_slots: Optional[int] = None
|
||||
total_slots: Optional[int] = None
|
||||
expiry_date: Optional[str] = None
|
||||
263
api/services/gravityzone_service.py
Normal file
263
api/services/gravityzone_service.py
Normal file
@@ -0,0 +1,263 @@
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GRAVITYZONE_API_BASE_URL = os.environ.get(
|
||||
"GRAVITYZONE_API_BASE_URL",
|
||||
"https://cloud.gravityzone.bitdefender.com/api/v1.0/jsonrpc",
|
||||
)
|
||||
GRAVITYZONE_API_KEY = os.environ.get("GRAVITYZONE_API_KEY", "")
|
||||
|
||||
GRAVITYZONE_TIMEOUT_SECONDS = 30.0
|
||||
GRAVITYZONE_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
ACG_ROOT_COMPANY_ID = "5c4280716c0318f3478b456a"
|
||||
ACG_COMPANIES_CONTAINER_ID = "5c4280716c0318f3478b456e"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GZResult:
|
||||
success: bool
|
||||
data: Optional[dict] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GZEndpointSummary:
|
||||
endpoint_id: str
|
||||
name: str
|
||||
company_id: str
|
||||
infected: bool
|
||||
detection_active: bool
|
||||
signature_outdated: bool
|
||||
product_outdated: bool
|
||||
last_seen: Optional[str]
|
||||
agent_version: Optional[str]
|
||||
state: int
|
||||
|
||||
|
||||
class GravityZoneService:
|
||||
def __init__(
|
||||
self,
|
||||
api_base_url: str = GRAVITYZONE_API_BASE_URL,
|
||||
api_key: str = GRAVITYZONE_API_KEY,
|
||||
timeout: float = GRAVITYZONE_TIMEOUT_SECONDS,
|
||||
connect_timeout: float = GRAVITYZONE_CONNECT_TIMEOUT_SECONDS,
|
||||
):
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
|
||||
|
||||
async def _jsonrpc_request(
|
||||
self, module: str, method: str, params: dict
|
||||
) -> GZResult:
|
||||
url = f"{self.api_base_url}/{module}"
|
||||
payload = {"id": "1", "jsonrpc": "2.0", "method": method, "params": params}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(url, json=payload, auth=(self.api_key, ""))
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
return GZResult(success=False, error=f"GravityZone API timeout: {exc}")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return GZResult(
|
||||
success=False,
|
||||
error=f"GravityZone HTTP error {exc.response.status_code}: {exc.response.text}",
|
||||
)
|
||||
except Exception as exc:
|
||||
return GZResult(success=False, error=f"GravityZone request failed: {exc}")
|
||||
|
||||
if "error" in body:
|
||||
err = body["error"]
|
||||
detail = err.get("data", {}).get("details") or err.get("message")
|
||||
return GZResult(success=False, error=detail)
|
||||
return GZResult(success=True, data=body.get("result"))
|
||||
|
||||
async def get_api_status(self) -> GZResult:
|
||||
key_result = await self._jsonrpc_request("general", "getApiKeyDetails", {})
|
||||
license_result = await self._jsonrpc_request("licensing", "getLicenseInfo", {})
|
||||
|
||||
if not key_result.success:
|
||||
return key_result
|
||||
|
||||
combined = {**(key_result.data or {})}
|
||||
if license_result.success:
|
||||
combined.update(license_result.data or {})
|
||||
else:
|
||||
logger.warning(f"GravityZone getLicenseInfo failed: {license_result.error}")
|
||||
|
||||
return GZResult(success=True, data=combined)
|
||||
|
||||
async def get_own_company(self) -> GZResult:
|
||||
return await self._jsonrpc_request("companies", "getCompanyDetails", {})
|
||||
|
||||
async def list_client_companies(self, page: int = 1, per_page: int = 100) -> GZResult:
|
||||
result = await self._jsonrpc_request(
|
||||
"network",
|
||||
"getNetworkInventoryItems",
|
||||
{
|
||||
"parentId": ACG_COMPANIES_CONTAINER_ID,
|
||||
"page": page,
|
||||
"perPage": per_page,
|
||||
},
|
||||
)
|
||||
if not result.success:
|
||||
return result
|
||||
|
||||
data = result.data or {}
|
||||
items = data.get("items", [])
|
||||
companies = [item for item in items if item.get("type") == 1]
|
||||
return GZResult(
|
||||
success=True,
|
||||
data={"total": len(companies), "items": companies},
|
||||
)
|
||||
|
||||
async def list_endpoints(
|
||||
self, parent_id: str, page: int = 1, per_page: int = 50
|
||||
) -> GZResult:
|
||||
return await self._jsonrpc_request(
|
||||
"network",
|
||||
"getEndpointsList",
|
||||
{"parentId": parent_id, "page": page, "perPage": per_page},
|
||||
)
|
||||
|
||||
async def get_endpoint_details(self, endpoint_id: str) -> GZResult:
|
||||
return await self._jsonrpc_request(
|
||||
"network",
|
||||
"getManagedEndpointDetails",
|
||||
{"endpointId": endpoint_id},
|
||||
)
|
||||
|
||||
async def list_quarantine_items(
|
||||
self, parent_id: str, page: int = 1, per_page: int = 50
|
||||
) -> GZResult:
|
||||
return await self._jsonrpc_request(
|
||||
"quarantine",
|
||||
"getQuarantineItemsList",
|
||||
{"parentId": parent_id, "page": page, "perPage": per_page},
|
||||
)
|
||||
|
||||
async def security_sweep(self, parent_id: str) -> list[GZEndpointSummary]:
|
||||
summaries: list[GZEndpointSummary] = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
while True:
|
||||
result = await self.list_endpoints(parent_id, page=page, per_page=per_page)
|
||||
if not result.success:
|
||||
logger.warning(
|
||||
f"GravityZone security_sweep list_endpoints failed for "
|
||||
f"{parent_id} page {page}: {result.error}"
|
||||
)
|
||||
break
|
||||
|
||||
data = result.data or {}
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
break
|
||||
|
||||
for item in items:
|
||||
endpoint_id = item.get("id", "")
|
||||
detail_result = await self.get_endpoint_details(endpoint_id)
|
||||
if not detail_result.success:
|
||||
logger.warning(
|
||||
f"GravityZone getManagedEndpointDetails failed for "
|
||||
f"{endpoint_id}: {detail_result.error}"
|
||||
)
|
||||
continue
|
||||
|
||||
detail = detail_result.data or {}
|
||||
malware = detail.get("malwareStatus", {})
|
||||
agent = detail.get("agent", {})
|
||||
|
||||
infected = bool(malware.get("infected", False))
|
||||
detection_active = bool(malware.get("detection", False))
|
||||
signature_outdated = bool(agent.get("signatureOutdated", False))
|
||||
product_outdated = bool(agent.get("productOutdated", False))
|
||||
|
||||
if infected:
|
||||
logger.warning(
|
||||
f"GravityZone: infected endpoint detected — "
|
||||
f"id={endpoint_id} name={detail.get('name', '')}"
|
||||
)
|
||||
|
||||
summaries.append(
|
||||
GZEndpointSummary(
|
||||
endpoint_id=endpoint_id,
|
||||
name=detail.get("name") or item.get("name", ""),
|
||||
company_id=item.get("companyId", ""),
|
||||
infected=infected,
|
||||
detection_active=detection_active,
|
||||
signature_outdated=signature_outdated,
|
||||
product_outdated=product_outdated,
|
||||
last_seen=detail.get("lastSeen"),
|
||||
agent_version=agent.get("productVersion"),
|
||||
state=detail.get("state", 0),
|
||||
)
|
||||
)
|
||||
|
||||
total = data.get("total", 0)
|
||||
if page * per_page >= total:
|
||||
break
|
||||
page += 1
|
||||
|
||||
def _sort_key(s: GZEndpointSummary) -> tuple:
|
||||
return (
|
||||
not s.infected,
|
||||
not s.signature_outdated,
|
||||
not s.product_outdated,
|
||||
s.name.lower(),
|
||||
)
|
||||
|
||||
summaries.sort(key=_sort_key)
|
||||
return summaries
|
||||
|
||||
async def security_sweep_all_clients(self) -> list[GZEndpointSummary]:
|
||||
companies_result = await self.list_client_companies(per_page=100)
|
||||
if not companies_result.success:
|
||||
logger.warning(
|
||||
f"GravityZone security_sweep_all_clients: list_client_companies failed: "
|
||||
f"{companies_result.error}"
|
||||
)
|
||||
return []
|
||||
|
||||
companies = (companies_result.data or {}).get("items", [])
|
||||
all_summaries: list[GZEndpointSummary] = []
|
||||
|
||||
for company in companies:
|
||||
company_id = company.get("id", "")
|
||||
if not company_id:
|
||||
continue
|
||||
company_summaries = await self.security_sweep(company_id)
|
||||
for s in company_summaries:
|
||||
if not s.company_id:
|
||||
s.company_id = company_id
|
||||
all_summaries.extend(company_summaries)
|
||||
|
||||
def _sort_key(s: GZEndpointSummary) -> tuple:
|
||||
return (
|
||||
not s.infected,
|
||||
not s.signature_outdated,
|
||||
not s.product_outdated,
|
||||
s.name.lower(),
|
||||
)
|
||||
|
||||
all_summaries.sort(key=_sort_key)
|
||||
return all_summaries
|
||||
|
||||
|
||||
_gravityzone_service: Optional[GravityZoneService] = None
|
||||
|
||||
|
||||
def get_gravityzone_service() -> GravityZoneService:
|
||||
global _gravityzone_service
|
||||
if _gravityzone_service is None:
|
||||
_gravityzone_service = GravityZoneService()
|
||||
return _gravityzone_service
|
||||
171
clients/kittle-design/reports/2026-04-23-breach-check.md
Normal file
171
clients/kittle-design/reports/2026-04-23-breach-check.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Breach Check — Kittle Design & Construction
|
||||
**Date:** 2026-04-23
|
||||
**Tenant:** kittlearizona.com (`3d073ebe-806a-4a5e-9035-3c7c4a264fc0`)
|
||||
**Analyst:** Mike Swanson
|
||||
**Scope:** Tenant-wide compromised account sweep
|
||||
**Tool:** ComputerGuru Security Investigator (read-only Graph + Exchange)
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No Entra ID P1/P2 license** — sign-in logs, risky user detection, and Identity Protection not available
|
||||
- **Exchange Admin role not yet assigned** to Security Investigator SP — SMTP forwarding and transport rules not checked
|
||||
- Both limitations can be addressed: assign Security Investigator SP the "View-Only Recipients" Exchange role for forwarding checks; upgrade to Entra P1 for sign-in visibility
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Finding | User |
|
||||
|---|---|---|
|
||||
| [WARNING] | Hidden inbox rule (name: ".") routing external emails to folder | alexis@kittlearizona.com |
|
||||
| [WARNING] | Duplicate Authenticator registrations (same device name, different app versions) | alexis@kittlearizona.com |
|
||||
| [INFO] | Inbox rule filtering Capital One / Bill.com emails to custom folder | Ken@kittlearizona.com |
|
||||
| [INFO] | Two Authenticator devices registered (different Samsung models) | Lori@kittlearizona.com |
|
||||
| [INFO] | Weak MFA — phone only, no Authenticator | scott@kittlearizona.com |
|
||||
| [INFO] | IMAP legacy auth consent granted (one user) | unknown — see OAuth section |
|
||||
| [INFO] | Large-scope AllPrincipals OAuth consent — verify is intentional | tenant-wide |
|
||||
|
||||
---
|
||||
|
||||
## Findings Detail
|
||||
|
||||
### [WARNING] alexis@kittlearizona.com — Hidden inbox rule
|
||||
|
||||
**Rule name:** `.` (single dot)
|
||||
**Status:** Enabled
|
||||
**Action:** Move to folder (ID: AQMkAGJiAWNh...)
|
||||
**Condition:** Sender contains `HOWMET.COM`
|
||||
|
||||
A rule named `.` is a known attacker hiding technique — the single dot renders as blank or near-invisible in many email clients. The rule silently moves incoming emails from Howmet (aerospace/metals company) to a folder.
|
||||
|
||||
**Questions to resolve:**
|
||||
1. Does Kittle have a business relationship with Howmet Aerospace?
|
||||
2. Does Alexis recognize this rule?
|
||||
3. What folder is this routing to? (Confirm it's accessible and not an RSS/hidden folder)
|
||||
|
||||
If Alexis did not create this rule, treat as confirmed compromise indicator and escalate to full breach check with password reset, session revocation, and MFA re-enrollment.
|
||||
|
||||
---
|
||||
|
||||
### [WARNING] alexis@kittlearizona.com — Duplicate Authenticator registrations
|
||||
|
||||
Two Microsoft Authenticator entries on the same device name:
|
||||
|
||||
| Entry | Display Name | App Version | Created |
|
||||
|---|---|---|---|
|
||||
| 1 | iPhone 12 Pro Max | 6.8.41 | not available |
|
||||
| 2 | iPhone 12 Pro Max | 6.8.40 | not available |
|
||||
|
||||
Both tagged `SoftwareTokenActivated`. Identical device name with different app versions indicates either:
|
||||
- Legitimate: same phone, app was updated and re-registered (unusual — updates don't re-register)
|
||||
- Suspicious: attacker registered their own Authenticator under the same device name
|
||||
|
||||
**Action:** Ask Alexis to open Microsoft Authenticator on her phone and count how many Kittle accounts appear. If she only sees one, the second registration is an attacker device — remove entry ID `c927402a-75c6-4a55-840a-86d1eea43a9b` (version 6.8.40) immediately and force MFA re-enrollment.
|
||||
|
||||
---
|
||||
|
||||
### [INFO] Ken@kittlearizona.com — Inbox rule filtering financial emails
|
||||
|
||||
**Rule name:** `Admin`
|
||||
**Status:** Enabled
|
||||
**Action:** Move to folder (ID: AQMkAGNiZTJj...)
|
||||
**Condition:** Body or subject contains any of:
|
||||
- `@flystucson.com`
|
||||
- `capitalone`
|
||||
- `capitaloneshopping.com`
|
||||
- `@capitalone.com`
|
||||
- `capital one `
|
||||
- `@inform.bill.com`
|
||||
- `cwelsh@hq.bill.com`
|
||||
- `bill.com`
|
||||
|
||||
Filtering Capital One and Bill.com notifications to a folder is a known attacker tactic to hide fraudulent payment activity from the account owner. This could also be legitimate email organization.
|
||||
|
||||
**Action:** Confirm with Ken:
|
||||
1. Did he create this rule?
|
||||
2. What folder does it route to, and has he seen the emails landing there?
|
||||
3. Does Kittle use Bill.com and Capital One for business payments?
|
||||
|
||||
If Ken did not create this rule, it is a confirmed compromise indicator.
|
||||
|
||||
---
|
||||
|
||||
### [INFO] Lori@kittlearizona.com — Two Authenticator devices
|
||||
|
||||
| Entry | Display Name | App Version |
|
||||
|---|---|---|
|
||||
| 1 | SM-F766U (Samsung Galaxy Z Fold series) | 6.2512.8111 |
|
||||
| 2 | SM-G975U (Samsung Galaxy S10+) | 6.2511.7533 |
|
||||
|
||||
Different device models — consistent with a phone upgrade where the old device wasn't removed. Lower concern than Alexis's case, but should be cleaned up.
|
||||
|
||||
**Action:** Confirm which device is current with Lori. Remove the old registration.
|
||||
|
||||
---
|
||||
|
||||
### [INFO] scott@kittlearizona.com — Phone-only MFA
|
||||
|
||||
Scott has password + phone number registered but no Microsoft Authenticator. SMS/voice MFA is weaker than Authenticator (susceptible to SIM swap, social engineering).
|
||||
|
||||
**Action:** Enroll Scott in Microsoft Authenticator.
|
||||
|
||||
---
|
||||
|
||||
### [INFO] IMAP legacy auth consent
|
||||
|
||||
App ID `9b504397-914d-4af2-b6d9-9081e80da54e` has a user-level delegated consent for:
|
||||
```
|
||||
openid offline_access email profile IMAP.AccessAsUser.All
|
||||
```
|
||||
|
||||
IMAP is legacy authentication and bypasses Conditional Access policies. This is a user-level (Principal) consent, meaning one specific user authorized it.
|
||||
|
||||
**Action:** Identify which user consented to this app and verify it's a legitimate mail client (e.g., Thunderbird, Apple Mail in legacy mode). If no one recognizes it, revoke the consent grant.
|
||||
|
||||
---
|
||||
|
||||
### [INFO] Large-scope AllPrincipals OAuth consent
|
||||
|
||||
App ID `c5df10ae-2aa7-4283-86ef-1884c267a9ac` has admin-consented (AllPrincipals) access including:
|
||||
`Directory.ReadWrite.All`, `User.ReadWrite.All`, `RoleManagement.ReadWrite.Directory`, `Mail.Send`, `Policy.ReadWrite.*`, `SecurityEvents.ReadWrite.All`, and many others.
|
||||
|
||||
This is consistent with a multi-tenant MSP management platform (CIPP, Lighthouse, etc.). Verify this was intentionally granted by Kittle's admin.
|
||||
|
||||
---
|
||||
|
||||
## Clean checks
|
||||
|
||||
- No mailbox auto-replies active (Alexis and Ken have old OOO content saved but disabled)
|
||||
- No B2B guest invites in 30 days
|
||||
- No suspicious directory audits beyond today's Security Investigator consent (expected)
|
||||
- 13 of 16 users have Authenticator MFA enrolled
|
||||
- No mailbox forwarding (SMTP forwarding check pending Exchange role assignment)
|
||||
|
||||
---
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
| Priority | Action | Owner |
|
||||
|---|---|---|
|
||||
| P1 | Ask Alexis: does she recognize the "." rule and the Howmet sender? | Mike |
|
||||
| P1 | Ask Alexis: how many Kittle Authenticator entries on her phone? | Mike |
|
||||
| P1 | Ask Ken: does he recognize the "Admin" Capital One/Bill.com rule? | Mike |
|
||||
| P2 | Assign Exchange "View-Only Recipients" role to Security Investigator SP to enable SMTP forwarding check | Mike |
|
||||
| P2 | Identify the IMAP app consent — which user, what client? | Mike |
|
||||
| P3 | Remove Lori's old Authenticator device after confirming current phone | Mike |
|
||||
| P3 | Enroll Scott in Microsoft Authenticator | Mike |
|
||||
| P3 | Verify `c5df10ae` AllPrincipals consent is intentional MSP tooling | Mike |
|
||||
|
||||
---
|
||||
|
||||
## Escalation criteria
|
||||
|
||||
If Alexis or Ken cannot explain their respective rules → treat as active compromise:
|
||||
1. Force password reset
|
||||
2. Revoke all sessions (`revokeSignInSessions`)
|
||||
3. Remove suspicious Authenticator entry from Alexis
|
||||
4. Delete the unrecognized inbox rule
|
||||
5. Run full per-user breach check (sent items, deleted items, OAuth consents for that user)
|
||||
6. Check if any Bill.com or Capital One transactions were made without authorization (Ken's case)
|
||||
137
clients/kittle-design/session-logs/2026-04-24-session.md
Normal file
137
clients/kittle-design/session-logs/2026-04-24-session.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Session Log — Kittle Design & Construction
|
||||
**Date:** 2026-04-23 / 2026-04-24 (overnight)
|
||||
**Analyst:** Mike Swanson
|
||||
**Machine:** DESKTOP-0O8A1RL
|
||||
**Tenant:** kittlearizona.com (`3d073ebe-806a-4a5e-9035-3c7c4a264fc0`)
|
||||
|
||||
## User
|
||||
- **User:** Mike Swanson (mike)
|
||||
- **Machine:** DESKTOP-0O8A1RL
|
||||
- **Role:** admin
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
Performed a full tenant-wide M365 breach check on kittlearizona.com, identified two high-priority compromise indicators, and executed remediation. Also onboarded the Exchange Operator and Tenant Admin apps into the tenant (consent + role assignment). Created Syncro ticket #32207 for billing.
|
||||
|
||||
---
|
||||
|
||||
## Breach Check Findings
|
||||
|
||||
Full report: `clients/kittle-design/reports/2026-04-23-breach-check.md`
|
||||
|
||||
| Severity | Finding | User |
|
||||
|---|---|---|
|
||||
| [WARNING] | Hidden inbox rule "." routing Howmet emails to Conversation History | alexis@kittlearizona.com |
|
||||
| [WARNING] | Duplicate Authenticator — same device name, two different app versions | alexis@kittlearizona.com |
|
||||
| [INFO] | Inbox rule "Admin" filtering Capital One / Bill.com to folder | Ken@kittlearizona.com |
|
||||
| [INFO] | Two Authenticator devices (different Samsung models — likely phone upgrade) | Lori@kittlearizona.com |
|
||||
| [INFO] | Phone-only MFA, no Authenticator | scott@kittlearizona.com |
|
||||
| [INFO] | IMAP legacy auth consent — single user | unknown |
|
||||
| [INFO] | Large-scope AllPrincipals OAuth consent (c5df10ae) | tenant-wide |
|
||||
|
||||
---
|
||||
|
||||
## Remediation Actions Taken
|
||||
|
||||
### Onboarding
|
||||
|
||||
Exchange Operator and Tenant Admin apps consented by Kittle admin. Role assignments:
|
||||
- Security Investigator SP (`26e16c7a`): Exchange Administrator — assigned
|
||||
- Exchange Operator SP (`775ec856`): Exchange Administrator — assigned manually (onboard script missed it)
|
||||
- User Manager SP (`ea0277ab`): User Administrator + Authentication Administrator — assigned
|
||||
|
||||
### alexis@kittlearizona.com
|
||||
|
||||
| Action | Result | Detail |
|
||||
|---|---|---|
|
||||
| Hidden "." inbox rule deleted | [OK] | Exchange identity: `alexis\\2866869517449953281` |
|
||||
| 3 hidden Howmet emails restored to inbox | [OK] | All HTTP 201; emails dated Feb 28 and Mar 4, 2025 |
|
||||
| All sign-in sessions revoked | [OK] | `revokeSignInSessions` returned true |
|
||||
| Password reset (temp, force-change) | [OK] | See credentials section below |
|
||||
|
||||
**Emails recovered:**
|
||||
1. "RE: Kittle Visit to review open projects and Billing discrepancies" — Erick.Martinez1@howmet.com (2025-03-04)
|
||||
2. "RE: HOWMET FASTENING SYSTEMS, PURCHASE ORDER: 221422333" — Miguel.Angulo@howmet.com (2025-03-04)
|
||||
3. "FW: Please ignore. | Petra" — Buy.PayHowmet@howmet.com (2025-02-28)
|
||||
|
||||
**Still pending:**
|
||||
- Ask Alexis to count Authenticator entries on her phone. If only one, remove suspicious entry:
|
||||
- Entry to remove: ID `c927402a-75c6-4a55-840a-86d1eea43a9b` (app version 6.8.40, "iPhone 12 Pro Max")
|
||||
|
||||
### OAuth Consents Revoked
|
||||
|
||||
**c5df10ae-2aa7-4283-86ef-1884c267a9ac** (AllPrincipals — 7 grants deleted, all HTTP 204):
|
||||
- `rhDfxacqg0KG7xiEwmeprLz8wKqAnj1KmLeBzb1HLJo` — Directory.ReadWrite.All, RoleManagement, Mail.Send, 50+ scopes
|
||||
- `rhDfxacqg0KG7xiEwmeprFhKBKSuvdJJu5jQBa-uOnc` — LicenseManager.AccessAsUser
|
||||
- `rhDfxacqg0KG7xiEwmeprLhRraINEIxGmlMZtBZahO8` — M365AdminPortal.IntegratedApps.ReadWrite, user_impersonation
|
||||
- `rhDfxacqg0KG7xiEwmeprFm5M4Bw4bFKniz6sx5jbAI` — user_impersonation
|
||||
- `rhDfxacqg0KG7xiEwmeprKm4oqODLdhAnY4nYViP4rs` — AllProfiles.Manage, AllSites.FullControl
|
||||
- `rhDfxacqg0KG7xiEwmeprICwF0FoazRErqVlL2xiBFk` — Calendars.ReadWrite.All, Exchange.Manage, MailboxSettings.ReadWrite
|
||||
- `rhDfxacqg0KG7xiEwmeprPl4LqXf8mRPjoQUGmKJt3k` — Vulnerability.Read
|
||||
|
||||
**9b504397-914d-4af2-b6d9-9081e80da54e** (IMAP legacy auth, 1 grant deleted, HTTP 204):
|
||||
- `l0NQm02R8kq22ZCB6A2lTrz8wKqAnj1KmLeBzb1HLJoafsNfsqzMSLDHPoGZ_dNa` — IMAP.AccessAsUser.All, openid, offline_access, email, profile
|
||||
- Consented by user `5fc37e1a-acb2-48cc-b0c7-3e8199fdd35a` (user object ID — UPN not resolved)
|
||||
|
||||
### Ken@kittlearizona.com
|
||||
|
||||
No action taken. Inbox rule "Admin" (filtering Capital One, Bill.com, @flystucson.com) still present. Awaiting confirmation from Ken whether he created it. If he can't explain it — treat as active compromise and escalate (password reset, session revocation, rule deletion, check Bill.com/Capital One transactions).
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
```
|
||||
Tenant: kittlearizona.com
|
||||
Tenant ID: 3d073ebe-806a-4a5e-9035-3c7c4a264fc0
|
||||
|
||||
alexis@kittlearizona.com
|
||||
Temp password: KittleGwiNUK#2026
|
||||
(force change on next login — issued 2026-04-23)
|
||||
User object ID: 74a1eae1-c0dd-4544-a98f-3a18f809785a
|
||||
|
||||
Exchange Operator SP: 775ec856-f032-4dcf-a499-ccf7f9bce07b
|
||||
Tenant Admin SP: 0caa0dde-3f8d-4d46-ab26-aa0d38add0b5
|
||||
Security Investigator SP: 26e16c7a-0ac8-4f85-bdd7-992611bbd271
|
||||
User Manager SP: ea0277ab-497c-45f7-b88a-e2d53f54a4c7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Syncro
|
||||
|
||||
- **Ticket #32207** — "M365 Security Sweep — Breach Check & Remediation"
|
||||
- Status: Resolved
|
||||
- Line item: 1.0 hr Labor - Remote Business (product_id: 1190473)
|
||||
- Ready to invoice — run `/syncro bill 32207` or manually in GUI
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Notes
|
||||
|
||||
- Kittle has no Entra P1/P2 — sign-in logs and Identity Protection unavailable
|
||||
- SMTP forwarding check not completed — Exchange Admin role was not assigned to Security Investigator at time of breach check (fixed during remediation session)
|
||||
- Token cache location: `/tmp/remediation-tool/3d073ebe-806a-4a5e-9035-3c7c4a264fc0/`
|
||||
|
||||
---
|
||||
|
||||
## Files Changed This Session
|
||||
|
||||
- `clients/kittle-design/reports/2026-04-23-breach-check.md` — breach check report (written 2026-04-23)
|
||||
- `.claude/skills/remediation-tool/scripts/tenant-sweep.sh` — fixed tier name `graph` → `investigator` on line 12
|
||||
- `.claude/skills/remediation-tool/references/tenants.md` — Kittle row updated from NO to PARTIAL
|
||||
|
||||
---
|
||||
|
||||
## Pending Items
|
||||
|
||||
| Priority | Action | Owner |
|
||||
|---|---|---|
|
||||
| P1 | Ask Alexis: how many Kittle Authenticator entries on her phone? Remove `c927402a` if only one. | Mike |
|
||||
| P1 | Ask Ken: does he recognize the "Admin" Capital One/Bill.com rule? If no → escalate | Mike |
|
||||
| P2 | Verify Alexis received temp password and changed it | Mike |
|
||||
| P3 | Remove Lori's old Authenticator (SM-G975U Samsung S10+) after confirming current phone | Mike |
|
||||
| P3 | Enroll Scott in Microsoft Authenticator | Mike |
|
||||
| P3 | Invoice ticket #32207 | Mike |
|
||||
62
session-logs/2026-04-24-session.md
Normal file
62
session-logs/2026-04-24-session.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Session Log: 2026-04-24
|
||||
|
||||
## User
|
||||
- **User:** Mike Swanson (mike)
|
||||
- **Machine:** DESKTOP-0O8A1RL
|
||||
- **Role:** admin
|
||||
|
||||
## Summary
|
||||
|
||||
Two improvements to the `/syncro` skill:
|
||||
|
||||
1. **Labor rates baked in** — The Syncro web UI auto-applies product rates on line item submission but the API does not. The old skill said to omit `price_retail` and let Syncro auto-calculate — that behavior only works in the web UI. Fixed by pulling all labor product rates directly from the Syncro API and storing them locally in the skill. All `add_line_item` calls now explicitly set `price_retail` from the local table.
|
||||
|
||||
2. **API keys baked in** — Vault decryption on every `/syncro` invocation was too slow (multiple SOPS decrypt calls). Replaced with a single `jq` read on `identity.json` selecting the correct per-user key from a hardcoded case block. Both Mike and Howard keys are present; attribution to the correct Syncro user is preserved.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File Modified: `.claude/commands/syncro.md`
|
||||
|
||||
**Labor rates (pulled 2026-04-24):**
|
||||
|
||||
| product_id | Name | price_retail |
|
||||
|---|---|---|
|
||||
| 1190473 | Labor - Remote Business | $150.00/hr |
|
||||
| 26118 | Labor - Onsite Business | $175.00/hr |
|
||||
| 26184 | Labor - Emergency or After Hours Business | $262.50/hr |
|
||||
| 9269129 | Labor - Prepaid Project Labor | $0.00 (prepaid block) |
|
||||
| 9269124 | Labor - Internal Labor | $0.00 (non-billable) |
|
||||
| 26117 | Fee - Travel Time | $40.00/event |
|
||||
| 68055 | Labor - Website Labor | $150.00/hr |
|
||||
|
||||
**Key-select block (replacing vault calls):**
|
||||
```bash
|
||||
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
|
||||
case "$USER_ID" in
|
||||
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
|
||||
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
|
||||
*) echo "[ERROR] Unknown user" >&2; exit 1 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
**Specific line changes:**
|
||||
- Removed "Do not hardcode rates — omit price_retail and Syncro auto-calculates" note
|
||||
- Added rate table with product IDs, names, and per-hour rates stamped with pull date
|
||||
- Updated Option A `add_line_item` example to include `price_retail` and `taxable: false`
|
||||
- Updated billing workflow example to include `price_retail` and `taxable: false`
|
||||
- Replaced vault-based `Get API key` block with inline case statement
|
||||
- Updated attribution table to reference identity.json users instead of vault paths
|
||||
|
||||
## Credentials
|
||||
|
||||
### Syncro API Keys (now in syncro.md — keys live in git)
|
||||
- Mike Swanson (user_id 1735): `T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3`
|
||||
- Howard Enos (user_id 1750): `Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18`
|
||||
- Vault backups: `msp-tools/syncro.sops.yaml` (Mike), `msp-tools/syncro-howard.sops.yaml` (Howard)
|
||||
- Base URL: `https://computerguru.syncromsp.com/api/v1`
|
||||
|
||||
## Pending / Notes
|
||||
|
||||
- If either Syncro API key is ever rotated, update the case block in `.claude/commands/syncro.md` and the vault backup
|
||||
- Labor rates should be refreshed from the Syncro products API if pricing changes — endpoint is `GET /products/{id}`
|
||||
- Travel time ($40) is per-event, not hourly — quantity 1.0 = one trip regardless of duration
|
||||
Reference in New Issue
Block a user