diff --git a/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js b/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js index f9f1a183f..323910aef 100644 --- a/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js +++ b/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js @@ -18,10 +18,10 @@ new SslMonitor('example-com-ssl', { alertDaysBeforeExpiry: 30, }, assertions: [ - SslAssertionBuilder.certExpiresInDays().greaterThan(30), - SslAssertionBuilder.chainTrusted().equals(true), - SslAssertionBuilder.hostnameVerified().equals(true), - SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30), + SslAssertionBuilder.connection('chainTrusted').equals(true), + SslAssertionBuilder.connection('hostnameVerified').equals(true), + SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'), ], }, }) diff --git a/examples/advanced-project/src/__checks__/uptime/ssl.check.ts b/examples/advanced-project/src/__checks__/uptime/ssl.check.ts index 39155094d..9a959c7ae 100644 --- a/examples/advanced-project/src/__checks__/uptime/ssl.check.ts +++ b/examples/advanced-project/src/__checks__/uptime/ssl.check.ts @@ -18,10 +18,10 @@ new SslMonitor('example-com-ssl', { alertDaysBeforeExpiry: 30, }, assertions: [ - SslAssertionBuilder.certExpiresInDays().greaterThan(30), - SslAssertionBuilder.chainTrusted().equals(true), - SslAssertionBuilder.hostnameVerified().equals(true), - SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30), + SslAssertionBuilder.connection('chainTrusted').equals(true), + SslAssertionBuilder.connection('hostnameVerified').equals(true), + SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'), ], }, }) diff --git a/packages/cli/src/ai-context/context.ts b/packages/cli/src/ai-context/context.ts index c4d28d784..f64a1044d 100644 --- a/packages/cli/src/ai-context/context.ts +++ b/packages/cli/src/ai-context/context.ts @@ -290,10 +290,10 @@ new SslMonitor('example-com-ssl', { alertDaysBeforeExpiry: 30, }, assertions: [ - SslAssertionBuilder.certExpiresInDays().greaterThan(30), - SslAssertionBuilder.chainTrusted().equals(true), - SslAssertionBuilder.hostnameVerified().equals(true), - SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30), + SslAssertionBuilder.connection('chainTrusted').equals(true), + SslAssertionBuilder.connection('hostnameVerified').equals(true), + SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'), ], }, }) diff --git a/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts b/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts index 338ab3ef3..4fc5ddd7e 100644 --- a/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts @@ -13,34 +13,119 @@ function render (assertion: SslAssertion): string { } describe('SSL Assertion Codegen', () => { - it('generates the new SSL assertion sources', () => { + it('generates the property-scoped SSL assertion sources', () => { const cases: { input: SslAssertion, expected: string }[] = [ - // Boolean sources emit a boolean literal, not a quoted string. + // CERTIFICATE / CONNECTION emit the property name as the first call argument. + // Numeric properties emit an unquoted number, matching the builder's typed target. { - input: { source: 'OCSP_STAPLED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, - expected: 'SslAssertionBuilder.ocspStapled().equals(true)\n', + input: { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '30', regex: null }, + expected: 'SslAssertionBuilder.certificate(\'daysUntilExpiry\').greaterThan(30)\n', }, { - input: { source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, - expected: 'SslAssertionBuilder.certNotExpired().equals(true)\n', + input: { source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'EQUALS', target: 'SHA256-RSA', regex: null }, + expected: 'SslAssertionBuilder.certificate(\'signatureAlgorithm\').equals(\'SHA256-RSA\')\n', }, + // Boolean properties emit a boolean literal, not a quoted string. { - input: { source: 'CHAIN_TRUSTED', property: '', comparison: 'EQUALS', target: 'false', regex: null }, - expected: 'SslAssertionBuilder.chainTrusted().equals(false)\n', + input: { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'false', regex: null }, + expected: 'SslAssertionBuilder.certificate(\'selfSigned\').equals(false)\n', }, - // Numeric key size emits an unquoted number. { - input: { source: 'KEY_SIZE_BITS', property: '', comparison: 'EQUALS', target: '2048', regex: null }, - expected: 'SslAssertionBuilder.keySizeBits().equals(2048)\n', + input: { source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'true', regex: null }, + expected: 'SslAssertionBuilder.connection(\'chainTrusted\').equals(true)\n', }, - // The string sources support the MATCHES (regex) operator. { - input: { source: 'CIPHER_SUITE', property: '', comparison: 'MATCHES', target: 'TLS_(AES|CHACHA)', regex: null }, - expected: 'SslAssertionBuilder.cipherSuite().matches(\'TLS_(AES|CHACHA)\')\n', + input: { source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3', regex: null }, + expected: 'SslAssertionBuilder.connection(\'tlsVersion\').equals(\'TLS1.3\')\n', + }, + // RESPONSE_TIME is numeric — the target is an unquoted number and no property is emitted. + { + input: { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null }, + expected: 'SslAssertionBuilder.responseTime().lessThan(1000)\n', + }, + // JSON_RESPONSE emits the JSONPath property. + { + input: { source: 'JSON_RESPONSE', property: '$.status', comparison: 'EQUALS', target: 'ok', regex: null }, + expected: 'SslAssertionBuilder.jsonResponse(\'$.status\').equals(\'ok\')\n', + }, + // TEXT_RESPONSE carries its regex in the property slot (the backend/runner contract). + { + input: { source: 'TEXT_RESPONSE', property: '', comparison: 'CONTAINS', target: 'healthy', regex: null }, + expected: 'SslAssertionBuilder.textResponse().contains(\'healthy\')\n', + }, + { + input: { source: 'TEXT_RESPONSE', property: 'status: (\\w+)', comparison: 'EQUALS', target: 'ok', regex: null }, + expected: 'SslAssertionBuilder.textResponse(\'status: (\\\\w+)\').equals(\'ok\')\n', }, ] for (const test of cases) { expect(render(test.input)).toEqual(test.expected) } }) + + // The backend accepts any target string, so a monitor created via the UI or API can + // carry a target the builder's typed operators cannot express. Rendering it as a typed + // literal anyway would rewrite the assertion on the next deploy ('yes' becoming false, + // '30.5' becoming 30), so these fall back to the string form, which preserves the value. + it('does not coerce a target its typed operators cannot express', () => { + const cases: { input: SslAssertion, expected: string }[] = [ + { + input: { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'yes', regex: null }, + expected: 'SslAssertionBuilder.certificate(\'selfSigned\').equals(\'yes\')\n', + }, + { + input: { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '', regex: null }, + expected: 'SslAssertionBuilder.certificate(\'keySizeBits\').equals(\'\')\n', + }, + { + input: { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: 'abc', regex: null }, + expected: 'SslAssertionBuilder.certificate(\'daysUntilExpiry\').greaterThan(\'abc\')\n', + }, + { + // parseInt would take the leading prefix and silently assert against 5. + input: { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '5%', regex: null }, + expected: 'SslAssertionBuilder.certificate(\'keySizeBits\').equals(\'5%\')\n', + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) + + // Codegen must render every target validateSslAssertion accepts as a numeric literal: + // the property's operators take a number, so a quoted target would not compile. parseInt + // would truncate the fractional and exponent forms. + it('renders every numeric target validation accepts as a number literal', () => { + const cases: { target: string, expected: string }[] = [ + { target: '30', expected: '30' }, + { target: '30.5', expected: '30.5' }, + { target: '0.5', expected: '0.5' }, + { target: '1e3', expected: '1000' }, + { target: '-5', expected: '-5' }, + { target: ' 30 ', expected: '30' }, + ] + for (const { target, expected } of cases) { + expect(render( + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target, regex: null }, + )).toEqual(`SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(${expected})\n`) + } + }) + + // A comparison outside a typed helper's set makes the helper throw, which would abort + // the whole import over one assertion. + it('renders rather than throws on a comparison the property does not support', () => { + expect(render( + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'CONTAINS', target: '30', regex: null }, + )).toEqual('SslAssertionBuilder.certificate(\'daysUntilExpiry\').contains(\'30\')\n') + expect(render( + { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'NOT_EQUALS', target: 'true', regex: null }, + )).toEqual('SslAssertionBuilder.certificate(\'selfSigned\').notEquals(\'true\')\n') + }) + + // An unknown property reaches codegen from remote data; validation reports it. + it('renders an unknown property rather than throwing', () => { + expect(render( + { source: 'CERTIFICATE', property: 'bogusProperty', comparison: 'EQUALS', target: 'x', regex: null }, + )).toEqual('SslAssertionBuilder.certificate(\'bogusProperty\').equals(\'x\')\n') + }) }) diff --git a/packages/cli/src/constructs/__tests__/ssl-assertion-grammar.spec.ts b/packages/cli/src/constructs/__tests__/ssl-assertion-grammar.spec.ts new file mode 100644 index 000000000..70e380076 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/ssl-assertion-grammar.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' + +import { Diagnostics } from '../diagnostics.js' +import { operatorComparisons } from '../internal/assertion-grammar.js' +import { certificateGrammar, connectionGrammar } from '../ssl-assertion-grammar.js' +import { booleanComparisons, numericComparisons, valueForSslAssertion } from '../ssl-assertion-codegen.js' +import { validateSslAssertion } from '../ssl-assertion-validation.js' +import { SslAssertion, SslAssertionBuilder } from '../ssl-assertion.js' +import { GeneratedFile, Output } from '../../sourcegen/index.js' + +// The grammar tables in internal/ssl-grammar.ts are the single declaration the builder, +// the validation whitelist, and codegen are all derived from. These tests iterate that +// data and assert the three consumers agree for every declared (property, operator), so a +// drift between them cannot land — replacing the per-class synchronization the previous +// design could not hold by hand. + +function render (assertion: SslAssertion): string { + const output = new Output() + valueForSslAssertion(new GeneratedFile('foo.ts'), assertion).render(output) + return output.finalize() +} + +// A runtime target of the right shape for a property's value kind. The builder stringifies +// it, so any in-kind value round-trips; validation then accepts it. +function sampleTarget (kind: string): string | number | boolean { + switch (kind) { + case 'number': + return 1 + case 'boolean': + return true + default: + return 'x' + } +} + +const sources = [ + { source: 'CERTIFICATE', method: 'certificate', grammar: certificateGrammar }, + { source: 'CONNECTION', method: 'connection', grammar: connectionGrammar }, +] as const + +describe('SSL assertion grammar', () => { + it('routes every declared operator through the public builder to the right wire fields', () => { + for (const { source, method, grammar } of sources) { + for (const [property, decl] of Object.entries(grammar)) { + for (const operator of decl.operators) { + type Builder = Record SslAssertion> + const builder = (SslAssertionBuilder[method] as (p: string) => Builder)(property) + const assertion = builder[operator](sampleTarget(decl.target.kind)) + expect(assertion).toMatchObject({ + source, + property, + comparison: operatorComparisons[operator], + }) + } + } + } + }) + + it('accepts every declared (property, operator) through validation', () => { + for (const { source, grammar } of sources) { + for (const [property, decl] of Object.entries(grammar)) { + for (const operator of decl.operators) { + const assertion: SslAssertion = { + source, + property, + comparison: operatorComparisons[operator], + target: String(sampleTarget(decl.target.kind)), + regex: null, + } + const diags = new Diagnostics() + validateSslAssertion(diags, assertion, 0) + expect(diags.isFatal(), `${source}.${property}.${operator} should validate`).toEqual(false) + } + } + } + }) + + it('renders every declared (property, operator) naming the same property and operator', () => { + for (const { method, grammar } of sources) { + for (const [property, decl] of Object.entries(grammar)) { + for (const operator of decl.operators) { + const assertion: SslAssertion = { + source: method === 'certificate' ? 'CERTIFICATE' : 'CONNECTION', + property, + comparison: operatorComparisons[operator], + target: String(sampleTarget(decl.target.kind)), + regex: null, + } + const source = render(assertion) + expect(source).toContain(`SslAssertionBuilder.${method}('${property}')`) + expect(source).toContain(`.${operator}(`) + } + } + } + }) + + // Codegen renders a number target as a bare number literal and a boolean as a bare + // boolean, both only for the comparisons it has typed paths for. A property of that kind + // declaring any other operator would fall through to the quoted-string form, which does + // not compile against the property's typed builder method — the drift this guards against. + // + // The renderable sets are codegen's own predicates (numericComparisons / booleanComparisons), + // not restated here, so this cannot fall out of step with what codegen actually renders. + it('lets number and boolean properties declare only operators codegen renders as literals', () => { + const renderableFor: Record> = { + number: numericComparisons, + boolean: booleanComparisons, + } + for (const { grammar } of sources) { + for (const [property, decl] of Object.entries(grammar)) { + const renderable = renderableFor[decl.target.kind] + if (renderable === undefined) { + continue + } + for (const operator of decl.operators) { + const comparison = operatorComparisons[operator] + expect(Object.hasOwn(renderable, comparison), `${property}.${operator} is not rendered as a ${decl.target.kind} literal`).toBe(true) + } + } + } + }) +}) diff --git a/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts b/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts index e64de275e..7e542ea92 100644 --- a/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts @@ -1,13 +1,10 @@ /** - * Type-level tests for SslAssertionBuilder typed constants. + * Tests for the property-scoped SslAssertionBuilder. * - * These tests verify two things: - * 1. Valid constant / string-literal values are accepted by the typed builders. - * 2. Invalid strings are REJECTED at compile time (via @ts-expect-error) where - * the builder is strictly typed. - * - * If a @ts-expect-error directive becomes "unused" it means the underlying typing - * was loosened — investigate before removing it. + * The builder mirrors the API check grammar: `certificate` / `connection` take a + * property name, `jsonResponse` a JSONPath and `textResponse` an optional regex. The + * exported TlsVersion / SignatureAlgorithm / CipherSuite constant maps are usable as + * assertion targets. */ import { describe, it, expect } from 'vitest' @@ -18,126 +15,196 @@ import { SignatureAlgorithm, } from '../index.js' -describe('SslAssertionBuilder — typed target values', () => { - describe('tlsVersion() — strict 4-value union', () => { - it('accepts all TlsVersion constants', () => { - expect(SslAssertionBuilder.tlsVersion().equals(TlsVersion.TLS1_0)).toBeTruthy() - expect(SslAssertionBuilder.tlsVersion().equals(TlsVersion.TLS1_1)).toBeTruthy() - expect(SslAssertionBuilder.tlsVersion().equals(TlsVersion.TLS1_2)).toBeTruthy() - expect(SslAssertionBuilder.tlsVersion().equals(TlsVersion.TLS1_3)).toBeTruthy() +describe('SslAssertionBuilder', () => { + describe('certificate(property)', () => { + it('builds numeric certificate assertions', () => { + expect(SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30)).toMatchObject({ + source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '30', + }) + expect(SslAssertionBuilder.certificate('keySizeBits').equals(2048)).toMatchObject({ + source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '2048', + }) }) - it('accepts TLS version string literals directly', () => { - expect(SslAssertionBuilder.tlsVersion().equals('TLS1.2')).toBeTruthy() - expect(SslAssertionBuilder.tlsVersion().equals('TLS1.3')).toBeTruthy() + it('builds string certificate assertions', () => { + expect(SslAssertionBuilder.certificate('issuerCN').contains('Let\'s Encrypt')).toMatchObject({ + source: 'CERTIFICATE', property: 'issuerCN', comparison: 'CONTAINS', target: 'Let\'s Encrypt', + }) + expect(SslAssertionBuilder.certificate('sans').notContains('evil.example.com')).toMatchObject({ + source: 'CERTIFICATE', property: 'sans', comparison: 'NOT_CONTAINS', target: 'evil.example.com', + }) }) - it('rejects an arbitrary string that is not a valid TLS version', () => { - // @ts-expect-error 'not-a-tls-version' is not assignable to TlsVersionValue - SslAssertionBuilder.tlsVersion().equals('not-a-tls-version') + it('accepts a SignatureAlgorithm constant as a target', () => { + expect(SslAssertionBuilder.certificate('signatureAlgorithm').equals(SignatureAlgorithm.SHA256_RSA)).toMatchObject({ + source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'EQUALS', target: 'SHA256-RSA', + }) }) - it('rejects a numeric TLS version (wrong type)', () => { - // @ts-expect-error number is not assignable to TlsVersionValue - SslAssertionBuilder.tlsVersion().equals(1.3) + it('builds boolean certificate assertions', () => { + expect(SslAssertionBuilder.certificate('selfSigned').equals(false)).toMatchObject({ + source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'false', + }) + expect(SslAssertionBuilder.certificate('isCA').equals(true)).toMatchObject({ + source: 'CERTIFICATE', property: 'isCA', comparison: 'EQUALS', target: 'true', + }) }) - }) - describe('signatureAlgorithm() — complete Go x509.SignatureAlgorithm.String() union', () => { - it('accepts all SignatureAlgorithm constants (Go format)', () => { - // RSA family - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.MD2_RSA)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.MD5_RSA)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA1_RSA)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA256_RSA)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA384_RSA)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA512_RSA)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA256_RSAPSS)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA384_RSAPSS)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA512_RSAPSS)).toBeTruthy() - // DSA family - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.DSA_SHA1)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.DSA_SHA256)).toBeTruthy() - // ECDSA family - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.ECDSA_SHA1)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.ECDSA_SHA256)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.ECDSA_SHA384)).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.ECDSA_SHA512)).toBeTruthy() - // EdDSA - expect(SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.ED25519)).toBeTruthy() - }) - - it('accepts Go-format string literals directly', () => { - expect(SslAssertionBuilder.signatureAlgorithm().equals('SHA256-RSA')).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals('ECDSA-SHA256')).toBeTruthy() - expect(SslAssertionBuilder.signatureAlgorithm().equals('Ed25519')).toBeTruthy() - }) - - it('rejects OID-style strings (wrong format — the runner uses Go String() not OID names)', () => { - // @ts-expect-error 'sha256WithRSAEncryption' is not assignable to SignatureAlgorithmValue - SslAssertionBuilder.signatureAlgorithm().equals('sha256WithRSAEncryption') - }) - - it('rejects arbitrary strings', () => { - // @ts-expect-error 'invalid-alg' is not assignable to SignatureAlgorithmValue - SslAssertionBuilder.signatureAlgorithm().equals('invalid-alg') + // Numeric and boolean targets are narrowed at compile time, so the wire payload is + // the only place a wrong-typed target could still surface. These run under vitest + // and hold regardless of whether the type-level cases above are ever checked. + it('stringifies typed targets onto the wire', () => { + expect(SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30).target).toEqual('30') + expect(SslAssertionBuilder.certificate('keySizeBits').equals(2048).target).toEqual('2048') + expect(SslAssertionBuilder.certificate('selfSigned').equals(false).target).toEqual('false') + expect(SslAssertionBuilder.connection('chainTrusted').equals(true).target).toEqual('true') }) }) - describe('cipherSuite() — intentionally unconstrained (open-ended IANA set)', () => { - it('accepts CipherSuite constants for common suites', () => { - expect(SslAssertionBuilder.cipherSuite().equals(CipherSuite.TLS_AES_256_GCM_SHA384)).toBeTruthy() - expect(SslAssertionBuilder.cipherSuite().equals(CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)).toBeTruthy() + describe('connection(property)', () => { + it('accepts a TlsVersion constant as a target', () => { + expect(SslAssertionBuilder.connection('tlsVersion').equals(TlsVersion.TLS1_3)).toMatchObject({ + source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3', + }) + expect(SslAssertionBuilder.connection('tlsVersion').greaterThan(TlsVersion.TLS1_2)).toMatchObject({ + source: 'CONNECTION', property: 'tlsVersion', comparison: 'GREATER_THAN', target: 'TLS1.2', + }) }) - it('accepts any string — including suites not in the CipherSuite constant list', () => { - // These are valid Go tls.CipherSuiteName() outputs that exceed the short constant list - expect(SslAssertionBuilder.cipherSuite().equals('TLS_RSA_WITH_RC4_128_SHA')).toBeTruthy() - expect(SslAssertionBuilder.cipherSuite().equals('TLS_RSA_WITH_3DES_EDE_CBC_SHA')).toBeTruthy() - expect(SslAssertionBuilder.cipherSuite().notEquals('TLS_RSA_WITH_RC4_128_SHA')).toBeTruthy() - expect(SslAssertionBuilder.cipherSuite().equals('TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256')).toBeTruthy() + it('accepts a CipherSuite constant as a target', () => { + expect(SslAssertionBuilder.connection('cipherSuite').equals(CipherSuite.TLS_AES_256_GCM_SHA384)).toMatchObject({ + source: 'CONNECTION', property: 'cipherSuite', comparison: 'EQUALS', target: 'TLS_AES_256_GCM_SHA384', + }) + }) + + it('builds boolean connection assertions', () => { + expect(SslAssertionBuilder.connection('chainTrusted').equals(true)).toMatchObject({ + source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'true', + }) + expect(SslAssertionBuilder.connection('hostnameVerified').equals(true)).toMatchObject({ + source: 'CONNECTION', property: 'hostnameVerified', comparison: 'EQUALS', target: 'true', + }) + }) + + it('builds a resolvedIp contains assertion', () => { + expect(SslAssertionBuilder.connection('resolvedIp').contains('93.184')).toMatchObject({ + source: 'CONNECTION', property: 'resolvedIp', comparison: 'CONTAINS', target: '93.184', + }) + }) + }) + + describe('responseTime()', () => { + it('builds a numeric response-time assertion without a property', () => { + expect(SslAssertionBuilder.responseTime().lessThan(1000)).toMatchObject({ + source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', + }) }) }) - describe('matches() — regex operator on the string sources', () => { - it('produces a MATCHES assertion for cipherSuite / issuerCn / signatureAlgorithm', () => { - expect(SslAssertionBuilder.cipherSuite().matches('TLS_(AES|CHACHA)')).toMatchObject({ - source: 'CIPHER_SUITE', comparison: 'MATCHES', target: 'TLS_(AES|CHACHA)', + describe('jsonResponse(property)', () => { + it('builds a JSONPath-scoped assertion', () => { + expect(SslAssertionBuilder.jsonResponse('$.status').equals('ok')).toMatchObject({ + source: 'JSON_RESPONSE', property: '$.status', comparison: 'EQUALS', target: 'ok', }) - expect(SslAssertionBuilder.issuerCn().matches('^Let\'s Encrypt')).toMatchObject({ - source: 'ISSUER_CN', comparison: 'MATCHES', target: '^Let\'s Encrypt', + expect(SslAssertionBuilder.jsonResponse('$.data').isNotNull()).toMatchObject({ + source: 'JSON_RESPONSE', property: '$.data', comparison: 'NOT_NULL', }) - expect(SslAssertionBuilder.signatureAlgorithm().matches('SHA(256|384)')).toMatchObject({ - source: 'SIGNATURE_ALGORITHM', comparison: 'MATCHES', target: 'SHA(256|384)', + }) + }) + + describe('textResponse(regex)', () => { + it('builds a text-response assertion, optionally scoped by regex', () => { + expect(SslAssertionBuilder.textResponse().contains('healthy')).toMatchObject({ + source: 'TEXT_RESPONSE', property: '', comparison: 'CONTAINS', target: 'healthy', regex: null, + }) + // The pattern rides in `property` — the slot the backend Joi schema and the + // go-runner (EvaluateRegExp) read the TEXT_RESPONSE pattern from; `regex` is dead. + expect(SslAssertionBuilder.textResponse('status: (\\w+)').equals('ok')).toMatchObject({ + source: 'TEXT_RESPONSE', property: 'status: (\\w+)', comparison: 'EQUALS', target: 'ok', regex: null, }) }) }) - describe('per-source builders reject operators the backend does not allow', () => { - it('rejects them at compile time', () => { - // Compile-time-only checks: the removed methods do not exist at runtime, so the - // body is type-checked but never executed (an uncalled function). + // The `@ts-expect-error` lines below document the intended narrowing but currently + // assert nothing: tsconfig.json excludes `src/**/__tests__` and vitest does not + // type-check, so no build step ever evaluates them. RED-739 tracks wiring that up. + // Until it lands, only the runtime `expect` calls here are enforced. + describe('type narrowing (compile-time)', () => { + it('constrains connection(tlsVersion) targets to TlsVersionValue', () => { + expect(SslAssertionBuilder.connection('tlsVersion').equals(TlsVersion.TLS1_3)).toMatchObject({ + source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3', + }) + // @ts-expect-error 'bogus' is not a TlsVersionValue + SslAssertionBuilder.connection('tlsVersion').equals('bogus') + }) + + it('constrains certificate(signatureAlgorithm) targets to SignatureAlgorithmValue', () => { + expect(SslAssertionBuilder.certificate('signatureAlgorithm').equals(SignatureAlgorithm.SHA256_RSA)).toMatchObject({ + source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'EQUALS', target: 'SHA256-RSA', + }) + // @ts-expect-error 'nope' is not a SignatureAlgorithmValue + SslAssertionBuilder.certificate('signatureAlgorithm').equals('nope') + }) + + it('rejects unknown property names', () => { + // @ts-expect-error 'bogusProperty' is not an SslCertificateProperty + SslAssertionBuilder.certificate('bogusProperty') + // @ts-expect-error 'bogusProperty' is not an SslConnectionProperty + SslAssertionBuilder.connection('bogusProperty') + }) + it('constrains numeric certificate properties to number targets', () => { + // @ts-expect-error a numeric property does not take a string target + SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan('30 days') + // @ts-expect-error a numeric property does not take a boolean target + SslAssertionBuilder.certificate('keySizeBits').equals(true) + }) + + it('constrains boolean properties to boolean targets', () => { + // @ts-expect-error a boolean property does not take a string target + SslAssertionBuilder.certificate('selfSigned').equals('yes') + // @ts-expect-error a boolean property does not take a string target + SslAssertionBuilder.connection('chainTrusted').equals('yes') + }) + + // Each property exposes only the operators the backend accepts for its value type, + // so an unsupported comparison is a compile error rather than a deploy diagnostic. + it('offers only the operators a property supports', () => { + // These operators do not exist at runtime, so the body is type-checked but never + // executed (an uncalled function) — calling it would throw a TypeError. const _typeChecks = () => { - // keySizeBits supports EQUALS only (GREATER_THAN_OR_EQUAL was dropped) - // @ts-expect-error greaterThan is not available on the key-size builder - SslAssertionBuilder.keySizeBits().greaterThan(2048) - // @ts-expect-error notEquals is not available on the key-size builder - SslAssertionBuilder.keySizeBits().notEquals(2048) - // boolean sources take a boolean, not the string 'true' - // @ts-expect-error 'true' (string) is not assignable to boolean - SslAssertionBuilder.certNotExpired().equals('true') - // matches is not offered on non-string sources - // @ts-expect-error matches is not available on the cert-not-expired builder - SslAssertionBuilder.certNotExpired().matches('x') - // the general string operators are not offered on SSL string sources - // @ts-expect-error contains is not available on the cipher-suite builder - SslAssertionBuilder.cipherSuite().contains('x') - // tlsVersion supports EQUALS only - // @ts-expect-error notEquals is not available on the tls-version builder - SslAssertionBuilder.tlsVersion().notEquals('TLS1.3') + // @ts-expect-error a boolean property supports EQUALS only + SslAssertionBuilder.certificate('selfSigned').notEquals(true) + // @ts-expect-error a numeric property is not substring-matched + SslAssertionBuilder.certificate('keySizeBits').contains('20') + // @ts-expect-error an opaque identifier is not substring-matched + SslAssertionBuilder.certificate('fingerprintSha256').contains('ab') + // @ts-expect-error a string list has no whole-value to compare against + SslAssertionBuilder.certificate('sans').equals('example.com') + // @ts-expect-error an enum is not ordered + SslAssertionBuilder.certificate('signatureAlgorithm').greaterThan('SHA256-RSA') + // @ts-expect-error a free-form string is not ordered + SslAssertionBuilder.connection('cipherSuite').greaterThan('TLS_AES_256_GCM_SHA384') } expect(_typeChecks).toBeDefined() }) + + it('allows the operators a property does support', () => { + expect(SslAssertionBuilder.connection('tlsVersion').greaterThan(TlsVersion.TLS1_2)).toMatchObject({ + source: 'CONNECTION', property: 'tlsVersion', comparison: 'GREATER_THAN', target: 'TLS1.2', + }) + expect(SslAssertionBuilder.certificate('sans').notContains('evil.example.com')).toMatchObject({ + source: 'CERTIFICATE', property: 'sans', comparison: 'NOT_CONTAINS', target: 'evil.example.com', + }) + expect(SslAssertionBuilder.certificate('serialNumber').notEquals('ab12')).toMatchObject({ + source: 'CERTIFICATE', property: 'serialNumber', comparison: 'NOT_EQUALS', target: 'ab12', + }) + }) + + it('leaves cipherSuite targets unconstrained (arbitrary string)', () => { + expect(SslAssertionBuilder.connection('cipherSuite').equals('SOME_FUTURE_SUITE')).toMatchObject({ + source: 'CONNECTION', property: 'cipherSuite', comparison: 'EQUALS', target: 'SOME_FUTURE_SUITE', + }) + }) }) }) diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts index 40ccfb2af..f2c27b2c4 100644 --- a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -46,8 +46,8 @@ describe('SslMonitor', () => { alertDaysBeforeExpiry: 20, }, assertions: [ - SslAssertionBuilder.certExpiresInDays().greaterThan(20), - SslAssertionBuilder.chainTrusted().equals(true), + SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(20), + SslAssertionBuilder.connection('chainTrusted').equals(true), ], }, }) @@ -63,8 +63,8 @@ describe('SslMonitor', () => { port: 443, }, assertions: [ - expect.objectContaining({ source: 'CERT_EXPIRES_IN_DAYS', comparison: 'GREATER_THAN', target: '20' }), - expect.objectContaining({ source: 'CHAIN_TRUSTED', comparison: 'EQUALS', target: 'true' }), + expect.objectContaining({ source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '20' }), + expect.objectContaining({ source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'true' }), ], }, }) @@ -90,7 +90,7 @@ describe('SslMonitor', () => { skipChainValidation: false, }, assertions: [ - SslAssertionBuilder.certExpiresInDays().greaterThan(30), + SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30), ], }, }) @@ -125,22 +125,22 @@ describe('SslMonitor', () => { hostname: 'example.com', sslConfig: {}, assertions: [ - SslAssertionBuilder.keySizeBits().equals(2048), - SslAssertionBuilder.tlsVersion().equals('TLS1.3'), - SslAssertionBuilder.cipherSuite().equals('TLS_AES_256_GCM_SHA384'), - SslAssertionBuilder.issuerCn().equals('Let\'s Encrypt'), - SslAssertionBuilder.ocspStapled().equals(true), + SslAssertionBuilder.certificate('keySizeBits').equals(2048), + SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'), + SslAssertionBuilder.connection('cipherSuite').equals('TLS_AES_256_GCM_SHA384'), + SslAssertionBuilder.certificate('issuerCN').equals('Let\'s Encrypt'), + SslAssertionBuilder.connection('ocspStapled').equals(true), ], }, }) const payload = check.synthesize() as any expect(payload.request.assertions).toEqual([ - expect.objectContaining({ source: 'KEY_SIZE_BITS', comparison: 'EQUALS', target: '2048' }), - expect.objectContaining({ source: 'TLS_VERSION', comparison: 'EQUALS', target: 'TLS1.3' }), - expect.objectContaining({ source: 'CIPHER_SUITE', comparison: 'EQUALS', target: 'TLS_AES_256_GCM_SHA384' }), - expect.objectContaining({ source: 'ISSUER_CN', comparison: 'EQUALS', target: 'Let\'s Encrypt' }), - expect.objectContaining({ source: 'OCSP_STAPLED', comparison: 'EQUALS', target: 'true' }), + expect.objectContaining({ source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '2048' }), + expect.objectContaining({ source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3' }), + expect.objectContaining({ source: 'CONNECTION', property: 'cipherSuite', comparison: 'EQUALS', target: 'TLS_AES_256_GCM_SHA384' }), + expect.objectContaining({ source: 'CERTIFICATE', property: 'issuerCN', comparison: 'EQUALS', target: 'Let\'s Encrypt' }), + expect.objectContaining({ source: 'CONNECTION', property: 'ocspStapled', comparison: 'EQUALS', target: 'true' }), ]) }) @@ -294,53 +294,213 @@ describe('SslMonitor', () => { ])) }) + it('should error on an unknown property for a property-scoped source', async () => { + const diags = await validateWith([ + { source: 'CERTIFICATE', property: 'bogusProperty', comparison: 'EQUALS', target: 'x', regex: null }, + ] as unknown as SslRequest['assertions']) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The CERTIFICATE assertion at "request.assertions[0]" has an unknown property "bogusProperty".', + ), + }), + ])) + }) + + it('should error on a comparison the property does not allow', async () => { + const diags = await validateWith([ + // certificate keySizeBits is numeric — CONTAINS is not allowed. + { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'CONTAINS', target: '2048', regex: null }, + ]) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The CERTIFICATE "keySizeBits" assertion at "request.assertions[0]" has an unsupported comparison "CONTAINS".', + ), + }), + ])) + }) + it('should error on a comparison the source does not allow', async () => { const diags = await validateWith([ - // KEY_SIZE_BITS supports EQUALS only. - { source: 'KEY_SIZE_BITS', property: '', comparison: 'GREATER_THAN', target: '2048', regex: null }, + // RESPONSE_TIME is numeric — CONTAINS is not allowed. + { source: 'RESPONSE_TIME', property: '', comparison: 'CONTAINS', target: '100', regex: null }, ]) expect(diags.isFatal()).toEqual(true) expect(diags.observations).toEqual(expect.arrayContaining([ expect.objectContaining({ message: expect.stringContaining( - 'The KEY_SIZE_BITS assertion at "request.assertions[0]" has an unsupported comparison "GREATER_THAN".', + 'The RESPONSE_TIME assertion at "request.assertions[0]" has an unsupported comparison "CONTAINS".', ), }), ])) }) - it('should error on MATCHES for a source that does not allow it', async () => { + it('should error on a numeric property with a non-numeric target', async () => { + // The builder types these targets as numbers, but an object literal bypasses it and + // the backend accepts the string — it just never matches, so the assertion would + // silently never fire. + const cases: Array<{ target: string, expected: string }> = [ + { target: '30 days', expected: 'must compare against a number, but got "30 days".' }, + { target: '', expected: 'must compare against a number, but got (none).' }, + { target: 'abc', expected: 'must compare against a number, but got "abc".' }, + ] + for (const { target, expected } of cases) { + const diags = await validateWith([ + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target, regex: null }, + ]) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + `The CERTIFICATE "daysUntilExpiry" assertion at "request.assertions[0]" ${expected}`, + ), + }), + ])) + } + }) + + it('should error on a non-numeric RESPONSE_TIME target', async () => { const diags = await validateWith([ - { source: 'CERT_EXPIRES_IN_DAYS', property: '', comparison: 'MATCHES', target: '.*', regex: null }, + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1s', regex: null }, ]) expect(diags.isFatal()).toEqual(true) expect(diags.observations).toEqual(expect.arrayContaining([ - expect.objectContaining({ message: expect.stringContaining('has an unsupported comparison "MATCHES"') }), + expect.objectContaining({ + message: expect.stringContaining( + 'The RESPONSE_TIME assertion at "request.assertions[0]" must compare against a number, but got "1s".', + ), + }), ])) }) - it('should error on a boolean source with a non-boolean target', async () => { + // Check files are loaded without typechecking, so an object-literal assertion can + // carry a non-string target. It must be reported, not thrown on. + it('should report rather than crash on a non-string target', async () => { + const cases: unknown[] = [ + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'EQUALS', target: 30, regex: null }, + { source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: true, regex: null }, + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'EQUALS', regex: null }, + ] + for (const assertion of cases) { + const diags = await validateWith([assertion] as SslRequest['assertions']) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('written as a string') }), + ])) + } + }) + + // A target that is never inspected must not crash either, whatever its type. + it('should not crash on assertions whose target it does not inspect', async () => { + const diags = await validateWith([ + { source: 'TEXT_RESPONSE', property: '', comparison: 'NOT_EMPTY', regex: null }, + { source: 'JSON_RESPONSE', property: '$.x', comparison: 'IS_NULL', target: null, regex: null }, + { source: 'CERTIFICATE', property: 'issuerCN', comparison: 'CONTAINS', target: 'CA', regex: null }, + ] as unknown as SslRequest['assertions']) + expect(diags.isFatal()).toEqual(false) + }) + + it('should accept the numeric targets a numeric property does allow', async () => { const diags = await validateWith([ - { source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'yes', regex: null }, + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '30', regex: null }, + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'LESS_THAN', target: '30.5', regex: null }, + { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '2048', regex: null }, + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null }, + ]) + expect(diags.isFatal()).toEqual(false) + }) + + it('should error on a boolean property with a non-boolean target', async () => { + const diags = await validateWith([ + { source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'yes', regex: null }, ]) expect(diags.isFatal()).toEqual(true) expect(diags.observations).toEqual(expect.arrayContaining([ expect.objectContaining({ message: expect.stringContaining( - 'The CERT_NOT_EXPIRED assertion at "request.assertions[0]" must compare against "true" or "false", but got "yes".', + 'The CONNECTION "chainTrusted" assertion at "request.assertions[0]" must compare against "true" or "false", but got "yes".', ), }), ])) }) + it('should error on removed SSL-only operators against the new grammar', async () => { + // The regex (MATCHES) and >= (GREATER_THAN_OR_EQUAL) operators were dropped for SSL; + // they must be rejected on every source that could plausibly carry them. + const cases: Array<{ assertion: SslRequest['assertions'][number], message: string }> = [ + { + assertion: { source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'MATCHES', target: 'x', regex: null }, + message: 'The CERTIFICATE "signatureAlgorithm" assertion at "request.assertions[0]" has an unsupported comparison "MATCHES".', + }, + { + assertion: { source: 'CONNECTION', property: 'tlsVersion', comparison: 'GREATER_THAN_OR_EQUAL', target: 'TLS1.2', regex: null }, + message: 'The CONNECTION "tlsVersion" assertion at "request.assertions[0]" has an unsupported comparison "GREATER_THAN_OR_EQUAL".', + }, + { + assertion: { source: 'RESPONSE_TIME', property: '', comparison: 'GREATER_THAN_OR_EQUAL', target: '100', regex: null }, + message: 'The RESPONSE_TIME assertion at "request.assertions[0]" has an unsupported comparison "GREATER_THAN_OR_EQUAL".', + }, + ] + for (const { assertion, message } of cases) { + const diags = await validateWith([assertion]) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining(message) }), + ])) + } + }) + + it('should enforce the per-property comparison whitelist', async () => { + const cases: Array<{ assertion: SslRequest['assertions'][number], message: string }> = [ + { + // sans is STRING_LIST — only CONTAINS / NOT_CONTAINS, so EQUALS is rejected. + assertion: { source: 'CERTIFICATE', property: 'sans', comparison: 'EQUALS', target: 'example.com', regex: null }, + message: 'The CERTIFICATE "sans" assertion at "request.assertions[0]" has an unsupported comparison "EQUALS".', + }, + { + // serialNumber is ID — only EQUALS / NOT_EQUALS, so CONTAINS is rejected. + assertion: { source: 'CERTIFICATE', property: 'serialNumber', comparison: 'CONTAINS', target: 'ab', regex: null }, + message: 'The CERTIFICATE "serialNumber" assertion at "request.assertions[0]" has an unsupported comparison "CONTAINS".', + }, + { + // ocspStatus is ENUM — only EQUALS / NOT_EQUALS, so CONTAINS is rejected. + assertion: { source: 'CONNECTION', property: 'ocspStatus', comparison: 'CONTAINS', target: 'good', regex: null }, + message: 'The CONNECTION "ocspStatus" assertion at "request.assertions[0]" has an unsupported comparison "CONTAINS".', + }, + ] + for (const { assertion, message } of cases) { + const diags = await validateWith([assertion]) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining(message) }), + ])) + } + }) + + it('should accept the list-oriented comparisons the whitelist allows', async () => { + const diags = await validateWith([ + { source: 'CERTIFICATE', property: 'sans', comparison: 'CONTAINS', target: 'example.com', regex: null }, + { source: 'CONNECTION', property: 'resolvedIp', comparison: 'NOT_CONTAINS', target: '10.0.0.1', regex: null }, + ]) + expect(diags.isFatal()).toEqual(false) + }) + it('should not error on assertions built with SslAssertionBuilder', async () => { const diags = await validateWith([ - SslAssertionBuilder.certExpiresInDays().greaterThan(30), - SslAssertionBuilder.keySizeBits().equals(2048), - SslAssertionBuilder.chainTrusted().equals(true), - SslAssertionBuilder.tlsVersion().equals('TLS1.3'), - SslAssertionBuilder.cipherSuite().matches('TLS_(AES|CHACHA)'), - SslAssertionBuilder.issuerCn().matches('^Let\'s Encrypt'), + SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30), + SslAssertionBuilder.certificate('keySizeBits').equals(2048), + SslAssertionBuilder.certificate('issuerCN').contains('Let\'s Encrypt'), + SslAssertionBuilder.certificate('signatureAlgorithm').equals('SHA256-RSA'), + SslAssertionBuilder.certificate('selfSigned').equals(false), + SslAssertionBuilder.connection('chainTrusted').equals(true), + SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'), + SslAssertionBuilder.connection('cipherSuite').notEquals('TLS_RSA_WITH_RC4_128_SHA'), + SslAssertionBuilder.responseTime().lessThan(1000), + SslAssertionBuilder.jsonResponse('$.status').equals('ok'), + SslAssertionBuilder.textResponse().contains('healthy'), ]) expect(diags.isFatal()).toEqual(false) }) diff --git a/packages/cli/src/constructs/__tests__/traceroute-assertion-grammar.spec.ts b/packages/cli/src/constructs/__tests__/traceroute-assertion-grammar.spec.ts new file mode 100644 index 000000000..4f2d34d3c --- /dev/null +++ b/packages/cli/src/constructs/__tests__/traceroute-assertion-grammar.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' + +import { Diagnostics } from '../diagnostics.js' +import { operatorComparisons } from '../internal/assertion-grammar.js' +import { hopCountGrammar, packetLossGrammar, tracerouteResponseTimeGrammar } from '../traceroute-assertion-grammar.js' +import { valueForTracerouteAssertion } from '../traceroute-assertion-codegen.js' +import { validateTracerouteAssertion } from '../traceroute-assertion-validation.js' +import { TracerouteAssertion, TracerouteAssertionBuilder } from '../traceroute-assertion.js' +import { GeneratedFile, Output } from '../../sourcegen/index.js' + +// The traceroute grammar tables in traceroute-assertion-grammar.ts are the single +// declaration the builder and the validation whitelist derive from. These tests iterate +// that data and assert the two consumers agree for every declared (source, property, +// operator), so a drift between them cannot land. + +function render (assertion: TracerouteAssertion): string { + const output = new Output() + valueForTracerouteAssertion(new GeneratedFile('foo.ts'), assertion).render(output) + return output.finalize() +} + +// Every traceroute source is numeric; the builder call for one declared operator. +const cases = [ + ...Object.entries(tracerouteResponseTimeGrammar).flatMap(([property, decl]) => + decl.operators.map(operator => ({ + source: 'RESPONSE_TIME' as const, property, operator, method: 'responseTime', + }))), + ...hopCountGrammar.operators.map(operator => ({ + source: 'HOP_COUNT' as const, property: '', operator, method: 'hopCount', + })), + ...packetLossGrammar.operators.map(operator => ({ + source: 'PACKET_LOSS' as const, property: '', operator, method: 'packetLoss', + })), +] + +describe('traceroute assertion grammar', () => { + it('routes every declared operator through the public builder to the right wire fields', () => { + type Ops = Record TracerouteAssertion> + for (const { source, property, operator } of cases) { + const call = source === 'RESPONSE_TIME' + ? (TracerouteAssertionBuilder.responseTime as (p: string) => Ops)(property) + : (TracerouteAssertionBuilder[source === 'HOP_COUNT' ? 'hopCount' : 'packetLoss']() as unknown as Ops) + expect(call[operator](1)).toMatchObject({ + source, + property, + comparison: operatorComparisons[operator], + }) + } + }) + + it('accepts every declared (source, property, operator) through validation', () => { + for (const { source, property, operator } of cases) { + const assertion: TracerouteAssertion = { + source, + property, + comparison: operatorComparisons[operator], + target: '1', + regex: null, + } + const diags = new Diagnostics() + validateTracerouteAssertion(diags, assertion, 0) + expect(diags.isFatal(), `${source}.${property || '(none)'}.${operator} should validate`).toEqual(false) + } + }) + + // Validation consults each property's own grammar row, so a comparison no response-time + // property declares is rejected rather than accepted wholesale. (A cross-property + // divergence — one property dropping an operator another keeps — cannot be expressed as a + // static case while all rows are identical; the per-property derivation prevents it + // structurally, and this guards that validation is strict rather than accept-all.) + it('rejects a comparison a property does not declare', () => { + for (const property of Object.keys(tracerouteResponseTimeGrammar)) { + const diags = new Diagnostics() + validateTracerouteAssertion( + diags, + { source: 'RESPONSE_TIME', property, comparison: 'CONTAINS', target: '1', regex: null }, + 0, + ) + expect(diags.isFatal(), `RESPONSE_TIME.${property} should reject CONTAINS`).toEqual(true) + } + }) + + it('renders every declared (source, property, operator) naming the same method and operator', () => { + for (const { source, property, operator, method } of cases) { + const assertion: TracerouteAssertion = { + source, + property, + comparison: operatorComparisons[operator], + target: '1', + regex: null, + } + const generated = render(assertion) + expect(generated).toContain(`TracerouteAssertionBuilder.${method}(`) + expect(generated).toContain(`.${operator}(`) + if (source === 'RESPONSE_TIME') { + expect(generated).toContain(`responseTime('${property}')`) + } + } + }) +}) diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index 90d119d49..d65eb7409 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -172,14 +172,14 @@ describe('SslMonitorCodegen', () => { request: { sslConfig: { hostname: 'example.com' }, assertions: [ - { source: 'CERT_EXPIRES_IN_DAYS', property: '', comparison: 'GREATER_THAN', target: '20', regex: null }, - { source: 'CHAIN_TRUSTED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '20', regex: null }, + { source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'true', regex: null }, ], }, })) expect(source).toContain('SslAssertionBuilder') - expect(source).toContain('certExpiresInDays()') - expect(source).toContain('chainTrusted()') + expect(source).toContain('certificate(\'daysUntilExpiry\')') + expect(source).toContain('connection(\'chainTrusted\')') }) it('emits every SslAssertionBuilder source with its target', async () => { @@ -187,39 +187,37 @@ describe('SslMonitorCodegen', () => { request: { sslConfig: { hostname: 'example.com' }, assertions: [ - { source: 'CERT_EXPIRES_IN_DAYS', property: '', comparison: 'GREATER_THAN', target: '20', regex: null }, - { source: 'KEY_SIZE_BITS', property: '', comparison: 'EQUALS', target: '2048', regex: null }, - { source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, - { source: 'HOSTNAME_VERIFIED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, - { source: 'CHAIN_TRUSTED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, - { source: 'OCSP_STAPLED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, - { source: 'TLS_VERSION', property: '', comparison: 'EQUALS', target: 'TLS1.3', regex: null }, + { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '20', regex: null }, + { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '2048', regex: null }, + { source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'EQUALS', target: 'SHA256-RSA', regex: null }, + { source: 'CERTIFICATE', property: 'issuerCN', comparison: 'CONTAINS', target: 'Example CA', regex: null }, + { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'false', regex: null }, + { source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3', regex: null }, { - source: 'CIPHER_SUITE', - property: '', + source: 'CONNECTION', + property: 'cipherSuite', comparison: 'EQUALS', target: 'TLS_AES_256_GCM_SHA384', regex: null, }, - { source: 'SIGNATURE_ALGORITHM', property: '', comparison: 'EQUALS', target: 'SHA256-RSA', regex: null }, - { source: 'ISSUER_CN', property: '', comparison: 'EQUALS', target: 'Example CA', regex: null }, - { source: 'CERT_FINGERPRINT_SHA256', property: '', comparison: 'EQUALS', target: 'abc123', regex: null }, - { source: 'ISSUER_FINGERPRINT_SHA256', property: '', comparison: 'EQUALS', target: 'def456', regex: null }, + { source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'true', regex: null }, + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null }, + { source: 'JSON_RESPONSE', property: '$.status', comparison: 'EQUALS', target: 'ok', regex: null }, + { source: 'TEXT_RESPONSE', property: '', comparison: 'CONTAINS', target: 'healthy', regex: null }, ], }, })) - expect(source).toContain('certExpiresInDays().greaterThan(20)') - expect(source).toContain('keySizeBits().equals(2048)') - expect(source).toContain('certNotExpired().equals(true)') - expect(source).toContain('hostnameVerified().equals(true)') - expect(source).toContain('chainTrusted().equals(true)') - expect(source).toContain('ocspStapled().equals(true)') - expect(source).toContain('tlsVersion().equals(\'TLS1.3\')') - expect(source).toContain('cipherSuite().equals(\'TLS_AES_256_GCM_SHA384\')') - expect(source).toContain('signatureAlgorithm().equals(\'SHA256-RSA\')') - expect(source).toContain('issuerCn().equals(\'Example CA\')') - expect(source).toContain('certFingerprintSha256().equals(\'abc123\')') - expect(source).toContain('issuerFingerprintSha256().equals(\'def456\')') + expect(source).toContain('certificate(\'daysUntilExpiry\').greaterThan(20)') + expect(source).toContain('certificate(\'keySizeBits\').equals(2048)') + expect(source).toContain('certificate(\'signatureAlgorithm\').equals(\'SHA256-RSA\')') + expect(source).toContain('certificate(\'issuerCN\').contains(\'Example CA\')') + expect(source).toContain('certificate(\'selfSigned\').equals(false)') + expect(source).toContain('connection(\'tlsVersion\').equals(\'TLS1.3\')') + expect(source).toContain('connection(\'cipherSuite\').equals(\'TLS_AES_256_GCM_SHA384\')') + expect(source).toContain('connection(\'chainTrusted\').equals(true)') + expect(source).toContain('responseTime().lessThan(1000)') + expect(source).toContain('jsonResponse(\'$.status\').equals(\'ok\')') + expect(source).toContain('textResponse().contains(\'healthy\')') }) }) diff --git a/packages/cli/src/constructs/internal/assertion-codegen.ts b/packages/cli/src/constructs/internal/assertion-codegen.ts index 44d82bfea..429a263ec 100644 --- a/packages/cli/src/constructs/internal/assertion-codegen.ts +++ b/packages/cli/src/constructs/internal/assertion-codegen.ts @@ -16,6 +16,13 @@ export function unsupportedAssertionSource (source: never, kind?: string): never export interface ValueForNumericAssertionOptions { hasProperty?: boolean + /** + * How to read the target as a number. Defaults to `parseInt`, which takes the leading + * numeric prefix — lenient by design, since wire targets may carry units ('5%'). + * Callers that have already established the target is a full number can pass `Number` + * to keep fractions and avoid truncating '30.5' to 30. + */ + parse?: (target: string) => number } export function valueForNumericAssertion ( @@ -24,6 +31,7 @@ export function valueForNumericAssertion ( assertion: Assertion, options?: ValueForNumericAssertionOptions, ): Value { + const parse = options?.parse ?? ((target: string) => parseInt(target, 10)) return expr(ident(klass), builder => { builder.member(ident(method)) builder.call(builder => { @@ -36,25 +44,25 @@ export function valueForNumericAssertion ( case 'EQUALS': builder.member(ident('equals')) builder.call(builder => { - builder.number(parseInt(assertion.target, 10)) + builder.number(parse(assertion.target)) }) break case 'NOT_EQUALS': builder.member(ident('notEquals')) builder.call(builder => { - builder.number(parseInt(assertion.target, 10)) + builder.number(parse(assertion.target)) }) break case 'LESS_THAN': builder.member(ident('lessThan')) builder.call(builder => { - builder.number(parseInt(assertion.target, 10)) + builder.number(parse(assertion.target)) }) break case 'GREATER_THAN': builder.member(ident('greaterThan')) builder.call(builder => { - builder.number(parseInt(assertion.target, 10)) + builder.number(parse(assertion.target)) }) break default: @@ -63,16 +71,26 @@ export function valueForNumericAssertion ( }) } -// Emits an SSL boolean-source assertion: `Builder.method().equals(true|false)`. -// The four boolean SSL sources only support EQUALS and carry no property/regex. +export interface ValueForBooleanAssertionOptions { + hasProperty?: boolean +} + +// Emits a boolean-target assertion: `Builder.method([property]).equals(true|false)`. +// Boolean targets only support EQUALS and carry no regex. export function valueForBooleanAssertion ( klass: string, method: string, assertion: Assertion, + options?: ValueForBooleanAssertionOptions, ): Value { return expr(ident(klass), builder => { builder.member(ident(method)) - builder.call(() => {}) + builder.call(builder => { + const hasProperty = options?.hasProperty ?? false + if (hasProperty && assertion.property !== '') { + builder.string(assertion.property) + } + }) switch (assertion.comparison) { case 'EQUALS': builder.member(ident('equals')) @@ -89,10 +107,6 @@ export function valueForBooleanAssertion ( export interface ValueForGeneralAssertionOptions { hasProperty?: boolean hasRegex?: boolean - // When set, a `MATCHES` comparison emits `.matches(target)`. Only the SSL string - // sources (CIPHER_SUITE/ISSUER_CN/SIGNATURE_ALGORITHM) accept MATCHES; every other - // caller leaves it off, so MATCHES stays a `default: throw` for them. - hasMatches?: boolean } export function valueForGeneralAssertion ( @@ -199,15 +213,6 @@ export function valueForGeneralAssertion ( builder.empty() }) break - case 'MATCHES': - if (!options?.hasMatches) { - throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`) - } - builder.member(ident('matches')) - builder.call(builder => { - builder.string(assertion.target) - }) - break default: throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`) } diff --git a/packages/cli/src/constructs/internal/assertion-grammar.ts b/packages/cli/src/constructs/internal/assertion-grammar.ts new file mode 100644 index 000000000..f7494978a --- /dev/null +++ b/packages/cli/src/constructs/internal/assertion-grammar.ts @@ -0,0 +1,141 @@ +import { Assertion, GeneralAssertionBuilder, toAssertion } from './assertion.js' + +// A property-scoped assertion grammar: one declaration per property states the operators +// it exposes and the type of its target. The builder surface, the validation whitelist, +// and codegen's literal kind are all derived from it, so a property's grammar lives in one +// place instead of being restated in a builder class, a value-type table, and a whitelist. +// +// This module is monitor-agnostic; each monitor supplies its own grammar tables. + +// The operator catalog: builder method name -> wire comparison. Shared by every monitor +// grammar; adding an operator is one line here plus listing it on the properties that +// accept it. +export const operatorComparisons = { + equals: 'EQUALS', + notEquals: 'NOT_EQUALS', + greaterThan: 'GREATER_THAN', + lessThan: 'LESS_THAN', + contains: 'CONTAINS', + notContains: 'NOT_CONTAINS', +} as const + +export type OperatorName = keyof typeof operatorComparisons + +// How a target value is written on the wire. Drives validation's target-value check and +// codegen's choice of a bare literal versus a quoted string. +export type TargetValueType = 'number' | 'boolean' | 'string' + +// A target spec carries the runtime value kind and, through a phantom type parameter, the +// compile-time target type. The symbol member is optional and never assigned, so a spec is +// just `{ kind }` at runtime. +// +// The grammar constraint below uses the phantom-free supertype `AnyTargetSpec`, never +// `TargetSpec`: a `TargetSpec` constraint would contextually type a bare +// `stringTarget()` call, and contextual inference (`T = any`) would beat the `= string` +// default — silently widening a plain-string property's target to `any`. With no phantom +// member in the constraint there is no inference candidate, so the default holds. +declare const targetType: unique symbol + +export interface AnyTargetSpec { + readonly kind: TargetValueType +} + +export interface TargetSpec extends AnyTargetSpec { + readonly [targetType]?: T +} + +export function numberTarget (): TargetSpec { + return { kind: 'number' } +} + +export function booleanTarget (): TargetSpec { + return { kind: 'boolean' } +} + +export function stringTarget (): TargetSpec { + return { kind: 'string' } +} + +export interface PropertyGrammar { + readonly operators: readonly OperatorName[] + readonly target: AnyTargetSpec +} + +// `const G` keeps each row's operator array a literal tuple without callers writing +// `as const`, so `keyof typeof grammar` and the per-property operator sets stay precise. +export function defineGrammar> (grammar: G): G { + return grammar +} + +type TargetOf = Spec extends TargetSpec ? T : never + +// The builder surface for one property: each declared operator becomes a method taking the +// property's target type. Distributing over a union `Decl` means a builder created from a +// union property exposes only the operators common to every arm. +export type PropertyOperators = + Decl extends PropertyGrammar + ? { [Op in Decl['operators'][number]]: (target: TargetOf) => Assertion } + : never + +// Builds the operator object for a known property by looping over its declared operators. +// The cast is the one unavoidable bridge in the design: TypeScript cannot connect a runtime +// loop to a mapped type. It is sound because the loop defines exactly the keys in +// `decl.operators`, each routed through `operatorComparisons` — the same data +// `PropertyOperators` is computed from. +function makeOperators ( + source: Source, + property: string, + decl: Decl, +): PropertyOperators { + const operators: Record Assertion> = {} + for (const operator of decl.operators) { + operators[operator] = target => toAssertion(source, operatorComparisons[operator], target, property) + } + return operators as PropertyOperators +} + +// Resolves the operators for a property of a grammar. +// +// The typed signature makes an unknown property a compile error, but plain JavaScript +// callers and object-literal assertions still reach this at runtime with an arbitrary +// string. Those fall back to an unconstrained builder so the call returns something usable; +// the property is then reported by the monitor's validation at deploy time rather than +// throwing here. +export function operatorsForProperty< + Source extends string, + Grammar extends Record, + Property extends keyof Grammar & string, +> ( + grammar: Grammar, + source: Source, + property: Property, +): PropertyOperators { + if (!Object.hasOwn(grammar, property)) { + const fallback = new GeneralAssertionBuilder(source, property) + return fallback as unknown as PropertyOperators + } + return makeOperators(source, property, grammar[property]) +} + +// Resolves the operators for a source-scoped grammar — a single declaration with no +// property, for sources whose value is the source itself (e.g. traceroute HOP_COUNT). The +// property slot is emitted empty, matching the wire shape those sources carry. +export function operatorsForSource ( + source: Source, + decl: Decl, +): PropertyOperators { + return makeOperators(source, '', decl) +} + +// Runtime derivations shared by validation and codegen, so both read the same grammar the +// builder is generated from. + +// The wire comparisons a property accepts, from its declared operators. +export function comparisonsForGrammar (decl: PropertyGrammar): Record { + return Object.fromEntries(decl.operators.map(operator => [operatorComparisons[operator], true])) +} + +// How a property's target is written on the wire. +export function valueTypeForGrammar (decl: PropertyGrammar): TargetValueType { + return decl.target.kind +} diff --git a/packages/cli/src/constructs/internal/assertion.ts b/packages/cli/src/constructs/internal/assertion.ts index d5f2e62b0..7a2f20099 100644 --- a/packages/cli/src/constructs/internal/assertion.ts +++ b/packages/cli/src/constructs/internal/assertion.ts @@ -7,8 +7,8 @@ export interface Assertion { } // Builds an assertion payload from its parts. `comparison` is a free type parameter -// so any operator string (including SSL's `MATCHES`) is accepted without a central -// union. Empty property, stringified target, and null regex are the wire defaults. +// so any operator string is accepted without a central union. Empty property, +// stringified target, and null regex are the wire defaults. export function toAssertion< Source extends string, Comparison extends string, diff --git a/packages/cli/src/constructs/ssl-assertion-codegen.ts b/packages/cli/src/constructs/ssl-assertion-codegen.ts index 92083a6fa..2e8104b1c 100644 --- a/packages/cli/src/constructs/ssl-assertion-codegen.ts +++ b/packages/cli/src/constructs/ssl-assertion-codegen.ts @@ -5,40 +5,84 @@ import { valueForGeneralAssertion, valueForNumericAssertion, } from './internal/assertion-codegen.js' +import { isSslNumericTarget, sslPropertyValueType } from './ssl-assertion-grammar.js' import { SslAssertion } from './ssl-assertion.js' -const generalNoArgs = { hasProperty: false, hasRegex: false } -// The string sources additionally accept the MATCHES (regex) operator. -const generalWithMatches = { hasProperty: false, hasRegex: false, hasMatches: true } +// Every SSL source addresses its value through the property slot — CERTIFICATE / +// CONNECTION use a field selector, JSON_RESPONSE a JSONPath, and TEXT_RESPONSE a +// regex (the backend and runner read the TEXT_RESPONSE pattern from `property`, +// not `regex`). The regex slot is never emitted. +const withProperty = { hasProperty: true, hasRegex: false } + +// The comparisons each typed helper can render. A comparison outside the set makes the +// helper throw, which would abort the whole import over a single assertion, so the +// dispatch below checks membership rather than relying on the throw. Exported so the +// grammar consistency spec can assert numeric properties declare only operators this +// renders, rather than restating the set. +export const numericComparisons: Record = { + EQUALS: true, NOT_EQUALS: true, GREATER_THAN: true, LESS_THAN: true, +} + +// The boolean helper renders only EQUALS as a bare boolean literal; any other comparison +// falls through to the quoted-string form, which would not compile against a boolean +// operator. The grammar spec asserts boolean properties declare only this. +export const booleanComparisons: Record = { + EQUALS: true, +} + +// The operators for a numeric or boolean property take a `number` / `boolean`, so their +// targets must be emitted as bare literals rather than quoted strings — otherwise the +// generated code does not compile. +// +// Each typed path is guarded on the assertion actually fitting it, because remote data is +// not bound by the builder's types: the backend accepts any target string, so a monitor +// created via the UI or API can carry `selfSigned = 'yes'` or `keySizeBits = ''`. Coercing +// those would silently rewrite the assertion on the next deploy ('yes' would become +// `false`), so they fall through to the string form, which round-trips the value untouched +// and is reported by validateSslAssertion instead. +function valueForPropertyScopedAssertion (method: string, assertion: SslAssertion): Value { + const valueType = sslPropertyValueType(assertion.source, assertion.property) + + // Rendered with Number, not the helper's default parseInt: isSslNumericTarget has + // already established the whole target is a number, and parseInt would truncate '30.5' + // to 30 — a silently different assertion on the next deploy. Sharing the predicate with + // validateSslAssertion is what keeps the two honest: every target validation accepts is + // rendered as a numeric literal the property's operators take, so no assertion the CLI + // calls valid is emitted as code that does not compile. + const isRenderableNumber = valueType === 'number' + && Object.hasOwn(numericComparisons, assertion.comparison) + && isSslNumericTarget(assertion.target) + if (isRenderableNumber) { + return valueForNumericAssertion('SslAssertionBuilder', method, assertion, { + hasProperty: true, + parse: Number, + }) + } + + const isRenderableBoolean = valueType === 'boolean' + && Object.hasOwn(booleanComparisons, assertion.comparison) + && (assertion.target === 'true' || assertion.target === 'false') + if (isRenderableBoolean) { + return valueForBooleanAssertion('SslAssertionBuilder', method, assertion, { hasProperty: true }) + } + + return valueForGeneralAssertion('SslAssertionBuilder', method, assertion, withProperty) +} export function valueForSslAssertion (genfile: GeneratedFile, assertion: SslAssertion): Value { genfile.namedImport('SslAssertionBuilder', 'checkly/constructs') switch (assertion.source) { - case 'CERT_EXPIRES_IN_DAYS': - return valueForNumericAssertion('SslAssertionBuilder', 'certExpiresInDays', assertion) - case 'KEY_SIZE_BITS': - return valueForNumericAssertion('SslAssertionBuilder', 'keySizeBits', assertion) - case 'CERT_NOT_EXPIRED': - return valueForBooleanAssertion('SslAssertionBuilder', 'certNotExpired', assertion) - case 'HOSTNAME_VERIFIED': - return valueForBooleanAssertion('SslAssertionBuilder', 'hostnameVerified', assertion) - case 'CHAIN_TRUSTED': - return valueForBooleanAssertion('SslAssertionBuilder', 'chainTrusted', assertion) - case 'OCSP_STAPLED': - return valueForBooleanAssertion('SslAssertionBuilder', 'ocspStapled', assertion) - case 'TLS_VERSION': - return valueForGeneralAssertion('SslAssertionBuilder', 'tlsVersion', assertion, generalNoArgs) - case 'CIPHER_SUITE': - return valueForGeneralAssertion('SslAssertionBuilder', 'cipherSuite', assertion, generalWithMatches) - case 'ISSUER_CN': - return valueForGeneralAssertion('SslAssertionBuilder', 'issuerCn', assertion, generalWithMatches) - case 'CERT_FINGERPRINT_SHA256': - return valueForGeneralAssertion('SslAssertionBuilder', 'certFingerprintSha256', assertion, generalNoArgs) - case 'ISSUER_FINGERPRINT_SHA256': - return valueForGeneralAssertion('SslAssertionBuilder', 'issuerFingerprintSha256', assertion, generalNoArgs) - case 'SIGNATURE_ALGORITHM': - return valueForGeneralAssertion('SslAssertionBuilder', 'signatureAlgorithm', assertion, generalWithMatches) + case 'CERTIFICATE': + return valueForPropertyScopedAssertion('certificate', assertion) + case 'CONNECTION': + return valueForPropertyScopedAssertion('connection', assertion) + case 'JSON_RESPONSE': + return valueForGeneralAssertion('SslAssertionBuilder', 'jsonResponse', assertion, withProperty) + case 'TEXT_RESPONSE': + return valueForGeneralAssertion('SslAssertionBuilder', 'textResponse', assertion, withProperty) + case 'RESPONSE_TIME': + return valueForNumericAssertion('SslAssertionBuilder', 'responseTime', assertion) default: return unsupportedAssertionSource(assertion.source, 'SSL') } diff --git a/packages/cli/src/constructs/ssl-assertion-grammar.ts b/packages/cli/src/constructs/ssl-assertion-grammar.ts new file mode 100644 index 000000000..5b9ebc4aa --- /dev/null +++ b/packages/cli/src/constructs/ssl-assertion-grammar.ts @@ -0,0 +1,109 @@ +import { + PropertyGrammar, + TargetValueType, + booleanTarget, + comparisonsForGrammar, + defineGrammar, + numberTarget, + stringTarget, + valueTypeForGrammar, +} from './internal/assertion-grammar.js' +import type { SignatureAlgorithmValue, TlsVersionValue } from './ssl-assertion.js' + +// The single declaration of the SSL certificate/connection assertion grammar. Each row is +// a property's entire contract — the operators it exposes and the type of its target. The +// public `SslAssertionBuilder`, its property unions, the validation whitelist, and codegen +// all derive from these two tables, so a property cannot gain an operator the whitelist +// rejects, or a target type codegen renders wrong, without editing this one row. + +export const certificateGrammar = defineGrammar({ + /** Days until the certificate expires (numeric). */ + daysUntilExpiry: { operators: ['equals', 'notEquals', 'greaterThan', 'lessThan'], target: numberTarget() }, + /** Certificate key size in bits (numeric). */ + keySizeBits: { operators: ['equals', 'notEquals', 'greaterThan', 'lessThan'], target: numberTarget() }, + /** Certificate subject common name (free-form string). */ + subjectCN: { operators: ['equals', 'notEquals', 'contains', 'notContains'], target: stringTarget() }, + /** Certificate issuer common name (free-form string). */ + issuerCN: { operators: ['equals', 'notEquals', 'contains', 'notContains'], target: stringTarget() }, + /** Certificate serial number — an opaque identifier, compared whole. */ + serialNumber: { operators: ['equals', 'notEquals'], target: stringTarget() }, + /** Certificate SHA-256 fingerprint — an opaque identifier, compared whole. */ + fingerprintSha256: { operators: ['equals', 'notEquals'], target: stringTarget() }, + /** Issuer SHA-256 fingerprint — an opaque identifier, compared whole. */ + issuerFingerprintSha256: { operators: ['equals', 'notEquals'], target: stringTarget() }, + /** Certificate public key algorithm (e.g. `'RSA'`, `'ECDSA'`), compared whole. */ + keyAlgorithm: { operators: ['equals', 'notEquals'], target: stringTarget() }, + /** Certificate signature algorithm — a Go `x509 SignatureAlgorithm.String()` value. */ + signatureAlgorithm: { operators: ['equals', 'notEquals'], target: stringTarget() }, + /** Certificate subject alternative names. A list, so only membership can be asserted. */ + sans: { operators: ['contains', 'notContains'], target: stringTarget() }, + /** Whether the certificate is self-signed (boolean). */ + selfSigned: { operators: ['equals'], target: booleanTarget() }, + /** Whether the certificate is a CA certificate (boolean). */ + isCA: { operators: ['equals'], target: booleanTarget() }, +}) + +export const connectionGrammar = defineGrammar({ + /** Negotiated TLS version. Ordered, so `greaterThan` expresses a minimum. */ + tlsVersion: { operators: ['equals', 'notEquals', 'greaterThan', 'lessThan'], target: stringTarget() }, + /** Negotiated cipher suite (free-form string; hundreds of IANA names). */ + cipherSuite: { operators: ['equals', 'notEquals', 'contains', 'notContains'], target: stringTarget() }, + /** Whether the hostname is verified against the certificate (boolean). */ + hostnameVerified: { operators: ['equals'], target: booleanTarget() }, + /** Whether the certificate chain is trusted (boolean). */ + chainTrusted: { operators: ['equals'], target: booleanTarget() }, + /** Whether a stapled OCSP response was provided during the handshake (boolean). */ + ocspStapled: { operators: ['equals'], target: booleanTarget() }, + /** OCSP revocation status (e.g. `'good'`, `'revoked'`), compared whole. */ + ocspStatus: { operators: ['equals', 'notEquals'], target: stringTarget() }, + /** The IP address the hostname resolved to (free-form string). */ + resolvedIp: { operators: ['equals', 'notEquals', 'contains', 'notContains'], target: stringTarget() }, +}) + +const grammarBySource: Record> = { + CERTIFICATE: certificateGrammar, + CONNECTION: connectionGrammar, +} + +function propertyGrammar (source: string, property: string): PropertyGrammar | undefined { + const grammar = Object.hasOwn(grammarBySource, source) ? grammarBySource[source] : undefined + if (grammar === undefined || !Object.hasOwn(grammar, property)) { + return undefined + } + return grammar[property] +} + +/** + * The wire comparisons each property of a source accepts, keyed by property name. Empty for + * a source with no property-scoped grammar. Used by validation to reject an unsupported + * comparison written as an object literal. + */ +export function sslComparisonsForSource (source: string): Record> { + const grammar = Object.hasOwn(grammarBySource, source) ? grammarBySource[source] : {} + return Object.fromEntries( + Object.entries(grammar).map(([property, decl]) => [property, comparisonsForGrammar(decl)]), + ) +} + +/** + * How the target of a property-scoped SSL assertion is written, or `undefined` for a + * property the backend does not define. Callers must handle the unknown case: object + * literals bypass the builder's compile-time check and are reported by + * `validateSslAssertion` instead. + */ +export function sslPropertyValueType (source: string, property: string): TargetValueType | undefined { + const decl = propertyGrammar(source, property) + return decl === undefined ? undefined : valueTypeForGrammar(decl) +} + +/** + * Whether a target is one this CLI treats as a number. + * + * Validation and codegen must agree: anything validation accepts, codegen has to render as + * a bare numeric literal, because the property's operators take a `number` and a quoted + * target would not compile. Targets are plain strings on the wire and reach here from object + * literals and remote monitors, so this is a runtime predicate rather than a type. + */ +export function isSslNumericTarget (target: string): boolean { + return target.trim() !== '' && Number.isFinite(Number(target)) +} diff --git a/packages/cli/src/constructs/ssl-assertion-validation.ts b/packages/cli/src/constructs/ssl-assertion-validation.ts index d5668680f..fae2bf4fc 100644 --- a/packages/cli/src/constructs/ssl-assertion-validation.ts +++ b/packages/cli/src/constructs/ssl-assertion-validation.ts @@ -1,46 +1,121 @@ import { Diagnostics } from './diagnostics.js' import { addAssertionDiagnostic, quotedKeys } from './internal/assertion-validation.js' +import { TargetValueType } from './internal/assertion-grammar.js' +import { isSslNumericTarget, sslComparisonsForSource, sslPropertyValueType } from './ssl-assertion-grammar.js' import { SslAssertion } from './ssl-assertion.js' -type SslSourceRule = { - comparisons: Record - // Boolean sources compare against 'true'/'false' only; the backend evaluates any - // other target as a permanent non-match. Target *values* are validated only for - // boolean sources: booleans are a universal two-value set, so a typo is a pure - // footgun. The larger closed sets (TLS_VERSION, SIGNATURE_ALGORITHM) are constrained - // by the builder's value types instead; the backend accepts any target string (it is - // not rejected at deploy), so a hand-written out-of-set literal simply never matches - // at evaluation, and re-validating those here would duplicate the value unions. - booleanTarget?: boolean -} +// The comparisons the backend accepts. The `>=` operator and the regex operator are both +// dropped for SSL, so neither appears in any set. +type Comparisons = Record + +const NUMBER: Comparisons = { EQUALS: true, NOT_EQUALS: true, GREATER_THAN: true, LESS_THAN: true } -// The operators the backend accepts per source, minus the CLI-dropped -// GREATER_THAN_OR_EQUAL. Keyed by the source union so adding a source without a rule -// fails to compile. +// CERTIFICATE and CONNECTION are property-scoped: the allowed comparisons depend on the +// property, and an unknown property is itself an error. Their whitelists are derived from +// the grammar tables the builder is generated from (in internal/ssl-grammar.ts), so a +// property cannot expose an operator the whitelist rejects. RESPONSE_TIME, JSON_RESPONSE +// and TEXT_RESPONSE carry no whitelisted property (the property slot is a numeric +// placeholder / JSONPath / regex), so their comparisons are keyed by source. +type SslSourceRule = + | { properties: Record } + | { comparisons: Comparisons, valueType?: TargetValueType } + +// Keyed by the source union so adding a source without a rule fails to compile. const rules: Record = { - CERT_EXPIRES_IN_DAYS: { comparisons: { EQUALS: true, NOT_EQUALS: true, GREATER_THAN: true, LESS_THAN: true } }, - KEY_SIZE_BITS: { comparisons: { EQUALS: true } }, - CERT_NOT_EXPIRED: { comparisons: { EQUALS: true }, booleanTarget: true }, - HOSTNAME_VERIFIED: { comparisons: { EQUALS: true }, booleanTarget: true }, - CHAIN_TRUSTED: { comparisons: { EQUALS: true }, booleanTarget: true }, - OCSP_STAPLED: { comparisons: { EQUALS: true }, booleanTarget: true }, - TLS_VERSION: { comparisons: { EQUALS: true } }, - SIGNATURE_ALGORITHM: { comparisons: { EQUALS: true, MATCHES: true } }, - CIPHER_SUITE: { comparisons: { EQUALS: true, NOT_EQUALS: true, MATCHES: true } }, - ISSUER_CN: { comparisons: { EQUALS: true, NOT_EQUALS: true, MATCHES: true } }, - CERT_FINGERPRINT_SHA256: { comparisons: { EQUALS: true } }, - ISSUER_FINGERPRINT_SHA256: { comparisons: { EQUALS: true } }, + CERTIFICATE: { properties: sslComparisonsForSource('CERTIFICATE') }, + CONNECTION: { properties: sslComparisonsForSource('CONNECTION') }, + RESPONSE_TIME: { comparisons: NUMBER, valueType: 'number' }, + JSON_RESPONSE: { + comparisons: { + EQUALS: true, + NOT_EQUALS: true, + IS_EMPTY: true, + NOT_EMPTY: true, + GREATER_THAN: true, + LESS_THAN: true, + CONTAINS: true, + NOT_CONTAINS: true, + IS_NULL: true, + NOT_NULL: true, + }, + }, + TEXT_RESPONSE: { + comparisons: { + EQUALS: true, + NOT_EQUALS: true, + IS_EMPTY: true, + NOT_EMPTY: true, + CONTAINS: true, + NOT_CONTAINS: true, + GREATER_THAN: true, + LESS_THAN: true, + }, + }, +} + +function isPropertyScoped (rule: SslSourceRule): rule is { properties: Record } { + return 'properties' in rule +} + +function formatValue (value: string): string { + return value === '' ? '(none)' : `"${value}"` +} + +/** + * Reports a target that is not of the value type its source or property expects. + * + * Target *values* are checked where the accepted set is universal — booleans are two + * values, numbers are a total predicate — so a typo is a pure footgun the backend will not + * catch: it accepts any target string at deploy and simply never matches at evaluation, + * leaving an assertion that looks configured but silently never fires. + * + * The closed *enumerated* sets (tlsVersion, signatureAlgorithm) are deliberately not + * checked: the builder's typed operators already constrain them, and restating their + * members here would duplicate the value unions. + */ +function validateTargetValue ( + diagnostics: Diagnostics, + assertion: SslAssertion, + location: string, + valueType: TargetValueType | undefined, + subject: string, +): void { + // `target` is declared a string, but object-literal assertions are not typechecked when + // a check file is loaded, so a hand-written `target: 30` or an omitted target reaches + // here as a non-string. Report it rather than dereferencing it — a TypeError out of + // validate() would abort the command with no location or message. + const target: unknown = assertion.target + if (typeof target !== 'string') { + if (valueType !== undefined) { + addAssertionDiagnostic(diagnostics, + `The ${subject} assertion at "${location}" must compare against a ${valueType} written as a string, ` + + `but got ${JSON.stringify(target) ?? String(target)}.`) + } + return + } + + if (valueType === 'boolean' && target !== 'true' && target !== 'false') { + addAssertionDiagnostic(diagnostics, + `The ${subject} assertion at "${location}" must compare against "true" or "false", ` + + `but got "${target}".`) + } + + if (valueType === 'number' && !isSslNumericTarget(target)) { + addAssertionDiagnostic(diagnostics, + `The ${subject} assertion at "${location}" must compare against a number, ` + + `but got ${formatValue(target)}.`) + } } /** - * Reports SSL assertions whose source, comparison or boolean target the backend does - * not accept. + * Reports SSL assertions whose source, property, comparison or boolean target the + * backend does not accept. * * Assertions written as plain object literals bypass SslAssertionBuilder and are - * type-legal, because the fields are declared as plain strings. The backend rejects - * an unknown source or an operator not allowed for the source with a 400; a boolean - * source with a non-`true`/`false` target is accepted at deploy but never matches at - * evaluation, so it is reported too. + * type-legal, because the fields are declared as plain strings. The backend rejects an + * unknown source, an unknown property or an operator not allowed for the property with + * a 400; a boolean or numeric property whose target is not of that type is accepted at + * deploy but never matches at evaluation, so it is reported too. */ export function validateSslAssertion ( diagnostics: Diagnostics, @@ -59,16 +134,37 @@ export function validateSslAssertion ( return } - if (!Object.hasOwn(rule.comparisons, assertion.comparison)) { + if (!isPropertyScoped(rule)) { + if (!Object.hasOwn(rule.comparisons, assertion.comparison)) { + addAssertionDiagnostic(diagnostics, + `The ${assertion.source} assertion at "${location}" has an unsupported comparison ` + + `${formatValue(assertion.comparison)}. Expected one of ${quotedKeys(rule.comparisons)}.`) + } + validateTargetValue(diagnostics, assertion, location, rule.valueType, assertion.source) + return + } + + const comparisons = Object.hasOwn(rule.properties, assertion.property) + ? rule.properties[assertion.property] + : undefined + if (comparisons === undefined) { addAssertionDiagnostic(diagnostics, - `The ${assertion.source} assertion at "${location}" has an unsupported comparison ` - + `${assertion.comparison === '' ? '(none)' : `"${assertion.comparison}"`}. ` - + `Expected one of ${quotedKeys(rule.comparisons)}.`) + `The ${assertion.source} assertion at "${location}" has an unknown property ` + + `${formatValue(assertion.property)}. Expected one of ${quotedKeys(rule.properties)}.`) + return } - if (rule.booleanTarget && assertion.target !== 'true' && assertion.target !== 'false') { + if (!Object.hasOwn(comparisons, assertion.comparison)) { addAssertionDiagnostic(diagnostics, - `The ${assertion.source} assertion at "${location}" must compare against "true" or "false", ` - + `but got "${assertion.target}".`) + `The ${assertion.source} "${assertion.property}" assertion at "${location}" has an unsupported comparison ` + + `${formatValue(assertion.comparison)}. Expected one of ${quotedKeys(comparisons)}.`) } + + validateTargetValue( + diagnostics, + assertion, + location, + sslPropertyValueType(assertion.source, assertion.property), + `${assertion.source} "${assertion.property}"`, + ) } diff --git a/packages/cli/src/constructs/ssl-assertion.ts b/packages/cli/src/constructs/ssl-assertion.ts index 5752c08cd..f06fa800b 100644 --- a/packages/cli/src/constructs/ssl-assertion.ts +++ b/packages/cli/src/constructs/ssl-assertion.ts @@ -1,11 +1,18 @@ -import { Assertion as CoreAssertion, toAssertion } from './internal/assertion.js' +import { + Assertion as CoreAssertion, + GeneralAssertionBuilder, + NumericAssertionBuilder, +} from './internal/assertion.js' +import { PropertyOperators, operatorsForProperty } from './internal/assertion-grammar.js' +import { certificateGrammar, connectionGrammar } from './ssl-assertion-grammar.js' /** - * Known TLS protocol versions for use with {@link SslAssertionBuilder.tlsVersion}. + * Known TLS protocol versions for use as a target of the `tlsVersion` property on + * the {@link SslAssertionBuilder.connection} builder. * * @example * ```typescript - * SslAssertionBuilder.tlsVersion().equals(TlsVersion.TLS1_3) + * SslAssertionBuilder.connection('tlsVersion').equals(TlsVersion.TLS1_3) * ``` */ export const TlsVersion = { @@ -20,11 +27,12 @@ export type TlsVersionValue = (typeof TlsVersion)[keyof typeof TlsVersion] /** * Signature algorithms as reported by Go's `x509.Certificate.SignatureAlgorithm.String()`. * These are the exact values the SSL runner evaluates assertions against — use these - * constants (or the string literals they represent) with {@link SslAssertionBuilder.signatureAlgorithm}. + * constants (or the string literals they represent) as a target of the + * `signatureAlgorithm` property on the {@link SslAssertionBuilder.certificate} builder. * * @example * ```typescript - * SslAssertionBuilder.signatureAlgorithm().equals(SignatureAlgorithm.SHA256_RSA) + * SslAssertionBuilder.certificate('signatureAlgorithm').equals(SignatureAlgorithm.SHA256_RSA) * ``` */ export const SignatureAlgorithm = { @@ -53,14 +61,14 @@ export const SignatureAlgorithm = { export type SignatureAlgorithmValue = (typeof SignatureAlgorithm)[keyof typeof SignatureAlgorithm] /** - * Commonly-used IANA cipher suite names for use with - * {@link SslAssertionBuilder.cipherSuite}. Includes TLS 1.3 suites and - * widely-deployed TLS 1.2 suites. + * Commonly-used IANA cipher suite names for use as a target of the `cipherSuite` + * property on the {@link SslAssertionBuilder.connection} builder. Includes TLS 1.3 + * suites and widely-deployed TLS 1.2 suites. * * @example * ```typescript - * SslAssertionBuilder.cipherSuite().equals(CipherSuite.TLS_AES_256_GCM_SHA384) - * SslAssertionBuilder.cipherSuite().equals(CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) + * SslAssertionBuilder.connection('cipherSuite').equals(CipherSuite.TLS_AES_256_GCM_SHA384) + * SslAssertionBuilder.connection('cipherSuite').equals(CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) * ``` */ export const CipherSuite = { @@ -86,239 +94,102 @@ export const CipherSuite = { export type CipherSuiteValue = (typeof CipherSuite)[keyof typeof CipherSuite] +// Property-scoped SSL assertion sources. Certificate and connection facts are +// addressed by a `property` name (e.g. 'daysUntilExpiry', 'tlsVersion'); the +// remaining sources mirror the API check grammar. type SslAssertionSource = - | 'CERT_EXPIRES_IN_DAYS' - | 'CERT_NOT_EXPIRED' - | 'HOSTNAME_VERIFIED' - | 'CHAIN_TRUSTED' - | 'TLS_VERSION' - | 'CIPHER_SUITE' - | 'ISSUER_CN' - | 'CERT_FINGERPRINT_SHA256' - | 'ISSUER_FINGERPRINT_SHA256' - | 'KEY_SIZE_BITS' - | 'SIGNATURE_ALGORITHM' - | 'OCSP_STAPLED' + | 'CERTIFICATE' + | 'CONNECTION' + | 'RESPONSE_TIME' + | 'JSON_RESPONSE' + | 'TEXT_RESPONSE' export type SslAssertion = CoreAssertion -// One builder class per SSL source, each exposing only the operators (and value -// type) the backend accepts for that source. The classes are stateless — the source -// is baked into each `toAssertion` call — and are not exported: they are reachable -// only through `SslAssertionBuilder`, so they stay out of the package's public API. +// The certificate and connection assertion grammar is declared once, as data, in +// internal/ssl-grammar.ts: each property's row states the operators it exposes and its +// target type. The property unions below and the per-property builders returned by +// SslAssertionBuilder are derived from those tables, and validation and codegen read the +// same tables — so a property's grammar lives in one place, not four. -/** Days until the certificate expires (numeric). */ -class CertExpiresInDaysAssertionBuilder { - equals (target: number): SslAssertion { - return toAssertion('CERT_EXPIRES_IN_DAYS', 'EQUALS', target) - } - - notEquals (target: number): SslAssertion { - return toAssertion('CERT_EXPIRES_IN_DAYS', 'NOT_EQUALS', target) - } - - lessThan (target: number): SslAssertion { - return toAssertion('CERT_EXPIRES_IN_DAYS', 'LESS_THAN', target) - } - - greaterThan (target: number): SslAssertion { - return toAssertion('CERT_EXPIRES_IN_DAYS', 'GREATER_THAN', target) - } -} - -/** Certificate key size in bits (numeric, exact match only). */ -class KeySizeBitsAssertionBuilder { - equals (target: number): SslAssertion { - return toAssertion('KEY_SIZE_BITS', 'EQUALS', target) - } -} - -/** Whether the certificate is not expired (boolean). */ -class CertNotExpiredAssertionBuilder { - equals (target: boolean): SslAssertion { - return toAssertion('CERT_NOT_EXPIRED', 'EQUALS', target) - } -} - -/** Whether the hostname is verified (boolean). */ -class HostnameVerifiedAssertionBuilder { - equals (target: boolean): SslAssertion { - return toAssertion('HOSTNAME_VERIFIED', 'EQUALS', target) - } -} - -/** Whether the certificate chain is trusted (boolean). */ -class ChainTrustedAssertionBuilder { - equals (target: boolean): SslAssertion { - return toAssertion('CHAIN_TRUSTED', 'EQUALS', target) - } -} - -/** Whether a stapled OCSP response was provided during the handshake (boolean). */ -class OcspStapledAssertionBuilder { - equals (target: boolean): SslAssertion { - return toAssertion('OCSP_STAPLED', 'EQUALS', target) - } -} - -/** Negotiated TLS version — `.equals()` accepts only known TLS version strings. */ -class TlsVersionAssertionBuilder { - equals (target: TlsVersionValue): SslAssertion { - return toAssertion('TLS_VERSION', 'EQUALS', target) - } -} - -/** - * Certificate signature algorithm. `.equals()` takes a Go - * `x509.Certificate.SignatureAlgorithm.String()` value; `.matches()` takes a regex. - */ -class SignatureAlgorithmAssertionBuilder { - equals (target: SignatureAlgorithmValue): SslAssertion { - return toAssertion('SIGNATURE_ALGORITHM', 'EQUALS', target) - } - - matches (target: string): SslAssertion { - return toAssertion('SIGNATURE_ALGORITHM', 'MATCHES', target) - } -} - -/** Negotiated cipher suite (string) — exact, not-equal, or regex match. */ -class CipherSuiteAssertionBuilder { - equals (target: string): SslAssertion { - return toAssertion('CIPHER_SUITE', 'EQUALS', target) - } - - notEquals (target: string): SslAssertion { - return toAssertion('CIPHER_SUITE', 'NOT_EQUALS', target) - } - - matches (target: string): SslAssertion { - return toAssertion('CIPHER_SUITE', 'MATCHES', target) - } -} - -/** Certificate issuer common name (string) — exact, not-equal, or regex match. */ -class IssuerCnAssertionBuilder { - equals (target: string): SslAssertion { - return toAssertion('ISSUER_CN', 'EQUALS', target) - } - - notEquals (target: string): SslAssertion { - return toAssertion('ISSUER_CN', 'NOT_EQUALS', target) - } - - matches (target: string): SslAssertion { - return toAssertion('ISSUER_CN', 'MATCHES', target) - } -} +/** The certificate properties an assertion can be made against. */ +export type SslCertificateProperty = keyof typeof certificateGrammar -/** Certificate SHA-256 fingerprint (string, exact match only). */ -class CertFingerprintSha256AssertionBuilder { - equals (target: string): SslAssertion { - return toAssertion('CERT_FINGERPRINT_SHA256', 'EQUALS', target) - } -} - -/** Issuer SHA-256 fingerprint (string, exact match only). */ -class IssuerFingerprintSha256AssertionBuilder { - equals (target: string): SslAssertion { - return toAssertion('ISSUER_FINGERPRINT_SHA256', 'EQUALS', target) - } -} +/** The connection properties an assertion can be made against. */ +export type SslConnectionProperty = keyof typeof connectionGrammar /** * Builder class for creating SSL monitor assertions. - * Provides methods to create assertions for TLS certificates. + * + * Assertions are property-scoped: {@link certificate} and {@link connection} take the + * name of a certificate/connection property, {@link jsonResponse} takes a JSONPath and + * {@link textResponse} an optional regex. The comparison operators the backend accepts + * depend on the property's value type and are validated at deploy time. * * @example * ```typescript - * // Alert when the certificate is within 30 days of expiry - * SslAssertionBuilder.certExpiresInDays().greaterThan(30) - * - * // Require a trusted chain and a verified hostname - * SslAssertionBuilder.chainTrusted().equals(true) - * SslAssertionBuilder.hostnameVerified().equals(true) + * // Certificate facts, addressed by property name + * SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30) + * SslAssertionBuilder.certificate('issuerCN').contains("Let's Encrypt") + * SslAssertionBuilder.certificate('signatureAlgorithm').equals(SignatureAlgorithm.SHA256_RSA) + * SslAssertionBuilder.certificate('selfSigned').equals(false) * - * // Enforce a specific TLS version and key size - * SslAssertionBuilder.tlsVersion().equals('TLS1.3') - * SslAssertionBuilder.keySizeBits().equals(2048) + * // Connection / handshake facts + * SslAssertionBuilder.connection('tlsVersion').equals(TlsVersion.TLS1_3) + * SslAssertionBuilder.connection('cipherSuite').equals(CipherSuite.TLS_AES_256_GCM_SHA384) + * SslAssertionBuilder.connection('chainTrusted').equals(true) * - * // Match the issuer or cipher suite against a regex - * SslAssertionBuilder.issuerCn().matches("^Let's Encrypt") - * SslAssertionBuilder.cipherSuite().matches('TLS_(AES|CHACHA)') + * // Response time, JSON and text responses + * SslAssertionBuilder.responseTime().lessThan(1000) + * SslAssertionBuilder.jsonResponse('$.status').equals('ok') + * SslAssertionBuilder.textResponse().contains('healthy') * ``` */ export class SslAssertionBuilder { - /** Assertion builder for the number of days until the certificate expires. */ - static certExpiresInDays () { - return new CertExpiresInDaysAssertionBuilder() - } - - /** Assertion builder for the certificate key size in bits. */ - static keySizeBits () { - return new KeySizeBitsAssertionBuilder() - } - - /** Assertion builder for whether the certificate is not expired. */ - static certNotExpired () { - return new CertNotExpiredAssertionBuilder() - } - - /** Assertion builder for whether the hostname is verified. */ - static hostnameVerified () { - return new HostnameVerifiedAssertionBuilder() - } - - /** Assertion builder for whether the certificate chain is trusted. */ - static chainTrusted () { - return new ChainTrustedAssertionBuilder() - } - /** - * Assertion builder for the negotiated TLS version. `.equals()` accepts only the - * known TLS version strings — use the {@link TlsVersion} constants or the string - * literals `'TLS1.0'`…`'TLS1.3'`. + * Creates an assertion builder for a certificate property. + * @param property The certificate property to assert on (e.g. `'daysUntilExpiry'`, + * `'issuerCN'`, `'signatureAlgorithm'`, `'sans'`, `'selfSigned'`). */ - static tlsVersion () { - return new TlsVersionAssertionBuilder() + static certificate ( + property: Property, + ): PropertyOperators<'CERTIFICATE', typeof certificateGrammar[Property]> { + return operatorsForProperty(certificateGrammar, 'CERTIFICATE', property) } /** - * Assertion builder for the negotiated cipher suite. Go's `tls.CipherSuiteName()` - * can return hundreds of IANA names plus `0x....` hex fallbacks, so `.equals()` is - * unconstrained; use the {@link CipherSuite} constants for common suites, or - * `.matches()` with a regex pattern. + * Creates an assertion builder for a connection property. + * @param property The connection property to assert on (e.g. `'tlsVersion'`, + * `'cipherSuite'`, `'hostnameVerified'`, `'ocspStatus'`, `'resolvedIp'`). */ - static cipherSuite () { - return new CipherSuiteAssertionBuilder() - } - - /** Assertion builder for the certificate issuer common name. */ - static issuerCn () { - return new IssuerCnAssertionBuilder() - } - - /** Assertion builder for the certificate SHA-256 fingerprint. */ - static certFingerprintSha256 () { - return new CertFingerprintSha256AssertionBuilder() + static connection ( + property: Property, + ): PropertyOperators<'CONNECTION', typeof connectionGrammar[Property]> { + return operatorsForProperty(connectionGrammar, 'CONNECTION', property) } - /** Assertion builder for the issuer SHA-256 fingerprint. */ - static issuerFingerprintSha256 () { - return new IssuerFingerprintSha256AssertionBuilder() + /** + * Creates an assertion builder for the TLS handshake response time in milliseconds. + */ + static responseTime () { + return new NumericAssertionBuilder('RESPONSE_TIME') } /** - * Assertion builder for the certificate signature algorithm. `.equals()` takes a - * Go `x509.Certificate.SignatureAlgorithm.String()` value (e.g. `'SHA256-RSA'`) — - * use the {@link SignatureAlgorithm} constants — or `.matches()` with a regex. + * Creates an assertion builder for a JSON response body. + * @param property Optional JSONPath to a specific value (e.g. `'$.status'`). */ - static signatureAlgorithm () { - return new SignatureAlgorithmAssertionBuilder() + static jsonResponse (property?: string) { + return new GeneralAssertionBuilder('JSON_RESPONSE', property) } - /** Assertion builder for whether a stapled OCSP response was provided. */ - static ocspStapled () { - return new OcspStapledAssertionBuilder() + /** + * Creates an assertion builder for a text response body. + * @param regex Optional regex pattern (with a capture group) used to extract the value + * to compare from the serialized response document. Carried in the assertion's + * `property` field — the slot the backend and runner read the pattern from. + */ + static textResponse (regex?: string) { + return new GeneralAssertionBuilder('TEXT_RESPONSE', regex) } } diff --git a/packages/cli/src/constructs/traceroute-assertion-grammar.ts b/packages/cli/src/constructs/traceroute-assertion-grammar.ts new file mode 100644 index 000000000..f66c99fc0 --- /dev/null +++ b/packages/cli/src/constructs/traceroute-assertion-grammar.ts @@ -0,0 +1,40 @@ +import { PropertyGrammar, comparisonsForGrammar, defineGrammar, numberTarget } from './internal/assertion-grammar.js' + +// The single declaration of the traceroute assertion grammar. RESPONSE_TIME is +// property-scoped over statistical properties, each taking the full numeric operator set. +// HOP_COUNT and PACKET_LOSS are single scalar sources whose backend grammar omits +// notEquals, so they exist as their own one-off declarations. The builder, its property +// union, and the validation whitelist all derive from these tables. + +export const tracerouteResponseTimeGrammar = defineGrammar({ + /** Average round-trip time in milliseconds. */ + avg: { operators: ['equals', 'notEquals', 'greaterThan', 'lessThan'], target: numberTarget() }, + /** Minimum round-trip time in milliseconds. */ + min: { operators: ['equals', 'notEquals', 'greaterThan', 'lessThan'], target: numberTarget() }, + /** Maximum round-trip time in milliseconds. */ + max: { operators: ['equals', 'notEquals', 'greaterThan', 'lessThan'], target: numberTarget() }, + /** Standard deviation of round-trip times. */ + stdDev: { operators: ['equals', 'notEquals', 'greaterThan', 'lessThan'], target: numberTarget() }, +}) + +/** Number of network hops. The backend omits notEquals. */ +export const hopCountGrammar = { + operators: ['equals', 'greaterThan', 'lessThan'], + target: numberTarget(), +} as const satisfies PropertyGrammar + +/** Packet loss percentage (0-100). The backend omits notEquals. */ +export const packetLossGrammar = { + operators: ['equals', 'greaterThan', 'lessThan'], + target: numberTarget(), +} as const satisfies PropertyGrammar + +// The wire comparisons each response-time property accepts, keyed by property. Derived here, +// beside the grammar (as SSL keeps its comparison derivation), so validation is a thin +// consumer and the two monitors resolve the same concept in the same place. Per-property +// rather than one shared set, so a property that later diverges is validated against its own +// row. +export const tracerouteResponseTimeComparisons: Record> = + Object.fromEntries( + Object.entries(tracerouteResponseTimeGrammar).map(([property, decl]) => [property, comparisonsForGrammar(decl)]), + ) diff --git a/packages/cli/src/constructs/traceroute-assertion-validation.ts b/packages/cli/src/constructs/traceroute-assertion-validation.ts index 7ab1c7cc0..5fdb2f064 100644 --- a/packages/cli/src/constructs/traceroute-assertion-validation.ts +++ b/packages/cli/src/constructs/traceroute-assertion-validation.ts @@ -1,16 +1,12 @@ import { Diagnostics } from './diagnostics.js' +import { comparisonsForGrammar } from './internal/assertion-grammar.js' import { addAssertionDiagnostic, quotedKeys } from './internal/assertion-validation.js' -import { TracerouteAssertion, TracerouteResponseTimeProperty } from './traceroute-assertion.js' - -// Runtime counterparts of unions that exist only at compile time. Typing them as a -// Record keyed by the union makes a missing or misspelled entry a compile-time error, -// so neither list can drift from the union it mirrors. -const responseTimeProperties: Record = { - avg: true, - min: true, - max: true, - stdDev: true, -} +import { TracerouteAssertion } from './traceroute-assertion.js' +import { + hopCountGrammar, + packetLossGrammar, + tracerouteResponseTimeComparisons, +} from './traceroute-assertion-grammar.js' const assertionSources: Record = { RESPONSE_TIME: true, @@ -18,20 +14,18 @@ const assertionSources: Record = { PACKET_LOSS: true, } -// Comparisons the backend accepts per source. RESPONSE_TIME additionally permits -// NOT_EQUALS; hop count and packet loss do not. Keep in sync with the backend. -const responseTimeComparisons: Record = { - EQUALS: true, - NOT_EQUALS: true, - GREATER_THAN: true, - LESS_THAN: true, -} +// Each source's comparison whitelist is derived from the grammar tables the builder is +// generated from, so validation cannot drift from what the builder produces. RESPONSE_TIME's +// per-property comparisons come from the grammar module; hop count and packet loss are +// single scalar sources (they omit notEquals), derived here. +const hopCountComparisons = comparisonsForGrammar(hopCountGrammar) +const packetLossComparisons = comparisonsForGrammar(packetLossGrammar) -const numericComparisons: Record = { - EQUALS: true, - GREATER_THAN: true, - LESS_THAN: true, -} +// The comparisons accepted by any response-time property, for validating the comparison of +// an assertion whose property is itself invalid (so a bad comparison is still reported +// alongside the bad property). +const anyResponseTimeComparison: Record = + Object.assign({}, ...Object.values(tracerouteResponseTimeComparisons)) /** * Reports traceroute assertions whose source, property or comparison the backend @@ -49,15 +43,19 @@ export function validateTracerouteAssertion ( const location = `request.assertions[${index}]` switch (assertion.source) { - case 'RESPONSE_TIME': - if (!Object.hasOwn(responseTimeProperties, assertion.property)) { + case 'RESPONSE_TIME': { + const propertyComparisons = Object.hasOwn(tracerouteResponseTimeComparisons, assertion.property) + ? tracerouteResponseTimeComparisons[assertion.property] + : undefined + if (propertyComparisons === undefined) { addAssertionDiagnostic(diagnostics, `The RESPONSE_TIME assertion at "${location}" has an invalid property ` + `${assertion.property === '' ? '(none)' : `"${assertion.property}"`}. ` - + `Expected one of ${quotedKeys(responseTimeProperties)}.`) + + `Expected one of ${quotedKeys(tracerouteResponseTimeComparisons)}.`) } - validateComparison(diagnostics, assertion, location, responseTimeComparisons) + validateComparison(diagnostics, assertion, location, propertyComparisons ?? anyResponseTimeComparison) break + } case 'HOP_COUNT': // falls through case 'PACKET_LOSS': @@ -66,7 +64,8 @@ export function validateTracerouteAssertion ( `The ${assertion.source} assertion at "${location}" must not specify a property, ` + `but got "${assertion.property}".`) } - validateComparison(diagnostics, assertion, location, numericComparisons) + validateComparison(diagnostics, assertion, location, + assertion.source === 'HOP_COUNT' ? hopCountComparisons : packetLossComparisons) break default: addAssertionDiagnostic(diagnostics, diff --git a/packages/cli/src/constructs/traceroute-assertion.ts b/packages/cli/src/constructs/traceroute-assertion.ts index e3e723686..44e9a3d19 100644 --- a/packages/cli/src/constructs/traceroute-assertion.ts +++ b/packages/cli/src/constructs/traceroute-assertion.ts @@ -1,4 +1,6 @@ -import { Assertion as CoreAssertion, toAssertion } from './internal/assertion.js' +import { Assertion as CoreAssertion } from './internal/assertion.js' +import { PropertyOperators, operatorsForProperty, operatorsForSource } from './internal/assertion-grammar.js' +import { hopCountGrammar, packetLossGrammar, tracerouteResponseTimeGrammar } from './traceroute-assertion-grammar.js' type TracerouteAssertionSource = | 'RESPONSE_TIME' @@ -7,62 +9,9 @@ type TracerouteAssertionSource = export type TracerouteAssertion = CoreAssertion -export type TracerouteResponseTimeProperty = 'avg' | 'min' | 'max' | 'stdDev' - -// One builder class per source, each exposing only the operators the backend accepts -// for that source. The classes are stateless — the source (and, for response time, the -// statistical property) is baked into each `toAssertion` call — and are not exported: -// they are reachable only through `TracerouteAssertionBuilder`. - -/** Response time for a statistical property — accepts the full numeric operator set. */ -class ResponseTimeAssertionBuilder { - constructor (private property: TracerouteResponseTimeProperty) {} - equals (target: number): TracerouteAssertion { - return toAssertion('RESPONSE_TIME', 'EQUALS', target, this.property) - } - - notEquals (target: number): TracerouteAssertion { - return toAssertion('RESPONSE_TIME', 'NOT_EQUALS', target, this.property) - } - - lessThan (target: number): TracerouteAssertion { - return toAssertion('RESPONSE_TIME', 'LESS_THAN', target, this.property) - } - - greaterThan (target: number): TracerouteAssertion { - return toAssertion('RESPONSE_TIME', 'GREATER_THAN', target, this.property) - } -} - -/** Number of network hops — accepts EQUALS / LESS_THAN / GREATER_THAN (not NOT_EQUALS). */ -class HopCountAssertionBuilder { - equals (target: number): TracerouteAssertion { - return toAssertion('HOP_COUNT', 'EQUALS', target) - } - - lessThan (target: number): TracerouteAssertion { - return toAssertion('HOP_COUNT', 'LESS_THAN', target) - } - - greaterThan (target: number): TracerouteAssertion { - return toAssertion('HOP_COUNT', 'GREATER_THAN', target) - } -} - -/** Packet loss percentage (0-100) — accepts EQUALS / LESS_THAN / GREATER_THAN (not NOT_EQUALS). */ -class PacketLossAssertionBuilder { - equals (target: number): TracerouteAssertion { - return toAssertion('PACKET_LOSS', 'EQUALS', target) - } - - lessThan (target: number): TracerouteAssertion { - return toAssertion('PACKET_LOSS', 'LESS_THAN', target) - } - - greaterThan (target: number): TracerouteAssertion { - return toAssertion('PACKET_LOSS', 'GREATER_THAN', target) - } -} +// The response-time statistical properties, derived from the grammar table the builder is +// generated from, so the union and the per-property operators cannot drift. +export type TracerouteResponseTimeProperty = keyof typeof tracerouteResponseTimeGrammar /** * Builder class for creating traceroute monitor assertions. @@ -93,23 +42,25 @@ export class TracerouteAssertionBuilder { * - `stdDev`: Standard deviation of round-trip times * @returns A numeric assertion builder for the specified response time metric. */ - static responseTime (property: TracerouteResponseTimeProperty = 'avg') { - return new ResponseTimeAssertionBuilder(property) + static responseTime ( + property: Property = 'avg' as Property, + ): PropertyOperators<'RESPONSE_TIME', typeof tracerouteResponseTimeGrammar[Property]> { + return operatorsForProperty(tracerouteResponseTimeGrammar, 'RESPONSE_TIME', property) } /** * Creates an assertion builder for the number of network hops. * @returns A numeric assertion builder for the hop count. */ - static hopCount () { - return new HopCountAssertionBuilder() + static hopCount (): PropertyOperators<'HOP_COUNT', typeof hopCountGrammar> { + return operatorsForSource('HOP_COUNT', hopCountGrammar) } /** * Creates an assertion builder for the percentage of packet loss (0-100). * @returns A numeric assertion builder for packet loss. */ - static packetLoss () { - return new PacketLossAssertionBuilder() + static packetLoss (): PropertyOperators<'PACKET_LOSS', typeof packetLossGrammar> { + return operatorsForSource('PACKET_LOSS', packetLossGrammar) } }