diff --git a/.changeset/stupid-dryers-add.md b/.changeset/stupid-dryers-add.md new file mode 100644 index 0000000000..2bd31eb641 --- /dev/null +++ b/.changeset/stupid-dryers-add.md @@ -0,0 +1,5 @@ +--- +"@redocly/openapi-core": minor +--- + +Added `asyncapi-operation-security-defined` rule for AsyncAPI 2.x that reports when a security scheme referenced from an operation or server `security` array is not defined in `components.securitySchemes`. diff --git a/docs/@v1/rules/ruleset-templates.md b/docs/@v1/rules/ruleset-templates.md index 8823025709..f03683fd2f 100644 --- a/docs/@v1/rules/ruleset-templates.md +++ b/docs/@v1/rules/ruleset-templates.md @@ -438,6 +438,7 @@ rules: info-contact: off info-license-strict: warn operation-operationId: warn + asyncapi-operation-security-defined: error tag-description: warn tags-alphabetical: off channels-kebab-case: off diff --git a/docs/@v2/guides/lint-asyncapi.md b/docs/@v2/guides/lint-asyncapi.md index 65bfbc9721..cbf30b82d3 100644 --- a/docs/@v2/guides/lint-asyncapi.md +++ b/docs/@v2/guides/lint-asyncapi.md @@ -50,6 +50,7 @@ The currently supported rules are: - `info-contact`: the `Info` section must contain a valid `Contact` field. - `operation-operationId`: every operation must have a valid `operationId`. +- `security-defined`: every scheme referenced from an operation or server `security` array must be defined in `components.securitySchemes` (AsyncAPI 2.x and 3.0). - `channels-kebab-case`: channel address should be `kebab-case` (lowercase with hyphens). - `no-channel-trailing-slash`: channel names must not have trailing slashes in their address. - `tag-description`: all tags require a description. diff --git a/docs/@v2/rules/async/security-defined.md b/docs/@v2/rules/async/security-defined.md new file mode 100644 index 0000000000..5a2fcde841 --- /dev/null +++ b/docs/@v2/rules/async/security-defined.md @@ -0,0 +1,120 @@ +# security-defined + +Verifies that every security scheme referenced from an operation or server `security` array is defined in `components.securitySchemes`. + +| AsyncAPI | Compatibility | +| -------- | ------------- | +| 2.6 | ✅ | +| 3.0 | ✅ | + +## API design principles + +In AsyncAPI 2.x, `security` entries on operations and servers are bare security scheme names that must match a key under `components.securitySchemes`. +A typo or rename leaves the reference dangling, and the document remains structurally valid — the mismatch is only visible to clients at runtime. + +In AsyncAPI 3.0, `security` entries are `SecurityScheme` objects, typically expressed as `$ref`s into `components.securitySchemes`. The rule reports when a security `$ref` does not point into `components.securitySchemes` or when it points at a name that is not defined there. + +This rule catches those name mismatches at lint time. + +## Configuration + +| Option | Type | Description | +| -------- | ------ | ------------------------------------------------------------------------------------------ | +| severity | string | Possible values: `off`, `warn`, `error`. Default `error` (in `recommended` configuration). | + +An example configuration: + +```yaml +rules: + security-defined: error +``` + +## Examples + +Given this configuration: + +```yaml +rules: + security-defined: error +``` + +Example of an **incorrect** security definition due to a mismatch between the referenced name and `components.securitySchemes`: + +```yaml +asyncapi: '2.6.0' +channels: + user/signedup: + subscribe: + security: + - OAuth: [] # no matching scheme in components.securitySchemes + message: + messageId: UserSignedUp +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +Example of a **correct** AsyncAPI 2.x security definition: + +```yaml +asyncapi: '2.6.0' +channels: + user/signedup: + subscribe: + security: + - JWT: [] + message: + messageId: UserSignedUp +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +Example of an **incorrect** AsyncAPI 3.0 security definition where the `$ref` points at an undefined scheme: + +```yaml +asyncapi: '3.0.0' +operations: + sendMessage: + action: send + channel: + $ref: '#/channels/userSignedUp' + security: + - $ref: '#/components/securitySchemes/OAuth' +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +Example of a **correct** AsyncAPI 3.0 security definition: + +```yaml +asyncapi: '3.0.0' +operations: + sendMessage: + action: send + channel: + $ref: '#/channels/userSignedUp' + security: + - $ref: '#/components/securitySchemes/JWT' +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +## Related rules + +- [security-defined](../oas/security-defined.md) — equivalent rule for OpenAPI. + +## Resources + +- [AsyncAPI 2.x rule source](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/async2/security-defined.ts) +- [AsyncAPI 3.0 rule source](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/async3/security-defined.ts) diff --git a/docs/@v2/rules/built-in-rules.md b/docs/@v2/rules/built-in-rules.md index c9891808a7..0c86d0ff2f 100644 --- a/docs/@v2/rules/built-in-rules.md +++ b/docs/@v2/rules/built-in-rules.md @@ -122,6 +122,7 @@ Other rules, such as the `struct` and `info.*`, also apply to AsyncAPI. - [channels-kebab-case](./async/channels-kebab-case.md): Channels must be in `kebab-case` format - [no-channel-trailing-slash](./async/no-channel-trailing-slash.md): No trailing slashes on channels +- [security-defined](./async/security-defined.md): Security scheme names referenced from operations or servers must be defined in `components.securitySchemes` ## Arazzo rules diff --git a/docs/@v2/rules/ruleset-templates.md b/docs/@v2/rules/ruleset-templates.md index 5593ded8d4..18918857f5 100644 --- a/docs/@v2/rules/ruleset-templates.md +++ b/docs/@v2/rules/ruleset-templates.md @@ -151,6 +151,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: warn operation-operationId: warn + security-defined: warn struct: error tag-description: warn ``` @@ -163,6 +164,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: warn operation-operationId: warn + security-defined: warn struct: error tag-description: warn ``` @@ -357,6 +359,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: error operation-operationId: warn + security-defined: error struct: error tag-description: warn ``` @@ -372,6 +375,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: error operation-operationId: warn + security-defined: error struct: error tag-description: warn ``` diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 2e11a05018..bae3a6d3f9 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -153,6 +153,7 @@ - separator: AsyncAPI - page: rules/async/channels-kebab-case.md - page: rules/async/no-channel-trailing-slash.md + - page: rules/async/security-defined.md - separator: Open-RPC - page: rules/openrpc/spec-no-duplicated-method-params.md - page: rules/openrpc/spec-no-required-params-after-optional.md diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index ed14440bc6..bc216eddff 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -41,6 +41,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -57,6 +58,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -428,6 +430,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -444,6 +447,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index f4565a5cad..b31bf9ee00 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -163,6 +163,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -179,6 +180,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -487,6 +489,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -503,6 +506,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -816,6 +820,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -832,6 +837,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1229,6 +1235,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1245,6 +1252,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1553,6 +1561,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1569,6 +1578,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1882,6 +1892,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, @@ -1898,6 +1909,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", }, diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts index fbdd42c095..791fad4dd5 100644 --- a/packages/core/src/config/all.ts +++ b/packages/core/src/config/all.ts @@ -256,6 +256,7 @@ const all: RawGovernanceConfig<'built-in'> = { 'info-license-strict': 'error', 'no-channel-trailing-slash': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'error', 'no-duplicated-tag-names': 'error', @@ -270,6 +271,7 @@ const all: RawGovernanceConfig<'built-in'> = { 'info-license-strict': 'error', 'no-channel-trailing-slash': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'error', 'no-duplicated-tag-names': 'error', diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts index 4a00e4964e..d817acceb5 100644 --- a/packages/core/src/config/minimal.ts +++ b/packages/core/src/config/minimal.ts @@ -240,6 +240,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'warn', 'operation-operationId': 'warn', + 'security-defined': 'warn', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, @@ -254,6 +255,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'warn', 'operation-operationId': 'warn', + 'security-defined': 'warn', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/config/recommended-strict.ts b/packages/core/src/config/recommended-strict.ts index 250a6d5636..027cd70fa2 100644 --- a/packages/core/src/config/recommended-strict.ts +++ b/packages/core/src/config/recommended-strict.ts @@ -240,6 +240,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'error', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'off', }, @@ -254,6 +255,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'error', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts index f3333ef029..74b22a6a31 100644 --- a/packages/core/src/config/recommended.ts +++ b/packages/core/src/config/recommended.ts @@ -240,6 +240,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'warn', + 'security-defined': 'error', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, @@ -254,6 +255,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'warn', + 'security-defined': 'error', 'tag-description': 'warn', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/config/spec.ts b/packages/core/src/config/spec.ts index bea4c5e16e..dab81a32a1 100644 --- a/packages/core/src/config/spec.ts +++ b/packages/core/src/config/spec.ts @@ -240,6 +240,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'off', 'no-schema-type-mismatch': 'off', 'operation-operationId': 'off', + 'security-defined': 'off', 'tag-description': 'off', 'tags-alphabetical': 'off', }, @@ -254,6 +255,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'off', 'no-schema-type-mismatch': 'off', 'operation-operationId': 'off', + 'security-defined': 'off', 'tag-description': 'off', 'tags-alphabetical': 'off', }, diff --git a/packages/core/src/rules/async2/__tests__/security-defined.test.ts b/packages/core/src/rules/async2/__tests__/security-defined.test.ts new file mode 100644 index 0000000000..102438de52 --- /dev/null +++ b/packages/core/src/rules/async2/__tests__/security-defined.test.ts @@ -0,0 +1,177 @@ +import { outdent } from 'outdent'; + +import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { lintDocument } from '../../../lint.js'; +import { BaseResolver } from '../../../resolve.js'; + +describe('Async2 security-defined', () => { + it('should report when an operation references an undefined security scheme', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + subscribe: + security: + - undefinedScheme: [] + message: + messageId: Message1 + components: + securitySchemes: + knownScheme: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/channels/some~1channel/subscribe/security/0/undefinedScheme", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`undefinedScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when a server references an undefined security scheme', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - missingScheme: [] + channels: + some/channel: {} + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/servers/production/security/0/missingScheme", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`missingScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when all referenced schemes are defined', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + channels: + some/channel: + subscribe: + security: + - apiKeyAuth: [] + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when security array is empty', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + subscribe: + security: [] + message: + messageId: Message1 + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); +}); diff --git a/packages/core/src/rules/async2/index.ts b/packages/core/src/rules/async2/index.ts index 73aec1bb2c..2557b62036 100644 --- a/packages/core/src/rules/async2/index.ts +++ b/packages/core/src/rules/async2/index.ts @@ -15,6 +15,7 @@ import { TagDescription } from '../common/tag-description.js'; import { TagsAlphabetical } from '../common/tags-alphabetical.js'; import { ChannelsKebabCase } from './channels-kebab-case.js'; import { NoChannelTrailingSlash } from './no-channel-trailing-slash.js'; +import { SecurityDefined } from './security-defined.js'; export const rules: Async2RuleSet<'built-in'> = { struct: Struct as Async2Rule, @@ -23,6 +24,7 @@ export const rules: Async2RuleSet<'built-in'> = { 'info-contact': InfoContact as Async2Rule, 'info-license-strict': InfoLicenseStrict as Async2Rule, 'operation-operationId': OperationOperationId as Async2Rule, + 'security-defined': SecurityDefined, 'channels-kebab-case': ChannelsKebabCase, 'no-channel-trailing-slash': NoChannelTrailingSlash, 'tag-description': TagDescription as Async2Rule, diff --git a/packages/core/src/rules/async2/security-defined.ts b/packages/core/src/rules/async2/security-defined.ts new file mode 100644 index 0000000000..163a196a69 --- /dev/null +++ b/packages/core/src/rules/async2/security-defined.ts @@ -0,0 +1,43 @@ +import type { Location } from '../../ref-utils.js'; +import type { Async2Rule } from '../../visitors.js'; +import type { UserContext } from '../../walk.js'; + +export const SecurityDefined: Async2Rule = () => { + const referencedSchemes = new Map< + string, + { + defined?: boolean; + from: Location[]; + } + >(); + + return { + Root: { + leave(_root: unknown, { report }: UserContext) { + for (const [name, scheme] of referencedSchemes.entries()) { + if (scheme.defined) continue; + for (const reportedFromLocation of scheme.from) { + report({ + message: `There is no \`${name}\` security scheme defined.`, + location: reportedFromLocation.key(), + }); + } + } + }, + }, + SecurityScheme(_scheme: unknown, { key }: UserContext) { + referencedSchemes.set(key.toString(), { defined: true, from: [] }); + }, + SecurityRequirement(requirements: Record, { location }: UserContext) { + for (const requirement of Object.keys(requirements)) { + const authScheme = referencedSchemes.get(requirement); + const requirementLocation = location.child([requirement]); + if (!authScheme) { + referencedSchemes.set(requirement, { from: [requirementLocation] }); + } else { + authScheme.from.push(requirementLocation); + } + } + }, + }; +}; diff --git a/packages/core/src/rules/async3/__tests__/security-defined.test.ts b/packages/core/src/rules/async3/__tests__/security-defined.test.ts new file mode 100644 index 0000000000..d316bc4421 --- /dev/null +++ b/packages/core/src/rules/async3/__tests__/security-defined.test.ts @@ -0,0 +1,243 @@ +import { outdent } from 'outdent'; + +import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { lintDocument } from '../../../lint.js'; +import { BaseResolver } from '../../../resolve.js'; + +describe('Async3 security-defined', () => { + it('should report when an operation references an undefined security scheme via $ref', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: + - $ref: '#/components/securitySchemes/undefinedScheme' + components: + securitySchemes: + knownScheme: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage/security/0", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`undefinedScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when a server references an undefined security scheme via $ref', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/missingScheme' + channels: + some/channel: + address: some/channel + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/servers/production/security/0", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`missingScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when all $refs resolve to defined schemes', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when security array is empty', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: [] + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when a $ref points outside components.securitySchemes', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: + - $ref: '#/components/schemas/SomethingElse' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + schemas: + SomethingElse: + type: object + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage/security/0", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Security scheme \`$ref\` must point to \`#/components/securitySchemes\`.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/rules/async3/index.ts b/packages/core/src/rules/async3/index.ts index 0623b94b40..665a420bc0 100644 --- a/packages/core/src/rules/async3/index.ts +++ b/packages/core/src/rules/async3/index.ts @@ -15,6 +15,7 @@ import { TagDescription } from '../common/tag-description.js'; import { TagsAlphabetical } from '../common/tags-alphabetical.js'; import { ChannelsKebabCase } from './channels-kebab-case.js'; import { NoChannelTrailingSlash } from './no-channel-trailing-slash.js'; +import { SecurityDefined } from './security-defined.js'; export const rules: Async3RuleSet<'built-in'> = { struct: Struct as Async3Rule, @@ -23,6 +24,7 @@ export const rules: Async3RuleSet<'built-in'> = { 'info-contact': InfoContact as Async3Rule, 'info-license-strict': InfoLicenseStrict as Async3Rule, 'operation-operationId': OperationOperationId as Async3Rule, + 'security-defined': SecurityDefined, 'channels-kebab-case': ChannelsKebabCase, 'no-channel-trailing-slash': NoChannelTrailingSlash, 'tag-description': TagDescription as Async3Rule, diff --git a/packages/core/src/rules/async3/security-defined.ts b/packages/core/src/rules/async3/security-defined.ts new file mode 100644 index 0000000000..1201749fdb --- /dev/null +++ b/packages/core/src/rules/async3/security-defined.ts @@ -0,0 +1,31 @@ +import type { Async3Rule } from '../../visitors.js'; + +const SECURITY_LOCATION = /\/security\/\d+$/; +const COMPONENT_SCHEME_FRAGMENT = /^\/components\/securitySchemes\/([^/]+)$/; + +export const SecurityDefined: Async3Rule = () => { + return { + ref: { + leave(node, { location, report }, resolved) { + if (!SECURITY_LOCATION.test(location.pointer)) return; + + const fragment = node.$ref.split('#')[1] ?? ''; + const match = COMPONENT_SCHEME_FRAGMENT.exec(fragment); + if (!match) { + report({ + message: `Security scheme \`$ref\` must point to \`#/components/securitySchemes\`.`, + location: location.key(), + }); + return; + } + + if (resolved.node === undefined) { + report({ + message: `There is no \`${match[1]}\` security scheme defined.`, + location: location.key(), + }); + } + }, + }, + }; +}; diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index e1a4686f07..03987676e0 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -126,6 +126,7 @@ const builtInAsync2Rules = [ 'info-contact', 'info-license-strict', 'operation-operationId', + 'security-defined', 'tag-description', 'tags-alphabetical', 'channels-kebab-case', @@ -142,6 +143,7 @@ const builtInAsync3Rules = [ 'info-contact', 'info-license-strict', 'operation-operationId', + 'security-defined', 'tag-description', 'tags-alphabetical', 'channels-kebab-case',