domsniper 0.1.0
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/.env.example +40 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/package.json +72 -0
- package/src/app.tsx +2062 -0
- package/src/completions.ts +65 -0
- package/src/core/db.ts +1313 -0
- package/src/core/features/asn-lookup.ts +91 -0
- package/src/core/features/backlinks.ts +83 -0
- package/src/core/features/blacklist-check.ts +67 -0
- package/src/core/features/cert-transparency.ts +87 -0
- package/src/core/features/config.ts +81 -0
- package/src/core/features/cors-check.ts +90 -0
- package/src/core/features/dns-details.ts +27 -0
- package/src/core/features/domain-age.ts +33 -0
- package/src/core/features/domain-suggest.ts +87 -0
- package/src/core/features/drop-catch.ts +159 -0
- package/src/core/features/email-security.ts +112 -0
- package/src/core/features/expiring-feed.ts +160 -0
- package/src/core/features/export.ts +74 -0
- package/src/core/features/filter.ts +96 -0
- package/src/core/features/http-probe.ts +46 -0
- package/src/core/features/marketplace.ts +69 -0
- package/src/core/features/path-scanner.ts +123 -0
- package/src/core/features/port-scanner.ts +132 -0
- package/src/core/features/portfolio-bulk.ts +125 -0
- package/src/core/features/portfolio-monitor.ts +214 -0
- package/src/core/features/portfolio.ts +98 -0
- package/src/core/features/price-compare.ts +39 -0
- package/src/core/features/rdap.ts +128 -0
- package/src/core/features/reverse-ip.ts +73 -0
- package/src/core/features/s3-export.ts +99 -0
- package/src/core/features/scoring.ts +121 -0
- package/src/core/features/security-headers.ts +162 -0
- package/src/core/features/session.ts +74 -0
- package/src/core/features/snipe.ts +264 -0
- package/src/core/features/social-check.ts +81 -0
- package/src/core/features/ssl-check.ts +88 -0
- package/src/core/features/subdomain-discovery.ts +53 -0
- package/src/core/features/takeover-detect.ts +143 -0
- package/src/core/features/tech-stack.ts +135 -0
- package/src/core/features/tld-expand.ts +43 -0
- package/src/core/features/variations.ts +134 -0
- package/src/core/features/version-check.ts +58 -0
- package/src/core/features/waf-detect.ts +171 -0
- package/src/core/features/watch.ts +120 -0
- package/src/core/features/wayback.ts +64 -0
- package/src/core/features/webhooks.ts +126 -0
- package/src/core/features/whois-history.ts +99 -0
- package/src/core/features/zone-transfer.ts +75 -0
- package/src/core/index.ts +50 -0
- package/src/core/paths.ts +9 -0
- package/src/core/registrar.ts +413 -0
- package/src/core/theme.ts +140 -0
- package/src/core/types.ts +143 -0
- package/src/core/validate.ts +58 -0
- package/src/core/whois.ts +265 -0
- package/src/index.tsx +1888 -0
- package/src/market-client.ts +186 -0
- package/src/proxy/ca.ts +116 -0
- package/src/proxy/db.ts +175 -0
- package/src/proxy/server.ts +155 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
|
|
3
|
+
export interface WafResult {
|
|
4
|
+
domain: string;
|
|
5
|
+
detected: boolean;
|
|
6
|
+
waf: string | null;
|
|
7
|
+
confidence: "high" | "medium" | "low";
|
|
8
|
+
indicators: string[];
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface WafSignature {
|
|
13
|
+
name: string;
|
|
14
|
+
headerPatterns: { header: string; pattern: RegExp }[];
|
|
15
|
+
bodyPatterns: RegExp[];
|
|
16
|
+
cookiePatterns: RegExp[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const WAF_SIGNATURES: WafSignature[] = [
|
|
20
|
+
{
|
|
21
|
+
name: "Cloudflare",
|
|
22
|
+
headerPatterns: [
|
|
23
|
+
{ header: "server", pattern: /cloudflare/i },
|
|
24
|
+
{ header: "cf-ray", pattern: /.+/ },
|
|
25
|
+
{ header: "cf-cache-status", pattern: /.+/ },
|
|
26
|
+
],
|
|
27
|
+
bodyPatterns: [/cloudflare/i, /cf-browser-verification/i],
|
|
28
|
+
cookiePatterns: [/__cfduid/i, /__cf_bm/i],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "AWS WAF / CloudFront",
|
|
32
|
+
headerPatterns: [
|
|
33
|
+
{ header: "x-amz-cf-id", pattern: /.+/ },
|
|
34
|
+
{ header: "x-amz-cf-pop", pattern: /.+/ },
|
|
35
|
+
{ header: "x-amzn-waf", pattern: /.+/ },
|
|
36
|
+
],
|
|
37
|
+
bodyPatterns: [/awswaf/i],
|
|
38
|
+
cookiePatterns: [/awsalb/i, /awsalbcors/i],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "Akamai",
|
|
42
|
+
headerPatterns: [
|
|
43
|
+
{ header: "x-akamai-transformed", pattern: /.+/ },
|
|
44
|
+
{ header: "server", pattern: /akamaighost/i },
|
|
45
|
+
{ header: "x-akamai-session-info", pattern: /.+/ },
|
|
46
|
+
],
|
|
47
|
+
bodyPatterns: [/akamai/i, /reference.*akamai/i],
|
|
48
|
+
cookiePatterns: [/akamai/i, /ak_bmsc/i],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "Sucuri",
|
|
52
|
+
headerPatterns: [
|
|
53
|
+
{ header: "server", pattern: /sucuri/i },
|
|
54
|
+
{ header: "x-sucuri-id", pattern: /.+/ },
|
|
55
|
+
{ header: "x-sucuri-cache", pattern: /.+/ },
|
|
56
|
+
],
|
|
57
|
+
bodyPatterns: [/sucuri/i, /sucuri cloudproxy/i],
|
|
58
|
+
cookiePatterns: [/sucuri/i],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "Imperva / Incapsula",
|
|
62
|
+
headerPatterns: [
|
|
63
|
+
{ header: "x-cdn", pattern: /incapsula/i },
|
|
64
|
+
{ header: "x-iinfo", pattern: /.+/ },
|
|
65
|
+
],
|
|
66
|
+
bodyPatterns: [/incapsula/i, /imperva/i],
|
|
67
|
+
cookiePatterns: [/incap_ses/i, /visid_incap/i],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "F5 BIG-IP",
|
|
71
|
+
headerPatterns: [
|
|
72
|
+
{ header: "server", pattern: /big-?ip/i },
|
|
73
|
+
{ header: "x-cnection", pattern: /.+/ },
|
|
74
|
+
],
|
|
75
|
+
bodyPatterns: [],
|
|
76
|
+
cookiePatterns: [/bigipserver/i, /BIGipServer/i],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "ModSecurity",
|
|
80
|
+
headerPatterns: [
|
|
81
|
+
{ header: "server", pattern: /mod_security|modsecurity/i },
|
|
82
|
+
],
|
|
83
|
+
bodyPatterns: [/mod_security|modsecurity|owasp/i],
|
|
84
|
+
cookiePatterns: [],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "Fastly",
|
|
88
|
+
headerPatterns: [
|
|
89
|
+
{ header: "via", pattern: /varnish/i },
|
|
90
|
+
{ header: "x-fastly-request-id", pattern: /.+/ },
|
|
91
|
+
{ header: "x-served-by", pattern: /cache-/i },
|
|
92
|
+
],
|
|
93
|
+
bodyPatterns: [/fastly error/i],
|
|
94
|
+
cookiePatterns: [],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "DDoS-Guard",
|
|
98
|
+
headerPatterns: [
|
|
99
|
+
{ header: "server", pattern: /ddos-guard/i },
|
|
100
|
+
],
|
|
101
|
+
bodyPatterns: [/ddos-guard/i],
|
|
102
|
+
cookiePatterns: [/__ddg/i],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "Wordfence",
|
|
106
|
+
headerPatterns: [],
|
|
107
|
+
bodyPatterns: [/wordfence/i, /wfwaf-/i],
|
|
108
|
+
cookiePatterns: [/wordfence/i, /wfwaf/i],
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
export async function detectWaf(domain: string): Promise<WafResult> {
|
|
113
|
+
assertValidDomain(domain);
|
|
114
|
+
|
|
115
|
+
const result: WafResult = {
|
|
116
|
+
domain, detected: false, waf: null, confidence: "low", indicators: [], error: null,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const resp = await fetch(`https://${domain}`, {
|
|
121
|
+
signal: AbortSignal.timeout(10000),
|
|
122
|
+
headers: { "User-Agent": "DomainSniper/2.0" },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const body = await resp.text();
|
|
126
|
+
const cookies = resp.headers.get("set-cookie") || "";
|
|
127
|
+
|
|
128
|
+
for (const sig of WAF_SIGNATURES) {
|
|
129
|
+
let matchCount = 0;
|
|
130
|
+
const indicators: string[] = [];
|
|
131
|
+
|
|
132
|
+
// Check headers
|
|
133
|
+
for (const hp of sig.headerPatterns) {
|
|
134
|
+
const val = resp.headers.get(hp.header);
|
|
135
|
+
if (val && hp.pattern.test(val)) {
|
|
136
|
+
matchCount++;
|
|
137
|
+
indicators.push(`Header: ${hp.header}=${val.slice(0, 50)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check body
|
|
142
|
+
for (const bp of sig.bodyPatterns) {
|
|
143
|
+
if (bp.test(body)) {
|
|
144
|
+
matchCount++;
|
|
145
|
+
indicators.push(`Body pattern: ${bp.source.slice(0, 30)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check cookies
|
|
150
|
+
for (const cp of sig.cookiePatterns) {
|
|
151
|
+
if (cp.test(cookies)) {
|
|
152
|
+
matchCount++;
|
|
153
|
+
indicators.push(`Cookie pattern: ${cp.source.slice(0, 30)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (matchCount > 0) {
|
|
158
|
+
result.detected = true;
|
|
159
|
+
result.waf = sig.name;
|
|
160
|
+
result.indicators = indicators;
|
|
161
|
+
result.confidence = matchCount >= 3 ? "high" : matchCount >= 2 ? "medium" : "low";
|
|
162
|
+
return result; // Return first match (most likely)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
} catch (err: unknown) {
|
|
168
|
+
result.error = err instanceof Error ? err.message : "WAF detection failed";
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch mode — monitor domains at an interval
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { whoisLookup, verifyAvailability } from "../whois.js";
|
|
6
|
+
import { execFile } from "child_process";
|
|
7
|
+
|
|
8
|
+
export interface WatchConfig {
|
|
9
|
+
domains: string[];
|
|
10
|
+
intervalMs: number; // default 3600000 (1 hour)
|
|
11
|
+
notify: boolean; // desktop notifications
|
|
12
|
+
onAvailable?: (domain: string) => void;
|
|
13
|
+
onCheck?: (domain: string, status: string) => void;
|
|
14
|
+
onCycle?: (cycle: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DomainWatcher {
|
|
18
|
+
private config: WatchConfig;
|
|
19
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
private cycle = 0;
|
|
21
|
+
private _running = false;
|
|
22
|
+
|
|
23
|
+
constructor(config: WatchConfig) {
|
|
24
|
+
this.config = {
|
|
25
|
+
...{ intervalMs: 3600000, notify: true },
|
|
26
|
+
...config,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get running() { return this._running; }
|
|
31
|
+
|
|
32
|
+
async checkOnce(): Promise<Map<string, string>> {
|
|
33
|
+
const results = new Map<string, string>();
|
|
34
|
+
|
|
35
|
+
for (const domain of this.config.domains) {
|
|
36
|
+
this.config.onCheck?.(domain, "checking");
|
|
37
|
+
|
|
38
|
+
const whois = await whoisLookup(domain);
|
|
39
|
+
const verify = await verifyAvailability(domain);
|
|
40
|
+
|
|
41
|
+
let status = "taken";
|
|
42
|
+
if (whois.available && verify.confidence === "high") status = "available";
|
|
43
|
+
else if (whois.expired) status = "expired";
|
|
44
|
+
else if (whois.available) status = "available";
|
|
45
|
+
|
|
46
|
+
results.set(domain, status);
|
|
47
|
+
this.config.onCheck?.(domain, status);
|
|
48
|
+
|
|
49
|
+
if ((status === "available" || status === "expired") && this.config.notify) {
|
|
50
|
+
this.config.onAvailable?.(domain);
|
|
51
|
+
sendDesktopNotification(domain, status);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Rate limit
|
|
55
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
start() {
|
|
62
|
+
if (this._running) return;
|
|
63
|
+
this._running = true;
|
|
64
|
+
this.cycle = 0;
|
|
65
|
+
|
|
66
|
+
// Run immediately
|
|
67
|
+
void this.runCycle();
|
|
68
|
+
|
|
69
|
+
// Set interval
|
|
70
|
+
this.timer = setInterval(() => { void this.runCycle(); }, this.config.intervalMs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stop() {
|
|
74
|
+
this._running = false;
|
|
75
|
+
if (this.timer) {
|
|
76
|
+
clearInterval(this.timer);
|
|
77
|
+
this.timer = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
addDomain(domain: string) {
|
|
82
|
+
if (!this.config.domains.includes(domain)) {
|
|
83
|
+
this.config.domains.push(domain);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
removeDomain(domain: string) {
|
|
88
|
+
this.config.domains = this.config.domains.filter((d) => d !== domain);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async runCycle() {
|
|
92
|
+
this.cycle++;
|
|
93
|
+
this.config.onCycle?.(this.cycle);
|
|
94
|
+
await this.checkOnce();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendDesktopNotification(domain: string, status: string) {
|
|
99
|
+
const title = "Domain Sniper";
|
|
100
|
+
const rawMsg = status === "available"
|
|
101
|
+
? `${domain} is AVAILABLE!`
|
|
102
|
+
: `${domain} has EXPIRED and may be available soon`;
|
|
103
|
+
|
|
104
|
+
// Strip double quotes and backslashes to prevent osascript injection
|
|
105
|
+
const msg = rawMsg.replace(/["\\]/g, "");
|
|
106
|
+
|
|
107
|
+
// macOS notification
|
|
108
|
+
try {
|
|
109
|
+
const script = `display notification "${msg}" with title "${title}" sound name "Glass"`;
|
|
110
|
+
execFile("osascript", ["-e", script], () => {});
|
|
111
|
+
} catch {
|
|
112
|
+
// Notification failed silently
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatInterval(ms: number): string {
|
|
117
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
|
|
118
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
|
|
119
|
+
return `${(ms / 3600000).toFixed(1)}h`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
import type { WaybackResult } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export async function checkWayback(domain: string): Promise<WaybackResult> {
|
|
5
|
+
assertValidDomain(domain);
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const resp = await fetch(
|
|
9
|
+
`https://web.archive.org/wayback/available?url=${encodeURIComponent(domain)}×tamp=20000101`,
|
|
10
|
+
{ signal: AbortSignal.timeout(8000) }
|
|
11
|
+
);
|
|
12
|
+
const data = await resp.json() as {
|
|
13
|
+
archived_snapshots?: {
|
|
14
|
+
closest?: { available: boolean; timestamp: string; url: string };
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const closest = data.archived_snapshots?.closest;
|
|
19
|
+
let snapshots = 0;
|
|
20
|
+
let firstArchived: string | null = null;
|
|
21
|
+
let lastArchived: string | null = null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const cdxResp = await fetch(
|
|
25
|
+
`https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(domain)}&output=json&limit=1&fl=timestamp&sort=asc`,
|
|
26
|
+
{ signal: AbortSignal.timeout(8000) }
|
|
27
|
+
);
|
|
28
|
+
const cdxFirst = await cdxResp.json() as string[][];
|
|
29
|
+
if (cdxFirst.length > 1 && cdxFirst[1]) {
|
|
30
|
+
firstArchived = formatWaybackTs(cdxFirst[1][0]!);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cdxLastResp = await fetch(
|
|
34
|
+
`https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(domain)}&output=json&limit=1&fl=timestamp&sort=desc`,
|
|
35
|
+
{ signal: AbortSignal.timeout(8000) }
|
|
36
|
+
);
|
|
37
|
+
const cdxLast = await cdxLastResp.json() as string[][];
|
|
38
|
+
if (cdxLast.length > 1 && cdxLast[1]) {
|
|
39
|
+
lastArchived = formatWaybackTs(cdxLast[1][0]!);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const countResp = await fetch(
|
|
43
|
+
`https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(domain)}&output=json&limit=0&showNumPages=true`,
|
|
44
|
+
{ signal: AbortSignal.timeout(8000) }
|
|
45
|
+
);
|
|
46
|
+
const countText = await countResp.text();
|
|
47
|
+
snapshots = parseInt(countText, 10) || 0;
|
|
48
|
+
} catch {}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
hasHistory: !!closest?.available || snapshots > 0,
|
|
52
|
+
firstArchived,
|
|
53
|
+
lastArchived,
|
|
54
|
+
snapshots,
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return { hasHistory: false, firstArchived: null, lastArchived: null, snapshots: 0 };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatWaybackTs(ts: string): string {
|
|
62
|
+
if (ts.length < 8) return ts;
|
|
63
|
+
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)}`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export interface WebhookPayload {
|
|
2
|
+
domain: string;
|
|
3
|
+
status: string;
|
|
4
|
+
timestamp: string;
|
|
5
|
+
details?: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function sendWebhook(
|
|
9
|
+
url: string,
|
|
10
|
+
payload: WebhookPayload
|
|
11
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
12
|
+
try {
|
|
13
|
+
// Detect Slack vs Discord vs generic
|
|
14
|
+
const isSlack = url.includes("hooks.slack.com");
|
|
15
|
+
const isDiscord = url.includes("discord.com/api/webhooks");
|
|
16
|
+
|
|
17
|
+
let body: string;
|
|
18
|
+
|
|
19
|
+
if (isSlack) {
|
|
20
|
+
body = JSON.stringify({
|
|
21
|
+
text: `*Domain Sniper Alert*\n\`${payload.domain}\` is now *${payload.status.toUpperCase()}*`,
|
|
22
|
+
blocks: [
|
|
23
|
+
{
|
|
24
|
+
type: "section",
|
|
25
|
+
text: {
|
|
26
|
+
type: "mrkdwn",
|
|
27
|
+
text: `*Domain Sniper Alert*\n\n\`${payload.domain}\` is now *${payload.status.toUpperCase()}*\n_${payload.timestamp}_`,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
} else if (isDiscord) {
|
|
33
|
+
body = JSON.stringify({
|
|
34
|
+
embeds: [{
|
|
35
|
+
title: "Domain Sniper Alert",
|
|
36
|
+
description: `\`${payload.domain}\` is now **${payload.status.toUpperCase()}**`,
|
|
37
|
+
color: payload.status === "available" ? 0x00e88f : payload.status === "expired" ? 0xf5c542 : 0x5c9cf5,
|
|
38
|
+
timestamp: payload.timestamp,
|
|
39
|
+
}],
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
body = JSON.stringify(payload);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resp = await fetch(url, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
body,
|
|
49
|
+
signal: AbortSignal.timeout(10000),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!resp.ok) {
|
|
53
|
+
return { success: false, error: `HTTP ${resp.status}` };
|
|
54
|
+
}
|
|
55
|
+
return { success: true };
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
return { success: false, error: err instanceof Error ? err.message : "Webhook failed" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function sendEmailAlert(
|
|
62
|
+
config: { host: string; port: number; user: string; pass: string; to: string },
|
|
63
|
+
payload: WebhookPayload
|
|
64
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
65
|
+
// Use Bun's built-in TCP for basic SMTP
|
|
66
|
+
try {
|
|
67
|
+
const socket = await Bun.connect({
|
|
68
|
+
hostname: config.host,
|
|
69
|
+
port: config.port,
|
|
70
|
+
socket: {
|
|
71
|
+
data(_socket, _data) { /* consume responses */ },
|
|
72
|
+
open(socket) {
|
|
73
|
+
// Simple SMTP sequence
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
socket.write(`EHLO domain-sniper\r\n`);
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
socket.write(`AUTH LOGIN\r\n`);
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
socket.write(`${btoa(config.user)}\r\n`);
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
socket.write(`${btoa(config.pass)}\r\n`);
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
socket.write(`MAIL FROM:<${config.user}>\r\n`);
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
socket.write(`RCPT TO:<${config.to}>\r\n`);
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
socket.write(`DATA\r\n`);
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
const msg = [
|
|
90
|
+
`From: Domain Sniper <${config.user}>`,
|
|
91
|
+
`To: ${config.to}`,
|
|
92
|
+
`Subject: Domain Alert: ${payload.domain} is ${payload.status}`,
|
|
93
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
94
|
+
``,
|
|
95
|
+
`Domain Sniper Alert`,
|
|
96
|
+
``,
|
|
97
|
+
`Domain: ${payload.domain}`,
|
|
98
|
+
`Status: ${payload.status.toUpperCase()}`,
|
|
99
|
+
`Time: ${payload.timestamp}`,
|
|
100
|
+
``,
|
|
101
|
+
`---`,
|
|
102
|
+
`Sent by Domain Sniper`,
|
|
103
|
+
].join("\r\n");
|
|
104
|
+
socket.write(`${msg}\r\n.\r\n`);
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
socket.write(`QUIT\r\n`);
|
|
107
|
+
socket.end();
|
|
108
|
+
}, 500);
|
|
109
|
+
}, 500);
|
|
110
|
+
}, 500);
|
|
111
|
+
}, 500);
|
|
112
|
+
}, 500);
|
|
113
|
+
}, 500);
|
|
114
|
+
}, 500);
|
|
115
|
+
}, 500);
|
|
116
|
+
}, 500);
|
|
117
|
+
},
|
|
118
|
+
close() {},
|
|
119
|
+
error() {},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return { success: true };
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
return { success: false, error: err instanceof Error ? err.message : "Email send failed" };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHOIS history tracking — SQLite-backed snapshot storage
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
saveWhoisSnapshotDb,
|
|
7
|
+
getWhoisHistoryDb,
|
|
8
|
+
getWhoisHistoryCountDb,
|
|
9
|
+
} from "../db.js";
|
|
10
|
+
import { isValidDomain } from "../validate.js";
|
|
11
|
+
import type { WhoisResult } from "../whois.js";
|
|
12
|
+
|
|
13
|
+
export interface WhoisSnapshot {
|
|
14
|
+
timestamp: string;
|
|
15
|
+
domain: string;
|
|
16
|
+
registrar: string | null;
|
|
17
|
+
expiryDate: string | null;
|
|
18
|
+
createdDate: string | null;
|
|
19
|
+
updatedDate: string | null;
|
|
20
|
+
status: string[];
|
|
21
|
+
nameServers: string[];
|
|
22
|
+
available: boolean;
|
|
23
|
+
expired: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WhoisDiff {
|
|
27
|
+
field: string;
|
|
28
|
+
old: string;
|
|
29
|
+
new: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveWhoisSnapshot(whois: WhoisResult): string {
|
|
33
|
+
if (!isValidDomain(whois.domain)) throw new Error(`Invalid domain: ${whois.domain}`);
|
|
34
|
+
const id = saveWhoisSnapshotDb(whois.domain, {
|
|
35
|
+
registrar: whois.registrar,
|
|
36
|
+
expiryDate: whois.expiryDate,
|
|
37
|
+
createdDate: whois.createdDate,
|
|
38
|
+
updatedDate: whois.updatedDate,
|
|
39
|
+
status: whois.status,
|
|
40
|
+
nameServers: whois.nameServers,
|
|
41
|
+
available: whois.available,
|
|
42
|
+
expired: whois.expired,
|
|
43
|
+
rawText: whois.rawText,
|
|
44
|
+
});
|
|
45
|
+
return `db:whois-${id}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function dbRowToSnapshot(row: any): WhoisSnapshot {
|
|
49
|
+
return {
|
|
50
|
+
timestamp: row.snapshot_at,
|
|
51
|
+
domain: row.domain,
|
|
52
|
+
registrar: row.registrar,
|
|
53
|
+
expiryDate: row.expiry_date,
|
|
54
|
+
createdDate: row.created_date,
|
|
55
|
+
updatedDate: row.updated_date,
|
|
56
|
+
status: (() => { try { return JSON.parse(row.status || "[]"); } catch { return []; } })(),
|
|
57
|
+
nameServers: (() => { try { return JSON.parse(row.name_servers || "[]"); } catch { return []; } })(),
|
|
58
|
+
available: !!row.available,
|
|
59
|
+
expired: !!row.expired,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getWhoisHistory(domain: string): WhoisSnapshot[] {
|
|
64
|
+
if (!isValidDomain(domain)) return [];
|
|
65
|
+
const rows = getWhoisHistoryDb(domain);
|
|
66
|
+
return rows.map(dbRowToSnapshot).reverse(); // oldest first
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function diffWhoisSnapshots(
|
|
70
|
+
older: WhoisSnapshot,
|
|
71
|
+
newer: WhoisSnapshot
|
|
72
|
+
): WhoisDiff[] {
|
|
73
|
+
const diffs: WhoisDiff[] = [];
|
|
74
|
+
const fields: (keyof WhoisSnapshot)[] = [
|
|
75
|
+
"registrar", "expiryDate", "createdDate", "updatedDate", "available", "expired",
|
|
76
|
+
];
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
const oldVal = String(older[field] ?? "");
|
|
79
|
+
const newVal = String(newer[field] ?? "");
|
|
80
|
+
if (oldVal !== newVal) diffs.push({ field, old: oldVal, new: newVal });
|
|
81
|
+
}
|
|
82
|
+
const oldStatus = [...older.status].sort().join(", ");
|
|
83
|
+
const newStatus = [...newer.status].sort().join(", ");
|
|
84
|
+
if (oldStatus !== newStatus) diffs.push({ field: "status", old: oldStatus, new: newStatus });
|
|
85
|
+
const oldNs = [...older.nameServers].sort().join(", ");
|
|
86
|
+
const newNs = [...newer.nameServers].sort().join(", ");
|
|
87
|
+
if (oldNs !== newNs) diffs.push({ field: "nameServers", old: oldNs, new: newNs });
|
|
88
|
+
return diffs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getLatestDiff(domain: string): WhoisDiff[] | null {
|
|
92
|
+
const history = getWhoisHistory(domain);
|
|
93
|
+
if (history.length < 2) return null;
|
|
94
|
+
return diffWhoisSnapshots(history[history.length - 2]!, history[history.length - 1]!);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getHistoryCount(domain: string): number {
|
|
98
|
+
return getWhoisHistoryCountDb(domain);
|
|
99
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { assertValidDomain } from "../validate.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export interface ZoneTransferResult {
|
|
8
|
+
domain: string;
|
|
9
|
+
vulnerable: boolean;
|
|
10
|
+
nameServers: string[];
|
|
11
|
+
vulnerableNs: string[];
|
|
12
|
+
records: string[];
|
|
13
|
+
error: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function getNameServers(domain: string): Promise<string[]> {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync("dig", ["+short", domain, "NS"], { timeout: 5000 });
|
|
19
|
+
return stdout.trim().split("\n").filter(Boolean).map((ns) => ns.replace(/\.$/, ""));
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function attemptAxfr(domain: string, nameserver: string): Promise<{ success: boolean; records: string[] }> {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await execFileAsync(
|
|
28
|
+
"dig", ["@" + nameserver, domain, "AXFR", "+noall", "+answer", "+time=5"],
|
|
29
|
+
{ timeout: 10000 }
|
|
30
|
+
);
|
|
31
|
+
const lines = stdout.trim().split("\n").filter((l) => l && !l.startsWith(";"));
|
|
32
|
+
// If we got records, the zone transfer succeeded (vulnerable!)
|
|
33
|
+
if (lines.length > 0) {
|
|
34
|
+
return { success: true, records: lines.slice(0, 100) };
|
|
35
|
+
}
|
|
36
|
+
return { success: false, records: [] };
|
|
37
|
+
} catch {
|
|
38
|
+
return { success: false, records: [] };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function checkZoneTransfer(domain: string): Promise<ZoneTransferResult> {
|
|
43
|
+
assertValidDomain(domain);
|
|
44
|
+
|
|
45
|
+
const result: ZoneTransferResult = {
|
|
46
|
+
domain,
|
|
47
|
+
vulnerable: false,
|
|
48
|
+
nameServers: [],
|
|
49
|
+
vulnerableNs: [],
|
|
50
|
+
records: [],
|
|
51
|
+
error: null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
result.nameServers = await getNameServers(domain);
|
|
56
|
+
if (result.nameServers.length === 0) {
|
|
57
|
+
result.error = "No nameservers found";
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const ns of result.nameServers) {
|
|
62
|
+
const { success, records } = await attemptAxfr(domain, ns);
|
|
63
|
+
if (success) {
|
|
64
|
+
result.vulnerable = true;
|
|
65
|
+
result.vulnerableNs.push(ns);
|
|
66
|
+
result.records.push(...records);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
} catch (err: unknown) {
|
|
72
|
+
result.error = err instanceof Error ? err.message : "Zone transfer check failed";
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
}
|