create-backlist 10.0.2 → 10.0.4

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.
@@ -0,0 +1,137 @@
1
+ // Real performance profiler — Playwright + Performance API
2
+ export class PerformanceProfiler {
3
+ #session;
4
+
5
+ constructor(session) { this.#session = session; }
6
+
7
+ async profile(url) {
8
+ let playwright;
9
+ try { playwright = await import('playwright'); }
10
+ catch { return this.#empty(); }
11
+
12
+ const browser = await playwright.chromium.launch({
13
+ headless: true,
14
+ args : ['--no-sandbox'],
15
+ });
16
+
17
+ const context = await browser.newContext({
18
+ ignoreHTTPSErrors: true,
19
+ });
20
+ const page = await context.newPage();
21
+
22
+ const slowResources = [];
23
+ const resourceTimings = [];
24
+
25
+ page.on('response', async res => {
26
+ const timing = res.timing();
27
+ if (timing && timing.responseEnd > 0) {
28
+ const duration = timing.responseEnd - timing.requestStart;
29
+ const entry = {
30
+ url : res.url(),
31
+ type : res.request().resourceType(),
32
+ status : res.status(),
33
+ duration: Math.round(duration),
34
+ size : 0,
35
+ };
36
+ try {
37
+ const body = await res.body().catch(() => null);
38
+ entry.size = body?.length || 0;
39
+ } catch {}
40
+ resourceTimings.push(entry);
41
+ if (duration > 2000) slowResources.push(entry);
42
+ }
43
+ });
44
+
45
+ try {
46
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
47
+
48
+ // Real Web Vitals via Performance API
49
+ const metrics = await page.evaluate(() => {
50
+ return new Promise(resolve => {
51
+ const result = {
52
+ lcp : null, fcp : null, cls : null,
53
+ fid : null, ttfb: null, tti : null, tbt: null,
54
+ memoryUsed: null, domNodes: document.querySelectorAll('*').length,
55
+ };
56
+
57
+ // FCP + LCP
58
+ const po = new PerformanceObserver(list => {
59
+ for (const entry of list.getEntries()) {
60
+ if (entry.entryType === 'paint') {
61
+ if (entry.name === 'first-contentful-paint') result.fcp = Math.round(entry.startTime);
62
+ }
63
+ if (entry.entryType === 'largest-contentful-paint') {
64
+ result.lcp = Math.round(entry.startTime);
65
+ }
66
+ }
67
+ });
68
+ try { po.observe({ entryTypes: ['paint','largest-contentful-paint'] }); } catch {}
69
+
70
+ // CLS
71
+ let clsValue = 0;
72
+ const clsPo = new PerformanceObserver(list => {
73
+ for (const entry of list.getEntries()) {
74
+ if (!entry.hadRecentInput) clsValue += entry.value;
75
+ }
76
+ });
77
+ try { clsPo.observe({ entryTypes: ['layout-shift'] }); } catch {}
78
+
79
+ // TTFB
80
+ const nav = performance.getEntriesByType('navigation')[0];
81
+ if (nav) result.ttfb = Math.round(nav.responseStart);
82
+
83
+ // Memory
84
+ if (performance.memory) {
85
+ result.memoryUsed = performance.memory.usedJSHeapSize;
86
+ }
87
+
88
+ setTimeout(() => {
89
+ result.cls = parseFloat(clsValue.toFixed(4));
90
+ po.disconnect();
91
+ clsPo.disconnect();
92
+ resolve(result);
93
+ }, 5000);
94
+ });
95
+ });
96
+
97
+ // CPU timing via long task detection
98
+ const longTasks = await page.evaluate(() => {
99
+ const tasks = [];
100
+ const po = new PerformanceObserver(list => {
101
+ tasks.push(...list.getEntries().map(e => ({
102
+ duration : Math.round(e.duration),
103
+ startTime: Math.round(e.startTime),
104
+ })));
105
+ });
106
+ try { po.observe({ entryTypes: ['longtask'] }); } catch {}
107
+ return new Promise(r => setTimeout(() => { po.disconnect(); r(tasks); }, 3000));
108
+ }).catch(() => []);
109
+
110
+ const tbt = longTasks.reduce((sum, t) => sum + Math.max(t.duration - 50, 0), 0);
111
+
112
+ return {
113
+ ...metrics,
114
+ tbt : Math.round(tbt),
115
+ slowResources,
116
+ resourceTimings: resourceTimings.slice(0, 50),
117
+ longTasks,
118
+ url,
119
+ };
120
+
121
+ } catch (err) {
122
+ return { ...this.#empty(), error: err.message, url };
123
+ } finally {
124
+ await page.close().catch(() => {});
125
+ await context.close().catch(() => {});
126
+ await browser.close().catch(() => {});
127
+ }
128
+ }
129
+
130
+ #empty() {
131
+ return {
132
+ lcp: null, fcp: null, cls: null, fid: null,
133
+ ttfb: null, tti: null, tbt: null,
134
+ slowResources: [], resourceTimings: [], longTasks: [],
135
+ };
136
+ }
137
+ }
@@ -0,0 +1,207 @@
1
+ // Real security scanner — every result from actual HTTP responses
2
+ const SECURITY_CHECKS = [
3
+ {
4
+ id : 'csp',
5
+ name : 'Content-Security-Policy',
6
+ header: 'content-security-policy',
7
+ sev : 'P1',
8
+ category: 'headers',
9
+ validate: (v) => !!v,
10
+ recommendation: 'Add CSP header to prevent XSS attacks',
11
+ },
12
+ {
13
+ id : 'hsts',
14
+ name : 'HSTS',
15
+ header: 'strict-transport-security',
16
+ sev : 'P1',
17
+ category: 'headers',
18
+ validate: (v) => !!v,
19
+ recommendation: 'Add HSTS to enforce HTTPS',
20
+ },
21
+ {
22
+ id : 'xframe',
23
+ name : 'X-Frame-Options',
24
+ header: 'x-frame-options',
25
+ sev : 'P1',
26
+ category: 'headers',
27
+ validate: (v) => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()),
28
+ recommendation: 'Set X-Frame-Options: DENY or SAMEORIGIN',
29
+ },
30
+ {
31
+ id : 'xcto',
32
+ name : 'X-Content-Type-Options',
33
+ header: 'x-content-type-options',
34
+ sev : 'P2',
35
+ category: 'headers',
36
+ validate: (v) => v === 'nosniff',
37
+ recommendation: 'Set X-Content-Type-Options: nosniff',
38
+ },
39
+ {
40
+ id : 'rp',
41
+ name : 'Referrer-Policy',
42
+ header: 'referrer-policy',
43
+ sev : 'P2',
44
+ category: 'headers',
45
+ validate: (v) => !!v,
46
+ recommendation: 'Add Referrer-Policy header',
47
+ },
48
+ {
49
+ id : 'pp',
50
+ name : 'Permissions-Policy',
51
+ header: 'permissions-policy',
52
+ sev : 'P3',
53
+ category: 'headers',
54
+ validate: (v) => !!v,
55
+ recommendation: 'Add Permissions-Policy header',
56
+ },
57
+ {
58
+ id : 'server',
59
+ name : 'Server version not exposed',
60
+ header: 'server',
61
+ sev : 'P2',
62
+ category: 'information-disclosure',
63
+ validate: (v) => !v || (!v.includes('/') && !(/\d+\.\d+/.test(v))),
64
+ recommendation: 'Remove or genericize the Server header',
65
+ },
66
+ {
67
+ id : 'xpb',
68
+ name : 'X-Powered-By not exposed',
69
+ header: 'x-powered-by',
70
+ sev : 'P2',
71
+ category: 'information-disclosure',
72
+ validate: (v) => !v,
73
+ recommendation: 'Remove X-Powered-By header (app.disable("x-powered-by"))',
74
+ },
75
+ ];
76
+
77
+ const CORS_CHECKS = [
78
+ {
79
+ id : 'cors-wildcard-creds',
80
+ name : 'CORS wildcard + credentials',
81
+ sev : 'P0',
82
+ category: 'cors',
83
+ check : (h) => !(h['access-control-allow-origin'] === '*' && h['access-control-allow-credentials'] === 'true'),
84
+ recommendation: 'Never combine CORS wildcard with allow-credentials',
85
+ },
86
+ ];
87
+
88
+ const HTTPS_TIMEOUT = 10_000;
89
+
90
+ export class SecurityScanner {
91
+ #session;
92
+
93
+ constructor(session) { this.#session = session; }
94
+
95
+ async scan(url) {
96
+ const findings = [];
97
+
98
+ // Real HTTP request to get actual headers
99
+ let headers = {};
100
+ let statusCode = 0;
101
+ try {
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), HTTPS_TIMEOUT);
104
+ const res = await fetch(url, {
105
+ method : 'GET',
106
+ signal : controller.signal,
107
+ headers: { 'User-Agent': 'Backlist-SecurityScanner/12.0' },
108
+ redirect: 'follow',
109
+ });
110
+ clearTimeout(timer);
111
+ statusCode = res.status;
112
+ res.headers.forEach((v, k) => { headers[k] = v; });
113
+ } catch (err) {
114
+ findings.push({
115
+ check : 'Server reachability',
116
+ detail : `Cannot reach ${url}: ${err.message}`,
117
+ pass : false,
118
+ severity: 'P0',
119
+ category: 'connectivity',
120
+ evidence: { error: err.message },
121
+ recommendation: 'Ensure server is running and accessible',
122
+ });
123
+ return findings;
124
+ }
125
+
126
+ // Check all security headers against real response
127
+ for (const check of SECURITY_CHECKS) {
128
+ const value = headers[check.header] || '';
129
+ const pass = check.validate(value);
130
+ findings.push({
131
+ check : check.name,
132
+ detail : pass
133
+ ? `${check.header}: ${value || '(present)'}`
134
+ : `Missing or invalid ${check.header} — ${check.recommendation}`,
135
+ pass,
136
+ severity: pass ? 'INFO' : check.sev,
137
+ category: check.category,
138
+ evidence: { header: check.header, value: value || null, allHeaders: headers },
139
+ recommendation: check.recommendation,
140
+ });
141
+ }
142
+
143
+ // CORS checks on real headers
144
+ for (const check of CORS_CHECKS) {
145
+ const pass = check.check(headers);
146
+ findings.push({
147
+ check : check.name,
148
+ detail : pass ? 'CORS configuration looks safe' : `CORS misconfiguration: ${check.recommendation}`,
149
+ pass,
150
+ severity: pass ? 'INFO' : check.sev,
151
+ category: check.category,
152
+ evidence: {
153
+ 'access-control-allow-origin' : headers['access-control-allow-origin'],
154
+ 'access-control-allow-credentials': headers['access-control-allow-credentials'],
155
+ },
156
+ recommendation: check.recommendation,
157
+ });
158
+ }
159
+
160
+ // HTTPS check
161
+ const isHTTPS = url.startsWith('https://');
162
+ findings.push({
163
+ check : 'HTTPS enforced',
164
+ detail : isHTTPS ? 'Site uses HTTPS' : 'Site is using HTTP — not encrypted',
165
+ pass : isHTTPS,
166
+ severity: isHTTPS ? 'INFO' : 'P1',
167
+ category: 'encryption',
168
+ evidence: { url, protocol: new URL(url).protocol },
169
+ recommendation: 'Enforce HTTPS with valid SSL certificate',
170
+ });
171
+
172
+ // Probe sensitive files
173
+ const sensitiveProbes = [
174
+ { path: '/.env', name: '.env exposed' },
175
+ { path: '/.git/config', name: 'Git config exposed' },
176
+ { path: '/wp-config.php', name: 'WP config exposed' },
177
+ { path: '/phpinfo.php', name: 'phpinfo exposed' },
178
+ { path: '/server-status', name: 'Apache server-status' },
179
+ { path: '/actuator', name: 'Spring actuator exposed' },
180
+ { path: '/graphql', name: 'GraphQL introspection' },
181
+ ];
182
+
183
+ const base = new URL(url).origin;
184
+ for (const probe of sensitiveProbes) {
185
+ try {
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => controller.abort(), 5000);
188
+ const r = await fetch(`${base}${probe.path}`, { signal: controller.signal, redirect: 'manual' });
189
+ clearTimeout(timer);
190
+ const exposed = r.status === 200;
191
+ findings.push({
192
+ check : probe.name,
193
+ detail : exposed
194
+ ? `EXPOSED at ${base}${probe.path} — critical security risk`
195
+ : `Not exposed: ${probe.path}`,
196
+ pass : !exposed,
197
+ severity: exposed ? 'P0' : 'INFO',
198
+ category: 'information-disclosure',
199
+ evidence: { url: `${base}${probe.path}`, status: r.status },
200
+ recommendation: exposed ? `Immediately restrict access to ${probe.path}` : null,
201
+ });
202
+ } catch {}
203
+ }
204
+
205
+ return findings;
206
+ }
207
+ }
@@ -0,0 +1,248 @@
1
+ // Real SEO scanner — fetches and parses actual HTML
2
+ export class SEOScanner {
3
+ #session;
4
+
5
+ constructor(session) { this.#session = session; }
6
+
7
+ async scan(url) {
8
+ let html = '';
9
+ let statusCode = 0;
10
+ let headers = {};
11
+ let responseTime = 0;
12
+
13
+ const t0 = Date.now();
14
+ try {
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), 12_000);
17
+ const res = await fetch(url, {
18
+ headers : { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' },
19
+ signal : controller.signal,
20
+ redirect: 'follow',
21
+ });
22
+ clearTimeout(timer);
23
+ statusCode = res.status;
24
+ responseTime = Date.now() - t0;
25
+ res.headers.forEach((v, k) => { headers[k] = v; });
26
+ html = await res.text();
27
+ } catch (err) {
28
+ return {
29
+ pass : false,
30
+ checks: [{ name: 'Page reachable', pass: false, detail: err.message, severity: 'P0' }],
31
+ url,
32
+ };
33
+ }
34
+
35
+ const checks = [];
36
+
37
+ // Real HTML parsing checks
38
+ const has = (pattern) => pattern.test(html);
39
+ const get = (pattern) => (html.match(pattern) || [])[1]?.trim() || null;
40
+
41
+ // Title
42
+ const title = get(/<title[^>]*>([^<]+)<\/title>/i);
43
+ checks.push({
44
+ name : 'Title tag',
45
+ pass : !!title,
46
+ detail : title ? `"${title.slice(0, 60)}"` : 'Missing <title> tag',
47
+ category: 'meta',
48
+ severity: 'P1',
49
+ data : { title, length: title?.length },
50
+ recommendation: 'Add a unique, descriptive title (50-60 chars)',
51
+ });
52
+
53
+ if (title) {
54
+ checks.push({
55
+ name : 'Title length optimal',
56
+ pass : title.length >= 30 && title.length <= 60,
57
+ detail : `Title is ${title.length} chars (optimal: 30-60)`,
58
+ category: 'meta',
59
+ severity: 'P2',
60
+ data : { length: title.length },
61
+ recommendation: 'Keep title between 30-60 characters',
62
+ });
63
+ }
64
+
65
+ // Meta description
66
+ const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
67
+ || get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
68
+ checks.push({
69
+ name : 'Meta description',
70
+ pass : !!desc,
71
+ detail : desc ? `"${desc.slice(0, 80)}..."` : 'Missing meta description',
72
+ category: 'meta',
73
+ severity: 'P1',
74
+ data : { description: desc, length: desc?.length },
75
+ recommendation: 'Add meta description (120-160 chars)',
76
+ });
77
+
78
+ if (desc) {
79
+ checks.push({
80
+ name : 'Meta description length',
81
+ pass : desc.length >= 120 && desc.length <= 160,
82
+ detail : `Description is ${desc.length} chars (optimal: 120-160)`,
83
+ category: 'meta',
84
+ severity: 'P2',
85
+ data : { length: desc.length },
86
+ recommendation: 'Keep description between 120-160 characters',
87
+ });
88
+ }
89
+
90
+ // H1
91
+ const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
92
+ checks.push({
93
+ name : 'H1 tag',
94
+ pass : h1Count === 1,
95
+ detail : h1Count === 0 ? 'No H1 found' : h1Count > 1 ? `${h1Count} H1 tags (should be 1)` : 'Exactly 1 H1',
96
+ category: 'structure',
97
+ severity: 'P1',
98
+ data : { count: h1Count },
99
+ recommendation: 'Use exactly one H1 per page',
100
+ });
101
+
102
+ // Viewport
103
+ const hasViewport = has(/<meta[^>]+name=["']viewport["']/i);
104
+ checks.push({
105
+ name : 'Viewport meta',
106
+ pass : hasViewport,
107
+ detail : hasViewport ? 'Viewport meta found' : 'Missing viewport meta — mobile SEO broken',
108
+ category: 'mobile',
109
+ severity: 'P1',
110
+ recommendation: 'Add <meta name="viewport" content="width=device-width,initial-scale=1">',
111
+ });
112
+
113
+ // Lang
114
+ const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
115
+ checks.push({
116
+ name : 'HTML lang attribute',
117
+ pass : !!lang,
118
+ detail : lang ? `lang="${lang}"` : 'Missing lang attribute on <html>',
119
+ category: 'accessibility-seo',
120
+ severity: 'P1',
121
+ data : { lang },
122
+ recommendation: 'Add lang attribute to <html> element',
123
+ });
124
+
125
+ // Canonical
126
+ const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
127
+ checks.push({
128
+ name : 'Canonical link',
129
+ pass : !!canonical,
130
+ detail : canonical ? `Canonical: ${canonical}` : 'Missing canonical link',
131
+ category: 'technical-seo',
132
+ severity: 'P2',
133
+ data : { canonical },
134
+ recommendation: 'Add <link rel="canonical"> to prevent duplicate content',
135
+ });
136
+
137
+ // OG tags
138
+ const ogTitle = has(/<meta[^>]+property=["']og:title["']/i);
139
+ const ogDesc = has(/<meta[^>]+property=["']og:description["']/i);
140
+ const ogImage = has(/<meta[^>]+property=["']og:image["']/i);
141
+ const ogUrl = has(/<meta[^>]+property=["']og:url["']/i);
142
+ checks.push({
143
+ name : 'Open Graph tags',
144
+ pass : ogTitle && ogDesc && ogImage,
145
+ detail : `og:title=${ogTitle} og:description=${ogDesc} og:image=${ogImage} og:url=${ogUrl}`,
146
+ category: 'social',
147
+ severity: 'P2',
148
+ data : { ogTitle, ogDesc, ogImage, ogUrl },
149
+ recommendation: 'Add og:title, og:description, og:image for social sharing',
150
+ });
151
+
152
+ // Twitter card
153
+ const twitterCard = has(/<meta[^>]+name=["']twitter:card["']/i);
154
+ checks.push({
155
+ name : 'Twitter Card',
156
+ pass : twitterCard,
157
+ detail : twitterCard ? 'Twitter card found' : 'No Twitter card meta',
158
+ category: 'social',
159
+ severity: 'P3',
160
+ recommendation: 'Add twitter:card meta for Twitter sharing',
161
+ });
162
+
163
+ // Images with alt
164
+ const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
165
+ const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
166
+ checks.push({
167
+ name : 'Images have alt text',
168
+ pass : imgNoAlt === 0,
169
+ detail : imgNoAlt === 0
170
+ ? `All ${imgTotal} images have alt text`
171
+ : `${imgNoAlt}/${imgTotal} images missing alt text`,
172
+ category: 'accessibility-seo',
173
+ severity: 'P2',
174
+ data : { total: imgTotal, missing: imgNoAlt },
175
+ recommendation: 'Add descriptive alt text to all images',
176
+ });
177
+
178
+ // Structured data
179
+ const hasStructuredData = has(/application\/ld\+json/i);
180
+ checks.push({
181
+ name : 'Structured data (JSON-LD)',
182
+ pass : hasStructuredData,
183
+ detail : hasStructuredData ? 'JSON-LD structured data found' : 'No structured data',
184
+ category: 'structured-data',
185
+ severity: 'P3',
186
+ recommendation: 'Add JSON-LD structured data for rich search results',
187
+ });
188
+
189
+ // robots.txt
190
+ try {
191
+ const base = new URL(url).origin;
192
+ const robotsRes = await fetch(`${base}/robots.txt`, { signal: AbortSignal.timeout(5000) });
193
+ checks.push({
194
+ name : 'robots.txt',
195
+ pass : robotsRes.status === 200,
196
+ detail : robotsRes.status === 200 ? 'robots.txt accessible' : `robots.txt returned ${robotsRes.status}`,
197
+ category: 'crawling',
198
+ severity: 'P1',
199
+ recommendation: 'Ensure robots.txt exists and is properly configured',
200
+ });
201
+ } catch {
202
+ checks.push({ name: 'robots.txt', pass: false, detail: 'robots.txt unreachable', category: 'crawling', severity: 'P2' });
203
+ }
204
+
205
+ // sitemap.xml
206
+ try {
207
+ const base = new URL(url).origin;
208
+ const sitemapRes = await fetch(`${base}/sitemap.xml`, { signal: AbortSignal.timeout(5000) });
209
+ const sitemapOk = sitemapRes.status === 200;
210
+ let sitemapValid = false;
211
+ if (sitemapOk) {
212
+ const sitemapText = await sitemapRes.text().catch(() => '');
213
+ sitemapValid = sitemapText.includes('<urlset') || sitemapText.includes('<sitemapindex');
214
+ }
215
+ checks.push({
216
+ name : 'sitemap.xml',
217
+ pass : sitemapOk && sitemapValid,
218
+ detail : sitemapOk
219
+ ? (sitemapValid ? 'Valid sitemap.xml found' : 'sitemap.xml present but invalid XML')
220
+ : `sitemap.xml returned ${sitemapRes.status}`,
221
+ category: 'crawling',
222
+ severity: 'P1',
223
+ recommendation: 'Add a valid sitemap.xml and submit to Google Search Console',
224
+ });
225
+ } catch {
226
+ checks.push({ name: 'sitemap.xml', pass: false, detail: 'sitemap.xml unreachable', category: 'crawling', severity: 'P2' });
227
+ }
228
+
229
+ // Page speed (response time as SEO factor)
230
+ checks.push({
231
+ name : 'Server response time',
232
+ pass : responseTime < 800,
233
+ detail : `TTFB: ${responseTime}ms (Google recommends < 800ms)`,
234
+ category: 'performance-seo',
235
+ severity: responseTime > 2000 ? 'P1' : 'P2',
236
+ data : { responseTime },
237
+ recommendation: 'Optimize TTFB — use CDN, caching, or faster hosting',
238
+ });
239
+
240
+ return {
241
+ pass : checks.every(c => c.pass || c.severity === 'P3'),
242
+ checks,
243
+ url,
244
+ statusCode,
245
+ responseTime,
246
+ };
247
+ }
248
+ }