@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,163 @@
|
|
|
1
|
+
import type { IRuleCatalogEntry, RuleTier } from "./rule-catalog.types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BY_TIER: Record<
|
|
4
|
+
RuleTier,
|
|
5
|
+
Omit<IRuleCatalogEntry, "tier"> & { tier: RuleTier }
|
|
6
|
+
> = {
|
|
7
|
+
safety: { tier: "safety", falsePositiveRisk: "low" },
|
|
8
|
+
framework: { tier: "framework", falsePositiveRisk: "low" },
|
|
9
|
+
architecture: { tier: "architecture", falsePositiveRisk: "medium" },
|
|
10
|
+
experimental: { tier: "experimental", falsePositiveRisk: "high" },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Explicit catalog entries; unlisted rules inherit tier from pack defaults. */
|
|
14
|
+
const RULE_ENTRIES: Readonly<Record<string, IRuleCatalogEntry>> = {
|
|
15
|
+
"prefer-early-return": {
|
|
16
|
+
tier: "architecture",
|
|
17
|
+
tags: ["style"],
|
|
18
|
+
falsePositiveRisk: "medium",
|
|
19
|
+
},
|
|
20
|
+
"component-folder-structure": { tier: "architecture", tags: ["react"] },
|
|
21
|
+
"no-state-in-component-body": { tier: "architecture", tags: ["react"] },
|
|
22
|
+
"no-inline-jsx-functions": { tier: "architecture", tags: ["react"] },
|
|
23
|
+
"no-anonymous-useEffect": {
|
|
24
|
+
tier: "framework",
|
|
25
|
+
tags: ["react"],
|
|
26
|
+
falsePositiveRisk: "medium",
|
|
27
|
+
},
|
|
28
|
+
"no-derived-state-in-effect": {
|
|
29
|
+
tier: "framework",
|
|
30
|
+
tags: ["react"],
|
|
31
|
+
falsePositiveRisk: "medium",
|
|
32
|
+
},
|
|
33
|
+
"no-html-img-element": { tier: "framework", tags: ["nextjs"] },
|
|
34
|
+
"catch-must-handle": { tier: "safety", tags: ["security"] },
|
|
35
|
+
"no-auth-token-in-storage": { tier: "safety", tags: ["security"] },
|
|
36
|
+
"no-child-process-exec": { tier: "safety", tags: ["security"] },
|
|
37
|
+
"no-dynamic-regexp": { tier: "safety", tags: ["security"] },
|
|
38
|
+
"no-inner-html-assignment": { tier: "safety", tags: ["security"] },
|
|
39
|
+
"no-spawn-with-shell": { tier: "safety", tags: ["security"] },
|
|
40
|
+
"no-user-controlled-redirect": { tier: "safety", tags: ["security"] },
|
|
41
|
+
"no-user-controlled-fetch-url": { tier: "safety", tags: ["security"] },
|
|
42
|
+
"no-prototype-polluting-merge": { tier: "safety", tags: ["security"] },
|
|
43
|
+
"webhook-must-verify-signature-before-parse": {
|
|
44
|
+
tier: "safety",
|
|
45
|
+
tags: ["security"],
|
|
46
|
+
},
|
|
47
|
+
"upload-must-set-limits": { tier: "safety", tags: ["security"] },
|
|
48
|
+
"mutating-route-requires-authz": {
|
|
49
|
+
tier: "experimental",
|
|
50
|
+
tags: ["authorization"],
|
|
51
|
+
falsePositiveRisk: "high",
|
|
52
|
+
},
|
|
53
|
+
"server-action-requires-authz": {
|
|
54
|
+
tier: "experimental",
|
|
55
|
+
tags: ["authorization", "nextjs"],
|
|
56
|
+
falsePositiveRisk: "high",
|
|
57
|
+
},
|
|
58
|
+
"server-action-requires-authz-and-validation": {
|
|
59
|
+
tier: "experimental",
|
|
60
|
+
tags: ["authorization", "nextjs", "validation"],
|
|
61
|
+
falsePositiveRisk: "high",
|
|
62
|
+
},
|
|
63
|
+
"jwt-must-verify-not-decode": { tier: "safety", tags: ["security", "jwt"] },
|
|
64
|
+
"auth-cookie-must-set-samesite": {
|
|
65
|
+
tier: "safety",
|
|
66
|
+
tags: ["security", "cookies"],
|
|
67
|
+
},
|
|
68
|
+
"auth-cookie-must-set-maxage-or-expires": {
|
|
69
|
+
tier: "safety",
|
|
70
|
+
tags: ["security", "cookies"],
|
|
71
|
+
falsePositiveRisk: "medium",
|
|
72
|
+
},
|
|
73
|
+
"update-delete-must-have-where": {
|
|
74
|
+
tier: "safety",
|
|
75
|
+
tags: ["drizzle", "database"],
|
|
76
|
+
},
|
|
77
|
+
"update-delete-account-scoped-must-filter-scope": {
|
|
78
|
+
tier: "safety",
|
|
79
|
+
tags: ["drizzle", "database", "multi-tenant"],
|
|
80
|
+
},
|
|
81
|
+
"caught-error-log-requires-cause": { tier: "framework", tags: ["logging"] },
|
|
82
|
+
"logger-not-console": {
|
|
83
|
+
tier: "architecture",
|
|
84
|
+
tags: ["logging"],
|
|
85
|
+
falsePositiveRisk: "medium",
|
|
86
|
+
},
|
|
87
|
+
"server-only-modules-import-server-only": {
|
|
88
|
+
tier: "framework",
|
|
89
|
+
tags: ["nextjs"],
|
|
90
|
+
},
|
|
91
|
+
"no-secret-props-to-client": { tier: "safety", tags: ["nextjs", "security"] },
|
|
92
|
+
"mutation-should-revalidate-cache": {
|
|
93
|
+
tier: "framework",
|
|
94
|
+
tags: ["nextjs"],
|
|
95
|
+
falsePositiveRisk: "medium",
|
|
96
|
+
},
|
|
97
|
+
"no-conditional-expect": { tier: "framework", tags: ["testing"] },
|
|
98
|
+
"fake-timers-must-be-restored": { tier: "framework", tags: ["testing"] },
|
|
99
|
+
"no-real-network-in-unit-tests": {
|
|
100
|
+
tier: "framework",
|
|
101
|
+
tags: ["testing"],
|
|
102
|
+
falsePositiveRisk: "medium",
|
|
103
|
+
},
|
|
104
|
+
"id-param-requires-object-authz": {
|
|
105
|
+
tier: "experimental",
|
|
106
|
+
tags: ["authorization"],
|
|
107
|
+
falsePositiveRisk: "high",
|
|
108
|
+
},
|
|
109
|
+
"fetch-must-check-ok": { tier: "safety", tags: ["async", "http"] },
|
|
110
|
+
"json-parse-must-validate": { tier: "safety", tags: ["validation"] },
|
|
111
|
+
"no-unsafe-boundary-cast": { tier: "safety", tags: ["validation"] },
|
|
112
|
+
"exported-functions-require-return-type": {
|
|
113
|
+
tier: "framework",
|
|
114
|
+
falsePositiveRisk: "medium",
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const PACK_DEFAULT_TIER: Readonly<Record<string, RuleTier>> = {
|
|
119
|
+
security: "safety",
|
|
120
|
+
"runtime-boundaries": "safety",
|
|
121
|
+
authorization: "experimental",
|
|
122
|
+
"typescript-core": "safety",
|
|
123
|
+
"react-component-architecture": "framework",
|
|
124
|
+
nextjs: "framework",
|
|
125
|
+
fastify: "framework",
|
|
126
|
+
elysia: "framework",
|
|
127
|
+
drizzle: "framework",
|
|
128
|
+
bullmq: "framework",
|
|
129
|
+
"comment-hygiene": "architecture",
|
|
130
|
+
"code-flow": "framework",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function getRuleCatalogEntry(
|
|
134
|
+
ruleId: string,
|
|
135
|
+
packId?: string
|
|
136
|
+
): IRuleCatalogEntry {
|
|
137
|
+
const explicit = RULE_ENTRIES[ruleId];
|
|
138
|
+
|
|
139
|
+
if (explicit !== undefined) {
|
|
140
|
+
return explicit;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const packTier = packId !== undefined ? PACK_DEFAULT_TIER[packId] : undefined;
|
|
144
|
+
const tier = packTier ?? "framework";
|
|
145
|
+
|
|
146
|
+
return DEFAULT_BY_TIER[tier];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function listRulesByTier(
|
|
150
|
+
rules: readonly { packId: string; ruleId: string }[]
|
|
151
|
+
): Map<RuleTier, { packId: string; ruleId: string }[]> {
|
|
152
|
+
const byTier = new Map<RuleTier, { packId: string; ruleId: string }[]>();
|
|
153
|
+
|
|
154
|
+
for (const entry of rules) {
|
|
155
|
+
const meta = getRuleCatalogEntry(entry.ruleId, entry.packId);
|
|
156
|
+
const list = byTier.get(meta.tier) ?? [];
|
|
157
|
+
|
|
158
|
+
list.push(entry);
|
|
159
|
+
byTier.set(meta.tier, list);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return byTier;
|
|
163
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { noPrototypePollutingMergeRule } from "./rules/no-prototype-polluting-merge";
|
|
4
|
+
import { noUserControlledFetchUrlRule } from "./rules/no-user-controlled-fetch-url";
|
|
5
|
+
import { noUserControlledRedirectRule } from "./rules/no-user-controlled-redirect";
|
|
6
|
+
import { uploadMustSetLimitsRule } from "./rules/upload-must-set-limits";
|
|
7
|
+
import { webhookMustVerifySignatureBeforeParseRule } from "./rules/webhook-must-verify-signature-before-parse";
|
|
8
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
9
|
+
|
|
10
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
11
|
+
"no-prototype-polluting-merge": noPrototypePollutingMergeRule,
|
|
12
|
+
"no-user-controlled-fetch-url": noUserControlledFetchUrlRule,
|
|
13
|
+
"no-user-controlled-redirect": noUserControlledRedirectRule,
|
|
14
|
+
"upload-must-set-limits": uploadMustSetLimitsRule,
|
|
15
|
+
"webhook-must-verify-signature-before-parse":
|
|
16
|
+
webhookMustVerifySignatureBeforeParseRule,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const runtimeBoundariesPack: IRulePack = {
|
|
20
|
+
id: "runtime-boundaries",
|
|
21
|
+
description:
|
|
22
|
+
"Runtime boundary safety: open redirects, SSRF, prototype pollution, webhook verification, and upload limits.",
|
|
23
|
+
rules,
|
|
24
|
+
rulesConfig: {
|
|
25
|
+
"no-prototype-polluting-merge": "error",
|
|
26
|
+
"no-user-controlled-fetch-url": "error",
|
|
27
|
+
"no-user-controlled-redirect": "error",
|
|
28
|
+
"upload-must-set-limits": "warn",
|
|
29
|
+
"webhook-must-verify-signature-before-parse": "warn",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default runtimeBoundariesPack;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { isExpression, isIdentifierNamed } from "../../boundary-utils";
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "no-prototype-polluting-merge";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "prototypePollutingMerge";
|
|
9
|
+
|
|
10
|
+
const REQUEST_FIELD_NAMES = new Set(["body", "query", "params"]);
|
|
11
|
+
const REQUEST_OBJECT_NAMES = new Set(["req", "request", "ctx"]);
|
|
12
|
+
|
|
13
|
+
function isRequestFieldExpression(node: TSESTree.Expression): boolean {
|
|
14
|
+
if (isIdentifierNamed(node, "body")) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (isIdentifierNamed(node, "query")) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (isIdentifierNamed(node, "params")) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (node.type !== AST_NODE_TYPES.MemberExpression || node.computed) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const property = node.property;
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
property.type !== AST_NODE_TYPES.Identifier ||
|
|
34
|
+
!REQUEST_FIELD_NAMES.has(property.name)
|
|
35
|
+
) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const object = node.object;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
object.type === AST_NODE_TYPES.Identifier &&
|
|
43
|
+
REQUEST_OBJECT_NAMES.has(object.name)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isObjectAssignWithRequestFields(
|
|
48
|
+
node: TSESTree.CallExpression
|
|
49
|
+
): boolean {
|
|
50
|
+
const callee = node.callee;
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
54
|
+
callee.computed ||
|
|
55
|
+
callee.object.type !== AST_NODE_TYPES.Identifier ||
|
|
56
|
+
callee.object.name !== "Object" ||
|
|
57
|
+
callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
58
|
+
callee.property.name !== "assign"
|
|
59
|
+
) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return node.arguments.some((arg, index) => {
|
|
64
|
+
if (index === 0 || !isExpression(arg)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return isRequestFieldExpression(arg);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function objectHasRequestFieldSpread(node: TSESTree.ObjectExpression): boolean {
|
|
73
|
+
return node.properties.some((prop) => {
|
|
74
|
+
if (prop.type !== AST_NODE_TYPES.SpreadElement) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const argument = prop.argument;
|
|
79
|
+
|
|
80
|
+
return isExpression(argument) && isRequestFieldExpression(argument);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const noPrototypePollutingMergeRule = createRule<[], MessageIds>({
|
|
85
|
+
name: RULE_NAME,
|
|
86
|
+
meta: {
|
|
87
|
+
type: "problem",
|
|
88
|
+
docs: {
|
|
89
|
+
description:
|
|
90
|
+
"Disallow merging request body/query/params into objects — enables prototype pollution.",
|
|
91
|
+
},
|
|
92
|
+
schema: [],
|
|
93
|
+
messages: {
|
|
94
|
+
prototypePollutingMerge:
|
|
95
|
+
"Do not merge `body`, `query`, or `params` into objects via `Object.assign` or spread — validate fields explicitly.",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
defaultOptions: [],
|
|
99
|
+
create(context) {
|
|
100
|
+
return {
|
|
101
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
102
|
+
if (isObjectAssignWithRequestFields(node)) {
|
|
103
|
+
context.report({ node, messageId: "prototypePollutingMerge" });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
ObjectExpression(node: TSESTree.ObjectExpression) {
|
|
107
|
+
if (objectHasRequestFieldSpread(node)) {
|
|
108
|
+
context.report({ node, messageId: "prototypePollutingMerge" });
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { isExpression, isStringLiteral } from "../../boundary-utils";
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "no-user-controlled-fetch-url";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "userControlledFetchUrl";
|
|
9
|
+
|
|
10
|
+
const AXIOS_HTTP_METHODS = new Set(["get", "post"]);
|
|
11
|
+
|
|
12
|
+
function isFetchCall(node: TSESTree.CallExpression): boolean {
|
|
13
|
+
const callee = node.callee;
|
|
14
|
+
|
|
15
|
+
return callee.type === AST_NODE_TYPES.Identifier && callee.name === "fetch";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isAxiosHttpCall(node: TSESTree.CallExpression): boolean {
|
|
19
|
+
const callee = node.callee;
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
23
|
+
callee.computed ||
|
|
24
|
+
callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
25
|
+
!AXIOS_HTTP_METHODS.has(callee.property.name)
|
|
26
|
+
) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const object = callee.object;
|
|
31
|
+
|
|
32
|
+
return object.type === AST_NODE_TYPES.Identifier && object.name === "axios";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const noUserControlledFetchUrlRule = createRule<[], MessageIds>({
|
|
36
|
+
name: RULE_NAME,
|
|
37
|
+
meta: {
|
|
38
|
+
type: "problem",
|
|
39
|
+
docs: {
|
|
40
|
+
description:
|
|
41
|
+
"Disallow fetch/axios requests to non-literal URLs — dynamic URLs enable SSRF.",
|
|
42
|
+
},
|
|
43
|
+
schema: [],
|
|
44
|
+
messages: {
|
|
45
|
+
userControlledFetchUrl:
|
|
46
|
+
"HTTP request URL must be a string literal — do not pass user-controlled values to `fetch()` or `axios`.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
defaultOptions: [],
|
|
50
|
+
create(context) {
|
|
51
|
+
return {
|
|
52
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
53
|
+
if (!isFetchCall(node) && !isAxiosHttpCall(node)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const urlArg = node.arguments[0];
|
|
58
|
+
|
|
59
|
+
if (urlArg === undefined || !isExpression(urlArg)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!isStringLiteral(urlArg)) {
|
|
64
|
+
context.report({ node, messageId: "userControlledFetchUrl" });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { isExpression, isStringLiteral } from "../../boundary-utils";
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "no-user-controlled-redirect";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "userControlledRedirect";
|
|
9
|
+
|
|
10
|
+
function isRedirectCall(node: TSESTree.CallExpression): boolean {
|
|
11
|
+
const callee = node.callee;
|
|
12
|
+
|
|
13
|
+
if (callee.type === AST_NODE_TYPES.Identifier && callee.name === "redirect") {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
19
|
+
callee.computed ||
|
|
20
|
+
callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
21
|
+
callee.property.name !== "redirect"
|
|
22
|
+
) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const object = callee.object;
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
object.type === AST_NODE_TYPES.Identifier &&
|
|
30
|
+
(object.name === "reply" || object.name === "NextResponse")
|
|
31
|
+
) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
object.type === AST_NODE_TYPES.MemberExpression &&
|
|
37
|
+
!object.computed &&
|
|
38
|
+
object.object.type === AST_NODE_TYPES.Identifier &&
|
|
39
|
+
object.object.name === "NextResponse" &&
|
|
40
|
+
object.property.type === AST_NODE_TYPES.Identifier &&
|
|
41
|
+
object.property.name === "redirect"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const noUserControlledRedirectRule = createRule<[], MessageIds>({
|
|
46
|
+
name: RULE_NAME,
|
|
47
|
+
meta: {
|
|
48
|
+
type: "problem",
|
|
49
|
+
docs: {
|
|
50
|
+
description:
|
|
51
|
+
"Disallow redirects to non-literal URLs — user-controlled redirects enable open redirects.",
|
|
52
|
+
},
|
|
53
|
+
schema: [],
|
|
54
|
+
messages: {
|
|
55
|
+
userControlledRedirect:
|
|
56
|
+
"Redirect URL must be a string literal — do not pass user-controlled values to `redirect()`.",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
defaultOptions: [],
|
|
60
|
+
create(context) {
|
|
61
|
+
return {
|
|
62
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
63
|
+
if (!isRedirectCall(node)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const urlArg = node.arguments[0];
|
|
68
|
+
|
|
69
|
+
if (urlArg === undefined || !isExpression(urlArg)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!isStringLiteral(urlArg)) {
|
|
74
|
+
context.report({ node, messageId: "userControlledRedirect" });
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "upload-must-set-limits";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "missingUploadLimits";
|
|
8
|
+
|
|
9
|
+
const MULTIPART_CALLEE_NAMES = new Set(["file", "files", "saveRequestFiles"]);
|
|
10
|
+
const LIMIT_PROPERTY_NAMES = new Set(["limits", "maxFileSize"]);
|
|
11
|
+
|
|
12
|
+
function calleeEndsWithMultipartHandler(callee: TSESTree.Expression): boolean {
|
|
13
|
+
if (callee.type === AST_NODE_TYPES.Identifier && callee.name === "multer") {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (callee.computed || callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return MULTIPART_CALLEE_NAMES.has(callee.property.name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function objectHasLimitProperty(node: TSESTree.ObjectExpression): boolean {
|
|
29
|
+
return node.properties.some((prop) => {
|
|
30
|
+
if (prop.type !== AST_NODE_TYPES.Property || prop.computed) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const key = prop.key;
|
|
35
|
+
|
|
36
|
+
if (key.type === AST_NODE_TYPES.Identifier) {
|
|
37
|
+
return LIMIT_PROPERTY_NAMES.has(key.name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
key.type === AST_NODE_TYPES.Literal &&
|
|
42
|
+
typeof key.value === "string" &&
|
|
43
|
+
LIMIT_PROPERTY_NAMES.has(key.value)
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nodeReferencesLimitName(node: TSESTree.Node): boolean {
|
|
49
|
+
if (node.type === AST_NODE_TYPES.Identifier) {
|
|
50
|
+
return LIMIT_PROPERTY_NAMES.has(node.name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") {
|
|
54
|
+
return (
|
|
55
|
+
node.value.includes("multipart") ||
|
|
56
|
+
node.value.includes("@fastify/multipart")
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (node.type === AST_NODE_TYPES.ImportDeclaration) {
|
|
61
|
+
return node.source.value === "@fastify/multipart";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (node.type === AST_NODE_TYPES.ObjectExpression) {
|
|
65
|
+
return objectHasLimitProperty(node);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const uploadMustSetLimitsRule = createRule<[], MessageIds>({
|
|
72
|
+
name: RULE_NAME,
|
|
73
|
+
meta: {
|
|
74
|
+
type: "suggestion",
|
|
75
|
+
docs: {
|
|
76
|
+
description:
|
|
77
|
+
"Multipart upload handlers should declare `limits` or `maxFileSize` to bound request size.",
|
|
78
|
+
},
|
|
79
|
+
schema: [],
|
|
80
|
+
messages: {
|
|
81
|
+
missingUploadLimits:
|
|
82
|
+
"Multipart upload handler is missing `limits` or `maxFileSize` configuration.",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
defaultOptions: [],
|
|
86
|
+
create(context) {
|
|
87
|
+
let handlesMultipart = false;
|
|
88
|
+
let hasLimits = false;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
ImportDeclaration(node: TSESTree.ImportDeclaration) {
|
|
92
|
+
if (nodeReferencesLimitName(node)) {
|
|
93
|
+
handlesMultipart = true;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
Literal(node: TSESTree.Literal) {
|
|
97
|
+
if (nodeReferencesLimitName(node)) {
|
|
98
|
+
handlesMultipart = true;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
102
|
+
if (calleeEndsWithMultipartHandler(node.callee)) {
|
|
103
|
+
handlesMultipart = true;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
ObjectExpression(node: TSESTree.ObjectExpression) {
|
|
107
|
+
if (objectHasLimitProperty(node)) {
|
|
108
|
+
hasLimits = true;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
Identifier(node: TSESTree.Identifier) {
|
|
112
|
+
if (LIMIT_PROPERTY_NAMES.has(node.name)) {
|
|
113
|
+
hasLimits = true;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"Program:exit"() {
|
|
117
|
+
if (handlesMultipart && !hasLimits) {
|
|
118
|
+
context.report({
|
|
119
|
+
loc: { line: 1, column: 0 },
|
|
120
|
+
messageId: "missingUploadLimits",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "webhook-must-verify-signature-before-parse";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "jsonBeforeVerify";
|
|
8
|
+
|
|
9
|
+
function isWebhookFile(filename: string): boolean {
|
|
10
|
+
const base = filename.split(/[\\/]/).pop() ?? "";
|
|
11
|
+
|
|
12
|
+
return base.toLowerCase().includes("webhook");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isVerifyCall(node: TSESTree.CallExpression): boolean {
|
|
16
|
+
const callee = node.callee;
|
|
17
|
+
|
|
18
|
+
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
19
|
+
return callee.name === "verify" || callee.name.startsWith("verify");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
24
|
+
callee.computed ||
|
|
25
|
+
callee.property.type !== AST_NODE_TYPES.Identifier
|
|
26
|
+
) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const name = callee.property.name;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
name === "constructEvent" || name === "verify" || name.startsWith("verify")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isJsonCall(node: TSESTree.CallExpression): boolean {
|
|
38
|
+
const callee = node.callee;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
42
|
+
!callee.computed &&
|
|
43
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
44
|
+
callee.property.name === "json"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const webhookMustVerifySignatureBeforeParseRule = createRule<
|
|
49
|
+
[],
|
|
50
|
+
MessageIds
|
|
51
|
+
>({
|
|
52
|
+
name: RULE_NAME,
|
|
53
|
+
meta: {
|
|
54
|
+
type: "suggestion",
|
|
55
|
+
docs: {
|
|
56
|
+
description:
|
|
57
|
+
"Webhook handlers must verify signatures before calling `.json()` on the request body.",
|
|
58
|
+
},
|
|
59
|
+
schema: [],
|
|
60
|
+
messages: {
|
|
61
|
+
jsonBeforeVerify:
|
|
62
|
+
"Verify the webhook signature (`verify*` or `constructEvent`) before parsing the body with `.json()`.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultOptions: [],
|
|
66
|
+
create(context) {
|
|
67
|
+
if (!isWebhookFile(context.filename)) {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let verifySeen = false;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
75
|
+
if (isVerifyCall(node)) {
|
|
76
|
+
verifySeen = true;
|
|
77
|
+
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isJsonCall(node) && !verifySeen) {
|
|
82
|
+
context.report({ node, messageId: "jsonBeforeVerify" });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|