cyberark-mcp 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,19 +1,42 @@
1
1
  # cyberark-mcp
2
2
 
3
- CyberArk PVWA API: logon, safes, accounts. Token cached per process.
3
+ Password breach checks and identity security audits - no API key.
4
+
5
+ ## Tools
6
+
7
+ - `check_password` — Check a password against known data breaches using k-anonymity, so only five characters of its SHA-1 hash are sent and the password itself never leaves your machine. No API key needed.
8
+ - `check_passwords` — Check up to 10 passwords for known breaches in one call and report how many have already been leaked.
9
+ - `password_strength` — Score a password offline: entropy, character classes, common-word, keyboard and sequence patterns, plus an estimated offline crack time and a clear verdict.
10
+ - `generate_password` — Generate a random password from a cryptographically secure source, with control over length and character classes, and report its estimated crack time.
11
+ - `breach_catalog` — List breaches from the Have I Been Pwned catalogue, largest first, filtered by company domain or year, with the number of accounts and the kinds of data exposed.
12
+ - `breach_detail` — Look up one breach by name and get its date, exposed account count, verification status and the classes of data leaked.
13
+ - `domain_email_security` — Audit a domain's anti-spoofing setup: SPF, DMARC, DKIM selectors, MX records and CAA, graded A to F with the exact gaps to fix.
14
+ - `identify_hash` — Identify the likely algorithm behind a hash string — bcrypt, Argon2, crypt formats, MD5, SHA-1, SHA-256, SHA-512 and more — entirely offline.
15
+ - `hash_text` — Compute MD5, SHA-1, SHA-256 or SHA-512 digests of a string locally, for checksum and fingerprint checks.
16
+ - `breach_timeline` — Year-by-year view of known breaches from Have I Been Pwned: how many sites were breached and how many accounts were exposed.
17
+ - `subdomain_inventory` — Resolve common subdomains of a domain and show what each points to, flagging third-party CNAME targets that no longer resolve.
4
18
 
5
19
  ## Setup
6
20
 
7
- ```bash
8
- export CYBERARK_BASE_URL=...
9
- ```
21
+ No setup. Breach lookups use the public k-anonymity API and the rest runs locally.
10
22
 
