@starci/eslint-canon-be 1.2.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +145 -69
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +340 -389
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +168 -303
- package/data-access.mjs +452 -307
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/event-delivery.mjs +93 -93
- package/idempotency.mjs +101 -0
- package/index.mjs +137 -107
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +17 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +408 -432
- package/module-shape.mjs +202 -0
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +479 -452
- package/transport.mjs +168 -168
- package/type-safety.mjs +551 -511
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -265
package/config-owner.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold HFS configuration and secrets (catalog R43 `BE_CONFIG_OWNER`, R44 `BE_SECRET_DEFAULT`,
|
|
3
|
+
* and the secret-comparison half of R41 `BE_DEFAULT_DENY`).
|
|
4
|
+
*
|
|
5
|
+
* - `no-direct-env-read` keeps `process.env` inside `platform/config`. Everything else receives typed options
|
|
6
|
+
* from `<capability>.config.ts`; a `readEnvironment()` call inside a `useFactory` is the same read in
|
|
7
|
+
* disguise and is refused too.
|
|
8
|
+
* - `no-secret-default` refuses a literal default for a secret, password, token, key or URL: an `env.X ?? "..."`
|
|
9
|
+
* fallback, a property or variable of that name holding a non-empty string, a `.default("...")` on a
|
|
10
|
+
* schema of that name, and a path built from `process.cwd()` and `src` or `.starcistacks`. A missing value
|
|
11
|
+
* must stop the boot, naming the key.
|
|
12
|
+
* - `secret-compare-timing-safe` refuses `===` and `!==` on a secret-named identifier. A secret compares with
|
|
13
|
+
* `timingSafeEqual`, because the length of the matching prefix is a measurable side channel.
|
|
14
|
+
*
|
|
15
|
+
* Test lanes are outside the default and env rules: a spec arranges its own environment and fake credentials.
|
|
16
|
+
* They are not outside the comparison rule.
|
|
17
|
+
*/
|
|
18
|
+
import { keyName, staticText, walk, wordsOf } from "./lib/ast.mjs"
|
|
19
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
20
|
+
|
|
21
|
+
const PLATFORM_CONFIG = /\/src\/modules\/platform\/config\//
|
|
22
|
+
|
|
23
|
+
/** `process.env` or `process["env"]`. */
|
|
24
|
+
const isProcessEnv = (node) =>
|
|
25
|
+
node.type === "MemberExpression" &&
|
|
26
|
+
node.object.type === "Identifier" &&
|
|
27
|
+
node.object.name === "process" &&
|
|
28
|
+
(node.computed ? node.property.type === "Literal" && node.property.value === "env" : keyName(node.property) === "env")
|
|
29
|
+
|
|
30
|
+
/** Only the platform config capability reads the process environment. */
|
|
31
|
+
export const noDirectEnvRead = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: "problem",
|
|
34
|
+
docs: { description: "`process.env` is read only inside `platform/config`." },
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: {
|
|
37
|
+
env: "`process.env` is read outside `platform/config`. Add the key to the capability's `<capability>.config.ts` (zod) and receive the value through its `<capability>.options.ts`.",
|
|
38
|
+
factory: "`readEnvironment()` inside a `useFactory` reads the environment per module. `main.ts` reads it once and passes options through `AppModule.register`.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
create(context) {
|
|
42
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
43
|
+
if (isDeclarationFile(filename) || isTestLane(filename) || PLATFORM_CONFIG.test(filename)) return {}
|
|
44
|
+
return {
|
|
45
|
+
MemberExpression(node) {
|
|
46
|
+
if (isProcessEnv(node)) context.report({ node, messageId: "env" })
|
|
47
|
+
},
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
if (node.callee.type !== "Identifier" || node.callee.name !== "readEnvironment") return
|
|
50
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
51
|
+
if (parent.type === "Property" && keyName(parent.key) === "useFactory") {
|
|
52
|
+
context.report({ node, messageId: "factory" })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SECRET_WORDS = new Set(["password", "passwd", "passphrase", "secret", "token", "credential", "credentials", "apikey", "signature", "hmac"])
|
|
62
|
+
const KEY_PREFIXES = new Set(["api", "secret", "private", "access", "signing", "encryption", "master", "auth", "jwt", "session"])
|
|
63
|
+
const URL_WORDS = new Set(["url", "uri", "dsn", "endpoint"])
|
|
64
|
+
const NOT_A_VALUE_WORDS = new Set(["type", "kind", "length", "count", "ttl", "name", "prefix", "header", "scheme", "field", "path", "regex", "pattern"])
|
|
65
|
+
|
|
66
|
+
/** Whether a config name is a secret, or an infrastructure URL: `POSTGRES_PASSWORD`, `webhookSecret`, `redisUrl`. */
|
|
67
|
+
const configKind = (name) => {
|
|
68
|
+
const words = wordsOf(name)
|
|
69
|
+
const last = words.at(-1)
|
|
70
|
+
if (!last || NOT_A_VALUE_WORDS.has(last)) return null
|
|
71
|
+
if (SECRET_WORDS.has(last)) return "secret"
|
|
72
|
+
if (last === "key" && words.length > 1 && KEY_PREFIXES.has(words.at(-2))) return "secret"
|
|
73
|
+
if (URL_WORDS.has(last)) return "url"
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The name an env read spells: `process.env.X`, `env.X`, `env["X"]`, `config.get("X")`. */
|
|
78
|
+
const envReadName = (node) => {
|
|
79
|
+
if (node.type === "MemberExpression") {
|
|
80
|
+
const object = node.object
|
|
81
|
+
const isEnvObject = isProcessEnv(object) || (object.type === "Identifier" && /^env$/i.test(object.name))
|
|
82
|
+
if (!isEnvObject) return null
|
|
83
|
+
return node.computed ? staticText(node.property) : keyName(node.property)
|
|
84
|
+
}
|
|
85
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && keyName(node.callee.property) === "get") {
|
|
86
|
+
return staticText(node.arguments[0])
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isNonEmptyText = (node) => {
|
|
92
|
+
const text = staticText(node)
|
|
93
|
+
return text !== null && text.trim() !== ""
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** An UPPER_SNAKE literal is the NAME of an environment key (`const API_KEY = "API_KEY"`), not a secret value. */
|
|
97
|
+
const isKeyName = (node, name) => {
|
|
98
|
+
const text = staticText(node) ?? ""
|
|
99
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(text)) return true
|
|
100
|
+
// `const MEDIA_SIGNING_SECRET_KEY = "mediaSigningSecret"`: a constant that names a lookup key holds the key's name
|
|
101
|
+
return /^[A-Z][A-Z0-9_]*_KEY$/.test(name ?? "") && /^[a-z][A-Za-z0-9]*$/.test(text)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const hasScheme = (node) => /^[a-z][a-z0-9+.-]*:\/\//i.test(staticText(node) ?? "")
|
|
105
|
+
|
|
106
|
+
const targetName = (node) => {
|
|
107
|
+
if (node.type === "Property" || node.type === "PropertyDefinition") return keyName(node.key)
|
|
108
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") return node.id.name
|
|
109
|
+
if (node.type === "AssignmentPattern" && node.left.type === "Identifier") return node.left.name
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** No secret, password, token, key or URL config has a literal default; a missing value stops the boot. */
|
|
114
|
+
export const noSecretDefault = {
|
|
115
|
+
meta: {
|
|
116
|
+
type: "problem",
|
|
117
|
+
docs: { description: "A secret or URL config value never has a literal default." },
|
|
118
|
+
schema: [],
|
|
119
|
+
messages: {
|
|
120
|
+
fallback: "`{{name}}` falls back to a literal. A missing {{kind}} must stop the boot with an error naming `{{name}}`, not start the app with a value from source.",
|
|
121
|
+
literal: "`{{name}}` holds a literal {{kind}}. A {{kind}} is supplied by configuration, never written in source.",
|
|
122
|
+
schemaDefault: "`.default(...)` gives `{{name}}` a value in source. Remove the default so a missing {{kind}} fails validation at boot.",
|
|
123
|
+
cwdPath: "A path built from `process.cwd()` and `{{segment}}` depends on where the process was started. Resolve it from a configured root.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
create(context) {
|
|
127
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
128
|
+
if (isDeclarationFile(filename) || isTestLane(filename)) return {}
|
|
129
|
+
const report = (node, messageId, name, kind) => context.report({ node, messageId, data: { name, kind: kind === "url" ? "URL" : "secret" } })
|
|
130
|
+
const checkLiteralTarget = (node, value) => {
|
|
131
|
+
const name = targetName(node)
|
|
132
|
+
const kind = name ? configKind(name) : null
|
|
133
|
+
if (kind && value && isNonEmptyText(value) && (kind === "url" ? hasScheme(value) : !isKeyName(value, name))) report(node, "literal", name, kind)
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
LogicalExpression(node) {
|
|
137
|
+
if (node.operator !== "??" && node.operator !== "||") return
|
|
138
|
+
const name = envReadName(node.left)
|
|
139
|
+
const kind = name ? configKind(name) : null
|
|
140
|
+
if (kind && isNonEmptyText(node.right) && (kind === "url" ? hasScheme(node.right) : !isKeyName(node.right, name))) report(node, "fallback", name, kind)
|
|
141
|
+
},
|
|
142
|
+
Property(node) {
|
|
143
|
+
checkLiteralTarget(node, node.value)
|
|
144
|
+
},
|
|
145
|
+
PropertyDefinition(node) {
|
|
146
|
+
checkLiteralTarget(node, node.value)
|
|
147
|
+
},
|
|
148
|
+
VariableDeclarator(node) {
|
|
149
|
+
checkLiteralTarget(node, node.init)
|
|
150
|
+
},
|
|
151
|
+
AssignmentPattern(node) {
|
|
152
|
+
checkLiteralTarget(node, node.right)
|
|
153
|
+
},
|
|
154
|
+
CallExpression(node) {
|
|
155
|
+
const callee = node.callee
|
|
156
|
+
if (callee.type === "MemberExpression" && keyName(callee.property) === "default" && isNonEmptyText(node.arguments[0])) {
|
|
157
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
158
|
+
if (parent.type === "Property") {
|
|
159
|
+
const name = keyName(parent.key)
|
|
160
|
+
const kind = name ? configKind(name) : null
|
|
161
|
+
if (kind && (kind !== "url" || hasScheme(node.arguments[0]))) report(node, "schemaDefault", name, kind)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (callee.type === "MemberExpression" && ["join", "resolve"].includes(keyName(callee.property) ?? "")) {
|
|
168
|
+
let cwd = false
|
|
169
|
+
walk(node, (child) => {
|
|
170
|
+
if (child.type === "CallExpression" && child.callee.type === "MemberExpression" && keyName(child.callee.property) === "cwd") cwd = true
|
|
171
|
+
})
|
|
172
|
+
if (!cwd) return
|
|
173
|
+
for (const argument of node.arguments) {
|
|
174
|
+
const text = staticText(argument)
|
|
175
|
+
const segment = text === null ? null : text.split(/[\\/]/).find((part) => part === "src" || part === ".starcistacks")
|
|
176
|
+
if (segment) context.report({ node, messageId: "cwdPath", data: { segment } })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const SECRET_COMPARE_WORDS = new Set(["secret", "password", "passwd", "passphrase", "token", "signature", "hmac", "credential", "credentials", "apikey"])
|
|
185
|
+
|
|
186
|
+
/** The identifier or property a comparison operand ends in, else null. */
|
|
187
|
+
const operandName = (node) => {
|
|
188
|
+
if (node.type === "Identifier") return node.name
|
|
189
|
+
if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") return node.property.name
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Words before the last one that make a token a counter or a cursor, not a credential. */
|
|
194
|
+
const NOT_A_CREDENTIAL_QUALIFIERS = new Set(["fencing", "idempotency", "cursor", "page", "next", "continuation", "pagination", "sync", "csrf"])
|
|
195
|
+
|
|
196
|
+
const isSecretName = (name) => {
|
|
197
|
+
// an UPPER_SNAKE name is a constant naming a key, not the value it names
|
|
198
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(name)) return false
|
|
199
|
+
const words = wordsOf(name)
|
|
200
|
+
const last = words.at(-1)
|
|
201
|
+
if (!last || NOT_A_VALUE_WORDS.has(last) || words.some((word) => NOT_A_CREDENTIAL_QUALIFIERS.has(word))) return false
|
|
202
|
+
return SECRET_COMPARE_WORDS.has(last) || (last === "key" && words.length > 1 && KEY_PREFIXES.has(words.at(-2)))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** An emptiness or presence check reveals nothing a timing attack could use. */
|
|
206
|
+
const isPresenceOperand = (node) =>
|
|
207
|
+
(node.type === "Literal" && (node.value === null || node.value === "" || typeof node.value === "boolean")) ||
|
|
208
|
+
(node.type === "Identifier" && node.name === "undefined") ||
|
|
209
|
+
(node.type === "UnaryExpression" && node.operator === "typeof")
|
|
210
|
+
|
|
211
|
+
/** A secret is compared with `timingSafeEqual`, never with an equality operator. */
|
|
212
|
+
export const secretCompareTimingSafe = {
|
|
213
|
+
meta: {
|
|
214
|
+
type: "problem",
|
|
215
|
+
docs: { description: "A secret-named value is compared with `timingSafeEqual`, not `===` or `!==`." },
|
|
216
|
+
schema: [],
|
|
217
|
+
messages: {
|
|
218
|
+
compare: "`{{name}}` is a secret compared with `{{operator}}`. Compare secrets with `crypto.timingSafeEqual` on equal-length buffers so the comparison time does not leak the match.",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
create(context) {
|
|
222
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
223
|
+
if (isDeclarationFile(filename)) return {}
|
|
224
|
+
return {
|
|
225
|
+
BinaryExpression(node) {
|
|
226
|
+
if (!["===", "!==", "==", "!="].includes(node.operator)) return
|
|
227
|
+
if (isPresenceOperand(node.left) || isPresenceOperand(node.right)) return
|
|
228
|
+
for (const side of [node.left, node.right]) {
|
|
229
|
+
const name = operandName(side)
|
|
230
|
+
if (name && isSecretName(name)) {
|
|
231
|
+
context.report({ node, messageId: "compare", data: { name, operator: node.operator } })
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The rules this law contributes to the plugin. */
|
|
241
|
+
export const rules = {
|
|
242
|
+
"no-direct-env-read": noDirectEnvRead,
|
|
243
|
+
"no-secret-default": noSecretDefault,
|
|
244
|
+
"secret-compare-timing-safe": secretCompareTimingSafe,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** All three start at error: a secret in source or a timing leak is never a warning. */
|
|
248
|
+
export const recommended = {
|
|
249
|
+
"starci-be/no-direct-env-read": "error",
|
|
250
|
+
"starci-be/no-secret-default": "error",
|
|
251
|
+
"starci-be/secret-compare-timing-safe": "error",
|
|
252
|
+
}
|