From c569ddb9049fa524ef5dfd811a3e201c4c517ecd Mon Sep 17 00:00:00 2001 From: "REDMOND\\vayada" Date: Mon, 6 Jul 2026 12:21:47 -0700 Subject: [PATCH 1/4] Added Linter rule for BillingData property presence in resource's propertyBag. --- ...gDataInPropertiesBag_2026-07-06-11-12.json | 10 + docs/billing-data-in-properties-bag.md | 57 +++++ docs/rules.md | 6 + .../rulesets/generated/spectral/az-arm.js | 36 +++ packages/rulesets/src/spectral/az-arm.ts | 17 ++ .../billing-data-in-properties-bag.ts | 35 +++ .../billing-data-in-properties-bag.test.ts | 215 ++++++++++++++++++ 7 files changed, 376 insertions(+) create mode 100644 common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json create mode 100644 docs/billing-data-in-properties-bag.md create mode 100644 packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts create mode 100644 packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts diff --git a/common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json b/common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json new file mode 100644 index 000000000..5509441d5 --- /dev/null +++ b/common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft.azure/openapi-validator-rulesets", + "comment": "Add BillingDataInPropertiesBag rule that fails when a property named 'BillingData' (matched case-insensitively) is present in a resource's properties bag", + "type": "minor" + } + ], + "packageName": "@microsoft.azure/openapi-validator-rulesets" +} diff --git a/docs/billing-data-in-properties-bag.md b/docs/billing-data-in-properties-bag.md new file mode 100644 index 000000000..ca406b6f0 --- /dev/null +++ b/docs/billing-data-in-properties-bag.md @@ -0,0 +1,57 @@ +# BillingDataInPropertiesBag + +## Category + +ARM Error + +## Applies to + +ARM OpenAPI(swagger) specs + +## Description + +A property named `BillingData` (matched case-insensitively) must not be present in a resource's properties bag. If billing-related data is required, model it under a dedicated, appropriately named property or a separate model definition instead of placing a `BillingData` property directly in the resource properties bag. + +## How to fix the violation + +Remove the `BillingData` property from the resource properties bag. Represent the information using a differently named property or a dedicated model definition as appropriate. + +### Valid/Good Example + +```json +"Resource": { + "properties": { + "properties": { + "provisioningState": { + "type": "string" + } + } + } +} +``` + +### Invalid/Bad Example + +```json +"Resource": { + "properties": { + "properties": { + "billingData": { + "type": "string" + } + } + } +} +``` + +```json +"Resource": { + "properties": { + "properties": { + "billingData": { + "$ref": "#/definitions/BillingData" + } + } + } +} +``` diff --git a/docs/rules.md b/docs/rules.md index 7f2703807..5e738fb6b 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -145,6 +145,12 @@ This rule is to check if the tags definition of a resource conforms to the commo Please refer to [azure-resource-tags-schema.md](./azure-resource-tags-schema.md) for details. +### BillingDataInPropertiesBag + +A property named `BillingData` (matched case-insensitively) must not be present in a resource's properties bag. If billing-related data is required, model it under a dedicated, appropriately named property or a separate model definition instead of placing a `BillingData` property directly in the resource properties bag. + +Please refer to [billing-data-in-properties-bag.md](./billing-data-in-properties-bag.md) for details. + ### BodyPropertiesNamesCamelCase This violation is flagged if a request body parameter's property name (BodyPropertiesNamesCamelCase) is not in `camelCase` format. This is because the model's properties are sent across the wire and must adhere to common Json conventions for naming. This implies that every property name must start with a lower cased letter and all subsequent words within the name must start with a capital letter. In cases where there are acronyms involved, a maximum of three contiguous capital letters are allowed (acronyms should not be longer that 2 characters - see https://msdn.microsoft.com/en-us/library/141e06ef(v=vs.71).aspx, third upper case could be start of next word). Eg: `redisCache`, `publicIPAddress`, `location` are valid, but `sampleSQLQuery` is not (must be renamed as `sampleSqlQuery`). diff --git a/packages/rulesets/generated/spectral/az-arm.js b/packages/rulesets/generated/spectral/az-arm.js index c400a4631..40f1dfb74 100644 --- a/packages/rulesets/generated/spectral/az-arm.js +++ b/packages/rulesets/generated/spectral/az-arm.js @@ -1401,6 +1401,30 @@ const verifyArmPath = createRulesetFunction({ return errors; }); +const BILLING_DATA = "billingdata"; +const PROPERTIES$2 = "properties"; +const ERROR_MESSAGE$3 = "The 'BillingData' property is not allowed in the resource properties bag."; +function collectBillingDataPaths(object, basePath, matches) { + if (!_.isObject(object)) { + return; + } + for (const [key, value] of Object.entries(object)) { + if (key.toLowerCase() === BILLING_DATA) { + matches.push([...basePath, key]); + } + collectBillingDataPaths(value, [...basePath, key], matches); + } +} +const billingDataInPropertiesBag = (definition, _opts, ctx) => { + const properties = getProperties(definition); + const matches = []; + collectBillingDataPaths(properties, [], matches); + return matches.map((path) => ({ + message: ERROR_MESSAGE$3, + path: _.concat(ctx.path, PROPERTIES$2, path), + })); +}; + const bodyParamRepeatedInfo = (pathItem, _opts, paths) => { if (pathItem === null || typeof pathItem !== "object") { return []; @@ -4062,6 +4086,18 @@ const ruleset = { function: systemDataInPropertiesBag, }, }, + BillingDataInPropertiesBag: { + description: "The 'BillingData' property is not allowed in the resource properties bag.", + message: "{{description}}", + severity: "error", + stagingOnly: true, + resolved: true, + formats: [oas2], + given: ["$.definitions.*.properties[?(@property === 'properties')]^"], + then: { + function: billingDataInPropertiesBag, + }, + }, ReservedResourceNamesModelAsEnum: { rpcGuidelineCode: "RPC-ConstrainedCollections-V1-04", description: "Service-defined (reserved) resource names should be represented as an enum type with modelAsString set to true, not as a static string in the path.", diff --git a/packages/rulesets/src/spectral/az-arm.ts b/packages/rulesets/src/spectral/az-arm.ts index cab09555d..f190d4ea7 100644 --- a/packages/rulesets/src/spectral/az-arm.ts +++ b/packages/rulesets/src/spectral/az-arm.ts @@ -2,6 +2,7 @@ import { oas2 } from "@stoplight/spectral-formats" import { falsy, pattern, truthy } from "@stoplight/spectral-functions" import common from "./az-common" import verifyArmPath from "./functions/arm-path-validation" +import { billingDataInPropertiesBag } from "./functions/billing-data-in-properties-bag" import bodyParamRepeatedInfo from "./functions/body-param-repeated-info" import { camelCase } from "./functions/camel-case" import collectionObjectPropertiesNaming from "./functions/collection-object-properties-naming" @@ -1026,6 +1027,22 @@ const ruleset: any = { }, }, + // A property named 'BillingData' (matched case-insensitively) must not be present in a + // resource's properties bag. + BillingDataInPropertiesBag: { + description: "The 'BillingData' property is not allowed in the resource properties bag.", + message: "{{description}}", + severity: "error", + stagingOnly: true, + resolved: true, + formats: [oas2], + // given definitions that have the properties bag + given: ["$.definitions.*.properties[?(@property === 'properties')]^"], + then: { + function: billingDataInPropertiesBag, + }, + }, + /// /// ARM RPC rules for constrained resource collections /// diff --git a/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts b/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts new file mode 100644 index 000000000..4b7a034cb --- /dev/null +++ b/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts @@ -0,0 +1,35 @@ +// BillingDataInPropertiesBag +// A property named 'BillingData' (matched case-insensitively) must not be present in a +// resource's properties bag. + +import _ from "lodash" +import { getProperties } from "./utils" + +const BILLING_DATA = "billingdata" // compared case-insensitively +const PROPERTIES = "properties" +const ERROR_MESSAGE = "The 'BillingData' property is not allowed in the resource properties bag." + +// Recursively collect the path to every property named "BillingData" (case-insensitive) within +// the given properties bag, descending into all nested objects. +function collectBillingDataPaths(object: any, basePath: (string | number)[], matches: (string | number)[][]): void { + if (!_.isObject(object)) { + return + } + for (const [key, value] of Object.entries(object)) { + if (key.toLowerCase() === BILLING_DATA) { + matches.push([...basePath, key]) + } + collectBillingDataPaths(value, [...basePath, key], matches) + } +} + +export const billingDataInPropertiesBag = (definition: any, _opts: any, ctx: any) => { + const properties = getProperties(definition) + const matches: (string | number)[][] = [] + collectBillingDataPaths(properties, [], matches) + + return matches.map((path) => ({ + message: ERROR_MESSAGE, + path: _.concat(ctx.path, PROPERTIES, path), + })) +} diff --git a/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts b/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts new file mode 100644 index 000000000..e15083f0a --- /dev/null +++ b/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts @@ -0,0 +1,215 @@ +import { Spectral } from "@stoplight/spectral-core" +import linterForRule from "./utils" + +let linter: Spectral +const RULE = "BillingDataInPropertiesBag" +const ERROR_MESSAGE = "The 'BillingData' property is not allowed in the resource properties bag." + +beforeAll(async () => { + linter = await linterForRule(RULE) + return linter +}) + +test(`${RULE} should find no errors when billingData is absent from the properties bag`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + provisioningState: { + type: "string", + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0) + }) +}) + +test(`${RULE} should find errors when billingData references a model definition`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + billingData: { + $ref: "#/definitions/BillingData", + }, + }, + }, + }, + BillingData: { + type: "object", + properties: { + amount: { + type: "number", + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.billingData") + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should find errors when billingData is a primitive type`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + billingData: { + type: "string", + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.billingData") + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should find errors when billingData is an inline model`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + billingData: { + type: "object", + properties: { + amount: { + type: "number", + }, + }, + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.billingData") + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should be case-insensitive when matching the property name`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + BillingData: { + type: "string", + }, + BILLINGDATA: { + type: "string", + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(2) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.BillingData") + expect(results[1].path.join(".")).toBe("definitions.Resource.properties.properties.BILLINGDATA") + expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[1].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should not flag properties that only contain 'billingData' as a substring`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + billingDataId: { + type: "string", + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0) + }) +}) + +test(`${RULE} should find errors when billingData is in a referenced properties definition`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + $ref: "#/definitions/ResourceProperties", + }, + }, + }, + ResourceProperties: { + properties: { + billingData: { + type: "string", + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should not flag billingData defined as a top-level property outside the properties bag`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + provisioningState: { + type: "string", + }, + }, + billingData: { + type: "string", + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0) + }) +}) From 720f94d1f57e7dd9cfc0f24c1359494de322bb3d Mon Sep 17 00:00:00 2001 From: "REDMOND\\vayada" Date: Wed, 8 Jul 2026 14:07:11 -0700 Subject: [PATCH 2/4] Address PR review: restrict BillingData traversal to schema keywords; release files - Rework rule function to only traverse schema-structure keywords (properties, allOf/anyOf/oneOf, items, additionalProperties), fixing false positives on BillingData keys inside non-structural metadata (default/enum/vendor extensions). Adds a cycle guard. - Rewrite tests with realistic schema-form bags; add regression test for metadata false positives and coverage for allOf/items/additionalProperties. - Remove Rush change file; bump packages/rulesets to 2.3.0 and add CHANGELOG entry (per PR #824 pattern). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...gDataInPropertiesBag_2026-07-06-11-12.json | 10 - packages/rulesets/CHANGELOG.md | 6 + .../rulesets/generated/spectral/az-arm.js | 39 ++- packages/rulesets/package.json | 2 +- .../billing-data-in-properties-bag.ts | 61 +++- .../billing-data-in-properties-bag.test.ts | 280 ++++++++++++++++-- 6 files changed, 343 insertions(+), 55 deletions(-) delete mode 100644 common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json diff --git a/common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json b/common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json deleted file mode 100644 index 5509441d5..000000000 --- a/common/changes/@microsoft.azure/openapi-validator-rulesets/vayada-add-BillingDataInPropertiesBag_2026-07-06-11-12.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@microsoft.azure/openapi-validator-rulesets", - "comment": "Add BillingDataInPropertiesBag rule that fails when a property named 'BillingData' (matched case-insensitively) is present in a resource's properties bag", - "type": "minor" - } - ], - "packageName": "@microsoft.azure/openapi-validator-rulesets" -} diff --git a/packages/rulesets/CHANGELOG.md b/packages/rulesets/CHANGELOG.md index cd31fd3aa..4c84d8a6b 100644 --- a/packages/rulesets/CHANGELOG.md +++ b/packages/rulesets/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log - @microsoft.azure/openapi-validator-rulesets +## 2.3.0 + +### Minor changes + +- Added rule BillingDataInPropertiesBag to disallow the reserved 'BillingData' property name (case-insensitive) in a resource's properties bag + ## 2.2.6 ### Patches diff --git a/packages/rulesets/generated/spectral/az-arm.js b/packages/rulesets/generated/spectral/az-arm.js index 40f1dfb74..79085735f 100644 --- a/packages/rulesets/generated/spectral/az-arm.js +++ b/packages/rulesets/generated/spectral/az-arm.js @@ -1404,21 +1404,44 @@ const verifyArmPath = createRulesetFunction({ const BILLING_DATA = "billingdata"; const PROPERTIES$2 = "properties"; const ERROR_MESSAGE$3 = "The 'BillingData' property is not allowed in the resource properties bag."; -function collectBillingDataPaths(object, basePath, matches) { - if (!_.isObject(object)) { +function collectBillingDataPropertyPaths(schema, basePath, matches, visited) { + if (!_.isObject(schema) || visited.has(schema)) { return; } - for (const [key, value] of Object.entries(object)) { - if (key.toLowerCase() === BILLING_DATA) { - matches.push([...basePath, key]); + visited.add(schema); + const s = schema; + if (_.isObject(s.properties)) { + for (const [name, propertySchema] of Object.entries(s.properties)) { + if (name.toLowerCase() === BILLING_DATA) { + matches.push([...basePath, PROPERTIES$2, name]); + } + collectBillingDataPropertyPaths(propertySchema, [...basePath, PROPERTIES$2, name], matches, visited); + } + } + for (const keyword of ["allOf", "anyOf", "oneOf"]) { + const subschemas = s[keyword]; + if (Array.isArray(subschemas)) { + subschemas.forEach((subschema, index) => { + collectBillingDataPropertyPaths(subschema, [...basePath, keyword, index], matches, visited); + }); } - collectBillingDataPaths(value, [...basePath, key], matches); + } + if (Array.isArray(s.items)) { + s.items.forEach((itemSchema, index) => { + collectBillingDataPropertyPaths(itemSchema, [...basePath, "items", index], matches, visited); + }); + } + else if (_.isObject(s.items)) { + collectBillingDataPropertyPaths(s.items, [...basePath, "items"], matches, visited); + } + if (_.isObject(s.additionalProperties)) { + collectBillingDataPropertyPaths(s.additionalProperties, [...basePath, "additionalProperties"], matches, visited); } } const billingDataInPropertiesBag = (definition, _opts, ctx) => { - const properties = getProperties(definition); + const bag = getProperties(definition); const matches = []; - collectBillingDataPaths(properties, [], matches); + collectBillingDataPropertyPaths(bag, [], matches, new WeakSet()); return matches.map((path) => ({ message: ERROR_MESSAGE$3, path: _.concat(ctx.path, PROPERTIES$2, path), diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 349e69fa5..99c204afc 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft.azure/openapi-validator-rulesets", - "version": "2.2.6", + "version": "2.3.0", "description": "Azure OpenAPI Validator", "main": "dist/index.js", "files": [ diff --git a/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts b/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts index 4b7a034cb..2d4d4d47a 100644 --- a/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts +++ b/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts @@ -1,5 +1,5 @@ // BillingDataInPropertiesBag -// A property named 'BillingData' (matched case-insensitively) must not be present in a +// A property named 'BillingData' (matched case-insensitively) must not be defined in a // resource's properties bag. import _ from "lodash" @@ -9,24 +9,63 @@ const BILLING_DATA = "billingdata" // compared case-insensitively const PROPERTIES = "properties" const ERROR_MESSAGE = "The 'BillingData' property is not allowed in the resource properties bag." -// Recursively collect the path to every property named "BillingData" (case-insensitive) within -// the given properties bag, descending into all nested objects. -function collectBillingDataPaths(object: any, basePath: (string | number)[], matches: (string | number)[][]): void { - if (!_.isObject(object)) { +// Recursively collect the path to every property named "BillingData" (case-insensitive) that is +// defined within the given schema. Traversal is restricted to schema-structure keywords +// (properties, allOf/anyOf/oneOf, items, additionalProperties) so that only actual property +// definitions are inspected. Values inside non-structural metadata (e.g. default values, enum +// values, examples, or vendor extensions) are intentionally ignored to avoid false positives. +function collectBillingDataPropertyPaths( + schema: any, + basePath: (string | number)[], + matches: (string | number)[][], + visited: WeakSet +): void { + if (!_.isObject(schema) || visited.has(schema)) { return } - for (const [key, value] of Object.entries(object)) { - if (key.toLowerCase() === BILLING_DATA) { - matches.push([...basePath, key]) + visited.add(schema) + + const s = schema as { [key: string]: any } + + // properties: a map of property name -> property schema + if (_.isObject(s.properties)) { + for (const [name, propertySchema] of Object.entries(s.properties as { [key: string]: any })) { + if (name.toLowerCase() === BILLING_DATA) { + matches.push([...basePath, PROPERTIES, name]) + } + collectBillingDataPropertyPaths(propertySchema, [...basePath, PROPERTIES, name], matches, visited) + } + } + + // allOf / anyOf / oneOf: arrays of subschemas + for (const keyword of ["allOf", "anyOf", "oneOf"]) { + const subschemas = s[keyword] + if (Array.isArray(subschemas)) { + subschemas.forEach((subschema: any, index: number) => { + collectBillingDataPropertyPaths(subschema, [...basePath, keyword, index], matches, visited) + }) } - collectBillingDataPaths(value, [...basePath, key], matches) + } + + // items: a single subschema or an array of subschemas + if (Array.isArray(s.items)) { + s.items.forEach((itemSchema: any, index: number) => { + collectBillingDataPropertyPaths(itemSchema, [...basePath, "items", index], matches, visited) + }) + } else if (_.isObject(s.items)) { + collectBillingDataPropertyPaths(s.items, [...basePath, "items"], matches, visited) + } + + // additionalProperties: a subschema (when it is an object rather than a boolean) + if (_.isObject(s.additionalProperties)) { + collectBillingDataPropertyPaths(s.additionalProperties, [...basePath, "additionalProperties"], matches, visited) } } export const billingDataInPropertiesBag = (definition: any, _opts: any, ctx: any) => { - const properties = getProperties(definition) + const bag = getProperties(definition) const matches: (string | number)[][] = [] - collectBillingDataPaths(properties, [], matches) + collectBillingDataPropertyPaths(bag, [], matches, new WeakSet()) return matches.map((path) => ({ message: ERROR_MESSAGE, diff --git a/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts b/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts index e15083f0a..00460a19c 100644 --- a/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts +++ b/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts @@ -18,8 +18,11 @@ test(`${RULE} should find no errors when billingData is absent from the properti Resource: { properties: { properties: { - provisioningState: { - type: "string", + type: "object", + properties: { + provisioningState: { + type: "string", + }, }, }, }, @@ -39,8 +42,11 @@ test(`${RULE} should find errors when billingData references a model definition` Resource: { properties: { properties: { - billingData: { - $ref: "#/definitions/BillingData", + type: "object", + properties: { + billingData: { + $ref: "#/definitions/BillingData", + }, }, }, }, @@ -57,7 +63,7 @@ test(`${RULE} should find errors when billingData references a model definition` } return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) - expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.billingData") + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") expect(results[0].message).toBe(ERROR_MESSAGE) }) }) @@ -70,8 +76,11 @@ test(`${RULE} should find errors when billingData is a primitive type`, async () Resource: { properties: { properties: { - billingData: { - type: "string", + type: "object", + properties: { + billingData: { + type: "string", + }, }, }, }, @@ -80,7 +89,7 @@ test(`${RULE} should find errors when billingData is a primitive type`, async () } return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) - expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.billingData") + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") expect(results[0].message).toBe(ERROR_MESSAGE) }) }) @@ -93,11 +102,14 @@ test(`${RULE} should find errors when billingData is an inline model`, async () Resource: { properties: { properties: { - billingData: { - type: "object", - properties: { - amount: { - type: "number", + type: "object", + properties: { + billingData: { + type: "object", + properties: { + amount: { + type: "number", + }, }, }, }, @@ -108,7 +120,7 @@ test(`${RULE} should find errors when billingData is an inline model`, async () } return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) - expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.billingData") + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") expect(results[0].message).toBe(ERROR_MESSAGE) }) }) @@ -121,11 +133,14 @@ test(`${RULE} should be case-insensitive when matching the property name`, async Resource: { properties: { properties: { - BillingData: { - type: "string", - }, - BILLINGDATA: { - type: "string", + type: "object", + properties: { + BillingData: { + type: "string", + }, + BILLINGDATA: { + type: "string", + }, }, }, }, @@ -134,8 +149,8 @@ test(`${RULE} should be case-insensitive when matching the property name`, async } return linter.run(oasDoc).then((results) => { expect(results.length).toBe(2) - expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.BillingData") - expect(results[1].path.join(".")).toBe("definitions.Resource.properties.properties.BILLINGDATA") + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.BillingData") + expect(results[1].path.join(".")).toBe("definitions.Resource.properties.properties.properties.BILLINGDATA") expect(results[0].message).toBe(ERROR_MESSAGE) expect(results[1].message).toBe(ERROR_MESSAGE) }) @@ -149,8 +164,11 @@ test(`${RULE} should not flag properties that only contain 'billingData' as a su Resource: { properties: { properties: { - billingDataId: { - type: "string", + type: "object", + properties: { + billingDataId: { + type: "string", + }, }, }, }, @@ -175,6 +193,7 @@ test(`${RULE} should find errors when billingData is in a referenced properties }, }, ResourceProperties: { + type: "object", properties: { billingData: { type: "string", @@ -190,6 +209,39 @@ test(`${RULE} should find errors when billingData is in a referenced properties }) }) +test(`${RULE} should find errors when billingData is nested inside another property definition`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + type: "object", + properties: { + nested: { + type: "object", + properties: { + billingData: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe( + "definitions.Resource.properties.properties.properties.nested.properties.billingData" + ) + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + test(`${RULE} should not flag billingData defined as a top-level property outside the properties bag`, async () => { const oasDoc = { swagger: "2.0", @@ -198,8 +250,11 @@ test(`${RULE} should not flag billingData defined as a top-level property outsid Resource: { properties: { properties: { - provisioningState: { - type: "string", + type: "object", + properties: { + provisioningState: { + type: "string", + }, }, }, billingData: { @@ -213,3 +268,178 @@ test(`${RULE} should not flag billingData defined as a top-level property outsid expect(results.length).toBe(0) }) }) + +test(`${RULE} should not flag billingData keys that appear inside non-structural schema metadata`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + type: "object", + properties: { + config: { + type: "object", + default: { + billingData: "someDefaultValue", + }, + enum: [ + { + billingData: 1, + }, + ], + "x-ms-metadata": { + billingData: true, + }, + }, + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0) + }) +}) + +test(`${RULE} should find errors when billingData is defined via allOf composition`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + allOf: [ + { + type: "object", + properties: { + billingData: { + type: "string", + }, + }, + }, + ], + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.allOf.0.properties.billingData") + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should find errors when billingData is defined in array items`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + type: "object", + properties: { + list: { + type: "array", + items: { + type: "object", + properties: { + billingData: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe( + "definitions.Resource.properties.properties.properties.list.items.properties.billingData" + ) + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should find errors when billingData is defined in tuple-style array items`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { + type: "object", + properties: { + billingData: { + type: "string", + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe( + "definitions.Resource.properties.properties.properties.tuple.items.0.properties.billingData" + ) + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) + +test(`${RULE} should find errors when billingData is defined in additionalProperties`, async () => { + const oasDoc = { + swagger: "2.0", + paths: {}, + definitions: { + Resource: { + properties: { + properties: { + type: "object", + properties: { + map: { + type: "object", + additionalProperties: { + type: "object", + properties: { + billingData: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1) + expect(results[0].path.join(".")).toBe( + "definitions.Resource.properties.properties.properties.map.additionalProperties.properties.billingData" + ) + expect(results[0].message).toBe(ERROR_MESSAGE) + }) +}) From 232266eb860d35a440ca73e2769c6d561f4588ab Mon Sep 17 00:00:00 2001 From: "REDMOND\\vayada" Date: Thu, 9 Jul 2026 13:57:59 -0700 Subject: [PATCH 3/4] Address PR review: use 2.2.7 patch version bump Change packages/rulesets version from 2.3.0 to 2.2.7 (patch bump) and update CHANGELOG heading to '### Patches' per maintainer feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/rulesets/CHANGELOG.md | 4 ++-- packages/rulesets/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rulesets/CHANGELOG.md b/packages/rulesets/CHANGELOG.md index 4c84d8a6b..20b849d2b 100644 --- a/packages/rulesets/CHANGELOG.md +++ b/packages/rulesets/CHANGELOG.md @@ -1,8 +1,8 @@ # Change Log - @microsoft.azure/openapi-validator-rulesets -## 2.3.0 +## 2.2.7 -### Minor changes +### Patches - Added rule BillingDataInPropertiesBag to disallow the reserved 'BillingData' property name (case-insensitive) in a resource's properties bag diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 99c204afc..8b142d73d 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft.azure/openapi-validator-rulesets", - "version": "2.3.0", + "version": "2.2.7", "description": "Azure OpenAPI Validator", "main": "dist/index.js", "files": [ From f5a642b1d03bd77a17c2177e4d9862384657b1d7 Mon Sep 17 00:00:00 2001 From: "REDMOND\\vayada" Date: Tue, 21 Jul 2026 21:50:16 -0700 Subject: [PATCH 4/4] Generalize BillingData rule into ReservedNamesInPropertiesBag Address PR review (@raosuhas): redo the rule to disallow reserved property-bag names via an extensible RESERVED_PROPERTY_NAMES array instead of only 'BillingData'. Rename the rule/function/test/doc, surface a dynamic per-property message via {{error}}, and regenerate docs/rules.md and the az-arm.js bundle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/billing-data-in-properties-bag.md | 57 ---------- docs/reserved-names-in-properties-bag.md | 57 ++++++++++ docs/rules.md | 12 +- packages/rulesets/CHANGELOG.md | 2 +- .../rulesets/generated/spectral/az-arm.js | 103 +++++++++--------- packages/rulesets/src/spectral/az-arm.ts | 15 +-- .../billing-data-in-properties-bag.ts | 74 ------------- .../reserved-names-in-properties-bag.ts | 80 ++++++++++++++ ... reserved-names-in-properties-bag.test.ts} | 66 +++++------ 9 files changed, 234 insertions(+), 232 deletions(-) delete mode 100644 docs/billing-data-in-properties-bag.md create mode 100644 docs/reserved-names-in-properties-bag.md delete mode 100644 packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts create mode 100644 packages/rulesets/src/spectral/functions/reserved-names-in-properties-bag.ts rename packages/rulesets/src/spectral/test/{billing-data-in-properties-bag.test.ts => reserved-names-in-properties-bag.test.ts} (76%) diff --git a/docs/billing-data-in-properties-bag.md b/docs/billing-data-in-properties-bag.md deleted file mode 100644 index ca406b6f0..000000000 --- a/docs/billing-data-in-properties-bag.md +++ /dev/null @@ -1,57 +0,0 @@ -# BillingDataInPropertiesBag - -## Category - -ARM Error - -## Applies to - -ARM OpenAPI(swagger) specs - -## Description - -A property named `BillingData` (matched case-insensitively) must not be present in a resource's properties bag. If billing-related data is required, model it under a dedicated, appropriately named property or a separate model definition instead of placing a `BillingData` property directly in the resource properties bag. - -## How to fix the violation - -Remove the `BillingData` property from the resource properties bag. Represent the information using a differently named property or a dedicated model definition as appropriate. - -### Valid/Good Example - -```json -"Resource": { - "properties": { - "properties": { - "provisioningState": { - "type": "string" - } - } - } -} -``` - -### Invalid/Bad Example - -```json -"Resource": { - "properties": { - "properties": { - "billingData": { - "type": "string" - } - } - } -} -``` - -```json -"Resource": { - "properties": { - "properties": { - "billingData": { - "$ref": "#/definitions/BillingData" - } - } - } -} -``` diff --git a/docs/reserved-names-in-properties-bag.md b/docs/reserved-names-in-properties-bag.md new file mode 100644 index 000000000..e1a4c7377 --- /dev/null +++ b/docs/reserved-names-in-properties-bag.md @@ -0,0 +1,57 @@ +# ReservedNamesInPropertiesBag + +## Category + +ARM Error + +## Applies to + +ARM OpenAPI(swagger) specs + +## Description + +Certain property names are reserved and must not be defined in a resource's properties bag. Reserved names are matched case-insensitively and are maintained as an extensible list in the rule (currently `BillingData`); more names may be added over time. If information represented by a reserved name is required, model it under a dedicated, appropriately named property or a separate model definition instead of placing a reserved property directly in the resource properties bag. + +## How to fix the violation + +Remove the reserved property from the resource properties bag. Represent the information using a differently named property or a dedicated model definition as appropriate. + +### Valid/Good Example + +```json +"Resource": { + "properties": { + "properties": { + "provisioningState": { + "type": "string" + } + } + } +} +``` + +### Invalid/Bad Example + +```json +"Resource": { + "properties": { + "properties": { + "billingData": { + "type": "string" + } + } + } +} +``` + +```json +"Resource": { + "properties": { + "properties": { + "billingData": { + "$ref": "#/definitions/BillingData" + } + } + } +} +``` diff --git a/docs/rules.md b/docs/rules.md index 5e738fb6b..4e612bf53 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -145,12 +145,6 @@ This rule is to check if the tags definition of a resource conforms to the commo Please refer to [azure-resource-tags-schema.md](./azure-resource-tags-schema.md) for details. -### BillingDataInPropertiesBag - -A property named `BillingData` (matched case-insensitively) must not be present in a resource's properties bag. If billing-related data is required, model it under a dedicated, appropriately named property or a separate model definition instead of placing a `BillingData` property directly in the resource properties bag. - -Please refer to [billing-data-in-properties-bag.md](./billing-data-in-properties-bag.md) for details. - ### BodyPropertiesNamesCamelCase This violation is flagged if a request body parameter's property name (BodyPropertiesNamesCamelCase) is not in `camelCase` format. This is because the model's properties are sent across the wire and must adhere to common Json conventions for naming. This implies that every property name must start with a lower cased letter and all subsequent words within the name must start with a capital letter. In cases where there are acronyms involved, a maximum of three contiguous capital letters are allowed (acronyms should not be longer that 2 characters - see https://msdn.microsoft.com/en-us/library/141e06ef(v=vs.71).aspx, third upper case could be start of next word). Eg: `redisCache`, `publicIPAddress`, `location` are valid, but `sampleSQLQuery` is not (must be renamed as `sampleSqlQuery`). @@ -1094,6 +1088,12 @@ Per [common-api-contracts](https://github.com/Azure/azure-resource-manager-rpc/b Please refer to [required-read-only-system-data.md](./required-read-only-system-data.md) for details. +### ReservedNamesInPropertiesBag + +Certain property names are reserved and must not be defined in a resource's properties bag. Reserved names are matched case-insensitively and are maintained as an extensible list in the rule (currently `BillingData`); more names may be added over time. If information represented by a reserved name is required, model it under a dedicated, appropriately named property or a separate model definition instead of placing a reserved property directly in the resource properties bag. + +Please refer to [reserved-names-in-properties-bag.md](./reserved-names-in-properties-bag.md) for details. + ### ReservedResourceNamesModelAsEnum Service-defined (reserved) resource names must be represented as an `enum` type with `modelAsString` set to `true`, not diff --git a/packages/rulesets/CHANGELOG.md b/packages/rulesets/CHANGELOG.md index 20b849d2b..ab22e9329 100644 --- a/packages/rulesets/CHANGELOG.md +++ b/packages/rulesets/CHANGELOG.md @@ -4,7 +4,7 @@ ### Patches -- Added rule BillingDataInPropertiesBag to disallow the reserved 'BillingData' property name (case-insensitive) in a resource's properties bag +- Added rule ReservedNamesInPropertiesBag to disallow reserved property names (case-insensitive; currently 'BillingData') in a resource's properties bag ## 2.2.6 diff --git a/packages/rulesets/generated/spectral/az-arm.js b/packages/rulesets/generated/spectral/az-arm.js index 79085735f..690394f02 100644 --- a/packages/rulesets/generated/spectral/az-arm.js +++ b/packages/rulesets/generated/spectral/az-arm.js @@ -1401,53 +1401,6 @@ const verifyArmPath = createRulesetFunction({ return errors; }); -const BILLING_DATA = "billingdata"; -const PROPERTIES$2 = "properties"; -const ERROR_MESSAGE$3 = "The 'BillingData' property is not allowed in the resource properties bag."; -function collectBillingDataPropertyPaths(schema, basePath, matches, visited) { - if (!_.isObject(schema) || visited.has(schema)) { - return; - } - visited.add(schema); - const s = schema; - if (_.isObject(s.properties)) { - for (const [name, propertySchema] of Object.entries(s.properties)) { - if (name.toLowerCase() === BILLING_DATA) { - matches.push([...basePath, PROPERTIES$2, name]); - } - collectBillingDataPropertyPaths(propertySchema, [...basePath, PROPERTIES$2, name], matches, visited); - } - } - for (const keyword of ["allOf", "anyOf", "oneOf"]) { - const subschemas = s[keyword]; - if (Array.isArray(subschemas)) { - subschemas.forEach((subschema, index) => { - collectBillingDataPropertyPaths(subschema, [...basePath, keyword, index], matches, visited); - }); - } - } - if (Array.isArray(s.items)) { - s.items.forEach((itemSchema, index) => { - collectBillingDataPropertyPaths(itemSchema, [...basePath, "items", index], matches, visited); - }); - } - else if (_.isObject(s.items)) { - collectBillingDataPropertyPaths(s.items, [...basePath, "items"], matches, visited); - } - if (_.isObject(s.additionalProperties)) { - collectBillingDataPropertyPaths(s.additionalProperties, [...basePath, "additionalProperties"], matches, visited); - } -} -const billingDataInPropertiesBag = (definition, _opts, ctx) => { - const bag = getProperties(definition); - const matches = []; - collectBillingDataPropertyPaths(bag, [], matches, new WeakSet()); - return matches.map((path) => ({ - message: ERROR_MESSAGE$3, - path: _.concat(ctx.path, PROPERTIES$2, path), - })); -}; - const bodyParamRepeatedInfo = (pathItem, _opts, paths) => { if (pathItem === null || typeof pathItem !== "object") { return []; @@ -2788,6 +2741,54 @@ const requestBodyMustExistForPutPatch = (putPatchOperationParameters, _opts, ctx return errors; }; +const PROPERTIES$2 = "properties"; +const RESERVED_PROPERTY_NAMES = ["BillingData"]; +const reservedNameLookup = new Set(RESERVED_PROPERTY_NAMES.map((name) => name.toLowerCase())); +const errorMessage$1 = (name) => `Reserved property name '${name}' is not allowed in the resource properties bag.`; +function collectReservedNamePaths(schema, basePath, matches, visited) { + if (!_.isObject(schema) || visited.has(schema)) { + return; + } + visited.add(schema); + const s = schema; + if (_.isObject(s.properties)) { + for (const [name, propertySchema] of Object.entries(s.properties)) { + if (reservedNameLookup.has(name.toLowerCase())) { + matches.push({ path: [...basePath, PROPERTIES$2, name], name }); + } + collectReservedNamePaths(propertySchema, [...basePath, PROPERTIES$2, name], matches, visited); + } + } + for (const keyword of ["allOf", "anyOf", "oneOf"]) { + const subschemas = s[keyword]; + if (Array.isArray(subschemas)) { + subschemas.forEach((subschema, index) => { + collectReservedNamePaths(subschema, [...basePath, keyword, index], matches, visited); + }); + } + } + if (Array.isArray(s.items)) { + s.items.forEach((itemSchema, index) => { + collectReservedNamePaths(itemSchema, [...basePath, "items", index], matches, visited); + }); + } + else if (_.isObject(s.items)) { + collectReservedNamePaths(s.items, [...basePath, "items"], matches, visited); + } + if (_.isObject(s.additionalProperties)) { + collectReservedNamePaths(s.additionalProperties, [...basePath, "additionalProperties"], matches, visited); + } +} +const reservedNamesInPropertiesBag = (definition, _opts, ctx) => { + const bag = getProperties(definition); + const matches = []; + collectReservedNamePaths(bag, [], matches, new WeakSet()); + return matches.map((match) => ({ + message: errorMessage$1(match.name), + path: _.concat(ctx.path, PROPERTIES$2, match.path), + })); +}; + const ARM_ALLOWED_RESERVED_NAMES = ["operations"]; const INCLUDED_OPERATIONS = ["get", "put", "delete", "patch"]; const reservedResourceNamesModelAsEnum = (pathItem, _opts, ctx) => { @@ -4109,16 +4110,16 @@ const ruleset = { function: systemDataInPropertiesBag, }, }, - BillingDataInPropertiesBag: { - description: "The 'BillingData' property is not allowed in the resource properties bag.", - message: "{{description}}", + ReservedNamesInPropertiesBag: { + description: "Reserved property names are not allowed in the resource properties bag.", + message: "{{error}}", severity: "error", stagingOnly: true, resolved: true, formats: [oas2], given: ["$.definitions.*.properties[?(@property === 'properties')]^"], then: { - function: billingDataInPropertiesBag, + function: reservedNamesInPropertiesBag, }, }, ReservedResourceNamesModelAsEnum: { diff --git a/packages/rulesets/src/spectral/az-arm.ts b/packages/rulesets/src/spectral/az-arm.ts index f190d4ea7..033cbb7f1 100644 --- a/packages/rulesets/src/spectral/az-arm.ts +++ b/packages/rulesets/src/spectral/az-arm.ts @@ -2,7 +2,6 @@ import { oas2 } from "@stoplight/spectral-formats" import { falsy, pattern, truthy } from "@stoplight/spectral-functions" import common from "./az-common" import verifyArmPath from "./functions/arm-path-validation" -import { billingDataInPropertiesBag } from "./functions/billing-data-in-properties-bag" import bodyParamRepeatedInfo from "./functions/body-param-repeated-info" import { camelCase } from "./functions/camel-case" import collectionObjectPropertiesNaming from "./functions/collection-object-properties-naming" @@ -43,6 +42,7 @@ import { putRequestResponseScheme } from "./functions/put-request-response-schem import { PutResponseCodes } from "./functions/put-response-codes" import { queryParametersInCollectionGet } from "./functions/query-parameters-in-collection-get" import { requestBodyMustExistForPutPatch } from "./functions/request-body-must-exist-for-put-patch" +import { reservedNamesInPropertiesBag } from "./functions/reserved-names-in-properties-bag" import { reservedResourceNamesModelAsEnum } from "./functions/reserved-resource-names-model-as-enum" import resourceNameRestriction from "./functions/resource-name-restriction" import responseSchemaSpecifiedForSuccessStatusCode from "./functions/response-schema-specified-for-success-status-code" @@ -1027,11 +1027,12 @@ const ruleset: any = { }, }, - // A property named 'BillingData' (matched case-insensitively) must not be present in a - // resource's properties bag. - BillingDataInPropertiesBag: { - description: "The 'BillingData' property is not allowed in the resource properties bag.", - message: "{{description}}", + // Property names that are reserved (matched case-insensitively) must not be present in a + // resource's properties bag. The set of reserved names is defined in the + // reservedNamesInPropertiesBag function. + ReservedNamesInPropertiesBag: { + description: "Reserved property names are not allowed in the resource properties bag.", + message: "{{error}}", severity: "error", stagingOnly: true, resolved: true, @@ -1039,7 +1040,7 @@ const ruleset: any = { // given definitions that have the properties bag given: ["$.definitions.*.properties[?(@property === 'properties')]^"], then: { - function: billingDataInPropertiesBag, + function: reservedNamesInPropertiesBag, }, }, diff --git a/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts b/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts deleted file mode 100644 index 2d4d4d47a..000000000 --- a/packages/rulesets/src/spectral/functions/billing-data-in-properties-bag.ts +++ /dev/null @@ -1,74 +0,0 @@ -// BillingDataInPropertiesBag -// A property named 'BillingData' (matched case-insensitively) must not be defined in a -// resource's properties bag. - -import _ from "lodash" -import { getProperties } from "./utils" - -const BILLING_DATA = "billingdata" // compared case-insensitively -const PROPERTIES = "properties" -const ERROR_MESSAGE = "The 'BillingData' property is not allowed in the resource properties bag." - -// Recursively collect the path to every property named "BillingData" (case-insensitive) that is -// defined within the given schema. Traversal is restricted to schema-structure keywords -// (properties, allOf/anyOf/oneOf, items, additionalProperties) so that only actual property -// definitions are inspected. Values inside non-structural metadata (e.g. default values, enum -// values, examples, or vendor extensions) are intentionally ignored to avoid false positives. -function collectBillingDataPropertyPaths( - schema: any, - basePath: (string | number)[], - matches: (string | number)[][], - visited: WeakSet -): void { - if (!_.isObject(schema) || visited.has(schema)) { - return - } - visited.add(schema) - - const s = schema as { [key: string]: any } - - // properties: a map of property name -> property schema - if (_.isObject(s.properties)) { - for (const [name, propertySchema] of Object.entries(s.properties as { [key: string]: any })) { - if (name.toLowerCase() === BILLING_DATA) { - matches.push([...basePath, PROPERTIES, name]) - } - collectBillingDataPropertyPaths(propertySchema, [...basePath, PROPERTIES, name], matches, visited) - } - } - - // allOf / anyOf / oneOf: arrays of subschemas - for (const keyword of ["allOf", "anyOf", "oneOf"]) { - const subschemas = s[keyword] - if (Array.isArray(subschemas)) { - subschemas.forEach((subschema: any, index: number) => { - collectBillingDataPropertyPaths(subschema, [...basePath, keyword, index], matches, visited) - }) - } - } - - // items: a single subschema or an array of subschemas - if (Array.isArray(s.items)) { - s.items.forEach((itemSchema: any, index: number) => { - collectBillingDataPropertyPaths(itemSchema, [...basePath, "items", index], matches, visited) - }) - } else if (_.isObject(s.items)) { - collectBillingDataPropertyPaths(s.items, [...basePath, "items"], matches, visited) - } - - // additionalProperties: a subschema (when it is an object rather than a boolean) - if (_.isObject(s.additionalProperties)) { - collectBillingDataPropertyPaths(s.additionalProperties, [...basePath, "additionalProperties"], matches, visited) - } -} - -export const billingDataInPropertiesBag = (definition: any, _opts: any, ctx: any) => { - const bag = getProperties(definition) - const matches: (string | number)[][] = [] - collectBillingDataPropertyPaths(bag, [], matches, new WeakSet()) - - return matches.map((path) => ({ - message: ERROR_MESSAGE, - path: _.concat(ctx.path, PROPERTIES, path), - })) -} diff --git a/packages/rulesets/src/spectral/functions/reserved-names-in-properties-bag.ts b/packages/rulesets/src/spectral/functions/reserved-names-in-properties-bag.ts new file mode 100644 index 000000000..3b09bbb77 --- /dev/null +++ b/packages/rulesets/src/spectral/functions/reserved-names-in-properties-bag.ts @@ -0,0 +1,80 @@ +// ReservedNamesInPropertiesBag +// Property names that are reserved (matched case-insensitively) must not be defined in a +// resource's properties bag. + +import _ from "lodash" +import { getProperties } from "./utils" + +const PROPERTIES = "properties" + +// Property names that are reserved and must not appear in a resource's properties bag. +// Matching is case-insensitive. Add new reserved names to this array as they are identified. +const RESERVED_PROPERTY_NAMES = ["BillingData"] + +const reservedNameLookup = new Set(RESERVED_PROPERTY_NAMES.map((name) => name.toLowerCase())) + +const errorMessage = (name: string) => `Reserved property name '${name}' is not allowed in the resource properties bag.` + +// Recursively collect the path to every property whose name is reserved (case-insensitive) and is +// defined within the given schema. Traversal is restricted to schema-structure keywords +// (properties, allOf/anyOf/oneOf, items, additionalProperties) so that only actual property +// definitions are inspected. Values inside non-structural metadata (e.g. default values, enum +// values, examples, or vendor extensions) are intentionally ignored to avoid false positives. +function collectReservedNamePaths( + schema: any, + basePath: (string | number)[], + matches: { path: (string | number)[]; name: string }[], + visited: WeakSet +): void { + if (!_.isObject(schema) || visited.has(schema)) { + return + } + visited.add(schema) + + const s = schema as { [key: string]: any } + + // properties: a map of property name -> property schema + if (_.isObject(s.properties)) { + for (const [name, propertySchema] of Object.entries(s.properties as { [key: string]: any })) { + if (reservedNameLookup.has(name.toLowerCase())) { + matches.push({ path: [...basePath, PROPERTIES, name], name }) + } + collectReservedNamePaths(propertySchema, [...basePath, PROPERTIES, name], matches, visited) + } + } + + // allOf / anyOf / oneOf: arrays of subschemas + for (const keyword of ["allOf", "anyOf", "oneOf"]) { + const subschemas = s[keyword] + if (Array.isArray(subschemas)) { + subschemas.forEach((subschema: any, index: number) => { + collectReservedNamePaths(subschema, [...basePath, keyword, index], matches, visited) + }) + } + } + + // items: a single subschema or an array of subschemas + if (Array.isArray(s.items)) { + s.items.forEach((itemSchema: any, index: number) => { + collectReservedNamePaths(itemSchema, [...basePath, "items", index], matches, visited) + }) + } else if (_.isObject(s.items)) { + collectReservedNamePaths(s.items, [...basePath, "items"], matches, visited) + } + + // additionalProperties: a subschema (when it is an object rather than a boolean) + if (_.isObject(s.additionalProperties)) { + collectReservedNamePaths(s.additionalProperties, [...basePath, "additionalProperties"], matches, visited) + } +} + +export const reservedNamesInPropertiesBag = (definition: any, _opts: any, ctx: any) => { + const bag = getProperties(definition) + const matches: { path: (string | number)[]; name: string }[] = [] + collectReservedNamePaths(bag, [], matches, new WeakSet()) + + return matches.map((match) => ({ + message: errorMessage(match.name), + path: _.concat(ctx.path, PROPERTIES, match.path), + })) +} diff --git a/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts b/packages/rulesets/src/spectral/test/reserved-names-in-properties-bag.test.ts similarity index 76% rename from packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts rename to packages/rulesets/src/spectral/test/reserved-names-in-properties-bag.test.ts index 00460a19c..b61211b5c 100644 --- a/packages/rulesets/src/spectral/test/billing-data-in-properties-bag.test.ts +++ b/packages/rulesets/src/spectral/test/reserved-names-in-properties-bag.test.ts @@ -2,15 +2,15 @@ import { Spectral } from "@stoplight/spectral-core" import linterForRule from "./utils" let linter: Spectral -const RULE = "BillingDataInPropertiesBag" -const ERROR_MESSAGE = "The 'BillingData' property is not allowed in the resource properties bag." +const RULE = "ReservedNamesInPropertiesBag" +const errorMessageFor = (name: string) => `Reserved property name '${name}' is not allowed in the resource properties bag.` beforeAll(async () => { linter = await linterForRule(RULE) return linter }) -test(`${RULE} should find no errors when billingData is absent from the properties bag`, async () => { +test(`${RULE} should find no errors when no reserved name is present in the properties bag`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -34,7 +34,7 @@ test(`${RULE} should find no errors when billingData is absent from the properti }) }) -test(`${RULE} should find errors when billingData references a model definition`, async () => { +test(`${RULE} should find errors when a reserved name references a model definition`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -64,11 +64,11 @@ test(`${RULE} should find errors when billingData references a model definition` return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should find errors when billingData is a primitive type`, async () => { +test(`${RULE} should find errors when a reserved name is a primitive type`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -90,11 +90,11 @@ test(`${RULE} should find errors when billingData is a primitive type`, async () return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should find errors when billingData is an inline model`, async () => { +test(`${RULE} should find errors when a reserved name is an inline model`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -121,11 +121,11 @@ test(`${RULE} should find errors when billingData is an inline model`, async () return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should be case-insensitive when matching the property name`, async () => { +test(`${RULE} should be case-insensitive when matching a reserved name`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -151,12 +151,12 @@ test(`${RULE} should be case-insensitive when matching the property name`, async expect(results.length).toBe(2) expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.BillingData") expect(results[1].path.join(".")).toBe("definitions.Resource.properties.properties.properties.BILLINGDATA") - expect(results[0].message).toBe(ERROR_MESSAGE) - expect(results[1].message).toBe(ERROR_MESSAGE) + expect(results[0].message).toBe(errorMessageFor("BillingData")) + expect(results[1].message).toBe(errorMessageFor("BILLINGDATA")) }) }) -test(`${RULE} should not flag properties that only contain 'billingData' as a substring`, async () => { +test(`${RULE} should not flag properties that only contain a reserved name as a substring`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -180,7 +180,7 @@ test(`${RULE} should not flag properties that only contain 'billingData' as a su }) }) -test(`${RULE} should find errors when billingData is in a referenced properties definition`, async () => { +test(`${RULE} should find errors when a reserved name is in a referenced properties definition`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -205,11 +205,11 @@ test(`${RULE} should find errors when billingData is in a referenced properties return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.billingData") - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should find errors when billingData is nested inside another property definition`, async () => { +test(`${RULE} should find errors when a reserved name is nested inside another property definition`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -235,14 +235,12 @@ test(`${RULE} should find errors when billingData is nested inside another prope } return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) - expect(results[0].path.join(".")).toBe( - "definitions.Resource.properties.properties.properties.nested.properties.billingData" - ) - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.nested.properties.billingData") + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should not flag billingData defined as a top-level property outside the properties bag`, async () => { +test(`${RULE} should not flag a reserved name defined as a top-level property outside the properties bag`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -269,7 +267,7 @@ test(`${RULE} should not flag billingData defined as a top-level property outsid }) }) -test(`${RULE} should not flag billingData keys that appear inside non-structural schema metadata`, async () => { +test(`${RULE} should not flag reserved names that appear inside non-structural schema metadata`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -304,7 +302,7 @@ test(`${RULE} should not flag billingData keys that appear inside non-structural }) }) -test(`${RULE} should find errors when billingData is defined via allOf composition`, async () => { +test(`${RULE} should find errors when a reserved name is defined via allOf composition`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -330,11 +328,11 @@ test(`${RULE} should find errors when billingData is defined via allOf compositi return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.allOf.0.properties.billingData") - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should find errors when billingData is defined in array items`, async () => { +test(`${RULE} should find errors when a reserved name is defined in array items`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -363,14 +361,12 @@ test(`${RULE} should find errors when billingData is defined in array items`, as } return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) - expect(results[0].path.join(".")).toBe( - "definitions.Resource.properties.properties.properties.list.items.properties.billingData" - ) - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.list.items.properties.billingData") + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should find errors when billingData is defined in tuple-style array items`, async () => { +test(`${RULE} should find errors when a reserved name is defined in tuple-style array items`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -401,14 +397,12 @@ test(`${RULE} should find errors when billingData is defined in tuple-style arra } return linter.run(oasDoc).then((results) => { expect(results.length).toBe(1) - expect(results[0].path.join(".")).toBe( - "definitions.Resource.properties.properties.properties.tuple.items.0.properties.billingData" - ) - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].path.join(".")).toBe("definitions.Resource.properties.properties.properties.tuple.items.0.properties.billingData") + expect(results[0].message).toBe(errorMessageFor("billingData")) }) }) -test(`${RULE} should find errors when billingData is defined in additionalProperties`, async () => { +test(`${RULE} should find errors when a reserved name is defined in additionalProperties`, async () => { const oasDoc = { swagger: "2.0", paths: {}, @@ -440,6 +434,6 @@ test(`${RULE} should find errors when billingData is defined in additionalProper expect(results[0].path.join(".")).toBe( "definitions.Resource.properties.properties.properties.map.additionalProperties.properties.billingData" ) - expect(results[0].message).toBe(ERROR_MESSAGE) + expect(results[0].message).toBe(errorMessageFor("billingData")) }) })