Summary
Reactivating owasp-array-limit and owasp-string-limit via a custom ruleset produces zero violations against schemas that clearly violate them — an array-typed property with no maxItems, and a string-typed property with no maxLength/const/enum. Every other OWASP/OAS schema rule I tested (11 others, including the structurally near-identical owasp-integer-limit) fires correctly against the same document in the same invocation.
Bisected across release binaries: works through v0.26.1, broken from v0.26.2 onward (still broken in the current 0.29.9/0.30.0). The v0.26.2 release notes name the change most likely responsible: the new schema-directionality feature (#680), which makes these two rules skip any schema not classified as request-bound. I traced the classification logic (utils.GetSchemaDirection, utils/schema_direction.go) and found a plausible mechanism below — but even a schema referenced directly from a requestBody (unambiguously request-bound) still fails to fire, so the bug looks broader than an edge case in "response-only schemas should be skipped."
Environment
- vacuum version: reproduced on the actual pinned release binary
0.29.9 (downloaded directly from https://github.com/daveshanley/vacuum/releases/download/v0.29.9/vacuum_0.29.9_darwin_arm64.tar.gz — not a package manager build, not a source build).
- Also reproduced separately on a Homebrew-installed
0.30.0 binary (self-reported version).
- Bisection below uses the same official release-binary download pattern for each version tested.
- OS: macOS (Darwin 24.6.0), arm64.
Minimal reproduction
doc.json:
{
"openapi": "3.1.0",
"info": {"title": "x", "version": "1.0"},
"paths": {},
"components": {
"schemas": {
"Widget": {
"type": "object",
"properties": {
"tags": {"type": "array"},
"name": {"type": "string"}
}
}
}
}
}
ruleset.yaml:
extends: [[vacuum:oas, off], [vacuum:owasp, off]]
rules:
owasp-array-limit: true
owasp-string-limit: true
Command:
vacuum spectral-report -k -r ruleset.yaml doc.json out.json
Expected
Two violations: tags has no maxItems (owasp-array-limit's own message: schema of type `array` must specify `maxItems`), and name has no maxLength/const/enum (owasp-string-limit's own message: schema of type `string` must specify `maxLength`, `const` or `enum`).
Actual
Zero violations, on every binary from v0.26.2 onward (see bisection).
Positive control — same document, same invocation, adjacent rule fires correctly
Adding an equally-violating integer property and reactivating owasp-integer-limit alongside the same two rules, in one invocation:
doc-with-control.json:
{
"openapi": "3.1.0",
"info": {"title": "x", "version": "1.0"},
"paths": {},
"components": {
"schemas": {
"Widget": {
"type": "object",
"properties": {
"tags": {"type": "array"},
"name": {"type": "string"},
"count": {"type": "integer", "format": "int32"}
}
}
}
}
}
ruleset-with-control.yaml:
extends: [[vacuum:oas, off], [vacuum:owasp, off]]
rules:
owasp-array-limit: true
owasp-string-limit: true
owasp-integer-limit: true
Command:
vacuum spectral-report -k -r ruleset-with-control.yaml doc-with-control.json out-with-control.json
Result (out-with-control.json):
[
{
"code": "owasp-integer-limit",
"path": ["components", "schemas", "Widget", "properties", "count"],
"message": "schema of type `integer` must specify `minimum` and `maximum` or `exclusiveMinimum` and `exclusiveMaximum`",
"severity": 0,
"range": {"start": {"line": 12, "character": 21}, "end": {"line": 12, "character": 27}},
"source": "doc-with-control.json"
}
]
Only owasp-integer-limit fires — owasp-array-limit/owasp-string-limit stay silent on tags/name in the exact same document and invocation. This rules out a DrDocument-population issue, a -k/document-validity issue, or a ruleset-loading issue — all three rules go through identical machinery up to the point of their own per-rule logic.
Bisection
Same doc.json/ruleset.yaml above, run against each official release binary (vacuum_<version>_darwin_arm64.tar.gz from the matching GitHub release tag):
| version |
result |
| v0.25.0 |
✅ 2 violations (correct) |
| v0.26.0 |
✅ 2 violations (correct) |
| v0.26.1 |
✅ 2 violations (correct) |
| v0.26.2 |
❌ 0 violations |
| v0.26.3 – v0.26.8 |
❌ 0 violations |
| v0.27.0 |
❌ 0 violations |
| v0.28.0 |
❌ 0 violations |
| v0.29.9 (pinned) |
❌ 0 violations |
| v0.30.0 (Homebrew) |
❌ 0 violations |
v0.26.2's release notes (gh release view v0.26.2 / GitHub releases page):
OWASP string-limit and array-limit now respect schema directionality, so response-only schemas are skipped while request/bidirectional schemas are still checked. #680
This is the only changelog entry across the bisected range that mentions string-limit/array-limit, and it lands exactly on the v0.26.1→v0.26.2 boundary found above.
Root cause investigation
functions/owasp/array_limit.go/string_limit.go (as of v0.29.9) gate each schema on:
var direction = utils.GetSchemaDirection(context.DrDocument.V3Document.Document, schema.Name)
if direction != utils.DirectionRequest && direction != utils.DirectionBoth {
continue
}
GetSchemaDirection (utils/schema_direction.go) walks every path/operation's requestBody/parameters/responses, and for each one calls refMatches(schemaProxy, schemaName) to decide whether that operation's schema is the schema being classified. refMatchesInternal resolves this by taking the $ref string off the SchemaProxy under inspection, splitting on /, and comparing the last segment against name:
reference := schemaProxy.GetReference()
parts := strings.Split(reference, "/")
actualName := parts[len(parts)-1]
...
if name == actualName {
return true
}
I tested whether an unambiguous request-only schema (directly $ref'd from a POST /widgets requestBody, nothing pointing at it from any response) fixes this:
{
"openapi": "3.1.0",
"info": {"title": "x", "version": "1.0"},
"paths": {
"/widgets": {
"post": {
"requestBody": {
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Widget"}}}
},
"responses": {"200": {"description": "ok"}}
}
}
},
"components": {"schemas": {"Widget": {"type": "object", "properties": {"tags": {"type": "array"}, "name": {"type": "string"}}}}}
}
Run with the same owasp-array-limit/owasp-string-limit ruleset, no -k needed (this is a fully valid OAS 3.1 document) — still zero violations, while adding owasp-integer-limit and an integer property to the same schema still fires correctly in the same invocation. So this isn't only "orphaned/unreferenced schemas get miscategorized" — a schema that's about as unambiguously request-bound as it's possible to construct is still being classified as not-request (or the classification result isn't reaching the skip check correctly).
The likeliest mechanism, based on reading the code (not confirmed by instrumenting a build): GetSchemaDirection is called with schema.Name — the Name field off a context.DrDocument.Schemas entry, i.e. whatever the doctor/libopenapi "dr" model populates for a schema discovered by walking the whole document (array_limit.go's for _, schema := range context.DrDocument.Schemas loop). refMatchesInternal, on the other hand, derives its comparison name (actualName) purely from splitting a $ref string on / and taking the last segment. If schema.Name on the DrDocument.Schemas entry isn't in that same bare-trailing-segment form (e.g. it's empty, or a full path/pointer, or uses different casing/normalization), name == actualName never matches for any schema, usedInRequest/usedInResponse never get set, GetSchemaDirection always returns DirectionNone, and the rule skips every schema unconditionally — which matches what I observed (never fires, even for the most unambiguous case).
Suggested fix
Confirm what context.DrDocument.Schemas[i].Name actually holds for a components.schemas entry (and for an inline/nested schema) versus what refMatchesInternal's actualName (last /-segment of a $ref) expects — that mismatch, if it's what I think it is, would explain every observation above: total silence regardless of how unambiguously request-bound the schema is, while every rule not touched by #680 keeps working normally on the same documents.
Impact / workaround
We ported a subset of vacuum's built-in OAS/OWASP schema rules into a downstream tool (via the extends: [[pack, off]] + rules: {id: true} reactivation pattern) and had to exclude owasp-array-limit/owasp-string-limit from that set after finding they silently produce no findings — meaning a consumer would believe unbounded array/string schemas are clean when the check simply isn't running. No workaround beyond exclusion (or pinning to v0.26.1, if that's viable); every other rule we tried (13 total) works correctly.
Summary
Reactivating
owasp-array-limitandowasp-string-limitvia a custom ruleset produces zero violations against schemas that clearly violate them — anarray-typed property with nomaxItems, and astring-typed property with nomaxLength/const/enum. Every other OWASP/OAS schema rule I tested (11 others, including the structurally near-identicalowasp-integer-limit) fires correctly against the same document in the same invocation.Bisected across release binaries: works through v0.26.1, broken from v0.26.2 onward (still broken in the current
0.29.9/0.30.0). The v0.26.2 release notes name the change most likely responsible: the new schema-directionality feature (#680), which makes these two rules skip any schema not classified as request-bound. I traced the classification logic (utils.GetSchemaDirection,utils/schema_direction.go) and found a plausible mechanism below — but even a schema referenced directly from arequestBody(unambiguously request-bound) still fails to fire, so the bug looks broader than an edge case in "response-only schemas should be skipped."Environment
0.29.9(downloaded directly fromhttps://github.com/daveshanley/vacuum/releases/download/v0.29.9/vacuum_0.29.9_darwin_arm64.tar.gz— not a package manager build, not a source build).0.30.0binary (self-reported version).Minimal reproduction
doc.json:{ "openapi": "3.1.0", "info": {"title": "x", "version": "1.0"}, "paths": {}, "components": { "schemas": { "Widget": { "type": "object", "properties": { "tags": {"type": "array"}, "name": {"type": "string"} } } } } }ruleset.yaml:Command:
Expected
Two violations:
tagshas nomaxItems(owasp-array-limit's own message:schema of type `array` must specify `maxItems`), andnamehas nomaxLength/const/enum(owasp-string-limit's own message:schema of type `string` must specify `maxLength`, `const` or `enum`).Actual
Zero violations, on every binary from v0.26.2 onward (see bisection).
Positive control — same document, same invocation, adjacent rule fires correctly
Adding an equally-violating
integerproperty and reactivatingowasp-integer-limitalongside the same two rules, in one invocation:doc-with-control.json:{ "openapi": "3.1.0", "info": {"title": "x", "version": "1.0"}, "paths": {}, "components": { "schemas": { "Widget": { "type": "object", "properties": { "tags": {"type": "array"}, "name": {"type": "string"}, "count": {"type": "integer", "format": "int32"} } } } } }ruleset-with-control.yaml:Command:
Result (
out-with-control.json):[ { "code": "owasp-integer-limit", "path": ["components", "schemas", "Widget", "properties", "count"], "message": "schema of type `integer` must specify `minimum` and `maximum` or `exclusiveMinimum` and `exclusiveMaximum`", "severity": 0, "range": {"start": {"line": 12, "character": 21}, "end": {"line": 12, "character": 27}}, "source": "doc-with-control.json" } ]Only
owasp-integer-limitfires —owasp-array-limit/owasp-string-limitstay silent ontags/namein the exact same document and invocation. This rules out aDrDocument-population issue, a-k/document-validity issue, or a ruleset-loading issue — all three rules go through identical machinery up to the point of their own per-rule logic.Bisection
Same
doc.json/ruleset.yamlabove, run against each official release binary (vacuum_<version>_darwin_arm64.tar.gzfrom the matching GitHub release tag):v0.26.2's release notes (
gh release view v0.26.2/ GitHub releases page):This is the only changelog entry across the bisected range that mentions
string-limit/array-limit, and it lands exactly on the v0.26.1→v0.26.2 boundary found above.Root cause investigation
functions/owasp/array_limit.go/string_limit.go(as ofv0.29.9) gate each schema on:GetSchemaDirection(utils/schema_direction.go) walks every path/operation'srequestBody/parameters/responses, and for each one callsrefMatches(schemaProxy, schemaName)to decide whether that operation's schema is the schema being classified.refMatchesInternalresolves this by taking the$refstring off theSchemaProxyunder inspection, splitting on/, and comparing the last segment againstname:I tested whether an unambiguous request-only schema (directly
$ref'd from aPOST /widgetsrequestBody, nothing pointing at it from any response) fixes this:{ "openapi": "3.1.0", "info": {"title": "x", "version": "1.0"}, "paths": { "/widgets": { "post": { "requestBody": { "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Widget"}}} }, "responses": {"200": {"description": "ok"}} } } }, "components": {"schemas": {"Widget": {"type": "object", "properties": {"tags": {"type": "array"}, "name": {"type": "string"}}}}} }Run with the same
owasp-array-limit/owasp-string-limitruleset, no-kneeded (this is a fully valid OAS 3.1 document) — still zero violations, while addingowasp-integer-limitand anintegerproperty to the same schema still fires correctly in the same invocation. So this isn't only "orphaned/unreferenced schemas get miscategorized" — a schema that's about as unambiguously request-bound as it's possible to construct is still being classified as not-request (or the classification result isn't reaching the skip check correctly).The likeliest mechanism, based on reading the code (not confirmed by instrumenting a build):
GetSchemaDirectionis called withschema.Name— theNamefield off acontext.DrDocument.Schemasentry, i.e. whatever thedoctor/libopenapi"dr" model populates for a schema discovered by walking the whole document (array_limit.go'sfor _, schema := range context.DrDocument.Schemasloop).refMatchesInternal, on the other hand, derives its comparison name (actualName) purely from splitting a$refstring on/and taking the last segment. Ifschema.Nameon theDrDocument.Schemasentry isn't in that same bare-trailing-segment form (e.g. it's empty, or a full path/pointer, or uses different casing/normalization),name == actualNamenever matches for any schema,usedInRequest/usedInResponsenever get set,GetSchemaDirectionalways returnsDirectionNone, and the rule skips every schema unconditionally — which matches what I observed (never fires, even for the most unambiguous case).Suggested fix
Confirm what
context.DrDocument.Schemas[i].Nameactually holds for acomponents.schemasentry (and for an inline/nested schema) versus whatrefMatchesInternal'sactualName(last/-segment of a$ref) expects — that mismatch, if it's what I think it is, would explain every observation above: total silence regardless of how unambiguously request-bound the schema is, while every rule not touched by #680 keeps working normally on the same documents.Impact / workaround
We ported a subset of vacuum's built-in OAS/OWASP schema rules into a downstream tool (via the
extends: [[pack, off]] + rules: {id: true}reactivation pattern) and had to excludeowasp-array-limit/owasp-string-limitfrom that set after finding they silently produce no findings — meaning a consumer would believe unbounded array/string schemas are clean when the check simply isn't running. No workaround beyond exclusion (or pinning to v0.26.1, if that's viable); every other rule we tried (13 total) works correctly.