Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/checker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CheckOptions, ScanResult } from './types.js';
import { fetchHeaders } from './http-client.js';
import { detectWaf } from './waf-detector.js';
import {
analyzeHsts,
analyzeCsp,
Expand Down Expand Up @@ -48,6 +49,7 @@ export async function checkHeaders(
const infoDisclosure = analyzeInfoDisclosure(response.headers);
const score = calculateScore(headerResults, infoDisclosure);
const grade = scoreToGrade(score, headerResults);
const waf = detectWaf(response.statusCode, response.headers);

return {
url: response.finalUrl,
Expand All @@ -58,6 +60,8 @@ export async function checkHeaders(
rawHeaders: response.headers,
redirectChain: response.redirectChain,
tlsVersion: response.tlsVersion,
wafBlocked: waf.blocked,
wafVendor: waf.vendor,
timestamp: new Date().toISOString(),
};
}
5 changes: 5 additions & 0 deletions src/formatters/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export function formatTable(result: ScanResult): string {
if (result.tlsVersion) {
lines.push(`TLS: ${result.tlsVersion}`);
}
if (result.wafBlocked) {
lines.push(
`WAF: Blocked by ${result.wafVendor ?? 'unknown WAF'} (results may be unreliable)`
);
}
if (result.redirectChain.length > 0) {
lines.push(
`Redirects: ${result.redirectChain.length} hop(s)`
Expand Down
5 changes: 5 additions & 0 deletions src/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export function formatText(result: ScanResult): string {
if (result.tlsVersion) {
lines.push(`TLS: ${result.tlsVersion}`);
}
if (result.wafBlocked) {
lines.push(
`WAF: blocked by ${result.wafVendor ?? 'unknown WAF'} - results may be unreliable`
);
}
lines.push('');

for (const header of result.headers) {
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export interface ScanResult {
redirectChain: string[];
/** TLS version used (e.g. "TLSv1.3") */
tlsVersion: string | null;
/** True when the response looks like a WAF / bot-protection block */
wafBlocked: boolean;
/** Detected WAF vendor when wafBlocked is true (e.g. "Akamai"), null otherwise */
wafVendor: string | null;
/** Timestamp of the scan */
timestamp: string;
}
Expand Down
62 changes: 62 additions & 0 deletions src/waf-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export interface WafDetection {
/** True when the response looks like a WAF/bot-protection block */
blocked: boolean;
/** Detected WAF vendor when known, otherwise null */
vendor: string | null;
}

const BLOCKING_STATUS = new Set([401, 403, 406, 429, 503]);

/**
* Heuristic check for whether an HTTP response looks like a WAF / bot-protection block.
* Conservative on purpose: only flag when status code is in a typical block range AND
* a recognizable WAF signature is present, otherwise prefer false negatives over
* mislabeling a genuinely badly-configured site.
*/
export function detectWaf(
statusCode: number,
headers: Record<string, string>
): WafDetection {
if (!BLOCKING_STATUS.has(statusCode)) {
return { blocked: false, vendor: null };
}

const server = headers['server'] ?? '';

if (headers['x-blocked-by-waf'] || /AkamaiGHost/i.test(server)) {
return { blocked: true, vendor: 'Akamai' };
}

if (
headers['cf-mitigated'] ||
(headers['cf-ray'] && (statusCode === 403 || statusCode === 429 || statusCode === 503)) ||
(/cloudflare/i.test(server) && (statusCode === 403 || statusCode === 503))
) {
return { blocked: true, vendor: 'Cloudflare' };
}

if (headers['x-amzn-waf-action'] || /awselb|aws.*waf/i.test(server)) {
return { blocked: true, vendor: 'AWS WAF' };
}

if (headers['x-sucuri-id'] || /sucuri/i.test(server)) {
return { blocked: true, vendor: 'Sucuri' };
}

if (headers['x-iinfo'] || /imperva|incapsula/i.test(server)) {
return { blocked: true, vendor: 'Imperva' };
}

if (
/fastly/i.test(headers['x-cdn'] ?? '') &&
(statusCode === 403 || statusCode === 429)
) {
return { blocked: true, vendor: 'Fastly' };
}

if (statusCode === 403 || statusCode === 429) {
return { blocked: true, vendor: null };
}

return { blocked: false, vendor: null };
}
102 changes: 102 additions & 0 deletions tests/waf-detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { detectWaf } from '../src/waf-detector.js';

describe('detectWaf', () => {
it('returns blocked=false for status 200 with no WAF headers', () => {
expect(detectWaf(200, { server: 'nginx' })).toEqual({
blocked: false,
vendor: null,
});
});

it('returns blocked=false for status 200 even when fronted by Cloudflare', () => {
expect(
detectWaf(200, { server: 'cloudflare', 'cf-ray': '8f1e2a3b4c5d6e7f-AMS' })
).toEqual({ blocked: false, vendor: null });
});

it('detects Akamai via x-blocked-by-waf header', () => {
expect(
detectWaf(403, {
server: 'AkamaiGHost',
'x-blocked-by-waf': 'true',
})
).toEqual({ blocked: true, vendor: 'Akamai' });
});

it('detects Akamai via server header alone', () => {
expect(detectWaf(403, { server: 'AkamaiGHost' })).toEqual({
blocked: true,
vendor: 'Akamai',
});
});

it('detects Cloudflare via cf-mitigated header', () => {
expect(
detectWaf(403, { server: 'cloudflare', 'cf-mitigated': 'challenge' })
).toEqual({ blocked: true, vendor: 'Cloudflare' });
});

it('detects Cloudflare via cf-ray on a 403', () => {
expect(detectWaf(403, { 'cf-ray': '8f1e2a3b4c5d6e7f-AMS' })).toEqual({
blocked: true,
vendor: 'Cloudflare',
});
});

it('detects AWS WAF via x-amzn-waf-action', () => {
expect(detectWaf(403, { 'x-amzn-waf-action': 'block' })).toEqual({
blocked: true,
vendor: 'AWS WAF',
});
});

it('detects Sucuri via x-sucuri-id', () => {
expect(detectWaf(403, { 'x-sucuri-id': '12345' })).toEqual({
blocked: true,
vendor: 'Sucuri',
});
});

it('detects Imperva/Incapsula via x-iinfo', () => {
expect(detectWaf(403, { 'x-iinfo': '0-12345-67890 NNNN CT(0 0 0)' })).toEqual({
blocked: true,
vendor: 'Imperva',
});
});

it('detects Fastly block via x-cdn header', () => {
expect(detectWaf(429, { 'x-cdn': 'Fastly' })).toEqual({
blocked: true,
vendor: 'Fastly',
});
});

it('returns blocked=true with null vendor for generic 429', () => {
expect(detectWaf(429, { server: 'nginx' })).toEqual({
blocked: true,
vendor: null,
});
});

it('returns blocked=true with null vendor for plain 403', () => {
expect(detectWaf(403, { server: 'nginx' })).toEqual({
blocked: true,
vendor: null,
});
});

it('returns blocked=false for 404 even with WAF-like headers (status not in block range)', () => {
expect(detectWaf(404, { 'x-blocked-by-waf': 'true' })).toEqual({
blocked: false,
vendor: null,
});
});

it('returns blocked=false for 500 server error without WAF signature', () => {
expect(detectWaf(500, { server: 'nginx' })).toEqual({
blocked: false,
vendor: null,
});
});
});
Loading