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
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ import { HardenedHttpsValidationKit, defaultAgentOptions } from 'hardened-https-
// Create a validation kit with hardened defaults
const kit = new HardenedHttpsValidationKit({
...defaultAgentOptions(),
enableLogging: true,
});

// Define your HTTPS proxy agent options as usual
Expand Down Expand Up @@ -181,15 +180,15 @@ this.#kit.attachToSocket(socket, callback);

### `HardenedHttpsAgent` & `HardenedHttpsValidationKit` Options

The options below are used to configure the security policies of the `HardenedHttpsAgent`. When using the `HardenedHttpsValidationKit`, only a subset of these options are available (`ctPolicy`, `ocspPolicy`, `crlSetPolicy`, and `enableLogging`).
The options below are used to configure the security policies of the `HardenedHttpsAgent`. When using the `HardenedHttpsValidationKit`, only a subset of these options are available (`ctPolicy`, `ocspPolicy`, `crlSetPolicy`, and `loggerOptions`).

| **Property** | **Type** | **Required / Variants** | **Helper(s)** |
| ------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `ca` | `string \| Buffer \| Array<string \| Buffer>` | Required. Custom trust store that replaces Node.js defaults. Accepts PEM string, `Buffer`, or an array of either. | `embeddedCfsslCaBundle`, `useNodeDefaultCaBundle()` |
| `ctPolicy` | `CertificateTransparencyPolicy` | Optional. Enables CT when present. Fields: `logList: UnifiedCTLogList`, `minEmbeddedScts?: number`, `minDistinctOperators?: number`. | `basicCtPolicy()`, `embeddedUnifiedCtLogList` |
| `ocspPolicy` | `OCSPPolicy` | Optional. Enables OCSP when present. Fields: `mode: 'mixed' \| 'stapling' \| 'direct'`, `failHard: boolean`. | `basicStaplingOcspPolicy()`, `basicDirectOcspPolicy()` |
| `crlSetPolicy` | `CRLSetPolicy` | Optional. Enables CRLSet when present. Fields: `crlSet?: CRLSet`, `verifySignature?: boolean`, `updateStrategy?: 'always' \| 'on-expiry'`. | `basicCrlSetPolicy()` |
| `enableLogging` | `boolean` | Optional (default: `false`). | |
| `loggerOptions` | `LoggerOptions` | Optional logging configuration (level, sink, formatter, template). | `defaultLoggerOptions()` |
| Standard HTTPS opts | `https.AgentOptions` | Optional. Any standard Node.js `https.Agent` options (e.g., `keepAlive`, `maxSockets`, `timeout`, `maxFreeSockets`, `maxCachedSessions`) can be merged alongside the hardened options. | |

