Skip to content

Commit 1c28214

Browse files
danielpaulusclaude
andcommitted
fix(cli): restore type narrowing for SSL typed-constant assertions
Convert SslAssertionBuilder.certificate/connection to typed overloads so property names are narrowed to known unions (typos are compile errors + autocomplete), and the two typed-constant properties narrow the builder target type: connection('tlsVersion') -> TlsVersionValue, certificate('signatureAlgorithm') -> SignatureAlgorithmValue. cipherSuite stays unconstrained by prior decision. Add type-level @ts-expect-error coverage proving the narrowing, plus runtime validation coverage for the removed SSL-only operators (MATCHES, GREATER_THAN_OR_EQUAL) and the per-property comparison whitelist (sans/serialNumber/ocspStatus rejects; sans/resolvedIp accepts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AYwkvoaANQTH5dGStbBj2
1 parent 7bc71a7 commit 1c28214

3 files changed

Lines changed: 128 additions & 2 deletions

File tree

packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,38 @@ describe('SslAssertionBuilder', () => {
112112
})
113113
})
114114
})
115+
116+
// These cases assert compile-time narrowing. The `@ts-expect-error` lines are the
117+
// real assertions: `tsc --build` fails if any becomes a false positive (an unused
118+
// expect-error). The runtime `expect` keeps the block a valid test.
119+
describe('type narrowing (compile-time)', () => {
120+
it('constrains connection(tlsVersion) targets to TlsVersionValue', () => {
121+
expect(SslAssertionBuilder.connection('tlsVersion').equals(TlsVersion.TLS1_3)).toMatchObject({
122+
source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3',
123+
})
124+
// @ts-expect-error 'bogus' is not a TlsVersionValue
125+
SslAssertionBuilder.connection('tlsVersion').equals('bogus')
126+
})
127+
128+
it('constrains certificate(signatureAlgorithm) targets to SignatureAlgorithmValue', () => {
129+
expect(SslAssertionBuilder.certificate('signatureAlgorithm').equals(SignatureAlgorithm.SHA256_RSA)).toMatchObject({
130+
source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'EQUALS', target: 'SHA256-RSA',
131+
})
132+
// @ts-expect-error 'nope' is not a SignatureAlgorithmValue
133+
SslAssertionBuilder.certificate('signatureAlgorithm').equals('nope')
134+
})
135+
136+
it('rejects unknown property names', () => {
137+
// @ts-expect-error 'bogusProperty' is not a CertificateProperty
138+
SslAssertionBuilder.certificate('bogusProperty')
139+
// @ts-expect-error 'bogusProperty' is not a ConnectionProperty
140+
SslAssertionBuilder.connection('bogusProperty')
141+
})
142+
143+
it('leaves cipherSuite targets unconstrained (arbitrary string)', () => {
144+
expect(SslAssertionBuilder.connection('cipherSuite').equals('SOME_FUTURE_SUITE')).toMatchObject({
145+
source: 'CONNECTION', property: 'cipherSuite', comparison: 'EQUALS', target: 'SOME_FUTURE_SUITE',
146+
})
147+
})
148+
})
115149
})

packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,67 @@ describe('SslMonitor', () => {
352352
]))
353353
})
354354

