@craft-ts/dev-tools 0.7.0-beta.13 → 0.7.0-beta.16
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/README.md +60 -10
- package/package.json +8 -2
- package/src/bin/agent-selector.d.ts +22 -0
- package/src/bin/agent-selector.js +100 -0
- package/src/bin/agent-selector.js.map +1 -0
- package/src/bin/craft-graph.js +74 -8
- package/src/bin/craft-graph.js.map +1 -1
- package/src/bin/craft.d.ts +22 -0
- package/src/bin/craft.js +231 -28
- package/src/bin/craft.js.map +1 -1
- package/src/eslint-rules/anchor-has-href.cjs +37 -3
- package/src/eslint-rules/craft-computed-name-match.cjs +26 -5
- package/src/eslint-rules/craft-name-match-utils.cjs +18 -9
- package/src/eslint-rules/hyperscript-walk.cjs +3 -3
- package/src/eslint-rules/index.cjs +46 -1
- package/src/eslint-rules/no-auth-token-in-local-storage.cjs +17 -0
- package/src/eslint-rules/no-craft-computed-side-effects.cjs +12 -5
- package/src/eslint-rules/no-craft-service-component-same-file.cjs +138 -0
- package/src/eslint-rules/no-effect-import-in-frontend.cjs +52 -0
- package/src/eslint-rules/no-effect-in-params.cjs +31 -1
- package/src/eslint-rules/no-explicit-effect-type.cjs +137 -0
- package/src/eslint-rules/no-free-has.cjs +47 -0
- package/src/eslint-rules/no-heading-level-skip.cjs +6 -6
- package/src/eslint-rules/no-raw-class.cjs +76 -0
- package/src/eslint-rules/no-raw-css-value.cjs +163 -0
- package/src/eslint-rules/no-raw-user-url.cjs +114 -0
- package/src/eslint-rules/no-trust-forwarded-headers.cjs +43 -0
- package/src/eslint-rules/no-unsafe-html.cjs +93 -0
- package/src/eslint-rules/no-unsafe-transfer-state.cjs +54 -0
- package/src/eslint-rules/prefer-craft-template-blocks.cjs +8 -8
- package/src/eslint-rules/prefer-direct-yieldable-callback.cjs +57 -14
- package/src/eslint-rules/prefer-inline-effect-insertion.cjs +263 -0
- package/src/eslint-rules/prefer-inline-route-providers.cjs +138 -0
- package/src/eslint-rules/recommended-config.cjs +6 -0
- package/src/eslint-rules/require-reactive-template-bindings.cjs +8 -8
- package/src/eslint-rules/require-route-security-policy.cjs +87 -0
- package/src/eslint-rules/require-server-function-timeout.cjs +39 -0
- package/src/eslint-rules/security-config.cjs +14 -0
- package/src/eslint-rules/security-rule-utils.cjs +21 -0
- package/src/eslint-rules/style-file-boundary.cjs +75 -0
- package/src/eslint-rules/sync-effect-body.cjs +343 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +4 -0
- package/src/index.js.map +1 -1
- package/src/scripts/architecture-graph.js +8 -2
- package/src/scripts/architecture-graph.js.map +1 -1
- package/src/scripts/create/create-project.d.ts +60 -1
- package/src/scripts/create/create-project.js +2082 -184
- package/src/scripts/create/create-project.js.map +1 -1
- package/src/scripts/data-flow-graph.d.ts +1 -1
- package/src/scripts/effect-dependency-graph.d.ts +1 -1
- package/src/scripts/security-check.d.ts +30 -0
- package/src/scripts/security-check.js +223 -0
- package/src/scripts/security-check.js.map +1 -0
- package/src/scripts/style-architecture.d.ts +56 -0
- package/src/scripts/style-architecture.js +142 -0
- package/src/scripts/style-architecture.js.map +1 -0
- package/src/scripts/style-graph.d.ts +99 -0
- package/src/scripts/style-graph.js +179 -0
- package/src/scripts/style-graph.js.map +1 -0
- package/src/scripts/style-report.d.ts +55 -0
- package/src/scripts/style-report.js +67 -0
- package/src/scripts/style-report.js.map +1 -0
- package/src/scripts/template/migrate-template.js +1 -1
- package/src/scripts/template/migrate-template.js.map +1 -1
- package/src/scripts/angular-brand-codemod.ts +0 -3985
- package/src/scripts/architecture/migrate-architecture.ts +0 -653
- package/src/scripts/architecture-graph.ts +0 -3136
- package/src/scripts/components/migrate-components.ts +0 -227
- package/src/scripts/components/migration-diagnostic.ts +0 -8
- package/src/scripts/create/create-project.ts +0 -849
- package/src/scripts/data-flow-graph.ts +0 -240
- package/src/scripts/dependency-graph.ts +0 -5908
- package/src/scripts/effect-dependency-graph.ts +0 -630
- package/src/scripts/migrate.ts +0 -162
- package/src/scripts/migration-workspace.ts +0 -93
- package/src/scripts/primitives/migrate-named-primitives.ts +0 -627
- package/src/scripts/primitives/migrate-primitive-generators.ts +0 -410
- package/src/scripts/primitives/migrate-primitives.ts +0 -655
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.ts +0 -499
- package/src/scripts/primitives/migration-diagnostic.ts +0 -15
- package/src/scripts/routes/migrate-routes.ts +0 -895
- package/src/scripts/routes/migration-diagnostic.ts +0 -25
- package/src/scripts/routes/route-command.ts +0 -1807
- package/src/scripts/routes/verify-routes.ts +0 -1511
- package/src/scripts/services/config.ts +0 -60
- package/src/scripts/services/migrate-services.ts +0 -1348
- package/src/scripts/services/migration-diagnostic.ts +0 -28
- package/src/scripts/template/migrate-template.ts +0 -418
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const STYLE_MODULE = '@craft-ts/style';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `class:` binding is a sheet class, never a string.
|
|
5
|
+
*
|
|
6
|
+
* Without this, the visual matrix is a fiction: a class assembled at render
|
|
7
|
+
* time is a visual state nothing recorded, so the matrix enumerates what the
|
|
8
|
+
* sheets declare while the DOM shows something else. Partial tightness here
|
|
9
|
+
* buys 0 % of the guarantee, not 90 %.
|
|
10
|
+
*
|
|
11
|
+
* It fires **only in files that import `@craft-ts/style`**. A component that
|
|
12
|
+
* has not been migrated is not claiming the guarantee, and reporting it would
|
|
13
|
+
* teach people to disable the rule long before the app is ready for it. The day
|
|
14
|
+
* a file starts using the design system, it starts being held to it.
|
|
15
|
+
*/
|
|
16
|
+
module.exports = {
|
|
17
|
+
meta: {
|
|
18
|
+
type: 'problem',
|
|
19
|
+
docs: {
|
|
20
|
+
description:
|
|
21
|
+
'Disallow string and computed class bindings in files that use @craft-ts/style.',
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
messages: {
|
|
25
|
+
rawClass:
|
|
26
|
+
'A class binding must be a class from a craftStyles sheet, not a string. This one is invisible to the visual matrix, so the state it produces is one nothing will ever capture. Move the rule into the sheet and bind the class it returns.',
|
|
27
|
+
computedClass:
|
|
28
|
+
'A class binding must not be computed at render time. Every string this can produce is a visual state nobody can enumerate; make the variation an axis — when(tone.danger, [...]) — and set a data attribute instead.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
let usesStyle = false;
|
|
33
|
+
const reports = [];
|
|
34
|
+
|
|
35
|
+
const report = (node, messageId) => reports.push({ node, messageId });
|
|
36
|
+
|
|
37
|
+
const inspect = (value) => {
|
|
38
|
+
if (!value) return;
|
|
39
|
+
if (value.type === 'Literal' && typeof value.value === 'string') {
|
|
40
|
+
report(value, 'rawClass');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (value.type === 'TemplateLiteral') {
|
|
44
|
+
report(value, value.expressions.length ? 'computedClass' : 'rawClass');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
value.type === 'FunctionExpression' ||
|
|
49
|
+
value.type === 'ArrowFunctionExpression'
|
|
50
|
+
) {
|
|
51
|
+
report(value, 'computedClass');
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
ImportDeclaration(node) {
|
|
57
|
+
if (node.source.value === STYLE_MODULE) usesStyle = true;
|
|
58
|
+
},
|
|
59
|
+
Property(node) {
|
|
60
|
+
const key = node.key;
|
|
61
|
+
const name =
|
|
62
|
+
key.type === 'Identifier'
|
|
63
|
+
? key.name
|
|
64
|
+
: key.type === 'Literal'
|
|
65
|
+
? key.value
|
|
66
|
+
: undefined;
|
|
67
|
+
if (name !== 'class') return;
|
|
68
|
+
inspect(node.value);
|
|
69
|
+
},
|
|
70
|
+
'Program:exit'() {
|
|
71
|
+
if (!usesStyle) return;
|
|
72
|
+
for (const entry of reports) context.report(entry);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const STYLE_MODULE = '@craft-ts/style';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A style helper never takes a literal.
|
|
5
|
+
*
|
|
6
|
+
* The types already say so — no generated signature accepts a `string` — so
|
|
7
|
+
* this rule is not a second guard against the same mistake. It exists for the
|
|
8
|
+
* two places typing cannot reach: a value flowing in through `any`, and code
|
|
9
|
+
* that has not been typechecked yet while it is being written. The message
|
|
10
|
+
* points at the exact replacement, because a rule that only says "no" sends an
|
|
11
|
+
* agent looking for a way round.
|
|
12
|
+
*
|
|
13
|
+
* It extends `no-hardcoded-design-values` to style-helper arguments rather than
|
|
14
|
+
* duplicating it: same intent, one more position.
|
|
15
|
+
*/
|
|
16
|
+
const REPLACEMENTS = {
|
|
17
|
+
length: 'a scale step or a unit — space(4), unit.rem(1.5), radii.md',
|
|
18
|
+
color: 'a palette token — palette.surface.raised',
|
|
19
|
+
keyword: 'the keyword member — display.flex, position.sticky',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const GLOBAL_KEYWORDS = new Set([
|
|
23
|
+
'inherit',
|
|
24
|
+
'initial',
|
|
25
|
+
'unset',
|
|
26
|
+
'revert',
|
|
27
|
+
'revert-layer',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const LENGTH_LIKE = /^-?\d*\.?\d+(px|rem|em|%|vh|vw|ch|ex|pt|cm|mm|in)$/;
|
|
31
|
+
const COLOR_LIKE = /^(#[0-9a-fA-F]{3,8}|rgb|rgba|hsl|hsla|var\()/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Helpers whose argument is legitimately a primitive.
|
|
35
|
+
*
|
|
36
|
+
* `space(4)` is how a scale step is written, `cssVars('badge', …)` takes a
|
|
37
|
+
* name, `unsafeLength('13px', reason)` is the marked way out. Flagging these
|
|
38
|
+
* would teach people to disable the rule, which costs more than the rule buys.
|
|
39
|
+
*/
|
|
40
|
+
const TAKES_PRIMITIVES = new Set([
|
|
41
|
+
'space',
|
|
42
|
+
'num',
|
|
43
|
+
'int',
|
|
44
|
+
'ident',
|
|
45
|
+
'cssString',
|
|
46
|
+
'url',
|
|
47
|
+
'unsafeLength',
|
|
48
|
+
'unsafeAssume',
|
|
49
|
+
'oneOf',
|
|
50
|
+
'craftStyles',
|
|
51
|
+
'cssVars',
|
|
52
|
+
'definePalette',
|
|
53
|
+
'defineStateAxis',
|
|
54
|
+
'defineBreakpoints',
|
|
55
|
+
'defineAxis',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** Namespaces whose members are value constructors: `unit.px(4)`, `at.…`. */
|
|
59
|
+
const PRIMITIVE_NAMESPACES = new Set(['unit', 'kind', 'at']);
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
meta: {
|
|
63
|
+
type: 'problem',
|
|
64
|
+
docs: {
|
|
65
|
+
description:
|
|
66
|
+
'Disallow string and number literals as arguments to @craft-ts/style helpers.',
|
|
67
|
+
},
|
|
68
|
+
schema: [],
|
|
69
|
+
messages: {
|
|
70
|
+
rawValue:
|
|
71
|
+
"'{{value}}' is a raw CSS value, not a design-system value. Pass {{replacement}} instead. If the scale is missing the value, add it to the scale; if it genuinely cannot be proven, use unsafeLength('{{value}}', reason) so the debt is countable.",
|
|
72
|
+
rawGlobal:
|
|
73
|
+
"'{{value}}' is a CSS-wide keyword; pass it as a token — global.{{token}}(prop.<name>) — so no helper has to accept a string.",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
create(context) {
|
|
77
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
78
|
+
const styleImports = new Set();
|
|
79
|
+
const namespaces = new Set();
|
|
80
|
+
|
|
81
|
+
const replacementFor = (value) => {
|
|
82
|
+
if (typeof value === 'number') return REPLACEMENTS.length;
|
|
83
|
+
if (COLOR_LIKE.test(value)) return REPLACEMENTS.color;
|
|
84
|
+
if (LENGTH_LIKE.test(value)) return REPLACEMENTS.length;
|
|
85
|
+
return REPLACEMENTS.keyword;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const isStyleHelper = (callee) => {
|
|
89
|
+
if (callee.type === 'Identifier') {
|
|
90
|
+
return (
|
|
91
|
+
styleImports.has(callee.name) && !TAKES_PRIMITIVES.has(callee.name)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
callee.type === 'MemberExpression' &&
|
|
96
|
+
callee.object.type === 'Identifier'
|
|
97
|
+
) {
|
|
98
|
+
const object = callee.object.name;
|
|
99
|
+
if (PRIMITIVE_NAMESPACES.has(object)) return false;
|
|
100
|
+
if (namespaces.has(object)) {
|
|
101
|
+
// `style.space(4)` through a namespace import is still a value
|
|
102
|
+
// constructor; the member name is what decides.
|
|
103
|
+
return (
|
|
104
|
+
callee.property.type === 'Identifier' &&
|
|
105
|
+
!TAKES_PRIMITIVES.has(callee.property.name) &&
|
|
106
|
+
!PRIMITIVE_NAMESPACES.has(callee.property.name)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return styleImports.has(object);
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
ImportDeclaration(node) {
|
|
116
|
+
if (node.source.value !== STYLE_MODULE) return;
|
|
117
|
+
for (const specifier of node.specifiers) {
|
|
118
|
+
if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
119
|
+
namespaces.add(specifier.local.name);
|
|
120
|
+
} else if (specifier.type === 'ImportSpecifier') {
|
|
121
|
+
styleImports.add(specifier.local.name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
CallExpression(node) {
|
|
126
|
+
if (!isStyleHelper(node.callee)) return;
|
|
127
|
+
for (const argument of node.arguments) {
|
|
128
|
+
const isLiteral =
|
|
129
|
+
argument.type === 'Literal' &&
|
|
130
|
+
(typeof argument.value === 'string' ||
|
|
131
|
+
typeof argument.value === 'number');
|
|
132
|
+
const isTemplate =
|
|
133
|
+
argument.type === 'TemplateLiteral' && argument.quasis.length > 0;
|
|
134
|
+
if (!isLiteral && !isTemplate) continue;
|
|
135
|
+
|
|
136
|
+
const value = isLiteral
|
|
137
|
+
? argument.value
|
|
138
|
+
: sourceCode.getText(argument).slice(1, -1);
|
|
139
|
+
|
|
140
|
+
if (typeof value === 'string' && GLOBAL_KEYWORDS.has(value)) {
|
|
141
|
+
context.report({
|
|
142
|
+
node: argument,
|
|
143
|
+
messageId: 'rawGlobal',
|
|
144
|
+
data: {
|
|
145
|
+
value,
|
|
146
|
+
token: value.replace(/-([a-z])/g, (_, char) =>
|
|
147
|
+
char.toUpperCase(),
|
|
148
|
+
),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
context.report({
|
|
155
|
+
node: argument,
|
|
156
|
+
messageId: 'rawValue',
|
|
157
|
+
data: { value: String(value), replacement: replacementFor(value) },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { report } = require('./security-rule-utils.cjs');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attributs d'URL dont le nom n'a pas d'autre sens courant en JavaScript.
|
|
5
|
+
* `data`, `ping`, `cite` ou `manifest` sont exclus à dessein : ce sont des
|
|
6
|
+
* noms de champs métier fréquents, et la règle ne verrait que le nom.
|
|
7
|
+
*/
|
|
8
|
+
const URL_KEYS = /^(?:href|src|srcset|action|formaction|poster|xlink:href)$/i;
|
|
9
|
+
/** Helpers de la lib : leur retour est déjà validé. */
|
|
10
|
+
const SAFE_CALLS = new Set(['safeUrl', 'safeResourceUrl', 'safeUrlList']);
|
|
11
|
+
|
|
12
|
+
function isSafeExpression(node) {
|
|
13
|
+
if (!node) return true;
|
|
14
|
+
switch (node.type) {
|
|
15
|
+
case 'Literal':
|
|
16
|
+
case 'TemplateLiteral':
|
|
17
|
+
// Une URL écrite dans le source n'est pas une donnée utilisateur.
|
|
18
|
+
return true;
|
|
19
|
+
case 'CallExpression':
|
|
20
|
+
return (
|
|
21
|
+
(node.callee.type === 'Identifier' && SAFE_CALLS.has(node.callee.name)) ||
|
|
22
|
+
(node.callee.type === 'MemberExpression' &&
|
|
23
|
+
node.callee.property.type === 'Identifier' &&
|
|
24
|
+
SAFE_CALLS.has(node.callee.property.name))
|
|
25
|
+
);
|
|
26
|
+
case 'ConditionalExpression':
|
|
27
|
+
return (
|
|
28
|
+
isSafeExpression(node.consequent) && isSafeExpression(node.alternate)
|
|
29
|
+
);
|
|
30
|
+
case 'LogicalExpression':
|
|
31
|
+
return isSafeExpression(node.left) && isSafeExpression(node.right);
|
|
32
|
+
case 'TSAsExpression':
|
|
33
|
+
case 'TSNonNullExpression':
|
|
34
|
+
return isSafeExpression(node.expression);
|
|
35
|
+
case 'FunctionExpression':
|
|
36
|
+
case 'ArrowFunctionExpression':
|
|
37
|
+
// Une valeur de template Craft est un générateur ; une fonction
|
|
38
|
+
// ordinaire est un accesseur (`href: () => location.href`), pas une
|
|
39
|
+
// valeur posée dans le DOM.
|
|
40
|
+
return node.generator ? returnsSafely(node) : true;
|
|
41
|
+
default:
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isTemplateAttributeObject(property, sourceCode) {
|
|
47
|
+
const ancestors = sourceCode.getAncestors
|
|
48
|
+
? sourceCode.getAncestors(property)
|
|
49
|
+
: [];
|
|
50
|
+
const object = ancestors[ancestors.length - 1];
|
|
51
|
+
const parent = ancestors[ancestors.length - 2];
|
|
52
|
+
return Boolean(
|
|
53
|
+
object &&
|
|
54
|
+
object.type === 'ObjectExpression' &&
|
|
55
|
+
parent &&
|
|
56
|
+
parent.type === 'CallExpression' &&
|
|
57
|
+
parent.arguments.includes(object),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Vrai quand chaque valeur retournée par le générateur est déjà validée. */
|
|
62
|
+
function returnsSafely(node) {
|
|
63
|
+
if (node.body.type !== 'BlockStatement') return isSafeExpression(node.body);
|
|
64
|
+
const returns = [];
|
|
65
|
+
const visit = (statement) => {
|
|
66
|
+
if (!statement || typeof statement.type !== 'string') return;
|
|
67
|
+
if (statement.type === 'ReturnStatement') {
|
|
68
|
+
returns.push(statement.argument);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Les fonctions imbriquées ont leur propre valeur de retour.
|
|
72
|
+
if (
|
|
73
|
+
statement.type === 'FunctionDeclaration' ||
|
|
74
|
+
statement.type === 'FunctionExpression' ||
|
|
75
|
+
statement.type === 'ArrowFunctionExpression'
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const key of Object.keys(statement)) {
|
|
80
|
+
const value = statement[key];
|
|
81
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
82
|
+
else if (value && typeof value.type === 'string') visit(value);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
node.body.body.forEach(visit);
|
|
86
|
+
return returns.length > 0 && returns.every(isSafeExpression);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
meta: {
|
|
91
|
+
type: 'problem',
|
|
92
|
+
schema: [],
|
|
93
|
+
docs: { description: 'Sanitize user-controlled DOM URLs.' },
|
|
94
|
+
},
|
|
95
|
+
create(context) {
|
|
96
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
97
|
+
return {
|
|
98
|
+
Property(node) {
|
|
99
|
+
const name = node.key && (node.key.name ?? node.key.value);
|
|
100
|
+
if (!URL_KEYS.test(String(name))) return;
|
|
101
|
+
// Les attributs d'un template Craft sont un objet passé en argument
|
|
102
|
+
// d'un helper d'élément — `a('book', { href }, …)`. Un objet affecté
|
|
103
|
+
// à une variable est une structure de données, pas du DOM.
|
|
104
|
+
if (!isTemplateAttributeObject(node, sourceCode)) return;
|
|
105
|
+
if (isSafeExpression(node.value)) return;
|
|
106
|
+
report(
|
|
107
|
+
context,
|
|
108
|
+
node,
|
|
109
|
+
`Wrap the dynamic "${name}" value in safeUrl(), safeResourceUrl() or safeUrlList().`,
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const { report } = require('./security-rule-utils.cjs');
|
|
2
|
+
|
|
3
|
+
const FORWARDED = /^x-forwarded-(?:for|host|proto)$/i;
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
schema: [
|
|
9
|
+
{
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
// Frontière proxy de confiance : le fichier qui valide ces en-têtes
|
|
13
|
+
// doit pouvoir les lire, sinon la règle n'est pas tenable.
|
|
14
|
+
allowIn: { type: 'array', items: { type: 'string' } },
|
|
15
|
+
},
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
docs: {
|
|
20
|
+
description:
|
|
21
|
+
'Do not trust forwarded headers outside the configured proxy boundary.',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
const allowIn = (context.options[0] && context.options[0].allowIn) || [
|
|
26
|
+
'proxy-boundary',
|
|
27
|
+
'trusted-proxy',
|
|
28
|
+
];
|
|
29
|
+
const filename = context.filename ?? context.getFilename();
|
|
30
|
+
if (allowIn.some((fragment) => filename.includes(fragment))) return {};
|
|
31
|
+
return {
|
|
32
|
+
Literal(node) {
|
|
33
|
+
if (typeof node.value === 'string' && FORWARDED.test(node.value)) {
|
|
34
|
+
report(
|
|
35
|
+
context,
|
|
36
|
+
node,
|
|
37
|
+
'Forwarded headers are untrusted input; read them only in the proxy boundary module that validates them.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const { report } = require('./security-rule-utils.cjs');
|
|
2
|
+
|
|
3
|
+
const RAW_SINKS = /^(?:innerHTML|outerHTML|srcdoc)$/;
|
|
4
|
+
const DANGEROUS_CALLS = new Set([
|
|
5
|
+
'insertAdjacentHTML',
|
|
6
|
+
'createContextualFragment',
|
|
7
|
+
'write',
|
|
8
|
+
'writeln',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
/** `unsafeHtml(...)` n'est acceptable qu'accompagné d'une exception datée. */
|
|
12
|
+
function hasRecordedException(sourceCode) {
|
|
13
|
+
return /allowUnsafe\s*\(/.test(sourceCode);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
meta: {
|
|
18
|
+
type: 'problem',
|
|
19
|
+
schema: [],
|
|
20
|
+
docs: { description: 'Require an explicit Craft HTML sanitizer.' },
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
const sourceCode = (context.sourceCode ?? context.getSourceCode()).getText();
|
|
24
|
+
return {
|
|
25
|
+
AssignmentExpression(node) {
|
|
26
|
+
if (
|
|
27
|
+
node.left.type === 'MemberExpression' &&
|
|
28
|
+
!node.left.computed &&
|
|
29
|
+
node.left.property.type === 'Identifier' &&
|
|
30
|
+
RAW_SINKS.test(node.left.property.name)
|
|
31
|
+
) {
|
|
32
|
+
report(
|
|
33
|
+
context,
|
|
34
|
+
node,
|
|
35
|
+
'Raw HTML and srcdoc assignments are forbidden; use sanitizedHtml or an audited unsafeHtml exception.',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
CallExpression(node) {
|
|
40
|
+
if (
|
|
41
|
+
node.callee.type === 'Identifier' &&
|
|
42
|
+
node.callee.name === 'unsafeHtml' &&
|
|
43
|
+
!hasRecordedException(sourceCode)
|
|
44
|
+
) {
|
|
45
|
+
report(
|
|
46
|
+
context,
|
|
47
|
+
node,
|
|
48
|
+
'unsafeHtml requires an allowUnsafe(...) exception with an owner, a reason, a risk and an expiry date.',
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
node.callee.type === 'Identifier' &&
|
|
54
|
+
(node.callee.name === 'eval' || node.callee.name === 'Function')
|
|
55
|
+
) {
|
|
56
|
+
report(context, node, 'Dynamic code evaluation is forbidden.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (
|
|
60
|
+
node.callee.type === 'MemberExpression' &&
|
|
61
|
+
node.callee.property.type === 'Identifier' &&
|
|
62
|
+
DANGEROUS_CALLS.has(node.callee.property.name)
|
|
63
|
+
) {
|
|
64
|
+
const owner =
|
|
65
|
+
node.callee.object.type === 'Identifier'
|
|
66
|
+
? node.callee.object.name
|
|
67
|
+
: '';
|
|
68
|
+
if (
|
|
69
|
+
node.callee.property.name !== 'write' &&
|
|
70
|
+
node.callee.property.name !== 'writeln'
|
|
71
|
+
) {
|
|
72
|
+
report(
|
|
73
|
+
context,
|
|
74
|
+
node,
|
|
75
|
+
`${node.callee.property.name} is forbidden; use the Craft sanitized HTML API.`,
|
|
76
|
+
);
|
|
77
|
+
} else if (owner === 'document') {
|
|
78
|
+
report(
|
|
79
|
+
context,
|
|
80
|
+
node,
|
|
81
|
+
'document.write injects unparsed markup; use the Craft sanitized HTML API.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
NewExpression(node) {
|
|
87
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'Function') {
|
|
88
|
+
report(context, node, 'Dynamic code evaluation is forbidden.');
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const { report, isCallNamed } = require('./security-rule-utils.cjs');
|
|
2
|
+
|
|
3
|
+
function hasProperty(node, name) {
|
|
4
|
+
if (!node || node.type !== 'ObjectExpression') return false;
|
|
5
|
+
return node.properties.some((property) => {
|
|
6
|
+
// Un spread peut porter la propriété : on ne peut pas prouver son
|
|
7
|
+
// absence, et une règle qui accuse à tort finit désactivée.
|
|
8
|
+
if (property.type === 'SpreadElement') return true;
|
|
9
|
+
return (
|
|
10
|
+
property.type === 'Property' &&
|
|
11
|
+
(property.key.name ?? property.key.value) === name
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
meta: {
|
|
18
|
+
type: 'problem',
|
|
19
|
+
schema: [],
|
|
20
|
+
docs: { description: 'Require an explicit SSR transfer policy.' },
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
return {
|
|
24
|
+
CallExpression(node) {
|
|
25
|
+
if (isCallNamed(node, 'captureCraftTransferSnapshot')) {
|
|
26
|
+
if (node.arguments.length < 2 || !hasProperty(node.arguments[1], 'policy')) {
|
|
27
|
+
report(
|
|
28
|
+
context,
|
|
29
|
+
node,
|
|
30
|
+
'SSR transfer requires an explicit policy: captureCraftTransferSnapshot(registry, { policy }).',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Un rendu sans politique retombe sur le défaut fermé : le signaler
|
|
36
|
+
// évite de découvrir en production que rien ne s'hydrate.
|
|
37
|
+
if (isCallNamed(node, 'renderCraft')) {
|
|
38
|
+
const options = node.arguments[0];
|
|
39
|
+
if (
|
|
40
|
+
options &&
|
|
41
|
+
options.type === 'ObjectExpression' &&
|
|
42
|
+
!hasProperty(options, 'securityPolicy')
|
|
43
|
+
) {
|
|
44
|
+
report(
|
|
45
|
+
context,
|
|
46
|
+
node,
|
|
47
|
+
'renderCraft requires an explicit securityPolicy: the transfer snapshot is empty without one.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -121,13 +121,13 @@ module.exports = {
|
|
|
121
121
|
schema: [],
|
|
122
122
|
messages: {
|
|
123
123
|
ternary:
|
|
124
|
-
'Do not use a ternary in a Craft template. Use
|
|
124
|
+
'Do not use a ternary in a Craft template. Use ifNode(...) for boolean visibility or matchNode.exhaustive(...) for a discriminated union.',
|
|
125
125
|
logical:
|
|
126
126
|
'Do not use a logical expression in a Craft template. Move the derivation to state, query, or craftComputed, then render it with a Craft block.',
|
|
127
127
|
negation:
|
|
128
128
|
'Do not use negation in a Craft template. Move the boolean derivation to state, query, or craftComputed, then bind the resulting value.',
|
|
129
129
|
controlFlow:
|
|
130
|
-
'Do not use imperative control flow in a Craft template. Use
|
|
130
|
+
'Do not use imperative control flow in a Craft template. Use ifNode(...), matchNode.exhaustive(...), forNode(...), or deferNode(...) so the render contract stays type-checkable.',
|
|
131
131
|
},
|
|
132
132
|
},
|
|
133
133
|
|
|
@@ -169,7 +169,7 @@ module.exports = {
|
|
|
169
169
|
fix: (fixer) =>
|
|
170
170
|
[
|
|
171
171
|
fixer.replaceText(replacement.node, replacement.text),
|
|
172
|
-
namedImportFix(fixer, '
|
|
172
|
+
namedImportFix(fixer, 'ifNode'),
|
|
173
173
|
].filter(Boolean),
|
|
174
174
|
}),
|
|
175
175
|
});
|
|
@@ -200,7 +200,7 @@ module.exports = {
|
|
|
200
200
|
fix: (fixer) =>
|
|
201
201
|
[
|
|
202
202
|
fixer.replaceText(node, replacement),
|
|
203
|
-
namedImportFix(fixer, '
|
|
203
|
+
namedImportFix(fixer, 'matchNode'),
|
|
204
204
|
].filter(Boolean),
|
|
205
205
|
}),
|
|
206
206
|
});
|
|
@@ -244,7 +244,7 @@ module.exports = {
|
|
|
244
244
|
return undefined;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
const text = `
|
|
247
|
+
const text = `ifNode(${condition}, () => ${sourceCode.getText(
|
|
248
248
|
node.consequent,
|
|
249
249
|
)}, () => ${sourceCode.getText(node.alternate)})`;
|
|
250
250
|
const parent = node.parent;
|
|
@@ -271,7 +271,7 @@ module.exports = {
|
|
|
271
271
|
`${formatObjectKey(key)}: () => ${expression}`,
|
|
272
272
|
)
|
|
273
273
|
.join(', ');
|
|
274
|
-
return `return
|
|
274
|
+
return `return matchNode.exhaustive(${match.source}, ${JSON.stringify(
|
|
275
275
|
match.key,
|
|
276
276
|
)}, { ${handlers} });`;
|
|
277
277
|
}
|
|
@@ -429,8 +429,8 @@ function isRenderableArrow(node) {
|
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
return !new Set([
|
|
432
|
-
'
|
|
433
|
-
'
|
|
432
|
+
'forNode',
|
|
433
|
+
'deferNode',
|
|
434
434
|
'content',
|
|
435
435
|
'craftTemplate',
|
|
436
436
|
'renderTemplate',
|