@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.
- package/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- 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
|
+
});
|