355+
it('should error on removed SSL-only operators against the new grammar', async () => {
356+
// The regex (MATCHES) and >= (GREATER_THAN_OR_EQUAL) operators were dropped for SSL;
357+
// they must be rejected on every source that could plausibly carry them.
358+
const cases: Array<{ assertion: SslRequest['assertions'][number]; message: string }> = [
359+
{
360+
assertion: { source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'MATCHES', target: 'x', regex: null },
361+
message: 'The CERTIFICATE "signatureAlgorithm" assertion at "request.assertions[0]" has an unsupported comparison "MATCHES".',
362+
},
363+
{
364+
assertion: { source: 'CONNECTION', property: 'tlsVersion', comparison: 'GREATER_THAN_OR_EQUAL', target: 'TLS1.2', regex: null },
365+
message: 'The CONNECTION "tlsVersion" assertion at "request.assertions[0]" has an unsupported comparison "GREATER_THAN_OR_EQUAL".',
366+
},
367+
{
368+
assertion: { source: 'RESPONSE_TIME', property: '', comparison: 'GREATER_THAN_OR_EQUAL', target: '100', regex: null },
369+
message: 'The RESPONSE_TIME assertion at "request.assertions[0]" has an unsupported comparison "GREATER_THAN_OR_EQUAL".',
370+
},
371+
]
372+
for (const { assertion, message } of cases) {
373+
const diags = await validateWith([assertion])
374+
expect(diags.isFatal()).toEqual(true)
375+
expect(diags.observations).toEqual(expect.arrayContaining([
376+
expect.objectContaining({ message: expect.stringContaining(message) }),
377+
]))
378+
}
379+
})
380+
381+
it('should enforce the per-property comparison whitelist', async () => {
382+
const cases: Array<{ assertion: SslRequest['assertions'][number]; message: string }> = [
383+
{
384+
// sans is STRING_LIST — only CONTAINS / NOT_CONTAINS, so EQUALS is rejected.
385+
assertion: { source: 'CERTIFICATE', property: 'sans', comparison: 'EQUALS', target: 'example.com', regex: null },
386+
message: 'The CERTIFICATE "sans" assertion at "request.assertions[0]" has an unsupported comparison "EQUALS".',
387+
},
388+
{
389+
// serialNumber is ID — only EQUALS / NOT_EQUALS, so CONTAINS is rejected.
390+
assertion: { source: 'CERTIFICATE', property: 'serialNumber', comparison: 'CONTAINS', target: 'ab', regex: null },
391+
message: 'The CERTIFICATE "serialNumber" assertion at "request.assertions[0]" has an unsupported comparison "CONTAINS".',
392+
},
393+
{
394+
// ocspStatus is ENUM — only EQUALS / NOT_EQUALS, so CONTAINS is rejected.
395+
assertion: { source: 'CONNECTION', property: 'ocspStatus', comparison: 'CONTAINS', target: 'good', regex: null },
396+
message: 'The CONNECTION "ocspStatus" assertion at "request.assertions[0]" has an unsupported comparison "CONTAINS".',
397+
},
398+
]
399+
for (const { assertion, message } of cases) {
400+
const diags = await validateWith([assertion])
401+
expect(diags.isFatal()).toEqual(true)
402+
expect(diags.observations).toEqual(expect.arrayContaining([
403+
expect.objectContaining({ message: expect.stringContaining(message) }),
404+
]))
405+
}
406+
})
407+
408+
it('should accept the list-oriented comparisons the whitelist allows', async () => {
409+
const diags = await validateWith([
410+
{ source: 'CERTIFICATE', property: 'sans', comparison: 'CONTAINS', target: 'example.com', regex: null },
411+
{ source: 'CONNECTION', property: 'resolvedIp', comparison: 'NOT_CONTAINS', target: '10.0.0.1', regex: null },
412+
])
413+
expect(diags.isFatal()).toEqual(false)
414+
})
415+
355416
it('should not error on assertions built with SslAssertionBuilder', async () => {
356417
const diags = await validateWith([
357418
SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30),

packages/cli/src/constructs/ssl-assertion.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,33 @@ type SslAssertionSource =
104104

105105
export type SslAssertion = CoreAssertion<SslAssertionSource>
106106

107+
// The certificate/connection property names the backend understands. Narrowing the
108+
// `property` parameter to these unions turns a typo into a compile error and drives
109+
// autocomplete. The runtime whitelist in ssl-assertion-validation.ts is the source of
110+
// truth for object-literal assertions that bypass the builder.
111+
type CertificateProperty =
112+
| 'daysUntilExpiry'
113+
| 'subjectCN'
114+
| 'issuerCN'
115+
| 'serialNumber'
116+
| 'fingerprintSha256'
117+
| 'issuerFingerprintSha256'
118+
| 'keySizeBits'
119+
| 'keyAlgorithm'
120+
| 'signatureAlgorithm'
121+
| 'sans'
122+
| 'selfSigned'
123+
| 'isCA'
124+
125+
type ConnectionProperty =
126+
| 'tlsVersion'
127+
| 'cipherSuite'
128+
| 'hostnameVerified'
129+
| 'chainTrusted'
130+
| 'ocspStapled'
131+
| 'ocspStatus'
132+
| 'resolvedIp'
133+
107134
/**
108135
* Builder class for creating SSL monitor assertions.
109136
*
@@ -137,7 +164,9 @@ export class SslAssertionBuilder {
137164
* @param property The certificate property to assert on (e.g. `'daysUntilExpiry'`,
138165
* `'issuerCN'`, `'signatureAlgorithm'`, `'sans'`, `'selfSigned'`).
139166
*/
140-
static certificate (property: string) {
167+
static certificate (property: 'signatureAlgorithm'): GeneralAssertionBuilder<SslAssertionSource, SignatureAlgorithmValue>
168+
static certificate (property: CertificateProperty): GeneralAssertionBuilder<SslAssertionSource>
169+
static certificate (property: CertificateProperty) {
141170
return new GeneralAssertionBuilder<SslAssertionSource>('CERTIFICATE', property)
142171
}
143172

@@ -146,7 +175,9 @@ export class SslAssertionBuilder {
146175
* @param property The connection property to assert on (e.g. `'tlsVersion'`,
147176
* `'cipherSuite'`, `'hostnameVerified'`, `'ocspStatus'`, `'resolvedIp'`).
148177
*/
149-
static connection (property: string) {
178+
static connection (property: 'tlsVersion'): GeneralAssertionBuilder<SslAssertionSource, TlsVersionValue>
179+
static connection (property: ConnectionProperty): GeneralAssertionBuilder<SslAssertionSource>
180+
static connection (property: ConnectionProperty) {
150181
return new GeneralAssertionBuilder<SslAssertionSource>('CONNECTION', property)
151182
}
152183

0 commit comments

Comments
 (0)