11
- Needs CYBERARK_BASE_URL (PVWA host) plus CYBERARK_USERNAME and CYBERARK_PASSWORD.
23
+ Part of [awesome-mcps](https://github.com/mrfentmen/awesome-mcps).
12
24
 
13
- ## Tools
25
+ <!-- paywall -->
26
+ ## Premium tools
14
27
 
15
- - `list_safes` — CyberArk safes visible to the logon user.
16
- - `list_accounts` — CyberArk accounts, optionally filtered by safe.
17
- - `get_account` — One CyberArk account by id (metadata, not the password value).
28
+ These tools need a license key:
18
29
 
19
- Part of [awesome-mcps](https://github.com/mrfentmen/awesome-mcps).
30
+ * `password_variation_audit`
31
+ * `dns_record_inventory`
32
+
33
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-cyberark-mcp and set it in your MCP client config:
34
+
35
+ ```json
36
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
37
+ ```
38
+
39
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
40
+ working offline once one check has succeeded. Every other tool on this server
41
+ stays free.
42
+ <!-- /paywall -->
package/dist/api.d.ts CHANGED
@@ -1,7 +1,70 @@
1
- export declare class CyberarkError extends Error {
2
- constructor(message: string);
3
- }
4
1
  export declare function errorMessage(e: unknown): string;
5
- export declare function listSafes(): Promise<string>;
6
- export declare function listAccounts(safeName?: string): Promise<string>;
7
- export declare function getAccount(accountId: string): Promise<string>;
2
+ export declare function checkPassword(args: {
3
+ password: string;
4
+ }): Promise<string>;
5
+ export declare function checkPasswords(args: {
6
+ passwords: string[];
7
+ }): Promise<string>;
8
+ export interface StrengthResult {
9
+ length: number;
10
+ classes: number;
11
+ entropyBits: number;
12
+ crackSeconds: number;
13
+ findings: string[];
14
+ verdict: string;
15
+ }
16
+ /** Scores a password locally. Nothing is transmitted. */
17
+ export declare function analyzeStrength(password: string): StrengthResult;
18
+ export declare function passwordStrength(args: {
19
+ password: string;
20
+ }): Promise<string>;
21
+ export declare function generatePassword(args: {
22
+ length?: number;
23
+ symbols?: boolean;
24
+ digits?: boolean;
25
+ upperCase?: boolean;
26
+ }): Promise<string>;
27
+ export declare function breachCatalog(args: {
28
+ domain?: string;
29
+ year?: number;
30
+ limit?: number;
31
+ }): Promise<string>;
32
+ export declare function breachDetail(args: {
33
+ name: string;
34
+ }): Promise<string>;
35
+ export declare function domainEmailSecurity(args: {
36
+ domain: string;
37
+ }): Promise<string>;
38
+ export declare function identifyHash(args: {
39
+ hash: string;
40
+ }): Promise<string>;
41
+ export declare function hashText(args: {
42
+ text: string;
43
+ algorithm?: string;
44
+ }): Promise<string>;
45
+ export declare function breachTimeline(args: {
46
+ from?: number;
47
+ to?: number;
48
+ }): Promise<string>;
49
+ export declare function subdomainInventory(args: {
50
+ domain: string;
51
+ names?: string;
52
+ limit?: number;
53
+ }): Promise<string>;
54
+ export interface DnsInventoryArgs {
55
+ domain: string;
56
+ }
57
+ /** The full public DNS picture for a domain: delegation, mail, policy and zone authority in one pass. */
58
+ export declare function dnsRecordInventory(args: DnsInventoryArgs): Promise<string>;
59
+ export interface DnssecArgs {
60
+ domain: string;
61
+ }
62
+ /** Whether a domain is actually DNSSEC-signed and validated, following the chain from DS to DNSKEY to RRSIG. */
63
+ export declare function dnssecStatus(args: DnssecArgs): Promise<string>;
64
+ export interface VariationAuditArgs {
65
+ base: string;
66
+ year?: number;
67
+ limit?: number;
68
+ }
69
+ /** Builds the mutation dictionary a credential-stuffing attack would use from one base word and reports which variants are already breached. */
70
+ export declare function passwordVariationAudit(args: VariationAuditArgs): Promise<string>;
package/dist/api.js CHANGED
@@ -1,75 +1,735 @@
1
- export class CyberarkError extends Error {
2
- constructor(message) {
3
- super(message);
4
- this.name = "CyberarkError";
5
- }
6
- }
1
+ import { createHash, randomInt } from "node:crypto";
2
+ const UA = "mrfentmen-cyberark-mcp/1.0";
3
+ const PWNED = "https://api.pwnedpasswords.com";
4
+ const HIBP = "https://haveibeenpwned.com/api/v3";
5
+ const DOH = "https://dns.google/resolve";
7
6
  export function errorMessage(e) {
8
7
  return e instanceof Error ? e.message : String(e);
9
8
  }
10
- const UA = { "User-Agent": "awesome-mcps/1.0" };
11
- function envStrict(name) {
12
- const v = process.env[name];
13
- if (!v)
14
- throw new CyberarkError(`Set the ${name} environment variable.`);
15
- return v;
16
- }
17
- async function apiBase() {
18
- return envStrict("CYBERARK_BASE_URL").replace(/\/$/, "");
19
- }
20
- let cachedToken = "";
21
- async function authHeaders() {
22
- if (!cachedToken) {
23
- const base = await apiBase();
24
- const res = await fetch(`${base}/PasswordVault/API/Auth/Cyberark/Logon`, {
25
- method: "POST",
26
- headers: { ...UA, "Content-Type": "application/json" },
27
- body: JSON.stringify({ username: envStrict("CYBERARK_USERNAME"), password: envStrict("CYBERARK_PASSWORD") }),
28
- });
29
- if (!res.ok)
30
- throw new CyberarkError(`CyberArk logon failed: HTTP ${res.status}`);
31
- const data = (await res.json());
32
- if (!data.CyberArkLogonResult)
33
- throw new CyberarkError("CyberArk logon returned no token");
34
- cachedToken = data.CyberArkLogonResult;
35
- }
36
- return { Authorization: cachedToken };
37
- }
38
- function pretty(data) {
39
- const t = typeof data === "string" ? data : JSON.stringify(data, null, 2);
40
- return t.length > 12000 ? t.slice(0, 12000) + "\n…(truncated)" : t;
41
- }
42
- async function req(url, init = {}) {
43
- const res = await fetch(url, { headers: { ...UA, ...(await authHeaders()), ...(init.headers ?? {}) }, ...init });
44
- const ct = res.headers.get("content-type") ?? "";
45
- const data = ct.includes("json") ? await res.json() : await res.text();
46
- if (!res.ok) {
47
- const detail = typeof data === "string" ? data.slice(0, 300) : JSON.stringify(data).slice(0, 300);
48
- throw new CyberarkError(`HTTP ${res.status}: ${detail}`);
49
- }
50
- return data;
51
- }
52
- export async function listSafes() {
53
- const base = await apiBase();
54
- let url = `${base}/PasswordVault/API/Safes`;
55
- const data = await req(url);
56
- return pretty(data);
57
- }
58
- export async function listAccounts(safeName) {
59
- const base = await apiBase();
60
- let url = `${base}/PasswordVault/API/Accounts`;
61
- const qs = new URLSearchParams();
62
- if (safeName !== undefined)
63
- qs.append("safeName", String(safeName));
64
- const qstr = qs.toString();
65
- if (qstr)
66
- url += (url.includes('?') ? '&' : '?') + qstr;
67
- const data = await req(url);
68
- return pretty(data);
69
- }
70
- export async function getAccount(accountId) {
71
- const base = await apiBase();
72
- let url = `${base}/PasswordVault/API/Accounts/${encodeURIComponent(String(accountId))}`;
73
- const data = await req(url);
74
- return pretty(data);
9
+ /**
10
+ * Checks a password against the Have I Been Pwned range API using k-anonymity:
11
+ * only the first five characters of the SHA-1 hash ever leave this server, and
12
+ * the full password is never sent or logged.
13
+ */
14
+ async function pwnedCount(password) {
15
+ if (!password)
16
+ throw new Error("Provide the password to check.");
17
+ const hash = createHash("sha1").update(password, "utf8").digest("hex").toUpperCase();
18
+ const prefix = hash.slice(0, 5);
19
+ const suffix = hash.slice(5);
20
+ const res = await fetch(`${PWNED}/range/${prefix}`, {
21
+ headers: { "User-Agent": UA, "Add-Padding": "true" },
22
+ signal: AbortSignal.timeout(20000),
23
+ });
24
+ if (!res.ok)
25
+ throw new Error(`Have I Been Pwned returned HTTP ${res.status}.`);
26
+ const body = await res.text();
27
+ for (const line of body.split(/\r?\n/)) {
28
+ const [candidate, count] = line.split(":");
29
+ if (candidate?.trim().toUpperCase() === suffix)
30
+ return Number(count) || 0;
31
+ }
32
+ return 0;
33
+ }
34
+ export async function checkPassword(args) {
35
+ const count = await pwnedCount(args.password);
36
+ const lines = count > 0
37
+ ? [
38
+ `BREACHED — this password appears in ${count.toLocaleString("en-US")} known data breach record(s).`,
39
+ "Do not use it. Attackers try these first and it is not safe for any account.",
40
+ ]
41
+ : [
42
+ "Not found in any known breach.",
43
+ "It has not appeared in a public data breach dump. Pair it with a unique password per site.",
44
+ ];
45
+ return [
46
+ `Password breach check`,
47
+ `Only the first 5 characters of the SHA-1 hash were sent (k-anonymity), never the password itself.`,
48
+ "",
49
+ ...lines,
50
+ ].join("\n");
51
+ }
52
+ export async function checkPasswords(args) {
53
+ const list = (args.passwords ?? []).map((p) => String(p)).filter(Boolean).slice(0, 10);
54
+ if (!list.length)
55
+ return "Provide up to 10 passwords to check.";
56
+ const rows = [];
57
+ let breached = 0;
58
+ // Sequential so a burst of requests does not get rate limited.
59
+ for (let i = 0; i < list.length; i += 1) {
60
+ const count = await pwnedCount(list[i]);
61
+ if (count > 0)
62
+ breached += 1;
63
+ rows.push(`#${i + 1}: ${count > 0 ? `BREACHED (${count.toLocaleString("en-US")} record(s))` : "not in any breach"}`);
64
+ }
65
+ return [
66
+ `Checked ${list.length} password(s): ${breached} already breached.`,
67
+ "Passwords are never stored or echoed — each is hashed and only its 5-character hash prefix is sent.",
68
+ "",
69
+ ...rows,
70
+ ].join("\n");
71
+ }
72
+ const COMMON_WORDS = [
73
+ "password", "letmein", "welcome", "admin", "qwerty", "iloveyou", "monkey", "dragon",
74
+ "football", "baseball", "sunshine", "princess", "login", "abc123", "changeme", "secret",
75
+ "summer", "winter", "spring", "autumn", "shadow", "master", "freedom", "whatever",
76
+ "michael", "jordan", "superman", "trustno1", "starwars", "computer", "internet",
77
+ ];
78
+ const KEYBOARD_RUNS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890"];
79
+ function humanDuration(seconds) {
80
+ if (seconds < 1)
81
+ return "less than a second";
82
+ const units = [
83
+ ["year", 31557600], ["day", 86400], ["hour", 3600], ["minute", 60], ["second", 1],
84
+ ];
85
+ for (const [name, size] of units) {
86
+ const value = seconds / size;
87
+ if (value >= 1) {
88
+ const rounded = Math.round(value);
89
+ return `${rounded.toLocaleString("en-US")} ${name}${rounded === 1 ? "" : "s"}`;
90
+ }
91
+ }
92
+ return "less than a second";
93
+ }
94
+ /** Scores a password locally. Nothing is transmitted. */
95
+ export function analyzeStrength(password) {
96
+ const value = password ?? "";
97
+ const length = value.length;
98
+ const findings = [];
99
+ const hasLower = /[a-z]/.test(value);
100
+ const hasUpper = /[A-Z]/.test(value);
101
+ const hasDigit = /\d/.test(value);
102
+ const hasSymbol = /[^A-Za-z0-9]/.test(value);
103
+ const classes = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean).length;
104
+ let alphabet = 0;
105
+ if (hasLower)
106
+ alphabet += 26;
107
+ if (hasUpper)
108
+ alphabet += 26;
109
+ if (hasDigit)
110
+ alphabet += 10;
111
+ if (hasSymbol)
112
+ alphabet += 33;
113
+ let entropyBits = alphabet > 0 ? length * Math.log2(alphabet) : 0;
114
+ if (length < 12)
115
+ findings.push(`Only ${length} character(s) — use at least 12, ideally 16 or a passphrase.`);
116
+ if (classes < 3)
117
+ findings.push(`Uses ${classes} character type(s) — mix upper case, lower case, digits and symbols.`);
118
+ if (/(.)\1{2,}/.test(value))
119
+ findings.push("Repeats the same character three or more times.");
120
+ if (/(012|123|234|345|456|567|678|789|890|abc|bcd|cde|def)/i.test(value))
121
+ findings.push("Contains a straight sequence like 123 or abc.");
122
+ const lowerValue = value.toLowerCase();
123
+ for (const run of KEYBOARD_RUNS) {
124
+ for (let i = 0; i + 4 <= run.length; i += 1) {
125
+ const chunk = run.slice(i, i + 4);
126
+ if (lowerValue.includes(chunk)) {
127
+ findings.push(`Contains the keyboard pattern "${chunk}".`);
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ const matchedWord = COMMON_WORDS.find((word) => lowerValue.includes(word));
133
+ if (matchedWord)
134
+ findings.push(`Contains the common word "${matchedWord}".`);
135
+ const hasYear = /(19|20)\d{2}/.test(value);
136
+ if (hasYear)
137
+ findings.push("Contains a four-digit year, which is easy to guess.");
138
+ const capitalisedPlus = /^[A-Z][a-z]{3,}[\d!@#$%^&*.]{1,6}$/.test(value);
139
+ if (capitalisedPlus)
140
+ findings.push("Looks like a capitalised word plus digits or symbols — a pattern cracking tools try early.");
141
+ const keyboardRun = KEYBOARD_RUNS.some((run) => lowerValue.includes(run.slice(0, 4)));
142
+ const sequence = /(012|123|234|345|456|567|678|789|890|abc|bcd|cde|def)/i.test(value);
143
+ if (matchedWord || keyboardRun || sequence || hasYear || capitalisedPlus) {
144
+ // Dictionary words, keyboard walks, sequences and years collapse the search
145
+ // space, so bill those passwords far below the raw alphabet product.
146
+ entropyBits = Math.min(entropyBits, length * 3);
147
+ }
148
+ // 10 billion guesses per second: a modern offline attack on a fast hash.
149
+ const guesses = Math.pow(2, Math.max(entropyBits, 1));
150
+ const crackSeconds = guesses / 1e10;
151
+ const verdict = crackSeconds > 31557600 * 100
152
+ ? "Strong"
153
+ : crackSeconds > 31557600
154
+ ? "Reasonable"
155
+ : crackSeconds > 86400
156
+ ? "Weak"
157
+ : "Very weak";
158
+ return { length, classes, entropyBits: Math.round(entropyBits), crackSeconds, findings, verdict };
159
+ }
160
+ export async function passwordStrength(args) {
161
+ const r = analyzeStrength(args.password);
162
+ return [
163
+ "Password strength analysis (computed locally, never sent anywhere)",
164
+ `Verdict: ${r.verdict}`,
165
+ `Length: ${r.length} character(s) | Character types used: ${r.classes} of 4`,
166
+ `Estimated entropy: ${r.entropyBits} bits`,
167
+ `Offline crack time at 10 billion guesses/second: ${humanDuration(r.crackSeconds)}`,
168
+ "",
169
+ r.findings.length
170
+ ? `Issues found (${r.findings.length}):\n${r.findings.map((f) => ` - ${f}`).join("\n")}`
171
+ : "No obvious weaknesses found.",
172
+ ].join("\n");
173
+ }
174
+ const SYMBOLS = "!@#$%^&*()-_=+[]{};:,.?/";
175
+ export async function generatePassword(args) {
176
+ const length = Math.min(Math.max(Number(args.length ?? 20) || 20, 8), 128);
177
+ const pools = ["abcdefghijkmnopqrstuvwxyz"];
178
+ if (args.upperCase !== false)
179
+ pools.push("ABCDEFGHJKLMNPQRSTUVWXYZ");
180
+ if (args.digits !== false)
181
+ pools.push("23456789");
182
+ if (args.symbols !== false)
183
+ pools.push(SYMBOLS);
184
+ const all = pools.join("");
185
+ const chars = [];
186
+ // One character from every pool first, so the result always uses each class.
187
+ for (const pool of pools) {
188
+ if (chars.length >= length)
189
+ break;
190
+ chars.push(pool[randomInt(0, pool.length)]);
191
+ }
192
+ while (chars.length < length)
193
+ chars.push(all[randomInt(0, all.length)]);
194
+ // Shuffle with a cryptographic random source so the required characters are not first.
195
+ for (let i = chars.length - 1; i > 0; i -= 1) {
196
+ const j = randomInt(0, i + 1);
197
+ const tmp = chars[i];
198
+ chars[i] = chars[j];
199
+ chars[j] = tmp;
200
+ }
201
+ const password = chars.join("");
202
+ const r = analyzeStrength(password);
203
+ return [
204
+ "Generated password (from a cryptographically secure random source)",
205
+ "",
206
+ password,
207
+ "",
208
+ `Length: ${length} | Character classes: ${r.classes} of 4 | Entropy: ${r.entropyBits} bits`,
209
+ `Offline crack time: ${humanDuration(r.crackSeconds)}`,
210
+ "Store it in a secret manager, not in a document or chat.",
211
+ ].join("\n");
212
+ }
213
+ export async function breachCatalog(args) {
214
+ const res = await fetch(`${HIBP}/breaches`, {
215
+ headers: { "User-Agent": UA, Accept: "application/json" },
216
+ signal: AbortSignal.timeout(20000),
217
+ });
218
+ if (!res.ok)
219
+ throw new Error(`Have I Been Pwned returned HTTP ${res.status}.`);
220
+ let breaches = (await res.json());
221
+ if (args.domain) {
222
+ const wanted = args.domain.trim().toLowerCase();
223
+ breaches = breaches.filter((b) => (b.Domain ?? "").toLowerCase().includes(wanted));
224
+ }
225
+ if (args.year) {
226
+ breaches = breaches.filter((b) => b.BreachDate?.startsWith(String(args.year)));
227
+ }
228
+ breaches = breaches.filter((b) => !b.IsSensitive);
229
+ breaches.sort((a, b) => b.PwnCount - a.PwnCount);
230
+ const limit = Math.min(Math.max(Number(args.limit ?? 15) || 15, 1), 100);
231
+ const shown = breaches.slice(0, limit);
232
+ const lines = [
233
+ `Breach catalogue${args.domain ? ` for "${args.domain}"` : ""}${args.year ? ` in ${args.year}` : ""} — ${breaches.length} matching breach(es), largest first`,
234
+ "Source: Have I Been Pwned, which aggregates publicly disclosed breaches.",
235
+ "",
236
+ ];
237
+ shown.forEach((b, i) => {
238
+ lines.push(`${i + 1}. ${b.Title} (${b.Domain || "no domain"})`);
239
+ lines.push(` breached ${b.BreachDate} | ${b.PwnCount.toLocaleString("en-US")} accounts | data: ${(b.DataClasses ?? []).join(", ")}`);
240
+ });
241
+ if (!shown.length)
242
+ lines.push("No breaches matched those filters.");
243
+ return lines.join("\n");
244
+ }
245
+ export async function breachDetail(args) {
246
+ const name = (args.name ?? "").trim();
247
+ if (!name)
248
+ return "Provide a breach name such as LinkedIn or Adobe.";
249
+ const res = await fetch(`${HIBP}/breach/${encodeURIComponent(name)}`, {
250
+ headers: { "User-Agent": UA, Accept: "application/json" },
251
+ signal: AbortSignal.timeout(20000),
252
+ });
253
+ if (res.status === 404)
254
+ return `No breach named "${name}" is in the Have I Been Pwned catalogue.`;
255
+ if (!res.ok)
256
+ throw new Error(`Have I Been Pwned returned HTTP ${res.status}.`);
257
+ const b = (await res.json());
258
+ return [
259
+ `Breach: ${b.Title}`,
260
+ `Domain: ${b.Domain || "not disclosed"}`,
261
+ `Breach date: ${b.BreachDate}`,
262
+ `Added to the catalogue: ${b.AddedDate?.slice(0, 10) ?? "unknown"}`,
263
+ `Accounts exposed: ${b.PwnCount.toLocaleString("en-US")}`,
264
+ `Verified by the site: ${b.IsVerified ? "yes" : "no"}`,
265
+ `Data classes: ${(b.DataClasses ?? []).join(", ")}`,
266
+ "",
267
+ b.Description?.replace(/<[^>]+>/g, "").slice(0, 1200) ?? "",
268
+ ].filter(Boolean).join("\n");
269
+ }
270
+ const TYPE_NAMES = {
271
+ 1: "A", 2: "NS", 5: "CNAME", 15: "MX", 16: "TXT", 257: "CAA",
272
+ };
273
+ async function dns(name, type) {
274
+ const res = await fetch(`${DOH}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`, {
275
+ headers: { "User-Agent": UA, Accept: "application/dns-json" },
276
+ signal: AbortSignal.timeout(15000),
277
+ });
278
+ if (!res.ok)
279
+ throw new Error(`DNS-over-HTTPS returned HTTP ${res.status}.`);
280
+ return (await res.json());
281
+ }
282
+ const records = (d, type) => (d.Answer ?? [])
283
+ .filter((row) => TYPE_NAMES[row.type ?? 0] === type)
284
+ // TXT answers arrive wrapped in quotes; CAA answers start with a flag digit and
285
+ // must keep their inner quotes, so only strip a pair that wraps the whole value.
286
+ .map((row) => String(row.data ?? "").trim().replace(/^"([\s\S]*)"$/, "$1"));
287
+ const DKIM_SELECTORS = ["default", "google", "selector1", "selector2", "k1", "mail", "dkim"];
288
+ export async function domainEmailSecurity(args) {
289
+ const raw = (args.domain ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
290
+ if (!raw || !raw.includes("."))
291
+ return "Provide a domain like example.com.";
292
+ const [spfRes, dmarcRes, mxRes, caaRes] = await Promise.all([
293
+ dns(raw, "TXT"),
294
+ dns(`_dmarc.${raw}`, "TXT"),
295
+ dns(raw, "MX"),
296
+ dns(raw, "CAA"),
297
+ ]);
298
+ const spf = records(spfRes, "TXT").find((r) => r.startsWith("v=spf1"));
299
+ const dmarc = records(dmarcRes, "TXT").find((r) => r.startsWith("v=DMARC1"));
300
+ const mx = records(mxRes, "MX");
301
+ const caa = records(caaRes, "CAA");
302
+ const dkimHits = [];
303
+ for (const selector of DKIM_SELECTORS) {
304
+ const res = await dns(`${selector}._domainkey.${raw}`, "TXT");
305
+ if (records(res, "TXT").some((r) => /v=DKIM1|k=rsa|p=/.test(r)))
306
+ dkimHits.push(selector);
307
+ }
308
+ const checks = [
309
+ ["SPF record", Boolean(spf), spf ?? "missing — anyone can send mail claiming to be this domain"],
310
+ ["DMARC policy", Boolean(dmarc), dmarc ?? "missing — receiving servers have no instruction for spoofed mail"],
311
+ ["DKIM signing", dkimHits.length > 0, dkimHits.length ? `published selectors: ${dkimHits.join(", ")}` : `no key found at common selectors (${DKIM_SELECTORS.join(", ")})`],
312
+ ["MX records", mx.length > 0, mx.length ? `${mx.length} mail host(s)` : "no MX record — the domain cannot receive mail"],
313
+ ["CAA record", caa.length > 0, caa.length ? caa.join(" | ") : "missing — any certificate authority may issue certificates for this domain"],
314
+ ];
315
+ const passing = checks.filter(([, ok]) => ok).length;
316
+ const grade = passing === 5 ? "A" : passing === 4 ? "B" : passing === 3 ? "C" : passing === 2 ? "D" : "F";
317
+ return [
318
+ `Email and identity security for ${raw}`,
319
+ `Grade: ${grade} — ${passing} of ${checks.length} controls in place`,
320
+ "",
321
+ ...checks.map(([name, ok, detail]) => `${ok ? "PASS" : "MISS"} ${name}\n ${detail}`),
322
+ "",
323
+ spf && /[-~?]all/.test(spf)
324
+ ? `SPF enforcement: "${spf.match(/[-~?]all/)?.[0]}" (${spf.includes("-all") ? "rejects unauthorised senders" : spf.includes("~all") ? "soft-fails unauthorised senders" : "allows unauthorised senders"})`
325
+ : "",
326
+ dmarc && /p=(none|quarantine|reject)/.test(dmarc)
327
+ ? `DMARC policy: ${dmarc.match(/p=(none|quarantine|reject)/)?.[1]}`
328
+ : "",
329
+ ].filter(Boolean).join("\n");
330
+ }
331
+ // ---- batch-3 tools: hash ID, hashing, breach trends, subdomain inventory (2026-09-27) ----
332
+ const HASH_PATTERNS = [
333
+ { name: "bcrypt", pattern: /^\$2[aby]?\$\d{2}\$[./A-Za-z0-9]{53}$/, note: "bcrypt, with the cost factor in the third field" },
334
+ { name: "argon2", pattern: /^\$argon2(i|d|id)\$/, note: "Argon2 — modern password hash" },
335
+ { name: "sha512crypt", pattern: /^\$6\$/, note: "SHA-512 crypt, used in Linux /etc/shadow" },
336
+ { name: "sha256crypt", pattern: /^\$5\$/, note: "SHA-256 crypt, used in Linux /etc/shadow" },
337
+ { name: "md5crypt", pattern: /^\$1\$/, note: "MD5 crypt — obsolete" },
338
+ { name: "phpass", pattern: /^\$P\$[./A-Za-z0-9]{31}$/, note: "phpass, used by older WordPress and phpBB" },
339
+ { name: "mysql5", pattern: /^\*[A-F0-9]{40}$/, note: "MySQL 5 password hash" },
340
+ { name: "windows-lmntlm", pattern: /^[a-f0-9]{32}:[a-f0-9]{32}$/i, note: "Windows LM:NTLM pair" },
341
+ { name: "md5", pattern: /^[a-f0-9]{32}$/i, note: "MD5 or NTLM — same length, both fast and broken" },
342
+ { name: "sha1", pattern: /^[a-f0-9]{40}$/i, note: "SHA-1 or MySQL 4.1 password hash" },
343
+ { name: "sha256", pattern: /^[a-f0-9]{64}$/i, note: "SHA-256 or SHA3-256" },
344
+ { name: "sha512", pattern: /^[a-f0-9]{128}$/i, note: "SHA-512 or SHA3-512" },
345
+ { name: "crc32", pattern: /^[a-f0-9]{8}$/i, note: "CRC-32 checksum, not a security hash" },
346
+ ];
347
+ export async function identifyHash(args) {
348
+ const value = (args.hash ?? "").trim();
349
+ if (!value)
350
+ throw new Error("Provide a hash string to identify.");
351
+ if (value.length > 512)
352
+ throw new Error("That is longer than any supported hash; paste one hash at a time.");
353
+ const matches = HASH_PATTERNS.filter((p) => p.pattern.test(value));
354
+ const lines = [
355
+ `Hash identification (${value.length} character(s))`,
356
+ `Preview: ${value.slice(0, 72)}${value.length > 72 ? "…" : ""}`,
357
+ "",
358
+ matches.length ? `Possible formats (${matches.length}):` : "No known hash format matched.",
359
+ ...matches.map((m) => ` - ${m.name}: ${m.note}`),
360
+ ];
361
+ if (!matches.length) {
362
+ lines.push("A hash is normally hex or starts with $; check for a truncation, a salt prefix, or mixed case that broke the pattern.");
363
+ }
364
+ const weak = matches.some((m) => ["md5", "sha1", "crc32", "md5crypt"].includes(m.name));
365
+ if (weak)
366
+ lines.push("", "Warning: MD5, SHA-1 and CRC-32 are unsuitable for password storage. Migrate to bcrypt, scrypt or Argon2.");
367
+ lines.push("", "The format was identified locally — nothing was cracked and nothing left this machine.");
368
+ return lines.join("\n");
369
+ }
370
+ const HASH_ALGORITHMS = ["md5", "sha1", "sha256", "sha512"];
371
+ export async function hashText(args) {
372
+ const value = args.text ?? "";
373
+ if (!value)
374
+ throw new Error("Provide the text to hash.");
375
+ const wanted = (args.algorithm ?? "all").trim().toLowerCase();
376
+ const chosen = wanted === "all" ? [...HASH_ALGORITHMS] : HASH_ALGORITHMS.filter((a) => a === wanted);
377
+ if (!chosen.length)
378
+ throw new Error(`algorithm must be one of ${HASH_ALGORITHMS.join(", ")} or all`);
379
+ return [
380
+ `Hashes of ${value.length} character(s), computed locally:`,
381
+ ...chosen.map((a) => `${a}: ${createHash(a).update(value, "utf8").digest("hex")}`),
382
+ "",
383
+ "MD5 and SHA-1 are checksums, not password storage. Use bcrypt, scrypt or Argon2 for credentials.",
384
+ ].join("\n");
385
+ }
386
+ export async function breachTimeline(args) {
387
+ const res = await fetch(`${HIBP}/breaches`, {
388
+ headers: { "User-Agent": UA, Accept: "application/json" },
389
+ signal: AbortSignal.timeout(20000),
390
+ });
391
+ if (!res.ok)
392
+ throw new Error(`Have I Been Pwned returned HTTP ${res.status}.`);
393
+ const breaches = (await res.json()).filter((b) => !b.IsSensitive);
394
+ const thisYear = new Date().getFullYear();
395
+ const from = Math.min(Math.max(Math.floor(Number(args.from ?? 2004) || 2004), 1990), thisYear);
396
+ const to = Math.min(Math.max(Math.floor(Number(args.to ?? thisYear) || thisYear), from), thisYear);
397
+ const byYear = new Map();
398
+ for (const b of breaches) {
399
+ const year = Number((b.BreachDate ?? "").slice(0, 4));
400
+ if (!Number.isFinite(year) || year < from || year > to)
401
+ continue;
402
+ const row = byYear.get(year) ?? { breaches: 0, accounts: 0 };
403
+ row.breaches += 1;
404
+ row.accounts += Number(b.PwnCount ?? 0);
405
+ byYear.set(year, row);
406
+ }
407
+ const years = [...byYear.entries()].sort((a, b) => a[0] - b[0]);
408
+ if (!years.length)
409
+ return `No breaches in the Have I Been Pwned catalogue between ${from} and ${to}.`;
410
+ const totBreaches = years.reduce((s, [, r]) => s + r.breaches, 0);
411
+ const totAccounts = years.reduce((s, [, r]) => s + r.accounts, 0);
412
+ return [
413
+ `Have I Been Pwned breaches per year, ${from}-${to}`,
414
+ `${totBreaches} breached site(s) and ${totAccounts.toLocaleString("en-US")} exposed account record(s) in that window.`,
415
+ "",
416
+ ...years.map(([year, row]) => `${year}: ${String(row.breaches).padStart(4)} breach(es) | ${row.accounts.toLocaleString("en-US").padStart(16)} accounts`),
417
+ ].join("\n");
418
+ }
419
+ const TAKEOVER_SERVICES = [
420
+ { suffix: "github.io", service: "GitHub Pages" },
421
+ { suffix: "s3.amazonaws.com", service: "Amazon S3" },
422
+ { suffix: "herokuapp.com", service: "Heroku" },
423
+ { suffix: "netlify.app", service: "Netlify" },
424
+ { suffix: "vercel.app", service: "Vercel" },
425
+ { suffix: "myshopify.com", service: "Shopify" },
426
+ { suffix: "azurewebsites.net", service: "Azure App Service" },
427
+ { suffix: "cloudfront.net", service: "Amazon CloudFront" },
428
+ { suffix: "fastly.net", service: "Fastly" },
429
+ { suffix: "wordpress.com", service: "WordPress.com" },
430
+ { suffix: "surge.sh", service: "Surge" },
431
+ { suffix: "fly.dev", service: "Fly.io" },
432
+ { suffix: "onrender.com", service: "Render" },
433
+ { suffix: "web.app", service: "Firebase Hosting" },
434
+ ];
435
+ const DEFAULT_SUBDOMAINS = [
436
+ "www", "mail", "dev", "staging", "api", "app", "cdn", "blog", "shop", "test",
437
+ "admin", "portal", "docs", "support", "status", "beta", "demo", "m", "vpn", "assets",
438
+ ];
439
+ async function lookupHost(name, type) {
440
+ const body = await dns(name, type);
441
+ return { status: body.Status ?? -1, values: records(body, type) };
442
+ }
443
+ export async function subdomainInventory(args) {
444
+ const raw = (args.domain ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
445
+ if (!raw || !raw.includes("."))
446
+ return "Provide a domain like example.com.";
447
+ const candidates = args.names
448
+ ? args.names.split(/[,\s]+/).map((s) => s.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean)
449
+ : DEFAULT_SUBDOMAINS;
450
+ const limit = Math.min(Math.max(Math.floor(Number(args.limit ?? 20) || 20), 1), 40);
451
+ const list = [...new Set(candidates)].slice(0, limit);
452
+ const rows = [];
453
+ const findings = [];
454
+ for (const name of list) {
455
+ const host = `${name}.${raw}`;
456
+ const cname = await lookupHost(host, "CNAME");
457
+ if (cname.values.length) {
458
+ const target = cname.values[0];
459
+ const bare = target.replace(/\.$/, "");
460
+ const targetA = await lookupHost(bare, "A");
461
+ const service = TAKEOVER_SERVICES.find((s) => bare.toLowerCase().endsWith(s.suffix));
462
+ const dangling = targetA.values.length === 0 || targetA.status === 3;
463
+ rows.push(`${host} -> CNAME ${bare}${service ? ` (${service.service})` : ""} | target ${dangling ? "does not resolve" : `resolves to ${targetA.values.slice(0, 2).join(", ")}`}`);
464
+ if (dangling && service) {
465
+ findings.push(`${host} points at ${service.service} but the target does not resolve — claim the resource or remove the record.`);
466
+ }
467
+ continue;
468
+ }
469
+ const a = await lookupHost(host, "A");
470
+ if (a.values.length) {
471
+ rows.push(`${host} -> ${a.values.slice(0, 3).join(", ")}`);
472
+ continue;
473
+ }
474
+ rows.push(`${host} -> no A or CNAME record${a.status === 3 ? " (NXDOMAIN)" : a.status !== 0 ? ` (DNS status ${a.status})` : ""}`);
475
+ }
476
+ return [
477
+ `Subdomain inventory for ${raw} (${list.length} name(s), DNS-over-HTTPS)`,
478
+ "",
479
+ ...rows,
480
+ "",
481
+ findings.length
482
+ ? `Possible dangling records (${findings.length}):\n${findings.map((f) => ` - ${f}`).join("\n")}`
483
+ : "No dangling third-party CNAME records found in this sample.",
484
+ "Resolving subdomains is reconnaissance, so only run this against domains you own or are authorised to test.",
485
+ ].join("\n");
486
+ }
487
+ // ---- muxD tools:
488
+ const cleanDomain = (v) => (v ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/\.$/, "");
489
+ const stripOuterQuotes = (v) => v.trim().replace(/^"([\s\S]*)"$/, "$1");
490
+ /**
491
+ * The shared TYPE_NAMES table in this file only covers A, NS, CNAME, MX, TXT and
492
+ * CAA, so filtering through it silently yields nothing for SOA, AAAA, DS, DNSKEY
493
+ * and RRSIG. Filter by numeric type here instead so those are read correctly.
494
+ */
495
+ const DNS_TYPES = {
496
+ A: 1, NS: 2, CNAME: 5, SOA: 6, MX: 15, TXT: 16, AAAA: 28, DS: 43, RRSIG: 46, DNSKEY: 48, CAA: 257,
497
+ };
498
+ function typedRecords(body, type) {
499
+ const want = DNS_TYPES[type];
500
+ if (want === undefined)
501
+ throw new Error(`Unsupported record type ${type}.`);
502
+ return (body.Answer ?? [])
503
+ .filter((row) => row.type === want)
504
+ .map((row) => String(row.data ?? "").trim().replace(/^"([\s\S]*)"$/, "$1"));
505
+ }
506
+ /** A DoH query with the DNSSEC OK bit set, so the resolver returns RRSIGs and the AD flag. */
507
+ async function dnssecQuery(name, type) {
508
+ const res = await fetch(`${DOH}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}&do=1`, {
509
+ headers: { "User-Agent": UA, Accept: "application/dns-json" },
510
+ signal: AbortSignal.timeout(15000),
511
+ });
512
+ if (!res.ok)
513
+ throw new Error(`DNS-over-HTTPS returned HTTP ${res.status}.`);
514
+ return (await res.json());
515
+ }
516
+ /** The full public DNS picture for a domain: delegation, mail, policy and zone authority in one pass. */
517
+ export async function dnsRecordInventory(args) {
518
+ const raw = cleanDomain(args.domain);
519
+ if (!raw || !raw.includes("."))
520
+ return "Provide a domain like example.com.";
521
+ const types = [
522
+ ["NS", "delegation"],
523
+ ["A", "web endpoints"],
524
+ ["AAAA", "IPv6 endpoints"],
525
+ ["MX", "mail exchangers"],
526
+ ["TXT", "text policy records"],
527
+ ["SOA", "zone authority"],
528
+ ["CAA", "certificate authority policy"],
529
+ ];
530
+ const results = await Promise.all(types.map(async ([type, label]) => {
531
+ try {
532
+ return { type, label, body: await dns(raw, type) };
533
+ }
534
+ catch (e) {
535
+ return { type, label, error: e instanceof Error ? e.message : String(e) };
536
+ }
537
+ }));
538
+ const lines = [`DNS inventory for ${raw} (via DNS-over-HTTPS)`, ""];
539
+ for (const r of results) {
540
+ lines.push(`${r.label} (${r.type}):`);
541
+ if ("error" in r && r.error) {
542
+ lines.push(` lookup failed: ${r.error}`);
543
+ continue;
544
+ }
545
+ const body = r.body;
546
+ const found = typedRecords(body, r.type);
547
+ if (!found.length) {
548
+ const note = body.Status === 3 ? "none (NXDOMAIN for this type)" : "none published";
549
+ lines.push(` ${note}`);
550
+ continue;
551
+ }
552
+ const shown = r.type === "TXT" ? found.slice(0, 12) : found.slice(0, 8);
553
+ for (const value of shown)
554
+ lines.push(` ${value.length > 150 ? `${value.slice(0, 150)}…` : value}`);
555
+ if (found.length > shown.length)
556
+ lines.push(` ...and ${found.length - shown.length} more`);
557
+ }
558
+ const find = (t) => {
559
+ const r = results.find((x) => x.type === t);
560
+ return r && !("error" in r) ? typedRecords(r.body, t) : [];
561
+ };
562
+ const ns = find("NS");
563
+ const soa = find("SOA");
564
+ const caa = find("CAA");
565
+ const mx = find("MX");
566
+ const txt = find("TXT");
567
+ lines.push("");
568
+ const notes = [];
569
+ if (ns.length) {
570
+ const registrars = new Set(ns.map((n) => n.replace(/\.$/, "").split(".").slice(-2).join(".")));
571
+ lines.push(`Delegated to ${ns.length} name server(s) across ${registrars.size} operator(s): ${[...registrars].join(", ")}`);
572
+ const glue = ns.filter((n) => !(n.replace(/\.$/, "") in {}));
573
+ if (glue.length !== ns.length)
574
+ lines.push(` ${ns.length} name server record(s) in total`);
575
+ }
576
+ if (soa.length) {
577
+ const parts = soa[0].split(/\s+/);
578
+ const mname = parts[0] ?? "?";
579
+ const serial = parts[2] ?? "?";
580
+ lines.push(`Zone authority: primary ${mname.replace(/\.$/, "")}, serial ${serial}`);
581
+ if (/^\d{1,2}$/.test(serial))
582
+ notes.push(`SOA serial ${serial} is far below a normal epoch-based value, so this zone is not following the usual change-detection convention.`);
583
+ }
584
+ if (mx.length) {
585
+ const nullMx = mx.filter((m) => /^\s*0\s+\.\s*$/.test(m) || /^0 \.$/.test(m.trim()));
586
+ if (nullMx.length)
587
+ notes.push("A null MX (0 .) is published, which explicitly tells every mail server not to attempt delivery to this domain.");
588
+ }
589
+ else if (txt.length) {
590
+ notes.push("No MX record is published. If mail for this domain is expected, nothing will route it unless an MX-less sender falls back to A/AAAA, which almost none do.");
591
+ }
592
+ if (!caa.length) {
593
+ notes.push("No CAA record is published, so any public CA may issue a certificate for this domain. CAA is the only place to restrict that.");
594
+ }
595
+ else {
596
+ lines.push(`CAA permits: ${caa.join("; ")}`);
597
+ }
598
+ const spf = txt.filter((t) => /^v=spf1/i.test(t));
599
+ const dmarc = txt.filter((t) => /^v=DMARC1/i.test(t));
600
+ if (spf.length)
601
+ lines.push(`SPF present on the apex (${spf.length} record(s)); use domain_email_security for the full read.`);
602
+ if (dmarc.length)
603
+ lines.push("DMARC present on the apex.");
604
+ if (notes.length)
605
+ lines.push("", "Notes:", ...notes.map((n) => `- ${n}`));
606
+ return lines.join("\n");
607
+ }
608
+ /** Whether a domain is actually DNSSEC-signed and validated, following the chain from DS to DNSKEY to RRSIG. */
609
+ export async function dnssecStatus(args) {
610
+ const raw = cleanDomain(args.domain);
611
+ if (!raw || !raw.includes("."))
612
+ return "Provide a domain like example.com.";
613
+ const [dsRes, dnskeyRes, aRes] = await Promise.all([
614
+ dnssecQuery(raw, "DS"),
615
+ dnssecQuery(raw, "DNSKEY"),
616
+ dnssecQuery(raw, "A"),
617
+ ]);
618
+ const ds = typedRecords(dsRes, "DS");
619
+ const keys = typedRecords(dnskeyRes, "DNSKEY");
620
+ const sigs = (aRes.Answer ?? []).filter((row) => row.type === 46);
621
+ const ad = aRes.AD === true;
622
+ const lines = [`DNSSEC status for ${raw} (DNS-over-HTTPS with the DO bit set)`, ""];
623
+ lines.push(`Delegation signer (DS) records: ${ds.length}`);
624
+ for (const d of ds.slice(0, 4))
625
+ lines.push(` ${d}`);
626
+ lines.push(`Signing keys (DNSKEY) published: ${keys.length}`);
627
+ // A DNSKEY record reads "flags protocol algorithm public-key", so the key role
628
+ // lives in the flags field at index 0, not after the algorithm.
629
+ const flagOf = (k) => Number(k.trim().split(/\s+/)[0] ?? 0);
630
+ const algOf = (k) => k.trim().split(/\s+/)[2] ?? "?";
631
+ const algs = keys.map(algOf).filter(Boolean);
632
+ if (algs.length) {
633
+ const counts = new Map();
634
+ for (const a of algs)
635
+ counts.set(a, (counts.get(a) ?? 0) + 1);
636
+ lines.push(` algorithms in use: ${[...counts.entries()].map(([a, n]) => `algorithm ${a} (${n} key${n === 1 ? "" : "s"})`).join(", ")}`);
637
+ const ksk = keys.filter((k) => flagOf(k) === 257).length;
638
+ const zsk = keys.filter((k) => flagOf(k) === 256).length;
639
+ const other = keys.length - ksk - zsk;
640
+ lines.push(` key roles: ${ksk} key-signing (257), ${zsk} zone-signing (256)${other ? `, ${other} with another flag value` : ""}`);
641
+ }
642
+ lines.push(`Signatures returned with a normal A query: ${sigs.length}`);
643
+ lines.push(`Authenticated Data flag on the A answer: ${ad ? "set, the resolver validated the chain" : "not set"}`);
644
+ lines.push("");
645
+ if (ad) {
646
+ // The AD flag is the resolver's own verdict and outranks whatever appeared in
647
+ // the answer section, so trust it first.
648
+ lines.push("Signed and validated: the resolver returned the Authenticated Data flag, meaning it successfully built a chain of trust to a root key and verified the RRSIG on this answer.");
649
+ if (!ds.length || !keys.length) {
650
+ lines.push(`The answer section did not carry the DS and DNSKEY records for this query (${ds.length} DS, ${keys.length} DNSKEY returned), so the chain was validated out of band or from cache. That is normal and not a problem.`);
651
+ }
652
+ lines.push("Consequence for email: a receiver can verify a message claiming this domain was genuinely sent by it, provided the receiver actually validates DNSSEC. Most mail receivers do not, so treat this as defence in depth rather than a substitute for DMARC.");
653
+ }
654
+ else if (ds.length && keys.length) {
655
+ lines.push("Signed: this domain publishes both a DS record in the parent zone and DNSKEYs in its own zone, so a validating resolver can check answers for it.");
656
+ lines.push("The AD flag is still clear, which usually means the resolver in use was not a validating one, or the chain could not be completed. It is not proof the domain is unsigned.");
657
+ }
658
+ else if (!ds.length && !keys.length) {
659
+ lines.push("Not signed: this domain publishes neither a DS record nor any DNSKEYs, so answers for it can be spoofed by anyone on the path.");
660
+ lines.push("Consequence for email: a receiver cannot tell a forged message claiming to be from this domain from a genuine one using DNSSEC alone. Use DMARC with an enforcing policy instead, which is what actually stops spoofing.");
661
+ }
662
+ else if (!ds.length && keys.length) {
663
+ lines.push("Zoned but undelegated: DNSKEYs are published in the child zone but no DS record exists in the parent. The keys are ignored, so the domain is still treated as unsigned. This usually means signing was prepared but never completed.");
664
+ }
665
+ else {
666
+ lines.push("Broken chain: a DS record exists in the parent zone but the child publishes no DNSKEYs, so validation must fail. Expect SERVFAIL for validating resolvers until the keys are restored.");
667
+ }
668
+ return lines.join("\n");
669
+ }
670
+ const CAPS = "!@#$%^&*()-_=+[]{};:,.?";
671
+ const LEET = { a: "@", e: "3", i: "1", o: "0", s: "$" };
672
+ /** The predictable variations a credential-stuffing list would contain for a base word. */
673
+ function mutations(base, year) {
674
+ const word = base.trim();
675
+ const lower = word.toLowerCase();
676
+ const capitalised = lower.charAt(0).toUpperCase() + lower.slice(1);
677
+ const upper = lower.toUpperCase();
678
+ const thisYear = String(year);
679
+ const lastYear = String(year - 1);
680
+ const leet = lower.replace(/[aeios]/g, (ch) => LEET[ch] ?? ch);
681
+ const out = new Set();
682
+ const bases = [word, lower, capitalised, upper, leet];
683
+ for (const b of bases) {
684
+ if (!b)
685
+ continue;
686
+ out.add(b);
687
+ for (const suffix of [thisYear, lastYear, "1", "12", "123", "1234", "!", "@", "#", "$", "!1", "2024"]) {
688
+ out.add(`${b}${suffix}`);
689
+ out.add(`${suffix}${b}`);
690
+ }
691
+ out.add(`${b}!`);
692
+ out.add(`${b}.`);
693
+ out.add(`${b}@${thisYear}`);
694
+ }
695
+ return [...out];
696
+ }
697
+ /** Builds the mutation dictionary a credential-stuffing attack would use from one base word and reports which variants are already breached. */
698
+ export async function passwordVariationAudit(args) {
699
+ const base = (args.base ?? "").trim();
700
+ if (!base)
701
+ return "Provide a base word, name or company, e.g. acme.";
702
+ if (base.length < 3)
703
+ return "Provide at least three characters, otherwise every short string is already in a breach list.";
704
+ const year = Math.floor(Number(args.year ?? new Date().getUTCFullYear())) || new Date().getUTCFullYear();
705
+ const limit = Math.min(Math.max(Math.floor(Number(args.limit ?? 40)) || 40, 5), 80);
706
+ const all = mutations(base, year).slice(0, limit);
707
+ const rows = [];
708
+ for (const pw of all) {
709
+ // pwnedCount already sends only the 5-character hash prefix, so no password
710
+ // leaves this server.
711
+ rows.push({ pw, count: await pwnedCount(pw) });
712
+ }
713
+ const breached = rows.filter((r) => r.count > 0).sort((a, b) => b.count - a.count);
714
+ const clean = rows.filter((r) => r.count === 0);
715
+ const lines = [
716
+ `Mutation audit for "${base}" (${rows.length} candidate password${rows.length === 1 ? "" : "s"}: ${limit - rows.length > 0 ? `first ${rows.length} of ${limit}, ` : ""}lower/capitalised/upper/leetspeak forms crossed with ${year}, ${year - 1} and common numeric and symbol suffixes)`,
717
+ `Each candidate was hashed locally and only its 5-character prefix was sent to Have I Been Pwned, so no password or full hash left this server.`,
718
+ `That is one range request per candidate, roughly 700 KB each, so this is a deliberate audit rather than something to run on every keystroke.`,
719
+ "",
720
+ `Result: ${breached.length} of ${rows.length} candidates are already in a public breach, covering ${breached.reduce((s, r) => s + r.count, 0).toLocaleString("en-US")} total leak records.`,
721
+ ];
722
+ if (breached.length) {
723
+ lines.push("", "Most exposed variants:");
724
+ for (const r of breached.slice(0, 20))
725
+ lines.push(` ${r.count.toLocaleString("en-US").padStart(12)} records ${r.pw}`);
726
+ if (breached.length > 20)
727
+ lines.push(` ...and ${breached.length - 20} more breached variants`);
728
+ }
729
+ if (clean.length) {
730
+ lines.push("", `Not found in any breach (${clean.length}): ${clean.slice(0, 12).map((r) => r.pw).join(", ")}${clean.length > 12 ? ", ..." : ""}`);
731
+ lines.push(" A clean result means the string is not in a public dump. It is still a predictable variation of a word an attacker will try, so it is not a safe choice for an account anyone else could guess the basis of.");
732
+ }
733
+ lines.push("", "Why this matters: if a password is a mutation of a company or product name, it will be in an attacker's dictionary even when no breach contains it, because lists like this are generated from the same guesswork.");
734
+ return lines.join("\n");
75
735
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns null when the caller may run the tool, or a short message telling
3
+ * them how to get a key.
4
+ *
5
+ * A good key is remembered for an hour, so a busy session does not hit the
6
+ * license server on every call and keeps working through a brief outage.
7
+ */
8
+ export declare function premiumRequired(tool: string): Promise<string | null>;
@@ -0,0 +1,79 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "cyberark-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "password_variation_audit",
8
+ "dns_record_inventory",
9
+ ]);
10
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
11
+ // The license check calls the marketplace's verify endpoint directly instead of
12
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
13
+ // check it makes is rejected by Supabase before it reaches the function. The
14
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
15
+ // own JavaScript and only ever reaches their licence check.
16
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
17
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
18
+ const OK_CACHE_MS = 60 * 60 * 1000;
19
+ const BAD_CACHE_MS = 60 * 1000;
20
+ const seen = new Map();
21
+ const REASONS = {
22
+ missing_key: "no key is set",
23
+ invalid_format: "that key is not a valid MCP Marketplace key",
24
+ not_found: "that key was not found",
25
+ revoked: "that key has been revoked",
26
+ rotated: "that key was rotated, use your new one",
27
+ expired: "that key has expired, renew it",
28
+ rate_limited: "there were too many checks just now, try again shortly",
29
+ network_error: "the license server could not be reached",
30
+ };
31
+ function blocked(tool, reason) {
32
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
33
+ return `"${tool}" needs a license key, but ${why}. ` +
34
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
35
+ }
36
+ async function verify(key) {
37
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
38
+ method: "POST",
39
+ headers: {
40
+ apikey: PUBLISHABLE_KEY,
41
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify({ key, slug: SLUG }),
45
+ // an unusable key answers 400 with a JSON body, so read the body either way
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ const data = (await res.json());
49
+ if (typeof data?.valid !== "boolean")
50
+ return { valid: false, reason: "unexpected_response" };
51
+ return data;
52
+ }
53
+ /**
54
+ * Returns null when the caller may run the tool, or a short message telling
55
+ * them how to get a key.
56
+ *
57
+ * A good key is remembered for an hour, so a busy session does not hit the
58
+ * license server on every call and keeps working through a brief outage.
59
+ */
60
+ export async function premiumRequired(tool) {
61
+ if (!PREMIUM.has(tool))
62
+ return null;
63
+ const key = process.env.MCP_LICENSE_KEY;
64
+ if (!key)
65
+ return blocked(tool, "missing_key");
66
+ const hit = seen.get(key);
67
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
68
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
69
+ }
70
+ let result;
71
+ try {
72
+ result = await verify(key);
73
+ }
74
+ catch {
75
+ result = { valid: false, reason: "network_error" };
76
+ }
77
+ seen.set(key, { ...result, at: Date.now() });
78
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
79
+ }
package/dist/server.js CHANGED
@@ -1,52 +1,241 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { errorMessage, listSafes, listAccounts, getAccount, } from "./api.js";
3
+ import { breachCatalog, breachDetail, checkPassword, checkPasswords, domainEmailSecurity, errorMessage, generatePassword, passwordStrength, identifyHash, hashText, breachTimeline, subdomainInventory, dnsRecordInventory, dnssecStatus, passwordVariationAudit } from "./api.js";
4
+ import { premiumRequired } from "./license.js";
4
5
  const text = (t) => ({ content: [{ type: "text", text: t }] });
5
6
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
7
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
7
8
  export function createServer() {
8
9
  const server = new McpServer({
9
10
  name: "cyberark-mcp",
10
- version: "1.0.0",
11
+ version: "2.0.0",
11
12
  });
12
- server.registerTool("list_safes", {
13
- title: "List safes",
14
- description: "CyberArk safes visible to the logon user.",
15
- inputSchema: z.object({}),
13
+ server.registerTool("check_password", {
14
+ title: "Check if a password is breached",
15
+ description: "Check a password against known data breaches using k-anonymity, so only five characters of its SHA-1 hash are sent and the password itself never leaves your machine. No API key needed.",
16
+ inputSchema: z.object({
17
+ password: z.string().describe("The password to check. It is hashed locally and never stored."),
18
+ }),
19
+ annotations: READ_ONLY,
20
+ }, async ({ password }) => {
21
+ try {
22
+ return text(await checkPassword({ password }));
23
+ }
24
+ catch (e) {
25
+ return textError(errorMessage(e));
26
+ }
27
+ });
28
+ server.registerTool("check_passwords", {
29
+ title: "Check several passwords",
30
+ description: "Check up to 10 passwords for known breaches in one call and report how many have already been leaked.",
31
+ inputSchema: z.object({
32
+ passwords: z.array(z.string()).describe("Passwords to check, maximum 10."),
33
+ }),
34
+ annotations: READ_ONLY,
35
+ }, async ({ passwords }) => {
36
+ try {
37
+ return text(await checkPasswords({ passwords }));
38
+ }
39
+ catch (e) {
40
+ return textError(errorMessage(e));
41
+ }
42
+ });
43
+ server.registerTool("password_strength", {
44
+ title: "Score password strength",
45
+ description: "Score a password offline: entropy, character classes, common-word, keyboard and sequence patterns, plus an estimated offline crack time and a clear verdict.",
46
+ inputSchema: z.object({
47
+ password: z.string().describe("The password to score. Analysis happens locally."),
48
+ }),
49
+ annotations: READ_ONLY,
50
+ }, async ({ password }) => {
51
+ try {
52
+ return text(await passwordStrength({ password }));
53
+ }
54
+ catch (e) {
55
+ return textError(errorMessage(e));
56
+ }
57
+ });
58
+ server.registerTool("generate_password", {
59
+ title: "Generate a strong password",
60
+ description: "Generate a random password from a cryptographically secure source, with control over length and character classes, and report its estimated crack time.",
61
+ inputSchema: z.object({
62
+ length: z.number().describe("Password length, 8 to 128. Defaults to 20.").optional(),
63
+ symbols: z.boolean().describe("Include symbols. Defaults to true.").optional(),
64
+ digits: z.boolean().describe("Include digits. Defaults to true.").optional(),
65
+ upperCase: z.boolean().describe("Include upper case letters. Defaults to true.").optional(),
66
+ }),
67
+ annotations: READ_ONLY,
68
+ }, async ({ length, symbols, digits, upperCase }) => {
69
+ try {
70
+ return text(await generatePassword({ length, symbols, digits, upperCase }));
71
+ }
72
+ catch (e) {
73
+ return textError(errorMessage(e));
74
+ }
75
+ });
76
+ server.registerTool("breach_catalog", {
77
+ title: "Browse known breaches",
78
+ description: "List breaches from the Have I Been Pwned catalogue, largest first, filtered by company domain or year, with the number of accounts and the kinds of data exposed.",
79
+ inputSchema: z.object({
80
+ domain: z.string().describe("Filter by company domain, for example adobe.com.").optional(),
81
+ year: z.number().describe("Filter by breach year, for example 2019.").optional(),
82
+ limit: z.number().describe("How many breaches to list, 1 to 100. Defaults to 15.").optional(),
83
+ }),
84
+ annotations: READ_ONLY,
85
+ }, async ({ domain, year, limit }) => {
86
+ try {
87
+ return text(await breachCatalog({ domain, year, limit }));
88
+ }
89
+ catch (e) {
90
+ return textError(errorMessage(e));
91
+ }
92
+ });
93
+ server.registerTool("breach_detail", {
94
+ title: "Breach details",
95
+ description: "Look up one breach by name and get its date, exposed account count, verification status and the classes of data leaked.",
96
+ inputSchema: z.object({
97
+ name: z.string().describe("Breach name such as LinkedIn or Adobe."),
98
+ }),
99
+ annotations: READ_ONLY,
100
+ }, async ({ name }) => {
101
+ try {
102
+ return text(await breachDetail({ name }));
103
+ }
104
+ catch (e) {
105
+ return textError(errorMessage(e));
106
+ }
107
+ });
108
+ server.registerTool("domain_email_security", {
109
+ title: "Domain email security audit",
110
+ description: "Audit a domain's anti-spoofing setup: SPF, DMARC, DKIM selectors, MX records and CAA, graded A to F with the exact gaps to fix.",
111
+ inputSchema: z.object({
112
+ domain: z.string().describe("Domain to audit, for example example.com."),
113
+ }),
114
+ annotations: READ_ONLY,
115
+ }, async ({ domain }) => {
116
+ try {
117
+ return text(await domainEmailSecurity({ domain }));
118
+ }
119
+ catch (e) {
120
+ return textError(errorMessage(e));
121
+ }
122
+ });
123
+ server.registerTool("identify_hash", {
124
+ title: "Identify a hash",
125
+ description: "Identify the likely algorithm behind a hash string — bcrypt, Argon2, crypt formats, MD5, SHA-1, SHA-256, SHA-512 and more — entirely offline.",
126
+ inputSchema: z.object({
127
+ hash: z.string().describe("One hash string to identify."),
128
+ }),
129
+ annotations: READ_ONLY,
130
+ }, async ({ hash }) => {
131
+ try {
132
+ return text(await identifyHash({ hash }));
133
+ }
134
+ catch (e) {
135
+ return textError(errorMessage(e));
136
+ }
137
+ });
138
+ server.registerTool("hash_text", {
139
+ title: "Hash text",
140
+ description: "Compute MD5, SHA-1, SHA-256 or SHA-512 digests of a string locally, for checksum and fingerprint checks.",
141
+ inputSchema: z.object({
142
+ text: z.string().describe("Text to hash."),
143
+ algorithm: z.string().describe("md5, sha1, sha256, sha512, or all (default all).").optional(),
144
+ }),
145
+ annotations: READ_ONLY,
146
+ }, async ({ text: value, algorithm }) => {
147
+ try {
148
+ return text(await hashText({ text: value, algorithm }));
149
+ }
150
+ catch (e) {
151
+ return textError(errorMessage(e));
152
+ }
153
+ });
154
+ server.registerTool("breach_timeline", {
155
+ title: "Breach timeline",
156
+ description: "Year-by-year view of known breaches from Have I Been Pwned: how many sites were breached and how many accounts were exposed.",
157
+ inputSchema: z.object({
158
+ from: z.number().describe("First year to include, defaults to 2004.").optional(),
159
+ to: z.number().describe("Last year to include, defaults to the current year.").optional(),
160
+ }),
161
+ annotations: READ_ONLY,
162
+ }, async ({ from, to }) => {
163
+ try {
164
+ return text(await breachTimeline({ from, to }));
165
+ }
166
+ catch (e) {
167
+ return textError(errorMessage(e));
168
+ }
169
+ });
170
+ server.registerTool("subdomain_inventory", {
171
+ title: "Subdomain inventory",
172
+ description: "Resolve common subdomains of a domain and show what each points to, flagging third-party CNAME targets that no longer resolve.",
173
+ inputSchema: z.object({
174
+ domain: z.string().describe("Your domain, e.g. example.com."),
175
+ names: z.string().describe("Subdomain labels to check instead of the default 20, comma separated.").optional(),
176
+ limit: z.number().describe("How many labels to check, 1-40. Defaults to 20.").optional(),
177
+ }),
178
+ annotations: READ_ONLY,
179
+ }, async ({ domain, names, limit }) => {
180
+ try {
181
+ return text(await subdomainInventory({ domain, names, limit }));
182
+ }
183
+ catch (e) {
184
+ return textError(errorMessage(e));
185
+ }
186
+ });
187
+ server.registerTool("dns_record_inventory", {
188
+ title: "DNS record inventory",
189
+ description: "The whole public DNS picture for one domain in a single pass: delegation, A and AAAA endpoints, mail exchangers, text policy records, zone authority and CAA. Notes a null MX, a missing MX where mail is expected, an absent CAA that lets any CA issue for the domain, and an SOA serial that is not epoch-based.",
190
+ inputSchema: z.object({
191
+ domain: z.string().describe("Domain to inventory, for example example.com."),
192
+ }),
16
193
  annotations: READ_ONLY,
17
- }, async ({}) => {
194
+ }, async (args) => {
195
+ // ---- paywall: dns_record_inventory ----
196
+ const paywallMessage = await premiumRequired("dns_record_inventory");
197
+ if (paywallMessage)
198
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
199
+ // ---- paywall: end ----
18
200
  try {
19
- return text(await listSafes());
201
+ return text(await dnsRecordInventory(args));
20
202
  }
21
203
  catch (e) {
22
204
  return textError(errorMessage(e));
23
205
  }
24
206
  });
25
- server.registerTool("list_accounts", {
26
- title: "List accounts",
27
- description: "CyberArk accounts, optionally filtered by safe.",
207
+ server.registerTool("dnssec_status", {
208
+ title: "DNSSEC status",
209
+ description: "Whether a domain is genuinely DNSSEC-signed, following the chain: the DS record in the parent zone, the DNSKEYs in the child, the RRSIGs returned with a normal query, and whether the resolver set the Authenticated Data flag. Separates a domain that is zoned but never delegated from one whose chain is genuinely broken, and explains what that means for email spoofing.",
28
210
  inputSchema: z.object({
29
- safeName: z.string().optional().describe("Safe name filter"),
211
+ domain: z.string().describe("Domain to check, for example example.com."),
30
212
  }),
31
213
  annotations: READ_ONLY,
32
- }, async ({ safeName }) => {
214
+ }, async (args) => {
33
215
  try {
34
- return text(await listAccounts(safeName));
216
+ return text(await dnssecStatus(args));
35
217
  }
36
218
  catch (e) {
37
219
  return textError(errorMessage(e));
38
220
  }
39
221
  });
40
- server.registerTool("get_account", {
41
- title: "Get account",
42
- description: "One CyberArk account by id (metadata, not the password value).",
222
+ server.registerTool("password_variation_audit", {
223
+ title: "Password variation audit",
224
+ description: "Builds the mutation dictionary a credential-stuffing attack would use from one base word, crossing case forms and leetspeak with the current and previous year plus common numeric and symbol suffixes, then checks every candidate against Have I Been Pwned. Shows which variants are already leaked and how many times, and which are still clean but still predictable.",
43
225
  inputSchema: z.object({
44
- accountId: z.string().describe("Account id"),
226
+ base: z.string().describe("Base word, name or company to mutate, e.g. acme."),
227
+ year: z.number().int().optional().describe("Year to use in the suffixes, defaults to the current year"),
228
+ limit: z.number().int().min(5).max(80).default(40).describe("Candidates to check, 5-80. Each costs one HIBP range request, default 40"),
45
229
  }),
46
230
  annotations: READ_ONLY,
47
- }, async ({ accountId }) => {
231
+ }, async (args) => {
232
+ // ---- paywall: password_variation_audit ----
233
+ const paywallMessage = await premiumRequired("password_variation_audit");
234
+ if (paywallMessage)
235
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
236
+ // ---- paywall: end ----
48
237
  try {
49
- return text(await getAccount(accountId));
238
+ return text(await passwordVariationAudit(args));
50
239
  }
51
240
  catch (e) {
52
241
  return textError(errorMessage(e));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cyberark-mcp",
3
- "version": "1.0.1",
4
- "description": "CyberArk PVWA API: logon, safes, accounts. Token cached per process.",
3
+ "version": "1.0.2",
4
+ "description": "Password breach checks and identity security audits - no API key.",
5
5
  "type": "module",
6
6
  "mcpName": "io.github.mrfentmen/cyberark-mcp",
7
7
  "repository": {
@@ -22,11 +22,12 @@
22
22
  "inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
23
23
  },
24
24
  "keywords": [
25
- "mcp",
26
- "cyberark",
27
- "secrets",
28
- "vault",
29
- "security"
25
+ "security",
26
+ "passwords",
27
+ "breach",
28
+ "dns",
29
+ "spf",
30
+ "mcp"
30
31
  ],
31
32
  "license": "MIT",
32
33
  "dependencies": {