@agjs/tsforge 0.1.18 → 0.2.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/package.json +4 -1
- package/scripts/build-rules-md.ts +78 -21
- package/scripts/sweep.ts +25 -20
- package/scripts/web-sweep.ts +292 -0
- package/src/browser/oracle.ts +29 -1
- package/src/cli.ts +9 -3
- package/src/config/index.ts +8 -0
- package/src/config/profiles.ts +150 -0
- package/src/config/tsforge-config.ts +64 -5
- package/src/detect-gate.ts +34 -1
- package/src/inference/inference.types.ts +8 -0
- package/src/inference/request.ts +5 -1
- package/src/inference/stream.ts +21 -2
- package/src/inference/wire.ts +0 -0
- package/src/loop/feedback/meta-rule-docs.ts +48 -0
- package/src/loop/feedback/rule-docs.ts +150 -0
- package/src/loop/rule-docs.generated.json +131 -1
- package/src/loop/run.ts +3 -0
- package/src/loop/session.ts +12 -5
- package/src/loop/ttsr-defaults.ts +175 -4
- package/src/meta-rules/registry.ts +32 -0
- package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
- package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
- package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
- package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
- package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
- package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
- package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
- package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
- package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
- package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
- package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
- package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
- package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
- package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
- package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
- package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
- package/src/meta-rules/utils/lockfiles.ts +105 -0
- package/src/meta-rules/utils/workflow-yaml.ts +86 -0
- package/src/rule-packs/authorization/index.ts +26 -0
- package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
- package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
- package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
- package/src/rule-packs/authorization/utils.ts +285 -0
- package/src/rule-packs/boundary-utils.ts +13 -0
- package/src/rule-packs/code-flow/index.ts +4 -1
- package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
- package/src/rule-packs/drizzle/index.ts +7 -0
- package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
- package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
- package/src/rule-packs/drizzle/utils.ts +133 -1
- package/src/rule-packs/fastify/index.ts +38 -0
- package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
- package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
- package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
- package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
- package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
- package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
- package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
- package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
- package/src/rule-packs/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
- package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
- package/src/rule-packs/module-boundaries/index.ts +3 -0
- package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
- package/src/rule-packs/nextjs/index.ts +32 -0
- package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
- package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
- package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
- package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
- package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
- package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
- package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
- package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
- package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
- package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
- package/src/rule-packs/nextjs/utils.ts +18 -0
- package/src/rule-packs/react-component-architecture/index.ts +18 -0
- package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
- package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
- package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
- package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
- package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
- package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
- package/src/rule-packs/rule-catalog.types.ts +21 -0
- package/src/rule-packs/rule-metadata.ts +163 -0
- package/src/rule-packs/runtime-boundaries/index.ts +33 -0
- package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
- package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
- package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
- package/src/rule-packs/security/index.ts +35 -0
- package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
- package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
- package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
- package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
- package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
- package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
- package/src/rule-packs/structured-logging/index.ts +6 -0
- package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
- package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
- package/src/rule-packs/test-conventions/index.ts +9 -0
- package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
- package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
- package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
- package/src/rule-packs/typescript-core/index.ts +30 -0
- package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
- package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
- package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
- package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
- package/src/stack-detection/packs.ts +57 -0
- package/strict.web.eslint.config.mjs +32 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
|
|
3
|
+
/** Narrow `unknown` to a record without a type assertion. */
|
|
4
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return typeof value === "object" && value !== null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DRIZZLE_PUSH_PATTERN = /\bdrizzle(?:-kit)?\s+push\b/u;
|
|
9
|
+
|
|
10
|
+
function collectScriptViolations(
|
|
11
|
+
scripts: Record<string, string>
|
|
12
|
+
): IMetaRuleViolation[] {
|
|
13
|
+
const violations: IMetaRuleViolation[] = [];
|
|
14
|
+
|
|
15
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
16
|
+
if (!DRIZZLE_PUSH_PATTERN.test(command)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
violations.push({
|
|
21
|
+
file: "package.json",
|
|
22
|
+
ruleId: "production-must-not-use-drizzle-push",
|
|
23
|
+
severity: "warn",
|
|
24
|
+
message: `Script "${name}" runs \`drizzle-kit push\` — use versioned SQL migrations (drizzle-kit generate + migrate) in production instead of schema push.`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return violations;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const productionMustNotUseDrizzlePushRule: IMetaRule = {
|
|
32
|
+
id: "production-must-not-use-drizzle-push",
|
|
33
|
+
category: "supply-chain",
|
|
34
|
+
description:
|
|
35
|
+
"Do not run drizzle-kit push in package.json scripts or CI workflows.",
|
|
36
|
+
severity: "warn",
|
|
37
|
+
appliesTo: ["drizzle"],
|
|
38
|
+
run({ packageJson, workflowFiles, readFile }) {
|
|
39
|
+
const violations: IMetaRuleViolation[] = [];
|
|
40
|
+
|
|
41
|
+
if (packageJson !== null) {
|
|
42
|
+
const scriptsValue = packageJson.scripts;
|
|
43
|
+
|
|
44
|
+
if (isRecord(scriptsValue)) {
|
|
45
|
+
const scripts: Record<string, string> = {};
|
|
46
|
+
|
|
47
|
+
for (const [key, value] of Object.entries(scriptsValue)) {
|
|
48
|
+
if (typeof value === "string") {
|
|
49
|
+
scripts[key] = value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
violations.push(...collectScriptViolations(scripts));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const file of workflowFiles) {
|
|
58
|
+
const text = readFile(file);
|
|
59
|
+
|
|
60
|
+
if (text === null || !DRIZZLE_PUSH_PATTERN.test(text)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
violations.push({
|
|
65
|
+
file,
|
|
66
|
+
ruleId: "production-must-not-use-drizzle-push",
|
|
67
|
+
severity: "warn",
|
|
68
|
+
message:
|
|
69
|
+
"Workflow runs `drizzle-kit push` — replace with checked-in migrations and `drizzle-kit migrate` for production schema changes.",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return violations;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
import { detectPresentLockfiles } from "../../utils/lockfiles";
|
|
3
|
+
|
|
4
|
+
export const singlePackageManagerRule: IMetaRule = {
|
|
5
|
+
id: "single-package-manager",
|
|
6
|
+
category: "supply-chain",
|
|
7
|
+
description:
|
|
8
|
+
"Do not mix lockfiles from different package managers in the same repo.",
|
|
9
|
+
severity: "warn",
|
|
10
|
+
run({ root }) {
|
|
11
|
+
const violations: IMetaRuleViolation[] = [];
|
|
12
|
+
const present = detectPresentLockfiles(root);
|
|
13
|
+
const managers = new Set(present.map((entry) => entry.manager));
|
|
14
|
+
|
|
15
|
+
if (managers.size <= 1) {
|
|
16
|
+
return violations;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lockfileNames = present.map((entry) => entry.filename).join(", ");
|
|
20
|
+
|
|
21
|
+
violations.push({
|
|
22
|
+
file: "package.json",
|
|
23
|
+
ruleId: "single-package-manager",
|
|
24
|
+
severity: "warn",
|
|
25
|
+
message: `Mixed package manager lockfiles detected (${lockfileNames}) — keep one lockfile for a single package manager and delete the rest.`,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return violations;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export type PackageManagerId = "npm" | "yarn" | "pnpm" | "bun";
|
|
5
|
+
|
|
6
|
+
export interface ILockfileInfo {
|
|
7
|
+
readonly manager: PackageManagerId;
|
|
8
|
+
readonly filename: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const LOCKFILES: readonly ILockfileInfo[] = [
|
|
12
|
+
{ manager: "npm", filename: "package-lock.json" },
|
|
13
|
+
{ manager: "yarn", filename: "yarn.lock" },
|
|
14
|
+
{ manager: "pnpm", filename: "pnpm-lock.yaml" },
|
|
15
|
+
{ manager: "bun", filename: "bun.lockb" },
|
|
16
|
+
{ manager: "bun", filename: "bun.lock" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const MANAGER_LOCKFILES: Readonly<Record<PackageManagerId, readonly string[]>> =
|
|
20
|
+
{
|
|
21
|
+
npm: ["package-lock.json"],
|
|
22
|
+
yarn: ["yarn.lock"],
|
|
23
|
+
pnpm: ["pnpm-lock.yaml"],
|
|
24
|
+
bun: ["bun.lockb", "bun.lock"],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function fileExists(root: string, filename: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
return statSync(join(root, filename)).isFile();
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Lockfiles present at the project root. */
|
|
36
|
+
export function detectPresentLockfiles(root: string): ILockfileInfo[] {
|
|
37
|
+
const present: ILockfileInfo[] = [];
|
|
38
|
+
|
|
39
|
+
for (const lockfile of LOCKFILES) {
|
|
40
|
+
if (fileExists(root, lockfile.filename)) {
|
|
41
|
+
present.push(lockfile);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return present;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Parse `packageManager` field (e.g. bun@1.3.14) into a manager id. */
|
|
49
|
+
export function parsePackageManagerField(
|
|
50
|
+
packageJson: Record<string, unknown> | null
|
|
51
|
+
): PackageManagerId | null {
|
|
52
|
+
if (packageJson === null) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const value = packageJson.packageManager;
|
|
57
|
+
|
|
58
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const manager = value.split("@")[0]?.trim();
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
manager === "npm" ||
|
|
66
|
+
manager === "yarn" ||
|
|
67
|
+
manager === "pnpm" ||
|
|
68
|
+
manager === "bun"
|
|
69
|
+
) {
|
|
70
|
+
return manager;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolve the canonical package manager for lockfile checks. */
|
|
77
|
+
export function resolvePackageManager(
|
|
78
|
+
root: string,
|
|
79
|
+
packageJson: Record<string, unknown> | null
|
|
80
|
+
): PackageManagerId | null {
|
|
81
|
+
const fromField = parsePackageManagerField(packageJson);
|
|
82
|
+
|
|
83
|
+
if (fromField !== null) {
|
|
84
|
+
return fromField;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const present = detectPresentLockfiles(root);
|
|
88
|
+
const managers = new Set(present.map((entry) => entry.manager));
|
|
89
|
+
|
|
90
|
+
if (managers.size === 1) {
|
|
91
|
+
return [...managers][0] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the root has a lockfile for the given package manager. */
|
|
98
|
+
export function hasLockfileForManager(
|
|
99
|
+
root: string,
|
|
100
|
+
manager: PackageManagerId
|
|
101
|
+
): boolean {
|
|
102
|
+
return MANAGER_LOCKFILES[manager].some((filename) =>
|
|
103
|
+
fileExists(root, filename)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface IJobBlock {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly lines: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const JOB_KEY_PATTERN = /^ {2}([\w-]+):\s*(?:#.*)?$/u;
|
|
7
|
+
|
|
8
|
+
/** Collect job blocks from a GitHub Actions workflow YAML string. */
|
|
9
|
+
export function collectJobBlocks(text: string): IJobBlock[] {
|
|
10
|
+
const lines = text.split("\n");
|
|
11
|
+
const blocks: IJobBlock[] = [];
|
|
12
|
+
let inJobs = false;
|
|
13
|
+
let current: { name: string; lines: string[] } | null = null;
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (/^jobs:\s*(?:#.*)?$/u.test(line)) {
|
|
17
|
+
inJobs = true;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!inJobs) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (/^\S/u.test(line)) {
|
|
26
|
+
inJobs = false;
|
|
27
|
+
|
|
28
|
+
if (current !== null) {
|
|
29
|
+
blocks.push(current);
|
|
30
|
+
current = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const jobMatch = JOB_KEY_PATTERN.exec(line);
|
|
37
|
+
|
|
38
|
+
if (jobMatch?.[1] !== undefined) {
|
|
39
|
+
if (current !== null) {
|
|
40
|
+
blocks.push(current);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
current = { name: jobMatch[1], lines: [] };
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (current !== null) {
|
|
48
|
+
current.lines.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (current !== null) {
|
|
53
|
+
blocks.push(current);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return blocks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether the workflow declares top-level permissions. */
|
|
60
|
+
export function hasWorkflowLevelPermissions(text: string): boolean {
|
|
61
|
+
return /^permissions:\s*(?:#.*)?$/mu.test(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Extract the top-level permissions block lines (excluding the header). */
|
|
65
|
+
export function collectWorkflowPermissionsLines(text: string): string[] {
|
|
66
|
+
const lines = text.split("\n");
|
|
67
|
+
const block: string[] = [];
|
|
68
|
+
let inPermissions = false;
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if (/^permissions:\s*(?:#.*)?$/u.test(line)) {
|
|
72
|
+
inPermissions = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (inPermissions) {
|
|
77
|
+
if (/^\S/u.test(line)) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
block.push(line);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return block;
|
|
86
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { idParamRequiresObjectAuthzRule } from "./rules/id-param-requires-object-authz";
|
|
4
|
+
import { mutatingRouteRequiresAuthzRule } from "./rules/mutating-route-requires-authz";
|
|
5
|
+
import { serverActionRequiresAuthzRule } from "./rules/server-action-requires-authz";
|
|
6
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
7
|
+
|
|
8
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
9
|
+
"id-param-requires-object-authz": idParamRequiresObjectAuthzRule,
|
|
10
|
+
"mutating-route-requires-authz": mutatingRouteRequiresAuthzRule,
|
|
11
|
+
"server-action-requires-authz": serverActionRequiresAuthzRule,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const authorizationPack: IRulePack = {
|
|
15
|
+
id: "authorization",
|
|
16
|
+
description:
|
|
17
|
+
"Experimental authorization heuristics for route handlers, server actions, and id-scoped database access.",
|
|
18
|
+
rules,
|
|
19
|
+
rulesConfig: {
|
|
20
|
+
"mutating-route-requires-authz": "error",
|
|
21
|
+
"server-action-requires-authz": "error",
|
|
22
|
+
"id-param-requires-object-authz": "warn",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default authorizationPack;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { walkSome } from "../../utils";
|
|
5
|
+
import {
|
|
6
|
+
authzOptionSchema,
|
|
7
|
+
containsAuthzCall,
|
|
8
|
+
defaultAuthzOptions,
|
|
9
|
+
getFunctionLikeBody,
|
|
10
|
+
isDbQueryCall,
|
|
11
|
+
isParamsIdRead,
|
|
12
|
+
resolveAuthzFunctions,
|
|
13
|
+
type AuthzRuleOptions,
|
|
14
|
+
type FunctionLike,
|
|
15
|
+
} from "../utils";
|
|
16
|
+
|
|
17
|
+
export const RULE_NAME = "id-param-requires-object-authz";
|
|
18
|
+
|
|
19
|
+
type MessageIds = "missingObjectAuthz";
|
|
20
|
+
|
|
21
|
+
function analyzeFunction(
|
|
22
|
+
node: FunctionLike,
|
|
23
|
+
authzNames: Set<string>
|
|
24
|
+
): { hasParamsId: boolean; hasDbQuery: boolean; hasAuthz: boolean } {
|
|
25
|
+
const body = getFunctionLikeBody(node);
|
|
26
|
+
|
|
27
|
+
if (body === null) {
|
|
28
|
+
return { hasParamsId: false, hasDbQuery: false, hasAuthz: false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
hasParamsId: walkSome(body, isParamsIdRead),
|
|
33
|
+
hasDbQuery: walkSome(body, (child) => {
|
|
34
|
+
if (child.type !== AST_NODE_TYPES.CallExpression) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return isDbQueryCall(child);
|
|
39
|
+
}),
|
|
40
|
+
hasAuthz: containsAuthzCall(body, authzNames),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const idParamRequiresObjectAuthzRule = createRule<
|
|
45
|
+
AuthzRuleOptions,
|
|
46
|
+
MessageIds
|
|
47
|
+
>({
|
|
48
|
+
name: RULE_NAME,
|
|
49
|
+
meta: {
|
|
50
|
+
type: "suggestion",
|
|
51
|
+
docs: {
|
|
52
|
+
description:
|
|
53
|
+
"Warn when a handler reads `params.id` and queries the database without an authorization check in the same function.",
|
|
54
|
+
},
|
|
55
|
+
schema: [authzOptionSchema],
|
|
56
|
+
messages: {
|
|
57
|
+
missingObjectAuthz:
|
|
58
|
+
"Reading `params.id` and querying the database in the same function requires object-level authorization (e.g. {{examples}}).",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
defaultOptions: [defaultAuthzOptions()],
|
|
62
|
+
create(context, [options]) {
|
|
63
|
+
const authzNames = resolveAuthzFunctions(options);
|
|
64
|
+
const examples = [...authzNames].slice(0, 2).join(", ");
|
|
65
|
+
|
|
66
|
+
function visitFunction(node: FunctionLike): void {
|
|
67
|
+
const { hasParamsId, hasDbQuery, hasAuthz } = analyzeFunction(
|
|
68
|
+
node,
|
|
69
|
+
authzNames
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (hasParamsId && hasDbQuery && !hasAuthz) {
|
|
73
|
+
context.report({
|
|
74
|
+
node,
|
|
75
|
+
messageId: "missingObjectAuthz",
|
|
76
|
+
data: { examples },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
FunctionDeclaration: visitFunction,
|
|
83
|
+
FunctionExpression: visitFunction,
|
|
84
|
+
ArrowFunctionExpression: visitFunction,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import {
|
|
5
|
+
authzOptionSchema,
|
|
6
|
+
containsAuthzCall,
|
|
7
|
+
defaultAuthzOptions,
|
|
8
|
+
getExportedMutatingHandlerName,
|
|
9
|
+
getFunctionLikeBody,
|
|
10
|
+
isRouteHandlerFile,
|
|
11
|
+
resolveAuthzFunctions,
|
|
12
|
+
type AuthzRuleOptions,
|
|
13
|
+
type FunctionLike,
|
|
14
|
+
} from "../utils";
|
|
15
|
+
|
|
16
|
+
export const RULE_NAME = "mutating-route-requires-authz";
|
|
17
|
+
|
|
18
|
+
type MessageIds = "missingAuthz";
|
|
19
|
+
|
|
20
|
+
function getHandlerFunction(node: TSESTree.Node): FunctionLike | null {
|
|
21
|
+
if (node.type === AST_NODE_TYPES.ExportNamedDeclaration) {
|
|
22
|
+
const declaration = node.declaration;
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
declaration?.type === AST_NODE_TYPES.FunctionDeclaration &&
|
|
26
|
+
declaration.body !== null
|
|
27
|
+
) {
|
|
28
|
+
return declaration;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (declaration?.type === AST_NODE_TYPES.VariableDeclaration) {
|
|
32
|
+
for (const declarator of declaration.declarations) {
|
|
33
|
+
const init = declarator.init;
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
init?.type === AST_NODE_TYPES.FunctionExpression ||
|
|
37
|
+
init?.type === AST_NODE_TYPES.ArrowFunctionExpression
|
|
38
|
+
) {
|
|
39
|
+
return init;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.body !== null) {
|
|
48
|
+
return node;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const mutatingRouteRequiresAuthzRule = createRule<
|
|
55
|
+
AuthzRuleOptions,
|
|
56
|
+
MessageIds
|
|
57
|
+
>({
|
|
58
|
+
name: RULE_NAME,
|
|
59
|
+
meta: {
|
|
60
|
+
type: "problem",
|
|
61
|
+
docs: {
|
|
62
|
+
description:
|
|
63
|
+
"POST/PUT/PATCH/DELETE route handlers must call an authorization helper before mutating state.",
|
|
64
|
+
},
|
|
65
|
+
schema: [authzOptionSchema],
|
|
66
|
+
messages: {
|
|
67
|
+
missingAuthz:
|
|
68
|
+
"Mutating route handler `{{method}}` must call an authorization helper (e.g. {{examples}}) before performing writes.",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
defaultOptions: [defaultAuthzOptions()],
|
|
72
|
+
create(context, [options]) {
|
|
73
|
+
const authzNames = resolveAuthzFunctions(options);
|
|
74
|
+
const examples = [...authzNames].slice(0, 2).join(", ");
|
|
75
|
+
|
|
76
|
+
function reportMissingAuthz(node: TSESTree.Node, method: string): void {
|
|
77
|
+
context.report({
|
|
78
|
+
node,
|
|
79
|
+
messageId: "missingAuthz",
|
|
80
|
+
data: { method, examples },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkHandler(exportNode: TSESTree.Node, method: string): void {
|
|
85
|
+
const handler = getHandlerFunction(exportNode);
|
|
86
|
+
|
|
87
|
+
if (handler === null) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const body = getFunctionLikeBody(handler);
|
|
92
|
+
|
|
93
|
+
if (body === null || containsAuthzCall(body, authzNames)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
reportMissingAuthz(exportNode, method);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration) {
|
|
102
|
+
if (!isRouteHandlerFile(context.filename)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const method = getExportedMutatingHandlerName(node);
|
|
107
|
+
|
|
108
|
+
if (method === null) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
checkHandler(node, method);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { walkSome } from "../../utils";
|
|
5
|
+
import {
|
|
6
|
+
authzOptionSchema,
|
|
7
|
+
containsAuthzCall,
|
|
8
|
+
defaultAuthzOptions,
|
|
9
|
+
getFunctionLikeBody,
|
|
10
|
+
hasUseServerDirective,
|
|
11
|
+
isDbMutationCall,
|
|
12
|
+
resolveAuthzFunctions,
|
|
13
|
+
type AuthzRuleOptions,
|
|
14
|
+
type FunctionLike,
|
|
15
|
+
} from "../utils";
|
|
16
|
+
|
|
17
|
+
export const RULE_NAME = "server-action-requires-authz";
|
|
18
|
+
|
|
19
|
+
type MessageIds = "missingAuthz";
|
|
20
|
+
|
|
21
|
+
function getFunctionName(node: FunctionLike): string {
|
|
22
|
+
if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id !== null) {
|
|
23
|
+
return node.id.name;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parent = node.parent;
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
parent?.type === AST_NODE_TYPES.VariableDeclarator &&
|
|
30
|
+
parent.id.type === AST_NODE_TYPES.Identifier
|
|
31
|
+
) {
|
|
32
|
+
return parent.id.name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return "server action";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const serverActionRequiresAuthzRule = createRule<
|
|
39
|
+
AuthzRuleOptions,
|
|
40
|
+
MessageIds
|
|
41
|
+
>({
|
|
42
|
+
name: RULE_NAME,
|
|
43
|
+
meta: {
|
|
44
|
+
type: "problem",
|
|
45
|
+
docs: {
|
|
46
|
+
description:
|
|
47
|
+
'Files with `"use server"` that perform database mutations must call an authorization helper in the same function.',
|
|
48
|
+
},
|
|
49
|
+
schema: [authzOptionSchema],
|
|
50
|
+
messages: {
|
|
51
|
+
missingAuthz:
|
|
52
|
+
'Server action "{{name}}" performs a database mutation but does not call an authorization helper (e.g. {{examples}}).',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
defaultOptions: [defaultAuthzOptions()],
|
|
56
|
+
create(context, [options]) {
|
|
57
|
+
const authzNames = resolveAuthzFunctions(options);
|
|
58
|
+
const examples = [...authzNames].slice(0, 2).join(", ");
|
|
59
|
+
let useServerFile = false;
|
|
60
|
+
|
|
61
|
+
function visitFunction(node: FunctionLike): void {
|
|
62
|
+
if (!useServerFile) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const body = getFunctionLikeBody(node);
|
|
67
|
+
|
|
68
|
+
if (body === null) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hasMutation = walkSome(
|
|
73
|
+
body,
|
|
74
|
+
(child) =>
|
|
75
|
+
child.type === AST_NODE_TYPES.CallExpression &&
|
|
76
|
+
isDbMutationCall(child)
|
|
77
|
+
);
|
|
78
|
+
const hasAuthz = containsAuthzCall(body, authzNames);
|
|
79
|
+
|
|
80
|
+
if (hasMutation && !hasAuthz) {
|
|
81
|
+
context.report({
|
|
82
|
+
node,
|
|
83
|
+
messageId: "missingAuthz",
|
|
84
|
+
data: {
|
|
85
|
+
name: getFunctionName(node),
|
|
86
|
+
examples,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
Program(node: TSESTree.Program) {
|
|
94
|
+
useServerFile = hasUseServerDirective(node);
|
|
95
|
+
},
|
|
96
|
+
FunctionDeclaration: visitFunction,
|
|
97
|
+
FunctionExpression: visitFunction,
|
|
98
|
+
ArrowFunctionExpression: visitFunction,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
});
|