@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.
@@ -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
+ }