diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58f9f43..70dbe03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,10 @@ jobs: - name: Install dependencies run: npm ci + - name: Audit dependencies + run: npm audit --audit-level=high + continue-on-error: true + - name: Type check run: npx tsc --noEmit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b22e94..329de80 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,7 @@ jobs: publish: runs-on: ubuntu-latest permissions: + id-token: write contents: read steps: - uses: actions/checkout@v4 @@ -20,6 +21,6 @@ jobs: - run: npm ci - run: npm run build - run: npm test - - run: npm publish --access public + - run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d329d..a85e4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] — 2026-07-12 + +### Added +- **SARIF 2.1.0 output** (`--format sarif`) for GitHub code-scanning and CI ingestion. +- **`Content-Security-Policy-Report-Only` analyzer**: detects report-only policies and warns when report-only is the *only* CSP present (monitoring, not enforcement). +- `detectWaf` is now exported from the package entry point for library consumers. + +### Changed +- **CSP scoring**: a directive set to `'none'` is recognized as fully locked-down (never penalized); `'nonce-…'` / `'sha256|384|512-…'` sources are credited and suppress the (browser-ignored) `'unsafe-inline'` penalty; `frame-ancestors` only earns its bonus for a real allowlist / `'self'` / `'none'`. +- **Publishing hardened**: npm provenance attestation on release, a conditional `exports` map, `prepublishOnly` now runs lint + tests + build, and CI runs a non-blocking dependency audit. + +### Fixed +- `frame-ancestors *` (and `'unsafe-inline'`) no longer incorrectly earned clickjacking-protection points. +- Corrected a stray quote in the CSP wildcard warning message. + + ## [1.1.1] — 2026-05-20 ### Added diff --git a/README.md b/README.md index eabbcd9..68163ed 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # @trustyourwebsite/security-headers +[![npm version](https://img.shields.io/npm/v/@trustyourwebsite/security-headers)](https://www.npmjs.com/package/@trustyourwebsite/security-headers) +[![CI](https://github.com/trustyourwebsite/security-headers/actions/workflows/ci.yml/badge.svg)](https://github.com/trustyourwebsite/security-headers/actions/workflows/ci.yml) +[![license](https://img.shields.io/npm/l/@trustyourwebsite/security-headers)](./LICENSE) + Zero-dependency Node.js tool that grades website security headers (HSTS, CSP, X-Frame-Options, etc.) with A+ to F scoring. CI-friendly with configurable minimum grade threshold. Built by [TrustYourWebsite](https://trustyourwebsite.com) — automated website compliance scanning for EU small businesses. @@ -50,7 +54,7 @@ security-headers https://example.com \ | Option | Default | Description | |--------|---------|-------------| -| `--format` | `table` | Output format: `json`, `text`, `table`, `csv` | +| `--format` | `table` | Output format: `json`, `text`, `table`, `csv`, `sarif` | | `--follow-redirects` | `true` | Follow HTTP redirects | | `--no-follow-redirects` | | Do not follow redirects | | `--max-redirects` | `5` | Maximum redirect hops | @@ -161,6 +165,13 @@ security-headers: - **Robust CSP parser.** Parses all CSP directives and flags dangerous values with specific remediation advice. - **CI-first.** `--ci` mode with exit codes makes it easy to add to any pipeline. +## How this differs from securityheaders.com + +- **Header-only analysis.** We inspect the HTTP response headers directly and never execute the page, so there is no browser rendering, JavaScript evaluation, or third-party network activity involved in a scan. +- **Deterministic, offline-capable scoring.** The same headers always produce the same grade. All grading logic runs locally, so you can score captured headers without an external service round-trip. +- **CI-friendly exit codes.** `--ci --min-grade` returns a non-zero exit code when a site falls below your threshold, so it drops straight into any pipeline. +- **Zero dependencies.** Built only on Node.js built-in modules, keeping the install footprint and attack surface minimal. + ## Requirements - Node.js 18+ diff --git a/package.json b/package.json index 02d4411..6accae2 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,17 @@ { "name": "@trustyourwebsite/security-headers", - "version": "1.1.1", + "version": "1.2.0", "description": "Zero-dependency Node.js tool that grades website security headers (HSTS, CSP, X-Frame-Options, etc.) with A+ to F scoring. CI-friendly with configurable minimum grade threshold.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "bin": { "security-headers": "dist/cli.js" }, @@ -19,7 +26,7 @@ "test": "vitest run", "test:watch": "vitest", "lint": "tsc --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run lint && npm run test && npm run build" }, "keywords": [ "security", diff --git a/src/checker.ts b/src/checker.ts index 599e159..1756095 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -4,6 +4,7 @@ import { detectWaf } from './waf-detector.js'; import { analyzeHsts, analyzeCsp, + analyzeCspReportOnly, analyzeXContentType, analyzeXFrame, analyzeReferrerPolicy, @@ -34,6 +35,7 @@ export async function checkHeaders( const headerResults = [ analyzeHsts(response.headers), analyzeCsp(response.headers), + analyzeCspReportOnly(response.headers), analyzeXContentType(response.headers), analyzeXFrame(response.headers), analyzeReferrerPolicy(response.headers), diff --git a/src/cli.ts b/src/cli.ts index 8ea2a96..0fa5f6c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { formatTable } from './formatters/table.js'; import { formatJson } from './formatters/json.js'; import { formatCsv } from './formatters/csv.js'; import { formatText } from './formatters/text.js'; +import { formatSarif } from './formatters/sarif.js'; import { createRequire } from 'node:module'; /** @@ -21,7 +22,7 @@ function getVersion(): string { return pkg.version; } -const VALID_FORMATS = new Set(['json', 'text', 'table', 'csv']); +const VALID_FORMATS = new Set(['json', 'text', 'table', 'csv', 'sarif']); const VALID_GRADES = new Set(['A+', 'A', 'B', 'C', 'D', 'F']); const HELP = ` @@ -33,7 +34,7 @@ Usage: security-headers [options] Options: - --format Output format: json, text, table, csv (default: table) + --format Output format: json, text, table, csv, sarif (default: table) --follow-redirects Follow HTTP redirects (default: true) --no-follow-redirects Do not follow redirects --max-redirects Maximum redirect hops (default: 5) @@ -183,6 +184,8 @@ function formatOutput( return formatCsv(result); case 'text': return formatText(result); + case 'sarif': + return formatSarif(result); case 'table': default: return formatTable(result); diff --git a/src/formatters/index.ts b/src/formatters/index.ts index 29d3e62..e603f80 100644 --- a/src/formatters/index.ts +++ b/src/formatters/index.ts @@ -2,3 +2,4 @@ export { formatTable } from './table.js'; export { formatJson } from './json.js'; export { formatCsv } from './csv.js'; export { formatText } from './text.js'; +export { formatSarif } from './sarif.js'; diff --git a/src/formatters/sarif.ts b/src/formatters/sarif.ts new file mode 100644 index 0000000..8f1ab54 --- /dev/null +++ b/src/formatters/sarif.ts @@ -0,0 +1,142 @@ +import type { HeaderResult, ScanResult } from '../types.js'; + +/** SARIF result severity levels. */ +type SarifLevel = 'error' | 'warning' | 'note' | 'none'; + +interface SarifRule { + id: string; + name: string; + shortDescription: { text: string }; + helpUri?: string; +} + +interface SarifResult { + ruleId: string; + level: SarifLevel; + message: { text: string }; + locations: Array<{ + physicalLocation: { artifactLocation: { uri: string } }; + }>; +} + +const TOOL_NAME = 'security-headers'; +const TOOL_URI = 'https://github.com/trustyourwebsite/security-headers'; +const SARIF_SCHEMA = + 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json'; + +/** + * Turns a header name into a stable, SARIF-friendly rule id + * (e.g. "Content-Security-Policy" -> "security-headers/content-security-policy"). + * @param headerName - The human-readable header name + * @returns A namespaced, lowercased rule id + */ +function ruleIdFor(headerName: string): string { + return `${TOOL_NAME}/${headerName.toLowerCase()}`; +} + +/** + * Maps a header result status to a SARIF result level. + * @param status - The header analysis status + * @returns The corresponding SARIF level + */ +function levelForStatus(status: HeaderResult['status']): SarifLevel { + switch (status) { + case 'fail': + return 'error'; + case 'warn': + return 'warning'; + case 'info': + return 'note'; + case 'pass': + default: + return 'none'; + } +} + +/** + * Formats a scan result as SARIF 2.1.0 JSON. + * Emits one rule per header check and one result per failing/warning + * (and noteworthy informational) header, plus information-disclosure findings. + * @param result - Scan result to format + * @returns Pretty-printed SARIF 2.1.0 JSON string + */ +export function formatSarif(result: ScanResult): string { + const rules: SarifRule[] = []; + const seenRuleIds = new Set(); + const results: SarifResult[] = []; + + const location = { + physicalLocation: { artifactLocation: { uri: result.url } }, + }; + + for (const header of result.headers) { + const ruleId = ruleIdFor(header.name); + if (!seenRuleIds.has(ruleId)) { + seenRuleIds.add(ruleId); + rules.push({ + id: ruleId, + name: header.name.replace(/-/g, ''), + shortDescription: { text: `${header.name} security header check` }, + helpUri: TOOL_URI, + }); + } + + // Only surface findings that need attention; passing headers add no result. + // Informational findings are reported only when they carry a real message + // (i.e. the header is present or there is remediation advice). + if (header.status === 'pass') continue; + if (header.status === 'info' && header.value === null && !header.remediation) { + continue; + } + + const text = header.remediation + ? `${header.message}. ${header.remediation}` + : header.message; + + results.push({ + ruleId, + level: levelForStatus(header.status), + message: { text }, + locations: [location], + }); + } + + // Information-disclosure findings are advisory notes. + const infoRuleId = `${TOOL_NAME}/information-disclosure`; + if (result.infoDisclosure.length > 0 && !seenRuleIds.has(infoRuleId)) { + seenRuleIds.add(infoRuleId); + rules.push({ + id: infoRuleId, + name: 'InformationDisclosure', + shortDescription: { text: 'Response header leaks software details' }, + helpUri: TOOL_URI, + }); + } + for (const info of result.infoDisclosure) { + results.push({ + ruleId: infoRuleId, + level: 'note', + message: { text: info.message }, + locations: [location], + }); + } + + const sarif = { + version: '2.1.0', + $schema: SARIF_SCHEMA, + runs: [ + { + tool: { + driver: { + name: TOOL_NAME, + informationUri: TOOL_URI, + rules, + }, + }, + results, + }, + ], + }; + + return JSON.stringify(sarif, null, 2); +} diff --git a/src/headers/csp-report-only.ts b/src/headers/csp-report-only.ts new file mode 100644 index 0000000..0dc48d5 --- /dev/null +++ b/src/headers/csp-report-only.ts @@ -0,0 +1,51 @@ +import type { HeaderResult } from '../types.js'; + +const HEADER = 'content-security-policy-report-only'; +const ENFORCING_HEADER = 'content-security-policy'; + +/** + * Analyzes the Content-Security-Policy-Report-Only header. + * Report-only mode monitors violations but does NOT enforce the policy, so a + * site relying on it alone has no CSP protection. This analyzer is purely + * informational: it never contributes to (or deducts from) the score, so a site + * that also ships a real enforcing Content-Security-Policy is not punished for + * additionally running a report-only policy. + * @param headers - Lowercase response headers + * @returns Header analysis result + */ +export function analyzeCspReportOnly( + headers: Record +): HeaderResult { + const value = headers[HEADER] ?? null; + + if (!value) { + return { + name: 'Content-Security-Policy-Report-Only', + status: 'info', + value: null, + message: 'No Content-Security-Policy-Report-Only header present', + severity: 'low', + score: 0, + maxScore: 0, + }; + } + + const hasEnforcing = Boolean(headers[ENFORCING_HEADER]); + + const message = hasEnforcing + ? 'Report-only policy present alongside an enforcing Content-Security-Policy — monitors violations but is not itself enforced' + : 'Report-only mode monitors violations but does NOT enforce the policy — no CSP protection is applied'; + + return { + name: 'Content-Security-Policy-Report-Only', + status: hasEnforcing ? 'info' : 'warn', + value, + message, + severity: 'low', + score: 0, + maxScore: 0, + remediation: hasEnforcing + ? undefined + : 'Once the policy no longer triggers violations, move it into an enforcing Content-Security-Policy header', + }; +} diff --git a/src/headers/csp.ts b/src/headers/csp.ts index c891d68..31aab17 100644 --- a/src/headers/csp.ts +++ b/src/headers/csp.ts @@ -15,6 +15,35 @@ interface CspDirective { values: string[]; } +/** + * Returns true when a directive is locked down to `'none'`, which blocks all + * sources. Such a directive is the strongest possible configuration and must + * never be penalized as dangerous or missing. + * @param values - Parsed directive source values (lowercased) + * @returns True when the only value is `'none'` + */ +function isNone(values: string[]): boolean { + return values.length === 1 && values[0] === "'none'"; +} + +/** + * Returns true when a source list contains a nonce or hash source expression, + * e.g. `'nonce-abc123'` or `'sha256-...'`. These are legitimate, secure ways to + * allow specific inline scripts/styles and cause browsers to ignore any + * accompanying `'unsafe-inline'` backwards-compat fallback (CSP2+). + * @param values - Parsed directive source values (lowercased) + * @returns True when a nonce or hash source is present + */ +function hasNonceOrHash(values: string[]): boolean { + return values.some( + (v) => + v.startsWith("'nonce-") || + v.startsWith("'sha256-") || + v.startsWith("'sha384-") || + v.startsWith("'sha512-") + ); +} + /** * Parses a CSP header string into individual directives. * @param csp - Raw CSP header value @@ -69,12 +98,25 @@ export function analyzeCsp(headers: Record): HeaderResult { const values = directiveMap.get(directive); if (!values) continue; + // 'none' blocks everything — it is the strongest configuration, never dangerous + if (isNone(values)) continue; + + // A nonce or hash makes 'unsafe-inline' inert in modern browsers, so it is + // no longer a real weakness for that directive + const nonceOrHash = hasNonceOrHash(values); + for (const dangerous of dangerousValues) { if (dangerous === '*') { if (values.includes('*')) { - warnings.push(`${directive} allows wildcard (*)"`); + warnings.push(`${directive} allows wildcard (*)`); score -= 3; } + } else if ( + dangerous === "'unsafe-inline'" && + nonceOrHash + ) { + // 'unsafe-inline' is ignored by browsers when a nonce/hash is present + continue; } else if (values.includes(dangerous)) { warnings.push(`${directive} contains ${dangerous}`); // unsafe-inline in style-src is nearly universal and far less dangerous than in script-src @@ -84,15 +126,33 @@ export function analyzeCsp(headers: Record): HeaderResult { } } + // Credit nonce/hash based inline handling in script-src / style-src as a + // secure mechanism rather than requiring 'unsafe-inline' + for (const directive of ['script-src', 'style-src']) { + const values = directiveMap.get(directive); + if (values && hasNonceOrHash(values)) { + score += 1; + } + } + // Check for default-src fallback if (!directiveMap.has('default-src')) { warnings.push("no default-src directive (scripts may load from anywhere if script-src isn't set)"); score -= 2; } - // Check for frame-ancestors (clickjacking protection via CSP) - if (directiveMap.has('frame-ancestors')) { - score += 2; + // Check for frame-ancestors (clickjacking protection via CSP). + // Only reward a real allowlist / 'self' / 'none' — a wildcard or 'unsafe-inline' + // provides no clickjacking protection and must not earn the bonus. + const frameAncestors = directiveMap.get('frame-ancestors'); + if (frameAncestors) { + const weakFrameAncestors = + frameAncestors.includes('*') || frameAncestors.includes("'unsafe-inline'"); + if (weakFrameAncestors) { + warnings.push('frame-ancestors is too permissive (does not restrict framing)'); + } else { + score += 2; + } } // Check for upgrade-insecure-requests diff --git a/src/headers/index.ts b/src/headers/index.ts index f6ba915..41490a9 100644 --- a/src/headers/index.ts +++ b/src/headers/index.ts @@ -1,5 +1,6 @@ export { analyzeHsts } from './hsts.js'; export { analyzeCsp, parseCsp } from './csp.js'; +export { analyzeCspReportOnly } from './csp-report-only.js'; export { analyzeXContentType } from './x-content-type.js'; export { analyzeXFrame } from './x-frame.js'; export { analyzeReferrerPolicy } from './referrer-policy.js'; diff --git a/src/index.ts b/src/index.ts index 7ba4743..87416dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ export { checkHeaders } from './checker.js'; export { calculateScore, scoreToGrade, gradeRank } from './grader.js'; export { parseCsp } from './headers/csp.js'; +export { detectWaf } from './waf-detector.js'; +export type { WafDetection } from './waf-detector.js'; export { formatTable } from './formatters/table.js'; export { formatJson } from './formatters/json.js'; export { formatCsv } from './formatters/csv.js'; export { formatText } from './formatters/text.js'; +export { formatSarif } from './formatters/sarif.js'; export type { Grade, diff --git a/src/types.ts b/src/types.ts index aa763ee..521d1c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,7 @@ export type HeaderStatus = 'pass' | 'warn' | 'fail' | 'info'; export type Severity = 'high' | 'medium' | 'low'; -export type OutputFormat = 'json' | 'text' | 'table' | 'csv'; +export type OutputFormat = 'json' | 'text' | 'table' | 'csv' | 'sarif'; export interface HeaderResult { /** Header name (e.g. "Strict-Transport-Security") */ diff --git a/tests/headers/cache-control.test.ts b/tests/headers/cache-control.test.ts new file mode 100644 index 0000000..26c7ba0 --- /dev/null +++ b/tests/headers/cache-control.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeCacheControl } from '../../src/headers/cache-control.js'; + +describe('analyzeCacheControl', () => { + it('warns when missing', () => { + const result = analyzeCacheControl({}); + expect(result.status).toBe('warn'); + expect(result.score).toBe(0); + }); + + it('passes with no-store (full marks)', () => { + const result = analyzeCacheControl({ 'cache-control': 'no-store' }); + expect(result.status).toBe('pass'); + expect(result.score).toBe(7); + }); + + it('passes with private, no-cache', () => { + const result = analyzeCacheControl({ + 'cache-control': 'private, no-cache', + }); + expect(result.status).toBe('pass'); + expect(result.score).toBe(6); + }); + + it('passes with private only', () => { + const result = analyzeCacheControl({ 'cache-control': 'private' }); + expect(result.status).toBe('pass'); + expect(result.score).toBe(5); + }); + + it('warns on public caching', () => { + const result = analyzeCacheControl({ + 'cache-control': 'public, max-age=31536000', + }); + expect(result.status).toBe('warn'); + expect(result.message).toContain('public'); + }); + + it('warns on an unclear directive', () => { + const result = analyzeCacheControl({ 'cache-control': 'max-age=600' }); + expect(result.status).toBe('warn'); + expect(result.remediation).toBeDefined(); + }); +}); diff --git a/tests/headers/coep.test.ts b/tests/headers/coep.test.ts new file mode 100644 index 0000000..33e35d8 --- /dev/null +++ b/tests/headers/coep.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeCoep } from '../../src/headers/coep.js'; + +describe('analyzeCoep', () => { + it('warns when missing', () => { + const result = analyzeCoep({}); + expect(result.status).toBe('warn'); + expect(result.score).toBe(0); + }); + + it('passes with require-corp (full marks)', () => { + const result = analyzeCoep({ + 'cross-origin-embedder-policy': 'require-corp', + }); + expect(result.status).toBe('pass'); + expect(result.score).toBe(5); + }); + + it('passes with credentialless', () => { + const result = analyzeCoep({ + 'cross-origin-embedder-policy': 'credentialless', + }); + expect(result.status).toBe('pass'); + expect(result.score).toBe(4); + }); + + it('warns with unsafe-none', () => { + const result = analyzeCoep({ + 'cross-origin-embedder-policy': 'unsafe-none', + }); + expect(result.status).toBe('warn'); + expect(result.score).toBe(0); + }); + + it('warns on an unexpected value', () => { + const result = analyzeCoep({ + 'cross-origin-embedder-policy': 'bogus', + }); + expect(result.status).toBe('warn'); + expect(result.remediation).toBeDefined(); + }); +}); diff --git a/tests/headers/corp.test.ts b/tests/headers/corp.test.ts new file mode 100644 index 0000000..97289a0 --- /dev/null +++ b/tests/headers/corp.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeCorp } from '../../src/headers/corp.js'; + +describe('analyzeCorp', () => { + it('warns when missing', () => { + const result = analyzeCorp({}); + expect(result.status).toBe('warn'); + expect(result.score).toBe(0); + }); + + it('passes with same-origin (full marks)', () => { + const result = analyzeCorp({ + 'cross-origin-resource-policy': 'same-origin', + }); + expect(result.status).toBe('pass'); + expect(result.score).toBe(5); + }); + + it('passes with same-site', () => { + const result = analyzeCorp({ + 'cross-origin-resource-policy': 'same-site', + }); + expect(result.status).toBe('pass'); + expect(result.score).toBe(5); + }); + + it('warns with cross-origin (least restrictive)', () => { + const result = analyzeCorp({ + 'cross-origin-resource-policy': 'cross-origin', + }); + expect(result.status).toBe('warn'); + expect(result.score).toBe(2); + }); + + it('warns on an unexpected value', () => { + const result = analyzeCorp({ + 'cross-origin-resource-policy': 'bogus', + }); + expect(result.status).toBe('warn'); + expect(result.remediation).toBeDefined(); + }); +}); diff --git a/tests/headers/csp-report-only.test.ts b/tests/headers/csp-report-only.test.ts new file mode 100644 index 0000000..66932a7 --- /dev/null +++ b/tests/headers/csp-report-only.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeCspReportOnly } from '../../src/headers/csp-report-only.js'; + +describe('analyzeCspReportOnly', () => { + it('is informational and scoreless when the header is absent', () => { + const result = analyzeCspReportOnly({}); + expect(result.status).toBe('info'); + expect(result.value).toBeNull(); + expect(result.maxScore).toBe(0); + expect(result.score).toBe(0); + }); + + it('warns when report-only is present without an enforcing CSP', () => { + const result = analyzeCspReportOnly({ + 'content-security-policy-report-only': "default-src 'self'", + }); + expect(result.status).toBe('warn'); + expect(result.message).toContain('does NOT enforce'); + expect(result.remediation).toBeDefined(); + // Must never affect the score + expect(result.maxScore).toBe(0); + expect(result.score).toBe(0); + }); + + it('is informational (not punishing) when a real enforcing CSP also exists', () => { + const result = analyzeCspReportOnly({ + 'content-security-policy': "default-src 'self'", + 'content-security-policy-report-only': "script-src 'none'", + }); + expect(result.status).toBe('info'); + expect(result.message).toContain('enforcing'); + expect(result.remediation).toBeUndefined(); + expect(result.maxScore).toBe(0); + expect(result.score).toBe(0); + }); +}); diff --git a/tests/headers/csp.test.ts b/tests/headers/csp.test.ts index d4b9e1d..9b249b5 100644 --- a/tests/headers/csp.test.ts +++ b/tests/headers/csp.test.ts @@ -62,4 +62,65 @@ describe('analyzeCsp', () => { }); expect(result.message).toContain('default-src'); }); + + it("treats 'none' as safe, not dangerous", () => { + const result = analyzeCsp({ + 'content-security-policy': + "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'", + }); + expect(result.status).toBe('pass'); + // No warnings should mention script-src / object-src / base-uri being dangerous + expect(result.message).not.toContain('contains'); + expect(result.message).not.toContain('wildcard'); + }); + + it("does not penalize default-src 'none'", () => { + const result = analyzeCsp({ + 'content-security-policy': "default-src 'none'", + }); + expect(result.status).toBe('pass'); + expect(result.message).not.toContain('wildcard'); + }); + + it('does not reward frame-ancestors *', () => { + const withWildcard = analyzeCsp({ + 'content-security-policy': "default-src 'self'; frame-ancestors *", + }); + const withoutFrameAncestors = analyzeCsp({ + 'content-security-policy': "default-src 'self'", + }); + // The wildcard frame-ancestors must not earn the +2 bonus + expect(withWildcard.score).toBeLessThanOrEqual(withoutFrameAncestors.score); + expect(withWildcard.status).toBe('warn'); + expect(withWildcard.message).toContain('frame-ancestors'); + }); + + it("rewards frame-ancestors 'self'", () => { + const withSelf = analyzeCsp({ + 'content-security-policy': "default-src 'self'; frame-ancestors 'self'", + }); + const withoutFrameAncestors = analyzeCsp({ + 'content-security-policy': "default-src 'self'", + }); + expect(withSelf.score).toBeGreaterThan(withoutFrameAncestors.score); + }); + + it('credits a nonce in script-src instead of penalizing unsafe-inline', () => { + const result = analyzeCsp({ + 'content-security-policy': + "default-src 'self'; script-src 'self' 'nonce-abc123' 'unsafe-inline'", + }); + // unsafe-inline is inert alongside a nonce, so it must not be flagged + expect(result.status).toBe('pass'); + expect(result.message).not.toContain('unsafe-inline'); + }); + + it('credits a sha256 hash in script-src', () => { + const result = analyzeCsp({ + 'content-security-policy': + "default-src 'self'; script-src 'self' 'sha256-abc123' 'unsafe-inline'", + }); + expect(result.status).toBe('pass'); + expect(result.message).not.toContain('unsafe-inline'); + }); }); diff --git a/tests/headers/set-cookie.test.ts b/tests/headers/set-cookie.test.ts new file mode 100644 index 0000000..c340296 --- /dev/null +++ b/tests/headers/set-cookie.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + analyzeSetCookie, + parseCookieAttributes, +} from '../../src/headers/set-cookie.js'; + +const URL = 'https://example.com'; + +describe('parseCookieAttributes', () => { + it('parses name and all security attributes', () => { + const attrs = parseCookieAttributes( + 'sid=abc123; Path=/; Secure; HttpOnly; SameSite=Strict' + ); + expect(attrs.name).toBe('sid'); + expect(attrs.secure).toBe(true); + expect(attrs.httpOnly).toBe(true); + expect(attrs.sameSite).toBe('strict'); + }); + + it('reports missing attributes as false/null', () => { + const attrs = parseCookieAttributes('sid=abc123'); + expect(attrs.secure).toBe(false); + expect(attrs.httpOnly).toBe(false); + expect(attrs.sameSite).toBeNull(); + }); +}); + +describe('analyzeSetCookie', () => { + it('is informational when no cookies are set', () => { + const result = analyzeSetCookie([], URL); + expect(result.status).toBe('info'); + expect(result.maxScore).toBe(0); + }); + + it('passes when all attributes are present', () => { + const result = analyzeSetCookie( + ['sid=abc; Secure; HttpOnly; SameSite=Strict'], + URL + ); + expect(result.status).toBe('pass'); + expect(result.score).toBe(5); + }); + + it('warns when Secure is missing', () => { + const result = analyzeSetCookie( + ['sid=abc; HttpOnly; SameSite=Strict'], + URL + ); + expect(result.status).toBe('warn'); + expect(result.message).toContain('Secure'); + }); + + it('warns when HttpOnly is missing', () => { + const result = analyzeSetCookie( + ['sid=abc; Secure; SameSite=Strict'], + URL + ); + expect(result.status).toBe('warn'); + expect(result.message).toContain('HttpOnly'); + }); + + it('warns when SameSite is missing', () => { + const result = analyzeSetCookie(['sid=abc; Secure; HttpOnly'], URL); + expect(result.status).toBe('warn'); + expect(result.message).toContain('SameSite'); + }); + + it('warns when SameSite=None lacks Secure', () => { + const result = analyzeSetCookie( + ['sid=abc; HttpOnly; SameSite=None'], + URL + ); + expect(result.status).toBe('warn'); + expect(result.message).toContain('SameSite=None requires Secure'); + }); + + it('does not require Secure on localhost', () => { + const result = analyzeSetCookie( + ['sid=abc; HttpOnly; SameSite=Strict'], + 'http://localhost:3000' + ); + expect(result.status).toBe('pass'); + }); + + it('flags a cookie with no security attributes at all', () => { + const result = analyzeSetCookie(['sid=abc'], URL); + expect(result.status).toBe('warn'); + expect(result.score).toBe(0); + }); +}); diff --git a/tests/sarif.test.ts b/tests/sarif.test.ts new file mode 100644 index 0000000..9d85f12 --- /dev/null +++ b/tests/sarif.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { formatSarif } from '../src/formatters/sarif.js'; +import type { ScanResult } from '../src/types.js'; + +const mockResult: ScanResult = { + url: 'https://example.com', + grade: 'B', + score: 72, + headers: [ + { + name: 'Strict-Transport-Security', + status: 'pass', + value: 'max-age=31536000; includeSubDomains', + message: 'max-age=31536000; includeSubDomains', + severity: 'high', + score: 13, + maxScore: 15, + }, + { + name: 'Content-Security-Policy', + status: 'fail', + value: null, + message: 'MISSING', + severity: 'high', + score: 0, + maxScore: 15, + remediation: 'Add Content-Security-Policy header', + }, + { + name: 'Referrer-Policy', + status: 'warn', + value: 'no-referrer-when-downgrade', + message: 'Consider strict-origin-when-cross-origin', + severity: 'medium', + score: 3, + maxScore: 7, + remediation: 'Use strict-origin-when-cross-origin', + }, + { + name: 'Content-Security-Policy-Report-Only', + status: 'info', + value: null, + message: 'No Content-Security-Policy-Report-Only header present', + severity: 'low', + score: 0, + maxScore: 0, + }, + ], + infoDisclosure: [ + { + name: 'Server', + value: 'nginx/1.24.0', + message: 'Server: nginx/1.24.0 — Remove version number', + }, + ], + rawHeaders: {}, + redirectChain: [], + tlsVersion: 'TLSv1.3', + wafBlocked: false, + wafVendor: null, + timestamp: '2024-01-01T00:00:00.000Z', +}; + +describe('formatSarif', () => { + it('outputs valid SARIF 2.1.0 JSON', () => { + const sarif = JSON.parse(formatSarif(mockResult)); + expect(sarif.version).toBe('2.1.0'); + expect(sarif.$schema).toContain('sarif-schema-2.1.0.json'); + expect(Array.isArray(sarif.runs)).toBe(true); + expect(sarif.runs).toHaveLength(1); + }); + + it('declares the tool driver with rules', () => { + const sarif = JSON.parse(formatSarif(mockResult)); + const driver = sarif.runs[0].tool.driver; + expect(driver.name).toBe('security-headers'); + expect(Array.isArray(driver.rules)).toBe(true); + // One rule per distinct header check plus the info-disclosure rule + const ruleIds = driver.rules.map((r: { id: string }) => r.id); + expect(ruleIds).toContain('security-headers/content-security-policy'); + expect(ruleIds).toContain('security-headers/information-disclosure'); + }); + + it('emits results with correct levels for fail and warn', () => { + const sarif = JSON.parse(formatSarif(mockResult)); + const results = sarif.runs[0].results as Array<{ + ruleId: string; + level: string; + message: { text: string }; + locations: unknown[]; + }>; + + const csp = results.find( + (r) => r.ruleId === 'security-headers/content-security-policy' + ); + expect(csp?.level).toBe('error'); + + const referrer = results.find( + (r) => r.ruleId === 'security-headers/referrer-policy' + ); + expect(referrer?.level).toBe('warning'); + }); + + it('does not emit a result for passing headers', () => { + const sarif = JSON.parse(formatSarif(mockResult)); + const results = sarif.runs[0].results as Array<{ ruleId: string }>; + expect( + results.some( + (r) => r.ruleId === 'security-headers/strict-transport-security' + ) + ).toBe(false); + }); + + it('skips scoreless informational findings with no value or remediation', () => { + const sarif = JSON.parse(formatSarif(mockResult)); + const results = sarif.runs[0].results as Array<{ ruleId: string }>; + expect( + results.some( + (r) => + r.ruleId === 'security-headers/content-security-policy-report-only' + ) + ).toBe(false); + }); + + it('reports information-disclosure findings as notes', () => { + const sarif = JSON.parse(formatSarif(mockResult)); + const results = sarif.runs[0].results as Array<{ + ruleId: string; + level: string; + message: { text: string }; + }>; + const info = results.find( + (r) => r.ruleId === 'security-headers/information-disclosure' + ); + expect(info?.level).toBe('note'); + expect(info?.message.text).toContain('nginx'); + }); + + it('includes the scanned URL as a result location', () => { + const sarif = JSON.parse(formatSarif(mockResult)); + const results = sarif.runs[0].results as Array<{ + locations: Array<{ + physicalLocation: { artifactLocation: { uri: string } }; + }>; + }>; + expect( + results[0].locations[0].physicalLocation.artifactLocation.uri + ).toBe('https://example.com'); + }); +});