diff --git a/package-lock.json b/package-lock.json index d46dbd0..967e790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@trustyourwebsite/security-headers", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trustyourwebsite/security-headers", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "bin": { "security-headers": "dist/cli.js" diff --git a/src/checker.ts b/src/checker.ts index bb16a3c..599e159 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -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, @@ -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, @@ -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(), }; } diff --git a/src/formatters/table.ts b/src/formatters/table.ts index 61ed4da..58ab24e 100644 --- a/src/formatters/table.ts +++ b/src/formatters/table.ts @@ -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)` diff --git a/src/formatters/text.ts b/src/formatters/text.ts index 4415246..6208097 100644 --- a/src/formatters/text.ts +++ b/src/formatters/text.ts @@ -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) { diff --git a/src/types.ts b/src/types.ts index 67cc75f..aa763ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } diff --git a/src/waf-detector.ts b/src/waf-detector.ts new file mode 100644 index 0000000..9be4803 --- /dev/null +++ b/src/waf-detector.ts @@ -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 +): 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 }; +} diff --git a/tests/waf-detector.test.ts b/tests/waf-detector.test.ts new file mode 100644 index 0000000..9ddf226 --- /dev/null +++ b/tests/waf-detector.test.ts @@ -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, + }); + }); +});