New skill for Google Workspace administration via the gws CLI: - SKILL.md with 4 workflows (Gmail, Drive/Sheets, Calendar, Security Audit) - 5 stdlib-only Python scripts (doctor, auth setup, recipe runner, audit, analyzer) - 3 reference docs, 2 asset files, 43 built-in recipes, 10 persona bundles - cs-workspace-admin agent, /google-workspace slash command - Standalone marketplace plugin entry with .claude-plugin/plugin.json - Cross-platform sync (Codex CLI, Gemini CLI), MkDocs docs pages - All documentation updated (173 skills, 250 tools, 15 agents, 15 commands) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
310 lines
11 KiB
Python
310 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Google Workspace Security Audit — Audit Workspace configuration for security risks.
|
|
|
|
Checks Drive external sharing, Gmail forwarding rules, OAuth app grants,
|
|
Calendar visibility, admin settings, and generates remediation commands.
|
|
Runs in demo mode with embedded sample data when gws is not installed.
|
|
|
|
Usage:
|
|
python3 workspace_audit.py
|
|
python3 workspace_audit.py --json
|
|
python3 workspace_audit.py --services gmail,drive,calendar
|
|
python3 workspace_audit.py --demo
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import List, Dict, Optional
|
|
|
|
|
|
@dataclass
|
|
class AuditFinding:
|
|
area: str
|
|
check: str
|
|
status: str # PASS, WARN, FAIL
|
|
message: str
|
|
risk: str = ""
|
|
remediation: str = ""
|
|
|
|
|
|
@dataclass
|
|
class AuditReport:
|
|
findings: List[dict] = field(default_factory=list)
|
|
score: int = 0
|
|
max_score: int = 100
|
|
grade: str = ""
|
|
summary: str = ""
|
|
demo_mode: bool = False
|
|
|
|
|
|
DEMO_FINDINGS = [
|
|
AuditFinding("drive", "External sharing", "WARN",
|
|
"External sharing is enabled for the domain",
|
|
"Data exfiltration via shared links",
|
|
"Review sharing settings in Admin Console > Apps > Google Workspace > Drive"),
|
|
AuditFinding("drive", "Link sharing defaults", "FAIL",
|
|
"Default link sharing is set to 'Anyone with the link'",
|
|
"Sensitive files accessible without authentication",
|
|
"gws admin settings update drive --defaultLinkSharing restricted"),
|
|
AuditFinding("gmail", "Auto-forwarding", "PASS",
|
|
"No auto-forwarding rules detected for admin accounts"),
|
|
AuditFinding("gmail", "SPF record", "PASS",
|
|
"SPF record configured correctly"),
|
|
AuditFinding("gmail", "DMARC record", "WARN",
|
|
"DMARC policy is set to 'none' (monitoring only)",
|
|
"Email spoofing not actively blocked",
|
|
"Update DMARC DNS record: v=DMARC1; p=quarantine; rua=mailto:dmarc@company.com"),
|
|
AuditFinding("gmail", "DKIM signing", "PASS",
|
|
"DKIM signing is enabled"),
|
|
AuditFinding("calendar", "Default visibility", "WARN",
|
|
"Calendar default visibility is 'See all event details'",
|
|
"Meeting details visible to all domain users",
|
|
"Admin Console > Apps > Calendar > Sharing settings > Set to 'Free/Busy'"),
|
|
AuditFinding("calendar", "External sharing", "PASS",
|
|
"External calendar sharing is restricted"),
|
|
AuditFinding("oauth", "Third-party apps", "FAIL",
|
|
"12 third-party OAuth apps with broad access detected",
|
|
"Unauthorized data access via OAuth grants",
|
|
"Review: Admin Console > Security > API controls > App access control"),
|
|
AuditFinding("oauth", "High-risk apps", "WARN",
|
|
"3 apps have Drive full access scope",
|
|
"Apps can read/modify all Drive files",
|
|
"Audit each app: gws admin tokens list --json | filter by scope"),
|
|
AuditFinding("admin", "Super admin count", "WARN",
|
|
"4 super admin accounts detected (recommended: 2-3)",
|
|
"Increased attack surface for privilege escalation",
|
|
"Reduce super admins: gws admin users list --query 'isAdmin=true' --json"),
|
|
AuditFinding("admin", "2-Step verification", "PASS",
|
|
"2-Step verification enforced for all users"),
|
|
AuditFinding("admin", "Password policy", "PASS",
|
|
"Minimum password length: 12 characters"),
|
|
AuditFinding("admin", "Login challenges", "PASS",
|
|
"Suspicious login challenges enabled"),
|
|
]
|
|
|
|
|
|
def run_gws_command(cmd: List[str]) -> Optional[str]:
|
|
"""Run a gws command and return stdout, or None on failure."""
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
|
if result.returncode == 0:
|
|
return result.stdout
|
|
return None
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
return None
|
|
|
|
|
|
def audit_drive() -> List[AuditFinding]:
|
|
"""Audit Drive sharing and security settings."""
|
|
findings = []
|
|
|
|
# Check sharing settings
|
|
output = run_gws_command(["gws", "drive", "about", "get", "--json"])
|
|
if output:
|
|
try:
|
|
data = json.loads(output)
|
|
# Check if external sharing is enabled
|
|
if data.get("canShareOutsideDomain", True):
|
|
findings.append(AuditFinding(
|
|
"drive", "External sharing", "WARN",
|
|
"External sharing is enabled",
|
|
"Data exfiltration via shared links",
|
|
"Review Admin Console > Apps > Drive > Sharing settings"
|
|
))
|
|
else:
|
|
findings.append(AuditFinding(
|
|
"drive", "External sharing", "PASS",
|
|
"External sharing is restricted"
|
|
))
|
|
except json.JSONDecodeError:
|
|
findings.append(AuditFinding(
|
|
"drive", "External sharing", "WARN",
|
|
"Could not parse Drive settings"
|
|
))
|
|
else:
|
|
findings.append(AuditFinding(
|
|
"drive", "External sharing", "WARN",
|
|
"Could not retrieve Drive settings"
|
|
))
|
|
|
|
return findings
|
|
|
|
|
|
def audit_gmail() -> List[AuditFinding]:
|
|
"""Audit Gmail forwarding and email security."""
|
|
findings = []
|
|
|
|
# Check forwarding rules
|
|
output = run_gws_command(["gws", "gmail", "users.settings.forwardingAddresses", "list", "me", "--json"])
|
|
if output:
|
|
try:
|
|
data = json.loads(output)
|
|
addrs = data if isinstance(data, list) else data.get("forwardingAddresses", [])
|
|
if addrs:
|
|
findings.append(AuditFinding(
|
|
"gmail", "Auto-forwarding", "WARN",
|
|
f"{len(addrs)} forwarding addresses configured",
|
|
"Data exfiltration via email forwarding",
|
|
"Review: gws gmail users.settings.forwardingAddresses list me --json"
|
|
))
|
|
else:
|
|
findings.append(AuditFinding(
|
|
"gmail", "Auto-forwarding", "PASS",
|
|
"No forwarding addresses configured"
|
|
))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
else:
|
|
findings.append(AuditFinding(
|
|
"gmail", "Auto-forwarding", "WARN",
|
|
"Could not check forwarding settings"
|
|
))
|
|
|
|
return findings
|
|
|
|
|
|
def audit_calendar() -> List[AuditFinding]:
|
|
"""Audit Calendar sharing settings."""
|
|
findings = []
|
|
|
|
output = run_gws_command(["gws", "calendar", "calendarList", "get", "primary", "--json"])
|
|
if output:
|
|
findings.append(AuditFinding(
|
|
"calendar", "Primary calendar", "PASS",
|
|
"Primary calendar accessible"
|
|
))
|
|
else:
|
|
findings.append(AuditFinding(
|
|
"calendar", "Primary calendar", "WARN",
|
|
"Could not access primary calendar"
|
|
))
|
|
|
|
return findings
|
|
|
|
|
|
def run_live_audit(services: List[str]) -> AuditReport:
|
|
"""Run live audit against actual gws installation."""
|
|
report = AuditReport()
|
|
all_findings = []
|
|
|
|
audit_map = {
|
|
"drive": audit_drive,
|
|
"gmail": audit_gmail,
|
|
"calendar": audit_calendar,
|
|
}
|
|
|
|
for svc in services:
|
|
fn = audit_map.get(svc)
|
|
if fn:
|
|
all_findings.extend(fn())
|
|
|
|
report.findings = [asdict(f) for f in all_findings]
|
|
report = calculate_score(report)
|
|
return report
|
|
|
|
|
|
def run_demo_audit() -> AuditReport:
|
|
"""Return demo audit report with embedded sample data."""
|
|
report = AuditReport(
|
|
findings=[asdict(f) for f in DEMO_FINDINGS],
|
|
demo_mode=True,
|
|
)
|
|
report = calculate_score(report)
|
|
return report
|
|
|
|
|
|
def calculate_score(report: AuditReport) -> AuditReport:
|
|
"""Calculate audit score and grade."""
|
|
total = len(report.findings)
|
|
if total == 0:
|
|
report.score = 0
|
|
report.grade = "N/A"
|
|
report.summary = "No checks performed"
|
|
return report
|
|
|
|
passes = sum(1 for f in report.findings if f["status"] == "PASS")
|
|
warns = sum(1 for f in report.findings if f["status"] == "WARN")
|
|
fails = sum(1 for f in report.findings if f["status"] == "FAIL")
|
|
|
|
# Score: PASS=100, WARN=50, FAIL=0
|
|
score = int(((passes * 100) + (warns * 50)) / total)
|
|
report.score = score
|
|
report.max_score = 100
|
|
|
|
if score >= 90:
|
|
report.grade = "A"
|
|
elif score >= 75:
|
|
report.grade = "B"
|
|
elif score >= 60:
|
|
report.grade = "C"
|
|
elif score >= 40:
|
|
report.grade = "D"
|
|
else:
|
|
report.grade = "F"
|
|
|
|
report.summary = f"{passes} passed, {warns} warnings, {fails} failures — Score: {score}/100 (Grade: {report.grade})"
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Security and configuration audit for Google Workspace",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s # Full audit (or demo if gws not installed)
|
|
%(prog)s --json # JSON output
|
|
%(prog)s --services gmail,drive # Audit specific services
|
|
%(prog)s --demo # Demo mode with sample data
|
|
""",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="Output JSON")
|
|
parser.add_argument("--services", default="gmail,drive,calendar",
|
|
help="Comma-separated services to audit (default: gmail,drive,calendar)")
|
|
parser.add_argument("--demo", action="store_true", help="Run with demo data")
|
|
args = parser.parse_args()
|
|
|
|
services = [s.strip() for s in args.services.split(",") if s.strip()]
|
|
|
|
if args.demo or not shutil.which("gws"):
|
|
report = run_demo_audit()
|
|
else:
|
|
report = run_live_audit(services)
|
|
|
|
if args.json:
|
|
print(json.dumps(asdict(report), indent=2))
|
|
else:
|
|
print(f"\n{'='*60}")
|
|
print(f" GOOGLE WORKSPACE SECURITY AUDIT")
|
|
if report.demo_mode:
|
|
print(f" (DEMO MODE — sample data)")
|
|
print(f"{'='*60}\n")
|
|
print(f" Score: {report.score}/{report.max_score} (Grade: {report.grade})\n")
|
|
|
|
current_area = ""
|
|
for f in report.findings:
|
|
if f["area"] != current_area:
|
|
current_area = f["area"]
|
|
print(f"\n {current_area.upper()}")
|
|
print(f" {'-'*40}")
|
|
|
|
icon = {"PASS": "PASS", "WARN": "WARN", "FAIL": "FAIL"}.get(f["status"], "????")
|
|
print(f" [{icon}] {f['check']}: {f['message']}")
|
|
if f.get("risk") and f["status"] != "PASS":
|
|
print(f" Risk: {f['risk']}")
|
|
if f.get("remediation") and f["status"] != "PASS":
|
|
print(f" Fix: {f['remediation']}")
|
|
|
|
print(f"\n {'='*56}")
|
|
print(f" {report.summary}")
|
|
print(f"\n{'='*60}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|