@agjs/tsforge 0.1.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.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,195 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
3
+
4
+ import { createRule } from "../../create-rule";
5
+
6
+ export const RULE_NAME = "bcrypt-rounds-min";
7
+
8
+ export interface BcryptRoundsMinOptions {
9
+ readonly minRounds?: number;
10
+ readonly bcryptModules?: readonly string[];
11
+ }
12
+
13
+ type RuleOptions = [BcryptRoundsMinOptions];
14
+ type MessageIds = "roundsTooLow";
15
+
16
+ const DEFAULT_MIN_ROUNDS = 10;
17
+ const DEFAULT_BCRYPT_MODULES: readonly string[] = ["bcrypt", "bcryptjs"];
18
+
19
+ const optionSchema: JSONSchema4 = {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ properties: {
23
+ minRounds: { type: "number", minimum: 1 },
24
+ bcryptModules: {
25
+ type: "array",
26
+ items: { type: "string" },
27
+ uniqueItems: true,
28
+ minItems: 1,
29
+ },
30
+ },
31
+ };
32
+
33
+ interface ImportTracker {
34
+ readonly bindings: Map<string, string>; // local name -> module
35
+ }
36
+
37
+ export const bcryptRoundsMinRule = createRule<RuleOptions, MessageIds>({
38
+ name: RULE_NAME,
39
+ meta: {
40
+ type: "problem",
41
+ docs: {
42
+ description:
43
+ "Disallow `bcrypt.hash` / `bcrypt.hashSync` calls with a numeric-literal rounds value below the configured minimum (default 10).",
44
+ },
45
+ schema: [optionSchema],
46
+ messages: {
47
+ roundsTooLow:
48
+ "{{module}}.{{method}} with {{found}} rounds is below the {{min}} minimum — too cheap to bruteforce in 2026.",
49
+ },
50
+ },
51
+ defaultOptions: [
52
+ {
53
+ minRounds: DEFAULT_MIN_ROUNDS,
54
+ bcryptModules: [...DEFAULT_BCRYPT_MODULES],
55
+ },
56
+ ],
57
+ create(context, [options]) {
58
+ const minRounds = options.minRounds ?? DEFAULT_MIN_ROUNDS;
59
+ const bcryptModules = new Set(
60
+ options.bcryptModules ?? DEFAULT_BCRYPT_MODULES
61
+ );
62
+
63
+ const tracker: ImportTracker = { bindings: new Map() };
64
+
65
+ function getRootIdentifier(
66
+ node: TSESTree.Node
67
+ ): TSESTree.Identifier | null {
68
+ let current: TSESTree.Node = node;
69
+
70
+ while (current.type === AST_NODE_TYPES.MemberExpression) {
71
+ current = current.object;
72
+ }
73
+
74
+ if (current.type === AST_NODE_TYPES.Identifier) {
75
+ return current;
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ function getMemberMethodName(
82
+ callee: TSESTree.MemberExpression
83
+ ): string | null {
84
+ if (
85
+ callee.computed ||
86
+ callee.property.type !== AST_NODE_TYPES.Identifier
87
+ ) {
88
+ return null;
89
+ }
90
+
91
+ return callee.property.name;
92
+ }
93
+
94
+ return {
95
+ ImportDeclaration(node) {
96
+ const source = node.source.value;
97
+
98
+ if (!bcryptModules.has(source)) {
99
+ return;
100
+ }
101
+
102
+ for (const spec of node.specifiers) {
103
+ if (
104
+ spec.type === AST_NODE_TYPES.ImportDefaultSpecifier ||
105
+ spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier
106
+ ) {
107
+ tracker.bindings.set(spec.local.name, source);
108
+ } else if (spec.type === AST_NODE_TYPES.ImportSpecifier) {
109
+ // `import { hash } from "bcrypt"` — local name resolves directly.
110
+ tracker.bindings.set(spec.local.name, source);
111
+ }
112
+ }
113
+ },
114
+ CallExpression(node) {
115
+ const callee = node.callee;
116
+
117
+ // Member style: `bcrypt.hash(...)`, `bcrypt.hashSync(...)`.
118
+ if (callee.type === AST_NODE_TYPES.MemberExpression) {
119
+ const root = getRootIdentifier(callee);
120
+
121
+ if (root === null) {
122
+ return;
123
+ }
124
+
125
+ const moduleName = tracker.bindings.get(root.name);
126
+
127
+ if (moduleName === undefined) {
128
+ return;
129
+ }
130
+
131
+ const method = getMemberMethodName(callee);
132
+
133
+ if (method !== "hash" && method !== "hashSync") {
134
+ return;
135
+ }
136
+
137
+ checkRoundsArg(node, moduleName, method);
138
+
139
+ return;
140
+ }
141
+
142
+ // Direct call style: `import { hash } from "bcrypt"; hash(plain, 8);`
143
+ if (callee.type === AST_NODE_TYPES.Identifier) {
144
+ const moduleName = tracker.bindings.get(callee.name);
145
+
146
+ if (moduleName === undefined) {
147
+ return;
148
+ }
149
+
150
+ if (callee.name !== "hash" && callee.name !== "hashSync") {
151
+ return;
152
+ }
153
+
154
+ checkRoundsArg(node, moduleName, callee.name);
155
+ }
156
+ },
157
+ };
158
+
159
+ function checkRoundsArg(
160
+ node: TSESTree.CallExpression,
161
+ moduleName: string,
162
+ method: string
163
+ ): void {
164
+ const arg = node.arguments[1];
165
+
166
+ if (arg === undefined) {
167
+ return;
168
+ }
169
+
170
+ // Identifier or any non-literal: assumed env-driven, accepted.
171
+ if (arg.type !== AST_NODE_TYPES.Literal) {
172
+ return;
173
+ }
174
+
175
+ if (typeof arg.value !== "number") {
176
+ return;
177
+ }
178
+
179
+ if (arg.value >= minRounds) {
180
+ return;
181
+ }
182
+
183
+ context.report({
184
+ node: arg,
185
+ messageId: "roundsTooLow",
186
+ data: {
187
+ module: moduleName,
188
+ method,
189
+ found: String(arg.value),
190
+ min: String(minRounds),
191
+ },
192
+ });
193
+ }
194
+ },
195
+ });
@@ -0,0 +1,188 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ const PATTERN_TYPES = new Set<string>([
4
+ AST_NODE_TYPES.AssignmentPattern,
5
+ AST_NODE_TYPES.RestElement,
6
+ AST_NODE_TYPES.ArrayPattern,
7
+ AST_NODE_TYPES.ObjectPattern,
8
+ AST_NODE_TYPES.TSEmptyBodyFunctionExpression,
9
+ ]);
10
+
11
+ function isExpression(node: TSESTree.Node): node is TSESTree.Expression {
12
+ return !PATTERN_TYPES.has(node.type);
13
+ }
14
+
15
+ export const DEFAULT_AUTH_COOKIE_NAMES: readonly string[] = [
16
+ "auth_token",
17
+ "session",
18
+ "sid",
19
+ "authToken",
20
+ ];
21
+
22
+ export const DEFAULT_TRUSTED_CONFIG_NAMES: readonly string[] = [
23
+ "AUTH_COOKIE_CONFIG",
24
+ ];
25
+
26
+ export const DEFAULT_SET_COOKIE_FUNCTIONS: readonly string[] = [
27
+ "setCookie",
28
+ "set",
29
+ ];
30
+
31
+ export interface CookieSetCall {
32
+ /**
33
+ * Cookie name as a string. Empty string when the call form does not name
34
+ * a cookie (caller must decide whether to flag — usually no).
35
+ */
36
+ readonly cookieName: string;
37
+ /**
38
+ * The ObjectExpression carrying cookie options (httpOnly, secure, ...).
39
+ * `null` if the call form has no recognizable options object.
40
+ */
41
+ readonly optionsNode: TSESTree.ObjectExpression | null;
42
+ }
43
+
44
+ function getStringLiteral(node: TSESTree.Node | undefined): string | null {
45
+ if (node?.type === AST_NODE_TYPES.Literal && typeof node.value === "string") {
46
+ return node.value;
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ function getMemberPropertyName(node: TSESTree.MemberExpression): string | null {
53
+ if (!node.computed && node.property.type === AST_NODE_TYPES.Identifier) {
54
+ return node.property.name;
55
+ }
56
+
57
+ if (
58
+ node.computed &&
59
+ node.property.type === AST_NODE_TYPES.Literal &&
60
+ typeof node.property.value === "string"
61
+ ) {
62
+ return node.property.value;
63
+ }
64
+
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Recognizes three call shapes:
70
+ *
71
+ * 1. `cookie.<name>.set({...})` — Elysia / Hono style
72
+ * 2. `set("<name>", value, {...})` — generic helper
73
+ * 3. `reply.setCookie("<name>", value, {...})` — Fastify
74
+ *
75
+ * Returns null when the call doesn't match any known shape with an auth-named
76
+ * cookie.
77
+ */
78
+ export function matchAuthCookieSet(
79
+ node: TSESTree.CallExpression,
80
+ authCookieNames: ReadonlySet<string>,
81
+ setCookieFunctions: ReadonlySet<string>
82
+ ): CookieSetCall | null {
83
+ const callee = node.callee;
84
+
85
+ // Form 1: `cookie.<name>.set({...})`
86
+ if (
87
+ callee.type === AST_NODE_TYPES.MemberExpression &&
88
+ !callee.computed &&
89
+ callee.property.type === AST_NODE_TYPES.Identifier &&
90
+ callee.property.name === "set" &&
91
+ callee.object.type === AST_NODE_TYPES.MemberExpression
92
+ ) {
93
+ const innerName = getMemberPropertyName(callee.object);
94
+
95
+ if (innerName !== null && authCookieNames.has(innerName)) {
96
+ const arg = node.arguments[0];
97
+ const optionsNode =
98
+ arg?.type === AST_NODE_TYPES.ObjectExpression ? arg : null;
99
+
100
+ return { cookieName: innerName, optionsNode };
101
+ }
102
+ }
103
+
104
+ // Forms 2 + 3: helper-style calls.
105
+ // Identifier callee: `set(...)`. MemberExpression callee: `reply.setCookie(...)`.
106
+ let calleeName: string | null = null;
107
+
108
+ if (callee.type === AST_NODE_TYPES.Identifier) {
109
+ calleeName = callee.name;
110
+ } else if (callee.type === AST_NODE_TYPES.MemberExpression) {
111
+ calleeName = getMemberPropertyName(callee);
112
+ }
113
+
114
+ if (calleeName === null || !setCookieFunctions.has(calleeName)) {
115
+ return null;
116
+ }
117
+
118
+ const cookieName = getStringLiteral(node.arguments[0]);
119
+
120
+ if (cookieName === null || !authCookieNames.has(cookieName)) {
121
+ return null;
122
+ }
123
+
124
+ // Options object is typically the last positional argument.
125
+ const last = node.arguments[node.arguments.length - 1];
126
+ const optionsNode =
127
+ last?.type === AST_NODE_TYPES.ObjectExpression ? last : null;
128
+
129
+ return { cookieName, optionsNode };
130
+ }
131
+
132
+ export interface PropertyValue {
133
+ readonly value: TSESTree.Expression | null;
134
+ readonly hasTrustedSpread: boolean;
135
+ }
136
+
137
+ /**
138
+ * Returns the value node for `key` in the options object, plus whether the
139
+ * options object spreads a trusted config identifier (which we treat as
140
+ * "everything is set correctly").
141
+ */
142
+ export function lookupCookieOption(
143
+ options: TSESTree.ObjectExpression,
144
+ key: string,
145
+ trustedConfigNames: ReadonlySet<string>
146
+ ): PropertyValue {
147
+ let hasTrustedSpread = false;
148
+
149
+ for (const prop of options.properties) {
150
+ if (prop.type === AST_NODE_TYPES.SpreadElement) {
151
+ if (
152
+ prop.argument.type === AST_NODE_TYPES.Identifier &&
153
+ trustedConfigNames.has(prop.argument.name)
154
+ ) {
155
+ hasTrustedSpread = true;
156
+ }
157
+
158
+ continue;
159
+ }
160
+
161
+ if (prop.type !== AST_NODE_TYPES.Property || prop.computed) {
162
+ continue;
163
+ }
164
+
165
+ let propName: string | null = null;
166
+
167
+ if (prop.key.type === AST_NODE_TYPES.Identifier) {
168
+ propName = prop.key.name;
169
+ } else if (
170
+ prop.key.type === AST_NODE_TYPES.Literal &&
171
+ typeof prop.key.value === "string"
172
+ ) {
173
+ propName = prop.key.value;
174
+ }
175
+
176
+ if (propName === key) {
177
+ const value = prop.value;
178
+
179
+ if (!isExpression(value)) {
180
+ return { value: null, hasTrustedSpread };
181
+ }
182
+
183
+ return { value, hasTrustedSpread };
184
+ }
185
+ }
186
+
187
+ return { value: null, hasTrustedSpread };
188
+ }
@@ -0,0 +1,25 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { pkceRequiredForOidcRule } from "./rules/pkce-required-for-oidc";
4
+ import { stateMustBeRedisBackedRule } from "./rules/state-must-be-redis-backed";
5
+ import { stateTtlBoundedRule } from "./rules/state-ttl-bounded";
6
+ import type { IRulePack } from "../rule-packs.types";
7
+
8
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
9
+ "pkce-required-for-oidc": pkceRequiredForOidcRule,
10
+ "state-must-be-redis-backed": stateMustBeRedisBackedRule,
11
+ "state-ttl-bounded": stateTtlBoundedRule,
12
+ };
13
+
14
+ export const oauthSecurityPack: IRulePack = {
15
+ id: "oauth-security",
16
+ description: "OAuth and OpenID patterns and security considerations",
17
+ rules,
18
+ rulesConfig: {
19
+ "pkce-required-for-oidc": "error",
20
+ "state-must-be-redis-backed": "error",
21
+ "state-ttl-bounded": "error",
22
+ },
23
+ };
24
+
25
+ export default oauthSecurityPack;
@@ -0,0 +1,296 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
3
+
4
+ import { createRule } from "../../create-rule";
5
+ import {
6
+ DEFAULT_OIDC_PROVIDERS,
7
+ DEFAULT_PROVIDERS_GLOB,
8
+ DEFAULT_VERIFIER_FN_NAMES,
9
+ matchesAnyGlobPattern,
10
+ toPosixRelative,
11
+ } from "../utils";
12
+
13
+ export const RULE_NAME = "pkce-required-for-oidc";
14
+
15
+ export interface PkceRequiredForOidcOptions {
16
+ readonly providersGlob?: string;
17
+ readonly oidcProviders?: readonly string[];
18
+ readonly verifierFnNames?: readonly string[];
19
+ }
20
+
21
+ type RuleOptions = [PkceRequiredForOidcOptions];
22
+ type MessageIds = "missingPkce";
23
+
24
+ const optionSchema: JSONSchema4 = {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ providersGlob: { type: "string", minLength: 1 },
29
+ oidcProviders: {
30
+ type: "array",
31
+ items: { type: "string" },
32
+ uniqueItems: true,
33
+ minItems: 1,
34
+ },
35
+ verifierFnNames: {
36
+ type: "array",
37
+ items: { type: "string" },
38
+ uniqueItems: true,
39
+ minItems: 1,
40
+ },
41
+ },
42
+ };
43
+
44
+ const BUILD_FN_RE = /^(buildAuthorizationURL|getAuthorizationURL)$/;
45
+
46
+ type FunctionLike =
47
+ | TSESTree.FunctionDeclaration
48
+ | TSESTree.FunctionExpression
49
+ | TSESTree.ArrowFunctionExpression;
50
+
51
+ function getFunctionName(node: FunctionLike): string | null {
52
+ if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id !== null) {
53
+ return node.id.name;
54
+ }
55
+
56
+ const parent = node.parent;
57
+
58
+ if (parent === undefined) {
59
+ return null;
60
+ }
61
+
62
+ if (
63
+ parent.type === AST_NODE_TYPES.VariableDeclarator &&
64
+ parent.id.type === AST_NODE_TYPES.Identifier
65
+ ) {
66
+ return parent.id.name;
67
+ }
68
+
69
+ if (
70
+ parent.type === AST_NODE_TYPES.MethodDefinition &&
71
+ parent.key.type === AST_NODE_TYPES.Identifier
72
+ ) {
73
+ return parent.key.name;
74
+ }
75
+
76
+ if (
77
+ parent.type === AST_NODE_TYPES.Property &&
78
+ parent.key.type === AST_NODE_TYPES.Identifier
79
+ ) {
80
+ return parent.key.name;
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ export const pkceRequiredForOidcRule = createRule<RuleOptions, MessageIds>({
87
+ name: RULE_NAME,
88
+ meta: {
89
+ type: "problem",
90
+ docs: {
91
+ description:
92
+ "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
93
+ },
94
+ schema: [optionSchema],
95
+ messages: {
96
+ missingPkce:
97
+ "{{providerClass}} is OIDC — pass a PKCE `codeVerifier` to `createAuthorizationURL(state, verifier, scopes)`.",
98
+ },
99
+ },
100
+ defaultOptions: [
101
+ {
102
+ providersGlob: DEFAULT_PROVIDERS_GLOB,
103
+ oidcProviders: [...DEFAULT_OIDC_PROVIDERS],
104
+ verifierFnNames: [...DEFAULT_VERIFIER_FN_NAMES],
105
+ },
106
+ ],
107
+ create(context, [options]) {
108
+ const providersGlob = options.providersGlob ?? DEFAULT_PROVIDERS_GLOB;
109
+ const oidcProviders = new Set(
110
+ options.oidcProviders ?? DEFAULT_OIDC_PROVIDERS
111
+ );
112
+ const verifierFnNames = new Set(
113
+ options.verifierFnNames ?? DEFAULT_VERIFIER_FN_NAMES
114
+ );
115
+
116
+ const relative = toPosixRelative(context.filename, context.cwd);
117
+
118
+ if (!matchesAnyGlobPattern(relative, [providersGlob])) {
119
+ return {};
120
+ }
121
+
122
+ let importedOidcProvider: string | null = null;
123
+ const verifierLocalNames = new Set<string>();
124
+
125
+ interface FrameInfo {
126
+ readonly node: FunctionLike;
127
+ readonly name: string;
128
+ verifierIdentifiers: Set<string>;
129
+ hasCreateAuthorizationCallWithVerifier: boolean;
130
+ hasCreateAuthorizationCall: boolean;
131
+ }
132
+
133
+ const stack: FrameInfo[] = [];
134
+
135
+ function visitFn(node: FunctionLike): void {
136
+ const name = getFunctionName(node);
137
+
138
+ if (name === null || !BUILD_FN_RE.test(name)) {
139
+ return;
140
+ }
141
+
142
+ stack.push({
143
+ node,
144
+ name,
145
+ verifierIdentifiers: new Set(),
146
+ hasCreateAuthorizationCallWithVerifier: false,
147
+ hasCreateAuthorizationCall: false,
148
+ });
149
+ }
150
+
151
+ function exitFn(node: FunctionLike): void {
152
+ const top = stack[stack.length - 1];
153
+
154
+ if (top?.node !== node) {
155
+ return;
156
+ }
157
+
158
+ stack.pop();
159
+
160
+ if (importedOidcProvider === null) {
161
+ return;
162
+ }
163
+
164
+ if (
165
+ top.hasCreateAuthorizationCall &&
166
+ !top.hasCreateAuthorizationCallWithVerifier
167
+ ) {
168
+ context.report({
169
+ node,
170
+ messageId: "missingPkce",
171
+ data: { providerClass: importedOidcProvider },
172
+ });
173
+ }
174
+ }
175
+
176
+ return {
177
+ ImportDeclaration(node) {
178
+ for (const spec of node.specifiers) {
179
+ if (spec.type !== AST_NODE_TYPES.ImportSpecifier) {
180
+ continue;
181
+ }
182
+
183
+ if (spec.imported.type !== AST_NODE_TYPES.Identifier) {
184
+ continue;
185
+ }
186
+
187
+ if (oidcProviders.has(spec.imported.name)) {
188
+ importedOidcProvider = spec.imported.name;
189
+ }
190
+
191
+ if (verifierFnNames.has(spec.imported.name)) {
192
+ verifierLocalNames.add(spec.local.name);
193
+ }
194
+ }
195
+ },
196
+
197
+ FunctionDeclaration: visitFn,
198
+ "FunctionDeclaration:exit": exitFn,
199
+ FunctionExpression: visitFn,
200
+ "FunctionExpression:exit": exitFn,
201
+ ArrowFunctionExpression: visitFn,
202
+ "ArrowFunctionExpression:exit": exitFn,
203
+
204
+ VariableDeclarator(node) {
205
+ if (stack.length === 0) {
206
+ return;
207
+ }
208
+
209
+ const top = stack[stack.length - 1];
210
+
211
+ if (top === undefined) {
212
+ return;
213
+ }
214
+
215
+ if (
216
+ node.id.type === AST_NODE_TYPES.Identifier &&
217
+ node.init !== null &&
218
+ node.init.type === AST_NODE_TYPES.AwaitExpression &&
219
+ node.init.argument.type === AST_NODE_TYPES.CallExpression
220
+ ) {
221
+ const callee = node.init.argument.callee;
222
+
223
+ if (
224
+ callee.type === AST_NODE_TYPES.Identifier &&
225
+ verifierLocalNames.has(callee.name)
226
+ ) {
227
+ top.verifierIdentifiers.add(node.id.name);
228
+ }
229
+ }
230
+
231
+ if (
232
+ node.id.type === AST_NODE_TYPES.Identifier &&
233
+ node.init !== null &&
234
+ node.init.type === AST_NODE_TYPES.CallExpression
235
+ ) {
236
+ const callee = node.init.callee;
237
+
238
+ if (
239
+ callee.type === AST_NODE_TYPES.Identifier &&
240
+ verifierLocalNames.has(callee.name)
241
+ ) {
242
+ top.verifierIdentifiers.add(node.id.name);
243
+ }
244
+ }
245
+ },
246
+
247
+ CallExpression(node) {
248
+ if (stack.length === 0) {
249
+ return;
250
+ }
251
+
252
+ const top = stack[stack.length - 1];
253
+
254
+ if (top === undefined) {
255
+ return;
256
+ }
257
+
258
+ const callee = node.callee;
259
+
260
+ if (
261
+ callee.type !== AST_NODE_TYPES.MemberExpression ||
262
+ callee.computed ||
263
+ callee.property.type !== AST_NODE_TYPES.Identifier ||
264
+ callee.property.name !== "createAuthorizationURL"
265
+ ) {
266
+ return;
267
+ }
268
+
269
+ top.hasCreateAuthorizationCall = true;
270
+
271
+ // Inspect args. In arctic OIDC providers the signature is:
272
+ // createAuthorizationURL(state, codeVerifier, scopes)
273
+ // The non-OIDC version is:
274
+ // createAuthorizationURL(state, scopes)
275
+ // Detect PKCE by: 2nd arg is a verifier identifier we've tracked,
276
+ // OR the call has 3 positional args (heuristic).
277
+ const second = node.arguments[1];
278
+
279
+ if (
280
+ second?.type === AST_NODE_TYPES.Identifier &&
281
+ (top.verifierIdentifiers.has(second.name) ||
282
+ verifierLocalNames.has(second.name))
283
+ ) {
284
+ top.hasCreateAuthorizationCallWithVerifier = true;
285
+
286
+ return;
287
+ }
288
+
289
+ if (node.arguments.length >= 3) {
290
+ // Conservative — assume the 3-arg form is the OIDC variant.
291
+ top.hasCreateAuthorizationCallWithVerifier = true;
292
+ }
293
+ },
294
+ };
295
+ },
296
+ });