check_kerberos_ticket

check_kerberos_ticket

Lists active Kerberos tickets on the endpoint and flags any that are expired or expiring soon. An expired or missing TGT is the top cause of 'VPN says authentication failed' and similar AD-login issues. Read-only; safe to run without consent.

Metadata

Name

check_kerberos_ticket

Updated

2 months ago

Source

GitHub source

Risk

Low

Requires consent

false

Affected scope

user

Code

/**
 * mcp/skills/checkKerberosTicket.ts — check_kerberos_ticket
 *
 * Lists active Kerberos tickets and flags ones that have expired or
 * will expire within the configured window.
 *
 * Platform strategy
 * -----------------
 * darwin  `klist` (Heimdal).  Output lists principal + valid-from/to.
 *         No tickets → non-zero exit; treat as a valid empty result.
 * win32   `klist` (built-in).  Output is ticket blocks prefixed with
 *         "#<idx>>".  We parse each block's Client, Server, EndTime.
 *
 * Never throws — parse failures resolve to { tickets: [], status: "error" }.
 */

import { z } from "zod";

import { execAsync, isDarwin, isWin32 } from "./_shared/platform";

// -- Meta ---------------------------------------------------------------------

export const meta = {
  name: "check_kerberos_ticket",
  description:
    "Lists active Kerberos tickets on the endpoint and flags any that are " +
    "expired or expiring soon. An expired or missing TGT is the top cause " +
    "of 'VPN says authentication failed' and similar AD-login issues. " +
    "Read-only; safe to run without consent.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    expiryWarnMinutes: z
      .number()
      .int()
      .min(1)
      .max(1440)
      .optional()
      .describe(
        "How many minutes before expiry to flag a ticket as 'expiring soon'. " +
        "Defaults to 60 (1 hour).",
      ),
  },
  outputKeys: ["platform", "tickets", "healthy", "status", "message"],
} as const;

// -- Types --------------------------------------------------------------------

export interface KerberosTicket {
  clientPrincipal: string;
  serverPrincipal: string;
  /** ISO 8601 timestamp; null if parsing failed. */
  endTime:         string | null;
  expired:         boolean;
  expiringSoon:    boolean;
}

export interface KerberosStatusResult {
  platform: "darwin" | "win32" | "other";
  tickets:  KerberosTicket[];
  /** True when at least one valid, non-expiring ticket is present. */
  healthy:  boolean;
  status:   "ok" | "expired" | "expiring" | "missing" | "error";
  message:  string;
}

// -- darwin parser ------------------------------------------------------------

function parseDarwinKlist(stdout: string, warnMs: number, now: number): KerberosTicket[] {
  const tickets: KerberosTicket[] = [];
  const principalMatch = stdout.match(/Principal:\s*([^\n]+)/);
  const principal = principalMatch ? principalMatch[1].trim() : "(unknown)";

const lines = stdout.split(/\r?\n/);
  for (const line of lines) {
    const m = line.match(
      /^\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d{4}\s+(\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d{4})\s+(\S+)/,
    );
    if (!m) continue;
    const endDate = new Date(m[1].replace(/\s+/g, " ") + " GMT+0000");
    const serverPrincipal = m[2];
    const endMs = endDate.getTime();
    const valid = !isNaN(endMs);
    tickets.push({
      clientPrincipal: principal,
      serverPrincipal,
      endTime: valid ? endDate.toISOString() : null,
      expired: valid ? endMs < now : false,
      expiringSoon: valid ? endMs - now < warnMs && endMs > now : false,
    });
  }
  return tickets;
}

// -- win32 parser -------------------------------------------------------------

function parseWin32Klist(stdout: string, warnMs: number, now: number): KerberosTicket[] {
  const tickets: KerberosTicket[] = [];
  const blocks = stdout.split(/#\d+>/).slice(1);
  for (const block of blocks) {
    const clientMatch = block.match(/Client:\s*([^\n\r]+?)\s*$/m);
    const serverMatch = block.match(/Server:\s*([^\n\r]+?)\s*$/m);
    const endMatch    = block.match(/End Time:\s*([^\n\r]+?)\s*$/m);
    if (!clientMatch || !serverMatch) continue;
    const client = clientMatch[1].trim();
    const server = serverMatch[1].trim();
    let endIso: string | null = null;
    let endMs:  number | null = null;
    if (endMatch) {
      const raw = endMatch[1].replace(/\s*\((?:local|UTC)\)\s*$/i, "").trim();
      const d   = new Date(raw);
      if (!isNaN(d.getTime())) {
        endIso = d.toISOString();
        endMs  = d.getTime();
      }
    }
    tickets.push({
      clientPrincipal: client,
      serverPrincipal: server,
      endTime:        endIso,
      expired:        endMs !== null ? endMs < now : false,
      expiringSoon:   endMs !== null ? endMs - now < warnMs && endMs > now : false,
    });
  }
  return tickets;
}

/**
 * Classifies a raw klist error message as "missing" (no credential cache)
 * vs "error" (something else).
 */
export function classifyKlistError(msg: string): "missing" | "error" {
  return /No credentials|cc_default|No such file/i.test(msg) ? "missing" : "error";
}

// Exported for unit tests.
export const __testing = { parseDarwinKlist, parseWin32Klist };

// -- Exported run function ----------------------------------------------------

export async function run({
  expiryWarnMinutes = 60,
}: {
  expiryWarnMinutes?: number;
} = {}): Promise<KerberosStatusResult> {
  const platform: "darwin" | "win32" | "other" =
    isDarwin() ? "darwin" : isWin32() ? "win32" : "other";

if (platform === "other") {
    return {
      platform, tickets: [], healthy: false,
      status: "error",
      message: "Unsupported platform — Kerberos tools not available.",
    };
  }

const warnMs = expiryWarnMinutes * 60 * 1_000;
  const now    = Date.now();

try {
    const { stdout } = await execAsync(`klist`, {
      maxBuffer: 2 * 1024 * 1024, timeout: 5_000,
    });
    const tickets = platform === "darwin"
      ? parseDarwinKlist(stdout, warnMs, now)
      : parseWin32Klist(stdout, warnMs, now);

if (tickets.length === 0) {
      return {
        platform, tickets, healthy: false,
        status: "missing",
        message: "No Kerberos tickets present — user is not currently authenticated to a KDC.",
      };
    }

const allExpired = tickets.every((t) => t.expired);
    const anyExpiring = tickets.some((t) => t.expiringSoon && !t.expired);
    const healthy = tickets.some((t) => !t.expired && !t.expiringSoon);

const status: KerberosStatusResult["status"] =
      allExpired ? "expired" : anyExpiring ? "expiring" : healthy ? "ok" : "error";

const message =
      status === "expired"
        ? `All ${tickets.length} Kerberos ticket(s) have expired. The user must re-authenticate.`
        : status === "expiring"
          ? `At least one Kerberos ticket expires within ${expiryWarnMinutes} minute(s).`
          : `Kerberos looks healthy — ${tickets.length} ticket(s) valid.`;

return { platform, tickets, healthy, status, message };  
  } catch (err) {
    const msg = (err as Error).message;
    const classification = classifyKlistError(msg);
    if (classification === "missing") {
      return {
        platform, tickets: [], healthy: false,
        status: "missing",
        message: "No Kerberos credentials cache found — user has no active tickets.",
      };
    }
    return {
      platform, tickets: [], healthy: false,
      status: "error",
      message: `klist failed: ${msg}`,
    };
  }
}

// -- CLI smoke test -----------------------------------------------------------

if (false) {
  run()
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}