Compare commits

...

6 Commits

Author SHA1 Message Date
daeea5f26c syncro skill: bake in labor rates and API keys
- Add local rate table (pulled 2026-04-24) for all 7 labor products; always
  set price_retail explicitly — Syncro API does not auto-apply product rates
- Replace vault-based key fetch with inline case block on identity.json user;
  both Mike and Howard keys included for correct per-user attribution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:14:13 -07:00
deecac745d session log: kittle — M365 breach check and remediation 2026-04-23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:13:16 -07:00
327dc329ab remediation-tool: fix tenant-sweep tier name; mark Kittle partially onboarded
- tenant-sweep.sh line 12: renamed tier `graph` to `investigator` to match
  the valid tier name expected by get-token.sh
- tenants.md: updated Kittle Design & Construction consent status from NO
  to PARTIAL with notes on what was consented and what remains pending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:13:16 -07:00
0499f06ff8 syncro: expand ticket creation to full 19-field workflow
Documents the 3-call create pattern (ticket → Initial Issue comment →
appointment), adds problem type and appointment type dropdowns with IDs,
fixes priority format to number-prefixed strings ("2 Normal"), adds Howard
to tech user ID table, and adds asset/contact lookup steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:13:16 -07:00
e7233d69a3 gravityzone: add full GravityZone integration module
Adds JSON-RPC client, Pydantic schemas, and FastAPI router for
Bitdefender GravityZone. Endpoints: status, companies, endpoints,
quarantine, and security sweep across all 55 managed client companies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:13:16 -07:00
e2b8fcee21 feat: add Bitdefender GravityZone integration module
Adds full GravityZone API integration to ClaudeTools. Key additions:

- api/services/gravityzone_service.py: JSON-RPC client with Basic auth,
  methods for company/endpoint/quarantine/licensing data, and security_sweep
  which paginates all endpoints, enriches with malware/agent status, and
  sorts infected > outdated > clean
- api/schemas/gravityzone.py: Pydantic response models for all endpoints
- api/routers/gravityzone.py: 7 REST endpoints at /api/gravityzone/*,
  JWT-protected, returns 502 on downstream GZ errors
- api/config.py: GRAVITYZONE_API_KEY + GRAVITYZONE_API_BASE_URL settings
- api/main.py: router registered under /api/gravityzone

Vault entry: msp-tools/gravityzone.sops.yaml (partner-level key, 14 modules)
Server .env updated, ticktick router synced, service restarted and verified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:13:16 -07:00
11 changed files with 1141 additions and 51 deletions

View File

@@ -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}" \

View File

@@ -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 | |

View File

@@ -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"

View File

@@ -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."""

View File

@@ -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
View 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)

View 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

View 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

View 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)

View 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 |

View 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