recker 1.0.27 → 1.0.28-next.9eb3868
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/dist/browser/scrape/extractors.js +2 -1
- package/dist/browser/scrape/types.d.ts +2 -1
- package/dist/cli/index.js +142 -3
- package/dist/cli/tui/shell.d.ts +1 -0
- package/dist/cli/tui/shell.js +157 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scrape/extractors.js +2 -1
- package/dist/scrape/types.d.ts +2 -1
- package/dist/seo/analyzer.d.ts +42 -0
- package/dist/seo/analyzer.js +727 -0
- package/dist/seo/index.d.ts +5 -0
- package/dist/seo/index.js +2 -0
- package/dist/seo/rules/accessibility.d.ts +2 -0
- package/dist/seo/rules/accessibility.js +128 -0
- package/dist/seo/rules/content.d.ts +2 -0
- package/dist/seo/rules/content.js +236 -0
- package/dist/seo/rules/crawl.d.ts +2 -0
- package/dist/seo/rules/crawl.js +307 -0
- package/dist/seo/rules/cwv.d.ts +2 -0
- package/dist/seo/rules/cwv.js +337 -0
- package/dist/seo/rules/ecommerce.d.ts +2 -0
- package/dist/seo/rules/ecommerce.js +252 -0
- package/dist/seo/rules/i18n.d.ts +2 -0
- package/dist/seo/rules/i18n.js +222 -0
- package/dist/seo/rules/images.d.ts +2 -0
- package/dist/seo/rules/images.js +180 -0
- package/dist/seo/rules/index.d.ts +52 -0
- package/dist/seo/rules/index.js +141 -0
- package/dist/seo/rules/internal-linking.d.ts +2 -0
- package/dist/seo/rules/internal-linking.js +375 -0
- package/dist/seo/rules/links.d.ts +2 -0
- package/dist/seo/rules/links.js +150 -0
- package/dist/seo/rules/local.d.ts +2 -0
- package/dist/seo/rules/local.js +265 -0
- package/dist/seo/rules/meta.d.ts +2 -0
- package/dist/seo/rules/meta.js +523 -0
- package/dist/seo/rules/mobile.d.ts +2 -0
- package/dist/seo/rules/mobile.js +71 -0
- package/dist/seo/rules/performance.d.ts +2 -0
- package/dist/seo/rules/performance.js +246 -0
- package/dist/seo/rules/pwa.d.ts +2 -0
- package/dist/seo/rules/pwa.js +302 -0
- package/dist/seo/rules/readability.d.ts +2 -0
- package/dist/seo/rules/readability.js +255 -0
- package/dist/seo/rules/schema.d.ts +2 -0
- package/dist/seo/rules/schema.js +54 -0
- package/dist/seo/rules/security.d.ts +2 -0
- package/dist/seo/rules/security.js +147 -0
- package/dist/seo/rules/social.d.ts +2 -0
- package/dist/seo/rules/social.js +373 -0
- package/dist/seo/rules/structural.d.ts +2 -0
- package/dist/seo/rules/structural.js +155 -0
- package/dist/seo/rules/technical.d.ts +2 -0
- package/dist/seo/rules/technical.js +223 -0
- package/dist/seo/rules/thresholds.d.ts +196 -0
- package/dist/seo/rules/thresholds.js +118 -0
- package/dist/seo/rules/types.d.ts +322 -0
- package/dist/seo/rules/types.js +11 -0
- package/dist/seo/types.d.ts +160 -0
- package/dist/seo/types.js +1 -0
- package/dist/utils/columns.d.ts +14 -0
- package/dist/utils/columns.js +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const readabilityRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'readability-flesch-score',
|
|
5
|
+
name: 'Flesch Reading Ease',
|
|
6
|
+
category: 'content',
|
|
7
|
+
severity: 'info',
|
|
8
|
+
description: 'Content should be easy to read for the target audience',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (ctx.fleschReadingEase === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
const score = ctx.fleschReadingEase;
|
|
13
|
+
let level;
|
|
14
|
+
let status;
|
|
15
|
+
if (score >= 60) {
|
|
16
|
+
level = score >= 80 ? 'Easy' : score >= 70 ? 'Fairly Easy' : 'Standard';
|
|
17
|
+
status = 'pass';
|
|
18
|
+
}
|
|
19
|
+
else if (score >= 50) {
|
|
20
|
+
level = 'Fairly Difficult';
|
|
21
|
+
status = 'info';
|
|
22
|
+
}
|
|
23
|
+
else if (score >= 30) {
|
|
24
|
+
level = 'Difficult';
|
|
25
|
+
status = 'warn';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
level = 'Very Difficult';
|
|
29
|
+
status = 'warn';
|
|
30
|
+
}
|
|
31
|
+
if (status === 'warn') {
|
|
32
|
+
return createResult({ id: 'readability-flesch-score', name: 'Flesch Reading Ease', category: 'content', severity: 'info' }, status, `Flesch score: ${Math.round(score)} (${level})`, {
|
|
33
|
+
recommendation: 'Simplify content for better readability',
|
|
34
|
+
evidence: {
|
|
35
|
+
found: Math.round(score),
|
|
36
|
+
expected: '60-70 for general web content',
|
|
37
|
+
impact: 'Difficult content has higher bounce rates',
|
|
38
|
+
learnMore: 'https://en.wikipedia.org/wiki/Flesch%E2%80%93Kincaid_readability_tests',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return createResult({ id: 'readability-flesch-score', name: 'Flesch Reading Ease', category: 'content', severity: 'info' }, status, `Flesch score: ${Math.round(score)} (${level})`);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'readability-sentence-length',
|
|
47
|
+
name: 'Sentence Length',
|
|
48
|
+
category: 'content',
|
|
49
|
+
severity: 'info',
|
|
50
|
+
description: 'Sentences should be concise for better readability',
|
|
51
|
+
check: (ctx) => {
|
|
52
|
+
if (ctx.avgSentenceLength === undefined)
|
|
53
|
+
return null;
|
|
54
|
+
const avgLength = ctx.avgSentenceLength;
|
|
55
|
+
if (avgLength > 30) {
|
|
56
|
+
return createResult({ id: 'readability-sentence-length', name: 'Sentence Length', category: 'content', severity: 'info' }, 'warn', `Average sentence length: ${Math.round(avgLength)} words (too long)`, {
|
|
57
|
+
recommendation: 'Break long sentences into shorter ones',
|
|
58
|
+
evidence: {
|
|
59
|
+
found: Math.round(avgLength),
|
|
60
|
+
expected: '15-20 words per sentence ideal, max 25',
|
|
61
|
+
impact: 'Long sentences are harder to understand on mobile',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (avgLength > 25) {
|
|
66
|
+
return createResult({ id: 'readability-sentence-length', name: 'Sentence Length', category: 'content', severity: 'info' }, 'info', `Average sentence length: ${Math.round(avgLength)} words (slightly long)`, {
|
|
67
|
+
recommendation: 'Consider shortening some sentences',
|
|
68
|
+
evidence: {
|
|
69
|
+
found: Math.round(avgLength),
|
|
70
|
+
expected: '15-20 words per sentence',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return createResult({ id: 'readability-sentence-length', name: 'Sentence Length', category: 'content', severity: 'info' }, 'pass', `Average sentence length: ${Math.round(avgLength)} words`);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'readability-paragraph-length',
|
|
79
|
+
name: 'Paragraph Length',
|
|
80
|
+
category: 'content',
|
|
81
|
+
severity: 'info',
|
|
82
|
+
description: 'Paragraphs should be short for web readability',
|
|
83
|
+
check: (ctx) => {
|
|
84
|
+
if (ctx.avgParagraphLength === undefined)
|
|
85
|
+
return null;
|
|
86
|
+
const avgLength = ctx.avgParagraphLength;
|
|
87
|
+
if (avgLength > 150) {
|
|
88
|
+
return createResult({ id: 'readability-paragraph-length', name: 'Paragraph Length', category: 'content', severity: 'info' }, 'warn', `Average paragraph length: ${Math.round(avgLength)} words (too long)`, {
|
|
89
|
+
recommendation: 'Break paragraphs into smaller chunks',
|
|
90
|
+
evidence: {
|
|
91
|
+
found: Math.round(avgLength),
|
|
92
|
+
expected: '40-80 words per paragraph for web',
|
|
93
|
+
impact: 'Wall of text reduces engagement and increases bounce rate',
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (avgLength > 100) {
|
|
98
|
+
return createResult({ id: 'readability-paragraph-length', name: 'Paragraph Length', category: 'content', severity: 'info' }, 'info', `Average paragraph length: ${Math.round(avgLength)} words`, {
|
|
99
|
+
recommendation: 'Consider breaking longer paragraphs',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return createResult({ id: 'readability-paragraph-length', name: 'Paragraph Length', category: 'content', severity: 'info' }, 'pass', `Average paragraph length: ${Math.round(avgLength)} words`);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'readability-passive-voice',
|
|
107
|
+
name: 'Passive Voice',
|
|
108
|
+
category: 'content',
|
|
109
|
+
severity: 'info',
|
|
110
|
+
description: 'Limit passive voice for clearer writing',
|
|
111
|
+
check: (ctx) => {
|
|
112
|
+
if (ctx.passiveVoicePercentage === undefined)
|
|
113
|
+
return null;
|
|
114
|
+
const percentage = ctx.passiveVoicePercentage;
|
|
115
|
+
if (percentage > 20) {
|
|
116
|
+
return createResult({ id: 'readability-passive-voice', name: 'Passive Voice', category: 'content', severity: 'info' }, 'warn', `Passive voice: ${Math.round(percentage)}% of sentences`, {
|
|
117
|
+
recommendation: 'Convert passive sentences to active voice',
|
|
118
|
+
evidence: {
|
|
119
|
+
found: `${Math.round(percentage)}%`,
|
|
120
|
+
expected: 'Less than 10% passive voice',
|
|
121
|
+
example: 'Instead of "The button was clicked by the user" → "The user clicked the button"',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (percentage > 15) {
|
|
126
|
+
return createResult({ id: 'readability-passive-voice', name: 'Passive Voice', category: 'content', severity: 'info' }, 'info', `Passive voice: ${Math.round(percentage)}% of sentences`, {
|
|
127
|
+
recommendation: 'Consider reducing passive voice usage',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'readability-transition-words',
|
|
135
|
+
name: 'Transition Words',
|
|
136
|
+
category: 'content',
|
|
137
|
+
severity: 'info',
|
|
138
|
+
description: 'Use transition words for better flow',
|
|
139
|
+
check: (ctx) => {
|
|
140
|
+
if (ctx.transitionWordPercentage === undefined)
|
|
141
|
+
return null;
|
|
142
|
+
const percentage = ctx.transitionWordPercentage;
|
|
143
|
+
if (percentage < 20) {
|
|
144
|
+
return createResult({ id: 'readability-transition-words', name: 'Transition Words', category: 'content', severity: 'info' }, 'info', `Transition words: ${Math.round(percentage)}% of sentences`, {
|
|
145
|
+
recommendation: 'Add transition words for better content flow',
|
|
146
|
+
evidence: {
|
|
147
|
+
found: `${Math.round(percentage)}%`,
|
|
148
|
+
expected: 'At least 30% of sentences',
|
|
149
|
+
example: 'Words like: however, therefore, additionally, furthermore, for example',
|
|
150
|
+
impact: 'Transition words improve comprehension and engagement',
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return createResult({ id: 'readability-transition-words', name: 'Transition Words', category: 'content', severity: 'info' }, 'pass', `Transition words: ${Math.round(percentage)}% of sentences`);
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'readability-subheading-distribution',
|
|
159
|
+
name: 'Subheading Distribution',
|
|
160
|
+
category: 'content',
|
|
161
|
+
severity: 'info',
|
|
162
|
+
description: 'Break content with subheadings every 300 words',
|
|
163
|
+
check: (ctx) => {
|
|
164
|
+
if (!ctx.wordCount || !ctx.h2Count)
|
|
165
|
+
return null;
|
|
166
|
+
const wordsPerSubheading = ctx.wordCount / (ctx.h2Count + 1);
|
|
167
|
+
if (ctx.wordCount > 500 && wordsPerSubheading > 400) {
|
|
168
|
+
return createResult({ id: 'readability-subheading-distribution', name: 'Subheading Distribution', category: 'content', severity: 'info' }, 'warn', `${Math.round(wordsPerSubheading)} words between subheadings (too many)`, {
|
|
169
|
+
recommendation: 'Add more H2/H3 subheadings to break up content',
|
|
170
|
+
evidence: {
|
|
171
|
+
found: `${ctx.h2Count} subheadings for ${ctx.wordCount} words`,
|
|
172
|
+
expected: 'One subheading every 250-350 words',
|
|
173
|
+
impact: 'Subheadings improve scannability and featured snippet eligibility',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (ctx.wordCount > 300 && wordsPerSubheading > 300) {
|
|
178
|
+
return createResult({ id: 'readability-subheading-distribution', name: 'Subheading Distribution', category: 'content', severity: 'info' }, 'info', `${Math.round(wordsPerSubheading)} words per section`, {
|
|
179
|
+
recommendation: 'Consider adding more subheadings',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return createResult({ id: 'readability-subheading-distribution', name: 'Subheading Distribution', category: 'content', severity: 'info' }, 'pass', `${Math.round(wordsPerSubheading)} words per section (good)`);
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'readability-text-variety',
|
|
187
|
+
name: 'Text Variety',
|
|
188
|
+
category: 'content',
|
|
189
|
+
severity: 'info',
|
|
190
|
+
description: 'Use varied sentence structures and word choices',
|
|
191
|
+
check: (ctx) => {
|
|
192
|
+
if (ctx.consecutiveSentenceStarts === undefined)
|
|
193
|
+
return null;
|
|
194
|
+
if (ctx.consecutiveSentenceStarts > 3) {
|
|
195
|
+
return createResult({ id: 'readability-text-variety', name: 'Text Variety', category: 'content', severity: 'info' }, 'info', `${ctx.consecutiveSentenceStarts} consecutive sentences start similarly`, {
|
|
196
|
+
recommendation: 'Vary sentence beginnings for better flow',
|
|
197
|
+
evidence: {
|
|
198
|
+
impact: 'Repetitive sentence structures feel monotonous',
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'readability-word-complexity',
|
|
207
|
+
name: 'Word Complexity',
|
|
208
|
+
category: 'content',
|
|
209
|
+
severity: 'info',
|
|
210
|
+
description: 'Avoid overly complex vocabulary',
|
|
211
|
+
check: (ctx) => {
|
|
212
|
+
if (ctx.complexWordPercentage === undefined)
|
|
213
|
+
return null;
|
|
214
|
+
if (ctx.complexWordPercentage > 15) {
|
|
215
|
+
return createResult({ id: 'readability-word-complexity', name: 'Word Complexity', category: 'content', severity: 'info' }, 'warn', `Complex words: ${Math.round(ctx.complexWordPercentage)}%`, {
|
|
216
|
+
recommendation: 'Use simpler vocabulary where possible',
|
|
217
|
+
evidence: {
|
|
218
|
+
found: `${Math.round(ctx.complexWordPercentage)}% words with 3+ syllables`,
|
|
219
|
+
expected: 'Less than 10% complex words for general audience',
|
|
220
|
+
impact: 'Complex vocabulary reduces comprehension',
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (ctx.complexWordPercentage > 10) {
|
|
225
|
+
return createResult({ id: 'readability-word-complexity', name: 'Word Complexity', category: 'content', severity: 'info' }, 'info', `Complex words: ${Math.round(ctx.complexWordPercentage)}%`);
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: 'readability-list-usage',
|
|
232
|
+
name: 'List Usage',
|
|
233
|
+
category: 'content',
|
|
234
|
+
severity: 'info',
|
|
235
|
+
description: 'Use lists to improve scannability',
|
|
236
|
+
check: (ctx) => {
|
|
237
|
+
if (!ctx.wordCount || ctx.listCount === undefined)
|
|
238
|
+
return null;
|
|
239
|
+
if (ctx.wordCount > 500 && ctx.listCount === 0) {
|
|
240
|
+
return createResult({ id: 'readability-list-usage', name: 'List Usage', category: 'content', severity: 'info' }, 'info', 'No lists found in long-form content', {
|
|
241
|
+
recommendation: 'Consider using bullet points or numbered lists',
|
|
242
|
+
evidence: {
|
|
243
|
+
found: `${ctx.wordCount} words with 0 lists`,
|
|
244
|
+
expected: 'At least 1 list per 500 words for long content',
|
|
245
|
+
impact: 'Lists improve scannability and featured snippet eligibility',
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (ctx.listCount > 0) {
|
|
250
|
+
return createResult({ id: 'readability-list-usage', name: 'List Usage', category: 'content', severity: 'info' }, 'pass', `${ctx.listCount} list(s) found`);
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const schemaRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'json-ld-exists',
|
|
5
|
+
name: 'Structured Data',
|
|
6
|
+
category: 'structured-data',
|
|
7
|
+
severity: 'info',
|
|
8
|
+
description: 'Page should have JSON-LD structured data',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (ctx.jsonLdCount === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
if (ctx.jsonLdCount === 0) {
|
|
13
|
+
return createResult({ id: 'json-ld-exists', name: 'Structured Data', category: 'structured-data', severity: 'info' }, 'info', 'No JSON-LD structured data found', { recommendation: 'Add Schema.org structured data for rich snippets' });
|
|
14
|
+
}
|
|
15
|
+
const types = ctx.jsonLdTypes?.join(', ') || 'unknown';
|
|
16
|
+
return createResult({ id: 'json-ld-exists', name: 'Structured Data', category: 'structured-data', severity: 'info' }, 'pass', `${ctx.jsonLdCount} JSON-LD block(s) found`, { value: types });
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'schema-standard-types',
|
|
21
|
+
name: 'Schema Types',
|
|
22
|
+
category: 'structured-data',
|
|
23
|
+
severity: 'info',
|
|
24
|
+
description: 'Use standard Schema.org types',
|
|
25
|
+
check: (ctx) => {
|
|
26
|
+
if (!ctx.jsonLdTypes || ctx.jsonLdTypes.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const recommended = [
|
|
29
|
+
'WebSite', 'WebPage', 'Article', 'Product', 'BreadcrumbList',
|
|
30
|
+
'Organization', 'Person', 'LocalBusiness', 'Recipe', 'Event',
|
|
31
|
+
'JobPosting', 'FAQPage', 'HowTo', 'VideoObject',
|
|
32
|
+
'SoftwareApplication', 'Review'
|
|
33
|
+
];
|
|
34
|
+
const hasStandard = ctx.jsonLdTypes.some(t => recommended.includes(t));
|
|
35
|
+
if (!hasStandard) {
|
|
36
|
+
return createResult({ id: 'schema-standard-types', name: 'Schema Types', category: 'structured-data', severity: 'info' }, 'info', 'Using uncommon Schema.org types', { value: ctx.jsonLdTypes.join(', '), recommendation: `Consider using standard types like ${recommended.slice(0, 3).join(', ')}` });
|
|
37
|
+
}
|
|
38
|
+
return createResult({ id: 'schema-standard-types', name: 'Schema Types', category: 'structured-data', severity: 'info' }, 'pass', 'Using standard Schema.org types', { value: ctx.jsonLdTypes.join(', ') });
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'breadcrumbs-presence',
|
|
43
|
+
name: 'Breadcrumbs Presence',
|
|
44
|
+
category: 'structured-data',
|
|
45
|
+
severity: 'info',
|
|
46
|
+
description: 'Breadcrumbs improve navigation and SEO structure',
|
|
47
|
+
check: (ctx) => {
|
|
48
|
+
if (!ctx.hasBreadcrumbsHtml && !ctx.hasBreadcrumbsSchema) {
|
|
49
|
+
return createResult({ id: 'breadcrumbs-presence', name: 'Breadcrumbs Presence', category: 'structured-data', severity: 'info' }, 'info', 'No breadcrumbs found (HTML or Schema.org)', { recommendation: 'Add breadcrumbs using HTML and/or Schema.org BreadcrumbList' });
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const securityRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'https-required',
|
|
5
|
+
name: 'HTTPS',
|
|
6
|
+
category: 'security',
|
|
7
|
+
severity: 'error',
|
|
8
|
+
description: 'Page must be served over HTTPS',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (ctx.isHttps === false) {
|
|
11
|
+
return createResult({ id: 'https-required', name: 'HTTPS', category: 'security', severity: 'error' }, 'fail', 'Page is not served over HTTPS', { recommendation: 'Enable HTTPS for all pages' });
|
|
12
|
+
}
|
|
13
|
+
if (ctx.isHttps === true) {
|
|
14
|
+
return createResult({ id: 'https-required', name: 'HTTPS', category: 'security', severity: 'error' }, 'pass', 'Page is served over HTTPS');
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'mixed-content',
|
|
21
|
+
name: 'Mixed Content',
|
|
22
|
+
category: 'security',
|
|
23
|
+
severity: 'error',
|
|
24
|
+
description: 'HTTPS pages should not load HTTP resources',
|
|
25
|
+
check: (ctx) => {
|
|
26
|
+
if (ctx.hasMixedContent) {
|
|
27
|
+
return createResult({ id: 'mixed-content', name: 'Mixed Content', category: 'security', severity: 'error' }, 'fail', 'Page has mixed content (HTTP resources on HTTPS page)', { recommendation: 'Update all resources to use HTTPS' });
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'security-csp-exists',
|
|
34
|
+
name: 'Content Security Policy (CSP)',
|
|
35
|
+
category: 'security',
|
|
36
|
+
severity: 'warning',
|
|
37
|
+
description: 'Content Security Policy header should be present to mitigate XSS attacks.',
|
|
38
|
+
check: (ctx) => {
|
|
39
|
+
if (!ctx.responseHeaders)
|
|
40
|
+
return null;
|
|
41
|
+
const cspHeader = ctx.responseHeaders['content-security-policy'] || ctx.responseHeaders['Content-Security-Policy'];
|
|
42
|
+
if (!cspHeader) {
|
|
43
|
+
return createResult({ id: 'security-csp-exists', name: 'Content Security Policy', category: 'security', severity: 'warning' }, 'warn', 'Content-Security-Policy header is missing', { recommendation: 'Implement a strong Content-Security-Policy to prevent XSS attacks.' });
|
|
44
|
+
}
|
|
45
|
+
return createResult({ id: 'security-csp-exists', name: 'Content Security Policy', category: 'security', severity: 'warning' }, 'pass', 'Content-Security-Policy header is present');
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'security-xfo-exists',
|
|
50
|
+
name: 'X-Frame-Options',
|
|
51
|
+
category: 'security',
|
|
52
|
+
severity: 'warning',
|
|
53
|
+
description: 'X-Frame-Options header should be present to prevent clickjacking.',
|
|
54
|
+
check: (ctx) => {
|
|
55
|
+
if (!ctx.responseHeaders)
|
|
56
|
+
return null;
|
|
57
|
+
const xfoHeader = ctx.responseHeaders['x-frame-options'] || ctx.responseHeaders['X-Frame-Options'];
|
|
58
|
+
if (!xfoHeader) {
|
|
59
|
+
return createResult({ id: 'security-xfo-exists', name: 'X-Frame-Options', category: 'security', severity: 'warning' }, 'warn', 'X-Frame-Options header is missing', { recommendation: 'Implement X-Frame-Options to prevent clickjacking attacks.' });
|
|
60
|
+
}
|
|
61
|
+
return createResult({ id: 'security-xfo-exists', name: 'X-Frame-Options', category: 'security', severity: 'warning' }, 'pass', `X-Frame-Options header is present: ${xfoHeader}`);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'security-cors-config',
|
|
66
|
+
name: 'CORS Configuration',
|
|
67
|
+
category: 'security',
|
|
68
|
+
severity: 'warning',
|
|
69
|
+
description: 'Review Access-Control-Allow-Origin header for proper CORS configuration.',
|
|
70
|
+
check: (ctx) => {
|
|
71
|
+
if (!ctx.responseHeaders)
|
|
72
|
+
return null;
|
|
73
|
+
const acaoHeader = ctx.responseHeaders['access-control-allow-origin'] || ctx.responseHeaders['Access-Control-Allow-Origin'];
|
|
74
|
+
if (acaoHeader === '*') {
|
|
75
|
+
return createResult({ id: 'security-cors-config', name: 'CORS Configuration', category: 'security', severity: 'warning' }, 'warn', 'Access-Control-Allow-Origin is set to "*"', { recommendation: 'Avoid wildcard (*) in Access-Control-Allow-Origin for sensitive content. Specify allowed origins.' });
|
|
76
|
+
}
|
|
77
|
+
if (!acaoHeader) {
|
|
78
|
+
return createResult({ id: 'security-cors-config', name: 'CORS Configuration', category: 'security', severity: 'warning' }, 'info', 'Access-Control-Allow-Origin header is missing', { recommendation: 'Consider explicit CORS configuration if resources are consumed cross-origin.' });
|
|
79
|
+
}
|
|
80
|
+
return createResult({ id: 'security-cors-config', name: 'CORS Configuration', category: 'security', severity: 'warning' }, 'pass', `Access-Control-Allow-Origin: ${acaoHeader}`);
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'security-hsts-exists',
|
|
85
|
+
name: 'Strict-Transport-Security (HSTS)',
|
|
86
|
+
category: 'security',
|
|
87
|
+
severity: 'warning',
|
|
88
|
+
description: 'HSTS header forces secure connections and improves SEO indirectly.',
|
|
89
|
+
check: (ctx) => {
|
|
90
|
+
if (!ctx.responseHeaders)
|
|
91
|
+
return null;
|
|
92
|
+
const hstsHeader = ctx.responseHeaders['strict-transport-security'] || ctx.responseHeaders['Strict-Transport-Security'];
|
|
93
|
+
if (!hstsHeader) {
|
|
94
|
+
return createResult({ id: 'security-hsts-exists', name: 'HSTS Header', category: 'security', severity: 'warning' }, 'warn', 'Strict-Transport-Security header is missing', { recommendation: 'Implement HSTS to force secure connections and benefit SEO.' });
|
|
95
|
+
}
|
|
96
|
+
return createResult({ id: 'security-hsts-exists', name: 'HSTS Header', category: 'security', severity: 'warning' }, 'pass', `Strict-Transport-Security header is present: ${hstsHeader}`);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'security-xcto-exists',
|
|
101
|
+
name: 'X-Content-Type-Options',
|
|
102
|
+
category: 'security',
|
|
103
|
+
severity: 'warning',
|
|
104
|
+
description: 'X-Content-Type-Options header prevents MIME sniffing attacks.',
|
|
105
|
+
check: (ctx) => {
|
|
106
|
+
if (!ctx.responseHeaders)
|
|
107
|
+
return null;
|
|
108
|
+
const xctoHeader = ctx.responseHeaders['x-content-type-options'] || ctx.responseHeaders['X-Content-Type-Options'];
|
|
109
|
+
if (!xctoHeader) {
|
|
110
|
+
return createResult({ id: 'security-xcto-exists', name: 'X-Content-Type-Options', category: 'security', severity: 'warning' }, 'warn', 'X-Content-Type-Options header is missing', { recommendation: 'Implement X-Content-Type-Options: nosniff to prevent MIME sniffing.' });
|
|
111
|
+
}
|
|
112
|
+
return createResult({ id: 'security-xcto-exists', name: 'X-Content-Type-Options', category: 'security', severity: 'warning' }, 'pass', `X-Content-Type-Options header is present: ${xctoHeader}`);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'security-rp-exists',
|
|
117
|
+
name: 'Referrer-Policy',
|
|
118
|
+
category: 'security',
|
|
119
|
+
severity: 'info',
|
|
120
|
+
description: 'Referrer-Policy controls how much referrer information is sent with requests.',
|
|
121
|
+
check: (ctx) => {
|
|
122
|
+
if (!ctx.responseHeaders)
|
|
123
|
+
return null;
|
|
124
|
+
const rpHeader = ctx.responseHeaders['referrer-policy'] || ctx.responseHeaders['Referrer-Policy'];
|
|
125
|
+
if (!rpHeader) {
|
|
126
|
+
return createResult({ id: 'security-rp-exists', name: 'Referrer-Policy', category: 'security', severity: 'info' }, 'info', 'Referrer-Policy header is missing', { recommendation: 'Consider implementing a Referrer-Policy for better privacy and control (e.g., strict-origin-when-cross-origin).' });
|
|
127
|
+
}
|
|
128
|
+
return createResult({ id: 'security-rp-exists', name: 'Referrer-Policy', category: 'security', severity: 'info' }, 'pass', `Referrer-Policy header is present: ${rpHeader}`);
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'security-pp-exists',
|
|
133
|
+
name: 'Permissions-Policy',
|
|
134
|
+
category: 'security',
|
|
135
|
+
severity: 'info',
|
|
136
|
+
description: 'Permissions-Policy controls browser features available to the page and iframes.',
|
|
137
|
+
check: (ctx) => {
|
|
138
|
+
if (!ctx.responseHeaders)
|
|
139
|
+
return null;
|
|
140
|
+
const ppHeader = ctx.responseHeaders['permissions-policy'] || ctx.responseHeaders['Permissions-Policy'];
|
|
141
|
+
if (!ppHeader) {
|
|
142
|
+
return createResult({ id: 'security-pp-exists', name: 'Permissions-Policy', category: 'security', severity: 'info' }, 'info', 'Permissions-Policy header is missing', { recommendation: 'Consider implementing a Permissions-Policy to disable unused browser features and enhance security (e.g., camera=(), microphone=()).' });
|
|
143
|
+
}
|
|
144
|
+
return createResult({ id: 'security-pp-exists', name: 'Permissions-Policy', category: 'security', severity: 'info' }, 'pass', `Permissions-Policy header is present: ${ppHeader}`);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
];
|