feat(lint): security-fls-unknown-field — a qualified FLS key naming a field the object does not declare - #16998
Conversation
…ect does not declare A permission set key like 'crm_account.description_nope' is stripped of its object prefix by PermissionEvaluator.getFieldPermissions and the remainder is looked up as a column, so a remainder no column answers to contributes nothing to the merged permission map: the declared masking never enforces and the field stays readable to every holder of the set. security-fls-unqualified-key caught only the bare spelling; this adds security-fls-unknown-field beside it, without touching it. Existence resolves through object-graph.ts, the shared field index this package already uses, so the rule inherits its three skips (object not in this stack, no readable field map, registry-injected system columns) rather than deriving a second opinion about what a field is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c58a6f438492e070bce1543a5207cd78a0d35adf && git checkout c58a6f438492e070bce1543a5207cd78a0d35adf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 419facddee69d4c4b569f3a8e33fe985b4267160 b84b9e23f28b05a77c5958253a5ae0a7653a1938 && git checkout -B drift-repro 419facddee69d4c4b569f3a8e33fe985b4267160 && git merge --no-ff b84b9e23f28b05a77c5958253a5ae0a7653a1938
node scripts/docs-audit/affected-docs.mjs --json 419facddee69d4c4b569f3a8e33fe985b4267160
|
…a schema that does not check it 'crm_account.' passed startsWith at runtime and resolved to the empty column name, matching nothing -- the same fail open security-fls-unknown-field exists to close. It was skipped with a comment claiming the schema owned the shape. PermissionSetSchema.fields is z.record(z.string(), FieldPermissionSchema) with no key regex and no refine in that file, and this loop is the package's only reader of those keys, so nothing reported it. The false sentence is replaced by the measurement, and a pin against the live schema keeps the premise from rotting silently. Judged after the two object skips, not before them: an empty field name is unmatchable independently of the object, but hoisting the check would start the rule judging keys whose object half it cannot resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Contract review (
|
Part of #16108 — deliberately
Part of, not a closing keyword: the card's own text makes the retirement of hotcrm's local assertiontest/authorization-coverage.test.tsconditional on this rule landing and being verified, and that verification is not this PR's to perform. Closing the card on merge would retire the accounting row while the downstream assertion is still the only thing holding the line.The defect, and the measurement that it stands
A permission set that authors
fields: { 'crm_account.description_nope': { readable: false } }declares a mask that can never enforce.PermissionEvaluator.getFieldPermissionskeeps a key only when it starts with the object prefix and then reads the remainder as a column name (key.substring(objectName.length + 1)), so a remainder no column answers to contributes nothing to the merged permission map — the field stays as readable and as editable as the object-level grant leaves it, for every holder of the set.security-fls-unqualified-keycatches only the bare spelling.The before leg was run first, as instructed. On the tree at
fe2b7554a5,packages/lintbuilt, one stack per leg throughvalidateSecurityPosture:descriptionsecurity-fls-unqualified-key,security-private-no-readscopecrm_account.description_nopesecurity-private-no-readscopeonlysecurity-fls-unknown-field,security-private-no-readscopecrm_account.descriptionsecurity-private-no-readscopeonlycrm_account.security-private-no-readscopeonlysecurity-fls-unknown-field,security-private-no-readscopeno_such_object.security-private-no-readscopeonlyB and C were indistinguishable before and are distinguished now; A is untouched and still reds under its own id, not under the new one.
security-private-no-readscopeis the constantinfo-tier background in all three legs, present in both columns, so it cancels.What triage estimated, and what it actually was — for #16119
Triage priced the hard part as getting the object's field set into this rule's input layer, and flagged it as the expensive half shared with #16119. That premise did not hold, and the answer is worth carrying to #16119 directly: the field set was already reachable, and not merely reachable —
validate-security-posture.tsalready imports fromobject-graph.ts(import { recordsOf } from './object-graph.js'), which is this package's shared field-resolution index. Reaching the field set cost one word added to an existing import statement (indexObjectGraph) and oneconstbefore the permission-set loop.object-graph.tsis not just a field-name lookup; it already encodes the three skips every field-existence rule in this package takes, and this rule inherits all three rather than deriving a second opinion:externalobject, an introspected datasource);created_at,owner_idwhere ownership provides one — which are real at runtime and appear in no authoredfields, resolved per object viainjectedColumnsFor, never the object-independent union.So #16119 should not budget for input plumbing on the RLS-predicate face either;
indexObjectGraphplusresolveFieldPathis already there, andvalidate-expressions.tsis already a consumer of it.Boundaries held
security-fls-unqualified-keyis untouched. Verified as a diff property, not an intention: the existing loop is byte-identical and the new rule is a second, disjoint loop over the same map beneath it — the first skips every key containing a dot, the second skips every key not containing one, so no key reaches both. Two ids, two messages, two prescriptions — an author suppressing one does not suppress the other.test/authorization-coverage.test.tsis not referenced, weakened or retired, and nothing in this diff or its changeset claims coverage that would let it be. This repo does not contain that file; the only thing this PR asserts about it is in the paragraph above.current_user.*variable, is reported by nothing — both fail CLOSED at runtime #16119 is not folded in. No RLS-predicate surface is touched.Design decisions worth a reviewer's eye
error, on the inverse of the usual ADR-0049 argument. This module's header says everyerrorrule but one mirrors a runtime enforcement point. This rule joins that exception alongsidesecurity-cbp-ambiguous-relation, for the same reason and stated at the push site: there is no runtime refusal to mirror because the runtime does not refuse — it silently ignores the key — so author time is the only place the defect can ever surface. It meets the same admissibility bar that exception states: decidable from the documents in front of the linter, no per-permission-set nuance to adjudicate, and no legitimate reading of a key that can never match. It is also the severity its siblingsecurity-fls-unqualified-keyalready carries for the identical runtime consequence.ObjectSchema.nameis/^[a-z_][a-z0-9_]*$/and cannot contain one. A key with more dots (crm_account.owner.name) is therefore judged on the whole remainder — which is what the evaluator looks up: FLS keys address columns, never joins. Resolving that as a relationship hop throughresolveFieldPathwould have been a fail-open divergence from the very gate this rule mirrors, so the rule readsnames/injectedmembership directly instead. Pinned by its own test.validateSecurityPosture, whose registration already declaresruntimeTypes: ['seed', 'permission', 'book', 'object']— both collections this rule reads are already in that entry, so no wall is crossed ([finding] The fourviews[]visibility-predicate rules are CLI-only — a Studio/REST/MCPviewwrite bypasses all of them; if they move to runtime-publish, they must move together #7220 / [3 of #7891] Flip the registration:runtimeTypesgainsobject+permission/book— whole rule families cross the publish door #8310).authoring-rules.tsandvalidate-security-posture.runtime-surface.test.tsboth described that entry as "12 rule ids". Measured by counting the distinctrule:constants the function emits, it was 13 before this change and is 14 now — the prose had already drifted by one, missingsecurity-cbp-ambiguous-relation([finding]controlled_by_parentmaster selection is silently decided by FIELD DECLARATION ORDER when an object declares several required lookups — reordering fields moves a security boundary with no diagnostic #14747). Both spellings are corrected to 14 with the drift noted in the test's comment. It is prose in the registration this rule joins, not an assertion; the pins beneath it are unchanged.Patch round — a fail-open this rule SAW and skipped, on a premise that was false
The first revision carried this line:
The schema does not own it, and I did not check before writing that. Re-derived here, with a control:
packages/spec/src/security/permission.zod.tsdeclaresfields: z.record(z.string(), FieldPermissionSchema).optional()— a bare string key, no pattern;refine,superRefineor.regex((control: the same grep overpackages/spec/src/data/object.zod.tsreturns 12, so the pattern is not silently matching nothing);packages/lint/src/validate-security-posture.tsis the only reader of permission-setfieldskeys in the package (one hit acrosspackages/lint/src/*.ts).So
'crm_account.'was reported by nothing, and at runtime it passes the prefix test and resolves to the empty column name — matching no column. That is the identical fail open this rule exists to close, sitting inside the rule, skipped by a sentence crediting coverage to a component that has none. That sentence is the same accounting trap this card warns about downstream, one level in, and it is the part of this PR I got wrong.Folded into
security-fls-unknown-field, with its own message branch (there is no field name to quote, so the dangling-field wording would have rendered asdeclares no field ''), a pin on the message, and a pin against the live schema so the premise cannot rot silently: if a future revision adds a key pattern or a refinement, that test reds and the rule's justification is re-opened deliberately instead of the rule quietly becoming a second opinion on the schema.Deliberately NOT widened. The check sits after the two object skips. An empty field name is in fact unmatchable independently of the object —
FieldSchema.nameis/^[a-z_][a-z0-9_]*$/, so no package can declare one — but hoisting it above skip 1 would start this rule judging keys whose object half it cannot resolve, which is exactly the disposition'.description'(empty object name) and a mis-cased object name are left to.'no_such_object.'therefore stays silent, pinned by leg E of the table above and by its own test with a firing control beside it.Clause-② — re-derived, not copied
My reading is
yes, and the changeset grades@objectstack/lintasminor. Derived in two steps rather than taken from the dispatch:npm pack --dry-run --jsononpackages/lintreports 17 entries withdist/published under the package'sfileswhitelist. Grepping the built artifact for the new rule id:dist/index.js= 1,dist/index.cjs= 1,dist/index.d.ts= 1. Positive control in the same command —security-fls-unqualified-key= 1 in each of the three. Nonsense control —security-fls-nonsense-control= 0 in each of the three. So the zero-vs-one reading is a reading.patch: a consumer whose stack carries a dangling FLS key builds clean on 17.3.0 and fails on the next version, aterrorseverity, on both the CLI path and the runtime publish door. Apatchcannot break a consumer's build. Notmajor: nothing is removed or reshaped, the new export is purely additive, and every stack without a dangling key is byte-identically clean.minoris also what this repo's own precedent grades a new gating rule id —.changeset/flow-filter-token-unknown.mdand.changeset/lint-field-no-consumers.mdare bothminorfor exactly this shape, while.changeset/chart-measure-unknown-presentation-positions.mdispatchfor a rule that narrows.skip-changesetwas never in play: the rule ships insidedist/, which is published.Verification
Run under
scripts/pm/os-verify-lock.sh; verdicts quoted from each gate's own printed line, exit codes captured before any pipe. All readings below are on the patched headb84b9e23f2.Tests and typecheck
pnpm --filter @objectstack/lint test—VERDICT command-exit 0; 102 files, 3596 tests passed.pnpm --filter @objectstack/lint typecheck—VERDICT command-exit 0(tsc --noEmitpluscheck:test-typecheck, the latter printingOK — @objectstack/lint's test layer compiles under packages/lint/tsconfig.test.json).Ablations — CORRECTED, and re-measured on the patched head
b84b9e23f2Each leg: mutate, prove the mutation reached disk by
git hash-objectdiffering from the file'sHEADblob, run, then restore withgit checkout HEAD -- ABSOLUTE_PATHunder anEXIT INT TERMtrap and prove the restore by the blob hash returning toa95310e35a84e083b4a87e802c4bd7fde3340b11withgit diff HEADempty. Scope:src/validate-security-posture.test.tsalone, 122 tests.if (surface.names.has(flsField) || surface.injected.has(flsField)) continue;replaced by a barecontinueflsGraphguards replaced byconst surface = flsGraph.get(flsObject) ?? { names: new Set(), injected: new Set(), fields: new Map() };Leg B is the one the review asked for, and it confirms the review's prediction exactly:
an all-qualified, all-real permission set emits NOTHING newstays green under it, because collapsing the skips cannot change the verdict on a key whose field genuinely resolves. Its four reds are precisely the skip-dependent ones —accepts object-qualified FLS keys,an object with NO fields at all does not throw,skip 1: an object this stack does not define is never judged, andskip 1 still holds for a truncated key naming an unknown object.skip 3, and mine does not. That is a mutation difference, not a disagreement about the code — my leg B leavesflsGraph.getin place, so a key on an object that IS in the stack still sees the real injected set andcreated_atstill resolves. Their leg evidently blanked the surface unconditionally. Both are legitimate mutations of "remove the skips"; the mutation text above says which one produced the 4.Leg C's 8 reconciles with the reviewer's independently measured true-always-fires count of 8, and it is the leg that answers the acceptance criterion by name: an implementation that always fires satisfies every positive test, and eight tests refuse it — including all five negative controls.
Negative controls, all with a firing control beside the zero
fieldsat all does not throw and is not judged — both spellings,fieldsabsent andfields: {};created_at) each emit zero, each measured against a nonsense sibling in the same run that emits one.Blast radius on the repo's own metadata
The rule was run over the shipped showcase's real security metadata (22 objects, 9 permission sets loaded from
examples/app-showcase/src): its six authored FLS keys — three distinct, all onshowcase_project— emit zero findings. Firing control in the same run: injecting one dangling key produces exactly onesecurity-fls-unknown-field.examples/app-crmauthors nofieldsmap at all. The authoritative sweep is CI'sobjectstack verifyleg over both example apps.Gates run locally (verdict line quoted, exit captured before any pipe)
pnpm check:nul-bytesOK (scanned 8410 text file(s) … no raw ASCII control bytes)check-empty-changeset --base origin/main1 declaring changeset(s) addedcheck-changeset-no-major --base origin/mainThis diff introduces no major bumpcheck-adr-0087-registration --base origin/mainadds no declared-breaking changesetpnpm check:changeset-gate-self-testspnpm check:published-files70 publishable package(s) … none narrows its resolvable surface against the merge basepnpm --filter @objectstack/lint check:doc-security-posture27 ObjectSchema.create example(s) … carry an os validate-clean security posturepnpm check:cross-package-test-inputs28 package(s) read outside themselves, all declaredpnpm check:test-source-alias73 packages with tests scannedpnpm check:type-check-coverage76/80 workspace packages type-checkedpnpm check:docs-audit/check-affected-docsDeclared NOT MEASURED, not green — each stopped on a stated prerequisite this container cannot meet, and each says so in its own words rather than passing:
check:published-readme-exports— exit 3,PREREQUISITE NOT MET: 44 packages' built type entries are absent, so nothing was judged. Needs a full-workspace build; CI's.check:type-check-debt--re-measure— exit 3,PREREQUISITE NOT MET: 28 workspace dependencies unbuilt. Its self-test and invariant half both passed (see the table).Lint, as a declared narrowing with all three readings
.tsfiles, count read from--format json: 5 files, 0 errors, 0 warnings;eslint.config.mjs:328states in as many words that there is noparserOptions.projectand no typed@typescript-eslintrules, so no verdict on an untouched file is a function of this diff. The five files also carry zero inline eslint directives, so--no-inline-config(whichpnpm lintadds and this run did not) changes nothing for them.A whole-repo
eslint .was additionally run: 104 errors / 30 warnings, none of them insidepackages/lint/src— they are pre-existing on the merge base and untouched by this diff.Not filed, noted here for the reviewer
docs/qa/platform-checklist/areas/access-security.jsonnames the sibling rule id and not this one. The previous new rule in this file (security-cbp-ambiguous-relation) is not there either, so there is no established obligation and none is discharged here. Recorded as a residual by the dispatching seat.This module's own header table indexes its rules by id and has never listed
security-fls-unqualified-key; the new rule is not added to it either, so the table's coverage is unchanged rather than made inconsistent. Both rules are documented in prose at their push sites, which is how the existing FLS rule is already documented.skills/objectstack-data/SKILL.mdtabulates 4 of this module's 14 rule ids. The new one joins the 10 already absent. Not touched: it is a published skill under the line ratchet, and a partial table is not a defect.A key naming an object this stack does not declare (
no_such_object.description) is still reported by nothing, and fails open the same way. That is skip 1, and it is deliberate rather than a gap in this rule: the object may be shipped by another installed package, so a package-time linter genuinely cannot answer. It is the same disposition the module's header already records for aguest-bound anchor set (lint:security-anchor-high-privilegekeys offpermissionSet.isDefault, so an anchor-bound set that does not author that flag is never checked #16110) — judged at bind/install time, not at authoring time. Named here so it is not mistaken for coverage this PR provides.Generated by Claude Code