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: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
Expand All @@ -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 }}
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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+
Expand Down
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { detectWaf } from './waf-detector.js';
import {
analyzeHsts,
analyzeCsp,
analyzeCspReportOnly,
analyzeXContentType,
analyzeXFrame,
analyzeReferrerPolicy,
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 5 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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 = `
Expand All @@ -33,7 +34,7 @@ Usage:
security-headers <url> [options]

Options:
--format <format> Output format: json, text, table, csv (default: table)
--format <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 <n> Maximum redirect hops (default: 5)
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/formatters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
142 changes: 142 additions & 0 deletions src/formatters/sarif.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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);
}
51 changes: 51 additions & 0 deletions src/headers/csp-report-only.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
): 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',
};
}
Loading
Loading