_All options are thoroughly documented directly in the library via JSDoc comments for easy in-editor reference and autocomplete._
Expand Down Expand Up @@ -283,7 +282,7 @@ new HardenedHttpsAgent({
Enable detailed logs:

```typescript
new HardenedHttpsAgent({ ...defaultAgentOptions(), enableLogging: true });
new HardenedHttpsAgent({ ...defaultAgentOptions(), loggerOptions: { level: 'debug' } });
```

## Contributing
Expand Down
4 changes: 3 additions & 1 deletion examples/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ async function main() {
const agent = new HardenedHttpsAgent({
...httpsAgentOptions,
...defaultAgentOptions(),
enableLogging: true, // Enable logging to see the validation process (disabled with defaultAgentOptions())
loggerOptions: {
level: 'debug',
}
});

const client = axios.create({ httpsAgent: agent, timeout: 15000 });
Expand Down
48 changes: 27 additions & 21 deletions examples/custom-options.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import axios from 'axios';
import https from 'node:https';
import { HardenedHttpsAgent, embeddedUnifiedCtLogList, embeddedCfsslCaBundle } from '../dist';
import { HardenedHttpsAgent, embeddedUnifiedCtLogList, embeddedCfsslCaBundle, LogFormatter } from '../dist';

const customLogFormatter: LogFormatter = (level, component, message, args) => {
return { message: `[${level.toUpperCase()}] ${component}: ${message}`, args };
};

async function main() {
// Customize standard agent options if required
Expand All @@ -14,27 +18,29 @@ async function main() {

// Merge standard agent options with hardened defaults and some custom policies
// Here we use values from the default options, but you can customize them as you want
const agent = new HardenedHttpsAgent(
{
...httpsAgentOptions,
ca: embeddedCfsslCaBundle, // or *your custom ca bundle* | useNodeDefaultCaBundle()
ctPolicy: {
logList: embeddedUnifiedCtLogList, // or *your custom log list*
minEmbeddedScts: 2,
minDistinctOperators: 2,
},
ocspPolicy: {
mode: 'mixed', // or 'stapling' | 'direct'
failHard: true,
},
crlSetPolicy: {
verifySignature: true,
updateStrategy: 'always', // or 'on-expiry'
},
enableLogging: true, // Enable logging to see the validation process (disabled with defaultAgentOptions())
const agent = new HardenedHttpsAgent({
...httpsAgentOptions,
ca: embeddedCfsslCaBundle, // or *your custom ca bundle* | useNodeDefaultCaBundle()
ctPolicy: {
logList: embeddedUnifiedCtLogList, // or *your custom log list*
minEmbeddedScts: 2,
minDistinctOperators: 2,
},
ocspPolicy: {
mode: 'mixed', // or 'stapling' | 'direct'
failHard: true,
},
crlSetPolicy: {
verifySignature: true,
updateStrategy: 'always', // or 'on-expiry'
},
console, // or your own `LogSink` (default is `console`)
);
loggerOptions: {
level: 'debug', // or 'info' | 'warn' | 'error' | 'silent'
sink: console, // or *your custom sink*
template: '{time} [{name}] {level}: {message}', // or *your custom template*
formatter: customLogFormatter, // or *your custom formatter*
}
});

const client = axios.create({ httpsAgent: agent, timeout: 15000 });
try {
Expand Down
4 changes: 3 additions & 1 deletion examples/got.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ async function main() {
const agent = new HardenedHttpsAgent({
...httpsAgentOptions,
...defaultAgentOptions(),
enableLogging: true, // Enable logging to see the validation process (disabled with defaultAgentOptions())
loggerOptions: {
level: 'debug',
}
});

const client = got.extend({
Expand Down
4 changes: 3 additions & 1 deletion examples/https-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ async function main() {
const agent = new HardenedHttpsAgent({
...httpsAgentOptions,
...defaultAgentOptions(),
enableLogging: true, // Enable logging to see the validation process (disabled with defaultAgentOptions())
loggerOptions: {
level: 'debug',
}
});

try {
Expand Down
4 changes: 3 additions & 1 deletion examples/validation-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ async function main() {
// Create a validation kit with hardened defaults
const kit = new HardenedHttpsValidationKit({
...defaultAgentOptions(),
enableLogging: true,
loggerOptions: {
level: 'debug',
}
});

// Define your HTTPS proxy agent options as usual
Expand Down
15 changes: 8 additions & 7 deletions src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Agent } from 'node:https';
import tls from 'node:tls';
import type { Duplex } from 'node:stream';
import { Logger, LogSink } from './logger';
import { Logger } from './logger';
import { HardenedHttpsAgentOptions } from './interfaces';
import { HardenedHttpsValidationKit } from './validation-kit';
import { NODE_DEFAULT_CA_SENTINEL } from './options';
Expand All @@ -11,7 +11,7 @@ export class HardenedHttpsAgent extends Agent {
#logger: Logger | undefined;
#kit: HardenedHttpsValidationKit;

constructor(options: HardenedHttpsAgentOptions, sink?: LogSink) {
constructor(options: HardenedHttpsAgentOptions) {
const useNodeDefaultCaBundle = (options as any)?.ca === NODE_DEFAULT_CA_SENTINEL;
const optionsForSuper = useNodeDefaultCaBundle ? (({ ca, ...rest }) => rest)(options as any) : options;
super(optionsForSuper);
Expand All @@ -24,16 +24,16 @@ export class HardenedHttpsAgent extends Agent {
throw new Error('The `ca` property cannot be empty.');
}

const { enableLogging, ctPolicy, ocspPolicy, crlSetPolicy } = this.#options;
if (enableLogging) this.#logger = new Logger(this.constructor.name, sink);
this.#kit = new HardenedHttpsValidationKit({ enableLogging, ctPolicy, ocspPolicy, crlSetPolicy }, sink);
const { ctPolicy, ocspPolicy, crlSetPolicy, loggerOptions } = this.#options;
if (loggerOptions) this.#logger = new Logger(this.constructor.name, loggerOptions);
this.#kit = new HardenedHttpsValidationKit({ ctPolicy, ocspPolicy, crlSetPolicy, loggerOptions });
}

override createConnection(
options: tls.ConnectionOptions,
callback: (err: Error | null, stream: Duplex) => void,
): Duplex {
this.#logger?.log('Initiating new TLS connection...');
this.#logger?.info('Initiating new TLS connection...');

// Allow validators to modify the connection options
const finalOptions = this.#kit.applyBeforeConnect(options);
Expand All @@ -42,11 +42,12 @@ export class HardenedHttpsAgent extends Agent {
const tlsSocket = tls.connect(finalOptions);
// Handle validation success
tlsSocket.on('hardened:validation:success', () => {
this.#logger?.info('TLS connection established and validated.');
callback(null, tlsSocket);
});
// Handle socket errors
tlsSocket.on('error', (err: Error) => {
this.#logger?.error('A socket error occurred during connection setup.', err);
this.#logger?.error('An error occurred during TLS connection setup', err);
callback(err, undefined as any);
});

Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { HardenedHttpsAgent } from './agent';
export { HardenedHttpsValidationKit } from './validation-kit';

export type {
HardenedHttpsAgentOptions,
CertificateTransparencyPolicy,
Expand All @@ -19,4 +21,5 @@ export {
defaultAgentOptions,
} from './options';

export { HardenedHttpsValidationKit, type ValidationKitEvents } from './validation-kit';
export { createTemplateFormatter } from './logger';
export type { LogSink, BindableLogSink, LogFormatter, LogLevel } from './logger';
12 changes: 7 additions & 5 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AgentOptions } from 'node:https';
import type { UnifiedCertificateTransparencyLogList as UnifiedCTLogList } from './types/uni-ct-log-list-schema';
import type { CRLSet } from '@gldywn/crlset.js';
import { LoggerOptions } from './logger';

export interface HardenedHttpsAgentOptions extends AgentOptions {
/**
Expand Down Expand Up @@ -31,19 +32,20 @@ export interface HardenedHttpsAgentOptions extends AgentOptions {
crlSetPolicy?: CRLSetPolicy;

/**
* An optional boolean to enable or disable logging.
*
* @default false
* Optional logger options.
*/
enableLogging?: boolean;
loggerOptions?: LoggerOptions;
}

// A minimal subset of options required for validation behavior only
export type HardenedHttpsValidationKitOptions = Pick<
HardenedHttpsAgentOptions,
'ctPolicy' | 'ocspPolicy' | 'crlSetPolicy' | 'enableLogging'
'ctPolicy' | 'ocspPolicy' | 'crlSetPolicy' | 'loggerOptions'
>;

// Options passed down to the validators to perform their checks
export type ValidatorsOptions = Omit<HardenedHttpsValidationKitOptions, 'loggerOptions'>;

export interface CertificateTransparencyPolicy {
/**
* The complete Certificate Transparency log list object.
Expand Down
143 changes: 129 additions & 14 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,142 @@
/* istanbul ignore next */
/* istanbul ignore file */

const DEFAULT_TEMPLATE = '{time} [{name}] {level}: {message}';
export class Logger {
private name: string;
private sink: LogSink;
constructor(name: string, sink?: LogSink) {
private readonly name: string;
private readonly sink: LogSink;
private readonly formatter?: LogFormatter;
private readonly minLevel: EffectiveLogLevel;

constructor(name: string, options?: LoggerOptions) {
this.name = name;
this.sink = sink ?? console;

const sink = options?.sink;
const formatter =
options?.formatter ??
(options?.template ? createTemplateFormatter(options.template) : createTemplateFormatter(DEFAULT_TEMPLATE));
this.minLevel = options?.level ?? 'info';

if (sink && typeof (sink as any).bind === 'function') {
const bound = (sink as BindableLogSink).bind(this.name);
this.sink = bound;
} else {
this.sink = sink ?? console;
}

this.formatter = formatter;
}

public debug(message: any, ...args: any[]): void {
if (!this.shouldLog('debug')) return;
const { outMessage, outArgs } = this.prepare('debug', message, args);
if (typeof this.sink.debug === 'function') {
this.sink.debug(outMessage, ...outArgs);
} else {
this.sink.info(outMessage, ...outArgs);
}
}

public log(message: string, ...args: any[]): void {
this.sink.log(`[Log] ${this.name}: ${message}`, ...args);
public info(message: any, ...args: any[]): void {
if (!this.shouldLog('info')) return;
const { outMessage, outArgs } = this.prepare('info', message, args);
this.sink.info(outMessage, ...outArgs);
}

public warn(message: string, ...args: any[]): void {
this.sink.warn(`[Warning] ${this.name}: ${message}`, ...args);
public warn(message: any, ...args: any[]): void {
if (!this.shouldLog('warn')) return;
const { outMessage, outArgs } = this.prepare('warn', message, args);
this.sink.warn(outMessage, ...outArgs);
}

public error(message: string, ...args: any[]): void {
this.sink.error(`[Error] ${this.name}: ${message}`, ...args);
public error(message: any, ...args: any[]): void {
if (!this.shouldLog('error')) return;
const { outMessage, outArgs } = this.prepare('error', message, args);
this.sink.error(outMessage, ...outArgs);
}

private prepare(level: LogLevel, message: any, args: any[]) {
if (!this.formatter) throw new Error('No formatter set');

const { message: formatted, args: formattedArgs } = this.formatter(level, this.name, message, args);
return { outMessage: formatted, outArgs: formattedArgs };
}

private shouldLog(level: LogLevel): boolean {
return priority(level) >= priority(this.minLevel);
}
}

export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type EffectiveLogLevel = LogLevel | 'silent';

export interface LogSink {
log(message: string, ...args: any[]): void;
warn(message: string, ...args: any[]): void;
error(message: string, ...args: any[]): void;
debug?(message: any, ...args: any[]): void;
info(message: any, ...args: any[]): void;
warn(message: any, ...args: any[]): void;
error(message: any, ...args: any[]): void;
}

export interface BindableLogSink extends LogSink {
bind(component: string): LogSink;
}

export type LogFormatter = (
level: LogLevel,
component: string,
message: any,
args: any[],
) => { message: any; args: any[] };

export interface LoggerOptions {
sink?: LogSink | BindableLogSink;
formatter?: LogFormatter;
template?: string;
level?: EffectiveLogLevel;
}

export function createTemplateFormatter(template: string): LogFormatter {
return (level, component, message, args) => {
const now = new Date().toISOString();
const tokens: Record<string, string> = {
time: now,
level: level.toUpperCase(),
name: component,
message: toSingleLineString(message, args),
};

const out = template.replace(/\{(time|level|name|message)\}/g, (_, key: keyof typeof tokens) => tokens[key]);
return { message: out, args: [] };
};
}

function toSingleLineString(message: any, args: any[]): string {
const parts = [message, ...args].map((v) => formatValue(v));
return parts.join(' ');
}

function formatValue(v: any): string {
if (typeof v === 'string') return v;
if (v instanceof Error) return v.stack || v.message || String(v);
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}

function priority(level: EffectiveLogLevel): number {
switch (level) {
case 'silent':
return 99;
case 'error':
return 40;
case 'warn':
return 30;
case 'info':
return 20;
case 'debug':
return 10;
default:
return 20;
}
}
Loading